Skip to content

Commit eee4a82

Browse files
authored
feat: support community-contributed modem drivers via module system (#145)
* feat(drivers): add DriverRegistry class for unified driver lookup Introduces a central DriverRegistry that supports both built-in drivers (lazy-loaded via class paths) and module-contributed drivers (eager-loaded classes). Module drivers take priority, enabling community overrides. * refactor(drivers): wire DriverRegistry into all consumers Replace hardcoded DRIVER_REGISTRY/DRIVER_DISPLAY_NAMES dicts and standalone load_driver() with the unified DriverRegistry singleton. Backward-compatible load_driver() wrapper kept for safety. * feat(modules): add "driver" contribution type to module loader Modules can now declare a "driver" contribution in their manifest. Driver modules are security-restricted from also contributing collectors or publishers. * feat(drivers): add GenericDriver for no-modem mode Returns empty but structurally valid DOCSIS data, enabling all modem-agnostic features (Speedtest, BQM, Journal, etc.) to work standalone without a physical modem. * feat(ui): show fun placeholder when no DOCSIS data is available When running with GenericDriver or a non-DOCSIS modem, the dashboard shows a humorous "He's dead, Jim" message instead of empty channel tables. Translated to all 4 supported languages. * fix(config): treat generic driver as configured without password The is_configured() check required modem_password to be set, but GenericDriver doesn't need credentials. Now recognizes modem_type "generic" as a valid configured state. * fix(ui): hide credentials for generic driver, fix setup light mode, auto-refresh on first load - Hide URL/username/password/test-connection when Generic Router selected (both setup wizard and settings page) - Fix setup page light mode by including tokens.css and adding box-sizing: border-box reset - Add background/color CSS variables to setup body - Auto-refresh dashboard every 5s while waiting for first poll (stops once data arrives or after 5 minutes) * feat(ui): show non-DOCSIS cards on dashboard for generic router Move speedtest health calculations before the has_docsis conditional so they're available in both DOCSIS and non-DOCSIS views. When using Generic Router, the dashboard now shows the no-DOCSIS placeholder for signal metrics but still renders speedtest, BNetzA, and module cards below it when configured. * test(web): add no-DOCSIS dashboard tests, redesign placeholder Add tests verifying the no-DOCSIS placeholder appears with empty channels and that the speed card renders when speedtest is configured. Redesign the placeholder to use a Lucide icon in a circular wrap matching the existing hero card design language. * fix(i18n): replace meme placeholder with informative generic router message Explain that DOCSIS channel data is unavailable in generic router mode and that metrics below (speedtest, BNetzA, etc.) show connection quality for fiber, DSL, or satellite users. Updated all 4 languages and changed icon from radio-tower to router. --------- Co-authored-by: itsDNNS <itsDNNS@users.noreply.github.com>
1 parent 5a0bbd4 commit eee4a82

File tree

21 files changed

+636
-110
lines changed

21 files changed

+636
-110
lines changed

app/blueprints/polling_bp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ def api_test_modem():
3131
password = data.get("modem_password", "")
3232
if password == PASSWORD_MASK and _config_manager:
3333
password = _config_manager.get("modem_password", "")
34-
from app.drivers import load_driver
34+
from app.drivers import driver_registry
3535
modem_type = data.get("modem_type", "fritzbox")
36-
driver = load_driver(
36+
driver = driver_registry.load_driver(
3737
modem_type,
3838
data.get("modem_url", "http://192.168.178.1"),
3939
data.get("modem_user", ""),

app/collectors/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ def discover_collectors(config_mgr, storage, event_detector, mqtt_pub, web, anal
5151
))
5252
# Modem collector (available if modem configured)
5353
elif config_mgr.is_configured():
54-
from ..drivers import load_driver
54+
from ..drivers import driver_registry
5555

5656
modem_type = config.get("modem_type", "fritzbox")
57-
driver = load_driver(
57+
driver = driver_registry.load_driver(
5858
modem_type,
5959
config["modem_url"],
6060
config["modem_user"],

app/config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,8 +283,12 @@ def save(self, data):
283283
log.info("Config saved to %s", self.config_path)
284284

285285
def is_configured(self):
286-
"""True if modem_password is set or demo_mode is active."""
287-
return bool(self.get("modem_password")) or self.is_demo_mode()
286+
"""True if modem is configured (password set or generic driver) or demo mode."""
287+
if self.is_demo_mode():
288+
return True
289+
if self.get("modem_type") == "generic":
290+
return True
291+
return bool(self.get("modem_password"))
288292

289293
def is_demo_mode(self):
290294
"""True if DEMO_MODE is enabled."""

app/drivers/__init__.py

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,19 @@
11
"""Modem driver abstractions."""
22

3-
import importlib
4-
import logging
3+
from .registry import DriverRegistry
54

6-
log = logging.getLogger("docsis.drivers")
5+
driver_registry = DriverRegistry()
76

8-
DRIVER_REGISTRY = {
9-
"fritzbox": "app.drivers.fritzbox.FritzBoxDriver",
10-
"tc4400": "app.drivers.tc4400.TC4400Driver",
11-
"ultrahub7": "app.drivers.ultrahub7.UltraHub7Driver",
12-
"vodafone_station": "app.drivers.vodafone_station.VodafoneStationDriver",
13-
"ch7465": "app.drivers.ch7465.CH7465Driver",
14-
"cm3500": "app.drivers.cm3500.CM3500Driver",
15-
}
16-
17-
DRIVER_DISPLAY_NAMES = {
18-
"fritzbox": "AVM FRITZ!Box",
19-
"tc4400": "Technicolor TC4400",
20-
"ultrahub7": "Vodafone Ultra Hub 7",
21-
"vodafone_station": "Vodafone Station",
22-
"ch7465": "Unitymedia Connect Box (CH7465)",
23-
"cm3500": "Arris CM3500B",
24-
}
7+
# Register built-in drivers
8+
driver_registry.register_builtin("fritzbox", "app.drivers.fritzbox.FritzBoxDriver", "AVM FRITZ!Box")
9+
driver_registry.register_builtin("tc4400", "app.drivers.tc4400.TC4400Driver", "Technicolor TC4400")
10+
driver_registry.register_builtin("ultrahub7", "app.drivers.ultrahub7.UltraHub7Driver", "Vodafone Ultra Hub 7")
11+
driver_registry.register_builtin("vodafone_station", "app.drivers.vodafone_station.VodafoneStationDriver", "Vodafone Station")
12+
driver_registry.register_builtin("ch7465", "app.drivers.ch7465.CH7465Driver", "Unitymedia Connect Box (CH7465)")
13+
driver_registry.register_builtin("cm3500", "app.drivers.cm3500.CM3500Driver", "Arris CM3500B")
14+
driver_registry.register_builtin("generic", "app.drivers.generic.GenericDriver", "Generic Router (No DOCSIS)")
2515

2616

2717
def load_driver(modem_type, url, user, password):
28-
"""Instantiate a modem driver by type name."""
29-
qualified = DRIVER_REGISTRY.get(modem_type)
30-
if not qualified:
31-
supported = ", ".join(sorted(DRIVER_REGISTRY))
32-
raise ValueError(
33-
f"Unknown modem_type '{modem_type}'. Supported: {supported}"
34-
)
35-
module_path, class_name = qualified.rsplit(".", 1)
36-
mod = importlib.import_module(module_path)
37-
cls = getattr(mod, class_name)
38-
return cls(url, user, password)
18+
"""Backward-compatible wrapper around driver_registry.load_driver()."""
19+
return driver_registry.load_driver(modem_type, url, user, password)

app/drivers/generic.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""Generic driver for non-DOCSIS / no-modem mode."""
2+
3+
from .base import ModemDriver
4+
5+
6+
class GenericDriver(ModemDriver):
7+
"""No-op driver that returns empty but structurally valid data.
8+
9+
Allows all modem-agnostic features (Speedtest, BQM, Smokeping,
10+
BNetzA, Weather, Journal) to work standalone.
11+
"""
12+
13+
def login(self) -> None:
14+
pass
15+
16+
def get_docsis_data(self) -> dict:
17+
return {
18+
"channelDs": {"docsis30": [], "docsis31": []},
19+
"channelUs": {"docsis30": [], "docsis31": []},
20+
}
21+
22+
def get_device_info(self) -> dict:
23+
return {
24+
"model": "Generic Router",
25+
"sw_version": "N/A",
26+
"manufacturer": "N/A",
27+
}
28+
29+
def get_connection_info(self) -> dict:
30+
return {}

app/drivers/registry.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Unified driver registry for built-in and module-contributed modem drivers."""
2+
3+
import importlib
4+
import logging
5+
6+
from .base import ModemDriver
7+
8+
log = logging.getLogger("docsis.drivers")
9+
10+
11+
class DriverRegistry:
12+
"""Central registry for all modem drivers (built-in and module-contributed).
13+
14+
Built-in drivers are stored as qualified class paths (lazy-loaded).
15+
Module drivers are stored as already-loaded classes.
16+
"""
17+
18+
def __init__(self):
19+
self._builtin: dict[str, str] = {}
20+
self._module_drivers: dict[str, type] = {}
21+
self._display_names: dict[str, str] = {}
22+
23+
def register_builtin(self, type_key: str, class_path: str, display_name: str) -> None:
24+
self._builtin[type_key] = class_path
25+
self._display_names[type_key] = display_name
26+
27+
def register_module_driver(self, type_key: str, cls: type, display_name: str) -> None:
28+
self._module_drivers[type_key] = cls
29+
self._display_names[type_key] = display_name
30+
31+
def load_driver(self, modem_type: str, url: str, user: str, password: str) -> ModemDriver:
32+
# Module drivers take priority (community can override/extend)
33+
if modem_type in self._module_drivers:
34+
cls = self._module_drivers[modem_type]
35+
return cls(url, user, password)
36+
37+
qualified = self._builtin.get(modem_type)
38+
if not qualified:
39+
supported = ", ".join(sorted(self.get_all_type_keys()))
40+
raise ValueError(
41+
f"Unknown modem_type '{modem_type}'. Supported: {supported}"
42+
)
43+
module_path, class_name = qualified.rsplit(".", 1)
44+
mod = importlib.import_module(module_path)
45+
cls = getattr(mod, class_name)
46+
return cls(url, user, password)
47+
48+
def get_available_drivers(self) -> list[tuple[str, str]]:
49+
all_keys = self.get_all_type_keys()
50+
return sorted(
51+
[(k, self._display_names.get(k, k)) for k in all_keys],
52+
key=lambda x: x[1],
53+
)
54+
55+
def get_all_type_keys(self) -> set[str]:
56+
return set(self._builtin) | set(self._module_drivers)
57+
58+
def has_driver(self, modem_type: str) -> bool:
59+
return modem_type in self._builtin or modem_type in self._module_drivers
60+
61+
def register_module_drivers(self, module_loader) -> None:
62+
for mod in module_loader.get_enabled_modules():
63+
if mod.driver_class and "driver" in mod.contributes:
64+
type_key = mod.id
65+
display_name = mod.name
66+
self.register_module_driver(type_key, mod.driver_class, display_name)
67+
log.info("Registered module driver: %s (%s)", type_key, display_name)

app/i18n/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"low": "niedrig",
8080
"error_label": "Fehler",
8181
"waiting_msg": "Warte auf erste DOCSIS-Abfrage...",
82+
"no_docsis_title": "Generischer Router-Modus",
83+
"no_docsis_msg": "DOCSIS-Kanaldaten sind in diesem Modus nicht verfügbar. Bei Verbindungen über Glasfaser, DSL oder Satellit zeigen die folgenden Metriken Ihre Verbindungsqualität.",
8284
"no_data": "Keine Daten für diesen Zeitraum vorhanden.",
8385
"trend_error": "Fehler beim Laden der Trenddaten.",
8486
"correctable": "Korrigierbar",

app/i18n/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"low": "low",
8080
"error_label": "Error",
8181
"waiting_msg": "Waiting for first DOCSIS query...",
82+
"no_docsis_title": "Generic Router Mode",
83+
"no_docsis_msg": "DOCSIS channel data is not available in this mode. If you connect via fiber, DSL, or satellite, the metrics below show your connection quality.",
8284
"no_data": "No data available for this period.",
8385
"trend_error": "Error loading trend data.",
8486
"correctable": "Correctable",

app/i18n/es.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"low": "bajo",
8080
"error_label": "Error",
8181
"waiting_msg": "Esperando la primera consulta DOCSIS...",
82+
"no_docsis_title": "Modo router genérico",
83+
"no_docsis_msg": "Los datos de canales DOCSIS no están disponibles en este modo. Si te conectas por fibra, DSL o satélite, las métricas a continuación muestran la calidad de tu conexión.",
8284
"no_data": "No hay datos disponibles para este período.",
8385
"trend_error": "Error al cargar los datos de tendencia.",
8486
"correctable": "Corregibles",

app/i18n/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"low": "faible",
8080
"error_label": "Erreur",
8181
"waiting_msg": "En attente de la première requête DOCSIS...",
82+
"no_docsis_title": "Mode routeur générique",
83+
"no_docsis_msg": "Les données de canaux DOCSIS ne sont pas disponibles dans ce mode. Si vous êtes connecté par fibre, DSL ou satellite, les métriques ci-dessous indiquent la qualité de votre connexion.",
8284
"no_data": "Aucune donnée disponible pour cette période.",
8385
"trend_error": "Erreur lors du chargement des tendances.",
8486
"correctable": "Corrigeables",

0 commit comments

Comments
 (0)