"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
+37 -37
View File
@@ -69,8 +69,8 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`. It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`.
- **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking; press it again to turn them back on; they come back by themselves at midnight. Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch. On a page with no AUTO-FIX key (NA today), ALARMS also stops every auto-fix and the key says both (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`). - **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking; press it again to turn them back on; they come back by themselves at midnight. Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch. On a page with no AUTO-FIX key (NA today), ALARMS also stops every auto-fix and the key says both (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`).
- **AUTO-FIX** (beside ALARMS on the campuses; `kind: autofix`): its own switch since 2026-10-21. On in the auto-fix hours. Press: pause it (`AUTO-FIX OFF / TIL SUN 5AM`: until the hours next start), or run it outside the hours until they next start or end; it goes back to its hours by itself. Holds always fix. With this key, ALARMS only silences. From NA's CAMPUS page a hold is the press. The Overview has a Pause auto-fix button. - **AUTO-FIX** (beside ALARMS on the locations; `kind: autofix`): its own switch since 2026-10-21. On in the auto-fix hours. Press: pause it (`AUTO-FIX OFF / TIL SUN 5AM`: until the hours next start), or run it outside the hours until they next start or end; it goes back to its hours by itself. Holds always fix. With this key, ALARMS only silences. From NA's LOCATION page a hold is the press. The Overview has a Pause auto-fix button.
- **LIGHTS** (Southcoast; `kind: bypass`, `"bypass": {"peers": ["Lighting"], "hosts": ["", "sc_tracks_new"]}`): hold 2 s to hand the lights to a lighting operator. Every network MIDI link to Lighting (ProPresenter's session, Tracks' session, and Companion's 56 Midi-Lighting buttons, which go out through ProPresenter's session) is taken out, the MIDI checks stop calling it lost and auto-fix stops putting it back; the key says `LIGHTS / OPERATOR / MIDI OFF` (steady blue). A link that comes back while the operator has it is taken out again within 10 s. Hold again: everything taken out is put back as it was, and every watched link to Lighting is reconnected (`LIGHTS / AUTO`). A peer that had called in (no address, ":5004") is not called back: it rejoins when Lighting calls again. Survives a restart; a hold from NA's CAMPUS page works too. `"conns"` can also switch Companion connections off for the operator. - **LIGHTS** (Southcoast; `kind: bypass`, `"bypass": {"peers": ["Lighting"], "hosts": ["", "sc_tracks_new"]}`): hold 2 s to hand the lights to a lighting operator. Every network MIDI link to Lighting (ProPresenter's session, Tracks' session, and Companion's 56 Midi-Lighting buttons, which go out through ProPresenter's session) is taken out, the MIDI checks stop calling it lost and auto-fix stops putting it back; the key says `LIGHTS / OPERATOR / MIDI OFF` (steady blue). A link that comes back while the operator has it is taken out again within 10 s. Hold again: everything taken out is put back as it was, and every watched link to Lighting is reconnected (`LIGHTS / AUTO`). A peer that had called in (no address, ":5004") is not called back: it rejoins when Lighting calls again. Survives a restart; a hold from NA's LOCATION page works too. `"conns"` can also switch Companion connections off for the operator.
## Auto-fix on Sundays (06:30–13:30) ## Auto-fix on Sundays (06:30–13:30)
@@ -81,7 +81,7 @@ During that window, with ALARMS on, the panel fixes a problem by itself once it
- Never automatic: restarting a running ProPresenter, Bible app, ProdCom (or DVS on NA Ingest, which restarts ProdCom), captions or the bridge, power-cycling a switch port, or turning on a connection someone switched off. `load_config` refuses an auto fix that closes a program. - Never automatic: restarting a running ProPresenter, Bible app, ProdCom (or DVS on NA Ingest, which restarts ProdCom), captions or the bridge, power-cycling a switch port, or turning on a connection someone switched off. `load_config` refuses an auto fix that closes a program.
- **Daily log:** everything auto-fix does is written as it happens to `~/Desktop/Auto-Fix Logs/<date> <weekday> Auto-Fix.log` on NA Ingest (e.g. `2026-10-11 Sunday Auto-Fix.log`): the problem, what it did, whether it worked, when the fault cleared, when it gave up or held back. Double-click it to open it in Console, which shows new lines live. One file per day; each is deleted a calendar month after its date (the 11 Oct file on 11 Nov). - **Daily log:** everything auto-fix does is written as it happens to `~/Desktop/Auto-Fix Logs/<date> <weekday> Auto-Fix.log` on NA Ingest (e.g. `2026-10-11 Sunday Auto-Fix.log`): the problem, what it did, whether it worked, when the fault cleared, when it gave up or held back. Double-click it to open it in Console, which shows new lines live. One file per day; each is deleted a calendar month after its date (the 11 Oct file on 11 Nov).
- **Stopping it:** press ALARMS (on the fly; nothing at all fixes itself until you press it again or midnight), or switch auto-fix off as a whole under Settings › Auto-fix (stays off until switched on). The window and the list are `"auto_fix"` in `config.json`. - **Stopping it:** press ALARMS (on the fly; nothing at all fixes itself until you press it again or midnight), or switch auto-fix off as a whole under Settings › Auto-fix (stays off until switched on). The window and the list are `"auto_fix"` in `config.json`.
- **READY page fixes** ticked under Settings › Auto-fix act on the checklist's gaps too, amber or red. A fix marked `"anytime": true` (only a READY fix may be; today the LTC write, `ready_ltc`) runs at any hour, not only in the auto-fix hours: once the weekend's set is on Tracks, every song's timecode is written into Planning Center for all four campuses by itself. ALARMS muted stops it like every other auto-fix; it catches up once they are back on. - **READY page fixes** ticked under Settings › Auto-fix act on the checklist's gaps too, amber or red. A fix marked `"anytime": true` (only a READY fix may be; today the LTC write, `ready_ltc`) runs at any hour, not only in the auto-fix hours: once the weekend's set is on Tracks, every song's timecode is written into Planning Center for all four locations by itself. ALARMS muted stops it like every other auto-fix; it catches up once they are back on.
## Audio failover (Studio B -> FOH Backup) ## Audio failover (Studio B -> FOH Backup)
@@ -153,7 +153,7 @@ everything on the page at once.
| LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again | | LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again |
| MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped | | MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped |
| PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again | | PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again |
| TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start; the other campuses' plans (`ready.ltc_campuses`: Apollo Beach, Southcoast, Woonsocket) get the same timecode for the same songs in this campus's order, and a song that is missing or out of order there is left alone. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again | | TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start; the other locations' plans (`ready.ltc_campuses`: Apollo Beach, Southcoast, Woonsocket) get the same timecode for the same songs in this location's order, and a song that is missing or out of order there is left alone. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again |
| BKGDS | every song has at least one slide with a background | looks again | | BKGDS | every song has at least one slide with a background | looks again |
| LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) | | LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) |
| LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center | | LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center |
@@ -168,7 +168,7 @@ everything on the page at once.
Nothing on the READY page writes to Companion, ProPresenter or the Ableton set. Nothing on the READY page writes to Companion, ProPresenter or the Ableton set.
**Another campus:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config, **Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config,
then change these in `"ready"`: then change these in `"ready"`:
- `pco_service_type` - `pco_service_type`
- `prop_url` - `prop_url`
@@ -176,7 +176,7 @@ then change these in `"ready"`:
- `lineup_url` - `lineup_url`
- `resolume_url` - `resolume_url`
For a campus that receives tracks rather than sending them, leave `locations` empty and take out the `prop_bundle_*` For a location that receives tracks rather than sending them, leave `locations` empty and take out the `prop_bundle_*`
lines; TRACKS OUT and PROP OUT then show `NOT SET UP`. A line whose address is blank shows `NOT SET UP` instead of lines; TRACKS OUT and PROP OUT then show `NOT SET UP`. A line whose address is blank shows `NOT SET UP` instead of
an alarm. an alarm.
@@ -223,29 +223,29 @@ A problem has to last 3 seconds before it shows, so one bad reading doesn't trig
With Editing on every screen grows inputs. Changes go into one shared draft on the Ingest Mac (two people editing see each other's version; a stale tab is told); the badge at the top counts them and opens **Review & Apply**, which reads every change back in plain words, says what will happen (the deck goes purple for about 5 s only when something the watchdog reads changed; pages re-import only when the layout changed) and runs the pipeline: check the config, keep the old version in `history/`, write, restart the watchdog and wait for its start stamp, re-import the pages (refused if any unmarked key sits on them), recolour the page-number keys. If the watchdog does not come back the previous version is put back by itself. Apply is refused while a HyperDeck is recording (the Emergency button needs a reason and the word LIVE, and is logged). History keeps every version with who/when/note; **Go back** and **Undo** make the chosen version the draft. With Editing on every screen grows inputs. Changes go into one shared draft on the Ingest Mac (two people editing see each other's version; a stale tab is told); the badge at the top counts them and opens **Review & Apply**, which reads every change back in plain words, says what will happen (the deck goes purple for about 5 s only when something the watchdog reads changed; pages re-import only when the layout changed) and runs the pipeline: check the config, keep the old version in `history/`, write, restart the watchdog and wait for its start stamp, re-import the pages (refused if any unmarked key sits on them), recolour the page-number keys. If the watchdog does not come back the previous version is put back by itself. Apply is refused while a HyperDeck is recording (the Emergency button needs a reason and the word LIVE, and is logged). History keeps every version with who/when/note; **Go back** and **Undo** make the chosen version the draft.
**Keys** draws both pages as the deck; with Editing on a key is a chip you drag to move (or drop on the tray to hide), + New key / + New restart key add one, Delete this key removes one, the page numbers and "this campus has a Restart page" sit under the tabs. **Overview** carries the jobs people actually do: Change the playlist (paste the link; the key, the play fix and the key guide follow), Add or remove a HyperDeck (tick the decks in use; the HD keys and the REC group follow, "may have no disk" per deck), Add a MIDI link (the Mac, its session and peer from the live list or typed, which key), and under More jobs: Add a computer, Make a PC program startable. Site setup › Companion offers "Add a key for it" on every connection nothing watches. **Computers › Setup** is the one-time checklist per computer with a light and a [how] per line and the set-up scripts generated from that computer's record (`settings/scripts.py` fills the templates in `remote-access/`: the login key, this Mac's addresses, one scheduled task per program listed); Remove this computer drops the fixes and checks that used it and says so. **Site setup › Site** shows the running and staged code with **Install the staged folder** (install.sh runs as a one-shot launchd job with the config left alone), **Export site bundle** (a zip to drop in as `sites/<id>/`) and **New campus from a bundle** (this campus's name, addresses and code stay; every copied computer is flagged until touched). **Keys** draws both pages as the deck; with Editing on a key is a chip you drag to move (or drop on the tray to hide), + New key / + New restart key add one, Delete this key removes one, the page numbers and "this location has a Restart page" sit under the tabs. **Overview** carries the jobs people actually do: Change the playlist (paste the link; the key, the play fix and the key guide follow), Add or remove a HyperDeck (tick the decks in use; the HD keys and the REC group follow, "may have no disk" per deck), Add a MIDI link (the Mac, its session and peer from the live list or typed, which key), and under More jobs: Add a computer, Make a PC program startable. Site setup › Companion offers "Add a key for it" on every connection nothing watches. **Computers › Setup** is the one-time checklist per computer with a light and a [how] per line and the set-up scripts generated from that computer's record (`settings/scripts.py` fills the templates in `remote-access/`: the login key, this Mac's addresses, one scheduled task per program listed); Remove this computer drops the fixes and checks that used it and says so. **Site setup › Site** shows the running and staged code with **Install the staged folder** (install.sh runs as a one-shot launchd job with the config left alone), **Export site bundle** (a zip to drop in as `sites/<id>/`) and **New location from a bundle** (this location's name, addresses and code stay; every copied computer is flagged until touched).
Fixes and the remote checks are built from fields (`settings/builders.py`): a builder (Start a Windows program, Restart a Mac program, Restart a helper, Reopen Ableton with its last set, Restart DVS, Press a Companion button (page, row, column)...) and its fields make the command; the stored command must equal what the fields build or the config is refused. Every Mac value is shell-quoted, every Windows value is regex-checked. A Custom command (Advanced) is the one place a command is typed by hand; Review shows commands in red. A fix built with Press a Companion button can be ticked failover (under more) and given an undo; the Failover calendar and its `after_seconds` sit under Settings › Auto-fix, under the auto-fix window. Fixes and the remote checks are built from fields (`settings/builders.py`): a builder (Start a Windows program, Restart a Mac program, Restart a helper, Reopen Ableton with its last set, Restart DVS, Press a Companion button (page, row, column)...) and its fields make the command; the stored command must equal what the fields build or the config is refused. Every Mac value is shell-quoted, every Windows value is regex-checked. A Custom command (Advanced) is the one place a command is typed by hand; Review shows commands in red. A fix built with Press a Companion button can be ticked failover (under more) and given an undo; the Failover calendar and its `after_seconds` sit under Settings › Auto-fix, under the auto-fix window.
The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs. The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs.
## Other campuses: auto-setup ## Other locations: auto-setup
A campus sets itself up. All a person does is let the panel in: one line on the campus's Mac that runs Companion, A location sets itself up. All a person does is let the panel in: one line on the location's Mac that runs Companion,
one line on each other computer. one line on each other computer.
1. **The Companion Mac.** Terminal, paste the campus's line from Settings › Auto-setup on any running campus (North 1. **The Companion Mac.** Terminal, paste the location's line from Settings › Auto-setup on any running location (North
Attleboro's page shows the Apollo Beach, Southcoast and Woonsocket lines), for example Attleboro's page shows the Apollo Beach, Southcoast and Woonsocket lines), for example
`curl -fsSL "http://10.1.1.1:8020/campus/install?id=ab" | zsh`. It downloads the program from that page (code only, `curl -fsSL "http://10.1.1.1:8020/location/install?id=ab" | zsh`. It downloads the program from that page (code only,
never that campus's settings or keys), installs it, makes the panel's login key, and runs never that location's settings or keys), installs it, makes the panel's login key, and runs
`python3 -m settings.campus setup`: Companion gets the panel's custom variables and the blink-clock trigger (through `python3 -m settings.location setup`: Companion gets the panel's custom variables and the blink-clock trigger (through
Companion's own internal API, as its web UI does), the first two pages from 19 that hold nothing made by hand are Companion's own internal API, as its web UI does), the first two pages from 19 that hold nothing made by hand are
used, the gear is found and the config is applied like any Apply. It prints the editing PIN. North Attleboro's key is used, the gear is found and the config is applied like any Apply. It prints the editing PIN. North Attleboro's key is
added so it can help remotely (turn on Remote Login for that). Running the line again updates the program. added so it can help remotely (turn on Remote Login for that). Running the line again updates the program.
2. **Each other computer.** Mac: Terminal, `curl -fsSL http://<that Mac>:8020/join | sh` (adds the key, turns on 2. **Each other computer.** Mac: Terminal, `curl -fsSL http://<that Mac>:8020/join | sh` (adds the key, turns on
Remote Login when it can, says it is here). PC: PowerShell as administrator, `irm http://<that Mac>:8020/join.ps1 | iex` Remote Login when it can, says it is here). PC: PowerShell as administrator, `irm http://<that Mac>:8020/join.ps1 | iex`
(OpenSSH, the key, a StatusPanel start task for each known program running on it, Wake-on-LAN, then says it is here). (OpenSSH, the key, a StatusPanel start task for each known program running on it, Wake-on-LAN, then says it is here).
The lines are on that campus's Settings › Auto-setup. The lines are on that location's Settings › Auto-setup.
**What gets a key** (`settings/autoconf.py`): each Companion connection of a known kind (HyperDecks, Tascam, consoles, **What gets a key** (`settings/autoconf.py`): each Companion connection of a known kind (HyperDecks, Tascam, consoles,
Dante with every device it sees, Shure receivers and PSMs with their channel count, ATEM, Videohub, Resolume, NovaStar, Dante with every device it sees, Shure receivers and PSMs with their channel count, ATEM, Videohub, Resolume, NovaStar,
@@ -256,55 +256,55 @@ its wired address (with the wired-network rule). A program's key also carries it
when it does not, REC AUDIO RF SHOW VIDEO STREAM group keys take the top row and wireless receivers fold into RF first. when it does not, REC AUDIO RF SHOW VIDEO STREAM group keys take the top row and wireless receivers fold into RF first.
Generic connections and unknown programs are listed under Settings › Auto-setup, not guessed at. Generic connections and unknown programs are listed under Settings › Auto-setup, not guessed at.
**After that** (`discover.auto`, "add new gear by itself", on for a new campus, off at North Attleboro): the settings page **After that** (`discover.auto`, "add new gear by itself", on for a new location, off at North Attleboro): the settings page
looks every ten minutes and soon after a computer joins, and adds what is new (a key in the first free cell, a Dante looks every ten minutes and soon after a computer joins, and adds what is new (a key in the first free cell, a Dante
device or MIDI link into its key). Never during the auto-fix hours or a recording; nothing is ever removed; a thing that device or MIDI link into its key). Never during the auto-fix hours or a recording; nothing is ever removed; a thing that
was there before and was taken off by someone is not added back (`discover-seen.json`). "Look now" adds what is new at was there before and was taken off by someone is not added back (`discover-seen.json`). "Look now" adds what is new at
once. Everything it makes is ordinary settings, changed on the page like North Attleboro's. once. Everything it makes is ordinary settings, changed on the page like North Attleboro's.
## The campuses at a glance ## The locations at a glance
| Campus | Settings page (bookmark these) | Companion Mac | Status / Restart pages | | Location | Settings page (bookmark these) | Companion Mac | Status / Restart pages |
|---|---|---|---| |---|---|---|---|
| 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, LOCATIONS 98, LOCATION 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 | | 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 | | 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 location: 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 silences and AUTO-FIX pauses auto-fix, separately. a hold on any key fixes it at any time, on that location's own deck or from NA's LOCATION page; ALARMS silences and AUTO-FIX pauses auto-fix, separately.
`python3 -m settings.campus rebuild` (on a campus's Companion Mac, in the panel folder) makes its setup again from `python3 -m settings.location rebuild` (on a location's Companion Mac, in the panel folder) makes its setup again from
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.location 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) ## A location 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. 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 `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. `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` Installed, it sets itself up like any location (`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 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 `~/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>`. Companion: `python3 -m settings.location 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
campus's settings, Apply history and page-key record into Google Drive: Shared drives > Waters Creative > Apps > location's settings, Apply history and page-key record into Google Drive: Shared drives > Waters Creative > Apps >
Status Panel Backups > <campus> > <campus>-<date>.tgz, newest 30 kept, the same pattern and alert email as the Status Panel Backups > <location> > <location>-<date>.tgz, newest 30 kept, the same pattern and alert email as the
checklist's backup-to-drive.sh. Other campuses are reached over SSH as the account they report. The repo keeps each checklist's backup-to-drive.sh. Other locations are reached over SSH as the account they report. The repo keeps each
campus's settings too: `config.json` (NA) and `sites/<id>/config.json`; a reinstall with `--site <id>` starts from it. location's settings too: `config.json` (NA) and `sites/<id>/config.json`; a reinstall with `--site <id>` starts from it.
Smart-plug keys (outlets.json) are never copied. Smart-plug keys (outlets.json) are never copied.
## Every campus on North Attleboro's deck (NA only) ## Every location on North Attleboro's deck (NA only)
Each campus set up from NA's install line tells NA's settings page where it is (`/api/campus/hello`) and learns Each location set up from NA's install line tells NA's settings page where it is (`/api/campus/hello`) and learns
nothing back: no campus ever shows another, and only NA (the hub: no "home" in `campuses.json`) does nothing back: no location ever shows another, and only NA (the hub: no "home" in `campuses.json`) does
(`settings/campuses.py`). NA's settings page reads each campus's key states every two seconds (`/api/state`) and (`settings/campuses.py`). NA's settings page reads each location's key states every two seconds (`/api/state`) and
keeps two pages in NA's Companion, added at the end when no page is empty (today 98 and 99): keeps two pages in NA's Companion, added at the end when no page is empty (today 98 and 99):
- **CAMPUSES (98):** row 0, one key per campus, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Row 1, its RESTART key. Tap NA's: NA's own pages. Tap another's: the CAMPUS page showing it. - **LOCATIONS (98):** row 0, one key per location, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Row 1, its RESTART key. Tap NA's: NA's own pages. Tap another's: the LOCATION page showing it.
- **CAMPUS (99):** the tapped campus's status (or Restart) page, same places, colours and words, live. Tap = acknowledge, hold = fix, at that campus, exactly as on its own deck (a Restart key's hold restarts it there). Bottom-left ("WS / ALL SITES") goes back. - **LOCATION (99):** the tapped location's status (or Restart) page, same places, colours and words, live. Tap = acknowledge, hold = fix, at that location, exactly as on its own deck (a Restart key's hold restarts it there). Bottom-left ("WS / ALL SITES") goes back.
## SSH from the admin laptop to every machine ## SSH from the admin laptop to every machine
@@ -336,7 +336,7 @@ Then run the installer there. It checks the config with the new code first (a ba
ssh checklist-ingest 'zsh ~/status-panel-install/install.sh' ssh checklist-ingest 'zsh ~/status-panel-install/install.sh'
``` ```
`install.sh --keep-config` updates the code and leaves the live config alone; `install.sh --rollback` puts the previous code back; `install.sh --site <id>` (a new campus) installs `sites/<id>/config.json` when the folder exists, otherwise `sites/_template/config.json`, and never replaces a config that is already installed. `install.sh --keep-config` updates the code and leaves the live config alone; `install.sh --rollback` puts the previous code back; `install.sh --site <id>` (a new location) installs `sites/<id>/config.json` when the folder exists, otherwise `sites/_template/config.json`, and never replaces a config that is already installed.
If you add or move tiles, rebuild the page: If you add or move tiles, rebuild the page:
@@ -396,7 +396,7 @@ To go back, pick 1.3.0 on the connection again.
| `status_panel.py` | the watchdog | | `status_panel.py` | the watchdog |
| `netgear.py` | reads the Netgear Dante switches (MAC tables, PoE) and power-cycles one PoE port | | `netgear.py` | reads the Netgear Dante switches (MAC tables, PoE) and power-cycles one PoE port |
| `coremidi.py` | reads network MIDI sessions and listens to MIDI sources (ctypes, no extra installs) | | `coremidi.py` | reads network MIDI sessions and listens to MIDI sources (ctypes, no extra installs) |
| `config.json` | tiles, positions, checks, fixes, hosts, windows: every setting (the `site` block names the campus; `spotify` holds the playlist; `timing` and `page_hold_ms` the numbers behind HOLD 2s and WAIT 1 MIN) | | `config.json` | tiles, positions, checks, fixes, hosts, windows: every setting (the `site` block names the location; `spotify` holds the playlist; `timing` and `page_hold_ms` the numbers behind HOLD 2s and WAIT 1 MIN) |
| `schema.py` | the registry of every setting: type, default, help, which screen of the settings page owns it, and the rules a config must pass (`tests/test_schema.py` keeps it in step with the code) | | `schema.py` | the registry of every setting: type, default, help, which screen of the settings page owns it, and the rules a config must pass (`tests/test_schema.py` keeps it in step with the code) |
| `make_page.py` | builds `status-page.companionconfig` from `config.json` | | `make_page.py` | builds `status-page.companionconfig` from `config.json` |
| `install.sh` | installs or updates the LaunchAgent on NA Ingest | | `install.sh` | installs or updates the LaunchAgent on NA Ingest |
+9 -9
View File
@@ -1,15 +1,15 @@
#!/bin/bash #!/bin/bash
# backup-campuses.sh - every campus's status panel settings, off-site in Google Drive, nightly. # backup-campuses.sh - every location's status panel settings, off-site in Google Drive, nightly.
# #
# The same pattern as the checklist's backup-to-drive.sh on this Mac: a LaunchAgent runs it at 04:30, # The same pattern as the checklist's backup-to-drive.sh on this Mac: a LaunchAgent runs it at 04:30,
# it copies into the shared Drive (Waters Creative > Apps > Status Panel Backups > <campus>), keeps # it copies into the shared Drive (Waters Creative > Apps > Status Panel Backups > <location>), keeps
# the newest 30 per campus, and emails an alert (through the checklist's alert.js) when something # the newest 30 per location, 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, except # 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. # on a standalone location (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 location 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 location
# lists. Never outlets.json (the smart-plug keys stay on their Mac). # lists. Never outlets.json (the smart-plug keys stay on their Mac).
set -uo pipefail set -uo pipefail
@@ -25,13 +25,13 @@ 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
} }
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 "")' \ MODE=$(/usr/bin/python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("standalone" if d.get("standalone") else "location" if d.get("home") else "hub" if d.get("list") else "")' \
"$DEST/campuses.json" 2>/dev/null) "$DEST/campuses.json" 2>/dev/null)
[ "$MODE" = hub ] || [ "$MODE" = standalone ] || { log "not the hub: nothing to back up from here"; exit 0; } [ "$MODE" = hub ] || [ "$MODE" = standalone ] || { log "not the hub: nothing to back up from here"; exit 0; }
DRIVE="" DRIVE=""
if [ "$MODE" = standalone ]; then if [ "$MODE" = standalone ]; then
# A campus on its own (Apollo Beach) keeps its own copies, in this Mac's Documents (or wherever # A location 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). # STATUS_PANEL_BACKUPS points, e.g. a Google Drive folder).
DRIVE="${STATUS_PANEL_BACKUPS:-$HOME/Documents/Status Panel Backups}" DRIVE="${STATUS_PANEL_BACKUPS:-$HOME/Documents/Status Panel Backups}"
else else
@@ -82,7 +82,7 @@ for cid, e in sorted((d.get("list") or {}).items()):
PY PY
) )
if [ -n "$FAILED" ]; then if [ -n "$FAILED" ]; then
alert "no backup tonight for:$FAILED" "The other campuses were backed up. A campus missing here is usually off, off the network, or no longer letting this Mac log in." alert "no backup tonight for:$FAILED" "The other locations were backed up. A location missing here is usually off, off the network, or no longer letting this Mac log in."
exit 1 exit 1
fi fi
log "── done ──" log "── done ──"
+547 -547
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -51,7 +51,7 @@ Weaknesses
Strengths Strengths
- Wizards that map one-to-one onto the user's own examples: Add a MIDI link with live pickers, Add or remove a HyperDeck adjusting the REC members too, Change the playlist by pasting a link, Add a computer, Add a Windows app task, New campus; plus a demo-mode walkthrough. - Wizards that map one-to-one onto the user's own examples: Add a MIDI link with live pickers, Add or remove a HyperDeck adjusting the REC members too, Change the playlist by pasting a link, Add a computer, Add a Windows app task, New location; plus a demo-mode walkthrough.
- The live Stream Deck picture on the Overview from sp_state with click-through to the key, a global search box, and a "Stream Deck" (where it sits) versus "Keys" (what it does) split a volunteer gets in one sentence. - The live Stream Deck picture on the Overview from sp_state with click-through to the key, a global search box, and a "Stream Deck" (where it sits) versus "Keys" (what it does) split a volunteer gets in one sentence.
- The safest Apply order (restart the watchdog, prove it, then pages and nav with a dry run shown), the key guide regenerated on every Apply, History never auto-pruned, and "Undo last apply" on the Overview. - The safest Apply order (restart the watchdog, prove it, then pages and nav with a dry run shown), the key guide regenerated on every Apply, History never auto-pruned, and "Undo last apply" on the Overview.
- Makes alert_after_seconds and the fix cooldown editable with the key texts following; validates fix kinds in load_config; requires host or conn on ping checks; the settings server imports only load_config, CHECKS and make_page so a page bug cannot take the panel down. - Makes alert_after_seconds and the fix cooldown editable with the key texts following; validates fix kinds in load_config; requires host or conn on ping checks; the settings server imports only load_config, CHECKS and make_page so a page bug cannot take the panel down.
@@ -68,7 +68,7 @@ Weaknesses
### Ideas the judges asked to graft (and where they landed) ### Ideas the judges asked to graft (and where they landed)
- From teaching: restart the watchdog and prove it started before pages are rebuilt or imported, and roll pages back too (K10). - From teaching: restart the watchdog and prove it started before pages are rebuilt or imported, and roll pages back too (K10).
- From teaching: the job wizards for the named asks (K4, P4) and "New campus from this one" (K17). - From teaching: the job wizards for the named asks (K4, P4) and "New location from this one" (K17).
- From teaching: the live deck on the Overview from sp_state with click-through (K4); the "what this key will say" preview (K5); the key guide rebuilt on every Apply (K15); History never auto-pruned (K14); Undo the last change (K14); "done by hand" ticks (K8 manual_done); alert_after_seconds and the fix cooldown editable with the key texts following (K12); a global search box (D10). - From teaching: the live deck on the Overview from sp_state with click-through (K4); the "what this key will say" preview (K5); the key guide rebuilt on every Apply (K15); History never auto-pruned (K14); Undo the last change (K14); "done by hand" ticks (K8 manual_done); alert_after_seconds and the fix cooldown editable with the key texts following (K12); a global search box (D10).
- From simplest: the AST-based schema sync test and "defaults in code must equal the schema" instead of removing the defaults from the watchdog (K16); the Phase 0 round-trip test (K16, P0). - From simplest: the AST-based schema sync test and "defaults in code must equal the schema" instead of removing the defaults from the watchdog (K16); the Phase 0 round-trip test (K16, P0).
- From simplest: the password-free edit gate, later reduced from allow-list plus unlock plus typed APPLY to one editing code (K2, C3). - From simplest: the password-free edit gate, later reduced from allow-list plus unlock plus typed APPLY to one editing code (K2, C3).
@@ -156,7 +156,7 @@ Round 1 critics read the first full plan, round 2 the revision that answered the
| 2 | Simplicity | major | The settings page needs its own Spotify permission, a Test button and an extra Allow step. | ADOPTED | K9 one permission light; K7 Run now goes through the watchdog so the page never drives Spotify; the P1 spike checks the grant. | | 2 | Simplicity | major | The settings page needs its own Spotify permission, a Test button and an extra Allow step. | ADOPTED | K9 one permission light; K7 Run now goes through the watchdog so the page never drives Spotify; the P1 spike checks the grant. |
| 2 | Simplicity | minor | Map cells are walls of text. | ADOPTED | K4 and K8: one status word per row, what it does, the keys that turn red. | | 2 | Simplicity | minor | Map cells are walls of text. | ADOPTED | K4 and K8: one status word per row, what it does, the keys that turn red. |
| 2 | Simplicity | minor | Six different "test" buttons with unclear effects. | ADOPTED | K9 read-only buttons say "Check ..." ; K7 "Run this fix now" is the one that acts. | | 2 | Simplicity | minor | Six different "test" buttons with unclear effects. | ADOPTED | K9 read-only buttons say "Check ..." ; K7 "Run this fix now" is the one that acts. |
| 2 | Simplicity | minor | Update the program and the campus export sit on the normal Site screen; pull_site.py is a terminal step. | SIMPLIFIED | C12 Install the staged folder and Export site bundle sit under Site setup (set up once); the K17 export replaces pull_site.py; scp remains (K13). | | 2 | Simplicity | minor | Update the program and the location export sit on the normal Site screen; pull_site.py is a terminal step. | SIMPLIFIED | C12 Install the staged folder and Export site bundle sit under Site setup (set up once); the K17 export replaces pull_site.py; scp remains (K13). |
| 2 | Simplicity | minor | Per-editor drafts with take-over and discard add a multi-user merge concept. | ADOPTED | K10 one shared draft; the 409 names who changed what. | | 2 | Simplicity | minor | Per-editor drafts with take-over and discard add a multi-user merge concept. | ADOPTED | K10 one shared draft; the 409 names who changed what. |
| 2 | Simplicity | minor | The Keys grid draws three overlays; "+ New key" offers ALL, ALARMS and NET. | ADOPTED | K5 draws the 8x4 deck; fixed chips for ALL / ALARMS / NET. | | 2 | Simplicity | minor | The Keys grid draws three overlays; "+ New key" offers ALL, ALARMS and NET. | ADOPTED | K5 draws the 8x4 deck; fixed chips for ALL / ALARMS / NET. |
| 2 | Simplicity | minor | Overview carries admin wizards, gear, a log tail and duplicate lights. | ADOPTED | K4 problems, the deck, computers, three job buttons plus More jobs, status lines (round 3 Simplicity set the three). | | 2 | Simplicity | minor | Overview carries admin wizards, gear, a log tail and duplicate lights. | ADOPTED | K4 problems, the deck, computers, three job buttons plus More jobs, status lines (round 3 Simplicity set the three). |
@@ -193,7 +193,7 @@ Round 1 critics read the first full plan, round 2 the revision that answered the
| 3 | Completeness | major | install.sh's deploy list omits the remote-access templates and companion-objects. | ADOPTED | K13 deploys both; K17 nav_templates/<version>.json. | | 3 | Completeness | major | install.sh's deploy list omits the remote-access templates and companion-objects. | ADOPTED | K13 deploys both; K17 nav_templates/<version>.json. |
| 3 | Completeness | major | Windows batching with %ERRORLEVEL% expands before the command runs, so every check reports OK. | ADOPTED | K12 generates per-command conditional echo pairs (SPn=0 on success, SPn=1 otherwise), never a bare %ERRORLEVEL%, verified once on the Lighting PC. | | 3 | Completeness | major | Windows batching with %ERRORLEVEL% expands before the command runs, so every check reports OK. | ADOPTED | K12 generates per-command conditional echo pairs (SPn=0 on success, SPn=1 otherwise), never a bare %ERRORLEVEL%, verified once on the Lighting PC. |
| 3 | Completeness | minor | No expected module per check type; a HyperDeck check on a generic TCP connection is silently wrong. | ADOPTED | K12 EXPECTED_MODULE filters the pickers and warns on mismatch; it lives in the K16 registry. | | 3 | Completeness | minor | No expected module per check type; a HyperDeck check on a generic TCP connection is silently wrong. | ADOPTED | K12 EXPECTED_MODULE filters the pickers and warns on mismatch; it lives in the K16 registry. |
| 3 | Completeness | minor | restart_page is optional but the Keys screen always has the tab and chips. | ADOPTED | K9 "this campus has no Restart page" switch (built in P3). | | 3 | Completeness | minor | restart_page is optional but the Keys screen always has the tab and chips. | ADOPTED | K9 "this location has no Restart page" switch (built in P3). |
| 3 | Completeness | minor | Windows cannot cross midnight. | ADOPTED | K18. | | 3 | Completeness | minor | Windows cannot cross midnight. | ADOPTED | K18. |
| 3 | Completeness | minor | Add a MIDI link has no typed fallback when the Mac is off. | ADOPTED | P4. | | 3 | Completeness | minor | Add a MIDI link has no typed fallback when the Mac is off. | ADOPTED | P4. |
| 3 | Completeness | minor | The Ingest self record can be named by ssh fixes, remote checks and midi_peer (a runtime KeyError). | ADOPTED | K12 host validated (exists, has ssh, not self); builders switch to local for the self record. | | 3 | Completeness | minor | The Ingest self record can be named by ssh fixes, remote checks and midi_peer (a runtime KeyError). | ADOPTED | K12 host validated (exists, has ssh, not self); builders switch to local for the self record. |
@@ -280,7 +280,7 @@ C14, preview run. Review has no "Preview the deck with these settings" button th
- Raw Custom commands (K6) are a remote-command surface on seven computers, guarded by one code over plain HTTP on a LAN. Mitigation: LAN-only binding narrowed by hosts.ingest.networks (K1), origin check and lockout (K2), the code asked again and the command shown red in Review (K6), zsh -n / sh -n, History naming the editor (K14), and the private repo's access list deciding who holds the code. - Raw Custom commands (K6) are a remote-command surface on seven computers, guarded by one code over plain HTTP on a LAN. Mitigation: LAN-only binding narrowed by hosts.ingest.networks (K1), origin check and lockout (K2), the code asked again and the command shown red in Review (K6), zsh -n / sh -n, History naming the editor (K14), and the private repo's access list deciding who holds the code.
- Probes are parameterised, not fixed (K8): names and paths from the config reach ssh lines. Mitigation: shlex-quoted and regex-checked parameters, a fixed command set, the metacharacter test (K16), never run during a fix, 6 s timeout, never wake a PC. - Probes are parameterised, not fixed (K8): names and paths from the config reach ssh lines. Mitigation: shlex-quoted and regex-checked parameters, a fixed command set, the metacharacter test (K16), never run during a fix, 6 s timeout, never wake a PC.
- Windows DefaultShell (K12): the batched checks assume cmd.exe. Mitigation: a light on the PC's card and refusal of cmd-batched checks when the shell is not cmd; verified once on the Lighting PC; setting DefaultShell stays a by-hand system change for the user. - Windows DefaultShell (K12): the batched checks assume cmd.exe. Mitigation: a light on the PC's card and refusal of cmd-batched checks when the shell is not cmd; verified once on the Lighting PC; setting DefaultShell stays a by-hand system change for the user.
- Python 3.13 removes telnetlib, which netgear.py uses (K12 self-check for 3.9-3.12). Mitigation: the self-check light; a newer macOS at another campus would lose the Dante map and PoE cycling until telnetlib is replaced, which is not in this plan. - Python 3.13 removes telnetlib, which netgear.py uses (K12 self-check for 3.9-3.12). Mitigation: the self-check light; a newer macOS at another location would lose the Dante map and PoE cycling until telnetlib is replaced, which is not in this plan.
- Companion tRPC spike unknowns (P1): custom-variable creation, trigger import, resetControl blanking a cell, the OBS variable, the page walk, whether one Automation grant covers both agents. Mitigation: recorded answers before any Apply step depends on them, with fallbacks (by hand, "unknown", schedule-only). - Companion tRPC spike unknowns (P1): custom-variable creation, trigger import, resetControl blanking a cell, the OBS variable, the page walk, whether one Automation grant covers both agents. Mitigation: recorded answers before any Apply step depends on them, with fallbacks (by hand, "unknown", schedule-only).
- Reads are open on the LAN (K2): logs, history bodies and the fleet map are visible to anyone who joins the network. Mitigation: the webhook is masked everywhere, no secret is in History, the bind is narrowed to the named LAN interfaces (K1). - Reads are open on the LAN (K2): logs, history bodies and the fleet map are visible to anyone who joins the network. Mitigation: the webhook is masked everywhere, no secret is in History, the bind is narrowed to the named LAN interfaces (K1).
- The only live signal is a recording HyperDeck (K11, C7), and a stale live-now.json falls back to the schedule. Mitigation: Review says "schedule only" when stale, Q2 fills the windows, OBS follows the spike. - The only live signal is a recording HyperDeck (K11, C7), and a stale live-now.json falls back to the schedule. Mitigation: Review says "schedule only" when stale, Q2 fills the windows, OBS follows the spike.
+11 -11
View File
@@ -19,13 +19,13 @@ Values are typed once: every string that names a program, path, task or helper l
| `site.id` NEW | Site setup > Site (Advanced) | Site id | Short name for folders and files ("na"). | `[a-z0-9_-]+`; restart | | `site.id` NEW | Site setup > Site (Advanced) | Site id | Short name for folders and files ("na"). | `[a-z0-9_-]+`; restart |
| `site.name`, `site.ingest_name`, `site.ingest_hostname`, `site.ingest_ips[]` NEW | Site setup > Site | Site name, this Mac's name, network name, addresses | Used in log headers, alert text, key notes and generated setup scripts (replaces "NA Ingest" and the IPs in windows-setup.ps1). [Check this Mac's network ports] fills the addresses. | non-empty; IPs valid; restart (a changed note alone never forces a re-import, D11) | | `site.name`, `site.ingest_name`, `site.ingest_hostname`, `site.ingest_ips[]` NEW | Site setup > Site | Site name, this Mac's name, network name, addresses | Used in log headers, alert text, key notes and generated setup scripts (replaces "NA Ingest" and the IPs in windows-setup.ps1). [Check this Mac's network ports] fills the addresses. | non-empty; IPs valid; restart (a changed note alone never forces a re-import, D11) |
| `site.settings_port` NEW | Site setup > Site (Advanced) | Port | Where this page listens: loopback plus every private-address port, narrowed by this Mac's LAN list. Default 8020. | 1024-65535; the dialog states the consequence (the page moves to the new address); settings | | `site.settings_port` NEW | Site setup > Site (Advanced) | Port | Where this page listens: loopback plus every private-address port, narrowed by this Mac's LAN list. Default 8020. | 1024-65535; the dialog states the consequence (the page moves to the new address); settings |
| `site.editing_code` NEW | Site setup > Site (Advanced) | Editing code | The code that switches Editing on. It is a setting in this campus's `config.json`, kept in the private git repo, so everyone with the repo has it (the user's choice, 2026-10-13); browsers remember it with the person's first name. Masked in every API response unless the request carries it. The one code: no admin code, no address list, no page-generated code. | non-empty; changing it: the dialog says every browser must enter the new code; none | | `site.editing_code` NEW | Site setup > Site (Advanced) | Editing code | The code that switches Editing on. It is a setting in this location's `config.json`, kept in the private git repo, so everyone with the repo has it (the user's choice, 2026-10-13); browsers remember it with the person's first name. Masked in every API response unless the request carries it. The one code: no admin code, no address list, no page-generated code. | non-empty; changing it: the dialog says every browser must enter the new code; none |
| `site.live_services[]` NEW | Site setup > Schedules | Live services | When a service is on: Apply, Run now, Restart the watchdog, Re-import, Check the switcher and Install are refused and your draft stays saved; auto-fix may run; alarms are always on. Defaults to `auto_fix.windows`; the Phase 0 migration writes it. | days in mon..sun; `HH:MM`; end <= start = runs past midnight (allowed); warn if empty; restart (ServiceMode is built at start) | | `site.live_services[]` NEW | Site setup > Schedules | Live services | When a service is on: Apply, Run now, Restart the watchdog, Re-import, Check the switcher and Install are refused and your draft stays saved; auto-fix may run; alarms are always on. Defaults to `auto_fix.windows`; the Phase 0 migration writes it. | days in mon..sun; `HH:MM`; end <= start = runs past midnight (allowed); warn if empty; restart (ServiceMode is built at start) |
| built-in live signal: a watched HyperDeck that "follows the recording" is recording | Site setup > Schedules ("Also counts as live", read-only line with "Live now?") | - | Derived from every hyperdeck check with "follows the recording"; the watchdog writes `live-now.json` on change / every 5 s with a timestamp; older than 30 s = schedule only (said in Review). No connection/variable/value triple on the page; "OBS is streaming" is added as a built-in kind only if the Phase 1 spike finds the module variable. | DNE; warn when no watched deck follows the recording | | built-in live signal: a watched HyperDeck that "follows the recording" is recording | Site setup > Schedules ("Also counts as live", read-only line with "Live now?") | - | Derived from every hyperdeck check with "follows the recording"; the watchdog writes `live-now.json` on change / every 5 s with a timestamp; older than 30 s = schedule only (said in Review). No connection/variable/value triple on the page; "OBS is streaming" is added as a built-in kind only if the Phase 1 spike finds the module variable. | DNE; warn when no watched deck follows the recording |
| `site.contacts` NEW | Site setup > Site | Contacts | Printed on the cheat sheet. | free text; none | | `site.contacts` NEW | Site setup > Site | Contacts | Printed on the cheat sheet. | free text; none |
| `site.dvs_helper`, `site.solo_dir` NEW | Site setup > Site (Advanced) | DVS helper path, Studio B helper folder | One owner for paths that fixes, checks and scripts repeat; builders and templates regenerate from them. | absolute path; restart | | `site.dvs_helper`, `site.solo_dir` NEW | Site setup > Site (Advanced) | DVS helper path, Studio B helper folder | One owner for paths that fixes, checks and scripts repeat; builders and templates regenerate from them. | absolute path; restart |
| `alert_webhook`, `site.alert_prefix` NEW, `timing.alert_after_seconds` NEW | Site setup > Site / Schedules (Advanced) | Alert message (web hook), message starts with, alert after | Posts "<prefix>: Companion has not answered for N s" and the all-clear; also a line for every command edit applied. Blank = off. Shown masked everywhere ("set, 44 chars"); [Change] [Send a test alert]. | http(s) or blank; 5-600; restart (captured in run()) | | `alert_webhook`, `site.alert_prefix` NEW, `timing.alert_after_seconds` NEW | Site setup > Site / Schedules (Advanced) | Alert message (web hook), message starts with, alert after | Posts "<prefix>: Companion has not answered for N s" and the all-clear; also a line for every command edit applied. Blank = off. Shown masked everywhere ("set, 44 chars"); [Change] [Send a test alert]. | http(s) or blank; 5-600; restart (captured in run()) |
| `ssh_key` | Site setup > Site (Advanced) | Login key | Private key on this Mac for every other computer; light + public half with [Copy]. [Make the login key] on the Setup tab makes the panel's own pair at a new campus (asks first). | file exists, 0600; `.pub` readable; restart | | `ssh_key` | Site setup > Site (Advanced) | Login key | Private key on this Mac for every other computer; light + public half with [Copy]. [Make the login key] on the Setup tab makes the panel's own pair at a new location (asks first). | file exists, 0600; `.pub` readable; restart |
| `companion.host/port/db` | Site setup > Companion (Advanced) | Address, port, settings database | Where Companion answers. The DB is read read-only for labels and addresses (module version only if the Phase 1 spike finds it trivially); blank = newest `vN.N/db.sqlite`. push_page and nav_keys read these too. | host non-empty; 1-65535; path exists; live probe; restart | | `companion.host/port/db` | Site setup > Companion (Advanced) | Address, port, settings database | Where Companion answers. The DB is read read-only for labels and addresses (module version only if the Phase 1 spike finds it trivially); blank = newest `vN.N/db.sqlite`. push_page and nav_keys read these too. | host non-empty; 1-65535; path exists; live probe; restart |
| `variables.state/heartbeat/command`, `variables.flash` NEW | Site setup > Companion (Advanced) | Variable names (state `sp_state`, heartbeat `sp_hb`, command `sp_cmd`, blink `flash_tick`) | Must exist in Companion: a hard error when Companion answers, a warning ("could not check") when it does not. Renaming the blink variable means renaming it inside the trigger in Companion too (said beside the field). | `[A-Za-z0-9_]+`; exists; pages + restart | | `variables.state/heartbeat/command`, `variables.flash` NEW | Site setup > Companion (Advanced) | Variable names (state `sp_state`, heartbeat `sp_hb`, command `sp_cmd`, blink `flash_tick`) | Must exist in Companion: a hard error when Companion answers, a warning ("could not check") when it does not. Renaming the blink variable means renaming it inside the trigger in Companion too (said beside the field). | `[A-Za-z0-9_]+`; exists; pages + restart |
| `page`, `page_name`; `restart_page.page`, `restart_page.page_name` | Keys tab header (only home; the Companion section links here) | Page numbers and names | Name derived "<n>\nStatus" / "<n>\nRestart" unless overridden. Number change: the old page keeps its keys; Apply resets our marked keys on it (asks; if the spike shows `controls.resetControl` cannot blank a cell, Review lists "delete these N keys on page 19 by hand") and re-runs the page-number buttons. Name-only change on an existing page: rename it in Companion first (live name shown). "Pages exist" light. | int >= 1; distinct; name contains the number; live checks; pages | | `page`, `page_name`; `restart_page.page`, `restart_page.page_name` | Keys tab header (only home; the Companion section links here) | Page numbers and names | Name derived "<n>\nStatus" / "<n>\nRestart" unless overridden. Number change: the old page keeps its keys; Apply resets our marked keys on it (asks; if the spike shows `controls.resetControl` cannot blank a cell, Review lists "delete these N keys on page 19 by hand") and re-runs the page-number buttons. Name-only change on an existing page: rename it in Companion first (live name shown). "Pages exist" light. | int >= 1; distinct; name contains the number; live checks; pages |
@@ -68,7 +68,7 @@ Values are typed once: every string that names a program, path, task or helper l
| `tiles[].id` | Keys (Advanced) | Id | Used inside Companion; master is `all`. | `[A-Za-z0-9_]+`; unique vs restart keys; pages | | `tiles[].id` | Keys (Advanced) | Id | Used inside Companion; master is `all`. | `[A-Za-z0-9_]+`; unique vs restart keys; pages |
| `tiles[].label` | Keys | Label | First line and prefix of faults. | <= 10 warn; pages | | `tiles[].label` | Keys | Label | First line and prefix of faults. | <= 10 warn; pages |
| `tiles[].pos` | Keys (drag) | Position / tray | Blank = in the tray "Shown inside the RF group (no key of their own)". | in grid; not col 0 rows 0-2; no overlap incl. jump/back pos; pages | | `tiles[].pos` | Keys (drag) | Position / tray | Blank = in the tray "Shown inside the RF group (no key of their own)". | in grid; not col 0 rows 0-2; no overlap incl. jump/back pos; pages |
| `tiles[].kind` | Keys | Kind | single key / group; ALL and ALARMS are fixed chips (moved and edited, never created or deleted); NET may be removed at a campus that does not want it (at most one; D13). | exactly one ALL, one ALARMS, at most one NET; pages | | `tiles[].kind` | Keys | Kind | single key / group; ALL and ALARMS are fixed chips (moved and edited, never created or deleted); NET may be removed at a location that does not want it (at most one; D13). | exactly one ALL, one ALARMS, at most one NET; pages |
| `tiles[].members[]` | Keys | Group members | Worst member shown ("worst member + N"); hold fixes all. | ids exist; not self; a hidden key must be in a group (warn); restart (pages when hold-ability changes, detected by the build) | | `tiles[].members[]` | Keys | Group members | Worst member shown ("worst member + N"); hold fixes all. | ids exist; not self; a hidden key must be in a group (warn); restart (pages when hold-ability changes, detected by the build) |
| `tiles[].checks[]` | Keys | Checks | See per type. Changing a key's check list drops that key's remembered acknowledgements and auto-fix tries (said in Review). | type in CHECKS; restart (pages when the build differs) | | `tiles[].checks[]` | Keys | Checks | See per type. Changing a key's check list drops that key's remembered acknowledgements and auto-fix tries (said in Review). | type in CHECKS; restart (pages when the build differs) |
| `tiles[].hold_always` | Keys > When held | also when the key is green | Even when green (HD, TASCAM, SPOTIFY). | fix exists with label; pages | | `tiles[].hold_always` | Keys > When held | also when the key is green | Even when green (HD, TASCAM, SPOTIFY). | fix exists with label; pages |
@@ -96,12 +96,12 @@ Values are typed once: every string that names a program, path, task or helper l
| artnet `source/max_silence_seconds/msg` | check row | Sender, silence, text | Port 6454 is fixed. | IP; 1-60; restart | | artnet `source/max_silence_seconds/msg` | check row | Sender, silence, text | Port 6454 is fixed. | IP; 1-60; restart |
| atem check `part/msg` | check row | Part, text | Needs the ATEM section. | restart | | atem check `part/msg` | check row | Part, text | Needs the ATEM section. | restart |
| `atem.host/node/library/reference`, `atem.compare` NEW, `atem.source_folder` NEW | Site setup > ATEM | Switcher address, patterns (resolved paths shown), saved file (upload or pick the newest from the folder), folder, what to keep in line | The upload (capped at 20 MB; XML with a DOCTYPE or entity declarations refused; parsed in the settings process with the watchdog's own parser) replaces `atem-reference.xml` atomically together with the config; its hash goes into History; the old file is kept dated under `history/atem/`; refused during a live service; Review shows "product name matches / N audio and M camera values differ from the current file" and needs the tick "I checked this is the right file for this switcher"; [Check the switcher now] reads only (3 s, a second connection, guarded). | IP; globs resolve; XML parses with the watchdog's own parser, no DOCTYPE or entity declarations, <= 20 MB; compare is a subset of MIX_OPTIONS/CCU_PARAMS; folder exists (light); restart | | `atem.host/node/library/reference`, `atem.compare` NEW, `atem.source_folder` NEW | Site setup > ATEM | Switcher address, patterns (resolved paths shown), saved file (upload or pick the newest from the folder), folder, what to keep in line | The upload (capped at 20 MB; XML with a DOCTYPE or entity declarations refused; parsed in the settings process with the watchdog's own parser) replaces `atem-reference.xml` atomically together with the config; its hash goes into History; the old file is kept dated under `history/atem/`; refused during a live service; Review shows "product name matches / N audio and M camera values differ from the current file" and needs the tick "I checked this is the right file for this switcher"; [Check the switcher now] reads only (3 s, a second connection, guarded). | IP; globs resolve; XML parses with the watchdog's own parser, no DOCTYPE or entity declarations, <= 20 MB; compare is a subset of MIX_OPTIONS/CCU_PARAMS; folder exists (light); restart |
| "this campus has no ATEM" switch (the `atem` section absent) | Site setup > ATEM | tick box | Hides the section; an atem check or a saved-switcher-file fix then fails validation with the key named. | cascade listed; restart | | "this location has no ATEM" switch (the `atem` section absent) | Site setup > ATEM | tick box | Hides the section; an atem check or a saved-switcher-file fix then fails validation with the key named. | cascade listed; restart |
| `restart_page.jump_pos/back_pos`, `restart_page.keys[].id/label/pos/fix/watch[]` | Keys (Restart tab) | jump/back chips; restart keys: label, position, fix, "keep flashing HOLD TO FIX while these are down" | watch = fix ids and "conn:<label>". | id unique vs tiles; fix exists; labels live-checked; pages (fix/watch: restart) | | `restart_page.jump_pos/back_pos`, `restart_page.keys[].id/label/pos/fix/watch[]` | Keys (Restart tab) | jump/back chips; restart keys: label, position, fix, "keep flashing HOLD TO FIX while these are down" | watch = fix ids and "conn:<label>". | id unique vs tiles; fix exists; labels live-checked; pages (fix/watch: restart) |
| "this campus has no Restart page" switch (the `restart_page` section absent) | Keys (Restart tab) | tick box | make_page builds no Restart page and no jump key; the restart keys and the jump chip go with it (cascade listed). | cascade listed; pages | | "this location has no Restart page" switch (the `restart_page` section absent) | Keys (Restart tab) | tick box | make_page builds no Restart page and no jump key; the restart keys and the jump chip go with it (cascade listed). | cascade listed; pages |
| Companion connection labels | Site setup > Companion | live list with module id, status, watched by; rename-to for a vanished label; [Add a key for it] on unwatched ones | | warning only | | Companion connection labels | Site setup > Companion | live list with module id, status, watched by; rename-to for a vanished label; [Add a key for it] on unwatched ones | | warning only |
| custom variables / blink trigger / modules / pages exist / our pages / page-number buttons | Site setup > Companion | lights; "coloured on N of M pages" (counted when the section is opened) + [Colour them now] + [Undo]; [Re-import the pages now] | Set up once by the person who installed Companion. | by hand except nav revert and re-import | | custom variables / blink trigger / modules / pages exist / our pages / page-number buttons | Site setup > Companion | lights; "coloured on N of M pages" (counted when the section is opened) + [Colour them now] + [Undo]; [Re-import the pages now] | Set up once by the person who installed Companion. | by hand except nav revert and re-import |
| `companion-objects/` (trigger export, quiet Tascam module, nav templates per version) NEW | Help > Set up a new campus | shown with the import steps | Captured from NA once (Phase 1); not editable on the page. | repo files, deployed to the data dir | | `companion-objects/` (trigger export, quiet Tascam module, nav templates per version) NEW | Help > Set up a new location | shown with the import steps | Captured from NA once (Phase 1); not editable on the page. | repo files, deployed to the data dir |
| Windows program table (`runs[].kind=task`) | Computers (card) | task name, exe candidates, args, at logon | Generates windows-setup.ps1, task names in fixes and the "is running on a Windows PC" check. | cmd-safe regex; none (dependents -> restart) | | Windows program table (`runs[].kind=task`) | Computers (card) | task name, exe candidates, args, at logon | Generates windows-setup.ps1, task names in fixes and the "is running on a Windows PC" check. | cmd-safe regex; none (dependents -> restart) |
| `$Key`, `$IngestIPs` in scripts | Computers > Setup | from `ssh_key.pub` and `site.ingest_ips` | | - | | `$Key`, `$IngestIPs` in scripts | Computers > Setup | from `ssh_key.pub` and `site.ingest_ips` | | - |
| `{{PT_BUNDLE_ID}}`, `{{SOLO_MENU_PATH}}` in the solo agent | Computers (Studio B card, Advanced) | Pro Tools bundle id, solo menu path | Differ per Pro Tools version/language; defaults are today's values. | non-empty; regenerating shows the agent needs a rebuild + Accessibility re-grant | | `{{PT_BUNDLE_ID}}`, `{{SOLO_MENU_PATH}}` in the solo agent | Computers (Studio B card, Advanced) | Pro Tools bundle id, solo menu path | Differ per Pro Tools version/language; defaults are today's values. | non-empty; regenerating shows the agent needs a rebuild + Accessibility re-grant |
@@ -171,7 +171,7 @@ The other NA records: **prop** (sshd, DVS daemon NA-ProPresenter-Mac, helper_dvs
- Every Mac: Remote Login on; Ingest login key authorised (generated `mac-authorize.sh`); host key known (with "changed since last time" + [Forget the old login key]); `/usr/bin/python3` present (for MIDI reads); DVS helper + sudoers (computers with `helper_dvs`); Audio MIDI Setup sessions named as the checks expect (expected beside live; a peer added by the Add a MIDI link job shows "will be added on first hold"; a typed session shows NO SESSION until it exists); apps at the listed paths; Studio B: solo agent running, `solo-mode.json` fresh, `trusted: true` (Accessibility), swiftc present for a rebuild. - Every Mac: Remote Login on; Ingest login key authorised (generated `mac-authorize.sh`); host key known (with "changed since last time" + [Forget the old login key]); `/usr/bin/python3` present (for MIDI reads); DVS helper + sudoers (computers with `helper_dvs`); Audio MIDI Setup sessions named as the checks expect (expected beside live; a peer added by the Add a MIDI link job shows "will be added on first hold"; a typed session shows NO SESSION until it exists); apps at the listed paths; Studio B: solo agent running, `solo-mode.json` fresh, `trusted: true` (Accessibility), swiftc present for a rebuild.
- Every Windows PC: sshd Automatic + running; OpenSSH DefaultShell is cmd.exe (light); firewall rule allows this site's Ingest IPs; key login works (both authorized_keys files); a `\StatusPanel\` task per `task` run; services' failure-restart; WoL on the adapter; BIOS WoL (Done by hand); Public profile when rtpMIDI is listed (light + Done by hand); Vista autoload (Done by hand); Wi-Fi power (Done by hand); RESULT block pasted (fills ssh user, MAC, IP); the host-key-changed line as on Macs. - Every Windows PC: sshd Automatic + running; OpenSSH DefaultShell is cmd.exe (light); firewall rule allows this site's Ingest IPs; key login works (both authorized_keys files); a `\StatusPanel\` task per `task` run; services' failure-restart; WoL on the adapter; BIOS WoL (Done by hand); Public profile when rtpMIDI is listed (light + Done by hand); Vista autoload (Done by hand); Wi-Fi power (Done by hand); RESULT block pasted (fills ssh user, MAC, IP); the host-key-changed line as on Macs.
- This Mac: Command Line Tools / python 3.9-3.12; watchdog agent (loaded, running, ExitTimeOut set to the derived value); settings agent; Companion checklist (variables, blink trigger, pages exist, our pages, quiet Tascam module, ATEM module, "use last page" noted); keychain item (if `dante_switches`); the watchdog's Spotify permission (if a spotify check); DVS helper + sudoers (if dvs_ingest); login key pair ([Make the login key] at a new campus); editing code present; ports for LAN / Dante / switch management up; Art-Net port bound (if artnet); saved-file folder mounted (if `atem.source_folder` is set); Remote Login on (for the MacBook's `checklist-<id>` alias); Church Translate not loaded. - This Mac: Command Line Tools / python 3.9-3.12; watchdog agent (loaded, running, ExitTimeOut set to the derived value); settings agent; Companion checklist (variables, blink trigger, pages exist, our pages, quiet Tascam module, ATEM module, "use last page" noted); keychain item (if `dante_switches`); the watchdog's Spotify permission (if a spotify check); DVS helper + sudoers (if dvs_ingest); login key pair ([Make the login key] at a new location); editing code present; ports for LAN / Dante / switch management up; Art-Net port bound (if artnet); saved-file folder mounted (if `atem.source_folder` is set); Remote Login on (for the MacBook's `checklist-<id>` alias); Church Translate not loaded.
**Generated scripts** (`settings/generate.py`): "Generate" fills `remote-access/mac-authorize.sh.template`, `mac-dvs-helper.sh.template`, `windows-setup.ps1.template`, `install-solo-mode.sh.template` and `solo-mode-agent.swift.template` with the placeholders `{{PUBLIC_KEY}}`, `{{INGEST_IPS}}`, `{{APPS}}`, `{{DVS_HELPER}}`, `{{SOLO_DIR}}`, `{{SOLO_LABEL}}`, `{{PT_BUNDLE_ID}}`, `{{SOLO_MENU_PATH}}` and `{{SITE}}` from the site's public key, Ingest IPs, program table, helper paths and site name; every Mac value is shell-quoted, every Windows value passes the cmd-safe regex (and contains no `!` or `^`); the Windows script also sets the OpenSSH DefaultShell to cmd.exe. Each script is shown with Copy / Download and the exact place to paste it (ARD "Send UNIX Command as root" / PowerShell as administrator / scp + ssh for Studio B; a rebuilt solo agent needs Accessibility re-granted on that Mac). The page never runs them (guideline 12). The schema check compares `site.solo_dir` against the generated strings of the Studio B checks, the solo fix and the swift template, so all three agree by construction. The templates are deployed to the data dir by `install.sh`, and `test_generate_from_data_dir` (Phase 5) proves generate.py finds and fills every one of them there. **Generated scripts** (`settings/generate.py`): "Generate" fills `remote-access/mac-authorize.sh.template`, `mac-dvs-helper.sh.template`, `windows-setup.ps1.template`, `install-solo-mode.sh.template` and `solo-mode-agent.swift.template` with the placeholders `{{PUBLIC_KEY}}`, `{{INGEST_IPS}}`, `{{APPS}}`, `{{DVS_HELPER}}`, `{{SOLO_DIR}}`, `{{SOLO_LABEL}}`, `{{PT_BUNDLE_ID}}`, `{{SOLO_MENU_PATH}}` and `{{SITE}}` from the site's public key, Ingest IPs, program table, helper paths and site name; every Mac value is shell-quoted, every Windows value passes the cmd-safe regex (and contains no `!` or `^`); the Windows script also sets the OpenSSH DefaultShell to cmd.exe. Each script is shown with Copy / Download and the exact place to paste it (ARD "Send UNIX Command as root" / PowerShell as administrator / scp + ssh for Studio B; a rebuilt solo agent needs Accessibility re-granted on that Mac). The page never runs them (guideline 12). The schema check compares `site.solo_dir` against the generated strings of the Studio B checks, the solo fix and the swift template, so all three agree by construction. The templates are deployed to the data dir by `install.sh`, and `test_generate_from_data_dir` (Phase 5) proves generate.py finds and fills every one of them there.
@@ -332,10 +332,10 @@ Every inventory item, and where the plan handles it or why it is deliberately no
| mdns instance/name (picker); Bonjour type fixed | Keys; type DNE | | mdns instance/name (picker); Bonjour type fixed | Keys; type DNE |
| midi_rx source/name/max_silence_seconds | Keys | | midi_rx source/name/max_silence_seconds | Keys |
| remote host/cmd/msg/shell NEW (Macs and Windows PCs); artnet source/max_silence/msg (port DNE); atem check part/msg | Keys; remote built from "A program is running on a Mac" / "on a Windows PC" / "A helper file is fresh" builders, Custom under Advanced with the editing code typed again, `sh -n` on the batched Mac line, Windows pairs with no `%ERRORLEVEL%` (verified on the Lighting PC), DefaultShell light, never run before Apply; host not self | | remote host/cmd/msg/shell NEW (Macs and Windows PCs); artnet source/max_silence/msg (port DNE); atem check part/msg | Keys; remote built from "A program is running on a Mac" / "on a Windows PC" / "A helper file is fresh" builders, Custom under Advanced with the editing code typed again, `sh -n` on the batched Mac line, Windows pairs with no `%ERRORLEVEL%` (verified on the Lighting PC), DefaultShell light, never run before Apply; host not self |
| atem section/host/node/library (resolved)/reference (upload, dated history)/source_folder NEW; MIX_OPTIONS/CCU_PARAMS; parser; atem_watch.js constants; atem_state.js | Site setup > ATEM incl. `atem.compare`, pick-newest from the folder, upload cap/DOCTYPE/diff/tick, live refusal, the saved file as part of the applied set (atomic, hashed in History, restored by rollback and Go back, never overwritten by install.sh), guarded "Check the switcher now" with the client-limit note; "this campus has no ATEM" switch; js constants DNE | | atem section/host/node/library (resolved)/reference (upload, dated history)/source_folder NEW; MIX_OPTIONS/CCU_PARAMS; parser; atem_watch.js constants; atem_state.js | Site setup > ATEM incl. `atem.compare`, pick-newest from the folder, upload cap/DOCTYPE/diff/tick, live refusal, the saved file as part of the applied set (atomic, hashed in History, restored by rollback and Go back, never overwritten by install.sh), guarded "Check the switcher now" with the client-limit note; "this location has no ATEM" switch; js constants DNE |
| auto_fix section/windows/after/tries/retry/pause/restart_connections/fixes/forget_seconds/log_dir/retention/header text | Site setup > Schedules (tick list and restart_connections main; numbers, folder and `keep_months` Advanced with defaults); windows derived from live services; header and "may run by itself" phrase built from site + windows; restart (AutoLog in __init__) | | auto_fix section/windows/after/tries/retry/pause/restart_connections/fixes/forget_seconds/log_dir/retention/header text | Site setup > Schedules (tick list and restart_connections main; numbers, folder and `keep_months` Advanced with defaults); windows derived from live services; header and "may run by itself" phrase built from site + windows; restart (AutoLog in __init__) |
| site.live_services NEW (and the former site.live_signals idea) | Site setup > Schedules (one schedule incl. overnight windows); defaults to `auto_fix.windows`, the migration writes it; "Also counts as live" shows only the built-in recording-deck signal (no conn/var/value triple; "OBS is streaming" only as a built-in kind if the Phase 1 spike finds the module variable); stale handling (30 s); no quiet time and no queue: Apply is refused while live and the draft is kept | | site.live_services NEW (and the former site.live_signals idea) | Site setup > Schedules (one schedule incl. overnight windows); defaults to `auto_fix.windows`, the migration writes it; "Also counts as live" shows only the built-in recording-deck signal (no conn/var/value triple; "OBS is streaming" only as a built-in kind if the Phase 1 spike finds the module variable); stale handling (30 s); no quiet time and no queue: Apply is refused while live and the draft is kept |
| restart_page section/page/page_name/jump_pos/back_pos/keys id/label/pos/fix/watch; section absent | Keys Restart tab (jump/back chips, "this campus has no Restart page" switch with its cascade; make_page builds nothing when absent) | | restart_page section/page/page_name/jump_pos/back_pos/keys id/label/pos/fix/watch; section absent | Keys Restart tab (jump/back chips, "this location has no Restart page" switch with its cascade; make_page builds nothing when absent) |
| make_page options/steps/style, nav templates, stale texts, PURPLE, button style, action ids, notes markers | DNE; notes wording from the site (+ the page address on ALL, markers on the jump keys, one `MARKERS` constant); stale seconds editable; nav templates per version in `companion-objects/nav_templates/` (root file dropped) with the recapture recipe | | make_page options/steps/style, nav templates, stale texts, PURPLE, button style, action ids, notes markers | DNE; notes wording from the site (+ the page address on ALL, markers on the jump keys, one `MARKERS` constant); stale seconds editable; nav templates per version in `companion-objects/nav_templates/` (root file dropped) with the recapture recipe |
| HOLD_MS, GRID, STALE_SECONDS, NAV_LOCATIONS, EXPORT_VERSION/COMPANION_BUILD | `page_hold_ms` (read by the watchdog for "HOLD 2s"), `page_grid`, `page_stale_seconds`, `companion.export_version/build` (picks the nav template); NAV_LOCATIONS DNE (locked cells) | | HOLD_MS, GRID, STALE_SECONDS, NAV_LOCATIONS, EXPORT_VERSION/COMPANION_BUILD | `page_hold_ms` (read by the watchdog for "HOLD 2s"), `page_grid`, `page_stale_seconds`, `companion.export_version/build` (picks the nav template); NAV_LOCATIONS DNE (locked cells) |
| push_page/nav_keys --host defaults; "on NA Ingest" wording | read the config; site block (Phase 0) | | push_page/nav_keys --host defaults; "on NA Ingest" wording | read the config; site block (Phase 0) |
@@ -382,7 +382,7 @@ Every inventory item, and where the plan handles it or why it is deliberately no
|---|---| |---|---|
| MacBook project folder, Gitea remote, checklist-ingest alias, memory files, gitea keychain item | DNE (dev side); scp staging (code only) + Export site bundle; `checklist-<id>` alias in the bootstrap list; no git credential on Ingest | | MacBook project folder, Gitea remote, checklist-ingest alias, memory files, gitea keychain item | DNE (dev side); scp staging (code only) + Export site bundle; `checklist-<id>` alias in the bootstrap list; no git credential on Ingest |
| NA Ingest LaunchAgent (exit timeout), staging folder, data dir, python3 3.9 (3.13 telnetlib risk), logs, auto-fix logs, state, dante-map, atem-reference.json, config backups, ssh sockets, MIDI streams, ATEM watcher, Art-Net listener, Bonjour, local CoreMIDI sessions, local probes, Spotify osascript, Companion db read | Ingest card lights incl. the python-version self-check, launchd agent state with the derived exit timeout and started.json (§B); Logs; History; Where things are; nothing copied to the staging folder by Apply | | NA Ingest LaunchAgent (exit timeout), staging folder, data dir, python3 3.9 (3.13 telnetlib risk), logs, auto-fix logs, state, dante-map, atem-reference.json, config backups, ssh sockets, MIDI streams, ATEM watcher, Art-Net listener, Bonjour, local CoreMIDI sessions, local probes, Spotify osascript, Companion db read | Ingest card lights incl. the python-version self-check, launchd agent state with the derived exit timeout and started.json (§B); Logs; History; Where things are; nothing copied to the staging folder by Apply |
| Companion app/version, custom variables, flash trigger, page 19, page 20, nav keys on 97 pages (gap at 96), quiet Tascam module, module folders, Companion Satellite, Stream Deck surfaces | Site setup > Companion (version, variables, pages exist / ours, page-number buttons "coloured on N of M" counted when the section is opened with the gap listed, module id per connection, version only if trivial); surfaces not shown; Satellite on the Ingest card (`watched:false`); Companion set-up by hand; `companion-objects/` for a new campus | | Companion app/version, custom variables, flash trigger, page 19, page 20, nav keys on 97 pages (gap at 96), quiet Tascam module, module folders, Companion Satellite, Stream Deck surfaces | Site setup > Companion (version, variables, pages exist / ours, page-number buttons "coloured on N of M" counted when the section is opened with the gap listed, module id per connection, version only if trivial); surfaces not shown; Satellite on the Ingest card (`watched:false`); Companion set-up by hand; `companion-objects/` for a new location |
| every Companion connection (HyperDeck_1-6, tascam, x32, ATEM, dante, Axient_1/2, ULXD-4, PSM_1-3, ableton, Propresenter-API, BibleTV, lighting-midi, OBS-Youtube, arena, RESI_Decoder, resi-studio-cues, VX1000, UHDJr) and unwatched ones (Govee x7, DiGiCo_OSC, m32, AbleSet, tcp-HyperDeck-1..6) | Site setup > Companion live list with module id, watched-by, module-mismatch warning, "Add a key for it"; the HyperDeck job offers only bmd-hyperdeck connections; Open question Q4 | | every Companion connection (HyperDeck_1-6, tascam, x32, ATEM, dante, Axient_1/2, ULXD-4, PSM_1-3, ableton, Propresenter-API, BibleTV, lighting-midi, OBS-Youtube, arena, RESI_Decoder, resi-studio-cues, VX1000, UHDJr) and unwatched ones (Govee x7, DiGiCo_OSC, m32, AbleSet, tcp-HyperDeck-1..6) | Site setup > Companion live list with module id, watched-by, module-mismatch warning, "Add a key for it"; the HyperDeck job offers only bmd-hyperdeck connections; Open question Q4 |
| Resi bridge, lobby captions, caption screen, ProdCom (start method unknown), ProdDash, Spotify.app, ATEM Software Control + midiEnabled, DVS on Ingest, Church Translate disabled, Google Drive, free disk | Ingest `runs` (`disabled` kind for Translate; Drive folder probe via `atem.source_folder`) + INGEST key checks; bridge "never by itself"; captions fix with two helpers; dvs_ingest with prodcom rendered inline; prodcom/proddash without restart_ok unless ticked | | Resi bridge, lobby captions, caption screen, ProdCom (start method unknown), ProdDash, Spotify.app, ATEM Software Control + midiEnabled, DVS on Ingest, Church Translate disabled, Google Drive, free disk | Ingest `runs` (`disabled` kind for Translate; Drive folder probe via `atem.source_folder`) + INGEST key checks; bridge "never by itself"; captions fix with two helpers; dvs_ingest with prodcom rendered inline; prodcom/proddash without restart_ok unless ticked |
| other Ingest agents (checklist runner, backups, Itsycal, Alfred, Signiant) **and anything installed later** | Ingest `runs` with `watched:false`; the inventory probe lists every agent, login item and listening port under the collapsed "Everything found on this computer" list on the card, raw and unfiltered; no Add/Ignore, no counts on Overview | | other Ingest agents (checklist runner, backups, Itsycal, Alfred, Signiant) **and anything installed later** | Ingest `runs` with `watched:false`; the inventory probe lists every agent, login item and listening port under the collapsed "Everything found on this computer" list on the card, raw and unfiltered; no Add/Ignore, no counts on Overview |
@@ -417,4 +417,4 @@ Every inventory item, and where the plan handles it or why it is deliberately no
| service_windows + ALARMS, auto_fix window/list, poller periods, SSH identity/options, hosts table, Mac/Windows external setup, Public profile, Vista autoload, keychain, Spotify Automation, MIDI sessions local/remote, Ingest services, ATEM Software Control pref, hidden tiles, groups, restart keys, fix catalogue, network, alert_webhook, colours, cooldown/hold age, project folder, variables not persisted, full change sequence | covered by §A-§D; one schedule on the page; the one alarms sentence and the Silence alarms until midnight button (not guarded); the full change sequence collapses to: change -> Review & Apply -> done (refused while live with the draft kept; Emergency override), with the repo snapshot optional | | service_windows + ALARMS, auto_fix window/list, poller periods, SSH identity/options, hosts table, Mac/Windows external setup, Public profile, Vista autoload, keychain, Spotify Automation, MIDI sessions local/remote, Ingest services, ATEM Software Control pref, hidden tiles, groups, restart keys, fix catalogue, network, alert_webhook, colours, cooldown/hold age, project folder, variables not persisted, full change sequence | covered by §A-§D; one schedule on the page; the one alarms sentence and the Silence alarms until midnight button (not guarded); the full change sequence collapses to: change -> Review & Apply -> done (refused while live with the draft kept; Emergency override), with the repo snapshot optional |
| History / Undo reachable from a clean state; pending work visible; vendor noise; two edit homes; four meanings of "key"; two meanings of "Settings" | History from Overview and its own menu entry, list and bodies open on the LAN; no pending work exists (a change refused while live stays a saved draft, said on Overview and in Review); raw inventory list on the card and no Overview count; fixes edited from the key, the catalogue under the "Set up once" divider; one owner screen per value with read-only links; editing code / login key / page-number buttons / connection; the page is "Status Panel", the section is "Site setup" | | History / Undo reachable from a clean state; pending work visible; vendor noise; two edit homes; four meanings of "key"; two meanings of "Settings" | History from Overview and its own menu entry, list and bodies open on the LAN; no pending work exists (a change refused while live stays a saved draft, said on Overview and in Review); raw inventory list on the card and no Overview count; fixes edited from the key, the catalogue under the "Set up once" divider; one owner screen per value with read-only links; editing code / login key / page-number buttons / connection; the page is "Status Panel", the section is "Site setup" |
| too many places; admin actions hidden under Help; three status vocabularies; contradictions about alarms; thresholds on main screens; hidden jobs; confusing tray; two watchdog homes; mixed confirmations; walkthrough behind the code | eight entries, the two set-up-once screens under a divider and readable by anyone; Help read-only, actions under Site setup > Site (Install the staged folder, Export site bundle); the one status legend printed once under the top bar's `?`; one alarms sentence; numbers under Advanced with defaults; three job buttons on Overview + More jobs; the labelled tray; watchdog buttons on Overview only; one confirmation style; the walkthrough needs no code because reads are open (the Training copy is deferred) | | too many places; admin actions hidden under Help; three status vocabularies; contradictions about alarms; thresholds on main screens; hidden jobs; confusing tray; two watchdog homes; mixed confirmations; walkthrough behind the code | eight entries, the two set-up-once screens under a divider and readable by anyone; Help read-only, actions under Site setup > Site (Install the staged folder, Export site bundle); the one status legend printed once under the top bar's `?`; one alarms sentence; numbers under Advanced with defaults; three job buttons on Overview + More jobs; the labelled tray; watchdog buttons on Overview only; one confirmation style; the walkthrough needs no code because reads are open (the Training copy is deferred) |
| the user's four named asks: a new MIDI link (incl. a brand-new peer, or a Mac that is off), a different Spotify playlist (name follows everywhere), HyperDecks 2 and 4 only (both set to "must have a disk", generic TCP connections not offered), a new campus (own windows incl. overnight, own wording, reachable page before the LAN port is named, optional Restart page, optional ATEM) | the Overview jobs (first three, one click each) and the "New campus from this one" job (started from a site bundle you pick, exported from the source campus's page and copied over with the folder, or from any sites/<x>/ folder in the copied repo; `install.sh --site <id>` uses sites/<id>/ when present, else sites/_template/; D12) incl. bootstrap, the template's self record with `networks: []` and `companion-objects/` in the data dir; every copied value flagged "copied from <site> - check it" until touched | | the user's four named asks: a new MIDI link (incl. a brand-new peer, or a Mac that is off), a different Spotify playlist (name follows everywhere), HyperDecks 2 and 4 only (both set to "must have a disk", generic TCP connections not offered), a new location (own windows incl. overnight, own wording, reachable page before the LAN port is named, optional Restart page, optional ATEM) | the Overview jobs (first three, one click each) and the "New location from this one" job (started from a site bundle you pick, exported from the source location's page and copied over with the folder, or from any sites/<x>/ folder in the copied repo; `install.sh --site <id>` uses sites/<id>/ when present, else sites/_template/; D12) incl. bootstrap, the template's self record with `networks: []` and `companion-objects/` in the data dir; every copied value flagged "copied from <site> - check it" until touched |
+18 -18
View File
@@ -1,12 +1,12 @@
# Status Panel settings page — the plan # Status Panel settings page — the plan
One web page on the Ingest Mac that holds every setting the panel has, shows what runs on which computer, and works the same at every campus. Nobody edits `config.json` or a script by hand any more. One web page on the Ingest Mac that holds every setting the panel has, shows what runs on which computer, and works the same at every location. Nobody edits `config.json` or a script by hand any more.
Companion files: [settings-plan-details.md](settings-plan-details.md) (every field, the data model, the apply pipeline, tests), [settings-plan-audit.md](settings-plan-audit.md) (how the plan was audited and what was cut), [settings-inventory.md](settings-inventory.md) (the 544 things the system has today, each with where it lives and how it is changed now). Companion files: [settings-plan-details.md](settings-plan-details.md) (every field, the data model, the apply pipeline, tests), [settings-plan-audit.md](settings-plan-audit.md) (how the plan was audited and what was cut), [settings-inventory.md](settings-inventory.md) (the 544 things the system has today, each with where it lives and how it is changed now).
## 1. What it is ## 1. What it is
The Status Panel is the Stream Deck page that is green when the room is fine and flashes red naming the problem. A watchdog program on the Ingest Mac runs it from one settings file. This plan adds a **settings page** on that same Mac, opened in any browser on the church network. Every setting is a labelled field with help: which computers exist and what runs on each, which keys are on the deck, what each key checks, what holding it does, when auto-fix may act, which Spotify playlist, switcher file, Dante devices, HyperDecks and MIDI links are in use. The page checks a change, shows in plain words what it will do, keeps a copy of every version, applies it safely, and the same page runs at every campus with that campus's own computers and gear. The Status Panel is the Stream Deck page that is green when the room is fine and flashes red naming the problem. A watchdog program on the Ingest Mac runs it from one settings file. This plan adds a **settings page** on that same Mac, opened in any browser on the church network. Every setting is a labelled field with help: which computers exist and what runs on each, which keys are on the deck, what each key checks, what holding it does, when auto-fix may act, which Spotify playlist, switcher file, Dante devices, HyperDecks and MIDI links are in use. The page checks a change, shows in plain words what it will do, keeps a copy of every version, applies it safely, and the same page runs at every location with that location's own computers and gear.
Four words are used everywhere on it: a **key** (on the Stream Deck), a **computer** (anything we log in to, this Mac included), a **fix** (what holding a key runs) and a **connection** (Companion's link to one device: a HyperDeck, the Tascam, the switcher). The code you type to switch Editing on is the **editing code**. The file this Mac uses to log in to the other computers is the **login key**. Four words are used everywhere on it: a **key** (on the Stream Deck), a **computer** (anything we log in to, this Mac included), a **fix** (what holding a key runs) and a **connection** (Companion's link to one device: a HyperDeck, the Tascam, the switcher). The code you type to switch Editing on is the **editing code**. The file this Mac uses to log in to the other computers is the **login key**.
@@ -14,8 +14,8 @@ Four words are used everywhere on it: a **key** (on the Stream Deck), a **comput
- It runs on the Ingest Mac as a second background program beside the watchdog, installed by the same `install.sh`. Plain Python, nothing to install. It is a separate program so a bug in the page can never stop the panel, and the page keeps working while the watchdog restarts. - It runs on the Ingest Mac as a second background program beside the watchdog, installed by the same `install.sh`. Plain Python, nothing to install. It is a separate program so a bug in the page can never stop the panel, and the page keeps working while the watchdog restarts.
- Open **http://na-ingest.local:8020/** from any Mac, PC, iPad or phone on the church network. The address is printed on the cheat sheet and written into the ALL key's note in Companion, so anyone who finds the deck finds the page. - Open **http://na-ingest.local:8020/** from any Mac, PC, iPad or phone on the church network. The address is printed on the cheat sheet and written into the ALL key's note in Companion, so anyone who finds the deck finds the page.
- **Anyone on the network can look** at everything: the deck, the computers, the logs, the history, the help. **Changing anything needs the editing code**: switch **Editing** on in the top bar, type the code once and your first name; the browser remembers both. The code is a setting in the campus's `config.json`, which lives in the private git repo, so everyone who has the repo has it; change it on Site setup › Site. The page never shows it to anyone who is not already editing. It is not a password for anything else. Five wrong tries lock the browser out for ten minutes. - **Anyone on the network can look** at everything: the deck, the computers, the logs, the history, the help. **Changing anything needs the editing code**: switch **Editing** on in the top bar, type the code once and your first name; the browser remembers both. The code is a setting in the location's `config.json`, which lives in the private git repo, so everyone who has the repo has it; change it on Site setup › Site. The page never shows it to anyone who is not already editing. It is not a password for anything else. Five wrong tries lock the browser out for ten minutes.
- It edits the live settings file the watchdog reads (in `~/Library/Application Support/status-panel/`). From then on the Ingest Mac's copy is the truth for that campus; the git repo keeps a snapshot you export from the page. - It edits the live settings file the watchdog reads (in `~/Library/Application Support/status-panel/`). From then on the Ingest Mac's copy is the truth for that location; the git repo keeps a snapshot you export from the page.
## 3. What you see ## 3. What you see
@@ -67,7 +67,7 @@ The first screen answers "is the room OK, and what do I do". In this order: the
### Keys ### Keys
Two tabs, **Status page** and **Restart page**, each drawn as the 8×4 deck people hold. Every key is a chip you can drag to move, drag off the deck to hide, or click to edit. Column 0 (UP / page number / DOWN) is locked. ALL and ALARMS can be moved but not deleted; NET can be removed at a campus that does not want it. Keys with no spot of their own (AXIENT 1, AXIENT 2, ULXD, PSM 1–3) sit in a tray labelled "Shown inside the RF group (no key of their own)". Each tab header holds its page number; the Restart tab's also holds the switch "this campus has no Restart page". A yellow note says "Layout changed: Apply will re-import the Status page" when it will. Two tabs, **Status page** and **Restart page**, each drawn as the 8×4 deck people hold. Every key is a chip you can drag to move, drag off the deck to hide, or click to edit. Column 0 (UP / page number / DOWN) is locked. ALL and ALARMS can be moved but not deleted; NET can be removed at a location that does not want it. Keys with no spot of their own (AXIENT 1, AXIENT 2, ULXD, PSM 1–3) sit in a tray labelled "Shown inside the RF group (no key of their own)". Each tab header holds its page number; the Restart tab's also holds the switch "this location has no Restart page". A yellow note says "Layout changed: Apply will re-import the Status page" when it will.
Click a key and its editor opens beside the grid. Two lines at the top answer the only two things a person needs to know: **WHAT THIS KEY WILL SAY** (every word the checks can put on it) and **HOLDING 2 s WILL:** ("switch HyperDeck_2's connection off and on, also when green"). Then only the fields that matter: the label, the group members (for a group key), the **checks** as a short list, and **When held** (which fix, and "also when the key is green"). "Add a check" asks a question first, not a type: *Is a device answering? Is a program running? Is a MIDI link up? Is a recorder OK? Is a setting right? Is Spotify playing? Is Dante complete? Is the lighting console sending?* You pick the computer or device from a live list (Companion's connections, the Dante devices, that Mac's MIDI sessions and peers, its programs) and the page builds the check. The raw check types and ids live under Advanced. Click a key and its editor opens beside the grid. Two lines at the top answer the only two things a person needs to know: **WHAT THIS KEY WILL SAY** (every word the checks can put on it) and **HOLDING 2 s WILL:** ("switch HyperDeck_2's connection off and on, also when green"). Then only the fields that matter: the label, the group members (for a group key), the **checks** as a short list, and **When held** (which fix, and "also when the key is green"). "Add a check" asks a question first, not a type: *Is a device answering? Is a program running? Is a MIDI link up? Is a recorder OK? Is a setting right? Is Spotify playing? Is Dante complete? Is the lighting console sending?* You pick the computer or device from a live list (Companion's connections, the Dante devices, that Mac's MIDI sessions and peers, its programs) and the page builds the check. The raw check types and ids live under Advanced.
@@ -121,11 +121,11 @@ COMPUTERS - MAP [Check computers now] looked 2
Six sections, each opening on the few things a person changes: Six sections, each opening on the few things a person changes:
- **Site:** site name, this Mac's name and addresses, contacts for the cheat sheet, the alert message hook. Advanced: page port, the login key (present / not, public half to copy), helper paths, the editing code, install the staged folder, export the site bundle. - **Site:** site name, this Mac's name and addresses, contacts for the cheat sheet, the alert message hook. Advanced: page port, the login key (present / not, public half to copy), helper paths, the editing code, install the staged folder, export the site bundle.
- **Schedules:** when **auto-fix may act by itself** (Sunday 06:30–13:30 today) and the tick list of fixes it may run; alarms are always on. Changes to the panel wait while a HyperDeck is recording, not by the clock (a campus can add clock windows here too; NA has none). Advanced: the timing numbers (a fault must last 3 s, flapping, the 60 s cooldown, the 2 s hold). - **Schedules:** when **auto-fix may act by itself** (Sunday 06:30–13:30 today) and the tick list of fixes it may run; alarms are always on. Changes to the panel wait while a HyperDeck is recording, not by the clock (a location can add clock windows here too; NA has none). Advanced: the timing numbers (a fault must last 3 s, flapping, the 60 s cooldown, the 2 s hold).
- **Companion:** the lights (variables exist, blink trigger, our pages exist, quiet Tascam module, ATEM module), the live list of connections with their module and which key watches each, "Add a key for it" for the ones nothing watches, the page-number buttons ("coloured on 96 of 97 pages" + Colour them now / Undo). Advanced: address, variable names, nav settings, grid, purple-after, export stamps. - **Companion:** the lights (variables exist, blink trigger, our pages exist, quiet Tascam module, ATEM module), the live list of connections with their module and which key watches each, "Add a key for it" for the ones nothing watches, the page-number buttons ("coloured on 96 of 97 pages" + Colour them now / Undo). Advanced: address, variable names, nav settings, grid, purple-after, export stamps.
- **Network & Dante:** the port and gateway the NET key watches, the Dante switches, and the **Dante device list** (the one place the DANTE key's devices are edited, with key text and per-device fix), "Check the Dante network now". Advanced: the blip rule, survey interval. - **Network & Dante:** the port and gateway the NET key watches, the Dante switches, and the **Dante device list** (the one place the DANTE key's devices are edited, with key text and per-device fix), "Check the Dante network now". Advanced: the blip rule, survey interval.
- **Spotify:** one field for the playlist (paste the link; name and song count fill in), the two waits, a light saying the watchdog may control Spotify. - **Spotify:** one field for the playlist (paste the link; name and song count fill in), the two waits, a light saying the watchdog may control Spotify.
- **ATEM:** the switcher address, the saved file (upload, or pick the newest from the Google Drive folder; you see what differs before it is applied), what to keep in line (audio, each camera setting), "Check the switcher now". A switch "this campus has no ATEM". - **ATEM:** the switcher address, the saved file (upload, or pick the newest from the Google Drive folder; you see what differs before it is applied), what to keep in line (audio, each camera setting), "Check the switcher now". A switch "this location has no ATEM".
### Review & Apply ### Review & Apply
@@ -148,21 +148,21 @@ Grouped here; every single field is in [the details table](settings-plan-details
| Spotify | playlist, name, waits | Site setup → Spotify | | Spotify | playlist, name, waits | Site setup → Spotify |
| ATEM | address, saved file, what to compare, node/library patterns | Site setup → ATEM | | ATEM | address, saved file, what to compare, node/library patterns | Site setup → ATEM |
| Site | name, this Mac's name/addresses, contacts, alert hook, port, helper paths, editing code | Site setup → Site | | Site | name, this Mac's name/addresses, contacts, alert hook, port, helper paths, editing code | Site setup → Site |
| Not editable on purpose | colours, the words on keys, the 3 s/1 s/0 s debounce defaults, poll rates, the hold/ack mechanics, Companion's API paths, the shell commands the page builds — these are how the panel *works*, the same at every campus (guideline 1), and are listed in Help › Glossary and Where things are | Help | | Not editable on purpose | colours, the words on keys, the 3 s/1 s/0 s debounce defaults, poll rates, the hold/ack mechanics, Companion's API paths, the shell commands the page builds — these are how the panel *works*, the same at every location (guideline 1), and are listed in Help › Glossary and Where things are | Help |
The four things you named, end to end: **A new MIDI link** = Add a MIDI link → pick the Mac → pick its session and peer from the live list (or type them if that Mac is off) → pick the key it goes on (MIDI if there is one, otherwise a new key) → it gains the check and the reconnect fix → Review & Apply. **A different playlist** = Change the playlist → paste the link → Apply (the key, the play fix and the key guide follow; only the watchdog restarts, and the key's note catches up at the next page re-import). **HyperDecks 2 and 4 only** = Add or remove a HyperDeck → untick 1, 3, 5, 6 → the HD keys and the REC group change (the emptied cells stay empty, or one click moves HD4 next to HD2), the page re-imports → Apply. **A new campus** = section 6. The four things you named, end to end: **A new MIDI link** = Add a MIDI link → pick the Mac → pick its session and peer from the live list (or type them if that Mac is off) → pick the key it goes on (MIDI if there is one, otherwise a new key) → it gains the check and the reconnect fix → Review & Apply. **A different playlist** = Change the playlist → paste the link → Apply (the key, the play fix and the key guide follow; only the watchdog restarts, and the key's note catches up at the next page re-import). **HyperDecks 2 and 4 only** = Add or remove a HyperDeck → untick 1, 3, 5, 6 → the HD keys and the REC group change (the emptied cells stay empty, or one click moves HD4 next to HD2), the page re-imports → Apply. **A new location** = section 6.
## 5. Computers and what runs where ## 5. Computers and what runs where
Each computer is a record the page owns: name, kind, how to log in, what runs there and whether the panel watches it. Today's eight are seeded from the inventory: NA Ingest (watchdog, settings page, Companion, Resi bridge, lobby captions and screen, ProdCom, ProdDash, Spotify, ATEM Software Control, DVS + helper, Google Drive for the switcher files, plus the other background tasks that are not the panel's), ProPresenter Mac, Tracks Mac, NA Translation, Studio B Mac Studio (the solo-mode helper, Accessibility), Lighting PC (Vista, Arena, rtpMIDI task + service, firewall, Public profile), LiveStream PC (OBS task with admin rights, wake-on-LAN), Bible TV PC (Bible app at logon, Wi-Fi power settings). The live look runs a fixed set of read-only commands on each computer (what launchd has loaded, which tasks and services exist, which programs run, is the login key accepted, is the helper installed), never during a fix, never waking a PC, with every name quoted so a typed name can never run as a command. The devices that are not computers are listed from the checks that watch them, with a note per device for where it lives. Each computer is a record the page owns: name, kind, how to log in, what runs there and whether the panel watches it. Today's eight are seeded from the inventory: NA Ingest (watchdog, settings page, Companion, Resi bridge, lobby captions and screen, ProdCom, ProdDash, Spotify, ATEM Software Control, DVS + helper, Google Drive for the switcher files, plus the other background tasks that are not the panel's), ProPresenter Mac, Tracks Mac, NA Translation, Studio B Mac Studio (the solo-mode helper, Accessibility), Lighting PC (Vista, Arena, rtpMIDI task + service, firewall, Public profile), LiveStream PC (OBS task with admin rights, wake-on-LAN), Bible TV PC (Bible app at logon, Wi-Fi power settings). The live look runs a fixed set of read-only commands on each computer (what launchd has loaded, which tasks and services exist, which programs run, is the login key accepted, is the helper installed), never during a fix, never waking a PC, with every name quoted so a typed name can never run as a command. The devices that are not computers are listed from the checks that watch them, with a note per device for where it lives.
The Setup tab generates each computer's one-time script (the Mac authorise script, the DVS helper, the Windows setup, the Studio B solo-mode agent) with that campus's key and addresses filled in. You still run them yourself (section 12); the page shows which lines are done. The Setup tab generates each computer's one-time script (the Mac authorise script, the DVS helper, the Windows setup, the Studio B solo-mode agent) with that location's key and addresses filled in. You still run them yourself (section 12); the page shows which lines are done.
## 6. The other campuses ## 6. The other locations
Apollo Beach, Woonsocket and Southcoast each already have a Mac Studio running Companion; the page and the watchdog go on that Mac. One settings file per campus = one site block plus that campus's computers, keys, checks, fixes, windows and gear. The code never contains a site name, an address, a label, a playlist, a path or a day name; everything site-shaped is data. Differences between campuses are plain differences in that data: fewer HD keys, no Studio B, another playlist, other Dante names, an extra PC, other page numbers, a Saturday evening service that runs past midnight. Apollo Beach, Woonsocket and Southcoast each already have a Mac Studio running Companion; the page and the watchdog go on that Mac. One settings file per location = one site block plus that location's computers, keys, checks, fixes, windows and gear. The code never contains a site name, an address, a label, a playlist, a path or a day name; everything site-shaped is data. Differences between locations are plain differences in that data: fewer HD keys, no Studio B, another playlist, other Dante names, an extra PC, other page numbers, a Saturday evening service that runs past midnight.
Setting up a new campus: install Companion and the command-line tools on its Ingest Mac, copy the folder over, run `install.sh --site <name>` once (it uses the repo's `sites/<name>/` if there is one, otherwise the empty template), do the Companion one-time steps (section 12). Then on the page: **New campus from this one** starts from a site bundle (export NA's from its page and copy it over with the folder, or pick any `sites/` folder in the copied repo) and walks you through it — keep, rename or drop each computer (dropping one drops every key, check and fix that used it and lists them), map the connection labels, Dante devices and MIDI sessions from that Companion's live lists, set the playlist, switcher, network and schedule, Review & Apply. Everything copied from NA is flagged "copied from North Attleboro — check it" until touched. The repo keeps one folder per site (`sites/na/`, `sites/<new>/`); the page's **Export site bundle** gives you the zip to drop in. Setting up a new location: install Companion and the command-line tools on its Ingest Mac, copy the folder over, run `install.sh --site <name>` once (it uses the repo's `sites/<name>/` if there is one, otherwise the empty template), do the Companion one-time steps (section 12). Then on the page: **New location from this one** starts from a site bundle (export NA's from its page and copy it over with the folder, or pick any `sites/` folder in the copied repo) and walks you through it — keep, rename or drop each computer (dropping one drops every key, check and fix that used it and lists them), map the connection labels, Dante devices and MIDI sessions from that Companion's live lists, set the playlist, switcher, network and schedule, Review & Apply. Everything copied from NA is flagged "copied from North Attleboro — check it" until touched. The repo keeps one folder per site (`sites/na/`, `sites/<new>/`); the page's **Export site bundle** gives you the zip to drop in.
## 7. Saving a change ## 7. Saving a change
@@ -170,7 +170,7 @@ Setting up a new campus: install Companion and the command-line tools on its Ing
2. **Check.** On every change and again at Apply: the rules (ids, positions, windows, addresses, every fix's computer exists…), the watchdog's own loader on a temporary copy, a build of both pages in memory, and live warnings (a connection label not in Companion, a computer unreachable, a private playlist). 2. **Check.** On every change and again at Apply: the rules (ids, positions, windows, addresses, every fix's computer exists…), the watchdog's own loader on a temporary copy, a build of both pages in memory, and live warnings (a connection label not in Companion, a computer unreachable, a private playlist).
3. **Review.** The plain change list, warnings, anything built as a command in red, what Apply will do. 3. **Review.** The plain change list, warnings, anything built as a command in red, what Apply will do.
4. **Apply**, each step only after the previous one worked: keep a copy of the current version in History → write the new file in one go (and the new switcher file with it, if there is one) → wait for any fix that is running to finish → restart the watchdog (a change to key notes alone skips this) → **prove it came back** (its start stamp with the new file's fingerprint, no error, still alive after 10 s) → only then re-import the pages if the layout changed (key notes alone never trigger a re-import; the page refuses to overwrite a key someone added by hand in Companion and names its position) → recolour the page-number buttons if pages changed → rebuild "What each key does". If the watchdog does not come back, the previous version (and switcher file) is restored automatically, once, and Overview shows a red note until the next good Apply. 4. **Apply**, each step only after the previous one worked: keep a copy of the current version in History → write the new file in one go (and the new switcher file with it, if there is one) → wait for any fix that is running to finish → restart the watchdog (a change to key notes alone skips this) → **prove it came back** (its start stamp with the new file's fingerprint, no error, still alive after 10 s) → only then re-import the pages if the layout changed (key notes alone never trigger a re-import; the page refuses to overwrite a key someone added by hand in Companion and names its position) → recolour the page-number buttons if pages changed → rebuild "What each key does". If the watchdog does not come back, the previous version (and switcher file) is restored automatically, once, and Overview shows a red note until the next good Apply.
5. **While a HyperDeck is recording** (that is what "live" means at NA; a campus may add clock windows) Apply is refused with the reason and when it ends; the draft stays saved. The same goes for Run this fix now, Restart the watchdog, re-imports, the switcher check and program installs. An **Emergency** button states the consequence, asks you to type LIVE and a reason, and is logged. Starting a watchdog that is down is always allowed. Silencing alarms is never blocked. 5. **While a HyperDeck is recording** (that is what "live" means at NA; a location may add clock windows) Apply is refused with the reason and when it ends; the draft stays saved. The same goes for Run this fix now, Restart the watchdog, re-imports, the switcher check and program installs. An **Emergency** button states the consequence, asks you to type LIVE and a reason, and is logged. Starting a watchdog that is down is always allowed. Silencing alarms is never blocked.
6. **Going back** = History → Go back to this (or Undo the last change) through the same steps. 6. **Going back** = History → Go back to this (or Undo the last change) through the same steps.
## 8. Safety ## 8. Safety
@@ -192,7 +192,7 @@ Help › four cards, then **What each key does** (regenerated on every Apply, so
- `schema.py`: the registry of every setting (type, default, range, help, example, which screen owns it, advanced or not). Tests keep it honest: every key the watchdog reads must be declared; every default in code must equal the schema's; every word a key can show must be in the glossary; today's config must still build today's pages exactly (apart from the key notes, which gain the page address and the markers); every one of today's 27 fixes must regenerate from its builder exactly. - `schema.py`: the registry of every setting (type, default, range, help, example, which screen owns it, advanced or not). Tests keep it honest: every key the watchdog reads must be declared; every default in code must equal the schema's; every word a key can show must be in the glossary; today's config must still build today's pages exactly (apart from the key notes, which gain the page address and the markers); every one of today's 27 fixes must regenerate from its builder exactly.
- `settings/`: the server (routes for the draft, validate, apply, history, live lists, logs, probes), the probes, the apply pipeline, the script generator, the key-guide generator, and one HTML/JS page with no framework. - `settings/`: the server (routes for the draft, validate, apply, history, live lists, logs, probes), the probes, the apply pipeline, the script generator, the key-guide generator, and one HTML/JS page with no framework.
- Watchdog changes are additive: a start stamp, a restart that finishes the running fix, remembered acknowledgements, a "run this fix" verb, new settings read with today's values as defaults, Windows program checks that actually work (cmd.exe needs `&& echo` pairs, not `%ERRORLEVEL%`). - Watchdog changes are additive: a start stamp, a restart that finishes the running fix, remembered acknowledgements, a "run this fix" verb, new settings read with today's values as defaults, Windows program checks that actually work (cmd.exe needs `&& echo` pairs, not `%ERRORLEVEL%`).
- `install.sh` validates the settings with the new code before touching anything, keeps the previous code for rollback, installs both programs, and never overwrites the campus's own files once the page exists. - `install.sh` validates the settings with the new code before touching anything, keeps the previous code for rollback, installs both programs, and never overwrites the location's own files once the page exists.
- Full detail: [settings-plan-details.md](settings-plan-details.md). - Full detail: [settings-plan-details.md](settings-plan-details.md).
## 11. Build order ## 11. Build order
@@ -207,14 +207,14 @@ Each step is deployed on its own, outside a service, with tests green, a live ch
| 3 Layout | Drag keys, add/delete keys, the Restart page, page numbers; Apply re-imports pages and recolours page-number buttons. | Move LED, add HD7, delete HD5, Apply, watch the deck change. | 2 days | | 3 Layout | Drag keys, add/delete keys, the Restart page, page numbers; Apply re-imports pages and recolours page-number buttons. | Move LED, add HD7, delete HD5, Apply, watch the deck change. | 2 days |
| 4 Jobs | Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer, Make a PC program startable; "Add a key for it" on unwatched connections. | Untick HD1/3/5/6 and run on 2 and 4; add a MIDI link to a Mac that is off. | 2.5 days | | 4 Jobs | Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer, Make a PC program startable; "Add a key for it" on unwatched connections. | Untick HD1/3/5/6 and run on 2 and 4; add a MIDI link to a Mac that is off. | 2.5 days |
| 5 Setup + teaching | The Setup tab with lights and generated scripts, Where things are, Things only a person can do, Install the staged folder from the page. | Add a new PC, generate its script, run it, paste the result, watch the lights go green. | 2 days | | 5 Setup + teaching | The Setup tab with lights and generated scripts, Where things are, Things only a person can do, Install the staged folder from the page. | Add a new PC, generate its script, run it, paste the result, watch the lights go green. | 2 days |
| 6 Other campuses | Site folders, the template, the bundle export, New campus from this one. | Build a demo campus on the MacBook with a Saturday night service and no OBS. | 1.5 days | | 6 Other locations | Site folders, the template, the bundle export, New location from this one. | Build a demo location on the MacBook with a Saturday night service and no OBS. | 1.5 days |
Later, if wanted: a Training copy of the page where nothing is real; an "OBS is streaming" live signal (if the experiment in step 1 finds the variable). Later, if wanted: a Training copy of the page where nothing is real; an "OBS is streaming" live signal (if the experiment in step 1 finds the variable).
## 12. What you still do by hand ## 12. What you still do by hand
- **A new Ingest Mac:** install Companion, `xcode-select --install`, Remote Login on, copy the folder over, run `install.sh --site <name>` once. - **A new Ingest Mac:** install Companion, `xcode-select --install`, Remote Login on, copy the folder over, run `install.sh --site <name>` once.
- **Companion, per campus:** create the custom variables `sp_state`, `sp_hb`, `sp_cmd`, `flash_tick` and the blink trigger (the page gives you the file to import, or a button if step 1 proves it safe); add pages up to the Status and Restart numbers; install the ATEM module; import the quiet Tascam module where there is a DA-6400; name the Stream Decks. Why: Companion only allows these in its own UI; a full import would wipe the variables. - **Companion, per location:** create the custom variables `sp_state`, `sp_hb`, `sp_cmd`, `flash_tick` and the blink trigger (the page gives you the file to import, or a button if step 1 proves it safe); add pages up to the Status and Restart numbers; install the ATEM module; import the quiet Tascam module where there is a DA-6400; name the Stream Decks. Why: Companion only allows these in its own UI; a full import would wipe the variables.
- **Every other Mac:** Remote Login on; paste the generated authorise script (and the DVS helper where DVS runs) via ARD "Send UNIX Command as root"; make the Audio MIDI Setup sessions the page names; on Studio B run the generated solo-mode install and allow Accessibility on that screen. Why: root rights and permission prompts must come from a person at that Mac. - **Every other Mac:** Remote Login on; paste the generated authorise script (and the DVS helper where DVS runs) via ARD "Send UNIX Command as root"; make the Audio MIDI Setup sessions the page names; on Studio B run the generated solo-mode install and allow Accessibility on that screen. Why: root rights and permission prompts must come from a person at that Mac.
- **Every Windows PC:** run the generated setup script as administrator, paste its RESULT block into the page; BIOS wake-on-LAN; keep the Lighting PC on Public; Vista autoload; the Bible TV Wi-Fi power settings (or Ethernet). Why: system and firewall changes need your OK; BIOS has no remote path. - **Every Windows PC:** run the generated setup script as administrator, paste its RESULT block into the page; BIOS wake-on-LAN; keep the Lighting PC on Public; Vista autoload; the Bible TV Wi-Fi power settings (or Ethernet). Why: system and firewall changes need your OK; BIOS has no remote path.
- **Ingest Mac:** type the Netgear login into the keychain yourself (the page shows the line); click Allow once on the Spotify prompt; keep Google Drive signed in for the switcher files. - **Ingest Mac:** type the Netgear login into the keychain yourself (the page shows the line); click Allow once on the Spotify prompt; keep Google Drive signed in for the switcher files.
@@ -225,7 +225,7 @@ Later, if wanted: a Training copy of the page where nothing is real; an "OBS is
1. **Who can edit:** the editing code lives in `config.json` in the private repo, so everyone with the repo has it (answered 2026-10-13). 1. **Who can edit:** the editing code lives in `config.json` in the private repo, so everyone with the repo has it (answered 2026-10-13).
2. **When changes must wait:** only while a HyperDeck is recording (answered 2026-10-13). No clock windows at NA; the Sunday 6:30–13:30 window is for auto-fix only. 2. **When changes must wait:** only while a HyperDeck is recording (answered 2026-10-13). No clock windows at NA; the Sunday 6:30–13:30 window is for auto-fix only.
3. **The other campuses:** Apollo Beach, Woonsocket and Southcoast, each with a Mac Studio already running Companion (answered 2026-10-13). 3. **The other locations:** Apollo Beach, Woonsocket and Southcoast, each with a Mac Studio already running Companion (answered 2026-10-13).
4. **Unwatched Companion connections** (7 Govee lights, DiGiCo_OSC, m32, AbleSet, tcp-HyperDeck-1..6): stay unwatched (answered 2026-10-13). 4. **Unwatched Companion connections** (7 Govee lights, DiGiCo_OSC, m32, AbleSet, tcp-HyperDeck-1..6): stay unwatched (answered 2026-10-13).
## 14. How this plan was audited ## 14. How this plan was audited
+5 -5
View File
@@ -5,7 +5,7 @@
# zsh install.sh install / update and (re)start # zsh install.sh install / update and (re)start
# zsh install.sh --keep-config update the code but leave the live config.json as it is # zsh install.sh --keep-config update the code but leave the live config.json as it is
# zsh install.sh --replace-config replace the live config even when it came from an Apply on the settings page # zsh install.sh --replace-config replace the live config even when it came from an Apply on the settings page
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/ # zsh install.sh --site <id> first install of a location: its config from sites/<id>/, else sites/_template/
# zsh install.sh --rollback put the previous code back (kept in prev/) and restart # zsh install.sh --rollback put the previous code back (kept in prev/) and restart
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay) # zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
# #
@@ -33,13 +33,13 @@ for arg in "$@"; do
*) echo "unknown option $arg" >&2; exit 2 ;; *) echo "unknown option $arg" >&2; exit 2 ;;
esac esac
done done
# The config to install: the campus folder when asked for, else this folder's config.json. # The config to install: the location folder when asked for, else this folder's config.json.
SRC_CONFIG="$SRC/config.json" SRC_CONFIG="$SRC/config.json"
if [[ -n "$SITE" ]]; then if [[ -n "$SITE" ]]; then
if [[ -f "$SRC/sites/$SITE/config.json" ]]; then SRC_CONFIG="$SRC/sites/$SITE/config.json" if [[ -f "$SRC/sites/$SITE/config.json" ]]; then SRC_CONFIG="$SRC/sites/$SITE/config.json"
elif [[ -f "$SRC/sites/_template/config.json" ]]; then SRC_CONFIG="$SRC/sites/_template/config.json"; echo "no sites/$SITE/ in the folder: starting from the template" elif [[ -f "$SRC/sites/_template/config.json" ]]; then SRC_CONFIG="$SRC/sites/_template/config.json"; echo "no sites/$SITE/ in the folder: starting from the template"
else echo "no sites/ folder here" >&2; exit 2; fi else echo "no sites/ folder here" >&2; exit 2; fi
[[ -f "$DEST/config.json" ]] && KEEP_CONFIG=1 && echo "a config is already installed: left alone (the page's 'New campus from a bundle' replaces it)" [[ -f "$DEST/config.json" ]] && KEEP_CONFIG=1 && echo "a config is already installed: left alone (the page's 'New location from a bundle' replaces it)"
fi fi
restart_agent() { # $1 = label, $2 = plist restart_agent() { # $1 = label, $2 = plist
@@ -212,12 +212,12 @@ fi
if [[ -d "$SRC/companion-objects" ]]; then if [[ -d "$SRC/companion-objects" ]]; then
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/" rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
fi fi
# The kit another campus installs from (the settings page serves it as /campus/program.tgz). # The kit another location installs from (the settings page serves it as /location/program.tgz).
rm -rf "$DEST/kit" && mkdir -p "$DEST/kit/sites" rm -rf "$DEST/kit" && mkdir -p "$DEST/kit/sites"
cp "$SRC/install.sh" "$DEST/kit/" cp "$SRC/install.sh" "$DEST/kit/"
[[ -f "$SRC/README.md" ]] && cp "$SRC/README.md" "$DEST/kit/" [[ -f "$SRC/README.md" ]] && cp "$SRC/README.md" "$DEST/kit/"
[[ -d "$SRC/sites/_template" ]] && cp -R "$SRC/sites/_template" "$DEST/kit/sites/" [[ -d "$SRC/sites/_template" ]] && cp -R "$SRC/sites/_template" "$DEST/kit/sites/"
# The nightly off-site backup of every campus's settings (does anything only on the hub, NA). # The nightly off-site backup of every location's settings (does anything only on the hub, NA).
if [[ -d "$SRC/backup" ]]; then if [[ -d "$SRC/backup" ]]; then
rm -rf "$DEST/backup" && cp -R "$SRC/backup" "$DEST/" && chmod +x "$DEST/backup/"*.sh rm -rf "$DEST/backup" && cp -R "$SRC/backup" "$DEST/" && chmod +x "$DEST/backup/"*.sh
BACKUP_LABEL=org.waterschurch.status-backup BACKUP_LABEL=org.waterschurch.status-backup
+8 -8
View File
@@ -203,7 +203,7 @@ def pick_plan(plans, now):
def pco_plan(rc, now, service_type=None, team=True): def pco_plan(rc, now, service_type=None, team=True):
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}. """This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.
`service_type` reads another campus's plan for the same weekend (its team is not needed).""" `service_type` reads another location's plan for the same weekend (its team is not needed)."""
auth, st = pco_login(rc) auth, st = pco_login(rc)
if service_type: if service_type:
st = str(service_type) st = str(service_type)
@@ -641,9 +641,9 @@ def song_ltc(rc, plan, look):
def match_in_order(na_songs, campus_songs): def match_in_order(na_songs, campus_songs):
"""Pair a campus's songs with the home campus's by title, keeping the home order: a campus song """Pair a location's songs with the home location's by title, keeping the home order: a location song
whose place breaks that order (or that the home campus does not play) is skipped, that song only. whose place breaks that order (or that the home location does not play) is skipped, that song only.
Returns ([(campus item, home song)], [skipped titles]).""" Returns ([(location item, home song)], [skipped titles])."""
pairs, skipped, last = [], [], -1 pairs, skipped, last = [], [], -1
for it in campus_songs: for it in campus_songs:
idx = next((i for i, s in enumerate(na_songs) if i > last and same(it["title"], s["title"])), None) idx = next((i for i, s in enumerate(na_songs) if i > last and same(it["title"], s["title"])), None)
@@ -656,7 +656,7 @@ def match_in_order(na_songs, campus_songs):
def campus_ltc(rc, na_plan, na_songs, campus, plan): def campus_ltc(rc, na_plan, na_songs, campus, plan):
"""One other campus's plan against the home timecode: its songs in the home plan's order, each """One other location's plan against the home timecode: its songs in the home plan's order, each
with the timecode the home set gives that song (None while the set has none) and what its LTC with the timecode the home set gives that song (None while the set has none) and what its LTC
note says now.""" note says now."""
note = rc.get("ltc_note", "LTC") note = rc.get("ltc_note", "LTC")
@@ -670,7 +670,7 @@ def campus_ltc(rc, na_plan, na_songs, campus, plan):
def eval_campus_ltc(rc, result, line): def eval_campus_ltc(rc, result, line):
"""The other campus's notes must say the home timecode for every matched song.""" """The other location's notes must say the home timecode for every matched song."""
tag = result["short"] tag = result["short"]
for s in result["songs"]: for s in result["songs"]:
if s["tracks"] is None: # the home set has no timecode for it yet: the home line says so if s["tracks"] is None: # the home set has no timecode for it yet: the home line says so
@@ -682,7 +682,7 @@ def eval_campus_ltc(rc, result, line):
line.fault(ERR, "%s LTC WRONG %s" % (tag, short(s["title"], 10)), "%s: %s says %s, the tracks start it at %s" % ( line.fault(ERR, "%s LTC WRONG %s" % (tag, short(s["title"], 10)), "%s: %s says %s, the tracks start it at %s" % (
result["name"], s["title"], s["pco"], s["tracks"])) result["name"], s["title"], s["pco"], s["tracks"]))
if result["skipped"]: if result["skipped"]:
line.notes.append("%s: not in this campus's order, left alone: %s" % (result["name"], ", ".join(result["skipped"]))) line.notes.append("%s: not in this location's order, left alone: %s" % (result["name"], ", ".join(result["skipped"])))
return line return line
@@ -1084,7 +1084,7 @@ def look(cfg, now=None, data_dir=None, sources=None):
res = campus_ltc(rc, plan, na_songs, campus, cplan) res = campus_ltc(rc, plan, na_songs, campus, cplan)
eval_campus_ltc(rc, res, lines["timecode"]) eval_campus_ltc(rc, res, lines["timecode"])
others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]])) others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]]))
except Exception as e: # noqa: BLE001 - one campus's plan down must not hide the home one except Exception as e: # noqa: BLE001 - one location's plan down must not hide the home one
tag = campus.get("short") or (campus.get("name") or "?")[:2].upper() tag = campus.get("short") or (campus.get("name") or "?")[:2].upper()
msg = str(e) if isinstance(e, ReadyError) else "PCO UNREACHABLE" msg = str(e) if isinstance(e, ReadyError) else "PCO UNREACHABLE"
lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e)) lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e))
+9 -9
View File
@@ -30,9 +30,9 @@ def field(path, type, default=None, help="", screen="", advanced=False, secret=F
# -- site -- # -- site --
field("site", "dict", {}, "This campus: its name, this Mac, and when changes must wait.", "Site") field("site", "dict", {}, "This location: its name, this Mac, and when changes must wait.", "Site")
field("site.id", "id", "na", "Short name used for folders and files.", "Site", advanced=True, regex=r"[a-z0-9_-]+") field("site.id", "id", "na", "Short name used for folders and files.", "Site", advanced=True, regex=r"[a-z0-9_-]+")
field("site.name", "str", "", "The campus, as people say it.", "Site", example="North Attleboro", apply="none") field("site.name", "str", "", "The location, as people say it.", "Site", example="North Attleboro", apply="none")
field("site.ingest_name", "str", "NA Ingest", "What this Mac is called in logs, notes and alerts.", "Site", apply="pages") field("site.ingest_name", "str", "NA Ingest", "What this Mac is called in logs, notes and alerts.", "Site", apply="pages")
field("site.ingest_hostname", "str", "", "This Mac's network name, used in the page address and by the page tools.", field("site.ingest_hostname", "str", "", "This Mac's network name, used in the page address and by the page tools.",
"Site", example="na-ingest.local", apply="none") "Site", example="na-ingest.local", apply="none")
@@ -325,7 +325,7 @@ field("tiles[].checks[].item", "str", None, "Which line of the weekly checklist
field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", advanced=True, apply="none") field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", advanced=True, apply="none")
# -- restart page -- # -- restart page --
field("restart_page", "dict", None, "The Restart page; absent = this campus has none.", "Keys", apply="pages") field("restart_page", "dict", None, "The Restart page; absent = this location has none.", "Keys", apply="pages")
field("restart_page.page", "int", 20, "Its number in Companion.", "Keys", lo=1, apply="pages") field("restart_page.page", "int", 20, "Its number in Companion.", "Keys", lo=1, apply="pages")
field("restart_page.page_name", "str", "20\\nRestart", "Its name in Companion.", "Keys", advanced=True, apply="pages") field("restart_page.page_name", "str", "20\\nRestart", "Its name in Companion.", "Keys", advanced=True, apply="pages")
field("restart_page.jump_pos", "list", None, "Where the 'go to Restart page' key sits on the Status page.", "Keys", field("restart_page.jump_pos", "list", None, "Where the 'go to Restart page' key sits on the Status page.", "Keys",
@@ -344,7 +344,7 @@ field("restart_page.keys[].watch", "list", [], "Keep flashing HOLD TO FIX while
# -- the READY page (the weekly checklist) -- # -- the READY page (the weekly checklist) --
field("ready_page", "dict", None, "The READY page: this weekend's checklist, checked against Planning Center; absent = " field("ready_page", "dict", None, "The READY page: this weekend's checklist, checked against Planning Center; absent = "
"this campus has none.", "Ready", apply="pages") "this location has none.", "Ready", apply="pages")
field("ready_page.page", "int", 24, "Its number in Companion.", "Ready", lo=1, apply="pages") field("ready_page.page", "int", 24, "Its number in Companion.", "Ready", lo=1, apply="pages")
field("ready_page.page_name", "str", "24\\nReady", "Its name in Companion.", "Ready", advanced=True, apply="pages") field("ready_page.page_name", "str", "24\\nReady", "Its name in Companion.", "Ready", advanced=True, apply="pages")
field("ready_page.back_pos", "list", None, "Where the 'back to Status' key sits on the READY page.", "Ready", field("ready_page.back_pos", "list", None, "Where the 'back to Status' key sits on the READY page.", "Ready",
@@ -368,9 +368,9 @@ field("ready.pco_module", "str", "pco-plan", "The ProdDash module whose Planning
advanced=True) advanced=True)
field("ready.pco_service_type", "str", None, "The Planning Center service type; blank = ProdDash's first.", "Ready") field("ready.pco_service_type", "str", None, "The Planning Center service type; blank = ProdDash's first.", "Ready")
field("ready.skip_songs", "list", [], "PCO songs that have no tracks or slides of their own.", "Ready") field("ready.skip_songs", "list", [], "PCO songs that have no tracks or slides of their own.", "Ready")
field("ready.ltc_campuses", "list", [], "Other campuses that play the same songs: their PCO plans get this campus's " field("ready.ltc_campuses", "list", [], "Other locations that play the same songs: their PCO plans get this location's "
"timecode too, song by song in this campus's order (a song out of order is left alone).", "Ready") "timecode too, song by song in this location's order (a song out of order is left alone).", "Ready")
field("ready.ltc_campuses[].name", "str", None, "The campus.", "Ready", required=True) field("ready.ltc_campuses[].name", "str", None, "The location.", "Ready", required=True)
field("ready.ltc_campuses[].short", "str", None, "Two letters for the key (AB).", "Ready", required=True) field("ready.ltc_campuses[].short", "str", None, "Two letters for the key (AB).", "Ready", required=True)
field("ready.ltc_campuses[].service_type", "str", None, "Its Planning Center service type.", "Ready", required=True) field("ready.ltc_campuses[].service_type", "str", None, "Its Planning Center service type.", "Ready", required=True)
field("ready.ltc_note", "str", "LTC", "The PCO note category that holds a song's timecode (checked against the " field("ready.ltc_note", "str", "LTC", "The PCO note category that holds a song's timecode (checked against the "
@@ -381,13 +381,13 @@ field("ready.prop_lists", "list", [], "PCO items whose description lists things
"{match, kind (count: one slide each / names: every name on a slide), short}.", "Ready", advanced=True) "{match, kind (count: one slide each / names: every name on a slide), short}.", "Ready", advanced=True)
field("ready.tracks_host", "str", None, "The computer Ableton runs on.", "Ready") field("ready.tracks_host", "str", None, "The computer Ableton runs on.", "Ready")
field("ready.projects", "path", "", "The shared Ableton Projects folder, as that computer sees it.", "Ready") field("ready.projects", "path", "", "The shared Ableton Projects folder, as that computer sees it.", "Ready")
field("ready.site_folder", "str", "", "This campus's folder inside it.", "Ready", example="NA") field("ready.site_folder", "str", "", "This location's folder inside it.", "Ready", example="NA")
field("ready.folder_format", "str", "{date} {loc} Project", "A weekend's project folder ({date} = YYMMDD).", field("ready.folder_format", "str", "{date} {loc} Project", "A weekend's project folder ({date} = YYMMDD).",
"Ready", advanced=True) "Ready", advanced=True)
field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecode (an LTC file named like " field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecode (an LTC file named like "
"LTC_00000000_60mins_30fps: it starts at 00:00:00:00, 30 frames a second).", "Ready", advanced=True) "LTC_00000000_60mins_30fps: it starts at 00:00:00:00, 30 frames a second).", "Ready", advanced=True)
field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready") field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready")
field("ready.locations", "list", [], "The campuses that get this campus's tracks.", "Ready") field("ready.locations", "list", [], "The locations that get this location's tracks.", "Ready")
field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True, field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True,
lo=10, hi=600) lo=10, hi=600)
field("ready.lineup_url", "url", "", "ProdDash's Band Lineup module.", "Ready") field("ready.lineup_url", "url", "", "ProdDash's Band Lineup module.", "Ready")
+4 -1
View File
@@ -499,8 +499,11 @@ class Applier:
def _pipeline(self, new_cfg, note, by, address, atem_file): def _pipeline(self, new_cfg, note, by, address, atem_file):
site = self.site site = self.site
old_cfg = site.load() site.load()
sha_before = site.cfg_sha 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"): if self.status.get("emergency"):
self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason"))) self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason")))
# 1. check # 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. and page layout - the same building blocks North Attleboro's were made of by hand.
Pure functions, no I/O: Pure functions, no I/O:
build(base, inv, campus, pin) a whole config for a new campus, from the empty template build(base, inv, location, pin) a whole config for a new location, from the empty template
extend(cfg, inv, seen) a running campus: only what is new (never removes anything) 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 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 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 ------------------------------------------------------------- # --- 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"] 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): # 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. # a row of wireless receivers folds first, as at North Attleboro.
@@ -86,7 +86,7 @@ SHARED_FIXES = {
} }
STATUS_ROWS, COLS = 4, 8 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, 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"}, "restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"},
{"days": ["wed"], "start": "09:00", "end": "21: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): 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")} taken = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
if cfg.get("restart_page"): if cfg.get("restart_page"):
taken.add(tuple(cfg["restart_page"]["jump_pos"])) taken.add(tuple(cfg["restart_page"]["jump_pos"]))
@@ -423,7 +423,7 @@ def place_new(cfg, tile):
def restart_layout(cfg, restart_items): 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") rp = cfg.get("restart_page")
if not rp: if not rp:
return return
@@ -446,7 +446,7 @@ def nets_of(inv):
def build(base, inv, campus, pin, pages=(19, 20)): 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) cfg = copy.deepcopy(base)
ing = inv.get("ingest") or {} ing = inv.get("ingest") or {}
wired = ing.get("wired") or {} wired = ing.get("wired") or {}
@@ -485,7 +485,7 @@ def build(base, inv, campus, pin, pages=(19, 20)):
def extend(cfg, inv, seen): 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. 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]).""" Returns (new config, [what was added, in words], [identities now seen])."""
cfg = copy.deepcopy(cfg) cfg = copy.deepcopy(cfg)
@@ -570,7 +570,7 @@ def keep_from(new, old):
cfg = copy.deepcopy(new) cfg = copy.deepcopy(new)
have = referenced(cfg) have = referenced(cfg)
kept = [] 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", []): 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"]}: if t.get("kind") not in ("bypass", "autofix") or t["id"] in {x["id"] for x in cfg["tiles"]}:
continue 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 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), 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 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 (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"). 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.location setup --id ab [--name "Apollo Beach"]
python3 -m settings.campus look what it finds (nothing changes) python3 -m settings.location look what it finds (nothing changes)
python3 -m settings.campus add add what is new now python3 -m settings.location add add what is new now
""" """
import argparse import argparse
import base64 import base64
@@ -35,7 +35,7 @@ VARIABLES = ("sp_state", "sp_hb", "sp_cmd", "flash_tick")
TRIGGER_NAME = "flash_tick_500" TRIGGER_NAME = "flash_tick_500"
LOOK_EVERY = 600 LOOK_EVERY = 600
SEEN, LAST = "discover-seen.json", "discover-last.json" 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", 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") "nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json")
KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup") KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup")
@@ -165,21 +165,21 @@ def companion_version_problem(cfg, standalone=False):
if standalone: if standalone:
return ("This Companion is older than version 5. The panel needs Companion 5.0.7: download it for Mac from\n" 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" " 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.") "(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.location upgrade-companion\n"
"then paste the install line again.") "then paste the install line again.")
except OSError: except OSError:
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, standalone=False): 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).""" 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 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) data = campuses.load(data_dir)
if standalone: if standalone:
data.pop("home", None) 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) site = server.Site(data_dir)
cfg = site.load() cfg = site.load()
if not is_fresh(cfg): 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) 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, standalone) 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): 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).""" pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one)."""
from settings import server from settings import server
site = server.Site(data_dir) site = server.Site(data_dir)
@@ -236,7 +236,7 @@ def rebuild(data_dir, say=print, pages=None):
if not rp: if not rp:
rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"]) rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"])
pages = tuple(pages or (cfg["page"], rp)) 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 {} comp = cfg.get("companion") or {}
for p in pages: 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)) 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): 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() cfg = site.load()
inv = discover.look(cfg, site.data_dir) inv = discover.look(cfg, site.data_dir)
seen = load_seen(site.data_dir) seen = load_seen(site.data_dir)
@@ -488,7 +488,7 @@ def shq(s):
def install_script(base, cid, support_key=None): 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()) m = re.match(r"[a-z0-9]+", str(cid).lower())
cid = m.group(0)[:8] if m else "new" cid = m.group(0)[:8] if m else "new"
name = CAMPUSES.get(cid, cid.upper()) name = CAMPUSES.get(cid, cid.upper())
@@ -506,7 +506,7 @@ fi
DIR="$HOME/status-panel-install" DIR="$HOME/status-panel-install"
mkdir -p "$DIR" && cd "$DIR" mkdir -p "$DIR" && cd "$DIR"
echo "Downloading the status panel from $SRC ..." 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"; } [[ -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 SUPPORT=%(key)s
if [[ -n "$SUPPORT" ]]; then if [[ -n "$SUPPORT" ]]; then
@@ -515,7 +515,7 @@ if [[ -n "$SUPPORT" ]]; then
fi fi
zsh install.sh --site %(cid)s zsh install.sh --site %(cid)s
cd "$HOME/Library/Application Support/status-panel" 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 if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
echo echo
echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely." 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("--id", default="new")
ap.add_argument("--name") ap.add_argument("--name")
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 location was installed from (the location 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 location)")
ap.add_argument("--standalone", action="store_true", help="a campus that runs on its own: reports to no other campus") 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") 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":
+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 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 campus ever sees another campus (2026-10-20, the user: "NA 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, 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: 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 LOCATIONS page one key per location: that location'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 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 campus's own deck. colours, live), view only: holds and presses are for that location's own deck.
The ALARMS corner goes back to the CAMPUSES page. 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 Two pages, whatever the number of locations (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 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 campus that stops answering goes purple. Pages are only written 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 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 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 need to monitor AB from NA") has "standalone" set instead of a home: it tells nobody, is no hub, and
lists only itself. lists only itself.
""" """
@@ -70,17 +70,22 @@ def me(cfg, url=None):
import pwd import pwd
site = cfg.get("site") or {} site = cfg.get("site") or {}
# The account this panel runs as: the hub logs in with it for the nightly backup. # 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} "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): 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", [])] 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): 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() cfg = site.load()
names = cfg.get("variables") or {} names = cfg.get("variables") or {}
comp = site.comp comp = site.comp
@@ -100,8 +105,8 @@ def http_json(url, body=None, timeout=3):
VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"} VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"}
SELECT = "sp_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 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 campus's status page, "RESTART" = its Restart page 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)] 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): 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} 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: for k in keys:
r, c = k["pos"] r, c = k["pos"]
@@ -120,8 +125,8 @@ def view_state(state, keys):
def build_view(local_cfg, page, overview_page): 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 """The LOCATION 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.""" 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) opts = make_page.settings(local_cfg)
controls = make_page.nav_controls(local_cfg) controls = make_page.nav_controls(local_cfg)
hold_ms = opts["hold_ms"] hold_ms = opts["hold_ms"]
@@ -132,17 +137,17 @@ def build_view(local_cfg, page, overview_page):
make_page.place(controls, (r, c), { make_page.place(controls, (r, c), {
"type": "button-layered", "style": {"layers": layers}, "type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, "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. " "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 campus's own deck." % (r, c, hold_ms / 1000.0)}, "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]}, "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [tap], str(hold_ms): [hold]},
"options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []}) "options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []})
back = make_page.static_button("campus_back", "ALL\\nSITES", 0x37474F, 0xFFFFFF, overview_page, 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"]: 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) layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True)
make_page.place(controls, (3, 0), back) 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=""): def select_action(cid, mode=""):
@@ -153,7 +158,7 @@ def select_action(cid, mode=""):
def campus_key(cid, name, names, actions, note, local_cfg): 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) opts = make_page.settings(local_cfg)
tag = short(cid, name) tag = short(cid, name)
stale_text = "`%s\\n?`" % tag 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") text["fontsize"] = make_page.prop("auto")
return {"type": "button-layered", "style": {"layers": layers}, return {"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, "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": []}}}, "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
"localVariables": []} "localVariables": []}
def build_overview(local_cfg, campuses, page, view_page): 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) controls = make_page.nav_controls(local_cfg)
mine = (local_cfg.get("site") or {}).get("id") mine = (local_cfg.get("site") or {}).get("id")
rp = (local_cfg.get("restart_page") or {}).get("page") 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"])], 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) "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, 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: else:
key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid}, 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)], 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) make_page.place(controls, (0, col), key)
if restart: if restart:
make_page.place(controls, (1, col), 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): 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. # 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)) last = max(p for p in range(1, 100) if exists(host, port, p))
if last >= 99: 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") ws = push_page.WebSocket(host, port, "/trpc")
try: 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: finally:
ws.close() ws.close()
return last + 1 return last + 1
@@ -278,7 +283,7 @@ class Mirror:
return not data.get("home") and not data.get("standalone") 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 location: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
cfg = self.site.load() cfg = self.site.load()
data = load(self.site.data_dir) data = load(self.site.data_dir)
mine = me(cfg) mine = me(cfg)
@@ -300,7 +305,7 @@ class Mirror:
return {p for p in t if p} return {p for p in t if p}
def refresh_pages(self): 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() cfg = self.site.load()
data = self.sync_registry() data = self.sync_registry()
if not self.is_hub(data): if not self.is_hub(data):
@@ -308,7 +313,7 @@ class Mirror:
mine = (cfg.get("site") or {}).get("id") mine = (cfg.get("site") or {}).get("id")
others = {cid: e for cid, e in data.get("list", {}).items() if cid != mine} 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"): 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", {}) pages = data.setdefault("pages", {})
layouts = data.setdefault("layouts", {}) layouts = data.setdefault("layouts", {})
for cid, e in sorted(others.items()): for cid, e in sorted(others.items()):
@@ -337,7 +342,7 @@ class Mirror:
return data return data
def pass_on(self, pick, others, restart=False): 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() text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
if not text: if not text:
return return
@@ -351,12 +356,12 @@ class Mirror:
remote = sp.Companion(host, 8000, timeout=3) remote = sp.Companion(host, 8000, timeout=3)
before = (remote.get_custom("sp_cmd") or "").strip() before = (remote.get_custom("sp_cmd") or "").strip()
remote.set_custom("sp_cmd", ";".join([before] + cmds if before else cmds)) 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: 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): 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() cfg = self.site.load()
data = load(self.site.data_dir) data = load(self.site.data_dir)
if not self.is_hub(data): if not self.is_hub(data):
@@ -380,10 +385,10 @@ class Mirror:
threads.append(t) threads.append(t)
for t in threads: for t in threads:
t.join(2) 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 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: 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] pick = sorted(others)[0]
self.site.comp.set_custom(SELECT, pick.upper()) self.site.comp.set_custom(SELECT, pick.upper())
restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART" 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): 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 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.""" 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 []} 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). 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 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} info["exes"] = {os.path.basename(p.strip()): p.strip() for p in ps if ".app/Contents/MacOS/" in p}
if wired: if wired:
ip, net = ifs[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)} info["wired"] = {"interface": wired, "ip": ip, "net": net, "gateway": gateway_of(wired)}
return info return info
@@ -303,7 +303,7 @@ def computers(cfg, data_dir):
def look(cfg, data_dir): def look(cfg, data_dir):
inv = {"ingest": this_mac(), "at": int(time.time())} 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", [])] 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"): if rule and inv["ingest"].get("wired"):
inv["ingest"]["wired"]["net"] = rule[0] 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 #!/usr/bin/env python3
"""Smart outlets the panel may switch (a hung recorder's mains, say), through Tuya's cloud with the """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: (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) 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") return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
if path == "/where.html": 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") 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 ---- # ---- auto-setup: the install line for a new location, the join lines for its computers ----
if path == "/campus/install": if path in ("/campus/install", "/location/install"):
key, _ = scripts.public_key(site.load()) 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") 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") return self.send(200, campus.program_kit(site.data_dir), "application/gzip")
if path == "/campus/companion.zip": 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 campus # 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). # still on an older one (packed once, kept beside the config, streamed).
app = "/Applications/Companion 5.0.7.app" app = "/Applications/Companion 5.0.7.app"
pack = os.path.join(site.data_dir, "companion-5.0.7.zip") 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": if path == "/api/state":
return self.send(200, campuses.state_now(site)) return self.send(200, campuses.state_now(site))
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 location only ever lists itself.
data = campuses.load(site.data_dir) data = campuses.load(site.data_dir)
hub = campuses.Mirror.is_hub(data) hub = campuses.Mirror.is_hub(data)
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if hub else {}, 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): if os.path.exists(fp):
z.write(fp, name) 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 " 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() data = buf.getvalue()
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "application/zip") self.send_header("Content-Type", "application/zip")
@@ -906,7 +906,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
site.discover_poke.set() site.discover_poke.set()
return self.send(200, {"ok": True}) return self.send(200, {"ok": True})
if path == "/api/campus/hello": 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 "") cid = str(body.get("id") or "")
url = str(body.get("url") 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) \ 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"}) 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): 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", {}) 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)
@@ -924,7 +924,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
campuses.save(site.data_dir, data) campuses.save(site.data_dir, data)
return self.send(200, {"ok": True}) return self.send(200, {"ok": True})
if path == "/api/fleet/hello": 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. # laptop's SSH shortcuts (tools/fleet.py). Nothing here acts on it.
try: try:
entry = discover.clean_hello(body, self.client_address[0]) 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}) return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
cfg = site.load() cfg = site.load()
mine = cfg.get("site") or {} 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}) 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 flag = "copied from %s - check it" % src
for alias, h in (new_cfg.get("hosts") or {}).items(): for alias, h in (new_cfg.get("hosts") or {}).items():
@@ -1110,7 +1110,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
f["label"] = f.get("label", "") f["label"] = f.get("label", "")
errors, warnings = schema.validate(new_cfg) errors, warnings = schema.validate(new_cfg)
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha) 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}) return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
if path == "/api/atem-reference/pick-newest": if path == "/api/atem-reference/pick-newest":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "") 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): def names_check(self, cfg):
"""Every device name the keys use, against what this Companion, the Dante network and the Macs report now: """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 site = self.site
try: try:
live = {c["label"]: c.get("moduleId") for c in site.comp.connections()} 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}) return self.send(404, {"error": "missing %s" % name})
def base_url(self): 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]) 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): if not re.fullmatch(r"[A-Za-z0-9.\-]+(:\d+)?", host):
host = "%s:%d" % (self.server.server_address[0], self.server.server_address[1]) host = "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
@@ -1506,7 +1506,7 @@ def main(argv=None):
stop = threading.Event() stop = threading.Event()
threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start() 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=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: try:
while True: while True:
time.sleep(3600) 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>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>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>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> <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>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> <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"], ["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"], ["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"], ["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"], ["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 campus, contacts, this Mac, the PIN"], ["site", "Site & PIN", "The location, contacts, this Mac, the PIN"],
["program", "Program & campus", "Update the code, export or start a campus"], ["program", "Program & location", "Update the code, export or start a location"],
["advanced", "Advanced", "Every number and name, by screen"], ["advanced", "Advanced", "Every number and name, by screen"],
]; ];
async function settings(rest) { async function settings(rest) {
@@ -917,7 +917,7 @@
wireSettings(which); wireSettings(which);
} }
function readySettingsHtml(c) { 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); 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>` 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") + fieldsHtml("Ready")
@@ -935,7 +935,7 @@
const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))]; 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>"; 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) { async function setupHtml(c) {
const d = await api("/api/discover").catch(() => null); const d = await api("/api/discover").catch(() => null);
if (!d) return "<p class='muted'>Auto-setup is not answering.</p>"; 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 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 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 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> 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> <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> <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="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>
${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> ${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>
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>` : ""}`; ${locations.map(([k, n]) => line(`curl -fsSL "${d.base}/location/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");
@@ -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> 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> <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> ${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() { async function outletsHtml() {
const o = await api("/api/outlets"); 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 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 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(""); 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>`; <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) { function renameConn(from, to) {
@@ -1511,7 +1511,7 @@
const spname = q("#spname"); if (spname) spname.onchange = () => setPath("spotify.name", spname.value.trim()); 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 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 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 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 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); } }; 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); } };
+3 -3
View File
@@ -1,7 +1,7 @@
# Sites # Sites
One folder per campus. `na/config.json` is North Attleboro (a link to the repo's `config.json`, which install.sh One folder per location. `na/config.json` is North Attleboro (a link to the repo's `config.json`, which install.sh
installs by default). `_template/config.json` is the empty campus: the three fixed keys, this Mac's record and installs by default). `_template/config.json` is the empty location: the three fixed keys, this Mac's record and
nothing else; `install.sh --site <id>` installs `sites/<id>/config.json` when the folder exists, otherwise the nothing else; `install.sh --site <id>` installs `sites/<id>/config.json` when the folder exists, otherwise the
template. The settings page's **Export site bundle** (Site setup > Site) gives you a zip to drop in as `sites/<id>/`; template. The settings page's **Export site bundle** (Site setup > Site) gives you a zip to drop in as `sites/<id>/`;
its **New campus from a bundle** starts a campus from another campus's bundle. its **New location from a bundle** starts a location from another location's bundle.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"site": { "site": {
"id": "new", "id": "new",
"name": "New campus", "name": "New location",
"ingest_name": "Ingest Mac", "ingest_name": "Ingest Mac",
"ingest_hostname": "", "ingest_hostname": "",
"ingest_ips": [], "ingest_ips": [],
+139 -1
View File
@@ -64,7 +64,9 @@
"prop_port_propresenter_api", "prop_port_propresenter_api",
"hyperdeck", "hyperdeck",
"midi_reconnect", "midi_reconnect",
"ableton_sc_tracks_new" "ableton_sc_tracks_new",
"superrack_sc_foh",
"x32edit_sc_foh"
] ]
}, },
"tiles": [ "tiles": [
@@ -205,6 +207,24 @@
"fix": "midi_reconnect", "fix": "midi_reconnect",
"tag": "PROP LIGHTS" "tag": "PROP LIGHTS"
}, },
{
"type": "midi_peer",
"session": "SC Tracks NEW",
"peer": "SC FOH Mac mini",
"address": "10.5.2.96:5004",
"fix": "midi_reconnect",
"tag": "FOH TRACKS",
"host": "sc_tracks_new"
},
{
"type": "midi_peer",
"session": "SC Tracks NEW",
"peer": "Lighting",
"address": "10.5.1.168:5004",
"fix": "midi_reconnect",
"tag": "TRACKS LIGHTS",
"host": "sc_tracks_new"
},
{ {
"type": "midi_wired", "type": "midi_wired",
"nets": [ "nets": [
@@ -266,6 +286,60 @@
"sc_tracks_new" "sc_tracks_new"
] ]
} }
},
{
"id": "superrack",
"label": "SUPERRACK",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "sc_foh",
"process": "SuperRack SoundGrid"
},
"msg": "SUPERRACK NOT RUNNING",
"fix": "superrack_sc_foh",
"host": "sc_foh",
"cmd": "pgrep -xq 'SuperRack SoundGrid'"
},
{
"type": "remote",
"template": "soundgrid",
"params": {
"host": "sc_foh"
},
"msg": "SOUNDGRID LOST",
"host": "sc_foh",
"cmd": "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'"
}
],
"pos": [
1,
4
]
},
{
"id": "x32_edit",
"label": "X32 EDIT",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "sc_foh",
"process": "X32-Edit"
},
"msg": "X32 EDIT NOT RUNNING",
"fix": "x32edit_sc_foh",
"host": "sc_foh",
"cmd": "pgrep -xq X32-Edit"
}
],
"pos": [
1,
5
]
} }
], ],
"fixes": { "fixes": {
@@ -349,6 +423,50 @@
"kind": "ssh", "kind": "ssh",
"host": "sc_tracks_new", "host": "sc_tracks_new",
"run": "L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); pkill -TERM -x Live; i=0; while pgrep -xq Live && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Live && pkill -KILL -x Live; sleep 1; if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi" "run": "L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); pkill -TERM -x Live; i=0; while pgrep -xq Live && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Live && pkill -KILL -x Live; sleep 1; if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi"
},
"superrack_sc_foh": {
"template": "mac_start",
"params": {
"host": "sc_foh",
"process": "SuperRack SoundGrid",
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app"
},
"label": "start SuperRack on SC FOH if it is not running",
"short": "SUPERRACK",
"kind": "ssh",
"host": "sc_foh",
"run": "pgrep -xq 'SuperRack SoundGrid' || open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'"
},
"restart_superrack_sc_foh": {
"template": "mac_restart",
"params": {
"host": "sc_foh",
"process": "SuperRack SoundGrid",
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
"wait_seconds": 10,
"force": true,
"background": false,
"stop_children": false,
"newest_of": ""
},
"label": "restart SuperRack on SC FOH (closes it first)",
"short": "SUPERRACK",
"kind": "ssh",
"host": "sc_foh",
"run": "pkill -TERM -x 'SuperRack SoundGrid'; i=0; while pgrep -xq 'SuperRack SoundGrid' && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq 'SuperRack SoundGrid' && pkill -KILL -x 'SuperRack SoundGrid'; sleep 1; open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'"
},
"x32edit_sc_foh": {
"template": "mac_start",
"params": {
"host": "sc_foh",
"process": "X32-Edit",
"app": "/Applications/X32-Edit.app"
},
"label": "start X32-Edit on SC FOH if it is not running",
"short": "X32 EDIT",
"kind": "ssh",
"host": "sc_foh",
"run": "pgrep -xq X32-Edit || open /Applications/X32-Edit.app"
} }
}, },
"hosts": { "hosts": {
@@ -387,6 +505,17 @@
"kind": "sshd" "kind": "sshd"
} }
] ]
},
"sc_foh": {
"name": "SC FOH",
"os": "mac",
"ssh": "media@10.5.2.96",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
]
} }
}, },
"gear": {}, "gear": {},
@@ -436,6 +565,15 @@
0, 0,
2 2
] ]
},
{
"id": "rs_superrack",
"label": "SUPERRACK",
"fix": "restart_superrack_sc_foh",
"pos": [
0,
3
]
} }
] ]
}, },
+2 -2
View File
@@ -1250,7 +1250,7 @@ class Engine:
def live_now(self, now): def live_now(self, now):
"""(live?, why): changes to the panel wait while this is true. A recording HyperDeck is """(live?, why): changes to the panel wait while this is true. A recording HyperDeck is
always live; a campus may add clock windows ("live_services" in "site").""" always live; a location may add clock windows ("live_services" in "site")."""
if self.any_recording: if self.any_recording:
return True, "a HyperDeck is recording" return True, "a HyperDeck is recording"
if in_windows(self.site.get("live_services", []), now): if in_windows(self.site.get("live_services", []), now):
@@ -1683,7 +1683,7 @@ class Engine:
self.note(tile_id, "RESTARTING\nSOON", "idle", now, 4) self.note(tile_id, "RESTARTING\nSOON", "idle", now, 4)
return [] return []
kind = self.tiles.get(tile_id, {}).get("kind") kind = self.tiles.get(tile_id, {}).get("kind")
if kind in ("service", "autofix"): # from North Attleboro's CAMPUS page: the hold is the press if kind in ("service", "autofix"): # from North Attleboro's LOCATION page: the hold is the press
mode = self.service if kind == "service" else self.autofix mode = self.service if kind == "service" else self.autofix
mode.set(not mode.on(now), now) mode.set(not mode.on(now), now)
log.info("%s %s (hold)", "SERVICE" if kind == "service" else "AUTO-FIX", "ON" if mode.on(now) else "OFF") log.info("%s %s (hold)", "SERVICE" if kind == "service" else "AUTO-FIX", "ON" if mode.on(now) else "OFF")
+7
View File
@@ -160,6 +160,13 @@ class TestPipeline(unittest.TestCase):
self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before
self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600") self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600")
def test_settings_edited_in_place_still_restart_the_watchdog(self):
cfg = self.site.load() # the cached object, edited as a script would
cfg["debounce_seconds"] = 5
self.run_apply(cfg, "in place")
names = {s["name"]: s["state"] for s in self.applier.status["steps"]}
self.assertEqual(names["restart"], "ok")
def test_page_only_data_needs_no_restart(self): def test_page_only_data_needs_no_restart(self):
new = json.loads(json.dumps(self.cfg)) new = json.loads(json.dumps(self.cfg))
new["gear"] = {"UHDJr": {"location": "lobby"}} new["gear"] = {"UHDJr": {"location": "lobby"}}
+16 -12
View File
@@ -1,5 +1,5 @@
"""Auto-setup for the other campuses: an inventory becomes keys that pass every rule, laid out like """Auto-setup for the other locations: an inventory becomes keys that pass every rule, laid out like
North Attleboro's; a running campus only gains what is new (never loses anything, never gets back North Attleboro's; a running location only gains what is new (never loses anything, never gets back
what someone took off); a computer's join report is checked; the lines people paste are right.""" what someone took off); a computer's join report is checked; the lines people paste are right."""
import io import io
import json import json
@@ -356,7 +356,11 @@ class TestRoutes(unittest.TestCase):
self.assertNotIn("outlets.json", names) self.assertNotIn("outlets.json", names)
status, body = get(self.port, "/campus/install?id=ws") status, body = get(self.port, "/campus/install?id=ws")
self.assertIn("--site ws", body.decode()) self.assertIn("--site ws", body.decode())
self.assertIn("SUPPORT='ssh-ed25519 AAAAC3NzaTESTKEY", body.decode()) # this campus can help the new one self.assertIn("SUPPORT='ssh-ed25519 AAAAC3NzaTESTKEY", body.decode()) # this location can help the new one
status, body = get(self.port, "/location/install?id=ws") # the address people see now
self.assertEqual(status, 200)
self.assertIn("/location/program.tgz", body.decode())
self.assertIn("settings.location setup", body.decode())
def test_look_now_needs_the_code(self): def test_look_now_needs_the_code(self):
self.assertEqual(get(self.port, "/api/discover/now", {})[0], 401) self.assertEqual(get(self.port, "/api/discover/now", {})[0], 401)
@@ -367,7 +371,7 @@ if __name__ == "__main__":
class TestCampusesView(unittest.TestCase): class TestCampusesView(unittest.TestCase):
"""North Attleboro alone shows every campus: a copy of each status page (view only) and a CAMPUSES page.""" """North Attleboro alone shows every location: a copy of each status page (view only) and a LOCATIONS page."""
def test_the_campus_page_is_view_only_cells_with_a_way_back(self): def test_the_campus_page_is_view_only_cells_with_a_way_back(self):
from settings import campuses as cv from settings import campuses as cv
@@ -375,10 +379,10 @@ class TestCampusesView(unittest.TestCase):
na = json.load(open(os.path.join(ROOT, "config.json"))) na = json.load(open(os.path.join(ROOT, "config.json")))
export = cv.build_view(na, 99, 98) export = cv.build_view(na, 99, 98)
ctrls = export["page"]["controls"] ctrls = export["page"]["controls"]
self.assertEqual(export["page"]["name"], "99\\nCampus") self.assertEqual(export["page"]["name"], "99\\nLocation")
self.assertIn("CAMPUSES", json.dumps(ctrls["3"]["0"])) # where ALARMS sits on every status page self.assertIn("LOCATIONS", json.dumps(ctrls["3"]["0"])) # where ALARMS sits on every status page
self.assertIn("$(custom:sp_view)", json.dumps(ctrls["3"]["0"]["style"])) # it says which campus is shown self.assertIn("$(custom:sp_view)", json.dumps(ctrls["3"]["0"]["style"])) # it says which location is shown
cells = [ctl for row in ctrls.values() for ctl in row.values() if "campus view" in (ctl.get("options") or {}).get("notes", "")] cells = [ctl for row in ctrls.values() for ctl in row.values() if "location view" in (ctl.get("options") or {}).get("notes", "")]
self.assertEqual(len(cells), 28) self.assertEqual(len(cells), 28)
acts = cells[0]["steps"]["0"]["action_sets"] acts = cells[0]["steps"]["0"]["action_sets"]
self.assertEqual(sorted(acts), ["2000", "down", "up"]) # tap = acknowledge, hold = fix, there self.assertEqual(sorted(acts), ["2000", "down", "up"]) # tap = acknowledge, hold = fix, there
@@ -404,16 +408,16 @@ class TestCampusesView(unittest.TestCase):
na = json.load(open(os.path.join(ROOT, "config.json"))) na = json.load(open(os.path.join(ROOT, "config.json")))
roster = {"na": {"name": "North Attleboro"}, "ws": {"name": "Woonsocket"}, "ab": {"name": "Apollo Beach"}} roster = {"na": {"name": "North Attleboro"}, "ws": {"name": "Woonsocket"}, "ab": {"name": "Apollo Beach"}}
export = cv.build_overview(na, roster, 98, 99) export = cv.build_overview(na, roster, 98, 99)
keys = [(r, c, ctl) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if "'campus " in (ctl.get("options") or {}).get("notes", "")] keys = [(r, c, ctl) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if "'location " in (ctl.get("options") or {}).get("notes", "")]
self.assertEqual([(r, c) for r, c, _ in keys], [("0", "1"), ("0", "2"), ("0", "3")]) self.assertEqual([(r, c) for r, c, _ in keys], [("0", "1"), ("0", "2"), ("0", "3")])
rs = export["page"]["controls"]["1"]["3"] # WS's RESTART key, under its ALL key rs = export["page"]["controls"]["1"]["3"] # WS's RESTART key, under its ALL key
acts = rs["steps"]["0"]["action_sets"]["up"] acts = rs["steps"]["0"]["action_sets"]["up"]
self.assertEqual([a["options"].get("value", {}).get("value") for a in acts[:2]] + [acts[2]["options"]["page"]["value"]], ["WS", "RESTART", 99]) self.assertEqual([a["options"].get("value", {}).get("value") for a in acts[:2]] + [acts[2]["options"]["page"]["value"]], ["WS", "RESTART", 99])
self.assertEqual(cv.campus_command([{"id": "rs_prop", "pos": [0, 1]}], "tap 0 1 1000;hold 0 1 1000", 1001, restart=True), self.assertEqual(cv.campus_command([{"id": "rs_prop", "pos": [0, 1]}], "tap 0 1 1000;hold 0 1 1000", 1001, restart=True),
["force rs_prop 1000"]) # on its Restart page a hold forces, a tap does nothing ["force rs_prop 1000"]) # on its Restart page a hold forces, a tap does nothing
self.assertIn("'campus na'", keys[0][2]["options"]["notes"]) self.assertIn("'location na'", keys[0][2]["options"]["notes"])
self.assertIn("page 19", keys[0][2]["options"]["notes"]) self.assertIn("page 19", keys[0][2]["options"]["notes"])
ws_key = next(k for _, _, k in keys if "'campus ws'" in k["options"]["notes"]) ws_key = next(k for _, _, k in keys if "'location ws'" in k["options"]["notes"])
acts = ws_key["steps"]["0"]["action_sets"]["up"] acts = ws_key["steps"]["0"]["action_sets"]["up"]
self.assertEqual([a["definitionId"] for a in acts], ["custom_variable_set_value", "custom_variable_set_value", "set_page"]) self.assertEqual([a["definitionId"] for a in acts], ["custom_variable_set_value", "custom_variable_set_value", "set_page"])
self.assertEqual((acts[0]["options"]["value"]["value"], acts[1]["options"]["value"]["value"], acts[2]["options"]["page"]["value"]), ("WS", "", 99)) self.assertEqual((acts[0]["options"]["value"]["value"], acts[1]["options"]["value"]["value"], acts[2]["options"]["page"]["value"]), ("WS", "", 99))
@@ -462,7 +466,7 @@ class TestCampusHello(TestRoutes):
class TestStandalone(unittest.TestCase): class TestStandalone(unittest.TestCase):
"""Apollo Beach runs its own panel (2026-10-21): it tells nobody, keeps no campus list, backs itself up.""" """Apollo Beach runs its own panel (2026-10-21): it tells nobody, keeps no location list, backs itself up."""
def test_a_standalone_campus_tells_nobody_and_is_no_hub(self): def test_a_standalone_campus_tells_nobody_and_is_no_hub(self):
from settings import campuses as cv from settings import campuses as cv
+5 -5
View File
@@ -19,7 +19,7 @@ sp.log.addHandler(logging.NullHandler())
sp.log.propagate = False sp.log.propagate = False
CONFIG = sp.load_config(os.path.join(ROOT, "config.json")) CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
# The tests pin their own READY hours (the campus sets its own on the settings page). # The tests pin their own READY hours (the location sets its own on the settings page).
CONFIG["ready"]["due"] = [{"days": ["sat"], "start": "12:00", "end": "23:59"}, {"days": ["sun"], "start": "00:00", "end": "13:30"}] CONFIG["ready"]["due"] = [{"days": ["sat"], "start": "12:00", "end": "23:59"}, {"days": ["sun"], "start": "00:00", "end": "13:30"}]
RC = CONFIG["ready"] RC = CONFIG["ready"]
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
@@ -556,7 +556,7 @@ if __name__ == "__main__":
class TestOtherCampuses(unittest.TestCase): class TestOtherCampuses(unittest.TestCase):
"""The other campuses play the same songs: their plans get North Attleboro's timecode, in its order.""" """The other locations play the same songs: their plans get North Attleboro's timecode, in its order."""
CAMPUS = {"name": "Apollo Beach", "short": "AB", "service_type": "1113969"} CAMPUS = {"name": "Apollo Beach", "short": "AB", "service_type": "1113969"}
def campus_plan(self, titles, notes=None): def campus_plan(self, titles, notes=None):
@@ -569,7 +569,7 @@ class TestOtherCampuses(unittest.TestCase):
na = rd.song_ltc(RC, plan(), ableton()) na = rd.song_ltc(RC, plan(), ableton())
titles = [s["title"] for s in na] titles = [s["title"] for s in na]
self.assertGreaterEqual(len(titles), 3) self.assertGreaterEqual(len(titles), 3)
# the campus plays song 2 first, then 1, then 3: song 1 breaks the order and is skipped; a song NA does not play is skipped too # the location plays song 2 first, then 1, then 3: song 1 breaks the order and is skipped; a song NA does not play is skipped too
cp = self.campus_plan([titles[1], titles[0], titles[2], "Something Else"]) cp = self.campus_plan([titles[1], titles[0], titles[2], "Something Else"])
res = rd.campus_ltc(RC, plan(), na, self.CAMPUS, cp) res = rd.campus_ltc(RC, plan(), na, self.CAMPUS, cp)
self.assertEqual([s["title"] for s in res["songs"]], [titles[1], titles[2]]) self.assertEqual([s["title"] for s in res["songs"]], [titles[1], titles[2]])
@@ -582,7 +582,7 @@ class TestOtherCampuses(unittest.TestCase):
self.assertEqual(rd.eval_campus_ltc(RC, rd.campus_ltc(RC, plan(), na, self.CAMPUS, right), rd.Line()).faults, []) self.assertEqual(rd.eval_campus_ltc(RC, rd.campus_ltc(RC, plan(), na, self.CAMPUS, right), rd.Line()).faults, [])
def test_a_song_without_home_timecode_is_matched_but_not_faulted_or_written(self): def test_a_song_without_home_timecode_is_matched_but_not_faulted_or_written(self):
"""The set is not on Tracks yet: the campus's songs are still in order, nothing is wrong at the campus, nothing is written.""" """The set is not on Tracks yet: the location's songs are still in order, nothing is wrong at the location, nothing is written."""
p = plan() p = plan()
cp = self.campus_plan([it["title"] for it in rd.plan_songs(RC, p)]) cp = self.campus_plan([it["title"] for it in rd.plan_songs(RC, p)])
res = rd.campus_ltc(RC, p, [], self.CAMPUS, cp) res = rd.campus_ltc(RC, p, [], self.CAMPUS, cp)
@@ -607,4 +607,4 @@ class TestOtherCampuses(unittest.TestCase):
self.assertTrue(rd.run_action(cfg, "ltc_to_pco", sleep=lambda s: None)) self.assertTrue(rd.run_action(cfg, "ltc_to_pco", sleep=lambda s: None))
finally: finally:
rd.http_json, rd.pco_get, rd.pco_login = real_http, real_get, real_login rd.http_json, rd.pco_get, rd.pco_login = real_http, real_get, real_login
self.assertEqual(len([c for c in calls if "/1113969/" in c[1] and c[0] == "POST"]), len(res["songs"])) # one note per campus song self.assertEqual(len([c for c in calls if "/1113969/" in c[1] and c[0] == "POST"]), len(res["songs"])) # one note per location song
+1 -1
View File
@@ -295,7 +295,7 @@ class TestSchemaMatchesCode(unittest.TestCase):
def test_phrases(self): def test_phrases(self):
cfg = sp.load_config(os.path.join(ROOT, "config.json")) cfg = sp.load_config(os.path.join(ROOT, "config.json"))
p = schema.phrases(cfg) p = schema.phrases(cfg)
self.assertEqual(p["auto_fix"], "may run by itself " + sp.windows_phrase(cfg["auto_fix"]["windows"])) # the campus's own hours self.assertEqual(p["auto_fix"], "may run by itself " + sp.windows_phrase(cfg["auto_fix"]["windows"])) # the location's own hours
self.assertEqual(schema.phrases(dict(cfg, auto_fix={"windows": [{"days": ["sun"], "start": "06:30", "end": "13:30"}]}))["auto_fix"], self.assertEqual(schema.phrases(dict(cfg, auto_fix={"windows": [{"days": ["sun"], "start": "06:30", "end": "13:30"}]}))["auto_fix"],
"may run by itself on Sundays 06:30-13:30") "may run by itself on Sundays 06:30-13:30")
self.assertEqual(p["live"], "changes wait while a HyperDeck is recording") self.assertEqual(p["live"], "changes wait while a HyperDeck is recording")
+1 -1
View File
@@ -227,7 +227,7 @@ class TestServer(unittest.TestCase):
self.assertIn("config.json", z.namelist()) self.assertIn("config.json", z.namelist())
bundle_cfg = json.loads(z.read("config.json")) bundle_cfg = json.loads(z.read("config.json"))
self.assertEqual(bundle_cfg["site"]["editing_code"], self.CODE) # the bundle is the real file self.assertEqual(bundle_cfg["site"]["editing_code"], self.CODE) # the bundle is the real file
# a new campus from that bundle: this campus's own site block stays, computers are flagged # a new location from that bundle: this location's own site block stays, computers are flagged
other = json.loads(json.dumps(bundle_cfg)); other["site"]["name"] = "Woonsocket"; other["site"]["editing_code"] = "theirs" other = json.loads(json.dumps(bundle_cfg)); other["site"]["name"] = "Woonsocket"; other["site"]["editing_code"] = "theirs"
buf = io.BytesIO() buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z2: with zipfile.ZipFile(buf, "w") as z2:
+2 -2
View File
@@ -1,5 +1,5 @@
"""The generated set-up scripts and the campus template: every value comes from the record, the """The generated set-up scripts and the location template: every value comes from the record, the
templates hold no site-shaped value, and the empty campus validates and builds its pages.""" templates hold no site-shaped value, and the empty location validates and builds its pages."""
import json import json
import os import os
import sys import sys
+1 -1
View File
@@ -30,7 +30,7 @@ CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it). # A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp() SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop
# ...and their own auto-fix hours, so the campus can change its own on the settings page. # ...and their own auto-fix hours, so the location can change its own on the settings page.
CONFIG["auto_fix"]["windows"] = [{"days": ["wed"], "start": "16:00", "end": "21:00"}, {"days": ["sun"], "start": "06:30", "end": "13:30"}] CONFIG["auto_fix"]["windows"] = [{"days": ["wed"], "start": "16:00", "end": "21:00"}, {"days": ["sun"], "start": "06:30", "end": "13:30"}]
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp() WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""SSH from the admin laptop to every machine on every campus. """SSH from the admin laptop to every machine on every location.
python3 tools/fleet.py ard the command to paste into Apple Remote Desktop (Send UNIX Command, as root) python3 tools/fleet.py ard the command to paste into Apple Remote Desktop (Send UNIX Command, as root)
python3 tools/fleet.py config write ~/.ssh/config.d/waters from the machines that reported back python3 tools/fleet.py config write ~/.ssh/config.d/waters from the machines that reported back
+5 -5
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""A status panel a campus installs and runs on its own: no North Attleboro. """A status panel a location installs and runs on its own: no North Attleboro.
python3 tools/standalone.py ab "Apollo Beach" -> dist/Apollo Beach Status Panel.zip 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 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". 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 The zip holds the program (the same files a location 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, or login keys), an installer and a read-me. Installed, the location sets itself up from what it finds,
exactly as Southcoast and Woonsocket did, but it reports to nobody (campuses.json "standalone"), lets 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. no other Mac in, and backs itself up every night into ~/Documents/Status Panel Backups.
""" """
@@ -37,7 +37,7 @@ rm -rf "$DIR" && mkdir -p "$DIR"
cd "$DIR" cd "$DIR"
zsh install.sh --site %(cid)s zsh install.sh --site %(cid)s
cd "$HOME/Library/Application Support/status-panel" cd "$HOME/Library/Application Support/status-panel"
/usr/bin/python3 -m settings.campus setup --id %(cid)s --name %(qname)s --standalone /usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --standalone
# This Mac's wired address (the default route's port). # This Mac's wired address (the default route's port).
IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}') IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}')
ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname) ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname)
@@ -57,7 +57,7 @@ README = """%(name)s - Status Panel
What it is What it is
A status page on the Stream Deck (Bitfocus Companion) that watches your gear - ProPresenter, the 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 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. the Mac that runs Companion. It is yours: nothing is sent to any other location.
Before you start Before you start
- Companion 5.0.7 on that Mac, with its HTTP API on (Companion > Settings > Protocols). - Companion 5.0.7 on that Mac, with its HTTP API on (Companion > Settings > Protocols).