Southcoast matches NA, per its startup checklist: X32s, ProPresenter to Lighting MIDI, pages 19/20

- An explicit look or rebuild may apply inside the auto-fix hours (never while live)
- A page move clears the page left behind when only our keys are on it
- A rebuild keeps MIDI links that are down now; first-setup import waits for the keys to land
- ProPresenter port drift fixed automatically (kind propresenter_port)
- sites/sc/config.json kept with the others

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:07:15 -04:00
co-authored by Claude Opus 5.5
parent ee4c97c699
commit bea587cfad
8 changed files with 554 additions and 20 deletions
+58 -2
View File
@@ -44,7 +44,7 @@ log = logging.getLogger("status-panel")
VERSION = "2026.10.15" # logged at start ("status panel running (code ...)") and written to started.json
# Every kind of fix the watchdog can run (validated by load_config).
FIX_KINDS = ("ssh", "local", "atem", "reconnect", "midi_connect", "netgear_poe", "hyperdeck", "spotify", "ready",
"companion_press")
"companion_press", "propresenter_port")
# Failover: a fix marked "failover" (pressing the FOH Backup button when Studio B dies) runs by itself
# inside these windows when the "failover" block names none, "after_seconds" after its fault is confirmed.
FAILOVER_WINDOWS = [{"days": ["sun"], "start": "06:30", "end": "13:30"}]
@@ -502,7 +502,7 @@ def check_midi_peer(chk, snap, ctx):
# Measured 2026-10-07: a joining peer reads "connected" for ~6 s until
# clock sync, then "running". A peer that dies is removed from the list:
# at once if it says goodbye, after ~60 s if it crashes or its cable is pulled.
if p.get("state", "running") in ("running", "connected"): # no state: older macOS, configured
if (p.get("state") or "running") in ("running", "connected"): # no state: older macOS, configured
return [("peer", Result(OK))]
return [("peer", Result(ERR, "MIDI " + (p["state"] or "?").upper()[:8]))]
return [("peer", Result(ERR, "MIDI LOST"))]
@@ -2755,6 +2755,60 @@ def execute_poe(cfg, name, entry):
return False
def propresenter_ports(cfg, fix):
"""The TCP ports ProPresenter listens on, on the Mac it runs on (this one, or fix "on")."""
cmd = "/usr/sbin/lsof -nP -iTCP -sTCP:LISTEN -a -c ProPresen 2>/dev/null"
argv = ssh_cmd(cfg, fix["on"]) + [cmd] if fix.get("on") else ["/bin/zsh", "-c", cmd]
out = subprocess.run(argv, capture_output=True, text=True, timeout=20).stdout
return sorted({int(m.group(1)) for m in re.finditer(r":(\d+) \(LISTEN\)", out)})
def propresenter_port(cfg, fix, label):
"""ProPresenter picked another network port (it does, now and then: Southcoast's checklist says to
copy it by hand; Woonsocket's had moved from 50822 to 49285): find the port that answers its API
and put it into Companion's connection, then let Companion reconnect. Never touches ProPresenter."""
import sqlite3
comp = Companion(cfg["companion"].get("host", "127.0.0.1"), cfg["companion"].get("port", 8000))
conns = {c["label"]: c for c in comp.connections()}
conn = conns.get(fix["conn"])
if not conn:
log.info("FIX %s -> no Companion connection %s", label, fix["conn"])
return False
path = companion_db_path(cfg)
db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2)
try:
conf = next((json.loads(v).get("config") or {} for (v,) in db.execute("SELECT value FROM instances")
if json.loads(v).get("label") == fix["conn"]), {})
finally:
db.close()
host = conf.get("host") or "127.0.0.1"
found = None
for port in propresenter_ports(cfg, fix):
try:
with urllib.request.urlopen("http://%s:%d/version" % (host, port), timeout=2) as r:
if "api_version" in r.read().decode("utf-8", "replace"):
found = port
break
except (OSError, ValueError):
continue
if not found:
log.info("FIX %s -> ProPresenter's network API answers on no port (is ProPresenter open, Network on?)", label)
return "PROP API\nNOT ON"
if int(conf.get("port") or 0) == found:
ok = comp.restart_connection(conn["id"])
log.info("FIX %s -> port %d is already right; connection restarted %s", label, found, "ok" if ok else "FAILED")
return ok
import push_page
ws = push_page.WebSocket(cfg["companion"].get("host", "127.0.0.1"), int(cfg["companion"].get("port", 8000)), "/trpc")
try:
push_page.Trpc(ws).mutation("instances.connections.setConfig", {"connectionId": conn["id"], "label": fix["conn"],
"config": dict(conf, port=found)})
finally:
ws.close()
log.info("FIX %s -> ProPresenter's API moved from port %s to %d: Companion's %s now uses %d", label, conf.get("port"), found, fix["conn"], found)
return True
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."""
@@ -2798,6 +2852,8 @@ def execute_fix(cfg, fix_id, chk):
return ok
if kind == "reconnect":
return reconnect_and_verify(cfg, fix, label)
if kind == "propresenter_port":
return propresenter_port(cfg, fix, label)
if kind == "midi_connect":
# Like Connect in Audio MIDI Setup: add the peer back to the session it dropped out of.
args = (chk["session"], chk["peer"], chk["address"])