Skip to content

Commit 820d400

Browse files
authored
Merge pull request #246 from itsDNNS/feat/245-smart-capture-sub-settings
feat: Smart Capture configurable trigger sub-settings and Connection Monitor integration
2 parents 4a91065 + f72d441 commit 820d400

File tree

17 files changed

+695
-56
lines changed

17 files changed

+695
-56
lines changed

app/config.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,15 @@
5555
"sc_flapping_window": 3600,
5656
"sc_flapping_threshold": 3,
5757
"sc_trigger_modulation": True,
58+
"sc_trigger_modulation_direction": "both",
59+
"sc_trigger_modulation_min_qam": "",
5860
"sc_trigger_snr": False,
5961
"sc_trigger_error_spike": False,
62+
"sc_trigger_error_spike_min_delta": 0,
6063
"sc_trigger_health": False,
64+
"sc_trigger_health_level": "any_degradation",
65+
"sc_trigger_packet_loss": False,
66+
"sc_trigger_packet_loss_min_pct": "5.0",
6167
}
6268

6369
ENV_MAP = {
@@ -105,9 +111,15 @@
105111
"sc_flapping_window": "SC_FLAPPING_WINDOW",
106112
"sc_flapping_threshold": "SC_FLAPPING_THRESHOLD",
107113
"sc_trigger_modulation": "SC_TRIGGER_MODULATION",
114+
"sc_trigger_modulation_direction": "SC_TRIGGER_MODULATION_DIRECTION",
115+
"sc_trigger_modulation_min_qam": "SC_TRIGGER_MODULATION_MIN_QAM",
108116
"sc_trigger_snr": "SC_TRIGGER_SNR",
109117
"sc_trigger_error_spike": "SC_TRIGGER_ERROR_SPIKE",
118+
"sc_trigger_error_spike_min_delta": "SC_TRIGGER_ERROR_SPIKE_MIN_DELTA",
110119
"sc_trigger_health": "SC_TRIGGER_HEALTH",
120+
"sc_trigger_health_level": "SC_TRIGGER_HEALTH_LEVEL",
121+
"sc_trigger_packet_loss": "SC_TRIGGER_PACKET_LOSS",
122+
"sc_trigger_packet_loss_min_pct": "SC_TRIGGER_PACKET_LOSS_MIN_PCT",
111123
}
112124

113125
# Deprecated env vars (FRITZ_* -> MODEM_*) - checked as fallback
@@ -126,9 +138,11 @@
126138

127139
INT_KEYS = {"poll_interval", "web_port", "history_days", "notify_cooldown", "health_hysteresis",
128140
"sc_global_cooldown", "sc_trigger_cooldown", "sc_max_actions_per_hour",
129-
"sc_flapping_window", "sc_flapping_threshold"}
141+
"sc_flapping_window", "sc_flapping_threshold",
142+
"sc_trigger_error_spike_min_delta"}
130143
BOOL_KEYS = {"demo_mode", "gaming_quality_enabled", "segment_utilization_enabled", "sc_enabled",
131-
"sc_trigger_modulation", "sc_trigger_snr", "sc_trigger_error_spike", "sc_trigger_health"}
144+
"sc_trigger_modulation", "sc_trigger_snr", "sc_trigger_error_spike", "sc_trigger_health",
145+
"sc_trigger_packet_loss"}
132146

133147
URL_KEYS = {"modem_url", "bqm_url", "speedtest_tracker_url", "notify_webhook_url"}
134148
_ALLOWED_URL_SCHEMES = {"http", "https"}

app/docsis_utils.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Shared DOCSIS utilities — QAM hierarchy and modulation ranking."""
2+
3+
import re
4+
5+
# QAM hierarchy: higher value = better modulation
6+
QAM_ORDER = {
7+
"QPSK": 1, "4QAM": 1,
8+
"8QAM": 2,
9+
"16QAM": 3,
10+
"32QAM": 4,
11+
"64QAM": 5,
12+
"128QAM": 6,
13+
"256QAM": 7,
14+
"512QAM": 8,
15+
"1024QAM": 9,
16+
"2048QAM": 10,
17+
"4096QAM": 11,
18+
}
19+
20+
21+
def qam_rank(modulation):
22+
"""Get QAM rank for any modulation format.
23+
24+
Handles both driver formats: "256QAM" (Ultra Hub 7, CM3500)
25+
and "qam_256" (Vodafone Station, CH7465, TC4400).
26+
27+
Returns 0 for unknown/empty modulation.
28+
"""
29+
if not modulation:
30+
return 0
31+
rank = QAM_ORDER.get(modulation)
32+
if rank is not None:
33+
return rank
34+
mod = modulation.upper().replace("-", "").replace("_", "")
35+
if mod == "QPSK":
36+
return QAM_ORDER["QPSK"]
37+
m = re.search(r"(\d+)", mod)
38+
if m and "QAM" in mod:
39+
return QAM_ORDER.get(f"{m.group(1)}QAM", 0)
40+
return 0

app/event_detector.py

Lines changed: 6 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Detect significant signal changes between consecutive DOCSIS snapshots."""
22

33
import logging
4-
import re
54
import threading
65

76
from .tz import utc_now
@@ -15,44 +14,12 @@
1514
# Import SNR thresholds from analyzer (loaded from thresholds.json)
1615
from app.analyzer import _get_snr_thresholds as _snr_thresholds
1716

18-
# QAM hierarchy: higher value = better modulation
19-
QAM_ORDER = {
20-
"QPSK": 1, "4QAM": 1,
21-
"8QAM": 2,
22-
"16QAM": 3,
23-
"32QAM": 4,
24-
"64QAM": 5,
25-
"128QAM": 6,
26-
"256QAM": 7,
27-
"512QAM": 8,
28-
"1024QAM": 9,
29-
"2048QAM": 10,
30-
"4096QAM": 11,
31-
}
17+
from .docsis_utils import QAM_ORDER, qam_rank as _qam_rank
18+
3219
# A drop of this many levels or more counts as critical (e.g. 256QAM → 16QAM = 4 levels)
3320
QAM_CRITICAL_DROP = 3
3421

3522

36-
def _qam_rank(modulation):
37-
"""Get QAM rank for any modulation format.
38-
39-
Handles both driver formats: "256QAM" (Ultra Hub 7, CM3500)
40-
and "qam_256" (Vodafone Station, CH7465, TC4400).
41-
"""
42-
if not modulation:
43-
return 0
44-
rank = QAM_ORDER.get(modulation)
45-
if rank is not None:
46-
return rank
47-
mod = modulation.upper().replace("-", "").replace("_", "")
48-
if mod == "QPSK":
49-
return QAM_ORDER["QPSK"]
50-
m = re.search(r"(\d+)", mod)
51-
if m and "QAM" in mod:
52-
return QAM_ORDER.get(f"{m.group(1)}QAM", 0)
53-
return 0
54-
55-
5623
class EventDetector:
5724
"""Compare consecutive analyses and emit event dicts."""
5825

@@ -253,6 +220,8 @@ def _check_modulation(self, events, ts, cur_analysis, prev_analysis):
253220
entry = {"channel": ch_id, "direction": "DS", "prev": prev_ds[ch_id], "current": cur_ds[ch_id]}
254221
cur_rank = _qam_rank(cur_ds[ch_id])
255222
prev_rank = _qam_rank(prev_ds[ch_id])
223+
entry["prev_rank"] = prev_rank
224+
entry["current_rank"] = cur_rank
256225
entry["rank_drop"] = prev_rank - cur_rank
257226
if cur_rank < prev_rank:
258227
downgrades.append(entry)
@@ -263,6 +232,8 @@ def _check_modulation(self, events, ts, cur_analysis, prev_analysis):
263232
entry = {"channel": ch_id, "direction": "US", "prev": prev_us[ch_id], "current": cur_us[ch_id]}
264233
cur_rank = _qam_rank(cur_us[ch_id])
265234
prev_rank = _qam_rank(prev_us[ch_id])
235+
entry["prev_rank"] = prev_rank
236+
entry["current_rank"] = cur_rank
266237
entry["rank_drop"] = prev_rank - cur_rank
267238
if cur_rank < prev_rank:
268239
downgrades.append(entry)

app/i18n/de.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,5 +825,24 @@
825825
"sc_no_adapter_warning": "Kein Aktionsadapter konfiguriert. Aktiviere Speedtest Tracker um Smart Capture zu nutzen.",
826826
"sc_badge_label": "Smart Capture",
827827
"correlation_source_capture": "Capture",
828-
"event_type_smart_capture_triggered": "Smart Capture"
828+
"event_type_smart_capture_triggered": "Smart Capture",
829+
"sc_trigger_direction": "Richtung",
830+
"sc_trigger_direction_both": "Beide",
831+
"sc_trigger_direction_ds": "Nur Downstream",
832+
"sc_trigger_direction_us": "Nur Upstream",
833+
"sc_trigger_min_qam": "Min. QAM-Stufe",
834+
"sc_trigger_min_qam_any": "Jede Verschlechterung",
835+
"sc_trigger_min_qam_256": "Unter 256QAM",
836+
"sc_trigger_min_qam_64": "Unter 64QAM",
837+
"sc_trigger_min_qam_16": "Unter 16QAM",
838+
"sc_trigger_min_delta": "Min. Fehlerdelta",
839+
"sc_trigger_min_delta_hint": "0 = bei jedem Fehlersprung auslösen",
840+
"sc_trigger_health_level": "Gesundheitsstufe",
841+
"sc_trigger_health_level_any": "Jede Verschlechterung",
842+
"sc_trigger_health_level_critical": "Nur Kritisch",
843+
"sc_trigger_packet_loss": "Paketverlust-Warnung",
844+
"sc_trigger_packet_loss_desc": "Wird ausgelöst wenn der Connection Monitor hohen Paketverlust auf einem Ziel erkennt",
845+
"sc_trigger_packet_loss_min_pct": "Min. Paketverlust %",
846+
"sc_trigger_packet_loss_min_pct_hint": "Benötigt Connection Monitor Modul. Löst nur aus wenn der Paketverlust sowohl diesen Schwellwert als auch den Connection Monitor Warnschwellwert überschreitet.",
847+
"event_type_cm_packet_loss_warning": "Paketverlust-Warnung"
829848
}

app/i18n/en.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,5 +825,24 @@
825825
"sc_no_adapter_warning": "No action adapter configured. Enable Speedtest Tracker to use Smart Capture.",
826826
"sc_badge_label": "Smart Capture",
827827
"correlation_source_capture": "Capture",
828-
"event_type_smart_capture_triggered": "Smart Capture"
828+
"event_type_smart_capture_triggered": "Smart Capture",
829+
"sc_trigger_direction": "Direction",
830+
"sc_trigger_direction_both": "Both",
831+
"sc_trigger_direction_ds": "Downstream only",
832+
"sc_trigger_direction_us": "Upstream only",
833+
"sc_trigger_min_qam": "Min QAM level",
834+
"sc_trigger_min_qam_any": "Any downgrade",
835+
"sc_trigger_min_qam_256": "Below 256QAM",
836+
"sc_trigger_min_qam_64": "Below 64QAM",
837+
"sc_trigger_min_qam_16": "Below 16QAM",
838+
"sc_trigger_min_delta": "Min error delta",
839+
"sc_trigger_min_delta_hint": "0 = trigger on any spike",
840+
"sc_trigger_health_level": "Health level",
841+
"sc_trigger_health_level_any": "Any degradation",
842+
"sc_trigger_health_level_critical": "Critical only",
843+
"sc_trigger_packet_loss": "Packet loss warning",
844+
"sc_trigger_packet_loss_desc": "Triggers when Connection Monitor detects high packet loss on any target",
845+
"sc_trigger_packet_loss_min_pct": "Min packet loss %",
846+
"sc_trigger_packet_loss_min_pct_hint": "Requires Connection Monitor module. Only fires when packet loss exceeds both this threshold and the Connection Monitor warning threshold.",
847+
"event_type_cm_packet_loss_warning": "Packet Loss Warning"
829848
}

app/i18n/es.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,5 +820,24 @@
820820
"sc_no_adapter_warning": "Ningun adaptador de accion configurado. Active Speedtest Tracker para usar Smart Capture.",
821821
"sc_badge_label": "Smart Capture",
822822
"correlation_source_capture": "Capture",
823-
"event_type_smart_capture_triggered": "Smart Capture"
823+
"event_type_smart_capture_triggered": "Smart Capture",
824+
"sc_trigger_direction": "Direccion",
825+
"sc_trigger_direction_both": "Ambos",
826+
"sc_trigger_direction_ds": "Solo descendente",
827+
"sc_trigger_direction_us": "Solo ascendente",
828+
"sc_trigger_min_qam": "Nivel QAM minimo",
829+
"sc_trigger_min_qam_any": "Cualquier degradacion",
830+
"sc_trigger_min_qam_256": "Por debajo de 256QAM",
831+
"sc_trigger_min_qam_64": "Por debajo de 64QAM",
832+
"sc_trigger_min_qam_16": "Por debajo de 16QAM",
833+
"sc_trigger_min_delta": "Delta de errores minimo",
834+
"sc_trigger_min_delta_hint": "0 = activar en cualquier pico",
835+
"sc_trigger_health_level": "Nivel de salud",
836+
"sc_trigger_health_level_any": "Cualquier degradacion",
837+
"sc_trigger_health_level_critical": "Solo critico",
838+
"sc_trigger_packet_loss": "Alerta de perdida de paquetes",
839+
"sc_trigger_packet_loss_desc": "Se activa cuando el Connection Monitor detecta alta perdida de paquetes",
840+
"sc_trigger_packet_loss_min_pct": "Perdida de paquetes min %",
841+
"sc_trigger_packet_loss_min_pct_hint": "Requiere el modulo Connection Monitor. Solo se activa cuando la perdida supera este umbral y el umbral de advertencia del Connection Monitor.",
842+
"event_type_cm_packet_loss_warning": "Alerta perdida de paquetes"
824843
}

app/i18n/fr.json

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,5 +817,24 @@
817817
"sc_no_adapter_warning": "Aucun adaptateur d'action configure. Activez Speedtest Tracker pour utiliser Smart Capture.",
818818
"sc_badge_label": "Smart Capture",
819819
"correlation_source_capture": "Capture",
820-
"event_type_smart_capture_triggered": "Smart Capture"
820+
"event_type_smart_capture_triggered": "Smart Capture",
821+
"sc_trigger_direction": "Direction",
822+
"sc_trigger_direction_both": "Les deux",
823+
"sc_trigger_direction_ds": "Descendant uniquement",
824+
"sc_trigger_direction_us": "Montant uniquement",
825+
"sc_trigger_min_qam": "Niveau QAM minimum",
826+
"sc_trigger_min_qam_any": "Toute degradation",
827+
"sc_trigger_min_qam_256": "En dessous de 256QAM",
828+
"sc_trigger_min_qam_64": "En dessous de 64QAM",
829+
"sc_trigger_min_qam_16": "En dessous de 16QAM",
830+
"sc_trigger_min_delta": "Delta d erreurs minimum",
831+
"sc_trigger_min_delta_hint": "0 = declencher sur tout pic",
832+
"sc_trigger_health_level": "Niveau de sante",
833+
"sc_trigger_health_level_any": "Toute degradation",
834+
"sc_trigger_health_level_critical": "Critique uniquement",
835+
"sc_trigger_packet_loss": "Alerte perte de paquets",
836+
"sc_trigger_packet_loss_desc": "Se declenche lorsque le Connection Monitor detecte une perte de paquets elevee",
837+
"sc_trigger_packet_loss_min_pct": "Perte de paquets min %",
838+
"sc_trigger_packet_loss_min_pct_hint": "Necessite le module Connection Monitor. Ne se declenche que lorsque la perte depasse ce seuil et le seuil d avertissement du Connection Monitor.",
839+
"event_type_cm_packet_loss_warning": "Alerte perte de paquets"
821840
}

app/main.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,23 +108,36 @@ def polling_loop(config_mgr, storage, stop_event):
108108

109109
# Smart Capture (always instantiated — _is_enabled() gates at runtime)
110110
from .smart_capture import SmartCaptureEngine, Trigger
111+
from .smart_capture.sub_filters import (
112+
modulation_sub_filter, snr_sub_filter, error_spike_sub_filter,
113+
health_sub_filter, packet_loss_sub_filter,
114+
)
111115
smart_capture = SmartCaptureEngine(storage, config_mgr)
112116
smart_capture.register_trigger(Trigger(
113117
event_type="modulation_change", action_type="capture",
114118
config_key="sc_trigger_modulation",
115119
min_severity="warning", require_details={"direction": "downgrade"},
120+
sub_filter=modulation_sub_filter,
116121
))
117122
smart_capture.register_trigger(Trigger(
118123
event_type="snr_change", action_type="capture",
119124
config_key="sc_trigger_snr", min_severity="warning",
125+
sub_filter=snr_sub_filter,
120126
))
121127
smart_capture.register_trigger(Trigger(
122128
event_type="error_spike", action_type="capture",
123129
config_key="sc_trigger_error_spike",
130+
sub_filter=error_spike_sub_filter,
124131
))
125132
smart_capture.register_trigger(Trigger(
126133
event_type="health_change", action_type="capture",
127134
config_key="sc_trigger_health", min_severity="warning",
135+
sub_filter=health_sub_filter,
136+
))
137+
smart_capture.register_trigger(Trigger(
138+
event_type="cm_packet_loss_warning", action_type="capture",
139+
config_key="sc_trigger_packet_loss",
140+
sub_filter=packet_loss_sub_filter,
128141
))
129142
log.info("Smart Capture: registered %d trigger(s)", len(smart_capture.triggers))
130143

@@ -146,6 +159,12 @@ def polling_loop(config_mgr, storage, stop_event):
146159
stt_collector.on_import = stt_adapter.on_results_imported
147160
log.info("Smart Capture: STT adapter wired to speedtest collector")
148161

162+
# Wire Smart Capture to Connection Monitor collector
163+
cm_collector = next((c for c in collectors if c.name == "connection_monitor"), None)
164+
if cm_collector and hasattr(cm_collector, 'set_smart_capture'):
165+
cm_collector.set_smart_capture(smart_capture)
166+
log.info("Smart Capture: wired to Connection Monitor collector")
167+
149168
# Inject collectors into web layer for manual polling and status endpoint
150169
modem_collector = next((c for c in collectors if c.name in ("modem", "demo")), None)
151170
if modem_collector:

app/modules/connection_monitor/collector.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ def __init__(self, config_mgr, storage, web, **kwargs):
4141
self._cm_storage = ConnectionMonitorStorage(db_path)
4242

4343
self._seeded = False
44+
self._smart_capture = None
45+
46+
def set_smart_capture(self, smart_capture):
47+
"""Inject Smart Capture engine for event evaluation."""
48+
self._smart_capture = smart_capture
4449

4550
def is_enabled(self) -> bool:
4651
return bool(self._config_mgr.get("connection_monitor_enabled", False))
@@ -148,8 +153,10 @@ def _check_events(self, samples: list[dict]):
148153
)
149154
all_events.extend(events)
150155

151-
if all_events and hasattr(self._core_storage, "save_events"):
152-
self._core_storage.save_events(all_events)
156+
if all_events and hasattr(self._core_storage, "save_events_with_ids"):
157+
self._core_storage.save_events_with_ids(all_events)
158+
if self._smart_capture:
159+
self._smart_capture.evaluate(all_events)
153160

154161
def _ensure_default_targets(self):
155162
"""Seed default targets on first enable."""

app/smart_capture/engine.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ def _evaluate_event(self, event: dict):
7878
if not matches:
7979
return
8080

81+
# Apply sub-filter if set
82+
matches = [
83+
(t, ev) for t, ev in matches
84+
if t.sub_filter is None or t.sub_filter(self._config, ev)
85+
]
86+
if not matches:
87+
return
88+
8189
results = self._guardrails.check_batch(matches)
8290

8391
for trigger, ev, allowed, reason in results:

0 commit comments

Comments
 (0)