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__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.DS_Store
|
.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 |
|
| 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 |
|
| 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);
|
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.
|
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
|
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).
|
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
|
## Backups
|
||||||
|
|
||||||
Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every
|
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
|
# 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.
|
# 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
|
# 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
|
# 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).
|
# 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
|
[ -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)' \
|
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 || { log "not the hub: nothing to back up from here"; exit 0; }
|
"$DEST/campuses.json" 2>/dev/null)
|
||||||
|
[ "$MODE" = hub ] || [ "$MODE" = standalone ] || { log "not the hub: nothing to back up from here"; exit 0; }
|
||||||
|
|
||||||
DRIVE=""
|
DRIVE=""
|
||||||
for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do
|
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; }
|
[ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; }
|
||||||
done
|
done
|
||||||
|
fi
|
||||||
if [ -z "$DRIVE" ]; then
|
if [ -z "$DRIVE" ]; then
|
||||||
alert "tonight's backup did not run - the Google Drive folder is missing" \
|
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."
|
"Shared drives > Waters Creative > Apps could not be found on this Mac. Usually Google Drive is not running or signed out."
|
||||||
|
|||||||
+52
-8
@@ -153,7 +153,7 @@ def wait_applied(applier, limit=240):
|
|||||||
return applier.status or {}
|
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."""
|
"""None when this Companion can take the panel (5.x), else what to tell the person."""
|
||||||
try:
|
try:
|
||||||
discover.companion_get(cfg, "/api/connections", 5)
|
discover.companion_get(cfg, "/api/connections", 5)
|
||||||
@@ -162,6 +162,11 @@ def companion_version_problem(cfg):
|
|||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5)
|
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 "
|
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"
|
"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"
|
" 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."
|
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):
|
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)."""
|
"""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
|
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 = campuses.load(data_dir)
|
||||||
|
if standalone:
|
||||||
|
data.pop("home", None)
|
||||||
|
data["standalone"] = True
|
||||||
|
else:
|
||||||
data["home"] = home
|
data["home"] = home
|
||||||
|
data.pop("standalone", None)
|
||||||
campuses.save(data_dir, data)
|
campuses.save(data_dir, data)
|
||||||
site = server.Site(data_dir)
|
site = server.Site(data_dir)
|
||||||
cfg = site.load()
|
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"))
|
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)
|
return look_and_add(site, say=say, force=True)
|
||||||
name = name or CAMPUSES.get(cid) or cid.upper()
|
name = name or CAMPUSES.get(cid) or cid.upper()
|
||||||
problem = companion_version_problem(cfg)
|
problem = companion_version_problem(cfg, standalone)
|
||||||
if problem:
|
if problem:
|
||||||
raise RuntimeError(problem)
|
raise RuntimeError(problem)
|
||||||
for line in companion_prepare(cfg):
|
for line in companion_prepare(cfg):
|
||||||
@@ -271,7 +282,7 @@ def clear_old_page(cfg, page):
|
|||||||
return "cleared"
|
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:
|
"""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
|
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."""
|
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
|
import urllib.request
|
||||||
root = os.path.expanduser("~/Library/Application Support/companion")
|
root = os.path.expanduser("~/Library/Application Support/companion")
|
||||||
app = "/Applications/Companion 5.0.7.app"
|
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):
|
if not os.path.exists(app):
|
||||||
say("Downloading Companion 5.0.7 from %s ..." % base)
|
say("Downloading Companion 5.0.7 from %s ..." % base)
|
||||||
tmp = "/tmp/companion-5.0.7.zip"
|
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")
|
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):
|
def summary(inv):
|
||||||
known = set(autoconf.COMPANION)
|
known = set(autoconf.COMPANION)
|
||||||
return {"connections": len(inv.get("connections") or []),
|
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("--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("--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("--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)
|
a = ap.parse_args(argv)
|
||||||
if a.what == "setup":
|
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":
|
elif a.what == "rebuild":
|
||||||
rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None)
|
rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None)
|
||||||
elif a.what == "upgrade-companion":
|
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":
|
elif a.what == "look":
|
||||||
cfg = sp.load_config(os.path.join(a.data_dir, "config.json"))
|
cfg = sp.load_config(os.path.join(a.data_dir, "config.json"))
|
||||||
inv = discover.look(cfg, a.data_dir)
|
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
|
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
|
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.
|
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 gzip
|
||||||
import logging
|
import logging
|
||||||
@@ -271,7 +275,7 @@ class Mirror:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_hub(data):
|
def is_hub(data):
|
||||||
return not data.get("home")
|
return not data.get("home") and not data.get("standalone")
|
||||||
|
|
||||||
def sync_registry(self):
|
def sync_registry(self):
|
||||||
"""A campus: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
|
"""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":
|
if path == "/api/campuses":
|
||||||
# The hub's own view (its settings page). A campus only ever lists itself.
|
# The hub's own view (its settings page). A campus only ever lists itself.
|
||||||
data = campuses.load(site.data_dir)
|
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 {},
|
hub = campuses.Mirror.is_hub(data)
|
||||||
"error": data.get("error"), "hub": not data.get("home")})
|
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":
|
if path == "/api/fleet":
|
||||||
return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())})
|
return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())})
|
||||||
if path == "/api/discover":
|
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),
|
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),
|
"joined": discover.registered(site.data_dir),
|
||||||
"join": {"mac": "curl -fsSL %s/join | sh" % base, "pc": "irm %s/join.ps1 | iex" % base},
|
"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":
|
if path == "/api/config":
|
||||||
cfg = site.load()
|
cfg = site.load()
|
||||||
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
|
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]):
|
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"})
|
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)
|
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", {})
|
lst = data.setdefault("list", {})
|
||||||
for other in [k for k, e in lst.items() if k != cid and (e or {}).get("url") == url]:
|
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.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="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>
|
<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>
|
${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>
|
${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>`;
|
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>` : ""}`;
|
||||||
}
|
}
|
||||||
function siteHtml(c) {
|
function siteHtml(c) {
|
||||||
const f = F("site.editing_code");
|
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": "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"})
|
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
|
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