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 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 06:38:27 -04:00
co-authored by Claude Fable 5.1
parent efd9b973eb
commit 87bfe7c162
2 changed files with 92 additions and 0 deletions
+43
View File
@@ -2470,6 +2470,44 @@ def dante_label(cfg):
return None 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): 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 """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.""" 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) except ValueError: # not the main thread (tests)
pass pass
threads = start_pollers(cfg, snap, stop, data_dir) 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"): if cfg.get("ready_page") and not (cfg.get("ready") or {}).get("disabled"):
import readiness import readiness
poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0]) poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0])
+49
View File
@@ -2255,3 +2255,52 @@ class TestCoreMIDI(unittest.TestCase):
if __name__ == "__main__": if __name__ == "__main__":
unittest.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))