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:
+116
-10
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user