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:
+41
-5
@@ -86,8 +86,11 @@ SHARED_FIXES = {
|
||||
}
|
||||
|
||||
STATUS_ROWS, COLS = 4, 8
|
||||
# North Attleboro's auto-fix hours, for every campus: Tuesday and Wednesday rehearsals, Sunday services.
|
||||
DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
|
||||
"restart_connections": True, "windows": [{"days": ["sun"], "start": "05:00", "end": "14:00"}]}
|
||||
"restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"},
|
||||
{"days": ["wed"], "start": "09:00", "end": "21:00"},
|
||||
{"days": ["sun"], "start": "05:00", "end": "14:00"}]}
|
||||
|
||||
|
||||
# --- small helpers ---------------------------------------------------------------------
|
||||
@@ -218,6 +221,27 @@ def items(inv, nets):
|
||||
apps_at[(a["app"], c.get("ip"))] = item
|
||||
for ip in c.get("ips") or []:
|
||||
apps_at.setdefault((a["app"], ip), item)
|
||||
# Programs on this Mac itself (the Companion Mac): a local process check and local start/restart fixes.
|
||||
ing = inv.get("ingest") or {}
|
||||
for a in MAC_APPS:
|
||||
exe = (ing.get("exes") or {}).get(a["process"])
|
||||
app_path = (ing.get("paths") or {}).get(a["process"])
|
||||
if not exe or not app_path:
|
||||
continue
|
||||
fid = slug("%s_ingest" % a["app"])
|
||||
pretty = a["label"].title() if a["app"] not in ("propresenter", "ableton") else {"propresenter": "ProPresenter", "ableton": "Ableton"}[a["app"]]
|
||||
start = {"template": "mac_start", "params": {"host": "ingest", "process": a["process"], "app": app_path},
|
||||
"label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]}
|
||||
again = {"template": "mac_restart", "params": {"host": "ingest", "process": a["process"], "app": app_path, "wait_seconds": 10, "force": True},
|
||||
"label": "restart %s on this Mac (closes it first)" % pretty, "short": a["label"]}
|
||||
item = {"ident": "app:ingest|%s" % a["process"], "group": a.get("group") or ("VIDEO" if a["app"] == "arena" else "STREAM" if a["app"] == "obs" else "SHOW"),
|
||||
"label": a["label"], "checks": [{"type": "process", "match": exe, "name": a["label"], "fix": fid}],
|
||||
"fixes": {fid: start, "restart_" + fid: again}, "restart": [{"label": a["label"], "fix": "restart_" + fid}] if a.get("restart") else [],
|
||||
"also": [], "fold": a.get("fold")}
|
||||
out.append(item)
|
||||
for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) + ["127.0.0.1"]:
|
||||
if ip:
|
||||
apps_at.setdefault((a["app"], ip), item)
|
||||
# Companion connections.
|
||||
counts = {}
|
||||
for conn in inv.get("connections") or []:
|
||||
@@ -343,7 +367,7 @@ def layout(cfg, new_tiles, restart_items):
|
||||
"""Put the keys on the Status page: everything fits, so no group keys; otherwise group keys along
|
||||
the top row and the members after them (a member with no room left shows inside its group only)."""
|
||||
fixed = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
|
||||
if restart_items:
|
||||
if cfg.get("restart_page"):
|
||||
fixed.add(tuple(cfg["restart_page"]["jump_pos"]))
|
||||
cells = free_cells(fixed)
|
||||
groups = [g for g in GROUPS if any(t["_group"] == g for t in new_tiles)]
|
||||
@@ -386,10 +410,10 @@ def place_new(cfg, tile):
|
||||
|
||||
|
||||
def restart_layout(cfg, restart_items):
|
||||
if not restart_items:
|
||||
cfg.pop("restart_page", None)
|
||||
"""The Restart page always exists (a campus set up before its computers joined gets their keys later)."""
|
||||
rp = cfg.get("restart_page")
|
||||
if not rp:
|
||||
return
|
||||
rp = cfg["restart_page"]
|
||||
taken = {tuple(rp["back_pos"])} | {tuple(k["pos"]) for k in rp.get("keys", [])}
|
||||
cells = free_cells(taken)
|
||||
ids = {k["id"] for k in rp.get("keys", [])}
|
||||
@@ -474,6 +498,18 @@ def extend(cfg, inv, seen):
|
||||
continue
|
||||
if any(c.get("params", {}).get("host") not in cfg["hosts"] for c in it["checks"] if c.get("template")):
|
||||
continue
|
||||
# A program whose Companion connection already has a key (Woonsocket's PROP got a second key):
|
||||
# its own checks join that key.
|
||||
home = next((t for t in cfg["tiles"] if any(("conn:" + c.get("conn", "")) in it["also"] for c in t.get("checks", []) if c.get("conn"))), None)
|
||||
if home is not None:
|
||||
for fid, f in it["fixes"].items():
|
||||
cfg["fixes"].setdefault(fid, f)
|
||||
home["checks"] += [c for c in it["checks"] if not c.get("conn")]
|
||||
if it["restart"] and cfg.get("restart_page"):
|
||||
restart_layout(cfg, it["restart"])
|
||||
added.append("%s checks on the %s key" % (key_text(it["label"]), home["label"]))
|
||||
now_seen += [it["ident"]] + it["also"]
|
||||
continue
|
||||
tid = slug(it["label"], taken)
|
||||
taken.add(tid)
|
||||
for fid, f in it["fixes"].items():
|
||||
|
||||
+111
-2
@@ -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)
|
||||
|
||||
+43
-16
@@ -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
|
||||
|
||||
|
||||
@@ -136,6 +136,11 @@ def this_mac():
|
||||
info = {"name": run(["/usr/sbin/scutil", "--get", "ComputerName"]).strip(),
|
||||
"hostname": (run(["/usr/sbin/scutil", "--get", "LocalHostName"]).strip() + ".local").lstrip("."),
|
||||
"ips": [ip for n, (ip, _) in sorted(ifs.items()) if not ip.startswith("127.")]}
|
||||
# What runs on this Mac itself (ProPresenter on Woonsocket's Companion Mac), with each app's path.
|
||||
ps = run(["/bin/ps", "-axo", "comm="]).splitlines()
|
||||
info["procs"] = sorted({os.path.basename(p.strip()) for p in ps if p.strip()})
|
||||
info["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app" for p in ps if ".app/Contents/MacOS/" in p}
|
||||
info["exes"] = {os.path.basename(p.strip()): p.strip() for p in ps if ".app/Contents/MacOS/" in p}
|
||||
if wired:
|
||||
ip, net = ifs[wired]
|
||||
# A /24 or smaller on a campus usually sits inside a /16 the rest of the gear shares: use what is configured.
|
||||
|
||||
+24
-1
@@ -692,6 +692,27 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, campus.install_script(self.base_url(), q.get("id", ["new"])[0], key), "text/plain; charset=utf-8")
|
||||
if path == "/campus/program.tgz":
|
||||
return self.send(200, campus.program_kit(site.data_dir), "application/gzip")
|
||||
if path == "/campus/companion.zip":
|
||||
# Companion 5.0.7 as this Mac has it, for `settings.campus upgrade-companion` on a campus
|
||||
# still on an older one (packed once, kept beside the config, streamed).
|
||||
app = "/Applications/Companion 5.0.7.app"
|
||||
pack = os.path.join(site.data_dir, "companion-5.0.7.zip")
|
||||
if not os.path.exists(app):
|
||||
return self.send(404, {"error": "no Companion 5.0.7 on this Mac"})
|
||||
if not os.path.exists(pack):
|
||||
subprocess.run(["/usr/bin/ditto", "-c", "-k", "--keepParent", app, pack + ".tmp"], check=True, timeout=900)
|
||||
os.replace(pack + ".tmp", pack)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Length", str(os.path.getsize(pack)))
|
||||
self.end_headers()
|
||||
with open(pack, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
return
|
||||
if path == "/join":
|
||||
return self.send(200, campus.join_mac(site.load(), self.base_url()), "text/plain; charset=utf-8")
|
||||
if path == "/join.ps1":
|
||||
@@ -893,7 +914,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
lst = data.setdefault("list", {})
|
||||
for other in [k for k, e in lst.items() if k != cid and (e or {}).get("url") == url]:
|
||||
lst.pop(other) # the same Mac under its old name (before its first setup)
|
||||
lst[cid] = {"id": cid, "name": str(body.get("name") or cid)[:40], "url": url}
|
||||
user = str(body.get("user") or "")
|
||||
lst[cid] = dict({"id": cid, "name": str(body.get("name") or cid)[:40], "url": url},
|
||||
**({"user": user} if re.fullmatch(r"[A-Za-z0-9._-]{1,40}", user) else {}))
|
||||
campuses.save(site.data_dir, data)
|
||||
return self.send(200, {"ok": True})
|
||||
if path == "/api/fleet/hello":
|
||||
|
||||
Reference in New Issue
Block a user