From 87bfe7c1620edcfb83905da77adfbacf5f3daa8a Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 18 Oct 2026 06:38:27 -0400 Subject: [PATCH] A Dante device Companion's module never listed before gets the Dante Controller connection restarted once The module only reads a device's channels when it starts, so a receiver added later is missing from the crosspoint lists until the connection restarts (the two AD4Q receivers today). The watchdog now remembers every Dante device it has seen in state.json and, when a newcomer appears, restarts that connection once: never during a live service, at most every ten minutes, and never for a device that merely dropped out and came back. Co-Authored-By: Claude Fable 5.1 --- status_panel.py | 43 +++++++++++++++++++++++++++++++++ tests/test_status_panel.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/status_panel.py b/status_panel.py index 16b4fe5..6ea1d00 100644 --- a/status_panel.py +++ b/status_panel.py @@ -2470,6 +2470,44 @@ def dante_label(cfg): return None +def watch_dante_newcomers(snap, cfg, comp, engine, now=None): + """A Dante device Companion's module has never listed before: restart that connection once, so + the module reads the newcomer's channels (its crosspoint lists skip a device it only discovered + after it started). Devices seen so far are kept in state.json; never during a live service; + at most once every ten minutes.""" + label = dante_label(cfg) + if not label: + return False + try: + present = set(json.loads(snap.var(label, "devices") or "[]")) + except (ValueError, TypeError): + return False + if not present: + return False + now = now or time.time() + known = set(engine.state.get("dante_known", []) or []) + if not known: + engine.state.set("dante_known", sorted(present)) + return False + new = present - known + if not new: + return False + if engine.live_now(now)[0]: + return False # after the service; the device stays new until then + if now - (engine.state.get("dante_known_restart", 0) or 0) < 600: + return False + c = snap.conns.get(label) + if not (c and c.get("enabled")): + return False + ok = comp.restart_connection(c["id"]) + log.info("AUTO dante: new device%s %s - reconnecting %s so its channels are known -> %s", + "s" if len(new) > 1 else "", ", ".join(sorted(new)), label, "ok" if ok else "failed") + engine.state.set("dante_known_restart", now) + if ok: + engine.state.set("dante_known", sorted(known | present)) + return ok + + def poll_dante_map(snap, cfg, comp, path, arp=arp_table, survey=None): """Every Dante device that is present now: where it is plugged in and whether that port powers it. Kept for devices that later disappear, so a hold can still find them.""" @@ -3201,6 +3239,11 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None): except ValueError: # not the main thread (tests) pass threads = start_pollers(cfg, snap, stop, data_dir) + if not dry_run: + c5 = make_companion(cfg) + t = Periodic("dante-newcomers", 60.0, lambda: watch_dante_newcomers(snap, cfg, c5, engine), stop) + t.start() + threads.append(t) if cfg.get("ready_page") and not (cfg.get("ready") or {}).get("disabled"): import readiness poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0]) diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index e261adf..68017f2 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -2255,3 +2255,52 @@ class TestCoreMIDI(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class TestDanteNewcomers(unittest.TestCase): + """A Dante device the module never listed before gets the Dante Controller connection restarted once.""" + + def setUp(self): + self.snap = healthy_snapshot(time.time()) + self.engine = sp.Engine(CONFIG, None) + self.restarts = [] + me = self + + class Comp: + def restart_connection(self, cid): + me.restarts.append(cid); return True + self.comp = Comp() + self.label = sp.dante_label(CONFIG) + self.snap.conns[self.label] = {"id": "dante-1", "enabled": True, "category": "good"} + + def devices(self, names): + self.snap.vars[(self.label, "devices")] = json.dumps(names) + + def test_first_list_is_remembered_then_a_newcomer_restarts_once(self): + now = time.time() + self.devices(["A", "B"]) + self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now)) + self.assertEqual(self.engine.state.get("dante_known"), ["A", "B"]) + self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 60)) # nothing new + self.devices(["A", "B", "BOH-AD4Q-A"]) + self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 120)) + self.assertEqual(self.restarts, ["dante-1"]) + self.assertEqual(self.engine.state.get("dante_known"), ["A", "B", "BOH-AD4Q-A"]) + self.devices(["A", "B", "BOH-AD4Q-A", "X"]) + self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 180)) # ten-minute cooldown + self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 800)) + self.assertEqual(len(self.restarts), 2) + self.devices(["A", "B"]) # devices going away never restart + self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 2000)) + self.devices(["A", "B", "BOH-AD4Q-A"]) # nor coming back + self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 2600)) + + def test_waits_for_the_service_to_end(self): + now = time.time() + self.devices(["A"]) + sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now) + self.devices(["A", "NEW"]) + self.engine.any_recording = True + self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 100)) + self.engine.any_recording = False + self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 200))