Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions src/controller/modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,33 @@ def module_discovery(self, module: Module) -> None:
"""Called by Network when zeroconf reports a new or updated module."""
if module.id in self._modules:
# Already-known module re-announcing over mDNS (e.g. an avahi TTL
# refresh) -- update_service() in network.py calls
# facade.module_rediscovered() immediately before this, which
# deliberately preserves a RECORDING status; add_module()'s
# wholesale replace with this freshly-constructed, mostly-default
# Module (status=WAITING, config={}, last_heartbeat_time=0.0, ...)
# would immediately undo that protection. Nothing here needs
# re-onboarding: name/version/ip freshness is already handled via
# the narrower update_module_version()/module_ip_changed() paths
# triggered by the module's own status/health reports.
self.logger.info(f"{module.id} re-announced via mDNS — no state change")
# refresh, or the first real mDNS sighting of a module that was
# auto-registered via received_module_config() with ip="" before
# its mDNS broadcast was ever seen) -- update_service() in
# network.py calls facade.module_rediscovered() immediately
# before this, which deliberately preserves a RECORDING status;
# add_module()'s wholesale replace with this freshly-constructed,
# mostly-default Module (status=WAITING, config={},
# last_heartbeat_time=0.0, ...) would immediately undo that
# protection. But mDNS is the only source of network identity
# (ip/port/zeroconf_name) -- module_ip_changed() is never called
# from anywhere else -- so refresh exactly those fields rather
# than no-op'ing entirely; everything else (status/config/
# last_heartbeat_time/etc.) is left untouched.
existing = self._modules[module.id]
identity = (existing.ip, existing.port, existing.zeroconf_name)
new_identity = (module.ip, module.port, module.zeroconf_name)
if identity != new_identity:
existing.ip = module.ip
existing.port = module.port
existing.zeroconf_name = module.zeroconf_name
self.logger.info(
f"{module.id} re-announced via mDNS — refreshed network identity "
f"({identity} -> {new_identity})"
)
self.broadcast_updated_modules()
else:
self.logger.info(f"{module.id} re-announced via mDNS — no state change")
return
self.logger.info(f"Adding new module {module.id}")
self.add_module(module)
Expand Down
50 changes: 50 additions & 0 deletions src/controller/tests/test_modules_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,53 @@ def test_genuinely_new_module_is_still_registered(self):

assert mgr._modules["camera_new"] is new_module
assert "camera_new" in mgr._config_states


class TestModuleDiscoveryRefreshesNetworkIdentity:
"""Regression coverage for a bug found 2026-08-17: a module whose
received_module_config() auto-registered it with ip="" (its ZMQ hello/config
reached the controller before mDNS ever discovered it) stayed at ip="" forever
-- module_discovery()'s early-return for already-known IDs discarded every
subsequent real mDNS announcement's ip/port/zeroconf_name, and
module_ip_changed() (the only other writer of .ip) is never called from
anywhere. Symptom: module shows online with heartbeats but no IP in the
frontend, and the livestream URL (built directly from module.ip) breaks."""

def test_blank_ip_is_corrected_by_a_real_mdns_announcement(self):
mgr = _make_modules()
existing = Module(
id="camera_abc", name="camera_abc", type="camera", version="", ip=""
)
mgr.add_module(existing)
existing.status = ModuleStatus.RECORDING # must survive the refresh

real_announcement = Module(
id="camera_abc", name="camera_abc", type="camera", version="1.0",
ip="10.0.0.237", port=5353, zeroconf_name="camera_abc._module._tcp.local.",
)
mgr.module_discovery(real_announcement)

tracked = mgr._modules["camera_abc"]
assert tracked.ip == "10.0.0.237"
assert tracked.port == 5353
assert tracked.zeroconf_name == "camera_abc._module._tcp.local."
assert tracked.status == ModuleStatus.RECORDING
assert tracked is existing

def test_dhcp_reassigned_ip_is_refreshed_on_re_announcement(self):
mgr = _make_modules()
existing = _register(mgr) # ip="10.0.0.2"
existing.status = ModuleStatus.RECORDING
existing.config = {"camera": {"fps": 30}}

reannounced = Module(
id="camera_abc", name="camera_abc", type="camera",
version="1.0", ip="10.0.0.55",
)
mgr.module_discovery(reannounced)

tracked = mgr._modules["camera_abc"]
assert tracked.ip == "10.0.0.55"
# Non-identity state must still be preserved
assert tracked.status == ModuleStatus.RECORDING
assert tracked.config == {"camera": {"fps": 30}}
Loading