A DANTE hold power-cycles a missing Studio B PoE box on its Netgear switch port.
STUDIO-B-SOLO-MON, MON, TB and TB-MON map to ports 0/14-0/17 on the Netgear M4250 Dante switch 10.66.1.102. netgear.py poe_cycle first checks the port's MAC table and refuses if a different device is plugged in there, then runs poe reset. Tested 2026-10-08 13:10 on SOLO-MON: dropped off Dante and was back about 17 s later. install.sh now copies netgear.py too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,9 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
|
||||
|---|---|
|
||||
| APPS: PROPRES OFFLINE / NOT RUNNING | restarts ProPresenter on its Mac (the newest version installed) |
|
||||
| APPS: ABLETON / TRANSLATE NOT RUNNING | starts Ableton on the Tracks Mac / Church Translate on NA Translation |
|
||||
| DANTE: PROP MAC / TRACKS / TRANSL GONE | reopens Dante Virtual Soundcard on that Mac (only helps if DVS starts its engine when opened) |
|
||||
| DANTE: PROP MAC / TRACKS / TRANSL / INGEST GONE | starts Dante Virtual Soundcard's engine on that Mac (`remote-access/mac-dvs-helper.sh`) |
|
||||
| DANTE: STB MON / SOLO / TB / TB MON GONE | power-cycles that box's PoE port on the Netgear Dante switch 10.66.1.102 (ports 0/14–0/17), after checking it is still that box on the port (`netgear.py`) |
|
||||
| DANTE: any other device GONE | restarts Companion's Dante module (hardware: if still gone, it is off or unplugged) |
|
||||
| INGEST: BRIDGE / CAPTIONS / PRODDASH | restarts that service on NA Ingest |
|
||||
| HD1–6 lost by Companion | reboots that deck. Never one that is recording, and never for NO DISK or NOT REC |
|
||||
| anything else with a Companion connection | restarts that Companion connection |
|
||||
|
||||
+9
-2
@@ -48,7 +48,11 @@
|
||||
"captions": { "kind": "local", "short": "CAPTIONS", "label": "restart lobby captions",
|
||||
"run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions.plist" },
|
||||
"proddash": { "kind": "local", "short": "PRODDASH", "label": "start ProdDash", "run": "open /Applications/ProdDash.app" },
|
||||
"hyperdeck": { "kind": "hyperdeck", "short": "HYPERDECK", "label": "reconnect Companion to the HyperDeck, or reboot the deck only if it is hung" }
|
||||
"hyperdeck": { "kind": "hyperdeck", "short": "HYPERDECK", "label": "reconnect Companion to the HyperDeck, or reboot the deck only if it is hung" },
|
||||
"poe_stb_solo": { "kind": "netgear_poe", "switch": "10.66.1.102", "port": "0/14", "mac": "00:1d:c1:51:34:c8", "short": "POWER CYCLE", "label": "power-cycle STUDIO-B-SOLO-MON (Dante switch 10.66.1.102 port 0/14)" },
|
||||
"poe_stb_mon": { "kind": "netgear_poe", "switch": "10.66.1.102", "port": "0/15", "mac": "00:1d:c1:51:18:84", "short": "POWER CYCLE", "label": "power-cycle STUDIO-B-MON (Dante switch 10.66.1.102 port 0/15)" },
|
||||
"poe_stb_tb": { "kind": "netgear_poe", "switch": "10.66.1.102", "port": "0/16", "mac": "00:1d:c1:89:1a:0a", "short": "POWER CYCLE", "label": "power-cycle STUDIO-B-TB talkback (Dante switch 10.66.1.102 port 0/16)" },
|
||||
"poe_stb_tbmon": { "kind": "netgear_poe", "switch": "10.66.1.102", "port": "0/17", "mac": "00:1d:c1:51:34:ea", "short": "POWER CYCLE", "label": "power-cycle STUDIO-B-TB-MON (Dante switch 10.66.1.102 port 0/17)" }
|
||||
},
|
||||
|
||||
"tiles": [
|
||||
@@ -72,7 +76,10 @@
|
||||
{ "id": "dante", "label": "DANTE", "pos": [2, 2], "checks": [
|
||||
{ "type": "dante", "conn": "audinate-dantecontroller",
|
||||
"fix": { "NA-ProPresenter-Mac": "dvs_prop", "NA-Tracks": "dvs_tracks", "NA-Translation": "dvs_translation",
|
||||
"STUDIO-A-Ingest": "dvs_ingest", "*": "restart-connection" },
|
||||
"STUDIO-A-Ingest": "dvs_ingest",
|
||||
"STUDIO-B-SOLO-MON": "poe_stb_solo", "STUDIO-B-MON": "poe_stb_mon",
|
||||
"STUDIO-B-TB": "poe_stb_tb", "STUDIO-B-TB-MON": "poe_stb_tbmon",
|
||||
"*": "restart-connection" },
|
||||
"devices": {
|
||||
"FOH-DiGiCo-DMI-1": "DMI-1", "FOH-DiGiCo-DMI-2": "DMI-2", "BOH-WING": "WING", "BOH-DQ-Rack": "DQ RACK",
|
||||
"BOH-AD4Q-A": "AD4Q-A", "BOH-AD4Q-B": "AD4Q-B", "STUDIO-A-X32": "STA X32",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ mkdir -p "$DEST"
|
||||
if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC/config.json" "$DEST/config.json"; then
|
||||
cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
fi
|
||||
cp "$SRC/status_panel.py" "$SRC/coremidi.py" "$SRC/config.json" "$DEST/"
|
||||
cp "$SRC/status_panel.py" "$SRC/coremidi.py" "$SRC/netgear.py" "$SRC/config.json" "$DEST/"
|
||||
|
||||
cat > "$PLIST" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
+36
@@ -10,6 +10,8 @@ unlocked inside the logged-in session, so run this from a LaunchAgent or a
|
||||
Terminal there, not over SSH.
|
||||
|
||||
python3 netgear.py lookup 00:1d:c1:51:18:84 ... which switch / port each MAC is on (read-only)
|
||||
python3 netgear.py poe-status 10.66.1.102 0/14 PoE state of ports (read-only)
|
||||
python3 netgear.py poe-cycle 10.66.1.102 0/14 [MAC] power-cycle one PoE port (refuses if MAC is elsewhere)
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
@@ -102,10 +104,44 @@ def poe_status(host, ports):
|
||||
sw.close()
|
||||
|
||||
|
||||
MAC_RE = re.compile(r"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
|
||||
|
||||
|
||||
def poe_cycle(host, port, expect_mac=None):
|
||||
"""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.
|
||||
|
||||
With expect_mac, refuse if the port has been re-patched: if the switch has learned
|
||||
other MACs on that port and not this one, a different device is plugged in there.
|
||||
(No MACs at all is fine: a dead device ages out of the table.)"""
|
||||
if not re.fullmatch(r"\d+/\d+", port):
|
||||
raise ValueError("one port like 0/14, got %r" % port)
|
||||
user, password = credentials()
|
||||
sw = Switch(host, user, password)
|
||||
try:
|
||||
if expect_mac:
|
||||
seen = {m.lower() for m in MAC_RE.findall(sw.run("show mac-addr-table interface %s" % port))}
|
||||
if seen and expect_mac.lower() not in seen:
|
||||
raise RuntimeError("%s %s has %s, not %s - re-patched? not power-cycling"
|
||||
% (host, port, ", ".join(sorted(seen)), expect_mac.lower()))
|
||||
sw.run("configure")
|
||||
sw.run("interface %s" % port)
|
||||
answer = sw.run("poe reset")
|
||||
sw.run("exit")
|
||||
sw.run("exit")
|
||||
finally:
|
||||
sw.close()
|
||||
if re.search(r"(?i)invalid|error|fail|unrecognized", answer):
|
||||
raise RuntimeError("%s %s: switch refused poe reset: %s" % (host, port, answer.strip()))
|
||||
print("POE RESET %s %s ok%s" % (host, port, (": " + answer.strip()) if answer.strip() else ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "lookup":
|
||||
lookup(sys.argv[2:])
|
||||
elif len(sys.argv) >= 4 and sys.argv[1] == "poe-status":
|
||||
poe_status(sys.argv[2], sys.argv[3:])
|
||||
elif len(sys.argv) in (4, 5) and sys.argv[1] == "poe-cycle":
|
||||
poe_cycle(sys.argv[2], sys.argv[3], sys.argv[4] if len(sys.argv) == 5 else None)
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
|
||||
@@ -1176,6 +1176,12 @@ def execute_fix(cfg, fix_id, chk):
|
||||
log.info("FIX %s -> %s%s", label, "ok" if r.returncode == 0 else "exit %d" % r.returncode,
|
||||
(": " + detail) if detail else "")
|
||||
return r.returncode == 0
|
||||
if kind == "netgear_poe":
|
||||
# Netgear AV switch on the Dante network: power-cycle one PoE port (netgear.py).
|
||||
import netgear
|
||||
netgear.poe_cycle(fix["switch"], fix["port"], fix.get("mac"))
|
||||
log.info("FIX %s -> switch %s port %s power-cycled", label, fix["switch"], fix["port"])
|
||||
return True
|
||||
if kind == "hyperdeck":
|
||||
port = chk.get("port", 9993)
|
||||
verdict = hyperdeck_probe(chk["ip"], port=port)
|
||||
|
||||
@@ -538,6 +538,29 @@ class TestFixes(unittest.TestCase):
|
||||
tiles = self.engine.step(self.snap, time.time())
|
||||
self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nFIX FAILED\nDVS", "failed"))
|
||||
|
||||
def test_studio_b_poe_boxes_are_power_cycled(self):
|
||||
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
|
||||
devices.remove("STUDIO-B-MON")
|
||||
devices.remove("STUDIO-B-TB")
|
||||
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(devices)
|
||||
self.assertEqual(self.plan("dante"), [("fix", "poe_stb_mon"), ("fix", "poe_stb_tb")])
|
||||
for fid in ("poe_stb_solo", "poe_stb_mon", "poe_stb_tb", "poe_stb_tbmon"):
|
||||
f = CONFIG["fixes"][fid]
|
||||
self.assertEqual(f["switch"], "10.66.1.102")
|
||||
self.assertRegex(f["mac"], r"^00:1d:c1:")
|
||||
|
||||
def test_netgear_fix_calls_the_switch_with_the_mac_guard(self):
|
||||
import types
|
||||
calls = []
|
||||
fake = types.ModuleType("netgear")
|
||||
fake.poe_cycle = lambda host, port, mac=None: calls.append((host, port, mac))
|
||||
sys.modules["netgear"] = fake
|
||||
try:
|
||||
self.assertTrue(sp.execute_fix(CONFIG, "poe_stb_solo", {}))
|
||||
finally:
|
||||
del sys.modules["netgear"]
|
||||
self.assertEqual(calls, [("10.66.1.102", "0/14", "00:1d:c1:51:34:c8")])
|
||||
|
||||
def test_ingest_dvs_and_no_auto_fix(self):
|
||||
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
|
||||
devices.remove("STUDIO-A-Ingest")
|
||||
@@ -565,7 +588,7 @@ class TestFixes(unittest.TestCase):
|
||||
|
||||
def test_every_fix_is_valid(self):
|
||||
for f in CONFIG["fixes"].values():
|
||||
self.assertIn(f.get("kind", "ssh"), ("ssh", "local", "hyperdeck"))
|
||||
self.assertIn(f.get("kind", "ssh"), ("ssh", "local", "hyperdeck", "netgear_poe"))
|
||||
|
||||
|
||||
class FakeDeck:
|
||||
|
||||
Reference in New Issue
Block a user