Skip to content

Commit 4a57c8c

Browse files
itsDNNSclaude
andcommitted
Add ThinkBroadband BQM graph archiving
Daily fetch and archive of broadband quality monitor graphs with gallery view, date navigation, calendar integration, and full i18n support (EN/DE/FR/ES). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cae1588 commit 4a57c8c

File tree

15 files changed

+463
-17
lines changed

15 files changed

+463
-17
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44

55
Versioning: `YYYY-MM-DD.N` (date + sequential build number per day)
66

7+
## [2026-02-09.17]
8+
9+
### Added
10+
- **ThinkBroadband BQM integration**: Daily fetch and archive of broadband quality monitor graphs (latency, packet loss)
11+
- **BQM gallery view**: New sidebar view to browse archived BQM graphs with date navigation and calendar integration
12+
- **BQM configuration**: `BQM_URL` env var and settings/setup UI field for the BQM share URL
13+
- **BQM API endpoints**: `GET /api/bqm/dates` and `GET /api/bqm/image/<date>` for graph retrieval
14+
- **BQM translations**: EN, DE, FR, ES support for all BQM-related UI strings
15+
716
## [2026-02-09.16]
817

918
### Added

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
- **Internationalization**: English, German, French, and Spanish UI
4141
- **LLM Export**: Generate structured reports for AI analysis (ChatGPT, Claude, Gemini, etc.)
4242
- **MQTT Auto-Discovery**: Zero-config integration with Home Assistant
43+
- **ThinkBroadband BQM**: Daily fetch and archive of broadband quality graphs with gallery view
4344
- **Optional Authentication**: Password-protected web UI with scrypt hashing
4445
- **Light/Dark Mode**: Persistent theme toggle
4546

@@ -86,6 +87,7 @@ Copy `.env.example` to `.env` and edit:
8687
| `WEB_PORT` | `8765` | Web UI port |
8788
| `HISTORY_DAYS` | `0` | Snapshot retention in days (0 = unlimited) |
8889
| `ADMIN_PASSWORD` | - | Web UI password (optional) |
90+
| `BQM_URL` | - | ThinkBroadband BQM share URL (.png, optional) |
8991

9092
</details>
9193

@@ -145,7 +147,7 @@ Copy `.env.example` to `.env` and edit:
145147
## Roadmap
146148

147149
### External Monitoring Integration
148-
- [ ] **ThinkBroadband BQM**: Daily fetch and archive of external broadband quality graphs (latency, packet loss)
150+
- [x] **ThinkBroadband BQM**: Daily fetch and archive of external broadband quality graphs (latency, packet loss)
149151
- [ ] **Speedtest Tracker**: Pull speed test results (download, upload, ping, jitter) from self-hosted [Speedtest Tracker](https://github.com/alexjustesen/speedtest-tracker)
150152

151153
### Enhanced Dashboard

app/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"language": "en",
3535
"isp_name": "",
3636
"admin_password": "",
37+
"bqm_url": "",
3738
}
3839

3940
ENV_MAP = {
@@ -50,6 +51,7 @@
5051
"history_days": "HISTORY_DAYS",
5152
"data_dir": "DATA_DIR",
5253
"admin_password": "ADMIN_PASSWORD",
54+
"bqm_url": "BQM_URL",
5355
}
5456

5557
# Deprecated env vars (FRITZ_* -> MODEM_*) - checked as fallback
@@ -228,6 +230,10 @@ def is_mqtt_configured(self):
228230
"""True if mqtt_host is set (MQTT is optional)."""
229231
return bool(self.get("mqtt_host"))
230232

233+
def is_bqm_configured(self):
234+
"""True if bqm_url is set (BQM is optional)."""
235+
return bool(self.get("bqm_url"))
236+
231237
def get_theme(self):
232238
"""Return 'dark' or 'light'."""
233239
theme = self.get("theme", "dark")

app/i18n/de.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,11 @@
154154
"login_button": "Anmelden",
155155
"logout": "Abmelden",
156156
"admin_password": "Admin-Passwort",
157-
"admin_password_hint": "Leer lassen um Authentifizierung zu deaktivieren"
157+
"admin_password_hint": "Leer lassen um Authentifizierung zu deaktivieren",
158+
159+
"bqm_title": "BQM-Graphen",
160+
"bqm_optional": "optional",
161+
"bqm_share_url": "BQM Share-URL",
162+
"bqm_url_hint": "ThinkBroadband BQM Share-Link einfuegen (.png)",
163+
"bqm_no_data": "Kein BQM-Graph fuer dieses Datum."
158164
}

app/i18n/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,11 @@
154154
"login_button": "Login",
155155
"logout": "Logout",
156156
"admin_password": "Admin Password",
157-
"admin_password_hint": "Leave empty to disable authentication"
157+
"admin_password_hint": "Leave empty to disable authentication",
158+
159+
"bqm_title": "BQM Graphs",
160+
"bqm_optional": "optional",
161+
"bqm_share_url": "BQM Share URL",
162+
"bqm_url_hint": "Paste your ThinkBroadband BQM share link (.png)",
163+
"bqm_no_data": "No BQM graph for this date."
158164
}

app/i18n/es.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,11 @@
154154
"login_button": "Entrar",
155155
"logout": "Cerrar sesión",
156156
"admin_password": "Contraseña de admin",
157-
"admin_password_hint": "Dejar vacío para desactivar la autenticación"
157+
"admin_password_hint": "Dejar vacío para desactivar la autenticación",
158+
159+
"bqm_title": "Gráficos BQM",
160+
"bqm_optional": "opcional",
161+
"bqm_share_url": "URL de compartir BQM",
162+
"bqm_url_hint": "Pega tu enlace de compartir ThinkBroadband BQM (.png)",
163+
"bqm_no_data": "No hay gráfico BQM para esta fecha."
158164
}

app/i18n/fr.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,5 +154,11 @@
154154
"login_button": "Se connecter",
155155
"logout": "Déconnexion",
156156
"admin_password": "Mot de passe admin",
157-
"admin_password_hint": "Laisser vide pour désactiver l'authentification"
157+
"admin_password_hint": "Laisser vide pour désactiver l'authentification",
158+
159+
"bqm_title": "Graphiques BQM",
160+
"bqm_optional": "optionnel",
161+
"bqm_share_url": "URL de partage BQM",
162+
"bqm_url_hint": "Collez votre lien de partage ThinkBroadband BQM (.png)",
163+
"bqm_no_data": "Pas de graphique BQM pour cette date."
158164
}

app/main.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import threading
66
import time
77

8-
from . import fritzbox, analyzer, web
8+
from . import fritzbox, analyzer, web, thinkbroadband
99
from .config import ConfigManager
1010
from .mqtt_publisher import MQTTPublisher
1111
from .storage import SnapshotStorage
@@ -57,6 +57,7 @@ def polling_loop(config_mgr, storage, stop_event):
5757
device_info = None
5858
connection_info = None
5959
discovery_published = False
60+
bqm_last_date = None
6061

6162
while not stop_event.is_set():
6263
try:
@@ -92,6 +93,15 @@ def polling_loop(config_mgr, storage, stop_event):
9293
web.update_state(analysis=analysis)
9394
storage.save_snapshot(analysis)
9495

96+
# Fetch BQM graph once per day
97+
if config_mgr.is_bqm_configured():
98+
today = time.strftime("%Y-%m-%d")
99+
if today != bqm_last_date:
100+
image = thinkbroadband.fetch_graph(config_mgr.get("bqm_url"))
101+
if image:
102+
storage.save_bqm_graph(image)
103+
bqm_last_date = today
104+
95105
except Exception as e:
96106
log.error("Poll error: %s", e)
97107
web.update_state(error=e)

app/storage.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ def _init_db(self):
3333
CREATE INDEX IF NOT EXISTS idx_snapshots_ts
3434
ON snapshots(timestamp)
3535
""")
36+
conn.execute("""
37+
CREATE TABLE IF NOT EXISTS bqm_graphs (
38+
id INTEGER PRIMARY KEY AUTOINCREMENT,
39+
date TEXT NOT NULL UNIQUE,
40+
timestamp TEXT NOT NULL,
41+
image_blob BLOB NOT NULL
42+
)
43+
""")
3644

3745
def save_snapshot(self, analysis):
3846
"""Save current analysis as a snapshot. Runs cleanup afterwards."""
@@ -140,8 +148,38 @@ def get_intraday_data(self, date):
140148
results.append(entry)
141149
return results
142150

151+
def save_bqm_graph(self, image_data):
152+
"""Save BQM graph for today. Skips if already exists (UNIQUE date)."""
153+
today = datetime.now().strftime("%Y-%m-%d")
154+
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
155+
try:
156+
with sqlite3.connect(self.db_path) as conn:
157+
conn.execute(
158+
"INSERT OR IGNORE INTO bqm_graphs (date, timestamp, image_blob) VALUES (?, ?, ?)",
159+
(today, ts, image_data),
160+
)
161+
log.debug("BQM graph saved for %s", today)
162+
except Exception as e:
163+
log.error("Failed to save BQM graph: %s", e)
164+
165+
def get_bqm_dates(self):
166+
"""Return list of dates with BQM graphs (newest first)."""
167+
with sqlite3.connect(self.db_path) as conn:
168+
rows = conn.execute(
169+
"SELECT date FROM bqm_graphs ORDER BY date DESC"
170+
).fetchall()
171+
return [r[0] for r in rows]
172+
173+
def get_bqm_graph(self, date):
174+
"""Return BQM graph PNG bytes for a date, or None."""
175+
with sqlite3.connect(self.db_path) as conn:
176+
row = conn.execute(
177+
"SELECT image_blob FROM bqm_graphs WHERE date = ?", (date,)
178+
).fetchone()
179+
return bytes(row[0]) if row else None
180+
143181
def _cleanup(self):
144-
"""Delete snapshots older than max_days. 0 = keep all."""
182+
"""Delete snapshots and BQM graphs older than max_days. 0 = keep all."""
145183
if self.max_days <= 0:
146184
return
147185
cutoff = (datetime.now() - timedelta(days=self.max_days)).strftime(
@@ -153,3 +191,10 @@ def _cleanup(self):
153191
).rowcount
154192
if deleted:
155193
log.info("Cleaned up %d old snapshots (before %s)", deleted, cutoff)
194+
cutoff_date = (datetime.now() - timedelta(days=self.max_days)).strftime("%Y-%m-%d")
195+
with sqlite3.connect(self.db_path) as conn:
196+
bqm_deleted = conn.execute(
197+
"DELETE FROM bqm_graphs WHERE date < ?", (cutoff_date,)
198+
).rowcount
199+
if bqm_deleted:
200+
log.info("Cleaned up %d old BQM graphs (before %s)", bqm_deleted, cutoff_date)

app/templates/index.html

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,11 @@
359359
<a class="sidebar-link" data-view="month">
360360
<span class="icon">&#128197;</span> {{ t.month_trend }}
361361
</a>
362+
{% if bqm_configured %}
363+
<a class="sidebar-link" data-view="bqm">
364+
<span class="icon">&#128200;</span> {{ t.bqm_title }}
365+
</a>
366+
{% endif %}
362367
<div class="sidebar-divider"></div>
363368
<a class="sidebar-link" href="/settings">
364369
<span class="icon">&#9881;</span> {{ t.settings }}
@@ -605,6 +610,21 @@ <h2 class="section-title">{{ t.upstream }} ({{ us|length }} {{ t.channels }})</h
605610
</div>
606611
</div>
607612
</div>
613+
614+
<!-- ═══ View: BQM Graphs ═══ -->
615+
{% if bqm_configured %}
616+
<div id="view-bqm" class="view">
617+
<div class="trend-header">
618+
<span class="trend-title">{{ t.bqm_title }}</span>
619+
</div>
620+
<div id="bqm-content">
621+
<div id="bqm-image-wrap" style="text-align:center;">
622+
<img id="bqm-image" style="max-width:100%; border-radius:8px; display:none;" alt="BQM Graph">
623+
</div>
624+
<div id="bqm-no-data" class="no-data-msg" style="display:none;"></div>
625+
</div>
626+
</div>
627+
{% endif %}
608628
</div><!-- /main-content -->
609629
</div><!-- /app-main -->
610630
</div><!-- /app-layout -->
@@ -719,15 +739,21 @@ <h2>{{ t.export_title }}</h2>
719739
});
720740
var dashView = document.getElementById('view-dashboard');
721741
var trendView = document.getElementById('view-trends');
742+
var bqmView = document.getElementById('view-bqm');
743+
dashView.classList.remove('active');
744+
trendView.classList.remove('active');
745+
if (bqmView) bqmView.classList.remove('active');
746+
stopAutoRefresh();
722747
if (view === 'live') {
723748
dashView.classList.add('active');
724-
trendView.classList.remove('active');
725749
updateDateLabel();
726750
if (!isHistorical) startAutoRefresh();
751+
} else if (view === 'bqm') {
752+
if (bqmView) bqmView.classList.add('active');
753+
updateDateLabel();
754+
loadBqmGraph(selectedDate);
727755
} else {
728-
dashView.classList.remove('active');
729756
trendView.classList.add('active');
730-
stopAutoRefresh();
731757
updateDateLabel();
732758
loadTrends(view, selectedDate);
733759
}
@@ -741,6 +767,8 @@ <h2>{{ t.export_title }}</h2>
741767
function updateDateLabel() {
742768
if (currentView === 'live') {
743769
dateLabel.textContent = isHistorical ? formatDateDE('{{ snapshot_ts[:10] if snapshot_ts else "" }}') : T.live;
770+
} else if (currentView === 'bqm') {
771+
dateLabel.textContent = formatDateDE(selectedDate) + ' (BQM)';
744772
} else {
745773
var rangeLabel = {day: T.day, week: T.week, month: T.month}[currentView] || '';
746774
dateLabel.textContent = formatDateDE(selectedDate) + ' (' + rangeLabel + ')';
@@ -752,7 +780,7 @@ <h2>{{ t.export_title }}</h2>
752780

753781
function dateNav(dir) {
754782
var d = new Date(selectedDate + 'T12:00:00');
755-
if (currentView === 'live' || currentView === 'day') {
783+
if (currentView === 'live' || currentView === 'day' || currentView === 'bqm') {
756784
d.setDate(d.getDate() + dir);
757785
} else if (currentView === 'week') {
758786
d.setDate(d.getDate() + dir * 7);
@@ -769,6 +797,8 @@ <h2>{{ t.export_title }}</h2>
769797
window.location.href = '/?t=' + encodeURIComponent(snap.timestamp);
770798
}
771799
});
800+
} else if (currentView === 'bqm') {
801+
loadBqmGraph(selectedDate);
772802
} else {
773803
loadTrends(currentView, selectedDate);
774804
}
@@ -800,6 +830,8 @@ <h2>{{ t.export_title }}</h2>
800830
renderCalendar();
801831
});
802832

833+
var bqmDates = [];
834+
803835
function loadCalendarData() {
804836
fetch('/api/calendar')
805837
.then(function(r) { return r.json(); })
@@ -808,6 +840,10 @@ <h2>{{ t.export_title }}</h2>
808840
calendarLoaded = true;
809841
renderCalendar();
810842
});
843+
fetch('/api/bqm/dates')
844+
.then(function(r) { return r.json(); })
845+
.then(function(dates) { bqmDates = dates || []; })
846+
.catch(function() {});
811847
}
812848

813849
function renderCalendar() {
@@ -830,7 +866,8 @@ <h2>{{ t.export_title }}</h2>
830866
var el = document.createElement('span');
831867
el.className = 'cal-day';
832868
el.textContent = d;
833-
if (datesWithData.indexOf(dateStr) !== -1) el.classList.add('has-data');
869+
var activeDates = (currentView === 'bqm') ? bqmDates : datesWithData;
870+
if (activeDates.indexOf(dateStr) !== -1) el.classList.add('has-data');
834871
if (dateStr === today) el.classList.add('today');
835872
if (dateStr === selectedDate) el.classList.add('selected');
836873
el.setAttribute('data-date', dateStr);
@@ -847,6 +884,9 @@ <h2>{{ t.export_title }}</h2>
847884
window.location.href = '/?t=' + encodeURIComponent(snap.timestamp);
848885
}
849886
});
887+
} else if (currentView === 'bqm') {
888+
updateDateLabel();
889+
loadBqmGraph(selectedDate);
850890
} else {
851891
updateDateLabel();
852892
loadTrends(currentView, selectedDate);
@@ -947,6 +987,22 @@ <h2>{{ t.export_title }}</h2>
947987
});
948988
}
949989

990+
/* ── BQM Graph ── */
991+
function loadBqmGraph(date) {
992+
var img = document.getElementById('bqm-image');
993+
var noData = document.getElementById('bqm-no-data');
994+
if (!img || !noData) return;
995+
img.style.display = 'none';
996+
noData.style.display = 'none';
997+
img.onload = function() { img.style.display = 'inline-block'; };
998+
img.onerror = function() {
999+
img.style.display = 'none';
1000+
noData.textContent = T.bqm_no_data || 'No BQM graph for this date.';
1001+
noData.style.display = 'block';
1002+
};
1003+
img.src = '/api/bqm/image/' + date;
1004+
}
1005+
9501006
/* ── Init ── */
9511007
updateDateLabel();
9521008
})();

0 commit comments

Comments
 (0)