Apollo Beach on its own: a standalone panel to send them

- tools/standalone.py builds dist/<Name> Status Panel.zip: program, install.command, READ ME FIRST.txt
- setup --standalone: campuses.json standalone, tells nobody, no hub, no NA key
- Standalone backs itself up nightly to ~/Documents/Status Panel Backups/<id>
- upgrade-companion --from <.dmg/.zip from bitfocus.io> (no NA needed)
- Only the hub lists campuses or shows other campuses' install lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:12:30 -04:00
co-authored by Claude Opus 5.5
parent bea587cfad
commit 2f8e3d47a3
9 changed files with 314 additions and 21 deletions
+1
View File
@@ -1,3 +1,4 @@
__pycache__/
*.pyc
.DS_Store
dist/
+12
View File
@@ -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/<id>` on its Companion Mac (or `$STATUS_PANEL_BACKUPS`). With an older
Companion: `python3 -m settings.campus upgrade-companion --from <the 5.0.7 .dmg from bitfocus.io>`.
## Backups
Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every
+14 -6
View File
@@ -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."
+53 -9
View File
@@ -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/<the .dmg>\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)
+5 -1
View File
@@ -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."""
+6 -3
View File
@@ -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)
+2 -2
View File
@@ -949,8 +949,8 @@
<div class="card"><h3 style="margin-top:0">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.")}</h3>
<div class="tablewrap"><table>${comps}${joined}${comps || joined ? "" : "<tr><td class='muted'>none yet</td></tr>"}</table></div>
${line(d.join.mac, "Mac: Terminal, paste")}${line(d.join.pc, "PC: PowerShell as administrator, paste")}</div>
<div class="card"><h3 style="margin-top:0">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.")}</h3>
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>`;
${d.hub ? `<div class="card"><h3 style="margin-top:0">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.")}</h3>
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>` : ""}`;
}
function siteHtml(c) {
const f = F("site.editing_code");
+72
View File
@@ -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
+149
View File
@@ -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://<Companion Mac>: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")))