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:
+58
-2
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user