Skip to content

Commit e61b5e8

Browse files
itsDNNSclaude
andcommitted
Initial commit: FritzBox DOCSIS Monitor with setup wizard
- DOCSIS channel monitoring via FritzBox data.lua API - Per-channel + summary sensors via MQTT Auto-Discovery - Health assessment with traffic-light evaluation - Web dashboard with timeline navigation and light/dark mode - Setup wizard for browser-based first-time configuration - Settings page for runtime config changes - Config persisted in config.json (Docker volume), env vars override - No secrets in tracked files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
0 parents  commit e61b5e8

File tree

17 files changed

+2410
-0
lines changed

17 files changed

+2410
-0
lines changed

.env.example

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# FritzBox DOCSIS Monitor - Environment Variables (optional)
2+
# Copy to .env and edit. All values can also be set via the web UI setup wizard.
3+
# Environment variables override config.json values.
4+
5+
# FritzBox connection
6+
FRITZ_URL=http://192.168.178.1
7+
FRITZ_USER=
8+
FRITZ_PASSWORD=
9+
10+
# MQTT broker
11+
MQTT_HOST=localhost
12+
MQTT_PORT=1883
13+
MQTT_USER=
14+
MQTT_PASSWORD=
15+
MQTT_TOPIC_PREFIX=fritzbox/docsis
16+
17+
# General
18+
POLL_INTERVAL=300
19+
WEB_PORT=8765
20+
HISTORY_DAYS=7
21+
DATA_DIR=/data

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.env
2+
.env.local
3+
*.db
4+
__pycache__/
5+
*.pyc
6+
.vscode/
7+
.idea/

Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
FROM python:3.12-slim
2+
WORKDIR /app
3+
COPY requirements.txt .
4+
RUN pip install --no-cache-dir -r requirements.txt
5+
COPY app/ ./app/
6+
CMD ["python", "-m", "app.main"]

README.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# FritzBox DOCSIS Monitor
2+
3+
Docker container that monitors DOCSIS channel health on AVM FRITZ!Box Cable routers and publishes per-channel sensor data to Home Assistant via MQTT Auto-Discovery.
4+
5+
## Features
6+
7+
- **Per-Channel Sensors**: Every downstream/upstream DOCSIS channel becomes its own Home Assistant sensor with full attributes (frequency, modulation, SNR, errors, DOCSIS version)
8+
- **Summary Sensors**: Aggregated metrics (power min/max/avg, SNR, error counts, overall health)
9+
- **Health Assessment**: Automatic traffic-light evaluation based on industry-standard thresholds
10+
- **Web UI**: Built-in dashboard on port 8765 with timeline navigation and light/dark mode
11+
- **Setup Wizard**: Browser-based configuration - no .env file needed
12+
- **Settings Page**: Change all settings at runtime, test connections, toggle themes
13+
- **MQTT Auto-Discovery**: Zero-config integration with Home Assistant
14+
15+
## Quick Start
16+
17+
```bash
18+
git clone https://github.com/dbraun-lab/fritzbox-docsis-monitor.git
19+
cd fritzbox-docsis-monitor
20+
docker compose up -d
21+
```
22+
23+
Open `http://localhost:8765` - the setup wizard guides you through configuration.
24+
25+
## Configuration
26+
27+
Configuration is stored in `config.json` inside the Docker volume and persists across restarts. You can also use environment variables (they override config.json values).
28+
29+
### Via Web UI (recommended)
30+
31+
1. Start the container - the setup wizard opens automatically
32+
2. Enter FritzBox URL, username, and password - test the connection
33+
3. Enter MQTT broker details - test the connection
34+
4. Set poll interval and history retention
35+
5. Done - monitoring starts immediately
36+
37+
Access `/settings` at any time to change configuration or toggle light/dark mode.
38+
39+
### Via Environment Variables (optional)
40+
41+
Copy `.env.example` to `.env` and edit:
42+
43+
| Variable | Default | Description |
44+
|---|---|---|
45+
| `FRITZ_URL` | `http://192.168.178.1` | FritzBox URL |
46+
| `FRITZ_USER` | - | FritzBox username |
47+
| `FRITZ_PASSWORD` | - | FritzBox password |
48+
| `MQTT_HOST` | `localhost` | MQTT broker host |
49+
| `MQTT_PORT` | `1883` | MQTT broker port |
50+
| `MQTT_USER` | - | MQTT username (optional) |
51+
| `MQTT_PASSWORD` | - | MQTT password (optional) |
52+
| `MQTT_TOPIC_PREFIX` | `fritzbox/docsis` | MQTT topic prefix |
53+
| `POLL_INTERVAL` | `300` | Polling interval in seconds |
54+
| `WEB_PORT` | `8765` | Web UI port |
55+
| `HISTORY_DAYS` | `7` | Snapshot retention in days |
56+
57+
## Created Sensors
58+
59+
### Per-Channel (~37 DS + 4 US)
60+
61+
- `sensor.docsis_ds_ch{id}` - State: Power (dBmV), Attributes: frequency, modulation, snr, errors, docsis_version, health
62+
- `sensor.docsis_us_ch{id}` - State: Power (dBmV), Attributes: frequency, modulation, multiplex, docsis_version, health
63+
64+
### Summary (14)
65+
66+
| Sensor | Unit | Description |
67+
|---|---|---|
68+
| `docsis_health` | - | Overall health (Gut/Grenzwertig/Schlecht) |
69+
| `docsis_health_details` | - | Detail text |
70+
| `docsis_ds_total` | - | Number of downstream channels |
71+
| `docsis_ds_power_min/max/avg` | dBmV | Downstream power range |
72+
| `docsis_ds_snr_min/avg` | dB | Downstream signal-to-noise |
73+
| `docsis_ds_correctable_errors` | - | Total correctable errors |
74+
| `docsis_ds_uncorrectable_errors` | - | Total uncorrectable errors |
75+
| `docsis_us_total` | - | Number of upstream channels |
76+
| `docsis_us_power_min/max/avg` | dBmV | Upstream power range |
77+
78+
## Reference Values
79+
80+
| Metric | Good | Marginal | Bad |
81+
|---|---|---|---|
82+
| DS Power | -7..+7 dBmV | +/-7..+/-10 | > +/-10 dBmV |
83+
| US Power | 35..49 dBmV | 50..54 | > 54 dBmV |
84+
| SNR / MER | > 30 dB | 25..30 | < 25 dB |
85+
86+
## Web UI
87+
88+
Access at `http://<host>:8765`. Auto-refreshes every 60 seconds. Shows:
89+
- Health status with color indicator
90+
- Summary metrics
91+
- Full downstream/upstream channel tables with per-row health indicators
92+
- Timeline navigation for historical snapshots
93+
- Reference value table
94+
95+
## Requirements
96+
97+
- AVM FRITZ!Box Cable router (tested with 6690 Cable)
98+
- MQTT broker (e.g., Mosquitto)
99+
- Home Assistant with MQTT integration
100+
101+
## License
102+
103+
MIT

app/__init__.py

Whitespace-only changes.

app/analyzer.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""DOCSIS channel health analysis with configurable thresholds."""
2+
3+
import logging
4+
5+
log = logging.getLogger("docsis.analyzer")
6+
7+
# --- Reference thresholds ---
8+
# Downstream Power (dBmV): ideal 0, good -7..+7, marginal -10..+10
9+
DS_POWER_WARN = 7.0
10+
DS_POWER_CRIT = 10.0
11+
12+
# Upstream Power (dBmV): good 35-49, marginal 50-54, bad >54
13+
US_POWER_WARN = 50.0
14+
US_POWER_CRIT = 54.0
15+
16+
# SNR / MER (dB): good >30, marginal 25-30, bad <25
17+
SNR_WARN = 30.0
18+
SNR_CRIT = 25.0
19+
20+
# Uncorrectable errors threshold
21+
UNCORR_ERRORS_CRIT = 10000
22+
23+
24+
def _parse_float(val, default=0.0):
25+
try:
26+
return float(val)
27+
except (TypeError, ValueError):
28+
return default
29+
30+
31+
def _channel_health(issues):
32+
"""Return health string from issue list."""
33+
if not issues:
34+
return "good"
35+
if any("critical" in i for i in issues):
36+
return "critical"
37+
return "warning"
38+
39+
40+
def _health_detail(issues):
41+
"""Build a human-readable detail string from issue list."""
42+
if not issues:
43+
return ""
44+
labels = []
45+
for i in issues:
46+
if "power" in i and "critical" in i:
47+
labels.append("Power kritisch")
48+
elif "power" in i:
49+
labels.append("Power erhoeht")
50+
if "snr" in i and "critical" in i:
51+
labels.append("SNR kritisch")
52+
elif "snr" in i:
53+
labels.append("SNR niedrig")
54+
return " + ".join(labels) if labels else ""
55+
56+
57+
def _assess_ds_channel(ch, docsis_ver):
58+
"""Assess a single downstream channel. Returns (health, health_detail)."""
59+
issues = []
60+
power = _parse_float(ch.get("powerLevel"))
61+
62+
if abs(power) > DS_POWER_CRIT:
63+
issues.append("power critical")
64+
elif abs(power) > DS_POWER_WARN:
65+
issues.append("power warning")
66+
67+
if docsis_ver == "3.0" and ch.get("mse"):
68+
snr = abs(_parse_float(ch["mse"]))
69+
if snr < SNR_CRIT:
70+
issues.append("snr critical")
71+
elif snr < SNR_WARN:
72+
issues.append("snr warning")
73+
elif docsis_ver == "3.1" and ch.get("mer"):
74+
snr = _parse_float(ch["mer"])
75+
if snr < SNR_CRIT:
76+
issues.append("snr critical")
77+
elif snr < SNR_WARN:
78+
issues.append("snr warning")
79+
80+
return _channel_health(issues), _health_detail(issues)
81+
82+
83+
def _assess_us_channel(ch):
84+
"""Assess a single upstream channel. Returns (health, health_detail)."""
85+
issues = []
86+
power = _parse_float(ch.get("powerLevel"))
87+
88+
if power > US_POWER_CRIT:
89+
issues.append("power critical")
90+
elif power > US_POWER_WARN:
91+
issues.append("power warning")
92+
93+
return _channel_health(issues), _health_detail(issues)
94+
95+
96+
def analyze(data: dict) -> dict:
97+
"""Analyze DOCSIS data and return structured result.
98+
99+
Returns dict with keys:
100+
summary: dict of summary metrics
101+
ds_channels: list of downstream channel dicts
102+
us_channels: list of upstream channel dicts
103+
"""
104+
ds = data.get("channelDs", {})
105+
ds31 = ds.get("docsis31", [])
106+
ds30 = ds.get("docsis30", [])
107+
108+
us = data.get("channelUs", {})
109+
us31 = us.get("docsis31", [])
110+
us30 = us.get("docsis30", [])
111+
112+
# --- Parse downstream channels ---
113+
ds_channels = []
114+
for ch in ds30:
115+
power = _parse_float(ch.get("powerLevel"))
116+
snr = abs(_parse_float(ch.get("mse"))) if ch.get("mse") else None
117+
health, health_detail = _assess_ds_channel(ch, "3.0")
118+
ds_channels.append({
119+
"channel_id": ch.get("channelID", 0),
120+
"frequency": ch.get("frequency", ""),
121+
"power": power,
122+
"modulation": ch.get("modulation") or ch.get("type", ""),
123+
"snr": snr,
124+
"correctable_errors": ch.get("corrErrors", 0),
125+
"uncorrectable_errors": ch.get("nonCorrErrors", 0),
126+
"docsis_version": "3.0",
127+
"health": health,
128+
"health_detail": health_detail,
129+
})
130+
for ch in ds31:
131+
power = _parse_float(ch.get("powerLevel"))
132+
snr = _parse_float(ch.get("mer")) if ch.get("mer") else None
133+
health, health_detail = _assess_ds_channel(ch, "3.1")
134+
ds_channels.append({
135+
"channel_id": ch.get("channelID", 0),
136+
"frequency": ch.get("frequency", ""),
137+
"power": power,
138+
"modulation": ch.get("modulation") or ch.get("type", ""),
139+
"snr": snr,
140+
"correctable_errors": ch.get("corrErrors", 0),
141+
"uncorrectable_errors": ch.get("nonCorrErrors", 0),
142+
"docsis_version": "3.1",
143+
"health": health,
144+
"health_detail": health_detail,
145+
})
146+
147+
ds_channels.sort(key=lambda c: c["channel_id"])
148+
149+
# --- Parse upstream channels ---
150+
us_channels = []
151+
for ch in us30:
152+
health, health_detail = _assess_us_channel(ch)
153+
us_channels.append({
154+
"channel_id": ch.get("channelID", 0),
155+
"frequency": ch.get("frequency", ""),
156+
"power": _parse_float(ch.get("powerLevel")),
157+
"modulation": ch.get("modulation") or ch.get("type", ""),
158+
"multiplex": ch.get("multiplex", ""),
159+
"docsis_version": "3.0",
160+
"health": health,
161+
"health_detail": health_detail,
162+
})
163+
for ch in us31:
164+
health, health_detail = _assess_us_channel(ch)
165+
us_channels.append({
166+
"channel_id": ch.get("channelID", 0),
167+
"frequency": ch.get("frequency", ""),
168+
"power": _parse_float(ch.get("powerLevel")),
169+
"modulation": ch.get("modulation") or ch.get("type", ""),
170+
"multiplex": ch.get("multiplex", ""),
171+
"docsis_version": "3.1",
172+
"health": health,
173+
"health_detail": health_detail,
174+
})
175+
176+
us_channels.sort(key=lambda c: c["channel_id"])
177+
178+
# --- Summary metrics ---
179+
ds_powers = [c["power"] for c in ds_channels]
180+
us_powers = [c["power"] for c in us_channels]
181+
ds_snrs = [c["snr"] for c in ds_channels if c["snr"] is not None]
182+
183+
total_corr = sum(c["correctable_errors"] for c in ds_channels)
184+
total_uncorr = sum(c["uncorrectable_errors"] for c in ds_channels)
185+
186+
summary = {
187+
"ds_total": len(ds_channels),
188+
"us_total": len(us_channels),
189+
"ds_power_min": round(min(ds_powers), 1) if ds_powers else 0,
190+
"ds_power_max": round(max(ds_powers), 1) if ds_powers else 0,
191+
"ds_power_avg": round(sum(ds_powers) / len(ds_powers), 1) if ds_powers else 0,
192+
"us_power_min": round(min(us_powers), 1) if us_powers else 0,
193+
"us_power_max": round(max(us_powers), 1) if us_powers else 0,
194+
"us_power_avg": round(sum(us_powers) / len(us_powers), 1) if us_powers else 0,
195+
"ds_snr_min": round(min(ds_snrs), 1) if ds_snrs else 0,
196+
"ds_snr_avg": round(sum(ds_snrs) / len(ds_snrs), 1) if ds_snrs else 0,
197+
"ds_correctable_errors": total_corr,
198+
"ds_uncorrectable_errors": total_uncorr,
199+
}
200+
201+
# --- Overall health ---
202+
issues = []
203+
if ds_powers and (min(ds_powers) < -DS_POWER_CRIT or max(ds_powers) > DS_POWER_CRIT):
204+
issues.append("DS Power ausserhalb Norm")
205+
if us_powers and max(us_powers) > US_POWER_CRIT:
206+
issues.append("US Power kritisch hoch")
207+
elif us_powers and max(us_powers) > US_POWER_WARN:
208+
issues.append("US Power erhoeht")
209+
if ds_snrs and min(ds_snrs) < SNR_CRIT:
210+
issues.append("SNR zu niedrig")
211+
elif ds_snrs and min(ds_snrs) < SNR_WARN:
212+
issues.append("SNR grenzwertig")
213+
if total_uncorr > UNCORR_ERRORS_CRIT:
214+
issues.append("Viele uncorrectable Errors")
215+
216+
if not issues:
217+
summary["health"] = "Gut"
218+
elif any("kritisch" in i for i in issues):
219+
summary["health"] = "Schlecht"
220+
else:
221+
summary["health"] = "Grenzwertig"
222+
summary["health_details"] = "; ".join(issues) if issues else "Alles OK"
223+
224+
log.info(
225+
"Analysis: DS=%d US=%d Health=%s",
226+
len(ds_channels), len(us_channels), summary["health"],
227+
)
228+
229+
return {
230+
"summary": summary,
231+
"ds_channels": ds_channels,
232+
"us_channels": us_channels,
233+
}

0 commit comments

Comments
 (0)