- 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>
408 lines
19 KiB
Python
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)
|