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
+8 -2
View File
@@ -443,9 +443,15 @@ class Applier:
if not ok:
return False, "page %d: %s" % (page, why)
push_page.push(host, port, page, path, expected)
after = push_page.fetch_page(host, port, page)
want = {(r, c) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if ctl and ctl.get("type") == "button-layered"}
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
# Companion places the keys a moment after the import call returns (Southcoast's first setup
# was judged too early, 2026-10-21): look again for up to 10 s before calling it a failure.
for _ in range(20):
after = push_page.fetch_page(host, port, page)
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
if want == have:
break
time.sleep(0.5)
if want != have:
return False, "page %d: the import did not land as built (%d keys built, %d on the page)" % (page, len(want), len(have))
done.append(name)
+54 -2
View File
@@ -283,14 +283,23 @@ def items(inv, nets):
"checks": [{"type": spec["kind"], "conn": label, "channels": n}, {"type": "ping", "conn": label}], "fixes": {}})
continue
checks = [{"type": "conn", "conn": label}]
fixes = {}
if conn.get("module") == "renewedvision-propresenter-api":
# ProPresenter changes its network port now and then: the fix finds the new one.
fid = slug("prop_port_" + label)
where = computer_by_ip(inv, ip_of(conn.get("host")) or conn.get("host"))
fixes[fid] = dict({"kind": "propresenter_port", "template": "propresenter_port", "params": {}, "conn": label, "short": "PROP PORT",
"label": "find ProPresenter's network port and reconnect Companion's %s to it" % label}, **({"on": where} if where else {}))
checks[0]["fix"] = fid
if spec.get("ping") and conn.get("host"):
checks.append({"type": "ping", "conn": label})
app_item = apps_at.get((spec.get("app"), ip_of(conn.get("host")))) if spec.get("app") else None
if app_item: # the program's own key carries its connection too
app_item["checks"] = checks + app_item["checks"]
app_item["fixes"].update(fixes)
app_item["also"].append(ident)
continue
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": {}, "restart": [], "also": []})
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": fixes, "restart": [], "also": []})
# Network MIDI: every peer in the sessions here and on the other Macs, at its wired address.
midi_checks = []
# Whose session a peer is, by its name: a peer joined by name or by an IPv6 address gets that Mac's wired address.
@@ -555,6 +564,49 @@ def merge_parts(cfg, it, fresh):
return words
def keep_from(new, old):
"""A rebuild keeps what is down right now and so was not found: MIDI links that dropped (Southcoast's
ProPresenter to Lighting lost its MIDI key, 2026-10-21) and computers that are off, with their keys."""
cfg = copy.deepcopy(new)
have = referenced(cfg)
kept = []
# Computers in the old setup that did not answer this time, and the keys that check them.
gone = {a: h for a, h in (old.get("hosts") or {}).items() if not h.get("self") and a not in cfg["hosts"]}
for alias, h in gone.items():
cfg["hosts"][alias] = h
for t in old.get("tiles", []):
if t.get("checks") and all(c.get("host") == alias or (c.get("params") or {}).get("host") == alias for c in t["checks"]) \
and t["id"] not in {x["id"] for x in cfg["tiles"]}:
for c in t["checks"]:
for fid in ([c["fix"]] if isinstance(c.get("fix"), str) else []):
if fid in old.get("fixes", {}):
cfg["fixes"].setdefault(fid, old["fixes"][fid])
place_new(cfg, {k: v for k, v in t.items() if k != "pos"})
kept.append(t["label"])
for k in (old.get("restart_page") or {}).get("keys", []):
f = old.get("fixes", {}).get(k.get("fix"), {})
if (f.get("host") == alias or (f.get("params") or {}).get("host") == alias) and cfg.get("restart_page"):
cfg["fixes"].setdefault(k["fix"], f)
restart_layout(cfg, [{"label": k["label"], "fix": k["fix"]}])
# MIDI links that are down now.
lost = [c for t in old.get("tiles", []) for c in t.get("checks", []) if c.get("type") == "midi_peer"
and "midi:%s|%s|%s" % (c.get("host", ""), c["session"], c["peer"]) not in have and (not c.get("host") or c["host"] in cfg["hosts"])]
if lost:
midi = next((t for t in cfg["tiles"] if any(c.get("type") in ("midi_peer", "midi_wired") for c in t.get("checks", []))), None)
if midi is None:
wired = [c for t in old.get("tiles", []) for c in t.get("checks", []) if c.get("type") == "midi_wired"]
midi = {"id": slug("midi", {t["id"] for t in cfg["tiles"]}), "label": "MIDI", "checks": wired[:1], "_group": "SHOW"}
place_new(cfg, midi)
midi = cfg["tiles"][-1]
at = next((i for i, c in enumerate(midi["checks"]) if c.get("type") == "midi_wired"), len(midi["checks"]))
midi["checks"][at:at] = lost
cfg["fixes"].setdefault("midi_reconnect", dict(SHARED_FIXES["midi_reconnect"]))
if "midi_reconnect" not in cfg["auto_fix"]["fixes"]:
cfg["auto_fix"]["fixes"].append("midi_reconnect")
kept += ["MIDI link %s" % c["peer"] for c in lost]
return cfg, kept
def finish(cfg):
"""Fixes and remote checks built by their builders, words filled in, as the page would."""
for fid, f in list(cfg["fixes"].items()):
@@ -578,7 +630,7 @@ def gentle(cfg):
for fid, f in cfg["fixes"].items():
if fid.startswith("restart_") or f.get("restart_ok") or f.get("never_auto"):
continue
if f.get("kind") in ("hyperdeck", "midi_connect", "reconnect") or (f.get("template") in ("mac_start", "win_start", "ableton")
if f.get("kind") in ("hyperdeck", "midi_connect", "reconnect", "propresenter_port") or (f.get("template") in ("mac_start", "win_start", "ableton")
and not (f.get("params") or {}).get("restart")):
out.append(fid)
return out
+2
View File
@@ -219,6 +219,8 @@ TEMPLATES = {
"suggest": lambda p, cfg: "reconnect Companion to it"},
"hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"},
"propresenter_port": {"words": "Follow ProPresenter's network port (when it changes) and reconnect Companion", "kind": "propresenter_port",
"start_only": True, "params": [], "render": None, "suggest": lambda p, cfg: "find ProPresenter's network port and reconnect Companion to it"},
"midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"},
"atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None,
+56 -13
View File
@@ -211,7 +211,7 @@ def setup(data_dir, cid, name=None, say=print, home=None):
return new
def rebuild(data_dir, say=print):
def rebuild(data_dir, say=print, pages=None):
"""A campus's setup made again from what is found now (today's recipes), keeping its name, PIN,
pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one)."""
from settings import server
@@ -224,7 +224,17 @@ def rebuild(data_dir, say=print):
rp = (cfg.get("restart_page") or {}).get("page")
if not rp:
rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"])
new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), (cfg["page"], rp))
pages = tuple(pages or (cfg["page"], rp))
if pages != (cfg["page"], rp): # moving (19/20 like every campus): only onto pages that are free or ours
comp = cfg.get("companion") or {}
for p in pages:
ok, why = push_page.safe_target(push_page.fetch_page(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), p))
if not ok:
raise RuntimeError("page %d: %s" % (p, why))
new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), pages)
new, kept = autoconf.keep_from(new, cfg)
if kept:
say("Kept (down right now, so not found): %s" % ", ".join(kept))
for k in ("contacts", "live_services", "ingest_name", "settings_port"):
if k in s:
new["site"][k] = s[k]
@@ -238,11 +248,29 @@ def rebuild(data_dir, say=print):
st = wait_applied(site.applier)
save_seen(data_dir, load_seen(data_dir) | set(seen))
say("%s: %s" % (st.get("state"), st.get("outcome", "")))
if st.get("state") == "done":
for p in sorted({cfg["page"], rp} - set(pages)):
say("Old page %d: %s" % (p, clear_old_page(cfg, p)))
say("Keys: %s" % ", ".join(t["label"] for t in new["tiles"] if not t.get("kind")))
say("Restart page %s: %s" % (rp, ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet"))
say("Restart page %s: %s" % (new.get("restart_page", {}).get("page"), ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet"))
return new
def clear_old_page(cfg, page):
"""A page left behind by a move is emptied, but only when it holds nothing a person made."""
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
ok, why = push_page.safe_target(push_page.fetch_page(host, port, page))
if not ok:
return "left as it is (%s)" % why
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.clearPage", {"pageNumber": page})
finally:
ws.close()
return "cleared"
def upgrade_companion(base, say=print):
"""Companion 5.0.7 from `base` (North Attleboro's settings page), as Woonsocket was upgraded on 2026-10-20:
the running Companion is quit, the newest settings folder is copied for 5.0 (and marked newest, or the
@@ -305,13 +333,14 @@ def record(data_dir, result):
# --- every ten minutes: what is new ------------------------------------------------------
def blocked(cfg, applier, now=None):
"""Why auto-setup may not apply right now, or None: never during a service or a recording."""
def blocked(cfg, applier, now=None, asked=False):
"""Why auto-setup may not apply right now, or None: never during a recording; by itself, never in the
auto-fix hours either (a person asking - Look now, setting up on site - may)."""
now = now or time.time()
live, why = applier.live()
if live:
return "live now (%s)" % why
if sp.in_windows((cfg.get("auto_fix") or {}).get("windows") or [], now):
if not asked and sp.in_windows((cfg.get("auto_fix") or {}).get("windows") or [], now):
return "inside the auto-fix hours (a service day): waits until they end"
if applier.busy():
return "an Apply is running"
@@ -327,7 +356,7 @@ def look_and_add(site, say=lambda s: None, force=False):
result = {"at": int(time.time()), "added": [], "waiting": [], "inventory": summary(inv)}
if added:
auto = (cfg.get("discover") or {}).get("auto")
why = blocked(cfg, site.applier)
why = blocked(cfg, site.applier, asked=force)
if not (auto or force):
result["waiting"] = added
result["why"] = "adding by itself is off (Settings > Site)"
@@ -449,10 +478,14 @@ if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
echo
echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely."
fi
# This Mac's wired address (the default route's port, not en0: at Southcoast en0 is a Dante-style link).
IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}')
ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname)
echo
echo "Next, on each other computer here (ProPresenter, Tracks, Lighting...):"
echo " Mac: open Terminal and paste curl -fsSL http://$(ipconfig getifaddr en0 2>/dev/null || hostname):8020/join | sh"
echo " PC: open PowerShell as administrator and paste irm http://$(ipconfig getifaddr en0 2>/dev/null || hostname):8020/join.ps1 | iex"
echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh"
echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)"
echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex"
""" % {"name": name, "base": shq(base), "key": shq(key), "cid": cid, "qname": shq(name)}
@@ -464,8 +497,17 @@ def join_mac(cfg, base):
return """#!/bin/sh
# Joins this Mac to %(ingest)s's status panel: lets it log in with its key (no password) and says this Mac is here.
KEY=%(key)s
mkdir -p "$HOME/.ssh" && touch "$HOME/.ssh/authorized_keys" && chmod 700 "$HOME/.ssh" && chmod 600 "$HOME/.ssh/authorized_keys"
grep -qF "$KEY" "$HOME/.ssh/authorized_keys" || echo "$KEY" >> "$HOME/.ssh/authorized_keys"
# Run as root (Apple Remote Desktop > Send UNIX Command): it is for whoever is logged in on this Mac.
U=$(id -un); H="$HOME"
if [ "$(id -u)" = 0 ]; then
U=$(stat -f%%Su /dev/console)
[ "$U" = root ] && { echo "Nobody is logged in on $(scutil --get ComputerName): log in, then send this again."; exit 1; }
H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}')
fi
mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys"
grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys"
[ "$(id -u)" = 0 ] && chown -R "$U:staff" "$H/.ssh"
[ "$(id -u)" = 0 ] && { /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1 || /bin/launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1; }
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
echo "Remote Login is off. Turning it on (it asks for this Mac's administrator password):"
if ! sudo /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1; then
@@ -476,7 +518,7 @@ if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
fi
esc() { printf '%%s' "$1" | sed 's/[\\\\"]//g'; }
IPS=$(/sbin/ifconfig | awk '/inet /{print $2}' | grep -v '^127\\.' | sed 's/.*/"&"/' | paste -sd, -)
BODY="{\\"user\\":\\"$(esc "$(id -un)")\\",\\"name\\":\\"$(esc "$(scutil --get ComputerName)")\\",\\"hostname\\":\\"$(esc "$(scutil --get LocalHostName).local")\\",\\"os\\":\\"mac\\",\\"ips\\":[$IPS]}"
BODY="{\\"user\\":\\"$(esc "$U")\\",\\"name\\":\\"$(esc "$(scutil --get ComputerName)")\\",\\"hostname\\":\\"$(esc "$(scutil --get LocalHostName).local")\\",\\"os\\":\\"mac\\",\\"ips\\":[$IPS]}"
if /usr/bin/curl -fsS -X POST -H 'Content-Type: application/json' -d "$BODY" %(hello)s >/dev/null; then
echo "Joined. %(ingest)s sets this Mac up in about a minute: watch its status page."
else
@@ -521,11 +563,12 @@ def main(argv=None):
ap.add_argument("--name")
ap.add_argument("--data-dir", default=PARENT)
ap.add_argument("--home", help="the settings page this campus was installed from (the campus list lives there)")
ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every campus)")
a = ap.parse_args(argv)
if a.what == "setup":
setup(a.data_dir, a.id, a.name, home=a.home)
elif a.what == "rebuild":
rebuild(a.data_dir)
rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None)
elif a.what == "upgrade-companion":
upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"))
elif a.what == "look":