"Location", never "campus", in everything people read; SC FOH joined with its MIDI links

- pages (NA 98 Locations / 99 Location, re-imported via PAGES_VERSION), keys, settings page, setup and
  install messages, alerts, docs, the Apollo Beach kit; python3 -m settings.location and /location/...
  addresses (the old names still answer for installed Macs)
- Apply reads the before settings from the file: an in-place edit skipped the watchdog restart
- SC: SUPERRACK (+SoundGrid) and X32 EDIT from SC FOH; FOH TRACKS and TRACKS LIGHTS MIDI links watched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 13:42:22 -04:00
co-authored by Claude Opus 5.5
parent d0c755824b
commit 7068244ba4
31 changed files with 951 additions and 788 deletions
+4 -1
View File
@@ -499,8 +499,11 @@ class Applier:
def _pipeline(self, new_cfg, note, by, address, atem_file):
site = self.site
old_cfg = site.load()
site.load()
sha_before = site.cfg_sha
# From the file, not the cached object: a caller that edited site.load()'s result in place would
# otherwise compare the new settings with themselves and skip the watchdog restart (2026-10-21).
old_cfg = sp.load_config(site.config_path)
if self.status.get("emergency"):
self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason")))
# 1. check
+10 -10
View File
@@ -1,10 +1,10 @@
"""Auto-setup: what a campus has (settings/discover.py's inventory) becomes its keys, checks, fixes
"""Auto-setup: what a location has (settings/discover.py's inventory) becomes its keys, checks, fixes
and page layout - the same building blocks North Attleboro's were made of by hand.
Pure functions, no I/O:
build(base, inv, campus, pin) a whole config for a new campus, from the empty template
extend(cfg, inv, seen) a running campus: only what is new (never removes anything)
build(base, inv, location, pin) a whole config for a new location, from the empty template
extend(cfg, inv, seen) a running location: only what is new (never removes anything)
Everything that comes out passes schema.validate and can be changed on the settings page like any
hand-made setting. Gear auto-setup does not know (a generic connection, an app it has no recipe
@@ -20,7 +20,7 @@ import status_panel as sp
# --- what auto-setup knows -------------------------------------------------------------
# Group keys sum up their members on a big campus, in this order along the top row.
# Group keys sum up their members on a big location, in this order along the top row.
GROUPS = ["REC", "AUDIO", "RF", "SHOW", "VIDEO", "STREAM"]
# Who gets a cell of its own when not everything fits (the rest show inside their group key only):
# a row of wireless receivers folds first, as at North Attleboro.
@@ -86,7 +86,7 @@ SHARED_FIXES = {
}
STATUS_ROWS, COLS = 4, 8
# North Attleboro's auto-fix hours, for every campus: Tuesday and Wednesday rehearsals, Sunday services.
# North Attleboro's auto-fix hours, for every location: Tuesday and Wednesday rehearsals, Sunday services.
DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
"restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"},
{"days": ["wed"], "start": "09:00", "end": "21:00"},
@@ -408,7 +408,7 @@ def layout(cfg, new_tiles, restart_items):
def place_new(cfg, tile):
"""One more key on a running campus: the first free cell, else inside its group, else on ALL only."""
"""One more key on a running location: the first free cell, else inside its group, else on ALL only."""
taken = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
if cfg.get("restart_page"):
taken.add(tuple(cfg["restart_page"]["jump_pos"]))
@@ -423,7 +423,7 @@ def place_new(cfg, tile):
def restart_layout(cfg, restart_items):
"""The Restart page always exists (a campus set up before its computers joined gets their keys later)."""
"""The Restart page always exists (a location set up before its computers joined gets their keys later)."""
rp = cfg.get("restart_page")
if not rp:
return
@@ -446,7 +446,7 @@ def nets_of(inv):
def build(base, inv, campus, pin, pages=(19, 20)):
"""A whole config for a new campus: the template, this Mac, and a key for everything found."""
"""A whole config for a new location: the template, this Mac, and a key for everything found."""
cfg = copy.deepcopy(base)
ing = inv.get("ingest") or {}
wired = ing.get("wired") or {}
@@ -485,7 +485,7 @@ def build(base, inv, campus, pin, pages=(19, 20)):
def extend(cfg, inv, seen):
"""What is new since the last look, added to a running campus. Never removes or changes what is
"""What is new since the last look, added to a running location. Never removes or changes what is
there; a thing seen before (and so deliberately taken off by someone) is not added again.
Returns (new config, [what was added, in words], [identities now seen])."""
cfg = copy.deepcopy(cfg)
@@ -570,7 +570,7 @@ def keep_from(new, old):
cfg = copy.deepcopy(new)
have = referenced(cfg)
kept = []
# Switch keys made for this campus by hand (Southcoast's LIGHTS) and an AUTO-FIX key: auto-setup makes neither.
# Switch keys made for this location by hand (Southcoast's LIGHTS) and an AUTO-FIX key: auto-setup makes neither.
for t in old.get("tiles", []):
if t.get("kind") not in ("bypass", "autofix") or t["id"] in {x["id"] for x in cfg["tiles"]}:
continue
+20 -20
View File
@@ -1,15 +1,15 @@
"""A campus that sets itself up.
"""A location that sets itself up.
On the campus's Mac that runs Companion, one line installs the panel from another campus's settings
On the location's Mac that runs Companion, one line installs the panel from another location's settings
page and runs `setup`: Companion gets the panel's custom variables and blink clock, two free pages
are picked, the gear is found (settings/discover.py) and turned into keys (settings/autoconf.py),
and that config is applied like any Apply on the page. Each other computer runs one join line
(Terminal on a Mac, an administrator PowerShell on a PC); from then on the settings page looks again
every ten minutes and adds what is new by itself (Settings > Site: "Add new gear by itself").
python3 -m settings.campus setup --id ab [--name "Apollo Beach"]
python3 -m settings.campus look what it finds (nothing changes)
python3 -m settings.campus add add what is new now
python3 -m settings.location setup --id ab [--name "Apollo Beach"]
python3 -m settings.location look what it finds (nothing changes)
python3 -m settings.location add add what is new now
"""
import argparse
import base64
@@ -35,7 +35,7 @@ VARIABLES = ("sp_state", "sp_hb", "sp_cmd", "flash_tick")
TRIGGER_NAME = "flash_tick_500"
LOOK_EVERY = 600
SEEN, LAST = "discover-seen.json", "discover-last.json"
# What the program kit carries (no settings, keys or history of the campus that serves it).
# What the program kit carries (no settings, keys or history of the location that serves it).
KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py",
"nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json")
KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup")
@@ -165,21 +165,21 @@ def companion_version_problem(cfg, standalone=False):
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"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location 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"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion\n"
"then paste the install line again.")
except OSError:
return "Companion does not answer on port 8000: open Companion (and its HTTP API, Settings > Protocols), then paste the line again."
def setup(data_dir, cid, name=None, say=print, home=None, 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 location (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 or standalone: # where it was installed from: the campus list lives there
if home or standalone: # where it was installed from: the location list lives there
data = campuses.load(data_dir)
if standalone:
data.pop("home", None)
@@ -191,7 +191,7 @@ def setup(data_dir, cid, name=None, say=print, home=None, standalone=False):
site = server.Site(data_dir)
cfg = site.load()
if not is_fresh(cfg):
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 location"))
return look_and_add(site, say=say, force=True)
name = name or CAMPUSES.get(cid) or cid.upper()
problem = companion_version_problem(cfg, standalone)
@@ -223,7 +223,7 @@ def setup(data_dir, cid, name=None, say=print, home=None, standalone=False):
def rebuild(data_dir, say=print, pages=None):
"""A campus's setup made again from what is found now (today's recipes), keeping its name, PIN,
"""A location's setup made again from what is found now (today's recipes), keeping its name, PIN,
pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one)."""
from settings import server
site = server.Site(data_dir)
@@ -236,7 +236,7 @@ def rebuild(data_dir, say=print, pages=None):
if not rp:
rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"])
pages = tuple(pages or (cfg["page"], rp))
if pages != (cfg["page"], rp): # moving (19/20 like every campus): only onto pages that are free or ours
if pages != (cfg["page"], rp): # moving (19/20 like every location): only onto pages that are free or ours
comp = cfg.get("companion") or {}
for p in pages:
ok, why = push_page.safe_target(push_page.fetch_page(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), p))
@@ -390,7 +390,7 @@ def blocked(cfg, applier, now=None, asked=False):
def look_and_add(site, say=lambda s: None, force=False):
"""Look, and add what is new (when the campus allows it, and not during a service)."""
"""Look, and add what is new (when the location allows it, and not during a service)."""
cfg = site.load()
inv = discover.look(cfg, site.data_dir)
seen = load_seen(site.data_dir)
@@ -488,7 +488,7 @@ def shq(s):
def install_script(base, cid, support_key=None):
"""What a new campus's Companion Mac runs: install from `base` (this page), then set itself up."""
"""What a new location's Companion Mac runs: install from `base` (this page), then set itself up."""
m = re.match(r"[a-z0-9]+", str(cid).lower())
cid = m.group(0)[:8] if m else "new"
name = CAMPUSES.get(cid, cid.upper())
@@ -506,7 +506,7 @@ fi
DIR="$HOME/status-panel-install"
mkdir -p "$DIR" && cd "$DIR"
echo "Downloading the status panel from $SRC ..."
/usr/bin/curl -fsSL "$SRC/campus/program.tgz" | /usr/bin/tar -xz
/usr/bin/curl -fsSL "$SRC/location/program.tgz" | /usr/bin/tar -xz
[[ -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"; }
SUPPORT=%(key)s
if [[ -n "$SUPPORT" ]]; then
@@ -515,7 +515,7 @@ if [[ -n "$SUPPORT" ]]; then
fi
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 --home "$SRC"
/usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --home "$SRC"
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
echo
echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely."
@@ -604,9 +604,9 @@ def main(argv=None):
ap.add_argument("--id", default="new")
ap.add_argument("--name")
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("--home", help="the settings page this location was installed from (the location list lives there)")
ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every location)")
ap.add_argument("--standalone", action="store_true", help="a location that runs on its own: reports to no other location")
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":
+47 -42
View File
@@ -1,23 +1,23 @@
"""Every campus's status, on North Attleboro's Companion only.
"""Every location's status, on North Attleboro's Companion only.
Each other campus's settings page tells North Attleboro's (the hub: a campus with no "home") where it
is, and learns nothing back: no other campus ever sees another campus (2026-10-20, the user: "NA
Each other location's settings page tells North Attleboro's (the hub: a location with no "home") where it
is, and learns nothing back: no other location ever sees another location (2026-10-20, the user: "NA
should be the only one that sees every single location's status"). Only the hub, every two seconds,
copies each campus's key states (what that campus's watchdog draws, /api/state) into its own
copies each location's key states (what that location's watchdog draws, /api/state) into its own
Companion as custom variables sp_state_<id> / sp_hb_<id>, and keeps two kinds of page there:
CAMPUSES page one key per campus: that campus's ALL key, with its name; tap = its status page
CAMPUS page whichever campus was tapped, as its own status page looks (same places, same
colours, live), view only: holds and presses are for that campus's own deck.
The ALARMS corner goes back to the CAMPUSES page.
LOCATIONS page one key per location: that location's ALL key, with its name; tap = its status page
LOCATION page whichever location was tapped, as its own status page looks (same places, same
colours, live), view only: holds and presses are for that location's own deck.
The ALARMS corner goes back to the LOCATIONS page.
Two pages, whatever the number of campuses (NA's Companion has 97 pages of its own; Companion stops
at 99): the CAMPUS page's keys are cells ("c_1_3"), and the hub fills them from the tapped campus's
layout (sp_view says which). A campus that stops answering goes purple. Pages are only written
Two pages, whatever the number of locations (NA's Companion has 97 pages of its own; Companion stops
at 99): the LOCATION page's keys are cells ("c_1_3"), and the hub fills them from the tapped location's
layout (sp_view says which). A location 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
A standalone location (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.
"""
@@ -70,17 +70,22 @@ def me(cfg, url=None):
import pwd
site = cfg.get("site") or {}
# The account this panel runs as: the hub logs in with it for the nightly backup.
return {"id": site.get("id") or "new", "name": site.get("name") or "This campus", "url": url or my_url(cfg),
return {"id": site.get("id") or "new", "name": site.get("name") or "This location", "url": url or my_url(cfg),
"user": pwd.getpwuid(os.getuid()).pw_name}
# Bumped when the pages are built differently (2: "campus" became "location", 2026-10-21), so the hub
# re-imports them.
PAGES_VERSION = 2
def layout_sha(cfg):
keys = [(t.get("id"), t.get("label"), t.get("pos"), t.get("kind"), t.get("members")) for t in cfg.get("tiles", [])]
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables")], sort_keys=True, default=str).encode()).hexdigest()[:16]
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables"), PAGES_VERSION], sort_keys=True, default=str).encode()).hexdigest()[:16]
def state_now(site):
"""What /api/state answers: this campus's key states as its watchdog draws them, right now."""
"""What /api/state answers: this location's key states as its watchdog draws them, right now."""
cfg = site.load()
names = cfg.get("variables") or {}
comp = site.comp
@@ -100,8 +105,8 @@ def http_json(url, body=None, timeout=3):
VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"}
SELECT = "sp_view"
VIEW_CMD = "sp_view_cmd" # a tap or hold on the CAMPUS page, passed on to that campus's own watchdog
MODE = "sp_view_mode" # "" = that campus's status page, "RESTART" = its Restart page
VIEW_CMD = "sp_view_cmd" # a tap or hold on the LOCATION page, passed on to that location's own watchdog
MODE = "sp_view_mode" # "" = that location's status page, "RESTART" = its Restart page
CELLS = [(r, c) for r in range(4) for c in range(1, 8)]
@@ -110,7 +115,7 @@ def cell_id(r, c):
def view_state(state, keys):
"""A campus's key states, moved onto the cells its keys sit in (empty cells blank)."""
"""A location's key states, moved onto the cells its keys sit in (empty cells blank)."""
out = {cell_id(r, c): {"x": "", "b": 0, "b2": 0, "f": 0xFFFFFF, "f2": 0xFFFFFF, "s": 14} for r, c in CELLS}
for k in keys:
r, c = k["pos"]
@@ -120,8 +125,8 @@ def view_state(state, keys):
def build_view(local_cfg, page, overview_page):
"""The CAMPUS page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
that campus (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
"""The LOCATION page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
that location (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
opts = make_page.settings(local_cfg)
controls = make_page.nav_controls(local_cfg)
hold_ms = opts["hold_ms"]
@@ -132,17 +137,17 @@ def build_view(local_cfg, page, overview_page):
make_page.place(controls, (r, c), {
"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
"notes": "STATUS tile 'campus view %d/%d' - the tapped campus's key here. Press: acknowledge it there. "
"Hold %g s: fix it there, like a hold on that campus's own deck." % (r, c, hold_ms / 1000.0)},
"notes": "STATUS tile 'location view %d/%d' - the tapped location's key here. Press: acknowledge it there. "
"Hold %g s: fix it there, like a hold on that location's own deck." % (r, c, hold_ms / 1000.0)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [tap], str(hold_ms): [hold]},
"options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []})
back = make_page.static_button("campus_back", "ALL\\nSITES", 0x37474F, 0xFFFFFF, overview_page,
"Tap: back to the CAMPUSES page (page %d)." % overview_page, "STATUS jump key")
"Tap: back to the LOCATIONS page (page %d)." % overview_page, "STATUS jump key")
for layer in back["style"]["layers"]:
if layer["type"] == "text": # which campus this page shows, then the way back
if layer["type"] == "text": # which location this page shows, then the way back
layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True)
make_page.place(controls, (3, 0), back)
return make_page.page_export("%d\\nCampus" % page, page, controls, local_cfg)
return make_page.page_export("%d\\nLocation" % page, page, controls, local_cfg)
def select_action(cid, mode=""):
@@ -153,7 +158,7 @@ def select_action(cid, mode=""):
def campus_key(cid, name, names, actions, note, local_cfg):
"""One CAMPUSES key: that campus's ALL key with its name on top."""
"""One LOCATIONS key: that location's ALL key with its name on top."""
opts = make_page.settings(local_cfg)
tag = short(cid, name)
stale_text = "`%s\\n?`" % tag
@@ -164,13 +169,13 @@ def campus_key(cid, name, names, actions, note, local_cfg):
text["fontsize"] = make_page.prop("auto")
return {"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
"notes": "STATUS tile 'campus %s' - %s's ALL key. %s" % (cid, name, note)},
"notes": "STATUS tile 'location %s' - %s's ALL key. %s" % (cid, name, note)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
"localVariables": []}
def build_overview(local_cfg, campuses, page, view_page):
"""Row 0: each campus's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page)."""
"""Row 0: each location's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page)."""
controls = make_page.nav_controls(local_cfg)
mine = (local_cfg.get("site") or {}).get("id")
rp = (local_cfg.get("restart_page") or {}).get("page")
@@ -181,7 +186,7 @@ def build_overview(local_cfg, campuses, page, view_page):
key = campus_key(cid, name, local_cfg["variables"], [make_page.set_page_action("campus_" + cid, "go", local_cfg["page"])],
"Tap: its status page (page %d)." % local_cfg["page"], local_cfg)
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, rp,
"Tap: this campus's Restart page (page %s)." % rp, "STATUS jump key") if rp else None
"Tap: this location's Restart page (page %s)." % rp, "STATUS jump key") if rp else None
else:
key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid},
select_action(cid) + [make_page.set_page_action("campus_" + cid, "go", view_page)],
@@ -193,7 +198,7 @@ def build_overview(local_cfg, campuses, page, view_page):
make_page.place(controls, (0, col), key)
if restart:
make_page.place(controls, (1, col), restart)
return make_page.page_export("%d\\nCampuses" % page, page, controls, local_cfg)
return make_page.page_export("%d\\nLocations" % page, page, controls, local_cfg)
def ensure_variables(cfg, ids):
@@ -232,10 +237,10 @@ def free_page(cfg, taken, start=21):
# None empty: a new page at the end (Companion's own page keys on it), never in between.
last = max(p for p in range(1, 100) if exists(host, port, p))
if last >= 99:
raise RuntimeError("Companion has 99 pages and none is empty: free one for the campuses")
raise RuntimeError("Companion has 99 pages and none is empty: free one for the locations")
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["CAMPUSES"]})
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["LOCATIONS"]})
finally:
ws.close()
return last + 1
@@ -278,7 +283,7 @@ class Mirror:
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."""
"""A location: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
cfg = self.site.load()
data = load(self.site.data_dir)
mine = me(cfg)
@@ -300,7 +305,7 @@ class Mirror:
return {p for p in t if p}
def refresh_pages(self):
"""The hub: each campus's layout (when it changed), the CAMPUS page once, the CAMPUSES page when the roster changes."""
"""The hub: each location's layout (when it changed), the LOCATION page once, the LOCATIONS page when the roster changes."""
cfg = self.site.load()
data = self.sync_registry()
if not self.is_hub(data):
@@ -308,7 +313,7 @@ class Mirror:
mine = (cfg.get("site") or {}).get("id")
others = {cid: e for cid, e in data.get("list", {}).items() if cid != mine}
if not others and not (data.get("pages") or {}).get("overview"):
return data # no other campus yet: no pages needed
return data # no other location yet: no pages needed
pages = data.setdefault("pages", {})
layouts = data.setdefault("layouts", {})
for cid, e in sorted(others.items()):
@@ -337,7 +342,7 @@ class Mirror:
return data
def pass_on(self, pick, others, restart=False):
"""A tap or hold on the CAMPUS page goes to the shown campus's watchdog, through its Companion."""
"""A tap or hold on the LOCATION page goes to the shown location's watchdog, through its Companion."""
text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
if not text:
return
@@ -351,12 +356,12 @@ class Mirror:
remote = sp.Companion(host, 8000, timeout=3)
before = (remote.get_custom("sp_cmd") or "").strip()
remote.set_custom("sp_cmd", ";".join([before] + cmds if before else cmds))
log.info("CAMPUS %s: %s (from %s's CAMPUS page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
log.info("LOCATION %s: %s (from %s's LOCATION page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
except OSError as e:
log.warning("CAMPUS %s: could not pass on %s: %s", pick, cmds, e)
log.warning("LOCATION %s: could not pass on %s: %s", pick, cmds, e)
def poll(self):
"""The hub: copy each other campus's key states into this Companion."""
"""The hub: copy each other location's key states into this Companion."""
cfg = self.site.load()
data = load(self.site.data_dir)
if not self.is_hub(data):
@@ -380,10 +385,10 @@ class Mirror:
threads.append(t)
for t in threads:
t.join(2)
# The CAMPUS page shows the tapped campus: its keys moved onto their cells.
# The LOCATION page shows the tapped location: its keys moved onto their cells.
pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it
if pick not in others and others:
# Nothing tapped yet (or a campus that left): show the first one rather than a purple page.
# Nothing tapped yet (or a location that left): show the first one rather than a purple page.
pick = sorted(others)[0]
self.site.comp.set_custom(SELECT, pick.upper())
restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART"
@@ -400,7 +405,7 @@ class Mirror:
def campus_command(keys, text, now, restart=False):
"""The CAMPUS page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that campus's own
"""The LOCATION page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that location's own
commands ("ack hd1", "restart hd1 <time>"; on its Restart page a hold is "force rs_x <time>" and a
tap does nothing, as on its own deck), for the keys at those cells. Stale ones are dropped."""
at = {tuple(k["pos"]): k["id"] for k in keys or []}
+3 -3
View File
@@ -1,4 +1,4 @@
"""Auto-setup's eyes: what this campus has, read from Companion, Dante, the network MIDI sessions,
"""Auto-setup's eyes: what this location has, read from Companion, Dante, the network MIDI sessions,
this Mac's own network, and the computers that joined (each one ran the join line once).
look(cfg, data_dir) the inventory autoconf.py turns into keys
@@ -143,7 +143,7 @@ def this_mac():
info["exes"] = {os.path.basename(p.strip()): p.strip() for p in ps if ".app/Contents/MacOS/" in p}
if wired:
ip, net = ifs[wired]
# A /24 or smaller on a campus usually sits inside a /16 the rest of the gear shares: use what is configured.
# A /24 or smaller on a location usually sits inside a /16 the rest of the gear shares: use what is configured.
info["wired"] = {"interface": wired, "ip": ip, "net": net, "gateway": gateway_of(wired)}
return info
@@ -303,7 +303,7 @@ def computers(cfg, data_dir):
def look(cfg, data_dir):
inv = {"ingest": this_mac(), "at": int(time.time())}
# A campus whose MIDI rule names its wired network keeps it (it may be wider than this Mac's subnet).
# A location whose MIDI rule names its wired network keeps it (it may be wider than this Mac's subnet).
rule = [n for t in sp.all_tiles(cfg) for c in t.get("checks", []) if c.get("type") == "midi_wired" for n in c.get("nets", [])]
if rule and inv["ingest"].get("wired"):
inv["ingest"]["wired"]["net"] = rule[0]
+6
View File
@@ -0,0 +1,6 @@
"""What people type: python3 -m settings.location setup | look | add | rebuild | upgrade-companion
(the same as settings.campus, the module's older name; installed Macs may still call that one)."""
from settings.campus import main
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Smart outlets the panel may switch (a hung recorder's mains, say), through Tuya's cloud with the
campus's own project keys. Credentials and the outlet list live in outlets.json beside the config
location's own project keys. Credentials and the outlet list live in outlets.json beside the config
(0600, never in the repo). Also a command for the watchdog's fixes:
outlet.py list the outlets on the account (name, id, on/off)
+15 -15
View File
@@ -687,14 +687,14 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
if path == "/where.html":
return self.send(200, keyguide.where_things_are(site.load(), site.data_dir, self.my_address()), "text/html; charset=utf-8")
# ---- auto-setup: the install line for a new campus, the join lines for its computers ----
if path == "/campus/install":
# ---- auto-setup: the install line for a new location, the join lines for its computers ----
if path in ("/campus/install", "/location/install"):
key, _ = scripts.public_key(site.load())
return self.send(200, campus.install_script(self.base_url(), q.get("id", ["new"])[0], key), "text/plain; charset=utf-8")
if path == "/campus/program.tgz":
if path in ("/campus/program.tgz", "/location/program.tgz"):
return self.send(200, campus.program_kit(site.data_dir), "application/gzip")
if path == "/campus/companion.zip":
# Companion 5.0.7 as this Mac has it, for `settings.campus upgrade-companion` on a campus
if path in ("/campus/companion.zip", "/location/companion.zip"):
# Companion 5.0.7 as this Mac has it, for `settings.campus upgrade-companion` on a location
# still on an older one (packed once, kept beside the config, streamed).
app = "/Applications/Companion 5.0.7.app"
pack = os.path.join(site.data_dir, "companion-5.0.7.zip")
@@ -721,7 +721,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/state":
return self.send(200, campuses.state_now(site))
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 location only ever lists itself.
data = campuses.load(site.data_dir)
hub = campuses.Mirror.is_hub(data)
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if hub else {},
@@ -824,7 +824,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
if os.path.exists(fp):
z.write(fp, name)
z.writestr("README.txt", "Site bundle of %s, exported %s from the settings page.\nDrop it in the repo as sites/%s/ or feed it to "
"'New campus from a bundle' on another campus's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
"'New location from a bundle' on another location's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
data = buf.getvalue()
self.send_response(200)
self.send_header("Content-Type", "application/zip")
@@ -906,7 +906,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
site.discover_poke.set()
return self.send(200, {"ok": True})
if path == "/api/campus/hello":
# Another campus says where its settings page is (the hub keeps the list for everyone).
# Another location says where its settings page is (the hub keeps the list for everyone).
cid = str(body.get("id") or "")
url = str(body.get("url") or "")
if not re.fullmatch(r"[a-z0-9]{1,8}", cid) or not re.fullmatch(r"http://\d{1,3}(\.\d{1,3}){3}:\d{2,5}", url) \
@@ -914,7 +914,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
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"})
return self.send(403, {"error": "this settings page keeps no list of locations"})
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)
@@ -924,7 +924,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
campuses.save(site.data_dir, data)
return self.send(200, {"ok": True})
if path == "/api/fleet/hello":
# A machine anywhere (any campus) says it now takes the admin laptop's key: kept for the
# A machine anywhere (any location) says it now takes the admin laptop's key: kept for the
# laptop's SSH shortcuts (tools/fleet.py). Nothing here acts on it.
try:
entry = discover.clean_hello(body, self.client_address[0])
@@ -1098,7 +1098,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
cfg = site.load()
mine = cfg.get("site") or {}
src = (new_cfg.get("site") or {}).get("name") or "the other campus"
src = (new_cfg.get("site") or {}).get("name") or "the other location"
new_cfg["site"] = dict(new_cfg.get("site") or {}, **{k: mine[k] for k in ("id", "name", "ingest_name", "ingest_hostname", "ingest_ips", "editing_code", "settings_port") if k in mine})
flag = "copied from %s - check it" % src
for alias, h in (new_cfg.get("hosts") or {}).items():
@@ -1110,7 +1110,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
f["label"] = f.get("label", "")
errors, warnings = schema.validate(new_cfg)
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha)
log.info("%s started a new campus from a bundle of %s", who, src)
log.info("%s started a new location from a bundle of %s", who, src)
return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
if path == "/api/atem-reference/pick-newest":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
@@ -1148,7 +1148,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
def names_check(self, cfg):
"""Every device name the keys use, against what this Companion, the Dante network and the Macs report now:
the mapping step of a copied campus."""
the mapping step of a copied location."""
site = self.site
try:
live = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
@@ -1388,7 +1388,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(404, {"error": "missing %s" % name})
def base_url(self):
"""This page as the caller reached it (an address, across campuses: names do not cross subnets)."""
"""This page as the caller reached it (an address, across locations: names do not cross subnets)."""
host = (self.headers.get("Host") or "").strip() or "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
if not re.fullmatch(r"[A-Za-z0-9.\-]+(:\d+)?", host):
host = "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
@@ -1506,7 +1506,7 @@ def main(argv=None):
stop = threading.Event()
threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start()
threading.Thread(target=campus.loop, args=(site, stop, site.discover_poke), name="auto-setup", daemon=True).start()
threading.Thread(target=campuses.loop, args=(site, stop), name="campuses", daemon=True).start()
threading.Thread(target=campuses.loop, args=(site, stop), name="locations", daemon=True).start()
try:
while True:
time.sleep(3600)
+12 -12
View File
@@ -858,7 +858,7 @@
<li>Settings › MIDI links: add one to the MIDI key; Run the fix on that check.</li>
<li>Keys › Fixes › VISTA: change the program file to Vista2.exe, watch the command rebuild; put it back.</li>
<li>Computers › Setup › Lighting PC: open the script, Copy. Computers › Lighting PC › Remove (read, then Cancel).</li>
<li>Settings › Switcher: Pick the newest file; Discard. Settings › Program & campus: Export bundle.</li>
<li>Settings › Switcher: Pick the newest file; Discard. Settings › Program & location: Export bundle.</li>
<li>A real change (a note on Computers › Gear): APPLY, then History › Undo, APPLY again.</li></ol></details>
<details><summary><b>The words a key can show</b></summary>${glossary}</details>
<details><summary><b>Only a person can</b></summary><ul class="small"><li>Run the set-up script on a new computer, allow Accessibility on the Studio B Mac, click Allow on the Spotify prompt once.</li><li>Create Companion's custom variables and the blink trigger, add pages, install modules, name the Stream Decks.</li><li>Type the Netgear login into the keychain; keep Google Drive signed in.</li><li>Save a new switcher file from ATEM Software Control when the show changes, then pick it under Settings › Switcher.</li></ul></details>
@@ -882,9 +882,9 @@
["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"],
["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"],
["setup", "Auto-setup", "What this Mac found, the line each computer runs to join, and the line that sets up another campus"],
["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"],
["program", "Program & campus", "Update the code, export or start a campus"],
["setup", "Auto-setup", "What this Mac found, the line each computer runs to join, and the line that sets up another location"],
["site", "Site & PIN", "The location, contacts, this Mac, the PIN"],
["program", "Program & location", "Update the code, export or start a location"],
["advanced", "Advanced", "Every number and name, by screen"],
];
async function settings(rest) {
@@ -917,7 +917,7 @@
wireSettings(which);
}
function readySettingsHtml(c) {
if (!c.ready_page) return "<p class='muted'>This campus has no READY page.</p>";
if (!c.ready_page) return "<p class='muted'>This location has no READY page.</p>";
const lists = JSON.stringify((c.ready || {}).prop_lists || [], null, 1);
return `<div class="card"><p style="margin:0">${onOff("ready", c.ready || {}, "the READY page")} ${(c.ready || {}).disabled ? "<b>The READY page is off:</b> its keys show OFF and nothing is looked at. Everything is kept." : "<b>The READY page</b> looks at this weekend every 2 minutes."}</p></div><div class="card"><h3 style="margin-top:0">When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}</h3>${calendarHtml("ready.due", (c.ready || {}).due)}</div>`
+ fieldsHtml("Ready")
@@ -935,7 +935,7 @@
const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))];
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `<h3>${esc(s)}</h3>${h}` : ""; }).join("") || "<p class='muted'>Nothing.</p>";
}
// Auto-setup: what was found and added, the join lines, and the install line for another campus.
// Auto-setup: what was found and added, the join lines, and the install line for another location.
async function setupHtml(c) {
const d = await api("/api/discover").catch(() => null);
if (!d) return "<p class='muted'>Auto-setup is not answering.</p>";
@@ -944,7 +944,7 @@
const comps = Object.entries(inv.computers || {}).map(([a, x]) => `<tr><td>${lightDot(x.reachable ? "ok" : "problem", x.reachable ? "logs in" : x.error || "does not let this Mac in yet")}${esc(x.name || a)}</td><td class="small">${esc(x.os || "")}</td><td class="mono small">${esc(x.ip || "")}</td><td class="small muted">${x.reachable ? "" : esc(x.error || "run the join line on it")}</td></tr>`).join("");
const joined = Object.entries(d.joined || {}).filter(([n]) => !Object.values(inv.computers || {}).some((x) => x.name === n)).map(([n, j]) => `<tr><td>${lightDot("warn", "joined; the next look sets it up")}${esc(n)}</td><td class="small">${esc(j.os)}</td><td class="mono small">${esc((j.ips || [])[0] || "")}</td><td class="small muted">joined ${esc(when(j.at))}</td></tr>`).join("");
const list = (xs) => xs && xs.length ? `<ul class="small">${xs.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>` : "<p class='small muted'>nothing</p>";
const campuses = [["ab", "Apollo Beach"], ["sc", "Southcoast"], ["ws", "Woonsocket"], ["na", "North Attleboro"]].filter(([k]) => k !== (c.site || {}).id);
const locations = [["ab", "Apollo Beach"], ["sc", "Southcoast"], ["ws", "Woonsocket"], ["na", "North Attleboro"]].filter(([k]) => k !== (c.site || {}).id);
return `<div class="card"><p style="margin-top:0">${inputHtml("discover.auto", F("discover.auto"), (c.discover || {}).auto)}</p>
<dl class="kv small"><dt>last look</dt><dd>${last.at ? esc(when(last.at)) : "not yet"}${last.error ? ` · <span style="color:var(--problem)">${esc(last.error)}</span>` : ""}</dd>
<dt>found</dt><dd>${inv.connections != null ? `${inv.connections} Companion connections · ${inv.dante || 0} Dante devices · ${inv.midi_sessions || 0} MIDI sessions · wired network ${esc(inv.wired || "?")}` : "–"}</dd></dl>
@@ -954,8 +954,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>
${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>` : ""}`;
${d.hub ? `<div class="card"><h3 style="margin-top:0">Set up another location${hint("On that location'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>
${locations.map(([k, n]) => line(`curl -fsSL "${d.base}/location/install?id=${k}" | zsh`, n)).join("")}</div>` : ""}`;
}
function siteHtml(c) {
const f = F("site.editing_code");
@@ -1286,7 +1286,7 @@
return `<div class="card"><dl class="kv"><dt>running</dt><dd>code ${esc(p.running_version || "?")}</dd><dt>staged</dt><dd>${staged}${same ? " <span class='muted'>· the same</span>" : ""}</dd><dt>folder</dt><dd class="mono small">${esc(p.folder)}</dd></dl>
<div class="row" style="margin-top:8px"><button id="installbtn" ${editing() && p.present ? "" : "disabled"} data-hint="Copy the new folder here with scp first. install.sh runs by itself, the config stays, the watchdog restarts, this page comes back.">Install the staged folder</button>${p.job === "running" ? " <span class='muted'>running…</span>" : ""}</div>
${p.log && p.log.length ? `<details><summary>last install log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""}</summary><pre class="log small">${esc(p.log.join("\n"))}</pre></details>` : ""}</div>
<div class="card"><div class="row"><a class="btn" href="/api/site/bundle" download data-hint="A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/ or feed it to another campus">Export campus bundle</a>${editing() ? `<label class="btn" data-hint="This campus's name, addresses and PIN stay; every copied computer is flagged until you touch it">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div></div>`;
<div class="card"><div class="row"><a class="btn" href="/api/site/bundle" download data-hint="A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/ or feed it to another location">Export location bundle</a>${editing() ? `<label class="btn" data-hint="This location's name, addresses and PIN stay; every copied computer is flagged until you touch it">New location from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div></div>`;
}
async function outletsHtml() {
const o = await api("/api/outlets");
@@ -1318,7 +1318,7 @@
const conns = n.connections.filter((c) => !c.live).map((c) => `<tr><td>${lightDot("problem")}<span class="mono">${esc(c.name)}</span></td><td class="small">${esc(c.keys.join(", "))}</td><td class="small">${n.companion_ok ? "not in this Companion" : "Companion not answering"}${c.module ? ` <span class="muted">(needs ${esc(c.module)})</span>` : ""}</td><td>${sel("conn", c.name, c.candidates)}</td></tr>`).join("");
const dante = n.dante.filter((d) => !d.seen).map((d) => `<tr><td>${lightDot("warn")}<span class="mono">${esc(d.name)}</span></td><td class="small">DANTE</td><td class="small">not seen on the Dante network</td><td>${sel("dante", d.name, d.candidates)}</td></tr>`).join("");
const midi = n.midi.filter((m) => !m.session_seen || !m.peer_seen).map((m) => `<tr><td>${lightDot("warn")}<span class="mono">${esc(m.session)} › ${esc(m.peer)}</span></td><td class="small">${esc(m.key)}</td><td class="small">${!m.session_seen ? `session not seen${m.host ? " on " + esc((cfg().hosts[m.host] || {}).name || m.host) : ""}${m.sessions_seen.length ? " (seen: " + esc(m.sessions_seen.join(", ")) + ")" : ""}` : `peer not in the session now${m.peers_seen.length ? " (in it: " + esc(m.peers_seen.join(", ")) + ")" : ""}`}</td><td class="small muted">change it on the key</td></tr>`).join("");
return `<div class="card"><h3 style="margin-top:0">Names the keys use that are not here (${n.missing})${hint("A copied campus, or something renamed. Map each name to what this campus has; everything that uses it follows.")}</h3>
return `<div class="card"><h3 style="margin-top:0">Names the keys use that are not here (${n.missing})${hint("A copied location, or something renamed. Map each name to what this location has; everything that uses it follows.")}</h3>
<div class="tablewrap"><table><tr><th>name</th><th>used by</th><th></th><th></th></tr>${conns}${dante}${midi}</table></div></div>`;
}
function renameConn(from, to) {
@@ -1511,7 +1511,7 @@
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim());
const splink = q("#splink"); if (splink) splink.onchange = () => { const m = splink.value.match(/(?:playlist[/:])([A-Za-z0-9]{22})/); if (m) setPath("spotify.playlist", "spotify:playlist:" + m[1]); else if (splink.value.trim()) alertBox("That is not a playlist link."); };
const ib = q("#installbtn"); if (ib) ib.onclick = () => confirmBox("Install the staged folder? The watchdog restarts (the deck goes purple for a few seconds) and this page restarts too.", "Install", async () => { try { const r = await post("/api/program/install", {}); alertBox(r.ok ? r.detail : "Not started: " + r.error); setTimeout(() => location.reload(), 25000); } catch (e) { alertBox(e.message); } });
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new campus from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and the Companion names, then Apply."}`); location.hash = "#settings/companion"; } catch (e) { alertBox(e.message); } }); };
const bf = q("#bundlefile"); if (bf) bf.onchange = async () => { const f = bf.files[0]; if (!f) return; confirmBox(`Start a new location from ${f.name}? Your current draft is replaced.`, "Start", async () => { try { const r = await fetch("/api/site/import", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/octet-stream" }, body: await f.arrayBuffer() }).then((x) => x.json()); if (r.error) return alertBox(r.error); state.draft = r.draft; state.atemPending = true; badge(); alertBox(`Copied from ${r.from}. ${r.errors.length ? "Not applicable yet: " + r.errors.slice(0, 3).join("; ") : "Look through Computers, Keys and the Companion names, then Apply."}`); location.hash = "#settings/companion"; } catch (e) { alertBox(e.message); } }); };
const af = q("#atemfile"); if (af) af.onchange = async () => { const f = af.files[0]; if (!f) return; try { const r = await fetch("/api/atem-reference", { method: "POST", headers: { "X-Edit-Key": state.editing, "X-Editor": state.name, "Content-Type": "application/xml" }, body: await f.arrayBuffer() }).then((x) => x.json()); showAtem(r); } catch (e) { alertBox(e.message); } };
const an = q("#atemnewest"); if (an) an.onclick = async () => { an.disabled = true; an.textContent = "Reading…"; try { showAtem(await post("/api/atem-reference/pick-newest")); } catch (e) { alertBox(e.message); route(); } };
const ad = q("#atemdiscard"); if (ad) ad.onclick = async () => { try { await post("/api/atem-reference/discard"); state.atemPending = false; badge(); route(); } catch (e) { alertBox(e.message); } };