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:
@@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
dist/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))
|
||||
Reference in New Issue
Block a user