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
+111 -2
View File
@@ -38,7 +38,7 @@ SEEN, LAST = "discover-seen.json", "discover-last.json"
# What the program kit carries (no settings, keys or history of the campus that serves it).
KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py",
"nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json")
KIT_DIRS = ("settings", "remote-access", "companion-objects")
KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup")
# --- Companion: the panel's variables, its blink clock, and two free pages ---------------
@@ -153,6 +153,23 @@ def wait_applied(applier, limit=240):
return applier.status or {}
def companion_version_problem(cfg):
"""None when this Companion can take the panel (5.x), else what to tell the person."""
try:
discover.companion_get(cfg, "/api/connections", 5)
return None
except OSError:
pass
try:
discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5)
return ("This Companion is older than version 5 (Woonsocket ran 4.0.3). The panel needs 5.0.7, as North "
"Attleboro runs. Upgrade it from NA's copy (its settings come along; the old version stays installed):\n"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.campus upgrade-companion\n"
"then paste the install line again.")
except OSError:
return "Companion does not answer on port 8000: open Companion (and its HTTP API, Settings > Protocols), then paste the line again."
def setup(data_dir, cid, name=None, say=print, home=None):
"""The first setup of a campus (or, when it is set up already, a look for what is new)."""
from settings import server, campuses
@@ -166,6 +183,9 @@ def setup(data_dir, cid, name=None, say=print, home=None):
say("%s is set up already: looking for what is new." % (cfg.get("site") or {}).get("name", "This campus"))
return look_and_add(site, say=say, force=True)
name = name or CAMPUSES.get(cid) or cid.upper()
problem = companion_version_problem(cfg)
if problem:
raise RuntimeError(problem)
for line in companion_prepare(cfg):
say("Companion: added %s" % line)
pages = pick_pages(cfg)
@@ -191,6 +211,83 @@ def setup(data_dir, cid, name=None, say=print, home=None):
return new
def rebuild(data_dir, say=print):
"""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
site = server.Site(data_dir)
cfg = site.load()
s = cfg.get("site") or {}
inv = discover.look(cfg, data_dir)
if inv.get("companion_error"):
raise RuntimeError("Companion does not answer: %s" % inv["companion_error"])
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))
for k in ("contacts", "live_services", "ingest_name", "settings_port"):
if k in s:
new["site"][k] = s[k]
new["discover"] = dict(new.get("discover") or {}, **(cfg.get("discover") or {}))
errors, _ = schema.validate(new)
if errors:
raise RuntimeError("the rebuilt config does not pass the rules: %s" % "; ".join(errors[:4]))
r = site.applier.start(new, "auto-setup: rebuilt with today's recipes", "auto-setup", "127.0.0.1")
if not r.get("ok"):
raise RuntimeError(r.get("error") or "the Apply did not start")
st = wait_applied(site.applier)
save_seen(data_dir, load_seen(data_dir) | set(seen))
say("%s: %s" % (st.get("state"), st.get("outcome", "")))
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"))
return new
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
launcher stops on "Config version mismatch"), 5.0.7 is installed beside the old one and started."""
import glob
import shutil
import subprocess
import urllib.request
root = os.path.expanduser("~/Library/Application Support/companion")
app = "/Applications/Companion 5.0.7.app"
if not os.path.exists(app):
say("Downloading Companion 5.0.7 from %s ..." % base)
tmp = "/tmp/companion-5.0.7.zip"
with urllib.request.urlopen(base.rstrip("/") + "/campus/companion.zip", timeout=600) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
subprocess.run(["/usr/bin/ditto", "-x", "-k", tmp, "/Applications/"], check=True)
os.remove(tmp)
subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", app], check=True)
dbs = sorted(glob.glob(os.path.join(root, "v*", "db.sqlite")), key=os.path.getmtime)
if not os.path.exists(os.path.join(root, "v5.0", "db.sqlite")):
if not dbs:
raise RuntimeError("no Companion settings found to bring along")
newest = os.path.dirname(dbs[-1])
subprocess.run(["/usr/bin/pkill", "-TERM", "-f", "Companion.*\\.app/Contents/MacOS/Companion"])
for _ in range(40):
if subprocess.run(["/usr/bin/pgrep", "-f", "Companion.*\\.app/Contents/MacOS/Companion"], capture_output=True).returncode:
break
time.sleep(0.5)
shutil.copytree(newest, os.path.join(root, "v5.0"))
say("Settings copied from %s to v5.0 (the old folder stays as it was)" % os.path.basename(newest))
for f in ("db.sqlite", "cache.sqlite"):
p = os.path.join(root, "v5.0", f)
if os.path.exists(p):
os.utime(p)
subprocess.run(["/usr/bin/open", "-g", app], check=True)
for _ in range(90):
try:
discover.companion_get({"companion": {}}, "/api/connections", 2)
say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.")
return True
except OSError:
time.sleep(1)
raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac")
def summary(inv):
known = set(autoconf.COMPANION)
return {"connections": len(inv.get("connections") or []),
@@ -274,6 +371,14 @@ def loop(site, stop, poke):
stop.wait(20) # a computer just joined: give its SSH a moment
def load_campuses_home(data_dir):
try:
with open(os.path.join(data_dir, "campuses.json")) as f:
return json.load(f).get("home")
except (OSError, ValueError):
return None
def load_last(data_dir):
try:
with open(os.path.join(data_dir, LAST)) as f:
@@ -411,7 +516,7 @@ try {
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("what", choices=("setup", "look", "add"))
ap.add_argument("what", choices=("setup", "look", "add", "rebuild", "upgrade-companion"))
ap.add_argument("--id", default="new")
ap.add_argument("--name")
ap.add_argument("--data-dir", default=PARENT)
@@ -419,6 +524,10 @@ def main(argv=None):
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)
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":
cfg = sp.load_config(os.path.join(a.data_dir, "config.json"))
inv = discover.look(cfg, a.data_dir)