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))