From c8b9216ea521ed8bab87528db7afebc40a63b750 Mon Sep 17 00:00:00 2001 From: christian Date: Thu, 8 Oct 2026 13:12:45 -0400 Subject: [PATCH] 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 --- README.md | 4 +++- config.json | 11 +++++++++-- install.sh | 2 +- netgear.py | 36 ++++++++++++++++++++++++++++++++++++ status_panel.py | 6 ++++++ tests/test_status_panel.py | 25 ++++++++++++++++++++++++- 6 files changed, 79 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5f07c15..2d3322e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/config.json b/config.json index 43d7d43..e422259 100644 --- a/config.json +++ b/config.json @@ -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", diff --git a/install.sh b/install.sh index 5a22cbd..24e13f0 100644 --- a/install.sh +++ b/install.sh @@ -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" < diff --git a/netgear.py b/netgear.py index fdd28f6..40f7d09 100644 --- a/netgear.py +++ b/netgear.py @@ -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__) diff --git a/status_panel.py b/status_panel.py index f6a9389..31b1f85 100644 --- a/status_panel.py +++ b/status_panel.py @@ -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) diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index 67648e3..ce26901 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -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: