"Location", never "campus", in everything people read; SC FOH joined with its MIDI links

- pages (NA 98 Locations / 99 Location, re-imported via PAGES_VERSION), keys, settings page, setup and
  install messages, alerts, docs, the Apollo Beach kit; python3 -m settings.location and /location/...
  addresses (the old names still answer for installed Macs)
- Apply reads the before settings from the file: an in-place edit skipped the watchdog restart
- SC: SUPERRACK (+SoundGrid) and X32 EDIT from SC FOH; FOH TRACKS and TRACKS LIGHTS MIDI links watched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:42:22 -04:00
co-authored by Claude Opus 5.5
parent d0c755824b
commit 7068244ba4
31 changed files with 951 additions and 788 deletions
+47 -42
View File
@@ -1,23 +1,23 @@
"""Every campus's status, on North Attleboro's Companion only.
"""Every location's status, on North Attleboro's Companion only.
Each other campus's settings page tells North Attleboro's (the hub: a campus with no "home") where it
is, and learns nothing back: no other campus ever sees another campus (2026-10-20, the user: "NA
Each other location's settings page tells North Attleboro's (the hub: a location with no "home") where it
is, and learns nothing back: no other location ever sees another location (2026-10-20, the user: "NA
should be the only one that sees every single location's status"). Only the hub, every two seconds,
copies each campus's key states (what that campus's watchdog draws, /api/state) into its own
copies each location's key states (what that location's watchdog draws, /api/state) into its own
Companion as custom variables sp_state_<id> / sp_hb_<id>, and keeps two kinds of page there:
CAMPUSES page one key per campus: that campus's ALL key, with its name; tap = its status page
CAMPUS page whichever campus was tapped, as its own status page looks (same places, same
colours, live), view only: holds and presses are for that campus's own deck.
The ALARMS corner goes back to the CAMPUSES page.
LOCATIONS page one key per location: that location's ALL key, with its name; tap = its status page
LOCATION page whichever location was tapped, as its own status page looks (same places, same
colours, live), view only: holds and presses are for that location's own deck.
The ALARMS corner goes back to the LOCATIONS page.
Two pages, whatever the number of campuses (NA's Companion has 97 pages of its own; Companion stops
at 99): the CAMPUS page's keys are cells ("c_1_3"), and the hub fills them from the tapped campus's
layout (sp_view says which). A campus that stops answering goes purple. Pages are only written
Two pages, whatever the number of locations (NA's Companion has 97 pages of its own; Companion stops
at 99): the LOCATION page's keys are cells ("c_1_3"), and the hub fills them from the tapped location's
layout (sp_view says which). A location that stops answering goes purple. Pages are only written
where nothing a person made sits (push_page.safe_target): an empty page, else one added at the
end. State: campuses.json in the data folder.
A standalone campus (Apollo Beach, 2026-10-21: "they want to take care of it on their own ... i dont
A standalone location (Apollo Beach, 2026-10-21: "they want to take care of it on their own ... i dont
need to monitor AB from NA") has "standalone" set instead of a home: it tells nobody, is no hub, and
lists only itself.
"""
@@ -70,17 +70,22 @@ def me(cfg, url=None):
import pwd
site = cfg.get("site") or {}
# 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),
return {"id": site.get("id") or "new", "name": site.get("name") or "This location", "url": url or my_url(cfg),
"user": pwd.getpwuid(os.getuid()).pw_name}
# Bumped when the pages are built differently (2: "campus" became "location", 2026-10-21), so the hub
# re-imports them.
PAGES_VERSION = 2
def layout_sha(cfg):
keys = [(t.get("id"), t.get("label"), t.get("pos"), t.get("kind"), t.get("members")) for t in cfg.get("tiles", [])]
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables")], sort_keys=True, default=str).encode()).hexdigest()[:16]
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables"), PAGES_VERSION], sort_keys=True, default=str).encode()).hexdigest()[:16]
def state_now(site):
"""What /api/state answers: this campus's key states as its watchdog draws them, right now."""
"""What /api/state answers: this location's key states as its watchdog draws them, right now."""
cfg = site.load()
names = cfg.get("variables") or {}
comp = site.comp
@@ -100,8 +105,8 @@ 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
VIEW_CMD = "sp_view_cmd" # a tap or hold on the LOCATION page, passed on to that location's own watchdog
MODE = "sp_view_mode" # "" = that location's status page, "RESTART" = its Restart page
CELLS = [(r, c) for r in range(4) for c in range(1, 8)]
@@ -110,7 +115,7 @@ def cell_id(r, c):
def view_state(state, keys):
"""A campus's key states, moved onto the cells its keys sit in (empty cells blank)."""
"""A location's key states, moved onto the cells its keys sit in (empty cells blank)."""
out = {cell_id(r, c): {"x": "", "b": 0, "b2": 0, "f": 0xFFFFFF, "f2": 0xFFFFFF, "s": 14} for r, c in CELLS}
for k in keys:
r, c = k["pos"]
@@ -120,8 +125,8 @@ def view_state(state, keys):
def build_view(local_cfg, page, overview_page):
"""The CAMPUS page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
that campus (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
"""The LOCATION page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
that location (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
opts = make_page.settings(local_cfg)
controls = make_page.nav_controls(local_cfg)
hold_ms = opts["hold_ms"]
@@ -132,17 +137,17 @@ def build_view(local_cfg, page, overview_page):
make_page.place(controls, (r, c), {
"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
"notes": "STATUS tile 'campus view %d/%d' - the tapped campus's key here. Press: acknowledge it there. "
"Hold %g s: fix it there, like a hold on that campus's own deck." % (r, c, hold_ms / 1000.0)},
"notes": "STATUS tile 'location view %d/%d' - the tapped location's key here. Press: acknowledge it there. "
"Hold %g s: fix it there, like a hold on that location's own deck." % (r, c, hold_ms / 1000.0)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [tap], str(hold_ms): [hold]},
"options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []})
back = make_page.static_button("campus_back", "ALL\\nSITES", 0x37474F, 0xFFFFFF, overview_page,
"Tap: back to the CAMPUSES page (page %d)." % overview_page, "STATUS jump key")
"Tap: back to the LOCATIONS 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
if layer["type"] == "text": # which location this page shows, then the way back
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)
return make_page.page_export("%d\\nLocation" % page, page, controls, local_cfg)
def select_action(cid, mode=""):
@@ -153,7 +158,7 @@ def select_action(cid, mode=""):
def campus_key(cid, name, names, actions, note, local_cfg):
"""One CAMPUSES key: that campus's ALL key with its name on top."""
"""One LOCATIONS key: that location's ALL key with its name on top."""
opts = make_page.settings(local_cfg)
tag = short(cid, name)
stale_text = "`%s\\n?`" % tag
@@ -164,13 +169,13 @@ def campus_key(cid, name, names, actions, note, local_cfg):
text["fontsize"] = make_page.prop("auto")
return {"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
"notes": "STATUS tile 'campus %s' - %s's ALL key. %s" % (cid, name, note)},
"notes": "STATUS tile 'location %s' - %s's ALL key. %s" % (cid, name, note)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
"localVariables": []}
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)."""
"""Row 0: each location'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")
rp = (local_cfg.get("restart_page") or {}).get("page")
@@ -181,7 +186,7 @@ def build_overview(local_cfg, campuses, page, view_page):
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
"Tap: this location'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)],
@@ -193,7 +198,7 @@ def build_overview(local_cfg, campuses, page, view_page):
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)
return make_page.page_export("%d\\nLocations" % page, page, controls, local_cfg)
def ensure_variables(cfg, ids):
@@ -232,10 +237,10 @@ def free_page(cfg, taken, start=21):
# None empty: a new page at the end (Companion's own page keys on it), never in between.
last = max(p for p in range(1, 100) if exists(host, port, p))
if last >= 99:
raise RuntimeError("Companion has 99 pages and none is empty: free one for the campuses")
raise RuntimeError("Companion has 99 pages and none is empty: free one for the locations")
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["CAMPUSES"]})
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["LOCATIONS"]})
finally:
ws.close()
return last + 1
@@ -278,7 +283,7 @@ class Mirror:
return not data.get("home") and not data.get("standalone")
def sync_registry(self):
"""A campus: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
"""A location: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
cfg = self.site.load()
data = load(self.site.data_dir)
mine = me(cfg)
@@ -300,7 +305,7 @@ class Mirror:
return {p for p in t if p}
def refresh_pages(self):
"""The hub: each campus's layout (when it changed), the CAMPUS page once, the CAMPUSES page when the roster changes."""
"""The hub: each location's layout (when it changed), the LOCATION page once, the LOCATIONS page when the roster changes."""
cfg = self.site.load()
data = self.sync_registry()
if not self.is_hub(data):
@@ -308,7 +313,7 @@ class Mirror:
mine = (cfg.get("site") or {}).get("id")
others = {cid: e for cid, e in data.get("list", {}).items() if cid != mine}
if not others and not (data.get("pages") or {}).get("overview"):
return data # no other campus yet: no pages needed
return data # no other location yet: no pages needed
pages = data.setdefault("pages", {})
layouts = data.setdefault("layouts", {})
for cid, e in sorted(others.items()):
@@ -337,7 +342,7 @@ class Mirror:
return data
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."""
"""A tap or hold on the LOCATION page goes to the shown location's watchdog, through its Companion."""
text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
if not text:
return
@@ -351,12 +356,12 @@ class Mirror:
remote = sp.Companion(host, 8000, timeout=3)
before = (remote.get_custom("sp_cmd") or "").strip()
remote.set_custom("sp_cmd", ";".join([before] + cmds if before else cmds))
log.info("CAMPUS %s: %s (from %s's CAMPUS page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
log.info("LOCATION %s: %s (from %s's LOCATION page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
except OSError as e:
log.warning("CAMPUS %s: could not pass on %s: %s", pick, cmds, e)
log.warning("LOCATION %s: could not pass on %s: %s", pick, cmds, e)
def poll(self):
"""The hub: copy each other campus's key states into this Companion."""
"""The hub: copy each other location's key states into this Companion."""
cfg = self.site.load()
data = load(self.site.data_dir)
if not self.is_hub(data):
@@ -380,10 +385,10 @@ class Mirror:
threads.append(t)
for t in threads:
t.join(2)
# The CAMPUS page shows the tapped campus: its keys moved onto their cells.
# The LOCATION page shows the tapped location: its keys moved onto their cells.
pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it
if pick not in others and others:
# Nothing tapped yet (or a campus that left): show the first one rather than a purple page.
# Nothing tapped yet (or a location that left): show the first one rather than a purple page.
pick = sorted(others)[0]
self.site.comp.set_custom(SELECT, pick.upper())
restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART"
@@ -400,7 +405,7 @@ class Mirror:
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
"""The LOCATION page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that location's own
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 []}