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:
+74
@@ -108,6 +108,7 @@ MAC_RE = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||
|
||||
|
||||
def poe_cycle(host, port, expect_mac=None):
|
||||
# Used directly only for a known port; poe_cycle_device finds the port by MAC.
|
||||
"""Power-cycle PoE on ONE port ("poe reset" in Interface Config mode). Nothing is saved
|
||||
to the switch's startup config: the port's settings are unchanged afterwards.
|
||||
|
||||
@@ -136,6 +137,79 @@ def poe_cycle(host, port, expect_mac=None):
|
||||
print("POE RESET %s %s ok%s" % (host, port, (": " + answer.strip()) if answer.strip() else ""))
|
||||
|
||||
|
||||
# --- Find devices by MAC, wherever they are plugged in ---------------------
|
||||
|
||||
TABLE_RE = re.compile(r"((?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2})\s+(\d+/\d+)")
|
||||
MAX_SHARED = 2 # more devices than this on one port means an uplink: never power-cycle it
|
||||
|
||||
|
||||
def parse_mac_table(text):
|
||||
"""'show mac-addr-table' -> ({mac: port}, {port: number of MACs learned on it})."""
|
||||
where, counts = {}, {}
|
||||
for mac, port in TABLE_RE.findall(text or ""):
|
||||
where[mac.lower()] = port
|
||||
counts[port] = counts.get(port, 0) + 1
|
||||
return where, counts
|
||||
|
||||
|
||||
def best_ports(tables, macs):
|
||||
"""tables: {switch: table text}. -> {mac: (switch, port, devices on that port)}.
|
||||
A MAC is seen on every switch it crosses; its own port is where it has the fewest
|
||||
neighbours (on the far switch it shows up on the uplink with everything else)."""
|
||||
best = {}
|
||||
for host, text in tables.items():
|
||||
where, counts = parse_mac_table(text)
|
||||
for mac in macs:
|
||||
if mac in where:
|
||||
cand = (host, where[mac], counts[where[mac]])
|
||||
if mac not in best or cand[2] < best[mac][2]:
|
||||
best[mac] = cand
|
||||
return best
|
||||
|
||||
|
||||
def poe_delivering(text):
|
||||
return bool(re.search(r"(?i)delivering power", text or ""))
|
||||
|
||||
|
||||
def survey(switches, macs):
|
||||
"""Read-only: {mac: {"switch", "port", "shared", "poe"}} for every MAC found."""
|
||||
macs = [m.lower() for m in macs]
|
||||
user, password = credentials()
|
||||
sessions, tables = {}, {}
|
||||
try:
|
||||
for host in switches:
|
||||
sessions[host] = Switch(host, user, password)
|
||||
tables[host] = sessions[host].run("show mac-addr-table")
|
||||
found = {}
|
||||
for mac, (host, port, shared) in best_ports(tables, macs).items():
|
||||
poe = shared <= MAX_SHARED and poe_delivering(sessions[host].run("show poe port info %s" % port))
|
||||
found[mac] = {"switch": host, "port": port, "shared": shared, "poe": poe}
|
||||
return found
|
||||
finally:
|
||||
for sw in sessions.values():
|
||||
sw.close()
|
||||
|
||||
|
||||
def poe_cycle_device(switches, mac, last_known=None):
|
||||
"""Power-cycle the PoE port a device is on NOW, found by its MAC. If it has aged out of
|
||||
the switch tables (dead long enough), use its last known port - poe_cycle then refuses
|
||||
unless that port is empty of other devices. Never touches a port shared by more than
|
||||
MAX_SHARED devices. Returns (switch, port, how it was found)."""
|
||||
mac = mac.lower()
|
||||
live = survey(switches, [mac]).get(mac)
|
||||
if live:
|
||||
if live["shared"] > MAX_SHARED:
|
||||
raise RuntimeError("%s is behind %s port %s with %d devices (an uplink) - not power-cycling"
|
||||
% (mac, live["switch"], live["port"], live["shared"]))
|
||||
host, port, how = live["switch"], live["port"], "found now"
|
||||
elif last_known and last_known.get("switch") and last_known.get("port"):
|
||||
host, port, how = last_known["switch"], last_known["port"], "last known port"
|
||||
else:
|
||||
raise RuntimeError("%s is not on any Dante switch and has no last known port" % mac)
|
||||
poe_cycle(host, port, expect_mac=mac)
|
||||
return host, port, how
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "lookup":
|
||||
lookup(sys.argv[2:])
|
||||
|
||||
Reference in New Issue
Block a user