Skip to content

Commit f381028

Browse files
committed
fix: hot-swap modem driver when modem_type changes in settings (#164)
The polling loop now detects modem config changes (type, URL, credentials) and re-instantiates the driver without requiring a container restart. Also makes the polling loop exit faster by using non-blocking executor shutdown and checking stop_event inside the as_completed loop.
1 parent 448a9c9 commit f381028

File tree

2 files changed

+190
-2
lines changed

2 files changed

+190
-2
lines changed

app/main.py

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ def run_web(port):
4646
serve(web.app, host="0.0.0.0", port=port, threads=4, _quiet=True)
4747

4848

49+
def _get_modem_config_key(config_mgr):
50+
"""Return modem config tuple for driver hot-swap change detection."""
51+
return (
52+
config_mgr.get("modem_type", "fritzbox"),
53+
config_mgr.get("modem_url", ""),
54+
config_mgr.get("modem_user", ""),
55+
config_mgr.get("modem_password", ""),
56+
)
57+
58+
4959
def polling_loop(config_mgr, storage, stop_event):
5060
"""Flat orchestrator: tick every second, let each collector decide when to poll."""
5161
config = config_mgr.get_all()
@@ -110,6 +120,13 @@ def polling_loop(config_mgr, storage, stop_event):
110120
web.init_collector(modem_collector)
111121
web.init_collectors(collectors)
112122

123+
# Track modem config for driver hot-swap detection
124+
modem_config_key = (
125+
_get_modem_config_key(config_mgr)
126+
if modem_collector and modem_collector.name == "modem"
127+
else None
128+
)
129+
113130
log.info(
114131
"Collectors: %s",
115132
", ".join(
@@ -129,10 +146,43 @@ def _run_collector(collector):
129146
finally:
130147
collector._collect_lock.release()
131148

132-
with ThreadPoolExecutor(
149+
executor = ThreadPoolExecutor(
133150
max_workers=len(collectors), thread_name_prefix="collector"
134-
) as executor:
151+
)
152+
try:
135153
while not stop_event.is_set():
154+
# ── Driver hot-swap: detect modem config change ──
155+
if modem_config_key is not None and modem_collector:
156+
new_key = _get_modem_config_key(config_mgr)
157+
if new_key != modem_config_key:
158+
log.info(
159+
"Modem config changed (%s -> %s), hot-swapping driver",
160+
modem_config_key[0], new_key[0],
161+
)
162+
from .collectors.modem import ModemCollector
163+
from .drivers import driver_registry
164+
new_driver = driver_registry.load_driver(*new_key)
165+
new_modem = ModemCollector(
166+
driver=new_driver,
167+
analyzer_fn=analyzer.analyze,
168+
event_detector=event_detector,
169+
storage=storage,
170+
mqtt_pub=mqtt_pub,
171+
web=web,
172+
poll_interval=config_mgr.get("poll_interval", 900),
173+
notifier=notifier,
174+
)
175+
collectors = [
176+
new_modem if c is modem_collector else c
177+
for c in collectors
178+
]
179+
modem_collector = new_modem
180+
web.init_collector(new_modem)
181+
web.init_collectors(collectors)
182+
web.reset_modem_state()
183+
modem_config_key = new_key
184+
log.info("Driver hot-swapped to %s", new_key[0])
185+
136186
futures = {}
137187
for collector in collectors:
138188
if stop_event.is_set():
@@ -146,6 +196,8 @@ def _run_collector(collector):
146196

147197
try:
148198
for future in as_completed(futures, timeout=120):
199+
if stop_event.is_set():
200+
break
149201
collector = futures[future]
150202
try:
151203
_, result = future.result()
@@ -168,6 +220,8 @@ def _run_collector(collector):
168220
future.cancel()
169221

170222
stop_event.wait(1)
223+
finally:
224+
executor.shutdown(wait=False, cancel_futures=True)
171225

172226
# Cleanup MQTT
173227
if mqtt_pub:

tests/test_collectors.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1158,3 +1158,137 @@ def test_orchestrator_stops_on_event(self, mock_web, mock_load):
11581158

11591159
polling_loop(config_mgr, storage, stop)
11601160
# If we get here without hanging, the test passes
1161+
1162+
@patch("app.drivers.driver_registry.load_driver")
1163+
@patch("app.main.web")
1164+
def test_driver_hot_swap_on_modem_type_change(self, mock_web, mock_load):
1165+
"""Polling loop should hot-swap the modem driver when modem_type changes."""
1166+
import threading
1167+
from app.main import polling_loop
1168+
1169+
mock_driver = MagicMock()
1170+
mock_driver.get_device_info.return_value = {"model": "Test", "sw_version": "1.0"}
1171+
mock_driver.get_connection_info.return_value = {}
1172+
mock_driver.get_docsis_data.return_value = {}
1173+
mock_load.return_value = mock_driver
1174+
1175+
config_mgr = self._make_config_mgr()
1176+
storage = MagicMock()
1177+
stop = threading.Event()
1178+
1179+
call_count = [0]
1180+
original_wait = stop.wait
1181+
1182+
def change_modem_after_first_tick(timeout=None):
1183+
call_count[0] += 1
1184+
if call_count[0] == 1:
1185+
# After first tick, change modem_type in config
1186+
config_mgr.get_all.return_value["modem_type"] = "tc4400"
1187+
config_mgr.get.side_effect = lambda k, d=None: {
1188+
"modem_type": "tc4400",
1189+
"modem_url": "http://fritz.box",
1190+
"modem_user": "admin",
1191+
"modem_password": "pass",
1192+
"poll_interval": 900,
1193+
}.get(k, d)
1194+
return original_wait(0)
1195+
elif call_count[0] >= 3:
1196+
stop.set()
1197+
return True
1198+
return original_wait(0)
1199+
1200+
stop.wait = change_modem_after_first_tick
1201+
1202+
polling_loop(config_mgr, storage, stop)
1203+
1204+
# load_driver should have been called at least twice:
1205+
# once for initial setup, once for hot-swap
1206+
assert mock_load.call_count >= 2
1207+
# Second call should use the new modem type
1208+
second_call = mock_load.call_args_list[1]
1209+
assert second_call[0][0] == "tc4400"
1210+
# Web state should have been reset for the swap
1211+
mock_web.reset_modem_state.assert_called()
1212+
mock_web.init_collector.assert_called()
1213+
1214+
@patch("app.drivers.driver_registry.load_driver")
1215+
@patch("app.main.web")
1216+
def test_driver_hot_swap_on_url_change(self, mock_web, mock_load):
1217+
"""Hot-swap should trigger when modem URL changes, not just type."""
1218+
import threading
1219+
from app.main import polling_loop
1220+
1221+
mock_driver = MagicMock()
1222+
mock_driver.get_device_info.return_value = {"model": "Test", "sw_version": "1.0"}
1223+
mock_driver.get_connection_info.return_value = {}
1224+
mock_driver.get_docsis_data.return_value = {}
1225+
mock_load.return_value = mock_driver
1226+
1227+
config_mgr = self._make_config_mgr()
1228+
storage = MagicMock()
1229+
stop = threading.Event()
1230+
1231+
call_count = [0]
1232+
original_wait = stop.wait
1233+
1234+
def change_url_after_first_tick(timeout=None):
1235+
call_count[0] += 1
1236+
if call_count[0] == 1:
1237+
# Change URL but keep same modem_type
1238+
config_mgr.get.side_effect = lambda k, d=None: {
1239+
"modem_type": "fritzbox",
1240+
"modem_url": "http://192.168.100.1",
1241+
"modem_user": "admin",
1242+
"modem_password": "pass",
1243+
"poll_interval": 900,
1244+
}.get(k, d)
1245+
return original_wait(0)
1246+
elif call_count[0] >= 3:
1247+
stop.set()
1248+
return True
1249+
return original_wait(0)
1250+
1251+
stop.wait = change_url_after_first_tick
1252+
1253+
polling_loop(config_mgr, storage, stop)
1254+
1255+
# load_driver called twice: initial + hot-swap for URL change
1256+
assert mock_load.call_count >= 2
1257+
second_call = mock_load.call_args_list[1]
1258+
assert second_call[0][1] == "http://192.168.100.1"
1259+
1260+
@patch("app.drivers.driver_registry.load_driver")
1261+
@patch("app.main.web")
1262+
def test_no_hot_swap_when_config_unchanged(self, mock_web, mock_load):
1263+
"""No hot-swap should occur when modem config hasn't changed."""
1264+
import threading
1265+
from app.main import polling_loop
1266+
1267+
mock_driver = MagicMock()
1268+
mock_driver.get_device_info.return_value = {"model": "Test", "sw_version": "1.0"}
1269+
mock_driver.get_connection_info.return_value = {}
1270+
mock_driver.get_docsis_data.return_value = {}
1271+
mock_load.return_value = mock_driver
1272+
1273+
config_mgr = self._make_config_mgr()
1274+
storage = MagicMock()
1275+
stop = threading.Event()
1276+
1277+
call_count = [0]
1278+
original_wait = stop.wait
1279+
1280+
def stop_after_ticks(timeout=None):
1281+
call_count[0] += 1
1282+
if call_count[0] >= 3:
1283+
stop.set()
1284+
return True
1285+
return original_wait(0)
1286+
1287+
stop.wait = stop_after_ticks
1288+
1289+
polling_loop(config_mgr, storage, stop)
1290+
1291+
# load_driver should only be called once (initial setup)
1292+
assert mock_load.call_count == 1
1293+
# reset_modem_state should NOT have been called (no swap)
1294+
mock_web.reset_modem_state.assert_not_called()

0 commit comments

Comments
 (0)