diff --git a/.gitignore b/.gitignore index b908d4c..a16c221 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc .DS_Store +dist/ diff --git a/README.md b/README.md index d90b575..62135fa 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,8 @@ once. Everything it makes is ordinary settings, changed on the page like North A |---|---|---|---| | North Attleboro (hub) | http://10.1.1.1:8020/ | NA Ingest | 19 / 20, READY 24, CAMPUSES 98, CAMPUS 99 | | Woonsocket | http://10.2.2.210:8020/ | WS ProPresenter Mac (wsprop) | 19 / 20 | +| Southcoast | http://10.5.2.60:8020/ | SC ProPresenter Mac (watersmedia) | 19 / 20 | +| Apollo Beach (standalone) | their own | their Companion Mac | 19 / 20 or the first free | Every campus: alarms armed all day; auto-fix on NA's hours (Tue 14:00–22:00, Wed 09:00–21:00, Sun 05:00–14:00); a hold on any key fixes it at any time, on that campus's own deck or from NA's CAMPUS page; ALARMS stops auto-fix. @@ -273,6 +275,16 @@ a hold on any key fixes it at any time, on that campus's own deck or from NA's C what is found now, keeping its name, PIN and pages. `python3 -m settings.campus upgrade-companion` brings an older Companion to 5.0.7 from NA's copy (settings come along; the old version stays installed). +## A campus on its own (Apollo Beach) + +Apollo Beach runs its panel itself: not on NA's deck, not in NA's backups, no NA key on its Macs. +`python3 tools/standalone.py ab "Apollo Beach"` makes `dist/Apollo Beach Status Panel.zip` (the program, an +`install.command` and a `READ ME FIRST.txt`; never anyone's settings, plug keys or login keys) to send them. +Installed, it sets itself up like any campus (`setup --standalone`: `campuses.json` gets `"standalone": true` +instead of a home, so it tells nobody and is no hub) and backs itself up nightly into +`~/Documents/Status Panel Backups/` on its Companion Mac (or `$STATUS_PANEL_BACKUPS`). With an older +Companion: `python3 -m settings.campus upgrade-companion --from `. + ## Backups Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every diff --git a/backup/backup-campuses.sh b/backup/backup-campuses.sh index 9f3e0d6..e138201 100755 --- a/backup/backup-campuses.sh +++ b/backup/backup-campuses.sh @@ -6,7 +6,8 @@ # the newest 30 per campus, and emails an alert (through the checklist's alert.js) when something # fails, because a backup nobody hears about failing is no backup. # -# Only the hub (North Attleboro: no "home" in campuses.json) does anything; elsewhere it exits. +# Only the hub (North Attleboro: no "home" in campuses.json) does anything; elsewhere it exits, except +# on a standalone campus (Apollo Beach), which backs up only itself into ~/Documents/Status Panel Backups. # Each campus is reached over SSH with the panel's own key, as the account it reported. What is # copied: config.json (the settings), history/ (every Apply), the page-key record and the campus # lists. Never outlets.json (the smart-plug keys stay on their Mac). @@ -24,13 +25,20 @@ alert() { [ -f "$js" ] && node "$js" "Status panel backup: $1" "${2:-}$(printf '\n\nLog: %s' "$LOG")" >> "$LOG" 2>&1 || true } -/usr/bin/python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("list") and not d.get("home") else 1)' \ - "$DEST/campuses.json" 2>/dev/null || { log "not the hub: nothing to back up from here"; exit 0; } +MODE=$(/usr/bin/python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("standalone" if d.get("standalone") else "campus" if d.get("home") else "hub" if d.get("list") else "")' \ + "$DEST/campuses.json" 2>/dev/null) +[ "$MODE" = hub ] || [ "$MODE" = standalone ] || { log "not the hub: nothing to back up from here"; exit 0; } DRIVE="" -for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do - [ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; } -done +if [ "$MODE" = standalone ]; then + # A campus on its own (Apollo Beach) keeps its own copies, in this Mac's Documents (or wherever + # STATUS_PANEL_BACKUPS points, e.g. a Google Drive folder). + DRIVE="${STATUS_PANEL_BACKUPS:-$HOME/Documents/Status Panel Backups}" +else + for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do + [ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; } + done +fi if [ -z "$DRIVE" ]; then alert "tonight's backup did not run - the Google Drive folder is missing" \ "Shared drives > Waters Creative > Apps could not be found on this Mac. Usually Google Drive is not running or signed out." diff --git a/settings/campus.py b/settings/campus.py index f1ebb60..d842e85 100644 --- a/settings/campus.py +++ b/settings/campus.py @@ -153,7 +153,7 @@ def wait_applied(applier, limit=240): return applier.status or {} -def companion_version_problem(cfg): +def companion_version_problem(cfg, standalone=False): """None when this Companion can take the panel (5.x), else what to tell the person.""" try: discover.companion_get(cfg, "/api/connections", 5) @@ -162,6 +162,11 @@ def companion_version_problem(cfg): pass try: discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5) + if standalone: + return ("This Companion is older than version 5. The panel needs Companion 5.0.7: download it for Mac from\n" + " https://bitfocus.io/companion (5.0.7, the .dmg), then on this Mac run\n" + " cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.campus upgrade-companion --from ~/Downloads/\n" + "(its settings come along; the old version stays installed), then run the installer again.") 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" @@ -170,12 +175,18 @@ def companion_version_problem(cfg): 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).""" +def setup(data_dir, cid, name=None, say=print, home=None, standalone=False): + """The first setup of a campus (or, when it is set up already, a look for what is new). + Standalone: it reports to nobody and nobody else sees it (Apollo Beach runs its own).""" from settings import server, campuses - if home: # where it was installed from: the campus list lives there + if home or standalone: # where it was installed from: the campus list lives there data = campuses.load(data_dir) - data["home"] = home + if standalone: + data.pop("home", None) + data["standalone"] = True + else: + data["home"] = home + data.pop("standalone", None) campuses.save(data_dir, data) site = server.Site(data_dir) cfg = site.load() @@ -183,7 +194,7 @@ 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) + problem = companion_version_problem(cfg, standalone) if problem: raise RuntimeError(problem) for line in companion_prepare(cfg): @@ -271,7 +282,7 @@ def clear_old_page(cfg, page): return "cleared" -def upgrade_companion(base, say=print): +def upgrade_companion(base, say=print, source=None): """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.""" @@ -281,6 +292,8 @@ def upgrade_companion(base, say=print): import urllib.request root = os.path.expanduser("~/Library/Application Support/companion") app = "/Applications/Companion 5.0.7.app" + if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io + install_companion_from(source, app, say) if not os.path.exists(app): say("Downloading Companion 5.0.7 from %s ..." % base) tmp = "/tmp/companion-5.0.7.zip" @@ -316,6 +329,35 @@ def upgrade_companion(base, say=print): raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac") +def install_companion_from(source, app, say=print): + """Companion copied from a downloaded .dmg or .zip to `app` (beside any older one).""" + import shutil + import subprocess + import tempfile + source = os.path.expanduser(source) + tmp = tempfile.mkdtemp(prefix="companion-") + try: + if source.lower().endswith(".dmg"): + mnt = os.path.join(tmp, "mnt") + subprocess.run(["/usr/bin/hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", mnt, source], check=True, capture_output=True) + try: + found = [n for n in os.listdir(mnt) if n.startswith("Companion") and n.endswith(".app")] + if not found: + raise RuntimeError("no Companion app inside %s" % source) + subprocess.run(["/usr/bin/ditto", os.path.join(mnt, found[0]), app], check=True) + finally: + subprocess.run(["/usr/bin/hdiutil", "detach", mnt], capture_output=True) + else: + subprocess.run(["/usr/bin/ditto", "-x", "-k", source, tmp], check=True) + found = [n for n in os.listdir(tmp) if n.startswith("Companion") and n.endswith(".app")] + if not found: + raise RuntimeError("no Companion app inside %s" % source) + subprocess.run(["/usr/bin/ditto", os.path.join(tmp, found[0]), app], check=True) + say("Companion installed as %s" % app) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + def summary(inv): known = set(autoconf.COMPANION) return {"connections": len(inv.get("connections") or []), @@ -564,13 +606,15 @@ def main(argv=None): ap.add_argument("--data-dir", default=PARENT) ap.add_argument("--home", help="the settings page this campus was installed from (the campus list lives there)") ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every campus)") + ap.add_argument("--standalone", action="store_true", help="a campus that runs on its own: reports to no other campus") + ap.add_argument("--from", dest="source", help="upgrade-companion: a Companion .dmg or .zip downloaded from bitfocus.io") a = ap.parse_args(argv) if a.what == "setup": - setup(a.data_dir, a.id, a.name, home=a.home) + setup(a.data_dir, a.id, a.name, home=a.home, standalone=a.standalone) elif a.what == "rebuild": rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None) elif a.what == "upgrade-companion": - upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020")) + upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"), source=a.source) elif a.what == "look": cfg = sp.load_config(os.path.join(a.data_dir, "config.json")) inv = discover.look(cfg, a.data_dir) diff --git a/settings/campuses.py b/settings/campuses.py index 8e556b9..e98ec85 100644 --- a/settings/campuses.py +++ b/settings/campuses.py @@ -16,6 +16,10 @@ at 99): the CAMPUS page's keys are cells ("c_1_3"), and the hub fills them from 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. + +A standalone campus (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. """ import gzip import logging @@ -271,7 +275,7 @@ class Mirror: @staticmethod def is_hub(data): - return not data.get("home") + 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.""" diff --git a/settings/server.py b/settings/server.py index 8033f42..b69e8d0 100644 --- a/settings/server.py +++ b/settings/server.py @@ -722,8 +722,9 @@ class Handler(http.server.BaseHTTPRequestHandler): if path == "/api/campuses": # The hub's own view (its settings page). A campus only ever lists itself. data = campuses.load(site.data_dir) - return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if not data.get("home") else {}, - "error": data.get("error"), "hub": not data.get("home")}) + hub = campuses.Mirror.is_hub(data) + return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if hub else {}, + "error": data.get("error"), "hub": hub, "standalone": bool(data.get("standalone"))}) if path == "/api/fleet": return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())}) if path == "/api/discover": @@ -732,7 +733,7 @@ class Handler(http.server.BaseHTTPRequestHandler): return self.send(200, {"auto": bool((cfg.get("discover") or {}).get("auto")), "last": campus.load_last(site.data_dir), "joined": discover.registered(site.data_dir), "join": {"mac": "curl -fsSL %s/join | sh" % base, "pc": "irm %s/join.ps1 | iex" % base}, - "base": base}) + "base": base, "hub": campuses.Mirror.is_hub(campuses.load(site.data_dir))}) if path == "/api/config": cfg = site.load() return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime}) @@ -911,6 +912,8 @@ class Handler(http.server.BaseHTTPRequestHandler): or not allowed_client(url[7:].split(":")[0]): return self.send(400, {"error": "an id like ws and an address like http://10.5.1.1:8020"}) data = campuses.load(site.data_dir) + if not campuses.Mirror.is_hub(data): + return self.send(403, {"error": "this settings page keeps no list of campuses"}) 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) diff --git a/settings/ui/app.js b/settings/ui/app.js index 1f650c3..4a5e3c4 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -949,8 +949,8 @@

Computers${hint("Each computer runs the join line once: it lets this Mac log in with its key and says it is here. The next look gives its programs keys.")}

${comps}${joined}${comps || joined ? "" : ""}
none yet
${line(d.join.mac, "Mac: Terminal, paste")}${line(d.join.pc, "PC: PowerShell as administrator, paste")}
-

Set up another campus${hint("On that campus's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}

- ${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}
`; + ${d.hub ? `

Set up another campus${hint("On that campus's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}

+ ${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}
` : ""}`; } function siteHtml(c) { const f = F("site.editing_code"); diff --git a/tests/test_campus.py b/tests/test_campus.py index bff1655..5eba74c 100644 --- a/tests/test_campus.py +++ b/tests/test_campus.py @@ -459,3 +459,75 @@ class TestCampusHello(TestRoutes): get(self.port, "/api/campus/hello", {"id": "new", "url": "http://10.5.1.9:8020"}) get(self.port, "/api/campus/hello", {"id": "sc", "name": "Southcoast", "url": "http://10.5.1.9:8020"}) self.assertNotIn("new", cv.load(self.data)["list"]) # same Mac, now named: the old entry goes + + +class TestStandalone(unittest.TestCase): + """Apollo Beach runs its own panel (2026-10-21): it tells nobody, keeps no campus list, backs itself up.""" + + def test_a_standalone_campus_tells_nobody_and_is_no_hub(self): + from settings import campuses as cv + data_dir = tempfile.mkdtemp(prefix="sp-sa-") + cv.save(data_dir, {"standalone": True}) + self.assertFalse(cv.Mirror.is_hub(cv.load(data_dir))) + self.assertTrue(cv.Mirror.is_hub({})) # NA: no home + self.assertFalse(cv.Mirror.is_hub({"home": "http://10.1.1.1:8020"})) + + class FakeSite: + def __init__(self): + self.data_dir = data_dir + + def load(self): + return {"site": {"id": "ab", "name": "Apollo Beach"}} + sent = [] + real, real_url = cv.http_json, cv.my_url + cv.http_json = lambda url, body=None, timeout=3: sent.append(url) or {"ok": True} + cv.my_url = lambda cfg: "http://10.4.1.1:8020" + try: + m = cv.Mirror(FakeSite()) + data = m.sync_registry() + m.poll() + m.refresh_pages() + finally: + cv.http_json, cv.my_url = real, real_url + self.assertEqual(sent, []) + self.assertEqual(list(data["list"]), ["ab"]) + + def test_setup_marks_it_standalone_and_drops_any_home(self): + from settings import campuses as cv + data_dir = tempfile.mkdtemp(prefix="sp-sa-") + cv.save(data_dir, {"home": "http://10.1.1.1:8020"}) + real = campus.is_fresh + campus.is_fresh = lambda cfg: False # set up already: only the look runs + real_look = campus.look_and_add + campus.look_and_add = lambda site, say=print, force=False: None + real_site = server.Site + server.Site = lambda d: type("S", (), {"load": lambda self: {}})() + try: + campus.setup(data_dir, "ab", "Apollo Beach", say=lambda *a: None, standalone=True) + finally: + campus.is_fresh, campus.look_and_add, server.Site = real, real_look, real_site + data = cv.load(data_dir) + self.assertTrue(data.get("standalone")) + self.assertNotIn("home", data) + + def test_the_standalone_zip_has_the_program_an_installer_and_no_settings(self): + import zipfile + sys.path.insert(0, os.path.join(ROOT, "tools")) + import standalone + out = tempfile.mkdtemp(prefix="sp-zip-") + path = standalone.build("ab", "Apollo Beach", out) + with zipfile.ZipFile(path) as z: + names = z.namelist() + inst = z.read("Apollo Beach Status Panel/install.command").decode() + mode = z.getinfo("Apollo Beach Status Panel/install.command").external_attr >> 16 + top = "Apollo Beach Status Panel/program/" + for f in ("status_panel.py", "install.sh", "settings/campus.py", "sites/_template/config.json", "backup/backup-campuses.sh"): + self.assertIn(top + f, names) + for never in ("config.json", "outlets.json", "campuses.json", "sites/ws/config.json", "sites/sc/config.json"): + self.assertNotIn(top + never, names) + self.assertFalse([n for n in names if "__pycache__" in n or "/._" in n]) + self.assertIn("Apollo Beach Status Panel/READ ME FIRST.txt", names) + self.assertTrue(mode & 0o100) + self.assertIn("--standalone", inst) + self.assertNotIn("10.1.1.1", inst) + self.assertNotIn("authorized_keys", inst) # no one else's key is let in diff --git a/tools/standalone.py b/tools/standalone.py new file mode 100644 index 0000000..bc4cbf7 --- /dev/null +++ b/tools/standalone.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""A status panel a campus installs and runs on its own: no North Attleboro. + + python3 tools/standalone.py ab "Apollo Beach" -> dist/Apollo Beach Status Panel.zip + +Apollo Beach, 2026-10-21 (the user): "they want to take care of it on their own, so make a sorta +standalone version that i can send them. i dont need to monitor AB from NA like i do for SC and WS". +The zip holds the program (the same files a campus downloads from NA, never NA's settings, plug keys +or login keys), an installer and a read-me. Installed, the campus sets itself up from what it finds, +exactly as Southcoast and Woonsocket did, but it reports to nobody (campuses.json "standalone"), lets +no other Mac in, and backs itself up every night into ~/Documents/Status Panel Backups. +""" +import os +import re +import sys +import zipfile + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) +from settings.campus import KIT_FILES, KIT_DIRS # noqa: E402 + +INSTALL = r"""#!/bin/zsh +# Status panel for %(name)s: installs on the Mac that runs Companion and sets itself up from what it finds. +# Safe to run again: it then updates the program and looks for new gear. +set -e +HERE=${0:A:h} +if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then + echo "First the Command Line Tools: a window opens - click Install, wait for it, then run this again." + /usr/bin/xcode-select --install 2>/dev/null || true + exit 1 +fi +DIR="$HOME/status-panel-install" +rm -rf "$DIR" && mkdir -p "$DIR" +/usr/bin/ditto "$HERE/program" "$DIR" +/usr/bin/xattr -dr com.apple.quarantine "$DIR" 2>/dev/null || true +[[ -f "$HOME/.ssh/id_status_panel" ]] || { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" && /usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName)" -f "$HOME/.ssh/id_status_panel"; } +cd "$DIR" +zsh install.sh --site %(cid)s +cd "$HOME/Library/Application Support/status-panel" +/usr/bin/python3 -m settings.campus setup --id %(cid)s --name %(qname)s --standalone +# This Mac's wired address (the default route's port). +IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}') +ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname) +echo +echo "Settings page: http://$ME:8020 (the editing code is printed above)" +echo +echo "Next, on each other computer here (ProPresenter, Tracks, Lighting, FOH...):" +echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh" +echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)" +echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex" +echo "Then on the settings page: Auto-setup > Look now." +""" + +README = """%(name)s - Status Panel +%(rule)s + +What it is + A status page on the Stream Deck (Bitfocus Companion) that watches your gear - ProPresenter, the + ATEM, Resi, consoles, Tracks, Lighting, MIDI links, the network - and fixes what it can. It runs on + the Mac that runs Companion. It is yours: nothing is sent to any other campus. + +Before you start + - Companion 5.0.7 on that Mac, with its HTTP API on (Companion > Settings > Protocols). + If yours is older, the installer says so and how to upgrade (download 5.0.7 for Mac from + https://bitfocus.io/companion; your buttons come along). + - Remote Login on for the other Macs (System Settings > General > Sharing), so the panel can check + and restart programs on them. + +Install (about 5 minutes) + 1. Copy this folder to the Companion Mac (Downloads is fine). + 2. Open Terminal, type zsh and a space, drag "install.command" from this folder into the + window, press Return. + 3. It finds what you have, makes the Stream Deck pages (19 status, 20 restart, or the first free + pages), and prints the settings page address and its editing code (PIN). Keep the PIN. + 4. On each other computer, paste the line it prints (Mac: Terminal; PC: PowerShell as + administrator). Then on the settings page: Auto-setup > Look now. New gear gets keys by itself. + +Using it (the Stream Deck) + - Green means fine. Flashing red names the problem, for example "PROP OFFLINE". + - Press a red key to acknowledge it: it stops flashing and stays dark red. + - Hold any key for 2 seconds to fix it: it reconnects, restarts or reopens whatever is failing. + - Purple means the panel itself is not running: restart the Companion Mac, or run the installer again. + - ALARMS (bottom-left) silences alarms and stops automatic fixes until midnight. Use it during + maintenance. + - The page-number key on every page turns red when something is wrong. Hold it for 1 second to + jump to the status page. + - RESTART PAGE opens the restart page. Hold a key there for 2 seconds to restart that program even + if it looks fine. + - Automatic fixes run Tuesday 2-10 pm, Wednesday 9 am-9 pm and Sunday 5 am-2 pm. Change the hours + on the settings page (Auto-fix) to match your services. + - Nothing is ever restarted while the HyperDeck is recording. + +The settings page (http://:8020) + Everything is changed there: keys, checks, fixes, auto-fix hours, the Restart page. Every Apply is + kept, and History puts back any earlier one. Changing gear? Auto-setup > Look now adds what is new. + +Backups + Every night at 4:30 am the settings are copied to ~/Documents/Status Panel Backups/%(cid)s on the + Companion Mac (the newest 30 are kept). Copy that folder somewhere safe now and then, or point it at + a Google Drive folder (ask whoever installed it). + +Run it again + Running install.command again updates the program and looks for new gear; your settings stay. +""" + + +def build(cid, name, out_dir): + cid = re.match(r"[a-z0-9]{1,8}", cid.lower()).group(0) + top = "%s Status Panel" % name + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, top + ".zip") + + def add_file(z, src, arc, mode=0o644): + info = zipfile.ZipInfo.from_file(src, arc) + info.external_attr = (0o100000 | mode) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + with open(src, "rb") as f: + z.writestr(info, f.read()) + + def add_text(z, text, arc, mode=0o644): + info = zipfile.ZipInfo(arc, date_time=(2026, 10, 21, 0, 0, 0)) + info.external_attr = (0o100000 | mode) << 16 + info.compress_type = zipfile.ZIP_DEFLATED + z.writestr(info, text) + + with zipfile.ZipFile(path, "w") as z: + prog = top + "/program/" + for f in KIT_FILES + ("install.sh", "README.md"): + p = os.path.join(ROOT, f) + if os.path.exists(p): + add_file(z, p, prog + f, 0o755 if f.endswith(".sh") else 0o644) + for d in KIT_DIRS + ("sites/_template",): + for base, dirs, files in os.walk(os.path.join(ROOT, d)): + dirs[:] = [x for x in dirs if x != "__pycache__"] + for f in files: + if f.endswith(".pyc") or f.startswith("._") or f == ".DS_Store": + continue + p = os.path.join(base, f) + add_file(z, p, prog + os.path.relpath(p, ROOT), 0o755 if f.endswith(".sh") else 0o644) + q = "'" + name.replace("'", "'\"'\"'") + "'" + add_text(z, INSTALL % {"name": name, "cid": cid, "qname": q}, top + "/install.command", 0o755) + add_text(z, README % {"name": name, "cid": cid, "rule": "=" * (len(name) + 15)}, top + "/READ ME FIRST.txt") + return path + + +if __name__ == "__main__": + if len(sys.argv) < 3: + sys.exit(__doc__) + print(build(sys.argv[1], sys.argv[2], os.path.join(ROOT, "dist")))