Files
status-indicators/settings/campuses.py
T
christianandClaude Opus 5.5 b793490d90 Hold Woonsocket's keys from NA; MIDI links moved and fixed on Macs without python; one check per link
- NA's CAMPUS page (99): tap = acknowledge, hold = fix, at the campus shown. NA's settings page
  turns the cell into that campus's key and writes the command into that campus's Companion
  (sp_cmd), exactly as a press on its own deck does. Logged on both sides.
- coremidi.JXA_CONNECT: connect_peer in JavaScript for Automation, used when a Mac has no python
  (no Command Line Tools): a MIDI hold now reconnects Woonsocket's Tracks, FOH and Lights.
- Woonsocket's MIDI links are on wired addresses now (Tracks to Lighting 10.2.2.2, Tracks to FOH
  10.2.1.12; the other ends list Tracks at 10.2.1.219), and the wired-network rule is back on there.
- Auto-setup keeps one check per MIDI link (the end that reports the link's state): two pulled
  links read 4 DOWN before.

Tried live: a hold sent from NA's page 99 reconnected the FOH link at Woonsocket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:00:31 -04:00

408 lines
19 KiB
Python

"""Every campus'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
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
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.
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
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.
"""
import gzip
import logging
import hashlib
import json
import os
import tempfile
import threading
import time
import urllib.parse
import urllib.request
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
from settings import discover
import make_page
import push_page
import status_panel as sp
FILE = "campuses.json"
log = logging.getLogger("status-settings") # the settings page's own log (status-settings.log)
POLL, REFRESH = 2.0, 60.0
SHORT = {"na": "NA", "ab": "AB", "sc": "SC", "ws": "WS"}
MIRROR_MARK = "STATUS tile" # copied keys keep the tile marker, so a later rebuild may overwrite them
def load(data_dir):
try:
with open(os.path.join(data_dir, FILE)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def save(data_dir, data):
sp.write_json(os.path.join(data_dir, FILE), data)
def short(cid, name=""):
return SHORT.get(cid) or (name or cid)[:4].upper()
def my_url(cfg):
w = (discover.this_mac().get("wired") or {}).get("ip") or "127.0.0.1"
return "http://%s:%d" % (w, int((cfg.get("site") or {}).get("settings_port", 8020)))
def me(cfg, url=None):
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)}
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]
def state_now(site):
"""What /api/state answers: this campus's key states as its watchdog draws them, right now."""
cfg = site.load()
names = cfg.get("variables") or {}
comp = site.comp
return {"id": (cfg.get("site") or {}).get("id"), "name": (cfg.get("site") or {}).get("name"),
"state": comp.get_custom(names.get("state", "sp_state")) or "", "hb": comp.get_custom(names.get("heartbeat", "sp_hb")) or "",
"layout": layout_sha(cfg), "time": int(time.time())}
def http_json(url, body=None, timeout=3):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"} if data else {}, method="POST" if data else "GET")
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
# --- the pages (the hub only) -----------------------------------------------------------
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
CELLS = [(r, c) for r in range(4) for c in range(1, 8)]
def cell_id(r, c):
return "c_%d_%d" % (r, c)
def view_state(state, keys):
"""A campus'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"]
if (r, c) in CELLS and k["id"] in state:
out[cell_id(r, c)] = state[k["id"]]
return out
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."""
opts = make_page.settings(local_cfg)
controls = make_page.nav_controls(local_cfg)
hold_ms = opts["hold_ms"]
for r, c in CELLS:
layers = make_page.layers(cell_id(r, c), {}, VIEW, "''", opts)
tap = make_page.command_action("view_%d_%d" % (r, c), "press", VIEW_CMD, "tap %d %d $(internal:time_unix)" % (r, c))
hold = make_page.command_action("view_%d_%d" % (r, c), "hold", VIEW_CMD, "hold %d %d $(internal:time_unix)" % (r, c))
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)},
"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")
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)
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 campus_key(cid, name, names, actions, note, local_cfg):
"""One CAMPUSES key: that campus's ALL key with its name on top."""
opts = make_page.settings(local_cfg)
tag = short(cid, name)
stale_text = "`%s\\n?`" % tag
layers = make_page.layers("all", {}, names, stale_text, opts)
text = next(layer for layer in layers if layer["type"] == "text")
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], opts["stale"])
text["text"] = make_page.prop("%s ? %s : `%s\\n${jsonpath($(custom:%s), '$.all.x')}`" % (stale, stale_text, tag, names["state"]), True)
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)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
"localVariables": []}
def build_overview(local_cfg, campuses, page, view_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):
name = campuses[cid].get("name", cid)
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)
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)],
"Tap: its status page, live (page %d)." % view_page, local_cfg)
make_page.place(controls, cell, key)
return make_page.page_export("%d\\nCampuses" % page, page, controls, local_cfg)
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]
if not want:
return
comp = cfg.get("companion") or {}
ws = push_page.WebSocket(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), "/trpc")
try:
api = push_page.Trpc(ws)
for n in want:
api.mutation("customVariables.create", {"name": n, "defaultVal": "0" if n.startswith("sp_hb_") else ""})
finally:
ws.close()
def free_page(cfg, taken, start=21):
"""The first page from `start` that holds nothing but Companion's own page keys."""
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
for page in range(start, 100):
if page in taken:
continue
if not exists(host, port, page):
break
try:
export = push_page.fetch_page(host, port, page)
except (OSError, ValueError):
continue
ctrls = (export.get("page") or {}).get("controls") or {}
if not any(ctl and ctl.get("type") not in push_page.NAV_TYPES for row in ctrls.values() for ctl in (row or {}).values()):
return page
# 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")
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["CAMPUSES"]})
finally:
ws.close()
return last + 1
def exists(host, port, page):
try:
push_page.fetch_page(host, port, page)
return True
except (OSError, ValueError):
return False
def push(cfg, page, export):
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:
raise RuntimeError("page %d: %s" % (page, why))
with tempfile.NamedTemporaryFile(suffix=".companionconfig", delete=False) as f:
f.write(gzip.compress(json.dumps(export).encode()))
path = f.name
try:
push_page.push(host, port, page, path, export["page"]["name"])
finally:
os.remove(path)
# --- the loops --------------------------------------------------------------------------
class Mirror:
def __init__(self, site):
self.site = site
self.remote = {} # id -> last /api/state answer
self.keys = {} # id -> [{"id", "pos"}] of its status page (from its /api/config)
@staticmethod
def is_hub(data):
return not data.get("home")
def sync_registry(self):
"""A campus: 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)
home = data.get("home")
if home and home.rstrip("/") != mine["url"]:
try:
http_json(home.rstrip("/") + "/api/campus/hello", mine)
data.pop("error", None)
except (OSError, ValueError) as e:
data["error"] = "hub %s: %s" % (home, e)
data["list"] = {mine["id"]: mine}
else:
data.setdefault("list", {})[mine["id"]] = mine
save(self.site.data_dir, data)
return data
def taken_pages(self, cfg):
t = {cfg.get("page"), (cfg.get("restart_page") or {}).get("page"), (cfg.get("ready_page") or {}).get("page")}
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."""
cfg = self.site.load()
data = self.sync_registry()
if not self.is_hub(data):
return data
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
pages = data.setdefault("pages", {})
layouts = data.setdefault("layouts", {})
for cid, e in sorted(others.items()):
st = self.remote.get(cid)
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")]
layouts[cid] = st.get("layout")
ensure_variables(cfg, list(others))
taken = self.taken_pages(cfg) | {p for p in pages.values() if p}
for which in ("overview", "view"):
if not pages.get(which):
pages[which] = free_page(cfg, taken)
taken.add(pages[which])
built = layout_sha(cfg)
if data.get("view_built") != built:
push(cfg, pages["view"], build_view(cfg, pages["view"], pages["overview"]))
data["view_built"] = built
roster = sorted(data["list"])
if data.get("overview_roster") != roster or data.get("overview_built") != built:
push(cfg, pages["overview"], build_overview(cfg, data["list"], pages["overview"], pages["view"]))
data["overview_roster"], data["overview_built"] = roster, built
data.pop("error", None)
save(self.site.data_dir, data)
return data
def pass_on(self, pick, others):
"""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 []
if not cmds:
return
host = urllib.parse.urlparse(others[pick]["url"]).hostname
try:
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"))
except OSError as e:
log.warning("CAMPUS %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."""
cfg = self.site.load()
data = load(self.site.data_dir)
if not self.is_hub(data):
return
mine = (cfg.get("site") or {}).get("id")
others = {cid: e for cid, e in (data.get("list") or {}).items() if cid != mine and e.get("url")}
threads = []
def one(cid, url):
try:
st = http_json(url.rstrip("/") + "/api/state", timeout=1.5)
except (OSError, ValueError):
return # not answering: its heartbeat goes stale, its keys purple
self.remote[cid] = st
if (data.get("pages") or {}).get("overview"):
self.site.comp.set_custom("sp_state_" + cid, st.get("state") or "{}")
self.site.comp.set_custom("sp_hb_" + cid, str(st.get("hb") or "0"))
for cid, e in others.items():
t = threading.Thread(target=one, args=(cid, e["url"]), daemon=True)
t.start()
threads.append(t)
for t in threads:
t.join(2)
# The CAMPUS page shows the tapped campus: 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.
pick = sorted(others)[0]
self.site.comp.set_custom(SELECT, pick.upper())
self.pass_on(pick if pick in others else None, others)
st = self.remote.get(pick)
if st and pick in self.keys 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["heartbeat"], str(st.get("hb") or "0"))
def campus_command(keys, text, now):
"""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."""
at = {tuple(k["pos"]): k["id"] for k in keys or []}
out = []
for part in (text or "").split(";"):
w = part.split()
if len(w) != 4 or w[0] not in ("tap", "hold") or not (w[1].isdigit() and w[2].isdigit() and w[3].isdigit()):
continue
tid = at.get((int(w[1]), int(w[2])))
if not tid or now - int(w[3]) > 15:
continue
out.append("ack %s" % tid if w[0] == "tap" else "restart %s %s" % (tid, w[3]))
return out
def loop(site, stop):
m = Mirror(site)
site.mirror = m
last = 0.0
stop.wait(15)
while not stop.is_set():
try:
m.poll()
if time.time() - last > REFRESH:
last = time.time()
m.refresh_pages()
except Exception as e: # noqa: BLE001 - the page must keep running
data = load(site.data_dir)
data["error"] = str(e)[:300]
save(site.data_dir, data)
stop.wait(POLL)