Find Dante PoE devices by MAC automatically instead of hard-coding ports.

Every 10 minutes the watchdog maps each Dante device: name and IP from
Companion, MAC from NA Ingest's ARP table, switch port and PoE state from
the Netgear switches in dante_switches, saved to dante-map.json. A DANTE hold
on a missing PoE-powered device power-cycles the port it is on now (found
live by MAC, or its last known port if it has aged out and nothing else is
there); never a port with more than 2 devices. Re-patching, adding or
renaming devices needs no config change. First live map: 19 devices, the 4
Studio B boxes on PoE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:31:51 -04:00
co-authored by Claude Opus 5.5
parent c8b9216ea5
commit d06798adf0
5 changed files with 275 additions and 40 deletions
+116 -10
View File
@@ -145,6 +145,8 @@ class Snapshot:
self.disk = {} # path -> free GB
self.listeners = {} # MIDI source name -> coremidi.Listener
self.remote = {} # (host, command) -> True / False / None (could not ask)
self.dante_map = {} # Dante device name -> {"ip", "mac", "switch", "port", "shared", "poe", "seen"}
self.dante_map_time = 0.0 # last successful survey of the Dante switches
def var(self, label, name):
return self.vars.get((label, name))
@@ -455,6 +457,10 @@ RESTARTABLE = ("conn", "hyperdeck", "shure", "psm", "tascam")
# A check's "fix" may name this instead of an entry in "fixes": restart its own Companion
# connection. The Dante button uses it for hardware, which cannot be restarted from here.
RESTART_CONNECTION = "restart-connection"
# The Dante button's catch-all: power-cycle the device's PoE port if the self-updating
# Dante map says it is PoE-powered (found by MAC, so re-patching needs no config change),
# otherwise restart Companion's Dante module.
DANTE_AUTO = "dante-auto"
def needed_variables(cfg):
@@ -599,6 +605,7 @@ class Engine:
self.meta = {} # key -> (tile id, check, sub key)
self.fix_last = {} # fix identity -> last run time
self.executor = execute_fix
self.poe_executor = execute_poe
self.notes = {} # tile id -> hold feedback {"text", "level", "until"}
self.now = 0.0
@@ -754,6 +761,15 @@ class Engine:
fix_id = chk.get("fix")
if isinstance(fix_id, dict):
fix_id = fix_id.get(sub, fix_id.get("*")) # "*": every other sub-check
if fix_id == DANTE_AUTO:
entry = snap.dante_map.get(sub) if chk["type"] == "dante" else None
if entry and entry.get("poe") and entry.get("mac"):
ident = ("poe", entry["mac"])
if ident not in seen:
seen.add(ident)
plan.append((ident, "poe", sub, chk))
continue
fix_id = RESTART_CONNECTION # not PoE-powered, or not mapped yet
if fix_id == RESTART_CONNECTION and chk.get("conn"):
ident = ("restart", chk["conn"]) # e.g. Companion's Dante module
if ident not in seen:
@@ -817,11 +833,15 @@ class Engine:
ok = comp.restart_connection(c["id"])
log.info("RESTART %s -> %s", what, "ok" if ok else "failed")
names.append("RECONNECT")
elif kind == "poe":
log.info("FIX %s: power-cycle %s's PoE port (found by MAC)", tile_id, what)
names.append("POWER CYCLE")
started.append((self.poe_executor, (self.cfg, what, dict(snap.dante_map.get(what, {})))))
else:
fix = self.cfg["fixes"][what]
log.info("FIX %s: %s", tile_id, fix.get("label", what))
names.append(fix.get("short", what.upper()))
started.append((what, chk))
started.append((self.executor, (self.cfg, what, chk)))
if not names:
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4)
return plan
@@ -832,8 +852,8 @@ class Engine:
self.note(tile_id, "FIXING\n" + summary, "fixing", now, 180)
results = []
def work(fix_id, chk):
ok = self.executor(self.cfg, fix_id, chk)
def work(fn, args):
ok = fn(*args)
results.append(ok is not False)
if len(results) == len(started): # the last one to finish reports for all
done = time.time()
@@ -841,8 +861,8 @@ class Engine:
self.note(tile_id, "CHECKING\n" + summary, "fixing", done, 20)
else:
self.note(tile_id, "FIX FAILED\n" + summary, "failed", done, 20)
for fix_id, chk in started:
threading.Thread(target=work, args=(fix_id, chk), daemon=True).start()
for fn, args in started:
threading.Thread(target=work, args=(fn, args), daemon=True).start()
return plan
@@ -1163,6 +1183,90 @@ def hyperdeck_probe(ip, timeout=3, port=9993):
return "answered" if buf else "hung"
# --- The self-updating Dante map: name -> IP -> MAC -> switch port -> PoE --------
def dante_map_path(data_dir):
return os.path.join(data_dir, "dante-map.json") if data_dir else None
def load_dante_map(path):
try:
with open(path) as f:
return json.load(f)
except (OSError, ValueError, TypeError):
return {}
def normalize_mac(mac):
return ":".join("%02x" % int(part, 16) for part in mac.split(":"))
def arp_table():
"""{ip: mac} from this Mac's ARP cache (macOS prints MACs without leading zeros)."""
out = subprocess.run(["/usr/sbin/arp", "-an"], capture_output=True, text=True, timeout=5).stdout
return {ip: normalize_mac(mac) for ip, mac in re.findall(r"\(([\d.]+)\) at ((?:[0-9a-fA-F]{1,2}:){5}[0-9a-fA-F]{1,2}) ", out)}
def dante_label(cfg):
for t in cfg["tiles"]:
for c in t.get("checks", []):
if c["type"] == "dante":
return c["conn"]
return 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
powers it. Kept for devices that later disappear, so a hold can still find them."""
switches, label = cfg.get("dante_switches"), dante_label(cfg)
if not switches or not label:
return
# Polled every 30 s so the first map appears soon after start; a full survey
# of the switches only every dante_map_seconds once one has succeeded.
if time.time() - snap.dante_map_time < cfg.get("dante_map_seconds", 600):
return
try:
present = json.loads(snap.var(label, "devices") or "[]")
except ValueError:
return
ips = arp()
wanted = {}
for name in present:
ip = comp.variable(label, name + "_ip")
if ip and ips.get(ip):
wanted[name] = (ip, ips[ip])
if not wanted:
return
if survey is None:
import netgear
survey = netgear.survey
found = survey(switches, [mac for _, mac in wanted.values()])
new, now = dict(snap.dante_map), int(time.time())
for name, (ip, mac) in wanted.items():
entry = dict(new.get(name, {}), ip=ip, mac=mac, seen=now)
entry.update(found.get(mac, {}))
new[name] = entry
snap.dante_map, snap.dante_map_time = new, time.time()
log.info("dante map: %d devices, %d on PoE ports", len(new), sum(1 for e in new.values() if e.get("poe")))
if path:
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(new, f, indent=1, sort_keys=True)
os.replace(tmp, path)
def execute_poe(cfg, name, entry):
"""Power-cycle a Dante device's PoE port, found by its MAC now (or its last known port)."""
try:
import netgear
host, port, how = netgear.poe_cycle_device(cfg.get("dante_switches", []), entry["mac"], entry)
log.info("FIX power-cycled %s on switch %s port %s (%s)", name, host, port, how)
return True
except Exception as e:
log.warning("FIX power-cycle %s failed: %s", name, e)
return False
def execute_fix(cfg, fix_id, chk):
"""Run one entry from config "fixes" (in its own thread). Only listed commands ever run.
Returns True if the command ran and succeeded."""
@@ -1248,9 +1352,9 @@ def load_config(path):
for c in t.get("checks", []):
fixes = c.get("fix") if isinstance(c.get("fix"), dict) else {"": c.get("fix")}
for f in fixes.values():
if f and f != RESTART_CONNECTION and f not in cfg.get("fixes", {}):
if f and f not in (RESTART_CONNECTION, DANTE_AUTO) and f not in cfg.get("fixes", {}):
raise ValueError("tile %s: unknown fix %s" % (t["id"], f))
if (f and f != RESTART_CONNECTION and cfg["fixes"][f].get("kind", "ssh") == "ssh"
if (f and f not in (RESTART_CONNECTION, DANTE_AUTO) and cfg["fixes"][f].get("kind", "ssh") == "ssh"
and cfg["fixes"][f]["host"] not in cfg.get("hosts", {})):
raise ValueError("fix %s: unknown host" % f)
if c["type"] == "remote" and c["host"] not in cfg.get("hosts", {}):
@@ -1278,9 +1382,9 @@ def make_companion(cfg):
return Companion(cfg["companion"].get("host", "127.0.0.1"), cfg["companion"].get("port", 8000))
def start_pollers(cfg, snap, stop):
def start_pollers(cfg, snap, stop, data_dir=None):
fast, slow = needed_variables(cfg)
c1, c2, c3 = make_companion(cfg), make_companion(cfg), make_companion(cfg)
c1, c2, c3, c4 = (make_companion(cfg) for _ in range(4))
iface = cfg.get("network", {}).get("interface")
threads = [
Periodic("connections", 1.0, lambda: poll_connections(c1, snap), stop),
@@ -1292,6 +1396,7 @@ def start_pollers(cfg, snap, stop):
Periodic("bonjour", 15.0, lambda: poll_mdns(snap), stop),
Periodic("http", 5.0, lambda: poll_http(snap, http_checks(cfg)), stop),
Periodic("remote", 10.0, lambda: poll_remote(snap, cfg), stop),
Periodic("dante-map", 30.0, lambda: poll_dante_map(snap, cfg, c4, dante_map_path(data_dir)), stop),
]
for t in threads:
t.start()
@@ -1335,7 +1440,8 @@ def run(cfg, data_dir, dry_run=False, stop=None):
comp = make_companion(cfg)
snap = Snapshot()
engine = Engine(cfg, os.path.join(data_dir, "service-mode.json"))
threads = start_pollers(cfg, snap, stop)
snap.dante_map = load_dante_map(dante_map_path(data_dir))
threads = start_pollers(cfg, snap, stop, data_dir)
stop.wait(2.5) # let the first polls land before judging anything
last_json, last_write, last_eval = None, 0.0, 0.0
down_since, alerted, warned_missing = None, False, set()