Every campus matches NA, tailored: Restart page, Companion-Mac programs, NA's auto-fix hours, nightly backups, restart from NA

- Auto-setup: programs on the Companion Mac itself (ProPresenter at Woonsocket) get a local process
  check and local start/restart fixes; every campus gets a Restart page (keys arrive as computers
  join); NA's auto-fix hours (Tue, Wed, Sun); a program found after its Companion connection joins
  that key instead of making a second one. settings.campus rebuild remakes a campus from what is
  found now, keeping its name, PIN and pages; upgrade-companion brings Companion to 5.0.7 from NA's
  copy (/campus/companion.zip), as Woonsocket was; setup stops with that advice on an older one.
- NA's CAMPUSES page has a RESTART key per campus; the CAMPUS page shows that campus's Restart page
  and a hold there restarts it at that campus.
- Backups: backup/backup-campuses.sh, nightly 04:30 on NA (installed everywhere, acts on the hub),
  into Drive > Waters Creative > Apps > Status Panel Backups/<campus>, 30 kept, alert email via the
  checklist's alert.js. Campuses report the account to back up with. sites/ws/config.json in the repo.
- Woonsocket rebuilt: 9 keys (PROP with ProPresenter on its Mac), Restart page 20 (SUPERRACK, VISTA,
  ABLETON, PROP), auto-fix on NA's hours; all OK. Both campuses backed up to Drive by the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 17:17:04 -04:00
co-authored by Claude Opus 5.5
parent b793490d90
commit 6d1cfea2f7
10 changed files with 985 additions and 29 deletions
+43 -16
View File
@@ -63,8 +63,11 @@ def my_url(cfg):
def me(cfg, url=None):
import pwd
site = cfg.get("site") or {}
return {"id": site.get("id") or "new", "name": site.get("name") or "This campus", "url": url or my_url(cfg)}
# The account this panel runs as: the hub logs in with it for the nightly backup.
return {"id": site.get("id") or "new", "name": site.get("name") or "This campus", "url": url or my_url(cfg),
"user": pwd.getpwuid(os.getuid()).pw_name}
def layout_sha(cfg):
@@ -94,6 +97,7 @@ def http_json(url, body=None, timeout=3):
VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"}
SELECT = "sp_view"
VIEW_CMD = "sp_view_cmd" # a tap or hold on the CAMPUS page, passed on to that campus's own watchdog
MODE = "sp_view_mode" # "" = that campus's status page, "RESTART" = its Restart page
CELLS = [(r, c) for r in range(4) for c in range(1, 8)]
@@ -132,14 +136,16 @@ def build_view(local_cfg, page, overview_page):
"Tap: back to the CAMPUSES page (page %d)." % overview_page, "STATUS jump key")
for layer in back["style"]["layers"]:
if layer["type"] == "text": # which campus this page shows, then the way back
layer["text"] = make_page.prop("`${$(custom:%s)}\\nALL SITES`" % SELECT, True)
layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True)
make_page.place(controls, (3, 0), back)
return make_page.page_export("%d\\nCampus" % page, page, controls, local_cfg)
def select_action(cid):
return {"type": "action", "id": make_page.stable_id("campus_" + cid, "select"), "definitionId": "custom_variable_set_value",
"connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid.upper())}, "children": {}}
def select_action(cid, mode=""):
return [{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "select"), "definitionId": "custom_variable_set_value",
"connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid.upper())}, "children": {}},
{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "mode"), "definitionId": "custom_variable_set_value",
"connectionId": "internal", "options": {"name": make_page.prop(MODE), "value": make_page.prop(mode)}, "children": {}}]
def campus_key(cid, name, names, actions, note, local_cfg):
@@ -160,18 +166,29 @@ def campus_key(cid, name, names, actions, note, local_cfg):
def build_overview(local_cfg, campuses, page, view_page):
"""Row 0: each campus's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page)."""
controls = make_page.nav_controls(local_cfg)
mine = (local_cfg.get("site") or {}).get("id")
for cid, cell in zip(sorted(campuses, key=lambda c: (c != mine, c)), CELLS):
rp = (local_cfg.get("restart_page") or {}).get("page")
for col, cid in enumerate(sorted(campuses, key=lambda c: (c != mine, c))[:7], start=1):
name = campuses[cid].get("name", cid)
tag = short(cid, name)
if cid == mine:
key = campus_key(cid, name, local_cfg["variables"], [make_page.set_page_action("campus_" + cid, "go", local_cfg["page"])],
"Tap: its status page (page %d)." % local_cfg["page"], local_cfg)
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, rp,
"Tap: this campus's Restart page (page %s)." % rp, "STATUS jump key") if rp else None
else:
key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid},
[select_action(cid), make_page.set_page_action("campus_" + cid, "go", view_page)],
select_action(cid) + [make_page.set_page_action("campus_" + cid, "go", view_page)],
"Tap: its status page, live (page %d)." % view_page, local_cfg)
make_page.place(controls, cell, key)
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, view_page,
"Tap: %s's Restart page, live (page %d); hold a key there to restart it at %s." % (name, view_page, name),
"STATUS jump key")
restart["steps"]["0"]["action_sets"]["up"] = select_action(cid, "RESTART") + restart["steps"]["0"]["action_sets"]["up"]
make_page.place(controls, (0, col), key)
if restart:
make_page.place(controls, (1, col), restart)
return make_page.page_export("%d\\nCampuses" % page, page, controls, local_cfg)
@@ -179,7 +196,7 @@ def ensure_variables(cfg, ids):
from settings import campus
have, _ = campus.companion_state(cfg)
want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have]
want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD) if n not in have]
want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD, MODE) if n not in have]
if not want:
return
comp = cfg.get("companion") or {}
@@ -250,6 +267,7 @@ class Mirror:
self.site = site
self.remote = {} # id -> last /api/state answer
self.keys = {} # id -> [{"id", "pos"}] of its status page (from its /api/config)
self.rkeys = {} # id -> the same for its Restart page
@staticmethod
def is_hub(data):
@@ -294,6 +312,7 @@ class Mirror:
if st and (layouts.get(cid) != st.get("layout") or cid not in self.keys):
remote_cfg = http_json(e["url"].rstrip("/") + "/api/config", timeout=8)["config"]
self.keys[cid] = [{"id": t["id"], "pos": t["pos"]} for t in remote_cfg.get("tiles", []) if t.get("pos")]
self.rkeys[cid] = [{"id": k["id"], "pos": k["pos"]} for k in (remote_cfg.get("restart_page") or {}).get("keys", []) if k.get("pos")]
layouts[cid] = st.get("layout")
ensure_variables(cfg, list(others))
taken = self.taken_pages(cfg) | {p for p in pages.values() if p}
@@ -313,13 +332,14 @@ class Mirror:
save(self.site.data_dir, data)
return data
def pass_on(self, pick, others):
def pass_on(self, pick, others, restart=False):
"""A tap or hold on the CAMPUS page goes to the shown campus's watchdog, through its Companion."""
text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
if not text:
return
self.site.comp.set_custom(VIEW_CMD, "")
cmds = campus_command(self.keys.get(pick), text, time.time()) if pick else []
keys = (self.rkeys if restart else self.keys).get(pick)
cmds = campus_command(keys, text, time.time(), restart) if pick else []
if not cmds:
return
host = urllib.parse.urlparse(others[pick]["url"]).hostname
@@ -362,20 +382,23 @@ class Mirror:
# Nothing tapped yet (or a campus that left): show the first one rather than a purple page.
pick = sorted(others)[0]
self.site.comp.set_custom(SELECT, pick.upper())
self.pass_on(pick if pick in others else None, others)
restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART"
self.pass_on(pick if pick in others else None, others, restart)
st = self.remote.get(pick)
if st and pick in self.keys and (data.get("pages") or {}).get("view"):
keys = (self.rkeys if restart else self.keys).get(pick)
if st and keys is not None and (data.get("pages") or {}).get("view"):
try:
state = json.loads(st.get("state") or "{}")
except ValueError:
state = {}
self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, self.keys[pick]), separators=(",", ":")))
self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, keys), separators=(",", ":")))
self.site.comp.set_custom(VIEW["heartbeat"], str(st.get("hb") or "0"))
def campus_command(keys, text, now):
def campus_command(keys, text, now, restart=False):
"""The CAMPUS page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that campus's own
commands ("ack hd1", "restart hd1 <time>"), for the keys at those cells. Stale ones are dropped."""
commands ("ack hd1", "restart hd1 <time>"; on its Restart page a hold is "force rs_x <time>" and a
tap does nothing, as on its own deck), for the keys at those cells. Stale ones are dropped."""
at = {tuple(k["pos"]): k["id"] for k in keys or []}
out = []
for part in (text or "").split(";"):
@@ -385,6 +408,10 @@ def campus_command(keys, text, now):
tid = at.get((int(w[1]), int(w[2])))
if not tid or now - int(w[3]) > 15:
continue
if restart:
if w[0] == "hold":
out.append("force %s %s" % (tid, w[3]))
continue
out.append("ack %s" % tid if w[0] == "tap" else "restart %s %s" % (tid, w[3]))
return out