Compare commits
58
Commits
9b33abb570
..
main
@@ -1,3 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
dist/
|
||||
|
||||
@@ -13,15 +13,18 @@ There are two parts:
|
||||
|--------|-------|-------|-------|-------|-------|-------|-------|-------|
|
||||
| row 0 | UP | **ALL** | REC | AUDIO | RF | SHOW | VIDEO | STREAM |
|
||||
| row 1 | page | HD1 | HD2 | HD3 | HD4 | HD5 | HD6 | TASCAM |
|
||||
| row 2 | DOWN | X32 | DANTE | MIDI | APPS | VISTA | RESTART PAGE | SPOTIFY |
|
||||
| row 2 | DOWN | X32 | DANTE | MIDI | APPS | VISTA | RESTART PAGE | **READY** |
|
||||
| row 3 | ALARMS | ATEM | OBS | RESI | NET | INGEST | LED | ARENA |
|
||||
|
||||
Row 0 holds the group buttons. Each one shows the first problem in its group, plus "+N" if there are more:
|
||||
|
||||
- REC: HD1–6 and the Tascam.
|
||||
- AUDIO: X32 and Dante.
|
||||
- AUDIO: X32, DiGiCo, Dante and STB AUDIO (the Studio B audio source for the stream; no key of its own).
|
||||
- RF: all six Shure units (AD4Q ×2, ULXD4, PSM1000 ×3).
|
||||
- SHOW: MIDI, APPS, VISTA and SPOTIFY.
|
||||
- SHOW: MIDI, APPS, VISTA and SPOTIFY. SPOTIFY has no key of its own any more; it shows inside SHOW, and its
|
||||
force-play key is on the Restart page.
|
||||
|
||||
**READY** (row 2, col 7) is the weekly worship checklist. Tap it to open the READY page; see below.
|
||||
- VIDEO: ATEM, LED and Arena.
|
||||
- STREAM: OBS and Resi.
|
||||
|
||||
@@ -55,6 +58,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
|
||||
| DANTE: PROP MAC / TRACKS / TRANSL / INGEST GONE | starts Dante Virtual Soundcard's engine on that Mac (`remote-access/mac-dvs-helper.sh`) |
|
||||
| DANTE: any device powered by its switch port (today the four Studio B boxes) | power-cycles the port it is on **now**, found by its MAC on the Netgear Dante switches |
|
||||
| DANTE: any other device GONE | restarts Companion's Dante module (hardware: if still gone, it is off or unplugged) |
|
||||
| STB AUDIO (inside AUDIO): STUDIO B NO PING / PRO TOOLS NOT RUNNING / STB TNX GONE | presses FOH Backup on Companion's Audio page (the stream audio comes from the FOH console). The same press happens by itself on Sundays, see Audio failover. |
|
||||
| INGEST: BRIDGE / CAPTIONS / CAPTION SCREEN / PRODCOM / PRODDASH | restarts that one on NA Ingest |
|
||||
| TASCAM (always, even when it looks fine) | reconnects Companion to the recorder and looks again after 20 s. **HUNG - POWER CYCLE IT**: the recorder answers ping but its control port has hung (only a power-cycle at the rack fixes that; it has no network reboot). **NO NETWORK / CHECK POWER**: it does not answer at all. |
|
||||
| any button whose Companion connection is **disabled** | turns the connection back on (nothing else) |
|
||||
@@ -64,7 +68,9 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
|
||||
**The Dante map is automatic.** Every 10 minutes the watchdog maps each Dante device: its name and IP from Companion, its MAC from NA Ingest's Dante port, and its switch port and PoE from the Netgear switches listed in `"dante_switches"`. It saves the map to `dante-map.json` in its data folder. Re-patching, adding or renaming a device needs no config change. A device that has been dead for a while ages out of the switch tables, so its last known port is used, but only if no other device has appeared there. A port carrying more than 2 devices (an uplink) is never power-cycled. The switch login is in NA Ingest's login keychain (service `netgear-status-panel`).
|
||||
|
||||
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, for example during maintenance. It re-arms itself at midnight.
|
||||
- **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; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix.
|
||||
- **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)
|
||||
|
||||
@@ -74,7 +80,26 @@ During that window, with ALARMS on, the panel fixes a problem by itself once it
|
||||
- It waits while the network is having a blip, and does nothing to Dante when several devices vanish at once (that is NA Ingest's view or the Dante network, not the Macs).
|
||||
- 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).
|
||||
- Press ALARMS to mute the panel for maintenance: that also stops auto-fix. 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 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)
|
||||
|
||||
The stream's audio comes in on the Dante device STUDIO-A-Resi-AJA-SDI, channels 1/2. Companion's Audio page (page 4) picks the source: **Studio B** (row 0, col 6: STUDIO-B-RedNet-TNX 63/64 to the AJA, X32 snippet 1) or **FOH Backup** (row 0, col 5: FOH-DiGiCo-DMI-2 63/64 to the AJA, X32 snippet 8). If Studio B crashes on a Sunday, the panel presses FOH Backup for you.
|
||||
|
||||
**STB AUDIO** (inside AUDIO, no key of its own) watches three things: Studio B answers ping (10.1.13.175, pinged every second, **STUDIO B NO PING** after 2 misses), Pro Tools is running on the Studio B Mac Studio (`pgrep -xq 'Pro Tools'` over SSH every 2 s, **PRO TOOLS NOT RUNNING**), and the Dante device STUDIO-B-RedNet-TNX is there (**STB TNX GONE**). Each check's fix is the FOH Backup press (`stream_foh_backup`, a `companion_press` fix: `POST /api/location/4/0/5/press` on Companion's HTTP API).
|
||||
|
||||
- **When:** inside the `"failover"` windows (Sunday 06:30–13:30 today), even while ALARMS is muted. Once per fault: it re-arms once the fault has been clear for 10 s (a check that cannot ask, say an SSH hiccup, does not count as clear).
|
||||
- **How fast:** about 5–6 s from a crash to the press (2–3 s to notice, 1 s debounce, 2 s `after_seconds`). A vanished Dante device takes as long as Companion's Dante module needs to notice it.
|
||||
- **Unlike auto-fix:** it does not wait 20 s, does not stop for a network blip, and is not limited to 2 tries per 10 minutes. The one guard: when several Dante devices vanish at once (that is NA Ingest's view of Dante, not Studio B) it holds back, like auto-fix.
|
||||
- **What the keys show:** AUDIO (and the hidden STB AUDIO) carry an extra line **ON FOH BACKUP** while the fault lasts and for 15 minutes after (`note_seconds`). Hold AUDIO right after and it says JUST TRIED.
|
||||
- **Log and alert:** every press goes to the daily auto-fix log as `FAILOVER:` lines, to the watchdog log, and to `alert_webhook` if set.
|
||||
- **Switching back is yours.** It never switches back by itself: when Studio B is up again, press **Studio B** on Companion's Studio B page (page 7) or Audio page (page 4). The alert names that button (the fix's `"undo"`).
|
||||
- **Never:** a press outside the window, a second press for the same fault, or a press back to Studio B.
|
||||
|
||||
In `config.json`: the `"failover"` block (`windows`, `after_seconds` 2, `note_seconds` 900); `"failover": true` and `"undo"` on the fix (`stream_foh_backup` undoes with `stream_studio_b`); `"interval_seconds"` on ping checks (default 3) and remote checks (default 10). The remote poll runs each computer's checks in its own thread, so a dead Studio B never delays the checks on another Mac.
|
||||
|
||||
**Another fallback button:** any Companion button can be a fix. On the settings page, Keys › Fixes › + Fix › "Press a Companion button" (page, row, column and what the button says), tick failover under more and pick its undo; **Settings › Auto switch** has it all on one screen: which buttons switch by themselves (tick, page/row/column, the button to switch back with, Press it now), the hours (calendar), `after_seconds` and `note_seconds`, and what counts as down (how often each check looks, how many misses, how long a fault must last). Keys › + Key / + check… adds more checks that call it (a ping, a program on a Mac, a Dante device) with fix = the press. The Help page "How do I add…" has an entry "A fallback button".
|
||||
|
||||
## Restart page (page 20)
|
||||
|
||||
@@ -91,8 +116,73 @@ Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **e
|
||||
| TASCAM | reconnects Companion to the recorder (see TASCAM above) |
|
||||
| SPOTIFY | plays the Christian EDM 2026 playlist on NA Ingest (opens Spotify first if it is closed) |
|
||||
|
||||
A pressing key says PRESSING, then DONE; a press that finds no button there says NO BUTTON THERE.
|
||||
|
||||
Add or move keys in `"restart_page"` in `config.json`, then `python3 make_page.py` and `python3 push_page.py --which restart`. `push_page.py` refuses any page that holds real buttons unless it already is that page.
|
||||
|
||||
## READY page (page 21): the weekly checklist
|
||||
|
||||
Tap **READY** on the status page (row 2, col 7). Each key is one line of the worship weekly checklist, checked
|
||||
against this weekend's plan in Planning Center by `readiness.py` (it looks every 2 minutes, and never while a
|
||||
HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
|
||||
|
||||
| | col 1 | col 2 | col 3 | col 4 | col 5 |
|
||||
|--------|-------|-------|-------|-------|-------|
|
||||
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI |
|
||||
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | |
|
||||
| row 2 (backstage) | LINEUP TV | | | | |
|
||||
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | |
|
||||
|
||||
The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and
|
||||
the first of them.
|
||||
|
||||
**Colours:** amber means not done yet. From **Saturday 12:00 until Sunday 13:30** (`"due"` in `"ready"`), what is
|
||||
still missing turns **blinking red**. A song that is not mixed stays amber, because it is only a guess. These keys
|
||||
are not part of ALL, so an unfinished checklist on a Tuesday never turns the page-number keys red.
|
||||
|
||||
**BOH MIX** is a tap key: only a person can judge it. Tap once when it is done; the key shows `DONE THU 14:02`.
|
||||
Tap again to take it back. It resets on Monday at midnight.
|
||||
|
||||
**Hold any key for 2 s** to fix what can be fixed, or to look again now if there is no fix. **Hold READY** to fix
|
||||
everything on the page at once.
|
||||
|
||||
| Key | Checks | Hold does |
|
||||
|---|---|---|
|
||||
| TRACKS | this weekend's set exists (`Ableton/Projects/NA/261018 NA Project`), it has a song group for every PCO song, and Live last opened it ("LIVE HAS OLD SET") | looks again |
|
||||
| FOH MIX | **NOT MIXED** (amber): every track of a song is still at 0 dB. **TO MASTER**: a stem goes to Master instead of out to the console | 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 |
|
||||
| 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 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 |
|
||||
| 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 |
|
||||
| TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. |
|
||||
| PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again |
|
||||
|
||||
**Where the answers come from:**
|
||||
- **Planning Center:** ProdDash's own login on this Mac (`~/Library/Application Support/ProdDash/modules.json`, module `pco-plan`). The plan is today's, else the next one, which is the same plan the lineup TV picks.
|
||||
- **ProPresenter:** its network API.
|
||||
- **The Ableton set:** read over SSH on the Tracks Mac at low priority, and only re-read when the file changed. It reads the saved file, so save in Live, then hold the key to look again.
|
||||
- **Resolume:** its web server on port 8080.
|
||||
|
||||
Nothing on the READY page writes to Companion, ProPresenter or the Ableton set.
|
||||
|
||||
**Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config,
|
||||
then change these in `"ready"`:
|
||||
- `pco_service_type`
|
||||
- `prop_url`
|
||||
- `tracks_host` and `site_folder`
|
||||
- `lineup_url`
|
||||
- `resolume_url`
|
||||
|
||||
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
|
||||
an alarm.
|
||||
|
||||
`python3 make_page.py` writes `ready-page.companionconfig`; `python3 push_page.py --which ready` imports it into
|
||||
page 21 (moved there from 24 on 2026-10-21; the Archive pages are 30 and 31).
|
||||
|
||||
## Page keys on every page
|
||||
|
||||
The **page number key** on every page follows the ALL button. It's green when everything is fine, flashes red when something is wrong, and turns purple if the watchdog stops. UP and DOWN stay normal. Which keys follow ALL is `"nav_keys"` in `config.json` (`["pagenum"]`; add `"pageup"` / `"pagedown"` to colour those too). The coloured key is Companion's own key converted to a regular button (`nav_templates.json`).
|
||||
@@ -111,6 +201,8 @@ The **page number key** on every page follows the ALL button. It's green when ev
|
||||
| | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. |
|
||||
| TASCAM | connection; the recorder's own Error / Caution. Use the **1.3.1-quiet** Tascam module (see below) so Companion's log isn't flooded.; pinged at the address on its Companion connection. |
|
||||
| X32, ARENA, OBS | Companion connection |
|
||||
| DIGICO (inside AUDIO) | Companion's `DiGiCo_FOH` connection; the relay helper `org.waterschurch.digico-relay` on NA Ingest (`~/digico-bridge/relay.py`: Companion and the DiGiCo tablets share the console's one External Control slot through it), **DIGICO RELAY STOPPED** / **NOT LOADED**, hold AUDIO restarts it; the console itself answers ping at 10.1.60.100, **DIGICO NO PING**. Companion's OSC connection reads "good" even with the console off, so the ping is what catches that. |
|
||||
| STB AUDIO (inside AUDIO) | the Studio B audio source for the stream: Studio B answers ping (10.1.13.175, every second, **STUDIO B NO PING** after 2 misses); Pro Tools running on the Studio B Mac Studio (over SSH every 2 s, **PRO TOOLS NOT RUNNING**); the Dante device STUDIO-B-RedNet-TNX (**STB TNX GONE**). Hold 2 s presses FOH Backup; on Sundays that press happens by itself (see Audio failover). |
|
||||
| ATEM | Companion connection; the switcher's **audio** (every Fairlight input's on/off/AFV and fader, e.g. Resi on input 16 ON, TRS off) and **camera settings** (iris, white balance, shutter, gain, detail, ND, lift/gamma/gain/offset, contrast, saturation) must match the restore file `atem-reference.xml` (a copy of `4k plus 2026-10-07 14-59-55.xml` from Google Drive Backup/NA/ATEM): **ATEM AUDIO WRONG** / **ATEM CAMERA WRONG**; ATEM Software Control's *Enable MIDI control* must be off: **ATEM SW MIDI ON**. Hold 2 s (and Sunday auto-fix) sends back only what differs; MIDI is fixed by quitting the app, switching it off and reopening it (the switcher is not affected). `atem_watch.js` keeps one connection to the switcher. New restore file: copy it over `atem-reference.xml` and re-run install.sh. |
|
||||
| LED | VX1000 and UHD Jr: Companion connection **and** each one answers ping (the NovaStar module keeps saying "Connected" after a processor is switched off). "UHDJR NO PING" = processor off or unreachable; "UHDJR OFFLINE" = Companion's connection failed; "UHDJR DISABLED" (amber) = connection turned off in Companion. |
|
||||
| DANTE | All 19 Dante devices ("19/19"), named when one drops: "WING GONE", "TRACKS GONE", "PROP MAC GONE". |
|
||||
@@ -131,12 +223,116 @@ 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.
|
||||
|
||||
**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...) 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.
|
||||
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.
|
||||
|
||||
## Other locations: auto-setup
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
`curl -fsSL "http://10.1.1.1:8020/location/install?id=ab" | zsh`. It downloads the program from that page (code only,
|
||||
never that location's settings or keys), installs it, makes the panel's login key, and runs
|
||||
`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
|
||||
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.
|
||||
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`
|
||||
(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 location's Settings › Auto-setup.
|
||||
|
||||
**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,
|
||||
OBS, Resi, ProPresenter, Ableton/AbleSet), each known program running on a joined computer (ProPresenter, Ableton,
|
||||
Arena, OBS, Church Translate, Pro Tools on a Mac; Vista, Arena, OBS, ProPresenter on a PC), and every network MIDI peer at
|
||||
its wired address (with the wired-network rule). A program's key also carries its Companion connection, its start fix
|
||||
(auto-fixable) and a Restart page key. rtpMIDI rides inside the MIDI key. When everything fits there are no group keys;
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
once. Everything it makes is ordinary settings, changed on the page like North Attleboro's.
|
||||
|
||||
## The locations at a glance
|
||||
|
||||
| Location | Settings page (bookmark these) | Companion Mac | Status / Restart pages |
|
||||
|---|---|---|---|
|
||||
| North Attleboro (hub) | http://10.1.1.1:8020/ | NA Ingest | 19 / 20, READY 21, LOCATIONS 98, LOCATION 99 |
|
||||
| Woonsocket | http://10.2.2.210:8020/ | WS ProPresenter Mac (wsprop) | 19 / 20 |
|
||||
| Southcoast | http://10.5.2.60:8020/ | SC ProPresenter Mac (watersmedia) | 19 / 20 |
|
||||
| Apollo Beach (standalone) | their own | their Companion Mac | 19 / 20 or the first free |
|
||||
|
||||
Every 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 location's own deck or from NA's LOCATION page; ALARMS silences and AUTO-FIX pauses auto-fix, separately.
|
||||
`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.location upgrade-companion` brings an older
|
||||
Companion to 5.0.7 from NA's copy (settings come along; the old version stays installed).
|
||||
|
||||
## Southcoast's Vista show
|
||||
|
||||
SC's Lighting PC (SC Lighting PC, 10.5.1.168) keeps several copies of `Tiverton Lighting.v3s`; Vista opens the
|
||||
**newest** (the user's choice, 2026-10-21), never an autosave from `_Backups`. Its `\StatusPanel\Start Vista` task runs
|
||||
`C:\ProgramData\StatusPanel\start-vista.ps1` (`remote-access/start-vista-newest.ps1`), which sets Vista's
|
||||
`<autoloadpath>` in `Documents\Vista 3\.preferences` to that copy (the old file kept as `.preferences.before-status-panel`)
|
||||
and starts Vista; a running Vista is left alone. `-WhatIf` shows the pick without changing anything. Running the PC join
|
||||
line again resets the task to plain `Vista.exe`: point it back at the script afterwards.
|
||||
|
||||
## 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.
|
||||
`python3 tools/standalone.py ab "Apollo Beach"` makes `dist/Apollo Beach Status Panel.zip` (the program, an
|
||||
`install.command` and a `READ ME FIRST.txt`; never anyone's settings, plug keys or login keys) to send them.
|
||||
Installed, it sets itself up like any 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
|
||||
`~/Documents/Status Panel Backups/<id>` on its Companion Mac (or `$STATUS_PANEL_BACKUPS`). With an older
|
||||
Companion: `python3 -m settings.location upgrade-companion --from <the 5.0.7 .dmg from bitfocus.io>`.
|
||||
|
||||
## Backups
|
||||
|
||||
Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every
|
||||
location's settings, Apply history and page-key record into Google Drive: Shared drives > Waters Creative > Apps >
|
||||
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 locations are reached over SSH as the account they report. The repo keeps each
|
||||
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.
|
||||
|
||||
## Other locations' problems on NA's page keys, and ALL AUTO-FIX
|
||||
|
||||
- **Blue band** (NA only, `nav_locations_band`): when another location has a problem, NA's page-number key on every
|
||||
page gets a blue band naming it ("SC", "SC WS", "SC?" when it stops answering). It flashes until that location
|
||||
acknowledges, then stays; the page's own name and number shrink into the top 60% so they stay readable. Red stays
|
||||
for NA's own problems.
|
||||
- **Page-number key holds at NA** (`nav_hold_ms` 2000 + `nav_hold_more`): tap = page 1, hold 2 s = the status page (19), keep holding to 3 s = the LOCATIONS page (98) to pick a location. WS and SC keep a 1 s hold to their status page.
|
||||
- **ALL AUTO-FIX** (LOCATIONS page 98, bottom-left): blue "ALL ON", or grey naming the paused locations. Tap: pause
|
||||
auto-fix at NA, WS and SC at once; tap again: every one back to its own auto-fix hours (`autofix auto`). Alarms stay
|
||||
on and holds still fix.
|
||||
|
||||
## Every location on North Attleboro's deck (NA only)
|
||||
|
||||
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 location ever shows another, and only NA (the hub: no "home" in `campuses.json`) does
|
||||
(`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):
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
`python3 tools/fleet.py ard` prints a command for Apple Remote Desktop (Send UNIX Command, as root): on each Mac it
|
||||
turns Remote Login on, lets the logged-in user take the laptop's key (`~/.ssh/id_ed25519_waters`) and reports to
|
||||
NA's settings page (`/api/fleet/hello`). `python3 tools/fleet.py config` then writes `~/.ssh/config.d/waters`:
|
||||
`ssh na-translation`, `ssh ws-tracks`... A Mac where nobody is logged in is skipped (run it again later).
|
||||
|
||||
## One-time Companion setup
|
||||
|
||||
1. **Variables → Custom Variables:** add three variables. Leave the start values empty and "persist" off:
|
||||
@@ -151,7 +347,7 @@ The page reads what the watchdog writes next to its config (`snapshot.json` ever
|
||||
This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest:
|
||||
|
||||
```bash
|
||||
scp -r status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml settings checklist-ingest:status-panel-install/
|
||||
scp -r status_panel.py readiness.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml settings checklist-ingest:status-panel-install/
|
||||
```
|
||||
|
||||
Then run the installer there. It checks the config with the new code first (a bad config never gets as far as the watchdog), keeps the code that was running in `prev/`, backs up the old config, writes the LaunchAgent with the exit timeout this config needs (the longest fix plus its verify time, so a restart never cuts a fix in half; launchd honours at most 60 s of it) and restarts the watchdog:
|
||||
@@ -160,7 +356,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'
|
||||
```
|
||||
|
||||
`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:
|
||||
|
||||
@@ -211,6 +407,9 @@ To go back, pick 1.3.0 on the connection again.
|
||||
|
||||
## Windows PCs
|
||||
|
||||
`remote-access/windows-baseline.ps1` is the show-PC baseline (NA LiveStream, 2026-10-19; NA Lighting, Bible TV and SC Lighting, 2026-10-21): Windows Update installs no drivers and never restarts while the user is signed in (active hours 5 AM-11 PM too), Game DVR off, Microsoft's bundled apps removed, no sleep, hibernate, disk or USB power saving. It never restarts the PC or touches its network card. Run it as an administrator (copied to `C:\ProgramData\StatusPanel\`); safe to run again.
|
||||
|
||||
|
||||
`remote-access/windows-setup.ps1` sets each PC up once (SSH for NA Ingest, start tasks, Wake-on-LAN). An app ticked "Run this program as an administrator" (OBS on the LiveStream PC) gets a start task that runs with highest privileges; otherwise Windows refuses to start it (error 0x800702E4). Windows restarts the SSH service and rtpMIDI by itself if they stop.
|
||||
|
||||
## Files
|
||||
@@ -220,11 +419,12 @@ To go back, pick 1.3.0 on the connection again.
|
||||
| `status_panel.py` | the watchdog |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `make_page.py` | builds `status-page.companionconfig` from `config.json` |
|
||||
| `install.sh` | installs or updates the LaunchAgent on NA Ingest |
|
||||
| `push_page.py` | imports the status page into page 19 over Companion's API |
|
||||
| `push_page.py` | imports the status page into page 19 over Companion's API (`--which restart` / `--which ready` for pages 20 and 24) |
|
||||
| `readiness.py` | the READY page: looks at Planning Center, ProPresenter, the Ableton set, ProdDash's lineup, Resolume and the shared drive, and runs the READY fixes |
|
||||
| `nav_keys.py` | colours (or restores) the page nav keys on every page |
|
||||
| `nav_templates.json` | Companion's own nav key designs, coloured by `make_page.py` |
|
||||
| `remote-access/` | one-time setup scripts for SSH to the Macs and Windows PCs |
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
# 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,
|
||||
# it copies into the shared Drive (Waters Creative > Apps > Status Panel Backups > <location>), keeps
|
||||
# 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.
|
||||
#
|
||||
# Only the hub (North Attleboro: no "home" in campuses.json) does anything; elsewhere it exits, except
|
||||
# on a standalone location (Apollo Beach), which backs up only itself into ~/Documents/Status Panel Backups.
|
||||
# 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 location
|
||||
# lists. Never outlets.json (the smart-plug keys stay on their Mac).
|
||||
|
||||
set -uo pipefail
|
||||
DEST="$HOME/Library/Application Support/status-panel"
|
||||
LOG="$HOME/Library/Logs/status-panel-backup.log"
|
||||
KEEP=30
|
||||
KEY="$HOME/.ssh/id_status_panel"
|
||||
FILES="config.json history nav_locations.json campuses.json computers.json discover-seen.json"
|
||||
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; echo "$*"; }
|
||||
alert() {
|
||||
log "ALERT: $1"
|
||||
local js="$HOME/checklist-app/scripts/alert.js"
|
||||
[ -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 "location" if d.get("home") else "hub" if d.get("list") else "")' \
|
||||
"$DEST/campuses.json" 2>/dev/null)
|
||||
[ "$MODE" = hub ] || [ "$MODE" = standalone ] || { log "not the hub: nothing to back up from here"; exit 0; }
|
||||
|
||||
DRIVE=""
|
||||
if [ "$MODE" = standalone ]; then
|
||||
# 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).
|
||||
DRIVE="${STATUS_PANEL_BACKUPS:-$HOME/Documents/Status Panel Backups}"
|
||||
else
|
||||
for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do
|
||||
[ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; }
|
||||
done
|
||||
fi
|
||||
if [ -z "$DRIVE" ]; then
|
||||
alert "tonight's backup did not run - the Google Drive folder is missing" \
|
||||
"Shared drives > Waters Creative > Apps could not be found on this Mac. Usually Google Drive is not running or signed out."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$DRIVE" || { alert "could not create $DRIVE"; exit 1; }
|
||||
log "── run start ── into $DRIVE"
|
||||
|
||||
STAMP="$(date '+%Y-%m-%d')"
|
||||
FAILED=""
|
||||
while IFS=$'\t' read -r id user host; do
|
||||
[ -z "$id" ] && continue
|
||||
mkdir -p "$DRIVE/$id"
|
||||
OUT="$DRIVE/$id/$id-$STAMP.tgz"
|
||||
TMP="$(mktemp -t status-backup)"
|
||||
if [ "$host" = "self" ]; then
|
||||
(cd "$DEST" && tar -czf "$TMP" $(ls -d $FILES 2>/dev/null)) 2>>"$LOG"
|
||||
else
|
||||
ssh -n -i "$KEY" -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new "$user@$host" \
|
||||
"cd 'Library/Application Support/status-panel' && tar -czf - \$(ls -d $FILES 2>/dev/null)" > "$TMP" 2>>"$LOG"
|
||||
fi
|
||||
# Listing it proves it whole (not gzip -t: macOS tar pads a piped archive, which gzip calls "trailing garbage").
|
||||
if [ -s "$TMP" ] && tar -tzf "$TMP" 2>/dev/null | grep -qx 'config.json'; then
|
||||
cp "$TMP" "$OUT.part" && mv "$OUT.part" "$OUT" && log "✓ $id -> $(basename "$OUT") ($(du -k "$OUT" | cut -f1) KB)"
|
||||
ls -1t "$DRIVE/$id"/"$id"-*.tgz 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do rm -f "$old"; done
|
||||
else
|
||||
FAILED="$FAILED $id"
|
||||
log "✗ $id: nothing usable came back from $user@$host"
|
||||
fi
|
||||
rm -f "$TMP"
|
||||
done < <(/usr/bin/python3 - "$DEST/campuses.json" "$DEST/config.json" <<'PY'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1])); me = (json.load(open(sys.argv[2])).get("site") or {}).get("id")
|
||||
for cid, e in sorted((d.get("list") or {}).items()):
|
||||
if cid == me:
|
||||
print("%s\t-\tself" % cid)
|
||||
elif e.get("user") and e.get("url"):
|
||||
print("%s\t%s\t%s" % (cid, e["user"], e["url"].split("//")[1].split(":")[0]))
|
||||
else:
|
||||
print("%s\t?\t?" % cid)
|
||||
PY
|
||||
)
|
||||
if [ -n "$FAILED" ]; then
|
||||
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
|
||||
fi
|
||||
log "── done ──"
|
||||
+1783
-1247
File diff suppressed because it is too large
Load Diff
+109
-4
@@ -168,14 +168,21 @@ def stuck_peer(sess, peer_name):
|
||||
return any(p.get("name") == peer_name and p.get("state") not in LIVE_STATES for p in sess.get("peers", []))
|
||||
|
||||
|
||||
def connect_peer(session_name, peer_name, address, settle=time.sleep):
|
||||
def listed_elsewhere(sess, peer_name, address):
|
||||
"""True if the peer is in the session at another address (a Bonjour name, a Wi-Fi address)."""
|
||||
return any(p.get("name") == peer_name and p.get("address") != address for p in sess.get("peers", []))
|
||||
|
||||
|
||||
def connect_peer(session_name, peer_name, address, settle=time.sleep, repoint=False):
|
||||
"""Ask macOS to connect `peer_name` at `address` ("10.1.10.206:5004") to the named session,
|
||||
like Audio MIDI Setup's Connect. Returns "already" (it is in the session and live), "asked",
|
||||
or "kicked" (it was listed but stuck: dropped, then connected again)."""
|
||||
"kicked" (it was listed but stuck: dropped, then connected again) or, with `repoint`, "moved"
|
||||
(it was listed at another address - a Bonjour name, Wi-Fi - and now joins at this one)."""
|
||||
with _lock:
|
||||
found = [(e, s) for e, s in _session_entities()
|
||||
if (s.get("dns-sd-name") or _string_prop(e, "name")) == session_name]
|
||||
if found and stuck_peer(found[0][1], peer_name):
|
||||
moving = bool(found and repoint and listed_elsewhere(found[0][1], peer_name, address))
|
||||
if found and (moving or stuck_peer(found[0][1], peer_name)):
|
||||
ent, sess = found[0]
|
||||
dropped = without_peer(sess, peer_name)
|
||||
with _lock:
|
||||
@@ -188,7 +195,7 @@ def connect_peer(session_name, peer_name, address, settle=time.sleep):
|
||||
err = _set_dict_prop(ent, "apple.midirtp.session", with_peer(dropped, peer_name, address))
|
||||
if err:
|
||||
raise OSError("MIDIObjectSetDictionaryProperty failed: %d" % err)
|
||||
return "kicked"
|
||||
return "moved" if moving else "kicked"
|
||||
result = _edit_session(session_name, lambda sess: with_peer(sess, peer_name, address))
|
||||
return "already" if result == "unchanged" else result
|
||||
|
||||
@@ -200,6 +207,104 @@ def disconnect_peer(session_name, peer_name):
|
||||
return "not connected" if result == "unchanged" else result
|
||||
|
||||
|
||||
# The same read as sessions(), for a Mac without python (no Command Line Tools): macOS's own
|
||||
# JavaScript for Automation calls CoreMIDI directly, nothing to install. Prints the sessions as JSON.
|
||||
# Older macOS keeps no "state" for a peer; a peer without one is listed as configured, no verdict.
|
||||
JXA_SESSIONS = r"""
|
||||
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
|
||||
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
|
||||
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
|
||||
var out = {}, n = $.MIDIGetNumberOfDevices();
|
||||
for (var i = 0; i < n; i++) {
|
||||
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
|
||||
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
|
||||
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
|
||||
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
|
||||
if (!s) continue;
|
||||
out[s['dns-sd-name'] || str(ent, $('name')) || '?'] = {port: s.port, peers: (s.peers || []).map(function (p) {
|
||||
var q = {name: p.name, address: p.address}; if (p.state !== undefined) q.state = p.state; return q; })};
|
||||
}
|
||||
}
|
||||
'SPMIDI' + JSON.stringify(out);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
# Connect, for a Mac without python: what connect_peer does, in JavaScript for Automation. Filled in
|
||||
# with jxa_connect(); prints SPMIDI"already" / "asked" / "kicked" / "moved", like connect_peer.
|
||||
JXA_CONNECT = r"""
|
||||
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
|
||||
var SESSION = __SESSION__, PEER = __PEER__, ADDRESS = __ADDRESS__, REPOINT = __REPOINT__;
|
||||
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
|
||||
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
|
||||
function put(ent, s) { var e = $.MIDIObjectSetDictionaryProperty(ent, $('apple.midirtp.session'), $(s)); if (e !== 0) throw new Error('MIDIObjectSetDictionaryProperty failed: ' + e); }
|
||||
var found = null, n = $.MIDIGetNumberOfDevices();
|
||||
for (var i = 0; i < n && !found; i++) {
|
||||
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
|
||||
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
|
||||
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
|
||||
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
|
||||
if (s && (s['dns-sd-name'] || str(ent, $('name'))) === SESSION) { found = [ent, s]; break; }
|
||||
}
|
||||
}
|
||||
if (!found) throw new Error('no network MIDI session named ' + SESSION + ' on this Mac');
|
||||
var ent = found[0], s = found[1], peers = s.peers || [], result;
|
||||
var mine = peers.filter(function (p) { return p.name === PEER; });
|
||||
var stuck = mine.some(function (p) { return p.state !== undefined && p.state !== 'running' && p.state !== 'connected'; });
|
||||
var elsewhere = mine.some(function (p) { return p.address !== ADDRESS; });
|
||||
if (mine.length && (stuck || (REPOINT && elsewhere))) {
|
||||
s.peers = peers.filter(function (p) { return p.name !== PEER; });
|
||||
put(ent, s); delay(1);
|
||||
s.peers = s.peers.concat([{address: ADDRESS, name: PEER}]); put(ent, s);
|
||||
result = (REPOINT && elsewhere) ? 'moved' : 'kicked';
|
||||
} else if (mine.length || peers.some(function (p) { return p.address === ADDRESS; })) {
|
||||
result = 'already';
|
||||
} else {
|
||||
s.peers = peers.concat([{address: ADDRESS, name: PEER}]); put(ent, s); result = 'asked';
|
||||
}
|
||||
'SPMIDI' + JSON.stringify(result);
|
||||
"""
|
||||
|
||||
|
||||
# Disconnect, for a Mac without python: what disconnect_peer does. Prints SPMIDI"asked" / "not connected".
|
||||
JXA_DISCONNECT = r"""
|
||||
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
|
||||
var SESSION = __SESSION__, PEER = __PEER__;
|
||||
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
|
||||
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
|
||||
var found = null, n = $.MIDIGetNumberOfDevices();
|
||||
for (var i = 0; i < n && !found; i++) {
|
||||
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
|
||||
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
|
||||
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
|
||||
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
|
||||
if (s && (s['dns-sd-name'] || str(ent, $('name'))) === SESSION) { found = [ent, s]; break; }
|
||||
}
|
||||
}
|
||||
if (!found) throw new Error('no network MIDI session named ' + SESSION + ' on this Mac');
|
||||
var ent = found[0], s = found[1], peers = s.peers || [], result = 'not connected';
|
||||
var left = peers.filter(function (p) { return p.name !== PEER; });
|
||||
if (left.length !== peers.length) {
|
||||
s.peers = left;
|
||||
var e = $.MIDIObjectSetDictionaryProperty(ent, $('apple.midirtp.session'), $(s));
|
||||
if (e !== 0) throw new Error('MIDIObjectSetDictionaryProperty failed: ' + e);
|
||||
result = 'asked';
|
||||
}
|
||||
'SPMIDI' + JSON.stringify(result);
|
||||
"""
|
||||
|
||||
|
||||
def jxa_disconnect(session_name, peer_name):
|
||||
import json as _json
|
||||
return JXA_DISCONNECT.replace("__SESSION__", _json.dumps(session_name)).replace("__PEER__", _json.dumps(peer_name))
|
||||
|
||||
|
||||
def jxa_connect(session_name, peer_name, address, repoint=False):
|
||||
"""The JXA_CONNECT script for one peer (values go in as JSON, never as code)."""
|
||||
import json as _json
|
||||
return (JXA_CONNECT.replace("__SESSION__", _json.dumps(session_name)).replace("__PEER__", _json.dumps(peer_name))
|
||||
.replace("__ADDRESS__", _json.dumps(address)).replace("__REPOINT__", "true" if repoint else "false"))
|
||||
|
||||
def sessions():
|
||||
"""{session name: {"port": int, "peers": [{"name", "address", "state"}]}}"""
|
||||
found = {}
|
||||
|
||||
+547
-547
File diff suppressed because it is too large
Load Diff
@@ -51,7 +51,7 @@ Weaknesses
|
||||
|
||||
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 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.
|
||||
@@ -68,7 +68,7 @@ Weaknesses
|
||||
### 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: 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 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).
|
||||
@@ -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 | 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 | 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 | 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). |
|
||||
@@ -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 | 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 | 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 | 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. |
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
@@ -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.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.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) |
|
||||
| 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.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()) |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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[].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[].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[].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 |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| "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) |
|
||||
| "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 |
|
||||
| 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) |
|
||||
| `$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 |
|
||||
@@ -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 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.
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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__) |
|
||||
| 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 |
|
||||
| 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) |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| 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) |
|
||||
| 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
@@ -1,12 +1,12 @@
|
||||
# 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).
|
||||
|
||||
## 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**.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- **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 location; the git repo keeps a snapshot you export from the page.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -121,11 +121,11 @@ COMPUTERS - MAP [Check computers now] looked 2
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
@@ -148,21 +148,21 @@ Grouped here; every single field is in [the details table](settings-plan-details
|
||||
| Spotify | playlist, name, waits | Site setup → Spotify |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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).
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- `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%`).
|
||||
- `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).
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
| 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).
|
||||
|
||||
## 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.
|
||||
- **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 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.
|
||||
@@ -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).
|
||||
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).
|
||||
|
||||
## 14. How this plan was audited
|
||||
|
||||
+64
-4
@@ -4,7 +4,8 @@
|
||||
#
|
||||
# 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 --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
|
||||
# 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 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 --remove stop it and remove the LaunchAgent (files stay)
|
||||
#
|
||||
@@ -18,7 +19,7 @@ SETTINGS_LABEL=org.waterschurch.status-settings
|
||||
DEST="$HOME/Library/Application Support/status-panel"
|
||||
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
||||
DOMAIN="gui/$(id -u)"
|
||||
CODE=(status_panel.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)
|
||||
CODE=(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)
|
||||
KEEP_CONFIG=0
|
||||
SITE=""
|
||||
SITE_NEXT=0
|
||||
@@ -26,18 +27,19 @@ for arg in "$@"; do
|
||||
if [[ $SITE_NEXT == 1 ]]; then SITE="$arg"; SITE_NEXT=0; continue; fi
|
||||
case "$arg" in
|
||||
--keep-config) KEEP_CONFIG=1 ;;
|
||||
--replace-config) REPLACE_CONFIG=1 ;;
|
||||
--site) SITE_NEXT=1 ;;
|
||||
--remove|--rollback|--now) ;;
|
||||
*) echo "unknown option $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
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"
|
||||
if [[ -n "$SITE" ]]; then
|
||||
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"
|
||||
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
|
||||
|
||||
restart_agent() { # $1 = label, $2 = plist
|
||||
@@ -151,6 +153,31 @@ then
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# 0b. Never undo an Apply. Live settings that came from the settings page (their fingerprint is the
|
||||
# "sha_after" of an entry in history/) and differ from the config this would install were changed on
|
||||
# the page since this folder was made: replacing them would quietly undo that (2026-10-15: READY hours
|
||||
# set on the page went back to the folder's defaults). Copy them into the folder first, or say so.
|
||||
if [[ $KEEP_CONFIG == 0 && ${REPLACE_CONFIG:-0} == 0 && -f "$DEST/config.json" && -d "$DEST/history" ]] \
|
||||
&& ! cmp -s "${SRC_CONFIG:-$SRC/config.json}" "$DEST/config.json" \
|
||||
&& /usr/bin/python3 -c '
|
||||
import glob, hashlib, json, os, sys
|
||||
d = sys.argv[1]
|
||||
live = hashlib.sha256(open(os.path.join(d, "config.json"), "rb").read()).hexdigest()
|
||||
for m in glob.glob(os.path.join(d, "history", "*.meta.json")):
|
||||
try:
|
||||
meta = json.load(open(m))
|
||||
except ValueError:
|
||||
continue
|
||||
if meta.get("sha_after") == live:
|
||||
print("the live settings come from the settings page (%s, %s): not replacing them." % (meta.get("by", "?"), os.path.basename(m)[:15]))
|
||||
sys.exit(0)
|
||||
sys.exit(1)' "$DEST"
|
||||
then
|
||||
echo "Copy them into this folder first (scp checklist-ingest:'Library/Application Support/status-panel/config.json' config.json)," >&2
|
||||
echo "or run with --keep-config (code only) or --replace-config (overwrite them)." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# 1. The config that will run, checked with the code that will run it.
|
||||
mkdir -p "$DEST"
|
||||
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
|
||||
@@ -185,6 +212,39 @@ fi
|
||||
if [[ -d "$SRC/companion-objects" ]]; then
|
||||
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
|
||||
fi
|
||||
# The kit another location installs from (the settings page serves it as /location/program.tgz).
|
||||
rm -rf "$DEST/kit" && mkdir -p "$DEST/kit/sites"
|
||||
cp "$SRC/install.sh" "$DEST/kit/"
|
||||
[[ -f "$SRC/README.md" ]] && cp "$SRC/README.md" "$DEST/kit/"
|
||||
[[ -d "$SRC/sites/_template" ]] && cp -R "$SRC/sites/_template" "$DEST/kit/sites/"
|
||||
# The nightly off-site backup of every location's settings (does anything only on the hub, NA).
|
||||
if [[ -d "$SRC/backup" ]]; then
|
||||
rm -rf "$DEST/backup" && cp -R "$SRC/backup" "$DEST/" && chmod +x "$DEST/backup/"*.sh
|
||||
BACKUP_LABEL=org.waterschurch.status-backup
|
||||
BACKUP_PLIST="$HOME/Library/LaunchAgents/$BACKUP_LABEL.plist"
|
||||
cat > "$BACKUP_PLIST" <<EOF2
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>$BACKUP_LABEL</string>
|
||||
<key>ProgramArguments</key><array><string>/bin/bash</string><string>$DEST/backup/backup-campuses.sh</string></array>
|
||||
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
|
||||
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>30</integer></dict>
|
||||
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel-backup.err.log</string>
|
||||
<key>StandardOutPath</key><string>$HOME/Library/Logs/status-panel-backup.out.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF2
|
||||
launchctl bootout "$DOMAIN/$BACKUP_LABEL" 2>/dev/null || true
|
||||
launchctl bootstrap "$DOMAIN" "$BACKUP_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
# The panel's own login key for the other computers (made once, never replaced).
|
||||
if [[ ! -f "$HOME/.ssh/id_status_panel" ]]; then
|
||||
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
|
||||
/usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName 2>/dev/null || hostname -s)" -f "$HOME/.ssh/id_status_panel"
|
||||
echo "made the panel's login key ~/.ssh/id_status_panel"
|
||||
fi
|
||||
|
||||
# 4. The LaunchAgent, with the exit timeout this config needs (a running fix finishes before a restart).
|
||||
T=$(date +%s)
|
||||
|
||||
+102
-6
@@ -15,8 +15,10 @@ Buttons write commands into sp_cmd, which status_panel.py reads:
|
||||
press ack <tile> (the master acks everything)
|
||||
hold 2 seconds restart <tile> (fixes what that tile is failing on)
|
||||
Restart page force <key> (hold 2 s: restart that thing even when it looks fine)
|
||||
READY page done <key> (tap a key only a person can check: done for the week)
|
||||
|
||||
python3 make_page.py -> status-page.companionconfig and restart-page.companionconfig
|
||||
python3 make_page.py -> status-page.companionconfig, restart-page.companionconfig
|
||||
and ready-page.companionconfig
|
||||
python3 make_page.py --out DIR -> the same files in DIR (the data dir on the Ingest Mac)
|
||||
"""
|
||||
import gzip
|
||||
@@ -27,7 +29,7 @@ import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from status_panel import RESTARTABLE, load_config # noqa: E402
|
||||
from status_panel import DAYS, RESTARTABLE, load_config, ready_keys # noqa: E402
|
||||
|
||||
# Defaults; "companion", "page_grid", "page_stale_seconds" and "page_hold_ms" in config.json override them.
|
||||
EXPORT_VERSION = 12 # Companion 5.0.7's export format
|
||||
@@ -38,7 +40,7 @@ PURPLE = 0x6A1B9A
|
||||
HOLD_MS = 2000
|
||||
# Every key this tool makes carries one of these in its notes; push_page.py overwrites a page only
|
||||
# when every key on it (besides Companion's own page keys) does.
|
||||
MARKERS = ("STATUS tile", "RESTART key", "STATUS jump key", "RESTART back key", "STATUS nav key")
|
||||
MARKERS = ("STATUS tile", "RESTART key", "STATUS jump key", "RESTART back key", "STATUS nav key", "READY back key")
|
||||
|
||||
|
||||
def settings(cfg):
|
||||
@@ -169,7 +171,24 @@ def button(tid, tile, cfg):
|
||||
hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0)
|
||||
kind = tile.get("kind")
|
||||
label = tile["label"]
|
||||
if kind == "master":
|
||||
rp = cfg.get("ready_page") or {}
|
||||
on_ready_page = any(k["id"] == tid for k in rp.get("keys", []))
|
||||
go = None
|
||||
if kind == "ready" and not on_ready_page:
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press, hold, go = None, "restart " + tid, rp.get("page")
|
||||
note = ("Tap: open the READY page (page %s). Hold %s s: fix everything on it that can be fixed "
|
||||
"(re-sync the lineup, the cross background, look again)." % (rp.get("page"), secs))
|
||||
elif kind == "ready":
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press, hold = "ack " + tid, "restart " + tid
|
||||
note = "Press: stop the blinking. Hold %s s: fix everything on this page that can be fixed." % secs
|
||||
elif any(c["type"] == "ready_manual" for c in tile.get("checks", [])):
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press, hold = "done " + tid, None
|
||||
note = "Tap: done for this week (tap again to take it back). Resets every %s." % (
|
||||
(cfg.get("ready") or {}).get("reset_day", "mon").capitalize())
|
||||
elif kind == "master":
|
||||
# Template string: '+' would add numbers, not join text (see above).
|
||||
stale_text = "`${'WATCHDOG\\nDOWN\\n'}${$(internal:instance_errors)} ERR`"
|
||||
press, hold = "ackall", None
|
||||
@@ -177,7 +196,27 @@ def button(tid, tile, cfg):
|
||||
elif kind == "service":
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press, hold = "service toggle", None
|
||||
note = "Press: turn service mode on/off until the next scheduled window starts or ends."
|
||||
sw = cfg.get("service_windows") or []
|
||||
always = len(sw) == 1 and set(sw[0].get("days", [])) == set(DAYS) and sw[0].get("start") == "00:00" and sw[0].get("end") in ("23:59", "00:00")
|
||||
if any(t.get("kind") == "autofix" for t in cfg.get("tiles", [])):
|
||||
note = ("Press: silence the alarms; press again to turn them back on. They come back by themselves %s. "
|
||||
"Auto-fix is the AUTO-FIX key." % ("at midnight" if always else "when the alarm schedule next starts or ends"))
|
||||
else:
|
||||
note = ("Press: silence the alarms AND stop every auto-fix; press again to turn both back on. They come back by "
|
||||
"themselves %s. Holds still fix. The stream-audio failover still switches by itself on Sundays."
|
||||
% ("at midnight" if always else "when the alarm schedule next starts or ends"))
|
||||
elif kind == "autofix":
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press, hold = "autofix toggle", None
|
||||
note = ("Press: pause auto-fix (alarms stay on) through the hours it is in now, or through the next ones when pressed "
|
||||
"outside them; press again to resume. It resumes by itself when those hours end. Holds always fix.")
|
||||
elif kind == "bypass":
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press, hold = "ack " + tid, "restart " + tid
|
||||
b = tile.get("bypass") or {}
|
||||
note = ("Hold %s s: hand %s to an operator - its MIDI links (%s) are taken out%s and the panel stops putting them "
|
||||
"back. Hold again: everything is put back." % (secs, label, ", ".join(b.get("peers") or []),
|
||||
" and Companion's %s switched off" % ", ".join(b["conns"]) if b.get("conns") else ""))
|
||||
else:
|
||||
stale_text = "'%s\\n?'" % label
|
||||
press = "ack " + tid
|
||||
@@ -193,7 +232,7 @@ def button(tid, tile, cfg):
|
||||
else:
|
||||
note = "Press: acknowledge." + (" Hold %s s: fix what this button is failing on (nothing if it is green)."
|
||||
% secs if hold else "")
|
||||
action_sets = {"down": [], "up": [command_action(tid, "press", names["command"], press)]}
|
||||
action_sets = {"down": [], "up": [set_page_action(tid, "go", go) if go else command_action(tid, "press", names["command"], press)]}
|
||||
run_while_held = []
|
||||
if hold:
|
||||
# Stamped with Companion's clock, so a hold made while the watchdog was down is dropped later.
|
||||
@@ -226,6 +265,33 @@ def nav_templates(cfg):
|
||||
raise FileNotFoundError("no nav key templates for Companion %s" % version)
|
||||
|
||||
|
||||
REMOTE_VAR = "sp_remote" # North Attleboro's summary of the other locations (settings/campuses.py writes it)
|
||||
BAND_BLUE, BAND_DARK = 0x1E6FFF, 0x0B2A66
|
||||
|
||||
|
||||
def band_on():
|
||||
r = "$(custom:%s)" % REMOTE_VAR
|
||||
return "$(internal:time_unix) - jsonpath(%s, '$.t') < 30 && jsonpath(%s, '$.n') > 0" % (r, r)
|
||||
|
||||
|
||||
def locations_band(flash_var):
|
||||
"""A blue band across the bottom of NA's page-number key naming the other locations with a problem
|
||||
("SC", "SC WS"): it flashes until that location acknowledges, then stays. Red stays NA's own
|
||||
(2026-10-21, the user: "i dont want the NA page buttons to flash red, that should he reserved for on site
|
||||
problems")."""
|
||||
r = "$(custom:%s)" % REMOTE_VAR
|
||||
on = band_on()
|
||||
color = "jsonpath(%s, '$.f') == 1 && $(custom:%s) == 1 ? %d : %d" % (r, flash_var, BAND_DARK, BAND_BLUE)
|
||||
box = {"id": stable_id("nav", "band_box"), "name": "Locations", "usage": "auto", "type": "box", "enabled": prop(on, True),
|
||||
"opacity": prop(100), "x": prop(0), "y": prop(60), "width": prop(100), "height": prop(40), "rotation": prop(0),
|
||||
"color": prop(color, True), "borderWidth": prop(0), "borderColor": prop(0), "borderPosition": prop("inside")}
|
||||
text = {"id": stable_id("nav", "band_text"), "name": "Locations text", "usage": "auto", "type": "text", "enabled": prop(on, True),
|
||||
"opacity": prop(100), "x": prop(0), "y": prop(60), "width": prop(100), "height": prop(40), "rotation": prop(0),
|
||||
"text": prop("jsonpath(%s, '$.x')" % r, True), "color": prop(0xFFFFFF), "halign": prop("center"), "valign": prop("center"),
|
||||
"fontsize": prop("auto"), "fontsizeAllowShrink": prop(True), "font": prop("companion-sans"), "outlineColor": prop(4278190080)}
|
||||
return [box, text]
|
||||
|
||||
|
||||
def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
|
||||
"""Companion's own UP / page number / DOWN key, coloured like the ALL button.
|
||||
|
||||
@@ -252,6 +318,12 @@ def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
|
||||
layer["borderColor"] = prop(fg, True)
|
||||
paint(layer.get("children", []))
|
||||
paint(key["style"]["layers"])
|
||||
if kind == "pagenum" and (cfg.get("nav_locations_band") or False):
|
||||
# While the band shows, the page's own name and number shrink into the top 60%, so nothing is hidden.
|
||||
for layer in key["style"]["layers"]:
|
||||
if layer["type"] == "group":
|
||||
layer["height"] = prop("(%s) ? 60 : 100" % band_on(), True)
|
||||
key["style"]["layers"] += locations_band(opts["flash"])
|
||||
if kind == "pagenum" and status_page:
|
||||
step = key["steps"]["0"]
|
||||
tap = step["action_sets"]["down"]
|
||||
@@ -261,6 +333,14 @@ def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
|
||||
"options": {"surfaceId": prop("self"), "page": prop(status_page)}, "children": {}}]
|
||||
step["action_sets"] = {"down": [], "up": tap, str(hold_ms): hold}
|
||||
step["options"]["runWhileHeld"] = [hold_ms]
|
||||
# Longer holds go further (NA, 2026-10-21: 2 s the status page, still held at 3 s the LOCATIONS page).
|
||||
for i, more in enumerate(sorted((cfg or {}).get("nav_hold_more") or [], key=lambda m: m["ms"])):
|
||||
if more["ms"] > hold_ms:
|
||||
step["action_sets"][str(more["ms"])] = [{"type": "action", "id": stable_id("nav", "hold%d" % (i + 2)),
|
||||
"definitionId": "set_page", "connectionId": "internal",
|
||||
"options": {"surfaceId": prop("self"), "page": prop(more["page"])},
|
||||
"children": {}}]
|
||||
step["options"]["runWhileHeld"].append(more["ms"])
|
||||
key["options"]["notes"] = ("%s (%s): colour follows the ALL button on the status page. "
|
||||
"Undo on every page with: python3 nav_keys.py --revert" % (NAV_MARKER, kind))
|
||||
return key
|
||||
@@ -322,6 +402,18 @@ def build_restart(cfg):
|
||||
return page_export(rp["page_name"], rp["page"], controls, cfg)
|
||||
|
||||
|
||||
def build_ready(cfg):
|
||||
"""The READY page: the weekly checklist, one key per line, drawn by the watchdog like the Status page."""
|
||||
rp = cfg["ready_page"]
|
||||
controls = nav_controls(cfg)
|
||||
place(controls, rp["back_pos"], static_button("ready_back", "STATUS\\nPAGE", 0x37474F, 0xFFFFFF, cfg["page"],
|
||||
"Tap: back to the status page (page %d)." % cfg["page"],
|
||||
"READY back key"))
|
||||
for key in ready_keys(cfg):
|
||||
place(controls, key["pos"], button(key["id"], key, cfg))
|
||||
return page_export(rp["page_name"], rp["page"], controls, cfg)
|
||||
|
||||
|
||||
def write(path, export):
|
||||
with gzip.open(path, "wb") as f: # Companion's own exports are gzipped JSON
|
||||
f.write(json.dumps(export, indent=1).encode())
|
||||
@@ -343,6 +435,10 @@ def main(argv=None):
|
||||
out = os.path.join(folder, "restart-page.companionconfig")
|
||||
write(out, build_restart(cfg))
|
||||
print("wrote %s (%d restart keys)" % (out, len(cfg["restart_page"]["keys"])))
|
||||
if cfg.get("ready_page") and not args.status_file:
|
||||
out = os.path.join(folder, "ready-page.companionconfig")
|
||||
write(out, build_ready(cfg))
|
||||
print("wrote %s (%d READY keys)" % (out, len(ready_keys(cfg))))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+6
-2
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import the status page (19) or the Restart page (20; both numbers come from config.json) into Companion without clicking through the UI.
|
||||
"""Import the status page (19), the Restart page (20) or the READY page (24; the numbers come from config.json) into Companion without clicking through the UI.
|
||||
|
||||
Does exactly what Import/Export -> Import -> "import into page N" does, over the
|
||||
same internal API the Companion web UI uses (tRPC over a WebSocket at /trpc):
|
||||
@@ -12,6 +12,7 @@ A key someone added is named by its position, so it can be moved first.
|
||||
|
||||
python3 push_page.py # the status page, from config.json
|
||||
python3 push_page.py --which restart # the Restart page
|
||||
python3 push_page.py --which ready # the READY page (the weekly checklist)
|
||||
python3 push_page.py --host 10.6.3.222 --page 19 --file status-page.companionconfig
|
||||
|
||||
Stdlib only (a minimal WebSocket client is included). Written against Companion 5.0.7.
|
||||
@@ -210,7 +211,7 @@ def push(host, port, page, file_path, expected_name):
|
||||
def main():
|
||||
cfg = json.load(open(os.path.join(HERE, "config.json")))
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--which", choices=("status", "restart"), default="status")
|
||||
ap.add_argument("--which", choices=("status", "restart", "ready"), default="status")
|
||||
ap.add_argument("--host", default=cfg.get("site", {}).get("ingest_hostname") or cfg["companion"].get("host", "127.0.0.1"))
|
||||
ap.add_argument("--port", type=int, default=cfg["companion"].get("port", 8000))
|
||||
ap.add_argument("--page", type=int)
|
||||
@@ -220,6 +221,9 @@ def main():
|
||||
if args.which == "restart":
|
||||
page, expected = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]
|
||||
default_file = "restart-page.companionconfig"
|
||||
elif args.which == "ready":
|
||||
page, expected = cfg["ready_page"]["page"], cfg["ready_page"]["page_name"]
|
||||
default_file = "ready-page.companionconfig"
|
||||
else:
|
||||
page, expected, default_file = cfg["page"], cfg["page_name"], "status-page.companionconfig"
|
||||
args.page = args.page or page
|
||||
|
||||
+1245
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
# start-vista-newest.ps1 - the status panel's "Start Vista" task on a Lighting PC: Vista opens the
|
||||
# newest copy of the location's show, through Vista's own autoload preference (as North Attleboro's
|
||||
# Vista opens Prop Takeover). Southcoast keeps several copies of "Tiverton Lighting.v3s"; the user
|
||||
# chose "whichever is newest" (2026-10-21). Autosave backups (_Backups) are never picked.
|
||||
#
|
||||
# Installed at C:\ProgramData\StatusPanel\start-vista.ps1; the task runs
|
||||
# powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File C:\ProgramData\StatusPanel\start-vista.ps1 -Show "Tiverton Lighting.v3s"
|
||||
# A running Vista is left alone: its show is never changed under it.
|
||||
param(
|
||||
[string]$Show = "Tiverton Lighting.v3s",
|
||||
[string]$Vista = "C:\Program Files (x86)\Chroma-Q\Vista3\Vista.exe",
|
||||
[switch]$WhatIf
|
||||
)
|
||||
if ((Get-Process -Name Vista -ErrorAction SilentlyContinue) -and -not $WhatIf) { "Vista is running: left alone"; exit 0 }
|
||||
$roots = @("$env:USERPROFILE\Desktop", "$env:USERPROFILE\Documents") | Where-Object { Test-Path $_ }
|
||||
$newest = Get-ChildItem -Path $roots -Recurse -Filter $Show -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -notmatch '\\_Backups\\' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
$prefs = "$env:USERPROFILE\Documents\Vista 3\.preferences"
|
||||
if ($newest -and (Test-Path $prefs)) {
|
||||
$text = [IO.File]::ReadAllText($prefs)
|
||||
$line = '<autoloadpath>' + [Security.SecurityElement]::Escape($newest.FullName) + '</autoloadpath>'
|
||||
$new = [regex]::Replace($text, '<autoloadpath>.*?</autoloadpath>', [Text.RegularExpressions.MatchEvaluator]{ param($m) $line })
|
||||
"newest: $($newest.FullName) ($($newest.LastWriteTime))"
|
||||
if ($new -ne $text -and -not $WhatIf) {
|
||||
Copy-Item $prefs "$prefs.before-status-panel" -Force
|
||||
[IO.File]::WriteAllText($prefs, $new)
|
||||
"autoload set"
|
||||
}
|
||||
} else {
|
||||
"no $Show found, or no Vista preferences: Vista starts with its own choice"
|
||||
}
|
||||
if (-not $WhatIf) { Start-Process -FilePath $Vista -WorkingDirectory (Split-Path $Vista) }
|
||||
@@ -0,0 +1,57 @@
|
||||
# windows-baseline.ps1 - the settings every show PC gets so Windows never breaks a service, as on the NA
|
||||
# LiveStream PC (2026-10-19): Windows Update installs no drivers and never restarts while the user is signed
|
||||
# in (active hours 5 AM - 11 PM as well); Game DVR off; Microsoft's bundled apps removed; no disk or USB power
|
||||
# saving, no sleep. Nothing here restarts the PC or touches its network card. Run as an administrator; safe to
|
||||
# run again. Prints what it set.
|
||||
$ErrorActionPreference = 'SilentlyContinue'; $ProgressPreference = 'SilentlyContinue'
|
||||
function K($p) { if (-not (Test-Path $p)) { New-Item $p -Force | Out-Null } }
|
||||
|
||||
# Windows Update: no drivers (the 10/15 NVIDIA driver broke a stream), no restarts while signed in.
|
||||
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'; $au = "$wu\AU"; K $wu; K $au
|
||||
Set-ItemProperty $wu ExcludeWUDriversInQualityUpdate 1 -Type DWord
|
||||
Set-ItemProperty $wu SetActiveHours 1 -Type DWord; Set-ItemProperty $wu ActiveHoursStart 5 -Type DWord; Set-ItemProperty $wu ActiveHoursEnd 23 -Type DWord
|
||||
Set-ItemProperty $au NoAutoRebootWithLoggedOnUsers 1 -Type DWord
|
||||
$ds = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DriverSearching'; K $ds
|
||||
Set-ItemProperty $ds SearchOrderConfig 0 -Type DWord; Set-ItemProperty $ds DontSearchWindowsUpdate 1 -Type DWord
|
||||
$dm = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata'; K $dm; Set-ItemProperty $dm PreventDeviceMetadataFromNetwork 1 -Type DWord
|
||||
Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DriverSearching' SearchOrderConfig 0 -Type DWord
|
||||
|
||||
# Game DVR / Xbox capture off (machine policy and this user).
|
||||
$gd = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\GameDVR'; K $gd; Set-ItemProperty $gd AllowGameDVR 0 -Type DWord
|
||||
K 'HKCU:\System\GameConfigStore'; Set-ItemProperty 'HKCU:\System\GameConfigStore' GameDVR_Enabled 0 -Type DWord
|
||||
K 'HKCU:\Software\Microsoft\Windows\CurrentVersion\GameDVR'; Set-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\GameDVR' AppCaptureEnabled 0 -Type DWord
|
||||
|
||||
# Microsoft's bundled apps (for every user, and not reinstalled for new ones).
|
||||
$bloat = 'Microsoft.XboxGameOverlay','Microsoft.XboxGamingOverlay','Microsoft.XboxSpeechToTextOverlay','Microsoft.Xbox.TCUI',
|
||||
'Microsoft.XboxIdentityProvider','Microsoft.GamingApp','Microsoft.Edge.GameAssist','Microsoft.BingNews','Microsoft.BingWeather',
|
||||
'Microsoft.BingSearch','Microsoft.MicrosoftSolitaireCollection','Clipchamp.Clipchamp','MicrosoftTeams','MSTeams','Microsoft.Copilot',
|
||||
'Microsoft.OutlookForWindows','microsoft.windowscommunicationsapps','Microsoft.MicrosoftOfficeHub','Microsoft.People',
|
||||
'Microsoft.YourPhone','MicrosoftWindows.CrossDevice','Microsoft.WindowsMaps','Microsoft.WindowsFeedbackHub','Microsoft.GetHelp',
|
||||
'Microsoft.Getstarted','Microsoft.Todos','Microsoft.Whiteboard','Microsoft.MicrosoftJournal','Microsoft.Windows.DevHome',
|
||||
'MicrosoftCorporationII.MicrosoftFamily','Microsoft.MixedReality.Portal','Microsoft.PowerAutomateDesktop','Microsoft.ZuneMusic',
|
||||
'Microsoft.ZuneVideo','Microsoft.549981C3F5F10'
|
||||
$prov = Get-AppxProvisionedPackage -Online
|
||||
foreach ($x in $bloat) {
|
||||
Get-AppxPackage -AllUsers -Name $x | Remove-AppxPackage -AllUsers
|
||||
$prov | Where-Object DisplayName -eq $x | Remove-AppxProvisionedPackage -Online -AllUsers | Out-Null
|
||||
}
|
||||
|
||||
# Power: never sleep or hibernate, disks never off, USB never suspended (DMX interfaces, capture cards).
|
||||
foreach ($m in 'ac', 'dc') {
|
||||
powercfg /change "standby-timeout-$m" 0; powercfg /change "hibernate-timeout-$m" 0; powercfg /change "disk-timeout-$m" 0
|
||||
}
|
||||
powercfg /setacvalueindex SCHEME_CURRENT 2a737441-1930-4402-8d77-b2bebba308a3 48e6b7a6-50f5-4782-a5d4-53bb8f07e226 0
|
||||
powercfg /setdcvalueindex SCHEME_CURRENT 2a737441-1930-4402-8d77-b2bebba308a3 48e6b7a6-50f5-4782-a5d4-53bb8f07e226 0
|
||||
powercfg /setactive SCHEME_CURRENT
|
||||
|
||||
# What it is now.
|
||||
$q = { param($sub, $set) ((powercfg /query SCHEME_CURRENT $sub $set | Select-String 'Current AC' | Select-Object -First 1).Line -replace '.*: ','') }
|
||||
$w = Get-ItemProperty $wu
|
||||
'RESULT ' + $env:COMPUTERNAME
|
||||
' updates: drivers excluded=' + $w.ExcludeWUDriversInQualityUpdate + ', active hours ' + $w.ActiveHoursStart + '-' + $w.ActiveHoursEnd +
|
||||
', no restart while signed in=' + (Get-ItemProperty $au).NoAutoRebootWithLoggedOnUsers
|
||||
' game DVR policy=' + (Get-ItemProperty $gd).AllowGameDVR + ', user=' + (Get-ItemProperty 'HKCU:\System\GameConfigStore').GameDVR_Enabled
|
||||
' power: sleep=' + (& $q SUB_SLEEP STANDBYIDLE) + ' hibernate=' + (& $q SUB_SLEEP HIBERNATEIDLE) + ' disk=' + (& $q SUB_DISK DISKIDLE) +
|
||||
' usb suspend=' + (& $q 2a737441-1930-4402-8d77-b2bebba308a3 48e6b7a6-50f5-4782-a5d4-53bb8f07e226)
|
||||
' bundled apps left: ' + ((($bloat | Where-Object { Get-AppxPackage -AllUsers -Name $_ }) -join ', ') -replace '^$', 'none')
|
||||
' ssh: ' + (Get-Service sshd).Status + ' / ' + (Get-Service sshd).StartType
|
||||
@@ -60,6 +60,12 @@ $userKeys = Join-Path $userSsh 'authorized_keys'
|
||||
if (-not (Test-Path $userKeys) -or -not (Select-String -Path $userKeys -SimpleMatch $KeyMark -Quiet)) {
|
||||
Add-Content -Path $userKeys -Value $Key -Encoding ascii
|
||||
}
|
||||
# The admin laptop's key too (it is let through the firewall above as well).
|
||||
foreach ($k in @({{ADMIN_KEYS}})) {
|
||||
foreach ($f in @($adminKeys, $userKeys)) {
|
||||
if (-not (Select-String -Path $f -SimpleMatch $k -Quiet)) { Add-Content -Path $f -Value $k -Encoding ascii }
|
||||
}
|
||||
}
|
||||
|
||||
# 3. Start tasks for the apps found on this PC (they run on this user's desktop)
|
||||
function Find-First($candidates) {
|
||||
|
||||
Binary file not shown.
@@ -9,6 +9,7 @@ must be declared here, every default in the code must equal the one here).
|
||||
Paths: "companion.host", "tiles[].checks[].conn" (a list element), "fixes.*.run" (a map entry).
|
||||
"""
|
||||
import copy
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
import status_panel as sp
|
||||
@@ -29,9 +30,13 @@ def field(path, type, default=None, help="", screen="", advanced=False, secret=F
|
||||
|
||||
|
||||
# -- 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.name", "str", "", "The campus, as people say it.", "Site", example="North Attleboro", apply="none")
|
||||
field("site.admin_ips", "list", [], "The admin laptop's fixed addresses: let through the Windows PCs' SSH firewall with this Mac.",
|
||||
"Site", advanced=True, apply="none")
|
||||
field("site.admin_keys", "list", [], "The admin laptop's public SSH keys: every PC and Mac set up from here takes them too.",
|
||||
"Site", advanced=True, 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_hostname", "str", "", "This Mac's network name, used in the page address and by the page tools.",
|
||||
"Site", example="na-ingest.local", apply="none")
|
||||
@@ -70,6 +75,8 @@ field("nav_keys", "list", ["pagenum"], "Which of Companion's page keys (page up,
|
||||
apply="pages")
|
||||
field("nav_hold_ms", "int", 1000, "Holding the page-number key this long jumps to the Status page.", "Companion",
|
||||
advanced=True, lo=200, hi=5000, apply="pages")
|
||||
field("nav_hold_more", "list", [], "Longer holds on the page-number key: [{\"ms\": 3000, \"page\": 98}] - still held at 3 s, "
|
||||
"it goes on to page 98 (NA: 2 s the status page, 3 s the LOCATIONS page).", "Companion", advanced=True, apply="pages")
|
||||
field("nav_tap_page", "int", 1, "Tapping the page-number key goes to this page.", "Companion", advanced=True, lo=1,
|
||||
apply="pages")
|
||||
field("variables", "dict", {}, "The custom variables the panel uses inside Companion.", "Companion", required=True)
|
||||
@@ -90,7 +97,7 @@ field("page_hold_ms", "int", 2000, "How long a key is held to run its fix.", "Sc
|
||||
|
||||
# -- schedules and timing --
|
||||
field("service_windows", "list", [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}],
|
||||
"When alarms are armed: always (the ALARMS key silences them until midnight).", "Schedules", advanced=True)
|
||||
"When alarms are armed: always (the ALARMS key silences them and stops auto-fix until midnight).", "Schedules", advanced=True)
|
||||
field("service_windows[].days", "days", None, "", "Schedules", required=True)
|
||||
field("service_windows[].start", "hhmm", None, "", "Schedules", required=True)
|
||||
field("service_windows[].end", "hhmm", None, "", "Schedules", required=True)
|
||||
@@ -111,6 +118,11 @@ field("auto_fix.windows", "list", [], "When auto-fix may act.", "Schedules")
|
||||
field("auto_fix.windows[].days", "days", None, "", "Schedules", required=True)
|
||||
field("auto_fix.windows[].start", "hhmm", None, "", "Schedules", required=True)
|
||||
field("auto_fix.windows[].end", "hhmm", None, "", "Schedules", required=True)
|
||||
field("discover", "dict", {}, "Auto-setup: this Mac looks for gear every ten minutes.", "Site", advanced=True)
|
||||
field("discover.auto", "bool", False, "Add new gear by itself: a new Companion connection, Dante device, MIDI link, computer "
|
||||
"or program gets its key (never during a service or a recording; nothing is ever removed).", "Site")
|
||||
field("auto_fix.disabled", "bool", False, "Auto-fix switched off as a whole: nothing is fixed by itself (holds "
|
||||
"still work).", "Schedules")
|
||||
field("auto_fix.after_seconds", "int", 20, "A fault must last this long before auto-fix acts.", "Schedules",
|
||||
advanced=True, lo=5, hi=600)
|
||||
field("auto_fix.max_tries", "int", 2, "Tries per fault, then it is yours to hold.", "Schedules", advanced=True,
|
||||
@@ -126,6 +138,19 @@ field("auto_fix.log_dir", "path", "~/Desktop/Auto-Fix Logs", "One file per day o
|
||||
"Schedules", advanced=True)
|
||||
field("auto_fix.keep_months", "int", 1, "Daily log files are deleted after this many months.", "Schedules",
|
||||
advanced=True, lo=1, hi=12)
|
||||
field("failover", "dict", {}, "Automatic fallback: when a fix marked failover may act (it acts even while ALARMS is "
|
||||
"muted).", "Schedules")
|
||||
field("failover.disabled", "bool", False, "The auto switch switched off as a whole: nothing is pressed by itself.",
|
||||
"Schedules")
|
||||
field("failover.windows", "list", [{"days": ["sun"], "start": "06:30", "end": "13:30"}], "When a failover may switch "
|
||||
"by itself.", "Schedules")
|
||||
field("failover.windows[].days", "days", None, "", "Schedules", required=True)
|
||||
field("failover.windows[].start", "hhmm", None, "", "Schedules", required=True)
|
||||
field("failover.windows[].end", "hhmm", None, "", "Schedules", required=True)
|
||||
field("failover.after_seconds", "int", 2, "The fault must have lasted this long before the failover switches.",
|
||||
"Schedules", lo=0, hi=60)
|
||||
field("failover.note_seconds", "int", 900, "After the fault clears, the key keeps saying ON <the fallback> this long "
|
||||
"(until the fix that switches back runs).", "Schedules", advanced=True, lo=60, hi=86400)
|
||||
|
||||
# -- network and Dante --
|
||||
field("network", "dict", {}, "The NET key.", "Network & Dante")
|
||||
@@ -171,6 +196,8 @@ field("hosts.*.ssh", "str", None, "How this Mac logs in: user@address.", "Comput
|
||||
field("hosts.*.mac", "mac", None, "The PC's hardware address, to wake it when it is off.", "Computers")
|
||||
field("hosts.*.wake_broadcast", "ip", None, "The broadcast address the wake-up goes to.", "Computers", advanced=True)
|
||||
field("hosts.*.name", "str", None, "The computer, as people say it.", "Computers", apply="none")
|
||||
field("hosts.*.disabled", "bool", False, "Switched off: kept, but nothing logs in to it - its checks and fixes are "
|
||||
"skipped (a computer that is away or being rebuilt).", "Computers")
|
||||
field("hosts.*.os", "enum", "mac", "Mac or Windows (a PC's checks are written for cmd.exe).", "Computers",
|
||||
options=["mac", "windows", "other"])
|
||||
field("hosts.*.role", "str", None, "What it is for.", "Computers", apply="none")
|
||||
@@ -184,6 +211,8 @@ field("hosts.*.manual_done", "dict", {}, "Set-up steps ticked by hand (who, when
|
||||
field("gear", "dict", {}, "Devices that are not computers: where each one is.", "Computers", apply="none")
|
||||
field("gear.*.location", "str", None, "Where it is in the building.", "Computers", apply="none")
|
||||
field("gear.*.notes", "str", None, "Notes for operators.", "Computers", apply="none")
|
||||
field("gear.*.kind", "str", None, "What it is (recorder, switcher, wireless receiver...).", "Computers", apply="none")
|
||||
field("gear.*.address", "str", None, "Its network address, if it has one.", "Computers", apply="none")
|
||||
|
||||
# -- fixes --
|
||||
field("fixes", "dict", {}, "Everything a hold can run.", "Fixes")
|
||||
@@ -196,7 +225,9 @@ field("fixes.*.label", "str", None, "Plain words: what it does.", "Fixes", requi
|
||||
field("fixes.*.reconnect", "bool", False, "Reconnect the Companion connection afterwards.", "Fixes")
|
||||
field("fixes.*.conn", "str", None, "The Companion connection it reconnects.", "Fixes")
|
||||
field("fixes.*.restart_ok", "bool", False, "May close a running program (and still run by itself).", "Fixes")
|
||||
field("fixes.*.on", "host", None, "The computer ProPresenter runs on, when not this Mac (follow its network port).", "Fixes")
|
||||
field("fixes.*.never_auto", "bool", False, "Never runs by itself.", "Fixes")
|
||||
field("fixes.*.anytime", "bool", False, "Ticked for auto-fix, runs by itself at any hour, not only in the auto-fix hours, unless ALARMS is muted (a READY fix that only writes to Planning Center).", "Fixes")
|
||||
field("fixes.*.part", "enum", None, "Which part of the switcher.", "Fixes", options=["audio", "camera"])
|
||||
field("fixes.*.verify_seconds", "int", 20, "Wait this long before judging a reconnect.", "Fixes", advanced=True,
|
||||
lo=5, hi=120)
|
||||
@@ -214,6 +245,16 @@ field("fixes.*.mac", "mac", None, "The device expected on that port.", "Fixes")
|
||||
field("fixes.*.playlist", "str", None, "A playlist other than the site's (rare).", "Fixes", advanced=True)
|
||||
field("fixes.*.template", "str", None, "The builder this fix was made with.", "Fixes", advanced=True, apply="none")
|
||||
field("fixes.*.params", "dict", None, "The builder's fields.", "Fixes", advanced=True, apply="none")
|
||||
field("fixes.*.disabled", "bool", False, "Switched off: kept, but never runs (a hold says TURNED OFF; auto-fix "
|
||||
"and the auto switch skip it).", "Fixes")
|
||||
field("fixes.*.action", "enum", None, "What a READY page fix does.", "Fixes", options=list(sp.READY_ACTIONS))
|
||||
field("fixes.*.page", "int", None, "The Companion page the button is on.", "Fixes", lo=1)
|
||||
field("fixes.*.row", "int", None, "The button's row on that page (the top row is 0).", "Fixes", lo=0)
|
||||
field("fixes.*.col", "int", None, "The button's column (the left column is 0).", "Fixes", lo=0)
|
||||
field("fixes.*.failover", "bool", False, "An automatic fallback: when a check that names this fix fails, the watchdog "
|
||||
"runs it by itself within seconds (inside the failover hours, even while ALARMS is muted, once per fault). It "
|
||||
"never switches back by itself.", "Fixes")
|
||||
field("fixes.*.undo", "str", None, "The fix that switches back by hand (its key takes the ON ... note off).", "Fixes")
|
||||
|
||||
# -- tiles (keys) --
|
||||
field("tiles", "list", [], "The keys on the Status page.", "Keys", required=True)
|
||||
@@ -222,10 +263,22 @@ field("tiles[].id", "id", None, "Used inside Companion.", "Keys", advanced=True,
|
||||
field("tiles[].label", "str", None, "The first line on the key.", "Keys", required=True, apply="pages")
|
||||
field("tiles[].pos", "list", None, "[row, column]; absent = no key of its own (shown inside a group).", "Keys",
|
||||
apply="pages")
|
||||
field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARMS), network (NET).", "Keys",
|
||||
advanced=True, options=["master", "service", "network"], apply="pages")
|
||||
field("nav_locations_band", "bool", False, "North Attleboro: a blue band on the page-number keys names the other locations "
|
||||
"with a problem (flashing until acknowledged there). Red stays for this location's own problems.", "Keys", advanced=True,
|
||||
apply="pages")
|
||||
field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARMS), network (NET), ready (READY: "
|
||||
"opens the READY page and sums up its checklist), autofix (AUTO-FIX: its own switch, so ALARMS only "
|
||||
"silences), bypass (hold: hand a device to an operator - its MIDI links out, its Companion connections off).",
|
||||
"Keys", advanced=True, options=["master", "service", "network", "ready", "autofix", "bypass"])
|
||||
field("tiles[].bypass", "dict", None, "A bypass key: peers (MIDI peer or session names to take out, e.g. Lighting), "
|
||||
"hosts (the Macs whose sessions to look in; \"\" is this Mac) and conns (Companion connections to switch off).",
|
||||
"Keys", advanced=True)
|
||||
field("tiles[].members", "list", None, "A group: the keys it sums up.", "Keys")
|
||||
field("tiles[].checks", "list", [], "What the key watches.", "Keys")
|
||||
field("tiles[].disabled", "bool", False, "Switched off: the key stays on the page (grey, OFF) but checks nothing "
|
||||
"and fixes nothing, like a connection switched off in Companion.", "Keys")
|
||||
field("tiles[].checks[].devices_off", "list", [], "Dante devices switched off: kept in the list, not watched.", "Keys")
|
||||
field("tiles[].checks[].disabled", "bool", False, "Switched off: this check is kept but not looked at.", "Keys")
|
||||
field("tiles[].hold_always", "str", None, "A fix a hold runs even when the key is green.", "Keys", apply="pages")
|
||||
field("tiles[].checks[].type", "enum", None, "What kind of check.", "Keys", required=True)
|
||||
field("tiles[].checks[].conn", "str", None, "The Companion connection.", "Keys")
|
||||
@@ -256,12 +309,14 @@ field("tiles[].checks[].failures", "int", 2, "Pings missed before NO PING.", "Ke
|
||||
field("tiles[].checks[].label", "str", None, "The launchd helper.", "Keys")
|
||||
field("tiles[].checks[].match", "str", None, "The program (pgrep -f pattern).", "Keys")
|
||||
field("tiles[].checks[].url", "url", None, "The web address to fetch.", "Keys")
|
||||
field("tiles[].checks[].interval_seconds", "int", 30, "Fetch it this often.", "Keys", advanced=True, lo=5, hi=3600)
|
||||
field("tiles[].checks[].interval_seconds", "int", 30, "Look this often, in seconds: a web address every 30, a command "
|
||||
"on another computer every 10, a ping every 3 (1 for a fast fallback check).", "Keys", advanced=True, lo=1, hi=3600)
|
||||
field("tiles[].checks[].path", "path", None, "The disk to watch.", "Keys")
|
||||
field("tiles[].checks[].warn_gb", "int", 20, "Amber below this many GB free.", "Keys", advanced=True)
|
||||
field("tiles[].checks[].err_gb", "int", 5, "Red below this many GB free.", "Keys", advanced=True)
|
||||
field("tiles[].checks[].session", "str", None, "The network MIDI session.", "Keys")
|
||||
field("tiles[].checks[].peer", "str", None, "The peer that must be in it.", "Keys")
|
||||
field("tiles[].checks[].nets", "list", None, "The wired network(s) every MIDI peer must come over, e.g. 10.1.0.0/16.", "Keys")
|
||||
field("tiles[].checks[].address", "str", None, "Where to reconnect the peer if it was never seen.", "Keys", advanced=True)
|
||||
field("tiles[].checks[].domain", "str", None, "The macOS preference domain.", "Keys")
|
||||
field("tiles[].checks[].key", "str", None, "The preference key.", "Keys")
|
||||
@@ -275,10 +330,11 @@ field("tiles[].checks[].shell", "enum", None, "sh (Macs) or cmd (PCs); blank = f
|
||||
advanced=True, options=["sh", "cmd"])
|
||||
field("tiles[].checks[].part", "enum", None, "Which part of the switcher.", "Keys", options=["audio", "camera"])
|
||||
field("tiles[].checks[].template", "str", None, "The builder this check was made with.", "Keys", advanced=True, apply="none")
|
||||
field("tiles[].checks[].item", "str", None, "Which line of the weekly checklist (READY page).", "Ready")
|
||||
field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", advanced=True, apply="none")
|
||||
|
||||
# -- 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_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",
|
||||
@@ -290,9 +346,74 @@ field("restart_page.keys[].id", "id", None, "", "Keys", advanced=True, required=
|
||||
field("restart_page.keys[].label", "str", None, "", "Keys", required=True, apply="pages")
|
||||
field("restart_page.keys[].pos", "list", None, "[row, column].", "Keys", required=True, apply="pages")
|
||||
field("restart_page.keys[].fix", "str", None, "What holding it runs.", "Keys", required=True)
|
||||
field("restart_page.keys[].disabled", "bool", False, "Switched off: the key stays on the page (OFF) but a hold does "
|
||||
"nothing.", "Keys", apply="pages")
|
||||
field("restart_page.keys[].watch", "list", [], "Keep flashing HOLD TO FIX while these fixes' checks or "
|
||||
"'conn:<label>' connections are down.", "Keys")
|
||||
|
||||
# -- the READY page (the weekly checklist) --
|
||||
field("ready_page", "dict", None, "The READY page: this weekend's checklist, checked against Planning Center; absent = "
|
||||
"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_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",
|
||||
apply="pages")
|
||||
field("ready_page.keys", "list", [], "The checklist keys (each like a Status page key, placed on the READY page).",
|
||||
"Ready", apply="pages")
|
||||
field("ready", "dict", {}, "Where the READY page looks.", "Ready")
|
||||
field("ready.disabled", "bool", False, "The READY page switched off as a whole: its keys show OFF and nothing is "
|
||||
"looked at.", "Ready")
|
||||
field("ready.interval_seconds", "int", 120, "Look this often (never while a HyperDeck is recording; a hold looks at "
|
||||
"once).", "Ready", advanced=True, lo=30, hi=3600)
|
||||
field("ready.due", "list", [], "From when gaps are red instead of amber.", "Ready")
|
||||
field("ready.due[].days", "days", None, "", "Ready", required=True)
|
||||
field("ready.due[].start", "hhmm", None, "", "Ready", required=True)
|
||||
field("ready.due[].end", "hhmm", None, "", "Ready", required=True)
|
||||
field("ready.reset_day", "enum", "mon", "Taps (BOH mix) count from midnight on this day.", "Ready",
|
||||
options=list(sp.DAYS))
|
||||
field("ready.pco_credentials", "path", "~/Library/Application Support/ProdDash/modules.json", "ProdDash's settings "
|
||||
"file, which holds the Planning Center login.", "Ready", advanced=True)
|
||||
field("ready.pco_module", "str", "pco-plan", "The ProdDash module whose Planning Center login is used.", "Ready",
|
||||
advanced=True)
|
||||
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.ltc_campuses", "list", [], "Other locations that play the same songs: their PCO plans get this location's "
|
||||
"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 location.", "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_note", "str", "LTC", "The PCO note category that holds a song's timecode (checked against the "
|
||||
"tracks, written from them by a hold); blank = not checked.",
|
||||
"Ready", advanced=True)
|
||||
field("ready.prop_url", "url", "", "ProPresenter's network API.", "Ready", example="http://10.1.14.225:1025")
|
||||
field("ready.prop_lists", "list", [], "PCO items whose description lists things the slides must have: "
|
||||
"{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.projects", "path", "", "The shared Ableton Projects folder, as that computer sees it.", "Ready")
|
||||
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).",
|
||||
"Ready", advanced=True)
|
||||
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)
|
||||
field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "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,
|
||||
lo=10, hi=600)
|
||||
field("ready.lineup_url", "url", "", "ProdDash's Band Lineup module.", "Ready")
|
||||
field("ready.lineup_teams", "list", ["Band", "Communication"], "PCO teams whose people must be on the lineup TV.",
|
||||
"Ready")
|
||||
field("ready.lineup_skip_positions", "list", [], "PCO positions the lineup TV does not show.", "Ready")
|
||||
field("ready.resolume_url", "url", "", "Resolume Arena's web server.", "Ready")
|
||||
field("ready.resolume_deck", "str", "", "The deck Resolume must be on.", "Ready")
|
||||
field("ready.prop_host", "str", None, "The ProPresenter computer: its macros and presentation files are read to find "
|
||||
"the Resolume columns Sunday's slides call up.", "Ready")
|
||||
field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready")
|
||||
field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes. {date} is this weekend's folder "
|
||||
"(Location Assets/{date} -> Location Assets/261025): any bundle in it counts as sent.", "Ready")
|
||||
field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True)
|
||||
field("ready.vocal_positions", "list", ["Vocalist", "[Leader]"], "PCO positions that need a vocal mic.", "Ready")
|
||||
field("ready.mic_positions", "dict", {}, "Other PCO positions and the mic they need (MC -> MC).", "Ready")
|
||||
|
||||
# --- Check types ---------------------------------------------------------------
|
||||
# fields: the keys that type reads; subs: the sub-check names it returns (fix tables use them);
|
||||
# identity: what it is about; module: the Companion module its connection is expected to use.
|
||||
@@ -302,7 +423,7 @@ CHECKS = {
|
||||
"hyperdeck": {"fields": ["conn", "optional", "disk_service_only", "full_minutes", "low_minutes", "rec_sync", "ip"],
|
||||
"subs": ["conn", "disk", "rec"], "identity": ["conn"], "module": "bmd-hyperdeck",
|
||||
"question": "Is a recorder OK?", "words": "A HyperDeck is connected, has a disk, and records with the others."},
|
||||
"dante": {"fields": ["conn", "devices", "count_all", "service_only"], "subs": ["conn", "<device name>"], "identity": ["conn"],
|
||||
"dante": {"fields": ["conn", "devices", "count_all", "service_only", "devices_off"], "subs": ["conn", "<device name>"], "identity": ["conn"],
|
||||
"module": "audinate-dantecontroller", "question": "Is Dante complete?", "words": "Every listed Dante device is present."},
|
||||
"shure": {"fields": ["conn", "channels", "battery_err_bars", "battery_warn_bars"], "subs": ["conn", "ch<N>_batt", "ch<N>_rf"],
|
||||
"identity": ["conn"], "module": "shure-wireless", "question": "Is a device answering?", "words": "A Shure receiver: batteries and interference."},
|
||||
@@ -310,7 +431,7 @@ CHECKS = {
|
||||
"module": "shure-psm1000", "question": "Is a device answering?", "words": "A PSM transmitter: RF mute."},
|
||||
"tascam": {"fields": ["conn"], "subs": ["conn", "error", "caution"], "identity": ["conn"], "module": "tascam-da-6400",
|
||||
"question": "Is a recorder OK?", "words": "The Tascam recorder reports no error or caution."},
|
||||
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
|
||||
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "interval_seconds", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
|
||||
"module": None, "question": "Is a device answering?", "words": "An address answers ping."},
|
||||
"launchd": {"fields": ["label", "name"], "subs": ["svc"], "identity": ["label"], "module": None,
|
||||
"question": "Is a program running?", "words": "A helper (LaunchAgent) on this Mac is loaded and running."},
|
||||
@@ -322,6 +443,8 @@ CHECKS = {
|
||||
"question": "Is a setting right?", "words": "Free space on a disk."},
|
||||
"midi_peer": {"fields": ["session", "peer", "host", "address"], "subs": ["peer"], "identity": ["host", "session", "peer"],
|
||||
"module": None, "question": "Is a MIDI link up?", "words": "A peer is in a network MIDI session (here or on another Mac)."},
|
||||
"midi_wired": {"fields": ["nets"], "subs": ["wired", "<host|session|peer>"], "identity": [], "module": None,
|
||||
"question": "Is a MIDI link up?", "words": "Every network MIDI peer comes over the wired network (not Wi-Fi or Dante)."},
|
||||
"pref": {"fields": ["domain", "key", "want", "msg", "template", "params"], "subs": ["pref"], "identity": ["domain", "key"],
|
||||
"module": None, "question": "Is a setting right?", "words": "A macOS preference has the value it should."},
|
||||
"spotify": {"fields": ["playlist", "fix"], "subs": ["spotify"], "identity": [], "module": None,
|
||||
@@ -330,20 +453,24 @@ CHECKS = {
|
||||
"question": "Is a MIDI link up?", "words": "A network MIDI session is advertised on Bonjour."},
|
||||
"midi_rx": {"fields": ["source", "name", "max_silence_seconds"], "subs": ["rx"], "identity": ["source"], "module": None,
|
||||
"question": "Is a MIDI link up?", "words": "MIDI is arriving from a source."},
|
||||
"remote": {"fields": ["host", "cmd", "msg", "shell", "template", "params"], "subs": ["remote"], "identity": ["host", "cmd"],
|
||||
"remote": {"fields": ["host", "cmd", "msg", "shell", "interval_seconds", "template", "params"], "subs": ["remote"], "identity": ["host", "cmd"],
|
||||
"module": None, "question": "Is a program running?", "words": "A command on another computer succeeds."},
|
||||
"artnet": {"fields": ["source", "max_silence_seconds", "msg"], "subs": ["artnet"], "identity": ["source"], "module": None,
|
||||
"question": "Is the lighting console sending?", "words": "Art-Net is arriving from the lighting program."},
|
||||
"atem": {"fields": ["part", "msg"], "subs": ["audio", "camera"], "identity": ["part"], "module": "bmd-atem",
|
||||
"question": "Is a setting right?", "words": "The switcher matches the saved file."},
|
||||
"ready": {"fields": ["item"], "subs": ["ready", "<what is missing>"], "identity": ["item"], "module": None,
|
||||
"question": "Is the weekend ready?", "words": "A line of the weekly checklist, checked against Planning Center."},
|
||||
"ready_manual": {"fields": ["item", "msg"], "subs": ["done"], "identity": ["item"], "module": None,
|
||||
"question": "Is the weekend ready?", "words": "A line of the weekly checklist a person taps done."},
|
||||
}
|
||||
COMMON_CHECK_FIELDS = ["type", "fix", "tag", "service_only", "debounce_seconds"]
|
||||
COMMON_CHECK_FIELDS = ["type", "fix", "tag", "service_only", "debounce_seconds", "disabled"]
|
||||
FIX_KINDS = sp.FIX_KINDS
|
||||
FIX_KIND_FIELDS = {
|
||||
"ssh": ["host", "run", "timeout", "reconnect", "conn"], "local": ["run", "timeout", "reconnect", "conn"],
|
||||
"atem": ["part"], "reconnect": ["conn", "verify_seconds", "hung_note"], "midi_connect": [],
|
||||
"atem": ["part"], "reconnect": ["conn", "verify_seconds", "hung_note"], "midi_connect": [], "propresenter_port": ["conn", "on"],
|
||||
"netgear_poe": ["switch", "port", "mac"], "hyperdeck": ["verify_seconds", "off_seconds", "hung_note"],
|
||||
"spotify": ["playlist"],
|
||||
"spotify": ["playlist"], "ready": ["action"], "companion_press": ["page", "row", "col"],
|
||||
}
|
||||
EXPECTED_MODULE = {t: spec["module"] for t, spec in CHECKS.items() if spec["module"]}
|
||||
|
||||
@@ -361,6 +488,14 @@ GLOSSARY = {
|
||||
"RF MUTE", "HUNG - POWER CYCLE IT", "NO NETWORK CHECK POWER", "STILL DOWN", "REFUSED", "LOGIN", "BLIP",
|
||||
"NOT REACHABLE", "DISK",
|
||||
],
|
||||
"the READY page: what is not ready yet (hold the key to fix it or look again)": [
|
||||
"TO DO", "ALL DONE", "TAP WHEN DONE", "TAP WHEN SET", "NOT DONE", "DONE", "NOT SET UP", "CHECKING",
|
||||
"NO TRACKS", "EMPTY", "NOT MIXED", "TO MASTER", "NO LIGHTS", "NO CUE TRACK", "NO SET", "LIVE HAS OLD SET",
|
||||
"MISSING", "SLIDES OLD?", "SLIDES", "NO TC", "NO LTC", "NO BG", "NO PLAYLIST", "OLD PLAN", "SYNC ERROR",
|
||||
"NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN",
|
||||
"PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE",
|
||||
"UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
|
||||
],
|
||||
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
|
||||
"what a key says while you hold it": [
|
||||
"NOTHING TO FIX", "NO AUTO FIX", "JUST TRIED", "WAIT 1 MIN", "WAIT", "MIN", "S", "CHECKING", "FIXING",
|
||||
@@ -369,7 +504,11 @@ GLOSSARY = {
|
||||
"STILL OFF IN COMPANION", "DISABLED IN COMPANION", "TURN ON", "RECONNECT", "POWER CYCLE", "FIXES", "HOLD TO FIX",
|
||||
"HOLD", "REC", "PLAYING", "OK", "OFF", "ON", "ALL OK", "WARN", "TRS", "GB", "NOT", "REACHABLE", "RECORDING",
|
||||
"LEFT ALONE", "TO FIX", "TO CLEAR", "NO AUTO", "FIX", "AUTO", "IN", "COMPANION", "POWER", "CYCLE",
|
||||
"STILL", "OFF IN", "DISABLED IN", "CONNECTING", "CHECK", "NETWORK", "NO",
|
||||
"STILL", "OFF IN", "DISABLED IN", "CONNECTING", "CHECK", "NETWORK", "NO", "PRESSING", "NO BUTTON THERE",
|
||||
"ON FOH BACKUP", "BUSY", "TAKING MIDI OUT", "PUTTING MIDI BACK", "TAKING", "PUTTING", "MIDI OUT", "MIDI BACK",
|
||||
],
|
||||
"a switch key (AUTO-FIX, or one like LIGHTS that hands a device to an operator)": [
|
||||
"OPERATOR", "MIDI OFF", "OFF IN SETTINGS", "SETTINGS", "TIL", "PAUSED",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -399,8 +538,14 @@ def key_record():
|
||||
kind, parts = "restart_key", parts[2:]
|
||||
elif p.startswith("restart_page."):
|
||||
kind, parts = "restart_page", parts[1:]
|
||||
elif p.startswith("ready_page."):
|
||||
kind, parts = "ready_page", parts[1:]
|
||||
elif p.startswith("ready."):
|
||||
kind, parts = "ready", parts[1:]
|
||||
elif p.startswith("auto_fix."):
|
||||
kind, parts = "auto", parts[1:]
|
||||
elif p.startswith("failover."):
|
||||
kind, parts = "failover", parts[1:]
|
||||
elif p.startswith("network."):
|
||||
kind, parts = "network", parts[1:]
|
||||
elif p.startswith("variables."):
|
||||
@@ -455,7 +600,7 @@ def validate(cfg, live=None):
|
||||
if errors:
|
||||
return errors, warnings
|
||||
ids, seen_pos = set(), {}
|
||||
kinds = {"master": 0, "service": 0, "network": 0}
|
||||
kinds = {"master": 0, "service": 0, "network": 0, "autofix": 0}
|
||||
fixes, hosts = cfg.get("fixes", {}), cfg.get("hosts", {})
|
||||
for t in cfg["tiles"]:
|
||||
tid = t.get("id", "")
|
||||
@@ -491,6 +636,16 @@ def validate(cfg, live=None):
|
||||
errors.append("%s: a ping needs an address or a connection" % cw)
|
||||
if c["type"] == "midi_peer" and not (c.get("session") and c.get("peer")):
|
||||
errors.append("%s: a MIDI link needs a session and a peer" % cw)
|
||||
if c["type"] == "midi_wired":
|
||||
nets = c.get("nets")
|
||||
if not (isinstance(nets, list) and nets):
|
||||
errors.append("%s: a wired-MIDI check needs the wired network(s), e.g. 10.1.0.0/16" % cw)
|
||||
else:
|
||||
for n in nets:
|
||||
try:
|
||||
ipaddress.ip_network(str(n), strict=False)
|
||||
except ValueError:
|
||||
errors.append("%s: %r is not a network (like 10.1.0.0/16)" % (cw, n))
|
||||
if c["type"] == "remote" and not (c.get("host") and c.get("cmd") and c.get("msg")):
|
||||
errors.append("%s: a remote check needs a computer, a command and the words to show" % cw)
|
||||
fx = c.get("fix")
|
||||
@@ -512,7 +667,14 @@ def validate(cfg, live=None):
|
||||
if m == t.get("id"):
|
||||
errors.append("key %s: is its own member" % t.get("id"))
|
||||
in_group = {m for t in cfg["tiles"] for m in t.get("members", [])}
|
||||
# A key moved to the Restart page (NA's ARENA, 2026-10-21): its checks still feed ALL and auto-fix,
|
||||
# and the Restart key watching it flashes, so it is seen.
|
||||
rk = (cfg.get("restart_page") or {}).get("keys") or []
|
||||
on_restart = {f for k in rk for f in [k.get("fix")] + list(k.get("watch") or [])}
|
||||
for t in cfg["tiles"]:
|
||||
fixes_of = {c.get("fix") for c in t.get("checks", []) if isinstance(c.get("fix"), str)} | {"conn:" + c["conn"] for c in t.get("checks", []) if c.get("conn")}
|
||||
if fixes_of & on_restart:
|
||||
continue
|
||||
if "pos" not in t and "members" not in t and t.get("id") not in in_group and t.get("kind") != "master":
|
||||
warnings.append("key %s has no place on the deck and is in no group: nobody will see it" % t.get("id"))
|
||||
if kinds["master"] != 1:
|
||||
@@ -521,6 +683,16 @@ def validate(cfg, live=None):
|
||||
errors.append("there must be exactly one ALARMS key (kind service); found %d" % kinds["service"])
|
||||
if kinds["network"] > 1:
|
||||
errors.append("at most one NET key (kind network); found %d" % kinds["network"])
|
||||
if kinds["autofix"] > 1:
|
||||
errors.append("at most one AUTO-FIX key (kind autofix); found %d" % kinds["autofix"])
|
||||
for t in cfg["tiles"]:
|
||||
if t.get("kind") == "bypass":
|
||||
b = t.get("bypass") if isinstance(t.get("bypass"), dict) else {}
|
||||
if not b.get("peers"):
|
||||
errors.append("key %s: a bypass key needs bypass.peers (the MIDI peers it takes out)" % t.get("id"))
|
||||
for h in b.get("hosts") or []:
|
||||
if h and h not in cfg.get("hosts", {}):
|
||||
errors.append("key %s: bypass host %s is not a computer in the settings" % (t.get("id"), h))
|
||||
for fid, fix in fixes.items():
|
||||
where = "fix %s" % fid
|
||||
kind = fix.get("kind", "ssh")
|
||||
@@ -544,10 +716,28 @@ def validate(cfg, live=None):
|
||||
errors.append("%s: needs the connection to reconnect" % where)
|
||||
if kind == "atem" and fix.get("part") not in ("audio", "camera"):
|
||||
errors.append("%s: part must be audio or camera" % where)
|
||||
if kind == "companion_press" and not (_whole(fix.get("page"), 1) and _whole(fix.get("row"), 0) and _whole(fix.get("col"), 0)):
|
||||
errors.append("%s: a Companion button needs its page, row and column" % where)
|
||||
if fix.get("failover") and fix.get("never_auto"):
|
||||
errors.append("%s: is a failover (runs by itself) and marked never to run by itself - one or the other" % where)
|
||||
if fix.get("anytime") and kind != "ready":
|
||||
errors.append("%s: only a READY page fix may run at any hour (anytime)" % where)
|
||||
if fix.get("anytime") and fix.get("never_auto"):
|
||||
errors.append("%s: runs at any hour (anytime) and never by itself - one or the other" % where)
|
||||
if fix.get("undo") and fix["undo"] not in fixes:
|
||||
errors.append("%s: the fix that switches back (undo) is unknown: %s" % (where, fix["undo"]))
|
||||
for k in fix:
|
||||
if k not in FIX_KIND_FIELDS.get(kind, []) and k not in ("kind", "short", "label", "restart_ok", "never_auto",
|
||||
"template", "params", "timeout"):
|
||||
if k not in FIX_KIND_FIELDS.get(kind, []) and k not in ("kind", "short", "label", "restart_ok", "never_auto", "anytime", "disabled",
|
||||
"template", "params", "timeout", "failover", "undo"):
|
||||
warnings.append("%s: %s is not a field of a %s fix" % (where, k, kind))
|
||||
named = set()
|
||||
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
|
||||
for c in t.get("checks", []):
|
||||
fx = c.get("fix")
|
||||
named |= set(fx.values()) if isinstance(fx, dict) else ({fx} if fx else set())
|
||||
for fid, fix in fixes.items():
|
||||
if fix.get("failover") and fid not in named:
|
||||
warnings.append("fix %s is a failover but no check runs it" % fid)
|
||||
for alias, h in hosts.items():
|
||||
if not re.fullmatch(r"[a-z0-9_]+", alias):
|
||||
errors.append("computer %s: the short name may only hold lowercase letters, digits and _" % alias)
|
||||
@@ -563,6 +753,29 @@ def validate(cfg, live=None):
|
||||
errors.append("key %s check %d: unknown computer %s" % (t.get("id"), i + 1, c["host"]))
|
||||
elif h.get("self"):
|
||||
errors.append("key %s check %d: %s is this Mac" % (t.get("id"), i + 1, c["host"]))
|
||||
# Network MIDI rides the wired network (2026-10-20): a link set to a Bonjour name or to an
|
||||
# address off it can come back over Wi-Fi or Dante, so it is refused once the MIDI key has the rule.
|
||||
nets = []
|
||||
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
|
||||
for c in t.get("checks", []):
|
||||
if c.get("type") == "midi_wired":
|
||||
for n in c.get("nets") or []:
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(str(n), strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
if nets:
|
||||
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
|
||||
for c in t.get("checks", []):
|
||||
if c.get("type") != "midi_peer" or c.get("disabled"):
|
||||
continue
|
||||
where = "key %s, MIDI link %s" % (t.get("id"), c.get("peer"))
|
||||
ip = sp.peer_ip(c.get("address"))
|
||||
if not c.get("address"):
|
||||
warnings.append("%s: no wired address - a reconnect may pick Wi-Fi or Dante" % where)
|
||||
elif ip is None or not any(ip in n for n in nets):
|
||||
errors.append("%s: %s is not on the wired network (%s) - give its wired address, like 10.1.10.206:5004"
|
||||
% (where, str(c["address"]).strip() or "?", ", ".join(str(n) for n in nets)))
|
||||
a = cfg.get("auto_fix") or {}
|
||||
for f in a.get("fixes", []):
|
||||
if f not in fixes:
|
||||
@@ -571,8 +784,11 @@ def validate(cfg, live=None):
|
||||
errors.append("auto-fix: %s is marked never to run by itself" % f)
|
||||
elif re.search(r"pkill|taskkill|killall", fixes[f].get("run", "")) and not fixes[f].get("restart_ok"):
|
||||
errors.append("auto-fix: %s closes a running program; tick 'may close a running program' or take it out" % f)
|
||||
elif fixes[f].get("failover"):
|
||||
warnings.append("auto-fix: %s is a failover - it already runs by itself as one" % f)
|
||||
_windows(cfg.get("service_windows"), "alarms window", errors)
|
||||
_windows(a.get("windows"), "auto-fix window", errors)
|
||||
_windows((cfg.get("failover") or {}).get("windows"), "failover window", errors)
|
||||
_windows((cfg.get("site") or {}).get("live_services"), "live services", errors)
|
||||
rp = cfg.get("restart_page")
|
||||
if rp:
|
||||
@@ -600,6 +816,51 @@ def validate(cfg, live=None):
|
||||
errors.append("the 'back to Status' key sits on %s" % rpos[tuple(rp["back_pos"])])
|
||||
if rp.get("page") == cfg.get("page"):
|
||||
errors.append("the Status page and the Restart page cannot be the same page")
|
||||
rd = cfg.get("ready_page")
|
||||
readies = [t for t in cfg["tiles"] if t.get("kind") == "ready"]
|
||||
if len(readies) > 1:
|
||||
errors.append("at most one READY key on the Status page; found %d" % len(readies))
|
||||
if rd:
|
||||
dpos = {}
|
||||
for k in rd.get("keys", []):
|
||||
kid = k.get("id", "")
|
||||
where = "READY key %s" % (kid or "?")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_]+", str(kid)):
|
||||
errors.append("%s: the id may only hold letters, digits and _" % where)
|
||||
if kid in ids:
|
||||
errors.append("%s: another key has that id" % where)
|
||||
ids.add(kid)
|
||||
if not k.get("label"):
|
||||
errors.append("%s: no label" % where)
|
||||
pos = pos_ok(k.get("pos"), where)
|
||||
if pos in dpos:
|
||||
errors.append("%s and %s share row %d column %d" % (dpos[pos], where, pos[0], pos[1]))
|
||||
dpos[pos] = where
|
||||
for i, c in enumerate(k.get("checks", [])):
|
||||
cw = "%s check %d" % (where, i + 1)
|
||||
if c.get("type") not in CHECKS:
|
||||
errors.append("%s: unknown check type %r" % (cw, c.get("type")))
|
||||
continue
|
||||
if c["type"] in ("ready", "ready_manual") and not c.get("item"):
|
||||
errors.append("%s: which checklist line (item)?" % cw)
|
||||
if c["type"] == "ready" and c.get("item") and c["item"] not in sp.READY_ITEMS:
|
||||
errors.append("%s: unknown checklist line %s" % (cw, c["item"]))
|
||||
fx = c.get("fix")
|
||||
for f in (fx.values() if isinstance(fx, dict) else [fx]):
|
||||
if f and f not in fixes and f not in (sp.RESTART_CONNECTION, sp.DANTE_AUTO):
|
||||
errors.append("%s: unknown fix %s" % (cw, f))
|
||||
if "back_pos" in rd:
|
||||
bp = pos_ok(rd["back_pos"], "the READY page's back key")
|
||||
if bp in dpos:
|
||||
errors.append("the 'back to Status' key sits on %s" % dpos[bp])
|
||||
if rd.get("page") in (cfg.get("page"), (rp or {}).get("page")):
|
||||
errors.append("the READY page cannot be the Status or Restart page")
|
||||
if not readies:
|
||||
warnings.append("the READY page has no READY key on the Status page: nobody can open it from there")
|
||||
_windows((cfg.get("ready") or {}).get("due"), "READY due window", errors)
|
||||
for fid, fix in fixes.items():
|
||||
if fix.get("kind") == "ready" and fix.get("action") not in sp.READY_ACTIONS:
|
||||
errors.append("fix %s: unknown READY action %s" % (fid, fix.get("action")))
|
||||
spo = cfg.get("spotify") or {}
|
||||
if any(c.get("type") == "spotify" for t in cfg["tiles"] for c in t.get("checks", [])):
|
||||
if not re.fullmatch(r"spotify:playlist:[A-Za-z0-9]+", str(spo.get("playlist", ""))):
|
||||
@@ -627,6 +888,11 @@ def validate(cfg, live=None):
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def _whole(v, lo):
|
||||
"""A whole number of at least lo (a bool is not one)."""
|
||||
return isinstance(v, int) and not isinstance(v, bool) and v >= lo
|
||||
|
||||
|
||||
def _get(cfg, path):
|
||||
"""The value at a plain path (no [] or *); None when absent."""
|
||||
if "[]" in path or ".*." in path:
|
||||
@@ -679,11 +945,15 @@ def phrases(cfg):
|
||||
"""The sentences the page and the logs build from the config."""
|
||||
a = cfg.get("auto_fix") or {}
|
||||
site = cfg.get("site") or {}
|
||||
fo_windows = (cfg.get("failover") or {}).get("windows", F["failover.windows"]["default"])
|
||||
return {
|
||||
"auto_fix": "may run by itself %s" % sp.windows_phrase(a.get("windows", [])) if a.get("windows") else "never runs by itself",
|
||||
"failover": "switches by itself %s" % sp.windows_phrase(fo_windows) if fo_windows else "never switches by itself",
|
||||
"live": ("changes wait while a HyperDeck is recording" + (" or %s" % sp.windows_phrase(site["live_services"])
|
||||
if site.get("live_services") else "")),
|
||||
"alarms": "Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves.",
|
||||
"alarms": ("Alarms are on every day. The ALARMS key silences them until midnight; AUTO-FIX pauses or resumes "
|
||||
"auto-fix on its own. Both come back by themselves." if any(t.get("kind") == "autofix" for t in cfg.get("tiles", []))
|
||||
else "Alarms are on every day. The ALARMS key silences them and stops every auto-fix until midnight, then both come back by themselves."),
|
||||
"hold": "HOLD %gs" % (cfg.get("page_hold_ms", 2000) / 1000.0),
|
||||
"ingest": site.get("ingest_name", "NA Ingest"),
|
||||
}
|
||||
|
||||
+21
-6
@@ -49,6 +49,8 @@ def build_hashes(cfg):
|
||||
out = {"status": sha(strip_notes(make_page.build(cfg)))}
|
||||
if cfg.get("restart_page"):
|
||||
out["restart"] = sha(strip_notes(make_page.build_restart(cfg)))
|
||||
if cfg.get("ready_page"):
|
||||
out["ready"] = sha(strip_notes(make_page.build_ready(cfg)))
|
||||
return out
|
||||
|
||||
|
||||
@@ -429,6 +431,9 @@ class Applier:
|
||||
elif name == "restart" and cfg.get("restart_page"):
|
||||
page, expected, fname = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"], "restart-page.companionconfig"
|
||||
export = make_page.build_restart(cfg)
|
||||
elif name == "ready" and cfg.get("ready_page"):
|
||||
page, expected, fname = cfg["ready_page"]["page"], cfg["ready_page"]["page_name"], "ready-page.companionconfig"
|
||||
export = make_page.build_ready(cfg)
|
||||
else:
|
||||
continue
|
||||
path = os.path.join(out_dir, fname)
|
||||
@@ -438,13 +443,20 @@ class Applier:
|
||||
if not ok:
|
||||
return False, "page %d: %s" % (page, why)
|
||||
push_page.push(host, port, page, path, expected)
|
||||
after = push_page.fetch_page(host, port, page)
|
||||
want = {(r, c) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if ctl and ctl.get("type") == "button-layered"}
|
||||
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
|
||||
# Companion places the keys a moment after the import call returns (Southcoast's first setup
|
||||
# was judged too early, 2026-10-21): look again for up to 10 s before calling it a failure.
|
||||
for _ in range(20):
|
||||
after = push_page.fetch_page(host, port, page)
|
||||
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
|
||||
if want == have:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
if want != have:
|
||||
return False, "page %d: the import did not land as built (%d keys built, %d on the page)" % (page, len(want), len(have))
|
||||
done.append(name)
|
||||
return True, ", ".join("page %d" % (cfg["page"] if n == "status" else cfg["restart_page"]["page"]) for n in done) or "nothing to import"
|
||||
number = {"status": cfg["page"], "restart": (cfg.get("restart_page") or {}).get("page"), "ready": (cfg.get("ready_page") or {}).get("page")}
|
||||
return True, ", ".join("page %s" % number.get(n) for n in done) or "nothing to import"
|
||||
|
||||
def colour_nav_keys(self, cfg):
|
||||
comp = cfg["companion"]
|
||||
@@ -488,8 +500,11 @@ class Applier:
|
||||
|
||||
def _pipeline(self, new_cfg, note, by, address, atem_file):
|
||||
site = self.site
|
||||
old_cfg = site.load()
|
||||
site.load()
|
||||
sha_before = site.cfg_sha
|
||||
# From the file, not the cached object: a caller that edited site.load()'s result in place would
|
||||
# otherwise compare the new settings with themselves and skip the watchdog restart (2026-10-21).
|
||||
old_cfg = sp.load_config(site.config_path)
|
||||
if self.status.get("emergency"):
|
||||
self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason")))
|
||||
# 1. check
|
||||
@@ -558,7 +573,7 @@ class Applier:
|
||||
# 4. pages
|
||||
if plan["pages"]:
|
||||
self.step("pages", "running")
|
||||
ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
|
||||
ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart", "ready")])
|
||||
if not ok:
|
||||
self.step("pages", "failed", detail)
|
||||
sp.write_json(os.path.join(site.data_dir, "pages-out-of-sync.json"), {"when": time.time(), "detail": detail})
|
||||
@@ -593,7 +608,7 @@ class Applier:
|
||||
ok, detail = self.restart_watchdog(site.cfg_sha)
|
||||
pages_note = ""
|
||||
if ok and plan.get("pages"):
|
||||
pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
|
||||
pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart", "ready")])
|
||||
pages_note = "; pages put back" if pok else "; the pages could not be put back: " + pdetail
|
||||
self.step("rollback", "ok" if ok else "failed", ("previous settings restored, " + detail + pages_note) if ok else detail)
|
||||
self.status["state"] = "failed"
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
"""Auto-setup: what a location has (settings/discover.py's inventory) becomes its keys, checks, fixes
|
||||
and page layout - the same building blocks North Attleboro's were made of by hand.
|
||||
|
||||
Pure functions, no I/O:
|
||||
|
||||
build(base, inv, location, pin) a whole config for a new location, from the empty template
|
||||
extend(cfg, inv, seen) a running location: only what is new (never removes anything)
|
||||
|
||||
Everything that comes out passes schema.validate and can be changed on the settings page like any
|
||||
hand-made setting. Gear auto-setup does not know (a generic connection, an app it has no recipe
|
||||
for) is reported, not guessed at.
|
||||
"""
|
||||
import copy
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
|
||||
from settings import builders as b
|
||||
import status_panel as sp
|
||||
|
||||
# --- what auto-setup knows -------------------------------------------------------------
|
||||
|
||||
# Group keys sum up their members on a big location, in this order along the top row.
|
||||
GROUPS = ["REC", "AUDIO", "RF", "SHOW", "VIDEO", "STREAM"]
|
||||
# Who gets a cell of its own when not everything fits (the rest show inside their group key only):
|
||||
# a row of wireless receivers folds first, as at North Attleboro.
|
||||
PRIORITY = ["REC", "SHOW", "AUDIO", "STREAM", "VIDEO", "RF"]
|
||||
|
||||
# Companion modules: which group, what kind of key, the key's text when there is only one.
|
||||
COMPANION = {
|
||||
"bmd-hyperdeck": {"group": "REC", "kind": "hyperdeck", "label": "HD"},
|
||||
"tascam-da-6400": {"group": "REC", "kind": "tascam", "label": "TASCAM"},
|
||||
"audinate-dantecontroller": {"group": "AUDIO", "kind": "dante", "label": "DANTE"},
|
||||
"behringer-x32": {"group": "AUDIO", "kind": "conn", "label": "X32"},
|
||||
"behringer-wing": {"group": "AUDIO", "kind": "conn", "label": "WING"},
|
||||
"midas-m32": {"group": "AUDIO", "kind": "conn", "label": "M32"},
|
||||
"allenheath-sq": {"group": "AUDIO", "kind": "conn", "label": "SQ"},
|
||||
"allenheath-dlive": {"group": "AUDIO", "kind": "conn", "label": "DLIVE"},
|
||||
"allenheath-avantis": {"group": "AUDIO", "kind": "conn", "label": "AVANTIS"},
|
||||
"yamaha-rcp": {"group": "AUDIO", "kind": "conn", "label": "YAMAHA"},
|
||||
"soundcraft-ui": {"group": "AUDIO", "kind": "conn", "label": "UI24"},
|
||||
"digico-sd": {"group": "AUDIO", "kind": "conn", "label": "DIGICO"},
|
||||
"shure-wireless": {"group": "RF", "kind": "shure", "label": "MICS"},
|
||||
"shure-psm1000": {"group": "RF", "kind": "psm", "label": "PSM"},
|
||||
"renewedvision-propresenter-api": {"group": "SHOW", "kind": "conn", "label": "PROP", "app": "propresenter"},
|
||||
"ableton-osc": {"group": "SHOW", "kind": "conn", "label": "ABLETON", "app": "ableton"},
|
||||
"leolabs-ableset": {"group": "SHOW", "kind": "conn", "label": "ABLESET", "app": "ableton"},
|
||||
"bmd-atem": {"group": "VIDEO", "kind": "conn", "label": "ATEM"},
|
||||
"bmd-videohub": {"group": "VIDEO", "kind": "conn", "label": "VIDEOHUB"},
|
||||
"resolume-arena": {"group": "VIDEO", "kind": "conn", "label": "ARENA", "app": "arena"},
|
||||
"novastar-controller": {"group": "VIDEO", "kind": "conn", "label": "LED", "ping": True},
|
||||
"obs-studio": {"group": "STREAM", "kind": "conn", "label": "OBS", "app": "obs"},
|
||||
"resi-decoders": {"group": "STREAM", "kind": "conn", "label": "RESI", "ping": True},
|
||||
}
|
||||
|
||||
# Programs on the computers: by OS, the process (Mac, as pgrep sees it) or program file (Windows).
|
||||
# "app" ties a program to a Companion connection of the same name; "restart" adds a Restart page key.
|
||||
MAC_APPS = [
|
||||
{"app": "propresenter", "process": "ProPresenter", "label": "PROP", "open": "/Applications/ProPresenter*.app", "restart": True},
|
||||
{"app": "ableton", "process": "Live", "label": "ABLETON", "open": "/Applications/Ableton Live*.app", "ableton": True, "restart": True},
|
||||
{"app": "arena", "process": "Arena", "label": "ARENA", "open": "/Applications/Resolume Arena*/Arena.app", "restart": True},
|
||||
{"app": "obs", "process": "obs", "label": "OBS", "open": "/Applications/OBS.app", "restart": True},
|
||||
{"app": "translate", "process": "Church Translate", "label": "TRANSLATE", "open": "/Applications/Church Translate.app", "restart": True},
|
||||
{"app": "protools", "process": "Pro Tools", "label": "PRO TOOLS", "open": "/Applications/Pro Tools.app", "restart": True},
|
||||
{"app": "vista", "process": "Vista", "label": "VISTA", "open": "/Applications/Vista*.app", "restart": True},
|
||||
{"app": "superrack", "process": "SuperRack SoundGrid", "label": "SUPERRACK", "open": "/Applications/*SuperRack*.app", "restart": True, "group": "AUDIO"},
|
||||
{"app": "x32edit", "process": "X32-Edit", "label": "X32 EDIT", "open": "/Applications/X32-Edit.app", "restart": False, "group": "AUDIO"},
|
||||
]
|
||||
WIN_APPS = [
|
||||
{"app": "vista", "exe": "Vista.exe", "label": "VISTA", "task": "Start Vista", "restart": True},
|
||||
{"app": "arena", "exe": "Arena.exe", "label": "ARENA", "task": "Start Arena", "restart": True},
|
||||
{"app": "obs", "exe": "obs64.exe", "label": "OBS", "task": "Start OBS", "restart": True},
|
||||
{"app": "propresenter", "exe": "ProPresenter.exe", "label": "PROP", "task": "Start ProPresenter", "restart": True},
|
||||
{"app": "rtpmidi", "exe": "rtpMIDI.exe", "label": "RTPMIDI", "task": "Start rtpMIDI", "restart": False, "fold": "midi_wired"},
|
||||
]
|
||||
# The task names the Windows join script makes, by program file (it reads WIN_APPS through campus.py).
|
||||
WIN_TASKS = {a["exe"]: a["task"] for a in WIN_APPS}
|
||||
|
||||
# Fixes shared by every key of a kind (the same words North Attleboro's carry).
|
||||
SHARED_FIXES = {
|
||||
"hyperdeck": {"kind": "hyperdeck", "template": "hyperdeck", "params": {}, "short": "HYPERDECK", "verify_seconds": 15,
|
||||
"hung_note": "STILL OFFLINE\nHOLD AGAIN",
|
||||
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck"},
|
||||
"midi_reconnect": {"kind": "midi_connect", "template": "midi_connect", "params": {}, "short": "RECONNECT",
|
||||
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address"},
|
||||
}
|
||||
|
||||
STATUS_ROWS, COLS = 4, 8
|
||||
# North Attleboro's auto-fix hours, for every location: Tuesday and Wednesday rehearsals, Sunday services.
|
||||
DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
|
||||
"restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"},
|
||||
{"days": ["wed"], "start": "09:00", "end": "21:00"},
|
||||
{"days": ["sun"], "start": "05:00", "end": "14:00"}]}
|
||||
|
||||
|
||||
# --- small helpers ---------------------------------------------------------------------
|
||||
|
||||
def slug(text, taken=()):
|
||||
base = re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_") or "key"
|
||||
out, n = base, 2
|
||||
while out in taken:
|
||||
out, n = "%s_%d" % (base, n), n + 1
|
||||
return out
|
||||
|
||||
|
||||
def key_text(label, limit=10):
|
||||
t = re.sub(r"[_\s]+", " ", str(label)).strip().upper()
|
||||
return t if len(t) <= limit else t[:limit].rstrip()
|
||||
|
||||
|
||||
def tag_for(name):
|
||||
return key_text(name, 12)
|
||||
|
||||
|
||||
def ip_of(address):
|
||||
ip = sp.peer_ip(address)
|
||||
return str(ip) if ip else None
|
||||
|
||||
|
||||
def in_nets(nets, address):
|
||||
ip = sp.peer_ip(address)
|
||||
return ip is not None and any(ip in ipaddress.ip_network(n, strict=False) for n in nets)
|
||||
|
||||
|
||||
def computer_by_ip(inv, ip):
|
||||
for alias, c in (inv.get("computers") or {}).items():
|
||||
if ip and ip in (c.get("ips") or []):
|
||||
return alias
|
||||
return None
|
||||
|
||||
|
||||
def referenced(cfg):
|
||||
"""Everything a config already watches, by the same identities discovery uses."""
|
||||
out = set()
|
||||
for t in sp.all_tiles(cfg):
|
||||
for c in t.get("checks", []):
|
||||
if c.get("conn"):
|
||||
out.add("conn:" + c["conn"])
|
||||
if c.get("type") == "dante":
|
||||
out.update("dante:" + d for d in (c.get("devices") or {}))
|
||||
if c.get("type") == "midi_peer":
|
||||
out.add("midi:%s|%s|%s" % (c.get("host", ""), c.get("session"), c.get("peer")))
|
||||
if c.get("type") == "midi_wired":
|
||||
out.add("midi_wired")
|
||||
p = c.get("params") or {}
|
||||
if c.get("type") == "remote" and p.get("host") and (p.get("process") or p.get("exe")):
|
||||
out.add("app:%s|%s" % (p["host"], p.get("process") or p.get("exe")))
|
||||
for alias, h in (cfg.get("hosts") or {}).items():
|
||||
out.add("host:" + alias)
|
||||
ip = str(h.get("ssh", "")).split("@")[-1]
|
||||
if ip:
|
||||
out.add("hostip:" + ip)
|
||||
return out
|
||||
|
||||
|
||||
# --- from the inventory to items ------------------------------------------------------
|
||||
|
||||
def items(inv, nets):
|
||||
"""[{ident, group, label, checks, fixes, restart, also_idents}] for everything auto-setup can watch,
|
||||
plus the computer records ({alias: host}). Each item is one key."""
|
||||
out, hosts = [], {}
|
||||
comps = inv.get("computers") or {}
|
||||
for alias, c in comps.items():
|
||||
if not c.get("reachable"):
|
||||
continue
|
||||
hosts[alias] = {"name": c.get("name") or alias, "os": c.get("os", "mac"), "ssh": "%s@%s" % (c["user"], c["ip"]),
|
||||
"role": "found by auto-setup", "runs": [{"kind": "sshd"}]}
|
||||
if c.get("macs"):
|
||||
hosts[alias]["mac"] = c["macs"][0]
|
||||
# Programs on the computers, by app, so a Companion connection to the same program joins its key.
|
||||
apps_at = {} # (app, ip) -> item
|
||||
for alias, c in comps.items():
|
||||
if not c.get("reachable"):
|
||||
continue
|
||||
procs = set(c.get("procs") or [])
|
||||
catalog = MAC_APPS if c.get("os", "mac") == "mac" else WIN_APPS
|
||||
for a in catalog:
|
||||
if c.get("os", "mac") == "mac":
|
||||
if a["process"] not in procs:
|
||||
continue
|
||||
app_path = (c.get("paths") or {}).get(a["process"]) or newest_app(c.get("apps") or [], a["open"])
|
||||
if not app_path:
|
||||
continue
|
||||
check = {"type": "remote", "template": "mac_program", "params": {"host": alias, "process": a["process"]},
|
||||
"msg": "%s NOT RUNNING" % a["label"]}
|
||||
if a.get("ableton"):
|
||||
start = {"template": "ableton", "params": {"host": alias, "app": app_path.rsplit("/", 1)[-1][:-4], "restart": False}}
|
||||
again = {"template": "ableton", "params": {"host": alias, "app": app_path.rsplit("/", 1)[-1][:-4], "restart": True}}
|
||||
else:
|
||||
start = {"template": "mac_start", "params": {"host": alias, "process": a["process"], "app": app_path}}
|
||||
again = {"template": "mac_restart", "params": {"host": alias, "process": a["process"], "app": app_path,
|
||||
"wait_seconds": 10, "force": True}}
|
||||
ident = "app:%s|%s" % (alias, a["process"])
|
||||
else:
|
||||
if a["exe"] not in procs or a["task"] not in (c.get("tasks") or []):
|
||||
continue # no start task on that PC: a hold could not start it
|
||||
check = {"type": "remote", "template": "win_program", "params": {"host": alias, "exe": a["exe"]},
|
||||
"msg": "%s NOT RUNNING" % a["label"]}
|
||||
start = {"template": "win_start", "params": {"host": alias, "task": a["task"], "exe": a["exe"]}}
|
||||
again = {"template": "win_restart", "params": {"host": alias, "task": a["task"], "exe": a["exe"]}}
|
||||
ident = "app:%s|%s" % (alias, a["exe"])
|
||||
fid = slug("%s_%s" % (a["app"], alias))
|
||||
pretty = {"propresenter": "ProPresenter", "ableton": "Ableton", "arena": "Resolume Arena", "obs": "OBS", "translate": "Church Translate",
|
||||
"protools": "Pro Tools", "vista": "Vista", "rtpmidi": "rtpMIDI", "superrack": "SuperRack", "x32edit": "X32-Edit"}.get(a["app"], a["label"].title())
|
||||
where = c.get("name") or alias
|
||||
start = dict(start, label="start %s on %s if it is not running" % (pretty, where), short=a["label"])
|
||||
again = dict(again, label="restart %s on %s (closes it first)" % (pretty, where), short=a["label"])
|
||||
item = {"ident": ident, "group": "SHOW" if a["app"] not in ("obs",) else "STREAM", "label": a["label"],
|
||||
"checks": [dict(check, fix=fid)], "fixes": {fid: start}, "restart": [], "also": [], "fold": a.get("fold")}
|
||||
if a.get("restart"):
|
||||
rid = "restart_" + fid
|
||||
item["fixes"][rid] = again
|
||||
item["restart"].append({"label": a["label"], "fix": rid})
|
||||
if a["app"] == "arena":
|
||||
item["group"] = "VIDEO"
|
||||
if a.get("group"):
|
||||
item["group"] = a["group"]
|
||||
if a["app"] == "superrack": # and its link to the Waves SoundGrid server
|
||||
item["checks"].append({"type": "remote", "template": "soundgrid", "params": {"host": alias}, "msg": "SOUNDGRID LOST"})
|
||||
out.append(item)
|
||||
apps_at[(a["app"], c.get("ip"))] = item
|
||||
for ip in c.get("ips") or []:
|
||||
apps_at.setdefault((a["app"], ip), item)
|
||||
# Programs on this Mac itself (the Companion Mac): a local process check and local start/restart fixes.
|
||||
ing = inv.get("ingest") or {}
|
||||
for a in MAC_APPS:
|
||||
exe = (ing.get("exes") or {}).get(a["process"])
|
||||
app_path = (ing.get("paths") or {}).get(a["process"])
|
||||
if not exe or not app_path:
|
||||
continue
|
||||
fid = slug("%s_ingest" % a["app"])
|
||||
pretty = a["label"].title() if a["app"] not in ("propresenter", "ableton") else {"propresenter": "ProPresenter", "ableton": "Ableton"}[a["app"]]
|
||||
# Any installed copy counts, and the newest opens: an update that installs beside the old one
|
||||
# ("ProPresenter 20.0.1.app" and "ProPresenter.app" at Woonsocket) never reads as stopped.
|
||||
newest = a["open"] if "*" in a.get("open", "") else ""
|
||||
if newest:
|
||||
exe = b.app_match(newest, a["process"])
|
||||
opens = {"app": "$APP", "newest_of": newest} if newest else {"app": app_path}
|
||||
start = {"template": "mac_start", "params": dict({"host": "ingest", "process": a["process"]}, **opens),
|
||||
"label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]}
|
||||
again = {"template": "mac_restart", "params": dict({"host": "ingest", "process": a["process"], "wait_seconds": 10, "force": True}, **opens),
|
||||
"label": "restart %s on this Mac (closes it first)" % pretty, "short": a["label"]}
|
||||
item = {"ident": "app:ingest|%s" % a["process"], "group": a.get("group") or ("VIDEO" if a["app"] == "arena" else "STREAM" if a["app"] == "obs" else "SHOW"),
|
||||
"label": a["label"], "checks": [{"type": "process", "match": exe, "name": a["label"], "fix": fid}],
|
||||
"fixes": {fid: start, "restart_" + fid: again}, "restart": [{"label": a["label"], "fix": "restart_" + fid}] if a.get("restart") else [],
|
||||
"also": [], "fold": a.get("fold")}
|
||||
out.append(item)
|
||||
for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) + ["127.0.0.1"]:
|
||||
if ip:
|
||||
apps_at.setdefault((a["app"], ip), item)
|
||||
# Companion connections.
|
||||
counts = {}
|
||||
for conn in inv.get("connections") or []:
|
||||
counts[conn.get("module")] = counts.get(conn.get("module"), 0) + 1
|
||||
hd_n = 0
|
||||
for conn in sorted(inv.get("connections") or [], key=lambda c: c["label"]):
|
||||
spec = COMPANION.get(conn.get("module"))
|
||||
if not spec or not conn.get("enabled", True):
|
||||
continue
|
||||
label = conn["label"]
|
||||
ident = "conn:" + label
|
||||
many = counts.get(conn["module"], 0) > 1
|
||||
text = spec["label"] if not many else key_text(label)
|
||||
if conn.get("module") == "behringer-x32" and "m32" in label.lower() and not many:
|
||||
text = "M32" # a Midas M32 on the same module (Woonsocket)
|
||||
if spec["kind"] == "hyperdeck":
|
||||
hd_n += 1
|
||||
text = "HD%d" % hd_n if many else "HD"
|
||||
out.append({"ident": ident, "group": "REC", "label": text, "restart": [], "also": [],
|
||||
"checks": [{"type": "hyperdeck", "conn": label, "fix": {"conn": "hyperdeck"}}],
|
||||
"fixes": {"hyperdeck": dict(SHARED_FIXES["hyperdeck"])}})
|
||||
continue
|
||||
if spec["kind"] == "tascam":
|
||||
fid = slug("reconnect_" + label)
|
||||
out.append({"ident": ident, "group": "REC", "label": text, "restart": [], "also": [],
|
||||
"checks": [{"type": "tascam", "conn": label, "fix": fid}, {"type": "ping", "conn": label, "name": text}],
|
||||
"fixes": {fid: {"kind": "reconnect", "template": "reconnect", "params": {}, "conn": label, "short": text,
|
||||
"verify_seconds": 20, "label": "reconnect Companion to %s" % label}}})
|
||||
continue
|
||||
if spec["kind"] == "dante":
|
||||
devices = {d: key_text(d, 12) for d in (inv.get("dante") or {}).get("devices") or []}
|
||||
out.append({"ident": ident, "group": "AUDIO", "label": "DANTE", "restart": [], "also": ["dante:" + d for d in devices],
|
||||
"checks": [{"type": "dante", "conn": label, "devices": devices, "fix": {"*": sp.DANTE_AUTO}}], "fixes": {}})
|
||||
continue
|
||||
if spec["kind"] in ("shure", "psm"):
|
||||
n = int(conn.get("channels") or (4 if spec["kind"] == "shure" else 2))
|
||||
out.append({"ident": ident, "group": "RF", "label": text if many else ("MICS" if spec["kind"] == "shure" else "PSM"),
|
||||
"restart": [], "also": [],
|
||||
"checks": [{"type": spec["kind"], "conn": label, "channels": n}, {"type": "ping", "conn": label}], "fixes": {}})
|
||||
continue
|
||||
checks = [{"type": "conn", "conn": label}]
|
||||
fixes = {}
|
||||
if conn.get("module") == "renewedvision-propresenter-api":
|
||||
# ProPresenter changes its network port now and then: the fix finds the new one.
|
||||
fid = slug("prop_port_" + label)
|
||||
where = computer_by_ip(inv, ip_of(conn.get("host")) or conn.get("host"))
|
||||
fixes[fid] = dict({"kind": "propresenter_port", "template": "propresenter_port", "params": {}, "conn": label, "short": "PROP PORT",
|
||||
"label": "find ProPresenter's network port and reconnect Companion's %s to it" % label}, **({"on": where} if where else {}))
|
||||
checks[0]["fix"] = fid
|
||||
if spec.get("ping") and conn.get("host"):
|
||||
checks.append({"type": "ping", "conn": label})
|
||||
app_item = apps_at.get((spec.get("app"), ip_of(conn.get("host")))) if spec.get("app") else None
|
||||
if app_item: # the program's own key carries its connection too
|
||||
app_item["checks"] = checks + app_item["checks"]
|
||||
app_item["fixes"].update(fixes)
|
||||
app_item["also"].append(ident)
|
||||
continue
|
||||
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": fixes, "restart": [], "also": []})
|
||||
# Network MIDI: every peer in the sessions here and on the other Macs, at its wired address.
|
||||
midi_checks = []
|
||||
# Whose session a peer is, by its name: a peer joined by name or by an IPv6 address gets that Mac's wired address.
|
||||
owner = {sname: (alias or "ingest") for alias, sessions in (inv.get("midi") or {}).items() for sname in (sessions or {})}
|
||||
ing = inv.get("ingest") or {}
|
||||
ing_ips = [ip for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) if ip]
|
||||
for host, sessions in (inv.get("midi") or {}).items():
|
||||
for sname, sess in (sessions or {}).items():
|
||||
for p in sess.get("peers") or []:
|
||||
addr = p.get("address") or ""
|
||||
if not in_nets(nets, addr):
|
||||
alias = owner.get(p.get("name")) or computer_named(inv, p.get("name"))
|
||||
ips = ing_ips if alias == "ingest" else (comps.get(alias) or {}).get("ips") or [] if alias else []
|
||||
ip = next((i for i in ips if in_nets(nets, i)), None)
|
||||
if not ip:
|
||||
continue # no wired address known: the wired check will name it
|
||||
port = (((inv.get("midi") or {}).get("" if alias == "ingest" else alias) or {}).get(p.get("name")) or {}).get("port") or 5004
|
||||
addr = "%s:%d" % (ip, port)
|
||||
chk = {"type": "midi_peer", "session": sname, "peer": p["name"], "address": addr, "fix": "midi_reconnect",
|
||||
"tag": tag_for(p["name"])}
|
||||
if host:
|
||||
chk["host"] = host
|
||||
midi_checks.append(("midi:%s|%s|%s" % (host, sname, p["name"]), chk))
|
||||
# One check per link, not one per end (2026-10-20: two links pulled at Woonsocket read "4 DOWN"):
|
||||
# keep the end whose Mac says whether the link is live (newer macOS reports a peer's state).
|
||||
def end_of(host):
|
||||
return host or "ingest"
|
||||
|
||||
def reports_state(host, sname):
|
||||
return any("state" in p for p in (((inv.get("midi") or {}).get(host) or {}).get(sname) or {}).get("peers") or [])
|
||||
by_pair = {}
|
||||
for ident, c in midi_checks:
|
||||
ip = ip_of(c["address"])
|
||||
other = owner.get(c["peer"]) or ("ingest" if ip and ip in ing_ips else None) or computer_by_ip(inv, ip) or c["peer"]
|
||||
pair = frozenset((end_of(c.get("host", "")), other))
|
||||
best = by_pair.get(pair)
|
||||
if best is None or (reports_state(c.get("host", ""), c["session"]) and not reports_state(best[1].get("host", ""), best[1]["session"])):
|
||||
by_pair[pair] = (ident, c)
|
||||
midi_checks = [x for x in midi_checks if x in by_pair.values()]
|
||||
if midi_checks:
|
||||
checks = [c for _, c in midi_checks] + [{"type": "midi_wired", "nets": list(nets), "fix": "midi_reconnect"}]
|
||||
out.append({"ident": "midi_wired", "group": "SHOW", "label": "MIDI", "checks": checks,
|
||||
"fixes": {"midi_reconnect": dict(SHARED_FIXES["midi_reconnect"])}, "restart": [],
|
||||
"also": [i for i, _ in midi_checks]})
|
||||
# A helper program (rtpMIDI) rides inside the key it serves when that key exists.
|
||||
for it in [x for x in out if x.get("fold")]:
|
||||
home = next((x for x in out if x["ident"] == it["fold"]), None)
|
||||
if home is not None:
|
||||
home["checks"] += it["checks"]
|
||||
home["fixes"].update(it["fixes"])
|
||||
home["also"].append(it["ident"])
|
||||
home.setdefault("folded", {})[it["ident"]] = {"checks": it["checks"], "fixes": it["fixes"], "label": it["label"]}
|
||||
out.remove(it)
|
||||
return out, hosts
|
||||
|
||||
|
||||
def computer_named(inv, name):
|
||||
n = str(name or "").lower()
|
||||
for alias, c in (inv.get("computers") or {}).items():
|
||||
if n and n in (str(c.get("name", "")).lower(), str(c.get("hostname", "")).lower().replace(".local", "")):
|
||||
return alias
|
||||
return None
|
||||
|
||||
|
||||
def newest_app(apps, pattern):
|
||||
"""The newest /Applications entry matching a glob-ish pattern ("/Applications/ProPresenter*.app")."""
|
||||
rx = re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$")
|
||||
hits = sorted(a for a in apps if rx.match(a))
|
||||
return hits[-1] if hits else None
|
||||
|
||||
|
||||
# --- layout ----------------------------------------------------------------------------
|
||||
|
||||
def free_cells(taken, rows=STATUS_ROWS):
|
||||
return [(r, c) for r in range(rows) for c in range(1, COLS) if (r, c) not in taken]
|
||||
|
||||
|
||||
def layout(cfg, new_tiles, restart_items):
|
||||
"""Put the keys on the Status page: everything fits, so no group keys; otherwise group keys along
|
||||
the top row and the members after them (a member with no room left shows inside its group only)."""
|
||||
fixed = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
|
||||
if cfg.get("restart_page"):
|
||||
fixed.add(tuple(cfg["restart_page"]["jump_pos"]))
|
||||
cells = free_cells(fixed)
|
||||
groups = [g for g in GROUPS if any(t["_group"] == g for t in new_tiles)]
|
||||
if len(new_tiles) > len(cells) and len(groups) > 1:
|
||||
for g in groups:
|
||||
members = [t["id"] for t in new_tiles if t["_group"] == g]
|
||||
cfg["tiles"].append({"id": slug(g, {t["id"] for t in cfg["tiles"]} | {t["id"] for t in new_tiles}),
|
||||
"label": g, "members": members, "_group": g})
|
||||
group_tiles = [t for t in cfg["tiles"] if t.get("_group")]
|
||||
for t in group_tiles:
|
||||
if cells:
|
||||
t["pos"] = list(cells.pop(0))
|
||||
# Who gets a cell: by priority while cells last; then placed in group order, so a group reads as a block.
|
||||
chosen = set()
|
||||
for g in PRIORITY:
|
||||
for t in new_tiles:
|
||||
if t["_group"] == g and len(chosen) < len(cells):
|
||||
chosen.add(t["id"])
|
||||
for t in new_tiles:
|
||||
if t["id"] in chosen:
|
||||
t["pos"] = list(cells.pop(0))
|
||||
cfg["tiles"].append(t)
|
||||
for t in cfg["tiles"]:
|
||||
t.pop("_group", None)
|
||||
|
||||
|
||||
def place_new(cfg, tile):
|
||||
"""One more key on a running location: the first free cell, else inside its group, else on ALL only."""
|
||||
taken = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
|
||||
if cfg.get("restart_page"):
|
||||
taken.add(tuple(cfg["restart_page"]["jump_pos"]))
|
||||
cells = free_cells(taken)
|
||||
group = next((t for t in cfg["tiles"] if t.get("label") == tile.get("_group") and t.get("members") is not None), None)
|
||||
if cells:
|
||||
tile["pos"] = list(cells[0])
|
||||
if group is not None:
|
||||
group["members"].append(tile["id"])
|
||||
tile.pop("_group", None)
|
||||
cfg["tiles"].append(tile)
|
||||
|
||||
|
||||
def restart_layout(cfg, restart_items):
|
||||
"""The Restart page always exists (a location set up before its computers joined gets their keys later)."""
|
||||
rp = cfg.get("restart_page")
|
||||
if not rp:
|
||||
return
|
||||
taken = {tuple(rp["back_pos"])} | {tuple(k["pos"]) for k in rp.get("keys", [])}
|
||||
cells = free_cells(taken)
|
||||
ids = {k["id"] for k in rp.get("keys", [])}
|
||||
for it in restart_items:
|
||||
kid = slug("rs_" + it["label"], ids)
|
||||
ids.add(kid)
|
||||
if not cells:
|
||||
break
|
||||
rp.setdefault("keys", []).append({"id": kid, "label": key_text(it["label"]), "fix": it["fix"], "pos": list(cells.pop(0))})
|
||||
|
||||
|
||||
# --- the two entry points --------------------------------------------------------------
|
||||
|
||||
def nets_of(inv):
|
||||
w = (inv.get("ingest") or {}).get("wired") or {}
|
||||
return [w["net"]] if w.get("net") else []
|
||||
|
||||
|
||||
def build(base, inv, campus, pin, pages=(19, 20)):
|
||||
"""A whole config for a new location: the template, this Mac, and a key for everything found."""
|
||||
cfg = copy.deepcopy(base)
|
||||
ing = inv.get("ingest") or {}
|
||||
wired = ing.get("wired") or {}
|
||||
site = cfg["site"]
|
||||
site.update({"id": campus["id"], "name": campus["name"], "ingest_name": ing.get("name") or "%s Ingest" % campus["name"],
|
||||
"ingest_hostname": ing.get("hostname") or "", "ingest_ips": [ip for ip in [wired.get("ip")] + list(ing.get("ips") or []) if ip],
|
||||
"editing_code": pin})
|
||||
site["ingest_ips"] = list(dict.fromkeys(site["ingest_ips"]))
|
||||
cfg["discover"] = {"auto": True}
|
||||
cfg["page"], cfg["page_name"] = pages[0], "%d\\nStatus" % pages[0]
|
||||
cfg["restart_page"] = {"page": pages[1], "page_name": "%d\\nRestart" % pages[1], "jump_pos": [3, 6], "back_pos": [3, 0], "keys": []}
|
||||
cfg["network"] = dict(cfg.get("network") or {}, interface=wired.get("interface") or "en0", gateway=wired.get("gateway") or "")
|
||||
cfg["service_windows"] = [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}]
|
||||
cfg["hosts"]["ingest"]["name"] = site["ingest_name"]
|
||||
for t in cfg["tiles"]:
|
||||
if t.get("kind") == "network":
|
||||
t["pos"] = [3, 7]
|
||||
found, hosts = items(inv, nets_of(inv))
|
||||
cfg["hosts"].update(hosts)
|
||||
tiles, restart, taken = [], [], {t["id"] for t in cfg["tiles"]}
|
||||
for it in found:
|
||||
tid = slug(it["label"], taken)
|
||||
taken.add(tid)
|
||||
tiles.append({"id": tid, "label": key_text(it["label"]), "checks": it["checks"], "_group": it["group"]})
|
||||
for fid, f in it["fixes"].items():
|
||||
cfg["fixes"].setdefault(fid, f)
|
||||
restart += it["restart"]
|
||||
tiles.sort(key=lambda t: GROUPS.index(t["_group"]))
|
||||
layout(cfg, tiles, restart)
|
||||
restart_layout(cfg, restart)
|
||||
finish(cfg)
|
||||
a = dict(DEFAULT_AUTO_FIX, **{k: v for k, v in (cfg.get("auto_fix") or {}).items() if k not in ("windows", "fixes")})
|
||||
a["fixes"] = gentle(cfg)
|
||||
cfg["auto_fix"] = a
|
||||
return cfg, [it["ident"] for it in found] + ["host:" + h for h in hosts] + [x for it in found for x in it["also"]]
|
||||
|
||||
|
||||
def extend(cfg, inv, seen):
|
||||
"""What is new since the last look, added to a running location. Never removes or changes what is
|
||||
there; a thing seen before (and so deliberately taken off by someone) is not added again.
|
||||
Returns (new config, [what was added, in words], [identities now seen])."""
|
||||
cfg = copy.deepcopy(cfg)
|
||||
have = referenced(cfg)
|
||||
skip = have | set(seen)
|
||||
nets = nets_of(inv) or [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", [])]
|
||||
found, hosts = items(inv, nets)
|
||||
added, now_seen = [], []
|
||||
for alias, h in hosts.items():
|
||||
if "host:" + alias in skip or "hostip:" + h["ssh"].split("@")[-1] in have:
|
||||
continue
|
||||
cfg["hosts"][alias] = h
|
||||
added.append("computer %s" % h["name"])
|
||||
now_seen.append("host:" + alias)
|
||||
taken = {t["id"] for t in sp.all_tiles(cfg)}
|
||||
for it in found:
|
||||
# A key that already exists for this thing: only its new parts (a Dante device, a MIDI peer).
|
||||
if it["ident"] in skip:
|
||||
fresh = [x for x in it["also"] if x not in skip]
|
||||
if fresh:
|
||||
added += merge_parts(cfg, it, fresh)
|
||||
now_seen += fresh
|
||||
continue
|
||||
if any(c.get("params", {}).get("host") not in cfg["hosts"] for c in it["checks"] if c.get("template")):
|
||||
continue
|
||||
# A program whose Companion connection already has a key (Woonsocket's PROP got a second key):
|
||||
# its own checks join that key.
|
||||
home = next((t for t in cfg["tiles"] if any(("conn:" + c.get("conn", "")) in it["also"] for c in t.get("checks", []) if c.get("conn"))), None)
|
||||
if home is not None:
|
||||
for fid, f in it["fixes"].items():
|
||||
cfg["fixes"].setdefault(fid, f)
|
||||
home["checks"] += [c for c in it["checks"] if not c.get("conn")]
|
||||
if it["restart"] and cfg.get("restart_page"):
|
||||
restart_layout(cfg, it["restart"])
|
||||
added.append("%s checks on the %s key" % (key_text(it["label"]), home["label"]))
|
||||
now_seen += [it["ident"]] + it["also"]
|
||||
continue
|
||||
tid = slug(it["label"], taken)
|
||||
taken.add(tid)
|
||||
for fid, f in it["fixes"].items():
|
||||
cfg["fixes"].setdefault(fid, f)
|
||||
place_new(cfg, {"id": tid, "label": key_text(it["label"]), "checks": it["checks"], "_group": it["group"]})
|
||||
if it["restart"] and cfg.get("restart_page"):
|
||||
restart_layout(cfg, it["restart"])
|
||||
added.append("%s key" % key_text(it["label"]))
|
||||
now_seen += [it["ident"]] + it["also"]
|
||||
if added:
|
||||
finish(cfg)
|
||||
auto = cfg.setdefault("auto_fix", dict(DEFAULT_AUTO_FIX, fixes=[]))
|
||||
auto["fixes"] = list(dict.fromkeys(list(auto.get("fixes") or []) + [f for f in gentle(cfg) if f not in auto.get("fixes", [])
|
||||
and any(f in it["fixes"] for it in found)]))
|
||||
return cfg, added, now_seen
|
||||
|
||||
|
||||
def merge_parts(cfg, it, fresh):
|
||||
"""New Dante devices and MIDI peers go into the key that already watches the rest."""
|
||||
words = []
|
||||
for t in sp.all_tiles(cfg):
|
||||
for c in t.get("checks", []):
|
||||
if c.get("type") == "dante" and it["ident"] == "conn:" + c.get("conn", ""):
|
||||
for x in fresh:
|
||||
if x.startswith("dante:"):
|
||||
name = x[6:]
|
||||
c.setdefault("devices", {})[name] = key_text(name, 12)
|
||||
words.append("Dante device %s" % name)
|
||||
if it["ident"] == "midi_wired":
|
||||
midi = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None)
|
||||
if midi is not None:
|
||||
wired_at = next(i for i, c in enumerate(midi["checks"]) if c.get("type") == "midi_wired")
|
||||
for c in it["checks"]:
|
||||
if c["type"] == "midi_peer" and "midi:%s|%s|%s" % (c.get("host", ""), c["session"], c["peer"]) in fresh:
|
||||
midi["checks"].insert(wired_at, c)
|
||||
wired_at += 1
|
||||
words.append("MIDI link %s" % c["peer"])
|
||||
cfg["fixes"].setdefault("midi_reconnect", dict(SHARED_FIXES["midi_reconnect"]))
|
||||
# A helper program found later (Southcoast's Lighting PC joined after its MIDI key existed: rtpMIDI was
|
||||
# marked seen but never added, 2026-10-21) joins the key it rides in.
|
||||
for ident, part in (it.get("folded") or {}).items():
|
||||
if ident not in fresh:
|
||||
continue
|
||||
home = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None) \
|
||||
if it["ident"] == "midi_wired" else None
|
||||
if home is None:
|
||||
continue
|
||||
at = next((i for i, c in enumerate(home["checks"]) if c.get("type") == "midi_wired"), len(home["checks"]))
|
||||
home["checks"][at:at] = [c for c in part["checks"] if c not in home["checks"]]
|
||||
for fid, f in part["fixes"].items():
|
||||
cfg["fixes"].setdefault(fid, f)
|
||||
words.append("%s checks on the %s key" % (key_text(part["label"]), home["label"]))
|
||||
return words
|
||||
|
||||
|
||||
def keep_from(new, old):
|
||||
"""A rebuild keeps what is down right now and so was not found: MIDI links that dropped (Southcoast's
|
||||
ProPresenter to Lighting lost its MIDI key, 2026-10-21) and computers that are off, with their keys."""
|
||||
cfg = copy.deepcopy(new)
|
||||
have = referenced(cfg)
|
||||
kept = []
|
||||
# Switch keys made for this location by hand (Southcoast's LIGHTS) and an AUTO-FIX key: auto-setup makes neither.
|
||||
for t in old.get("tiles", []):
|
||||
if t.get("kind") not in ("bypass", "autofix") or t["id"] in {x["id"] for x in cfg["tiles"]}:
|
||||
continue
|
||||
if t["kind"] == "autofix" and any(x.get("kind") == "autofix" for x in cfg["tiles"]):
|
||||
continue
|
||||
used = {tuple(x["pos"]) for x in cfg["tiles"] if x.get("pos")} | (
|
||||
{tuple(cfg["restart_page"]["jump_pos"])} if cfg.get("restart_page") else set())
|
||||
if t.get("pos") and tuple(t["pos"]) not in used:
|
||||
cfg["tiles"].append(copy.deepcopy(t))
|
||||
else:
|
||||
place_new(cfg, {k: v for k, v in copy.deepcopy(t).items() if k != "pos"})
|
||||
kept.append(t["label"])
|
||||
# Computers in the old setup that did not answer this time, and the keys that check them.
|
||||
gone = {a: h for a, h in (old.get("hosts") or {}).items() if not h.get("self") and a not in cfg["hosts"]}
|
||||
for alias, h in gone.items():
|
||||
cfg["hosts"][alias] = h
|
||||
for t in old.get("tiles", []):
|
||||
if t.get("checks") and all(c.get("host") == alias or (c.get("params") or {}).get("host") == alias for c in t["checks"]) \
|
||||
and t["id"] not in {x["id"] for x in cfg["tiles"]}:
|
||||
for c in t["checks"]:
|
||||
for fid in ([c["fix"]] if isinstance(c.get("fix"), str) else []):
|
||||
if fid in old.get("fixes", {}):
|
||||
cfg["fixes"].setdefault(fid, old["fixes"][fid])
|
||||
place_new(cfg, {k: v for k, v in t.items() if k != "pos"})
|
||||
kept.append(t["label"])
|
||||
for k in (old.get("restart_page") or {}).get("keys", []):
|
||||
f = old.get("fixes", {}).get(k.get("fix"), {})
|
||||
if (f.get("host") == alias or (f.get("params") or {}).get("host") == alias) and cfg.get("restart_page"):
|
||||
cfg["fixes"].setdefault(k["fix"], f)
|
||||
restart_layout(cfg, [{"label": k["label"], "fix": k["fix"]}])
|
||||
# MIDI links that are down now.
|
||||
lost = [c for t in old.get("tiles", []) for c in t.get("checks", []) if c.get("type") == "midi_peer"
|
||||
and "midi:%s|%s|%s" % (c.get("host", ""), c["session"], c["peer"]) not in have and (not c.get("host") or c["host"] in cfg["hosts"])]
|
||||
if lost:
|
||||
midi = next((t for t in cfg["tiles"] if any(c.get("type") in ("midi_peer", "midi_wired") for c in t.get("checks", []))), None)
|
||||
if midi is None:
|
||||
wired = [c for t in old.get("tiles", []) for c in t.get("checks", []) if c.get("type") == "midi_wired"]
|
||||
midi = {"id": slug("midi", {t["id"] for t in cfg["tiles"]}), "label": "MIDI", "checks": wired[:1], "_group": "SHOW"}
|
||||
place_new(cfg, midi)
|
||||
midi = cfg["tiles"][-1]
|
||||
at = next((i for i, c in enumerate(midi["checks"]) if c.get("type") == "midi_wired"), len(midi["checks"]))
|
||||
midi["checks"][at:at] = lost
|
||||
cfg["fixes"].setdefault("midi_reconnect", dict(SHARED_FIXES["midi_reconnect"]))
|
||||
if "midi_reconnect" not in cfg["auto_fix"]["fixes"]:
|
||||
cfg["auto_fix"]["fixes"].append("midi_reconnect")
|
||||
kept += ["MIDI link %s" % c["peer"] for c in lost]
|
||||
return cfg, kept
|
||||
|
||||
|
||||
def finish(cfg):
|
||||
"""Fixes and remote checks built by their builders, words filled in, as the page would."""
|
||||
for fid, f in list(cfg["fixes"].items()):
|
||||
if f.get("template") and "run" not in f and b.TEMPLATES.get(f["template"], {}).get("render"):
|
||||
built = b.build(cfg, f)
|
||||
built.setdefault("label", b.suggest_label(cfg, built) or fid)
|
||||
short = key_text(fid.replace("restart_", "").split("_")[0], 10)
|
||||
built.setdefault("short", short)
|
||||
cfg["fixes"][fid] = built
|
||||
elif f.get("template") and not f.get("label"):
|
||||
f["label"] = b.suggest_label(cfg, f) or fid
|
||||
for t in cfg["tiles"]:
|
||||
for i, c in enumerate(t.get("checks", [])):
|
||||
if c.get("template"):
|
||||
t["checks"][i] = b.build_check(cfg, c)
|
||||
|
||||
|
||||
def gentle(cfg):
|
||||
"""The fixes auto-fix may run by itself: starting what is closed and reconnecting - never a restart."""
|
||||
out = []
|
||||
for fid, f in cfg["fixes"].items():
|
||||
if fid.startswith("restart_") or f.get("restart_ok") or f.get("never_auto"):
|
||||
continue
|
||||
if f.get("kind") in ("hyperdeck", "midi_connect", "reconnect", "propresenter_port") or (f.get("template") in ("mac_start", "win_start", "ableton")
|
||||
and not (f.get("params") or {}).get("restart")):
|
||||
out.append(fid)
|
||||
return out
|
||||
+60
-5
@@ -30,6 +30,13 @@ def label(value, what):
|
||||
return value
|
||||
|
||||
|
||||
def whole(value, what):
|
||||
"""A whole number (0 counts; a bool, a blank or 1.5 does not)."""
|
||||
if isinstance(value, bool) or not re.fullmatch(r"-?\d+", str(value).strip()):
|
||||
raise ValueError("%s must be a whole number, not %r" % (what, value))
|
||||
return int(str(value).strip())
|
||||
|
||||
|
||||
def home_dir(path):
|
||||
"""~/x -> $HOME/x inside double quotes; refuses quotes and $ in the rest."""
|
||||
path = str(path)
|
||||
@@ -57,6 +64,22 @@ def mac_quit(process, wait_seconds, force, stop_children=False):
|
||||
return s
|
||||
|
||||
|
||||
def newest_app(pattern):
|
||||
"""APP=... the newest installed copy by its own version number (CFBundleShortVersionString), not by
|
||||
file name: "ProPresenter.app" sorts after "ProPresenter 20.1.app" by name (2026-10-21, before the
|
||||
Southcoast/Woonsocket ProPresenter update)."""
|
||||
return ("APP=$(for a in %s; do [ -d \"$a\" ] && printf '%%s|%%s\\n' \"$(defaults read \"$a/Contents/Info\" "
|
||||
"CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); "
|
||||
% str(pattern).strip())
|
||||
|
||||
|
||||
def app_match(pattern, process):
|
||||
"""A pgrep -f pattern for the program from any copy matching an /Applications glob (never its helpers)."""
|
||||
import re as _re
|
||||
parts = [_re.escape(x) for x in str(pattern).split("*")]
|
||||
return "[^/]*".join(parts).replace("\\ ", " ") + "/Contents/MacOS/" + _re.escape(process).replace("\\ ", " ") + "$"
|
||||
|
||||
|
||||
def mac_open(app, background=False, arg=None):
|
||||
"""open -a Name / open /path/App.app (+ -g to stay in the background)."""
|
||||
app = str(app)
|
||||
@@ -102,8 +125,10 @@ TEMPLATES = {
|
||||
"words": "Start a Mac program if it is not running",
|
||||
"os": "mac", "start_only": True,
|
||||
"params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None),
|
||||
("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)],
|
||||
"render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])),
|
||||
("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None),
|
||||
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
|
||||
"render": lambda p, cfg: ("pgrep -xq %s || { %s%s; }" % (q(p["process"]), newest_app(p["newest_of"]), mac_open("$APP"))
|
||||
if p.get("newest_of") else "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"]))),
|
||||
"suggest": lambda p, cfg: "start %s if it is not running" % p["app"],
|
||||
},
|
||||
"mac_restart": {
|
||||
@@ -116,7 +141,7 @@ TEMPLATES = {
|
||||
("background", "Open it in the background", "No window comes to the front.", "bool", False),
|
||||
("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False),
|
||||
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
|
||||
"render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "")
|
||||
"render": lambda p, cfg: (newest_app(p["newest_of"]) if p.get("newest_of") else "")
|
||||
+ mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False))
|
||||
+ mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)),
|
||||
"suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]),
|
||||
@@ -212,6 +237,8 @@ TEMPLATES = {
|
||||
"suggest": lambda p, cfg: "reconnect Companion to it"},
|
||||
"hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"},
|
||||
"propresenter_port": {"words": "Follow ProPresenter's network port (when it changes) and reconnect Companion", "kind": "propresenter_port",
|
||||
"start_only": True, "params": [], "render": None, "suggest": lambda p, cfg: "find ProPresenter's network port and reconnect Companion to it"},
|
||||
"midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"},
|
||||
"atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None,
|
||||
@@ -221,6 +248,17 @@ TEMPLATES = {
|
||||
"spotify": {"words": "Play the service playlist in Spotify", "kind": "spotify", "start_only": True, "params": [], "render": None,
|
||||
"suggest": lambda p, cfg: "play the %s playlist in Spotify on %s (opens Spotify if it is closed)"
|
||||
% ((cfg.get("spotify") or {}).get("name") or "service", (cfg.get("site") or {}).get("ingest_name") or "this Mac")},
|
||||
# a press: "copy" names the params that are also fields of the fix itself (the watchdog reads those)
|
||||
"companion_press": {
|
||||
"words": "Press a Companion button (any page, row and column)", "kind": "companion_press", "start_only": True,
|
||||
"params": [("page", "Page", "The Companion page the button is on (page 4 = Audio).", "int", None),
|
||||
("row", "Row", "Top row is 0.", "int", None),
|
||||
("col", "Column", "Left column is 0.", "int", None),
|
||||
("button", "What the button says", "Only for the words: FOH Backup, say.", "str", "")],
|
||||
"copy": ["page", "row", "col"], "render": None,
|
||||
"suggest": lambda p, cfg: "press the %sbutton in Companion (page %s, row %s, column %s)"
|
||||
% (("%s " % p["button"]) if p.get("button") else "", p["page"], p["row"], p["col"]),
|
||||
},
|
||||
"custom": {"words": "Custom command (Advanced: typed by hand)", "params": [], "render": None, "suggest": lambda p, cfg: ""},
|
||||
}
|
||||
|
||||
@@ -259,6 +297,9 @@ def build(cfg, fix):
|
||||
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
|
||||
out = {k: v for k, v in fix.items() if k not in ("run", "host", "kind")}
|
||||
out["template"], out["params"] = name, params
|
||||
for n in t.get("copy", []):
|
||||
words = next(w for a, w, _, _, _ in t["params"] if a == n)
|
||||
out[n] = params[n] = whole(params.get(n), "%s: %s" % (t["words"], words.lower()))
|
||||
if t.get("kind"):
|
||||
out["kind"] = t["kind"]
|
||||
return out
|
||||
@@ -301,6 +342,17 @@ CHECK_TEMPLATES = {
|
||||
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None)],
|
||||
"render": lambda p, cfg: "pgrep -xq %s" % q(p["process"]),
|
||||
},
|
||||
"win_program": {
|
||||
"words": "A program is running on a Windows PC", "type": "remote",
|
||||
"params": [P_HOST, ("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
|
||||
"render": lambda p, cfg: win_running(win(p["exe"], "program file")),
|
||||
},
|
||||
"soundgrid": {
|
||||
"words": "SuperRack is connected to its SoundGrid server", "type": "remote",
|
||||
"params": [P_HOST],
|
||||
# SuperRack keeps a control link to the Waves server (port 19000, over the SoundGrid network).
|
||||
"render": lambda p, cfg: "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'",
|
||||
},
|
||||
"helper_flag": {
|
||||
"words": "A helper file does not say something is wrong", "type": "remote",
|
||||
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
|
||||
@@ -362,8 +414,11 @@ def mismatches(cfg):
|
||||
except ValueError as e:
|
||||
out.append("fix %s: %s" % (fid, e))
|
||||
continue
|
||||
for k in ("run", "kind", "host"):
|
||||
if (b.get(k) or None) != (fix.get(k) or None):
|
||||
for k in ("run", "kind", "host") + tuple(TEMPLATES[fix["template"]].get("copy", [])):
|
||||
bv, fv = b.get(k), fix.get(k)
|
||||
if k in ("run", "kind", "host"):
|
||||
bv, fv = bv or None, fv or None # a blank and an absent command are the same
|
||||
if bv != fv:
|
||||
out.append("fix %s: its %s no longer matches its fields - open it on Fixes and press Save" % (fid, "command" if k == "run" else k))
|
||||
break
|
||||
for t in cfg.get("tiles") or []:
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
"""A location that sets itself up.
|
||||
|
||||
On the location's Mac that runs Companion, one line installs the panel from another location's settings
|
||||
page and runs `setup`: Companion gets the panel's custom variables and blink clock, two free pages
|
||||
are picked, the gear is found (settings/discover.py) and turned into keys (settings/autoconf.py),
|
||||
and that config is applied like any Apply on the page. Each other computer runs one join line
|
||||
(Terminal on a Mac, an administrator PowerShell on a PC); from then on the settings page looks again
|
||||
every ten minutes and adds what is new by itself (Settings > Site: "Add new gear by itself").
|
||||
|
||||
python3 -m settings.location setup --id ab [--name "Apollo Beach"]
|
||||
python3 -m settings.location look what it finds (nothing changes)
|
||||
python3 -m settings.location add add what is new now
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import gzip
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
|
||||
from settings import autoconf, discover, scripts
|
||||
import push_page
|
||||
import schema
|
||||
import status_panel as sp
|
||||
|
||||
CAMPUSES = {"na": "North Attleboro", "ab": "Apollo Beach", "sc": "Southcoast", "ws": "Woonsocket"}
|
||||
VARIABLES = ("sp_state", "sp_hb", "sp_cmd", "flash_tick")
|
||||
TRIGGER_NAME = "flash_tick_500"
|
||||
LOOK_EVERY = 600
|
||||
SEEN, LAST = "discover-seen.json", "discover-last.json"
|
||||
# What the program kit carries (no settings, keys or history of the location that serves it).
|
||||
KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py",
|
||||
"nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json")
|
||||
KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup")
|
||||
|
||||
|
||||
# --- Companion: the panel's variables, its blink clock, and two free pages ---------------
|
||||
|
||||
def flash_trigger_export(build="5.0.7", name=TRIGGER_NAME, enabled=True):
|
||||
"""A Companion export holding one trigger: flash_tick 1 for half a second, every second."""
|
||||
def nid():
|
||||
return base64.urlsafe_b64encode(os.urandom(15)).decode().rstrip("=")[:21]
|
||||
|
||||
def setvar(v):
|
||||
return {"id": nid(), "definitionId": "custom_variable_set_value", "connectionId": "internal",
|
||||
"options": {"name": {"isExpression": False, "value": "flash_tick"}, "value": {"isExpression": False, "value": v}},
|
||||
"type": "action", "children": {}}
|
||||
tid = nid()
|
||||
trig = {"type": "trigger", "options": {"name": name, "enabled": enabled, "sortOrder": 0},
|
||||
"actions": [setvar("1"), {"id": nid(), "definitionId": "wait", "connectionId": "internal",
|
||||
"options": {"time": {"isExpression": False, "value": "500"}}, "type": "action", "children": {}}, setvar("0")],
|
||||
"condition": [], "events": [{"id": nid(), "type": "interval", "enabled": True, "options": {"seconds": 1}}], "localVariables": []}
|
||||
return tid, {"version": 12, "type": "full", "companionBuild": build, "triggers": {tid: trig}, "triggerCollections": [],
|
||||
"custom_variables": {}, "customVariablesCollections": [], "instances": {}, "connectionCollections": []}
|
||||
|
||||
|
||||
def companion_state(cfg):
|
||||
raw = discover.companion_get(cfg, "/int/export/custom?buttons=false&connections=false&surfaces=&triggers=true&customVariables=true"
|
||||
"&expressionVariables=false&includeSecrets=false&imageLibrary=false&format=json", 10)
|
||||
d = json.loads(raw)
|
||||
names = {(t.get("options") or {}).get("name") for t in (d.get("triggers") or {}).values()}
|
||||
return set(d.get("custom_variables") or {}), names
|
||||
|
||||
|
||||
def companion_prepare(cfg):
|
||||
"""Create what the pages need inside Companion and is otherwise made by hand in its UI. Returns words."""
|
||||
comp = cfg.get("companion") or {}
|
||||
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
|
||||
have_vars, triggers = companion_state(cfg)
|
||||
done = []
|
||||
missing = [v for v in VARIABLES if v not in have_vars]
|
||||
if not missing and TRIGGER_NAME in triggers:
|
||||
return done
|
||||
ws = push_page.WebSocket(host, port, "/trpc")
|
||||
try:
|
||||
api = push_page.Trpc(ws)
|
||||
for v in missing:
|
||||
api.mutation("customVariables.create", {"name": v, "defaultVal": "0" if v == "flash_tick" else ""})
|
||||
done.append("custom variable %s" % v)
|
||||
if TRIGGER_NAME not in triggers:
|
||||
tid, export = flash_trigger_export()
|
||||
data = gzip.compress(json.dumps(export).encode())
|
||||
session = api.mutation("importExport.prepareImport.start", {"name": "status-panel-blink.companionconfig", "size": len(data)})
|
||||
api.mutation("importExport.prepareImport.uploadChunk", {"sessionId": session, "offset": 0, "data": base64.b64encode(data).decode()})
|
||||
summary = api.mutation("importExport.prepareImport.complete", {"sessionId": session, "expectedChecksum": hashlib.sha1(data).hexdigest(), "userData": None})
|
||||
if isinstance(summary, list) and summary and summary[0]:
|
||||
raise RuntimeError("Companion could not read the blink trigger: %s" % summary[0])
|
||||
api.mutation("importExport.importTriggers", {"selectedTriggerIds": [tid], "connectionIdRemapping": {}, "replaceExisting": False})
|
||||
done.append("the %s trigger (the keys' blink clock)" % TRIGGER_NAME)
|
||||
finally:
|
||||
ws.close()
|
||||
return done
|
||||
|
||||
|
||||
def pick_pages(cfg, count=2, start=19):
|
||||
"""Pages that hold nothing a person made (or only this panel's keys): the first `count` from `start`."""
|
||||
comp = cfg.get("companion") or {}
|
||||
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
|
||||
out = []
|
||||
for page in list(range(start, 100)) + list(range(2, start)):
|
||||
try:
|
||||
ok, _ = push_page.safe_target(push_page.fetch_page(host, port, page))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if ok:
|
||||
out.append(page)
|
||||
if len(out) == count:
|
||||
return out
|
||||
raise RuntimeError("Companion has no %d empty pages for the status panel" % count)
|
||||
|
||||
|
||||
# --- the first setup --------------------------------------------------------------------
|
||||
|
||||
def template(data_dir):
|
||||
for path in (os.path.join(data_dir, "kit", "sites", "_template", "config.json"),
|
||||
os.path.join(PARENT, "sites", "_template", "config.json")):
|
||||
if os.path.exists(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
raise RuntimeError("no sites/_template/config.json to start from")
|
||||
|
||||
|
||||
def is_fresh(cfg):
|
||||
return (cfg.get("site") or {}).get("id") in ("new", "", None) and len(cfg.get("tiles") or []) <= 3
|
||||
|
||||
|
||||
def load_seen(data_dir):
|
||||
try:
|
||||
with open(os.path.join(data_dir, SEEN)) as f:
|
||||
return set(json.load(f))
|
||||
except (OSError, ValueError):
|
||||
return set()
|
||||
|
||||
|
||||
def save_seen(data_dir, seen):
|
||||
sp.write_json(os.path.join(data_dir, SEEN), sorted(seen))
|
||||
|
||||
|
||||
def wait_applied(applier, limit=240):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < limit:
|
||||
st = applier.status or {}
|
||||
if st.get("state") not in ("running",):
|
||||
return st
|
||||
time.sleep(1)
|
||||
return applier.status or {}
|
||||
|
||||
|
||||
def companion_version_problem(cfg, standalone=False):
|
||||
"""None when this Companion can take the panel (5.x), else what to tell the person."""
|
||||
try:
|
||||
discover.companion_get(cfg, "/api/connections", 5)
|
||||
return None
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5)
|
||||
if standalone:
|
||||
return ("This Companion is older than version 5. The panel needs Companion 5.0.7: download it for Mac from\n"
|
||||
" https://bitfocus.io/companion (5.0.7, the .dmg), then on this Mac run\n"
|
||||
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion --from ~/Downloads/<the .dmg>\n"
|
||||
"(its settings come along; the old version stays installed), then run the installer again.")
|
||||
return ("This Companion is older than version 5 (Woonsocket ran 4.0.3). The panel needs 5.0.7, as North "
|
||||
"Attleboro runs. Upgrade it from NA's copy (its settings come along; the old version stays installed):\n"
|
||||
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion\n"
|
||||
"then paste the install line again.")
|
||||
except OSError:
|
||||
return "Companion does not answer on port 8000: open Companion (and its HTTP API, Settings > Protocols), then paste the line again."
|
||||
|
||||
|
||||
def setup(data_dir, cid, name=None, say=print, home=None, standalone=False):
|
||||
"""The first setup of a location (or, when it is set up already, a look for what is new).
|
||||
Standalone: it reports to nobody and nobody else sees it (Apollo Beach runs its own)."""
|
||||
from settings import server, campuses
|
||||
if home or standalone: # where it was installed from: the location list lives there
|
||||
data = campuses.load(data_dir)
|
||||
if standalone:
|
||||
data.pop("home", None)
|
||||
data["standalone"] = True
|
||||
else:
|
||||
data["home"] = home
|
||||
data.pop("standalone", None)
|
||||
campuses.save(data_dir, data)
|
||||
site = server.Site(data_dir)
|
||||
cfg = site.load()
|
||||
if not is_fresh(cfg):
|
||||
say("%s is set up already: looking for what is new." % (cfg.get("site") or {}).get("name", "This location"))
|
||||
return look_and_add(site, say=say, force=True)
|
||||
name = name or CAMPUSES.get(cid) or cid.upper()
|
||||
problem = companion_version_problem(cfg, standalone)
|
||||
if problem:
|
||||
raise RuntimeError(problem)
|
||||
for line in companion_prepare(cfg):
|
||||
say("Companion: added %s" % line)
|
||||
pages = pick_pages(cfg)
|
||||
say("Companion pages %d (status) and %d (restart) are free: using them." % tuple(pages))
|
||||
inv = discover.look(cfg, data_dir)
|
||||
if inv.get("companion_error"):
|
||||
raise RuntimeError("Companion's HTTP API does not answer (%s): turn it on in Companion's settings, then run this again" % inv["companion_error"])
|
||||
pin = "%04d" % random.SystemRandom().randrange(1000, 10000)
|
||||
new, seen = autoconf.build(template(data_dir), inv, {"id": cid, "name": name}, pin, pages)
|
||||
errors, _ = schema.validate(new)
|
||||
if errors:
|
||||
raise RuntimeError("the config auto-setup made does not pass the rules: %s" % "; ".join(errors[:4]))
|
||||
r = site.applier.start(new, "auto-setup: first setup of %s" % name, "auto-setup", "127.0.0.1")
|
||||
if not r.get("ok"):
|
||||
raise RuntimeError(r.get("error") or "the Apply did not start")
|
||||
st = wait_applied(site.applier)
|
||||
save_seen(data_dir, seen)
|
||||
keys = [t["label"] for t in new["tiles"] if not t.get("kind")]
|
||||
record(data_dir, {"at": int(time.time()), "added": ["%d keys: %s" % (len(keys), ", ".join(keys))], "inventory": summary(inv)})
|
||||
say("%s: %s" % (st.get("state"), st.get("outcome", "")))
|
||||
say("Keys: %s" % ", ".join(keys))
|
||||
say("Editing code (PIN) for the settings page: %s" % pin)
|
||||
return new
|
||||
|
||||
|
||||
def rebuild(data_dir, say=print, pages=None):
|
||||
"""A location's setup made again from what is found now (today's recipes), keeping its name, PIN,
|
||||
pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one)."""
|
||||
from settings import server
|
||||
site = server.Site(data_dir)
|
||||
cfg = site.load()
|
||||
s = cfg.get("site") or {}
|
||||
inv = discover.look(cfg, data_dir)
|
||||
if inv.get("companion_error"):
|
||||
raise RuntimeError("Companion does not answer: %s" % inv["companion_error"])
|
||||
rp = (cfg.get("restart_page") or {}).get("page")
|
||||
if not rp:
|
||||
rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"])
|
||||
pages = tuple(pages or (cfg["page"], rp))
|
||||
if pages != (cfg["page"], rp): # moving (19/20 like every location): only onto pages that are free or ours
|
||||
comp = cfg.get("companion") or {}
|
||||
for p in pages:
|
||||
ok, why = push_page.safe_target(push_page.fetch_page(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), p))
|
||||
if not ok:
|
||||
raise RuntimeError("page %d: %s" % (p, why))
|
||||
new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), pages)
|
||||
new, kept = autoconf.keep_from(new, cfg)
|
||||
if kept:
|
||||
say("Kept (down right now, so not found): %s" % ", ".join(kept))
|
||||
for k in ("contacts", "live_services", "ingest_name", "settings_port"):
|
||||
if k in s:
|
||||
new["site"][k] = s[k]
|
||||
new["discover"] = dict(new.get("discover") or {}, **(cfg.get("discover") or {}))
|
||||
errors, _ = schema.validate(new)
|
||||
if errors:
|
||||
raise RuntimeError("the rebuilt config does not pass the rules: %s" % "; ".join(errors[:4]))
|
||||
r = site.applier.start(new, "auto-setup: rebuilt with today's recipes", "auto-setup", "127.0.0.1")
|
||||
if not r.get("ok"):
|
||||
raise RuntimeError(r.get("error") or "the Apply did not start")
|
||||
st = wait_applied(site.applier)
|
||||
save_seen(data_dir, load_seen(data_dir) | set(seen))
|
||||
say("%s: %s" % (st.get("state"), st.get("outcome", "")))
|
||||
if st.get("state") == "done":
|
||||
for p in sorted({cfg["page"], rp} - set(pages)):
|
||||
say("Old page %d: %s" % (p, clear_old_page(cfg, p)))
|
||||
say("Keys: %s" % ", ".join(t["label"] for t in new["tiles"] if not t.get("kind")))
|
||||
say("Restart page %s: %s" % (new.get("restart_page", {}).get("page"), ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet"))
|
||||
return new
|
||||
|
||||
|
||||
def clear_old_page(cfg, page):
|
||||
"""A page left behind by a move is emptied, but only when it holds nothing a person made."""
|
||||
comp = cfg.get("companion") or {}
|
||||
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
|
||||
ok, why = push_page.safe_target(push_page.fetch_page(host, port, page))
|
||||
if not ok:
|
||||
return "left as it is (%s)" % why
|
||||
ws = push_page.WebSocket(host, port, "/trpc")
|
||||
try:
|
||||
push_page.Trpc(ws).mutation("pages.clearPage", {"pageNumber": page})
|
||||
finally:
|
||||
ws.close()
|
||||
return "cleared"
|
||||
|
||||
|
||||
def upgrade_companion(base, say=print, source=None):
|
||||
"""Companion 5.0.7 from `base` (North Attleboro's settings page), as Woonsocket was upgraded on 2026-10-20:
|
||||
the running Companion is quit, the newest settings folder is copied for 5.0 (and marked newest, or the
|
||||
launcher stops on "Config version mismatch"), 5.0.7 is installed beside the old one and started."""
|
||||
import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
root = os.path.expanduser("~/Library/Application Support/companion")
|
||||
app = "/Applications/Companion 5.0.7.app"
|
||||
# The watchdog's CompanionKeeper must not reopen the old Companion in the middle of this.
|
||||
with open(os.path.join(PARENT, "companion-upgrading"), "w") as f:
|
||||
f.write(str(time.time()))
|
||||
if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io
|
||||
install_companion_from(source, app, say)
|
||||
if not os.path.exists(app):
|
||||
say("Downloading Companion 5.0.7 from %s ..." % base)
|
||||
tmp = "/tmp/companion-5.0.7.zip"
|
||||
with urllib.request.urlopen(base.rstrip("/") + "/campus/companion.zip", timeout=600) as r, open(tmp, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
subprocess.run(["/usr/bin/ditto", "-x", "-k", tmp, "/Applications/"], check=True)
|
||||
os.remove(tmp)
|
||||
subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", app], check=True)
|
||||
dbs = sorted(glob.glob(os.path.join(root, "v*", "db.sqlite")), key=os.path.getmtime)
|
||||
if not os.path.exists(os.path.join(root, "v5.0", "db.sqlite")):
|
||||
if not dbs:
|
||||
raise RuntimeError("no Companion settings found to bring along")
|
||||
newest = os.path.dirname(dbs[-1])
|
||||
subprocess.run(["/usr/bin/pkill", "-TERM", "-f", "Companion.*\\.app/Contents/MacOS/Companion"])
|
||||
for _ in range(40):
|
||||
if subprocess.run(["/usr/bin/pgrep", "-f", "Companion.*\\.app/Contents/MacOS/Companion"], capture_output=True).returncode:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
shutil.copytree(newest, os.path.join(root, "v5.0"))
|
||||
say("Settings copied from %s to v5.0 (the old folder stays as it was)" % os.path.basename(newest))
|
||||
for f in ("db.sqlite", "cache.sqlite"):
|
||||
p = os.path.join(root, "v5.0", f)
|
||||
if os.path.exists(p):
|
||||
os.utime(p)
|
||||
subprocess.run(["/usr/bin/open", "-g", app], check=True)
|
||||
for _ in range(90):
|
||||
try:
|
||||
discover.companion_get({"companion": {}}, "/api/connections", 2)
|
||||
say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.")
|
||||
try:
|
||||
os.remove(os.path.join(PARENT, "companion-upgrading"))
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac")
|
||||
|
||||
|
||||
def install_companion_from(source, app, say=print):
|
||||
"""Companion copied from a downloaded .dmg or .zip to `app` (beside any older one)."""
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
source = os.path.expanduser(source)
|
||||
tmp = tempfile.mkdtemp(prefix="companion-")
|
||||
try:
|
||||
if source.lower().endswith(".dmg"):
|
||||
mnt = os.path.join(tmp, "mnt")
|
||||
subprocess.run(["/usr/bin/hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", mnt, source], check=True, capture_output=True)
|
||||
try:
|
||||
found = [n for n in os.listdir(mnt) if n.startswith("Companion") and n.endswith(".app")]
|
||||
if not found:
|
||||
raise RuntimeError("no Companion app inside %s" % source)
|
||||
subprocess.run(["/usr/bin/ditto", os.path.join(mnt, found[0]), app], check=True)
|
||||
finally:
|
||||
subprocess.run(["/usr/bin/hdiutil", "detach", mnt], capture_output=True)
|
||||
else:
|
||||
subprocess.run(["/usr/bin/ditto", "-x", "-k", source, tmp], check=True)
|
||||
found = [n for n in os.listdir(tmp) if n.startswith("Companion") and n.endswith(".app")]
|
||||
if not found:
|
||||
raise RuntimeError("no Companion app inside %s" % source)
|
||||
subprocess.run(["/usr/bin/ditto", os.path.join(tmp, found[0]), app], check=True)
|
||||
say("Companion installed as %s" % app)
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def summary(inv):
|
||||
known = set(autoconf.COMPANION)
|
||||
return {"connections": len(inv.get("connections") or []),
|
||||
"not_known": sorted("%s (%s)" % (c["label"], c["module"]) for c in inv.get("connections") or [] if c["module"] not in known and c["module"] != "internal"),
|
||||
"dante": len((inv.get("dante") or {}).get("devices") or []),
|
||||
"computers": {a: {"name": c.get("name"), "os": c.get("os"), "ip": c.get("ip"), "reachable": bool(c.get("reachable")),
|
||||
"error": c.get("error", "")} for a, c in (inv.get("computers") or {}).items()},
|
||||
"midi_sessions": sum(len(s or {}) for s in (inv.get("midi") or {}).values()),
|
||||
"wired": ((inv.get("ingest") or {}).get("wired") or {}).get("net")}
|
||||
|
||||
|
||||
def record(data_dir, result):
|
||||
sp.write_json(os.path.join(data_dir, LAST), result)
|
||||
|
||||
|
||||
# --- every ten minutes: what is new ------------------------------------------------------
|
||||
|
||||
def blocked(cfg, applier, now=None, asked=False):
|
||||
"""Why auto-setup may not apply right now, or None: never during a recording; by itself, never in the
|
||||
auto-fix hours either (a person asking - Look now, setting up on site - may)."""
|
||||
now = now or time.time()
|
||||
live, why = applier.live()
|
||||
if live:
|
||||
return "live now (%s)" % why
|
||||
if not asked and sp.in_windows((cfg.get("auto_fix") or {}).get("windows") or [], now):
|
||||
return "inside the auto-fix hours (a service day): waits until they end"
|
||||
if applier.busy():
|
||||
return "an Apply is running"
|
||||
return None
|
||||
|
||||
|
||||
def look_and_add(site, say=lambda s: None, force=False):
|
||||
"""Look, and add what is new (when the location allows it, and not during a service)."""
|
||||
cfg = site.load()
|
||||
inv = discover.look(cfg, site.data_dir)
|
||||
seen = load_seen(site.data_dir)
|
||||
new, added, now_seen = autoconf.extend(cfg, inv, seen)
|
||||
result = {"at": int(time.time()), "added": [], "waiting": [], "inventory": summary(inv)}
|
||||
if added:
|
||||
auto = (cfg.get("discover") or {}).get("auto")
|
||||
why = blocked(cfg, site.applier, asked=force)
|
||||
if not (auto or force):
|
||||
result["waiting"] = added
|
||||
result["why"] = "adding by itself is off (Settings > Site)"
|
||||
elif why:
|
||||
result["waiting"] = added
|
||||
result["why"] = why
|
||||
else:
|
||||
errors, _ = schema.validate(new)
|
||||
if errors:
|
||||
result["waiting"], result["why"] = added, "would not pass the rules: %s" % "; ".join(errors[:3])
|
||||
else:
|
||||
r = site.applier.start(new, "auto-setup found: %s" % ", ".join(added)[:180], "auto-setup", "127.0.0.1")
|
||||
if r.get("ok"):
|
||||
st = wait_applied(site.applier)
|
||||
if st.get("state") == "done":
|
||||
save_seen(site.data_dir, seen | set(now_seen))
|
||||
result["added"] = added
|
||||
else:
|
||||
result["waiting"], result["why"] = added, st.get("outcome") or "the Apply did not finish"
|
||||
else:
|
||||
result["waiting"], result["why"] = added, r.get("error", "the Apply did not start")
|
||||
record(site.data_dir, result)
|
||||
say("added: %s" % (", ".join(result["added"]) or "nothing"))
|
||||
if result["waiting"]:
|
||||
say("waiting: %s (%s)" % (", ".join(result["waiting"]), result.get("why")))
|
||||
return result
|
||||
|
||||
|
||||
def loop(site, stop, poke):
|
||||
"""The settings page's own background look: every ten minutes, and soon after a computer joins."""
|
||||
stop.wait(60)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
if (site.load().get("discover") or {}).get("auto") and not is_fresh(site.cfg):
|
||||
look_and_add(site)
|
||||
except Exception as e: # noqa: BLE001 - the page must keep running
|
||||
record(site.data_dir, dict(load_last(site.data_dir), error=str(e), at=int(time.time())))
|
||||
poke.wait(LOOK_EVERY)
|
||||
if poke.is_set():
|
||||
poke.clear()
|
||||
stop.wait(20) # a computer just joined: give its SSH a moment
|
||||
|
||||
|
||||
def load_campuses_home(data_dir):
|
||||
try:
|
||||
with open(os.path.join(data_dir, "campuses.json")) as f:
|
||||
return json.load(f).get("home")
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load_last(data_dir):
|
||||
try:
|
||||
with open(os.path.join(data_dir, LAST)) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
# --- the lines people run ----------------------------------------------------------------
|
||||
|
||||
def program_kit(data_dir):
|
||||
"""The program as a .tgz: the installed code, the settings page, the templates, install.sh."""
|
||||
buf = io.BytesIO()
|
||||
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
||||
for f in KIT_FILES:
|
||||
p = os.path.join(data_dir, f)
|
||||
if os.path.exists(p):
|
||||
tar.add(p, f)
|
||||
for d in KIT_DIRS:
|
||||
p = os.path.join(data_dir, d)
|
||||
if os.path.isdir(p):
|
||||
tar.add(p, d, filter=lambda ti: None if "__pycache__" in ti.name or ti.name.endswith(".pyc") else ti)
|
||||
kit = os.path.join(data_dir, "kit")
|
||||
for name in ("install.sh", "README.md"):
|
||||
p = os.path.join(kit, name)
|
||||
if os.path.exists(p):
|
||||
tar.add(p, name)
|
||||
p = os.path.join(kit, "sites", "_template")
|
||||
if os.path.isdir(p):
|
||||
tar.add(p, "sites/_template")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def shq(s):
|
||||
return "'" + str(s).replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def install_script(base, cid, support_key=None):
|
||||
"""What a new location's Companion Mac runs: install from `base` (this page), then set itself up."""
|
||||
m = re.match(r"[a-z0-9]+", str(cid).lower())
|
||||
cid = m.group(0)[:8] if m else "new"
|
||||
name = CAMPUSES.get(cid, cid.upper())
|
||||
key = support_key or ""
|
||||
return """#!/bin/zsh
|
||||
# Status panel for %(name)s: installs on this Mac (the one that runs Companion) and sets itself up.
|
||||
# Safe to run again: it then updates the program and looks for new gear.
|
||||
set -e
|
||||
SRC=%(base)s
|
||||
if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
|
||||
echo "First the Command Line Tools: a window opens - click Install, wait for it, then run this line again."
|
||||
/usr/bin/xcode-select --install 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
DIR="$HOME/status-panel-install"
|
||||
mkdir -p "$DIR" && cd "$DIR"
|
||||
echo "Downloading the status panel from $SRC ..."
|
||||
/usr/bin/curl -fsSL "$SRC/location/program.tgz" | /usr/bin/tar -xz
|
||||
[[ -f "$HOME/.ssh/id_status_panel" ]] || { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" && /usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName)" -f "$HOME/.ssh/id_status_panel"; }
|
||||
SUPPORT=%(key)s
|
||||
if [[ -n "$SUPPORT" ]]; then
|
||||
touch "$HOME/.ssh/authorized_keys" && chmod 600 "$HOME/.ssh/authorized_keys"
|
||||
grep -qF "$SUPPORT" "$HOME/.ssh/authorized_keys" || echo "$SUPPORT" >> "$HOME/.ssh/authorized_keys"
|
||||
fi
|
||||
zsh install.sh --site %(cid)s
|
||||
cd "$HOME/Library/Application Support/status-panel"
|
||||
/usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --home "$SRC"
|
||||
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
|
||||
echo
|
||||
echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely."
|
||||
fi
|
||||
# This Mac's wired address (the default route's port, not en0: at Southcoast en0 is a Dante-style link).
|
||||
IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}')
|
||||
ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname)
|
||||
echo
|
||||
echo "Next, on each other computer here (ProPresenter, Tracks, Lighting...):"
|
||||
echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh"
|
||||
echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)"
|
||||
echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex"
|
||||
""" % {"name": name, "base": shq(base), "key": shq(key), "cid": cid, "qname": shq(name)}
|
||||
|
||||
|
||||
def join_mac(cfg, base):
|
||||
key, _ = scripts.public_key(cfg)
|
||||
if not key:
|
||||
raise RuntimeError("this Mac has no login key yet (run the install line again)")
|
||||
ingest = (cfg.get("site") or {}).get("ingest_name") or "the Companion Mac"
|
||||
return """#!/bin/sh
|
||||
# Joins this Mac to %(ingest)s's status panel: lets it log in with its key (no password) and says this Mac is here.
|
||||
KEY=%(key)s
|
||||
# Run as root (Apple Remote Desktop > Send UNIX Command): it is for whoever is logged in on this Mac.
|
||||
U=$(id -un); H="$HOME"
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
U=$(stat -f%%Su /dev/console)
|
||||
[ "$U" = root ] && { echo "Nobody is logged in on $(scutil --get ComputerName): log in, then send this again."; exit 1; }
|
||||
H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}')
|
||||
fi
|
||||
mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys"
|
||||
grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys"
|
||||
for A in %(admin)s; do grep -qF "$A" "$H/.ssh/authorized_keys" || echo "$A" >> "$H/.ssh/authorized_keys"; done
|
||||
[ "$(id -u)" = 0 ] && chown -R "$U:staff" "$H/.ssh"
|
||||
[ "$(id -u)" = 0 ] && { /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1 || /bin/launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1; }
|
||||
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
|
||||
echo "Remote Login is off. Turning it on (it asks for this Mac's administrator password):"
|
||||
if ! sudo /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1; then
|
||||
echo "Please turn it on in System Settings > General > Sharing > Remote Login, then paste this line again."
|
||||
open "x-apple.systempreferences:com.apple.Sharing-Settings.extension" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
esc() { printf '%%s' "$1" | sed 's/[\\\\"]//g'; }
|
||||
IPS=$(/sbin/ifconfig | awk '/inet /{print $2}' | grep -v '^127\\.' | sed 's/.*/"&"/' | paste -sd, -)
|
||||
BODY="{\\"user\\":\\"$(esc "$U")\\",\\"name\\":\\"$(esc "$(scutil --get ComputerName)")\\",\\"hostname\\":\\"$(esc "$(scutil --get LocalHostName).local")\\",\\"os\\":\\"mac\\",\\"ips\\":[$IPS]}"
|
||||
if /usr/bin/curl -fsS -X POST -H 'Content-Type: application/json' -d "$BODY" %(hello)s >/dev/null; then
|
||||
echo "Joined. %(ingest)s sets this Mac up in about a minute: watch its status page."
|
||||
else
|
||||
echo "Could not reach %(ingest)s at %(base)s - is this Mac on the wired network?"
|
||||
exit 1
|
||||
fi
|
||||
""" % {"ingest": ingest, "key": shq(key), "hello": shq(base + "/api/hello"), "base": base,
|
||||
"admin": " ".join(shq(k) for k in scripts.admin_keys(cfg))}
|
||||
|
||||
|
||||
def join_windows(cfg, base):
|
||||
"""The Windows set-up script, with the programs found running on that PC instead of a list."""
|
||||
text = scripts.fill(scripts.template("windows-setup.ps1.template"), dict(
|
||||
scripts.site_values(cfg), PC_NAME="this PC", APPS=" # filled in below", PROGRAMS="the ones running now",
|
||||
SERVICES="'rtpMIDIService'"))
|
||||
known = "; ".join("'%s' = '%s'" % (a["exe"], a["task"].replace("Start ", "", 1)) for a in autoconf.WIN_APPS)
|
||||
detect = ("$known = @{ %s }\n"
|
||||
"$apps = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $known.ContainsKey((Split-Path $_.Path -Leaf)) } |\n"
|
||||
" Sort-Object Path -Unique | ForEach-Object { @{ Name = $known[(Split-Path $_.Path -Leaf)]; Exe = $_.Path } })" % known)
|
||||
text = re.sub(r"\$apps = @\(\n # filled in below\n\)", lambda m: detect, text)
|
||||
if "$known = @{" not in text:
|
||||
raise RuntimeError("the Windows template changed: cannot put the program finder in")
|
||||
text = text.replace("# Status panel remote access", "# Run in PowerShell as administrator: irm %s/join.ps1 | iex\n# Status panel remote access" % base, 1)
|
||||
# Not elevated: say how, before anything runs (SC's Lighting PC got a DISM error instead, 2026-10-21).
|
||||
text = scripts.with_admin_check(text)
|
||||
text += """
|
||||
# 5. Tell %(ingest)s this PC is here: it sets it up within a minute.
|
||||
$hello = @{ user = $env:USERNAME; name = $env:COMPUTERNAME; os = 'windows'
|
||||
ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notlike '127.*' } | ForEach-Object IPAddress)
|
||||
macs = @($nics | ForEach-Object MacAddress); tasks = @($apps | ForEach-Object { 'Start ' + $_.Name }) }
|
||||
try {
|
||||
Invoke-RestMethod -Method Post -Uri '%(hello)s' -ContentType 'application/json' -Body ($hello | ConvertTo-Json) | Out-Null
|
||||
Write-Host 'Joined: %(ingest)s sets this PC up in about a minute.'
|
||||
} catch { Write-Host ('Could not reach %(ingest)s: ' + $_.Exception.Message) }
|
||||
""" % {"ingest": (cfg.get("site") or {}).get("ingest_name") or "the Companion Mac", "hello": base + "/api/hello"}
|
||||
return text
|
||||
|
||||
|
||||
# --- command line ------------------------------------------------------------------------
|
||||
|
||||
def main(argv=None):
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("what", choices=("setup", "look", "add", "rebuild", "upgrade-companion"))
|
||||
ap.add_argument("--id", default="new")
|
||||
ap.add_argument("--name")
|
||||
ap.add_argument("--data-dir", default=PARENT)
|
||||
ap.add_argument("--home", help="the settings page this location was installed from (the location list lives there)")
|
||||
ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every location)")
|
||||
ap.add_argument("--standalone", action="store_true", help="a location that runs on its own: reports to no other location")
|
||||
ap.add_argument("--from", dest="source", help="upgrade-companion: a Companion .dmg or .zip downloaded from bitfocus.io")
|
||||
a = ap.parse_args(argv)
|
||||
if a.what == "setup":
|
||||
setup(a.data_dir, a.id, a.name, home=a.home, standalone=a.standalone)
|
||||
elif a.what == "rebuild":
|
||||
rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None)
|
||||
elif a.what == "upgrade-companion":
|
||||
upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"), source=a.source)
|
||||
elif a.what == "look":
|
||||
cfg = sp.load_config(os.path.join(a.data_dir, "config.json"))
|
||||
inv = discover.look(cfg, a.data_dir)
|
||||
print(json.dumps(summary(inv), indent=1))
|
||||
new, added, _ = autoconf.extend(cfg, inv, load_seen(a.data_dir))
|
||||
print("would add:", ", ".join(added) or "nothing")
|
||||
else:
|
||||
from settings import server
|
||||
look_and_add(server.Site(a.data_dir), say=print, force=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,534 @@
|
||||
"""Every location's status, on North Attleboro's Companion only.
|
||||
|
||||
Each other location's settings page tells North Attleboro's (the hub: a location with no "home") where it
|
||||
is, and learns nothing back: no other location ever sees another location (2026-10-20, the user: "NA
|
||||
should be the only one that sees every single location's status"). Only the hub, every two seconds,
|
||||
copies each 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:
|
||||
|
||||
LOCATIONS page one key per location: that location's ALL key, with its name; tap = its status page
|
||||
LOCATION page whichever location was tapped, as its own status page looks (same places, same
|
||||
colours, live), view only: holds and presses are for that location's own deck.
|
||||
The ALARMS corner goes back to the LOCATIONS page.
|
||||
|
||||
Two pages, whatever the number of locations (NA's Companion has 97 pages of its own; Companion stops
|
||||
at 99): the LOCATION page's keys are cells ("c_1_3"), and the hub fills them from the tapped location's
|
||||
layout (sp_view says which). A location that stops answering goes purple. Pages are only written
|
||||
where nothing a person made sits (push_page.safe_target): an empty page, else one added at the
|
||||
end. State: campuses.json in the data folder.
|
||||
|
||||
A standalone location (Apollo Beach, 2026-10-21: "they want to take care of it on their own ... i dont
|
||||
need to monitor AB from NA") has "standalone" set instead of a home: it tells nobody, is no hub, and
|
||||
lists only itself.
|
||||
"""
|
||||
import gzip
|
||||
import logging
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
|
||||
from settings import discover
|
||||
import make_page
|
||||
import push_page
|
||||
import status_panel as sp
|
||||
|
||||
FILE = "campuses.json"
|
||||
log = logging.getLogger("status-settings") # the settings page's own log (status-settings.log)
|
||||
POLL, REFRESH = 2.0, 60.0
|
||||
SHORT = {"na": "NA", "ab": "AB", "sc": "SC", "ws": "WS"}
|
||||
MIRROR_MARK = "STATUS tile" # copied keys keep the tile marker, so a later rebuild may overwrite them
|
||||
|
||||
|
||||
def load(data_dir):
|
||||
try:
|
||||
with open(os.path.join(data_dir, FILE)) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save(data_dir, data):
|
||||
sp.write_json(os.path.join(data_dir, FILE), data)
|
||||
|
||||
|
||||
def short(cid, name=""):
|
||||
return SHORT.get(cid) or (name or cid)[:4].upper()
|
||||
|
||||
|
||||
def my_url(cfg):
|
||||
w = (discover.this_mac().get("wired") or {}).get("ip") or "127.0.0.1"
|
||||
return "http://%s:%d" % (w, int((cfg.get("site") or {}).get("settings_port", 8020)))
|
||||
|
||||
|
||||
def me(cfg, url=None):
|
||||
import pwd
|
||||
site = cfg.get("site") or {}
|
||||
# The account this panel runs as: the hub logs in with it for the nightly backup.
|
||||
return {"id": site.get("id") or "new", "name": site.get("name") or "This location", "url": url or my_url(cfg),
|
||||
"user": pwd.getpwuid(os.getuid()).pw_name}
|
||||
|
||||
|
||||
# Bumped when the pages are built differently (2: "campus" became "location"; 3: ALL AUTO-FIX key, 2026-10-21), so the hub
|
||||
# re-imports them.
|
||||
PAGES_VERSION = 3
|
||||
|
||||
|
||||
def layout_sha(cfg):
|
||||
keys = [(t.get("id"), t.get("label"), t.get("pos"), t.get("kind"), t.get("members")) for t in cfg.get("tiles", [])]
|
||||
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables"), PAGES_VERSION], sort_keys=True, default=str).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def state_now(site):
|
||||
"""What /api/state answers: this location's key states as its watchdog draws them, right now."""
|
||||
cfg = site.load()
|
||||
names = cfg.get("variables") or {}
|
||||
comp = site.comp
|
||||
return {"id": (cfg.get("site") or {}).get("id"), "name": (cfg.get("site") or {}).get("name"),
|
||||
"state": comp.get_custom(names.get("state", "sp_state")) or "", "hb": comp.get_custom(names.get("heartbeat", "sp_hb")) or "",
|
||||
"layout": layout_sha(cfg), "time": int(time.time())}
|
||||
|
||||
|
||||
def http_json(url, body=None, timeout=3):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"} if data else {}, method="POST" if data else "GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
# --- the pages (the hub only) -----------------------------------------------------------
|
||||
|
||||
VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"}
|
||||
REMOTE = make_page.REMOTE_VAR # which other locations have a problem: the blue band on NA's page-number keys
|
||||
ALL_CMD = "sp_all_cmd" # a tap on NA's ALL AUTO-FIX key (LOCATIONS page)
|
||||
ALL_AF = "sp_all_af" # what that key shows: {"t", "x", "paused"}
|
||||
ALL_AF_POS = (3, 1) # where AUTO-FIX sits on every location's own status page
|
||||
SELECT = "sp_view"
|
||||
VIEW_CMD = "sp_view_cmd" # a tap or hold on the LOCATION page, passed on to that location's own watchdog
|
||||
MODE = "sp_view_mode" # "" = that location's status page, "RESTART" = its Restart page
|
||||
CELLS = [(r, c) for r in range(4) for c in range(1, 8)]
|
||||
|
||||
|
||||
def cell_id(r, c):
|
||||
return "c_%d_%d" % (r, c)
|
||||
|
||||
|
||||
def view_state(state, keys):
|
||||
"""A location's key states, moved onto the cells its keys sit in (empty cells blank)."""
|
||||
out = {cell_id(r, c): {"x": "", "b": 0, "b2": 0, "f": 0xFFFFFF, "f2": 0xFFFFFF, "s": 14} for r, c in CELLS}
|
||||
for k in keys:
|
||||
r, c = k["pos"]
|
||||
if (r, c) in CELLS and k["id"] in state:
|
||||
out[cell_id(r, c)] = state[k["id"]]
|
||||
return out
|
||||
|
||||
|
||||
def build_view(local_cfg, page, overview_page):
|
||||
"""The LOCATION page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
|
||||
that location (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
|
||||
opts = make_page.settings(local_cfg)
|
||||
controls = make_page.nav_controls(local_cfg)
|
||||
hold_ms = opts["hold_ms"]
|
||||
for r, c in CELLS:
|
||||
layers = make_page.layers(cell_id(r, c), {}, VIEW, "''", opts)
|
||||
tap = make_page.command_action("view_%d_%d" % (r, c), "press", VIEW_CMD, "tap %d %d $(internal:time_unix)" % (r, c))
|
||||
hold = make_page.command_action("view_%d_%d" % (r, c), "hold", VIEW_CMD, "hold %d %d $(internal:time_unix)" % (r, c))
|
||||
make_page.place(controls, (r, c), {
|
||||
"type": "button-layered", "style": {"layers": layers},
|
||||
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
|
||||
"notes": "STATUS tile 'location view %d/%d' - the tapped location's key here. Press: acknowledge it there. "
|
||||
"Hold %g s: fix it there, like a hold on that location's own deck." % (r, c, hold_ms / 1000.0)},
|
||||
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [tap], str(hold_ms): [hold]},
|
||||
"options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []})
|
||||
back = make_page.static_button("campus_back", "ALL\\nSITES", 0x37474F, 0xFFFFFF, overview_page,
|
||||
"Tap: back to the LOCATIONS page (page %d)." % overview_page, "STATUS jump key")
|
||||
for layer in back["style"]["layers"]:
|
||||
if layer["type"] == "text": # which location this page shows, then the way back
|
||||
layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True)
|
||||
make_page.place(controls, (3, 0), back)
|
||||
return make_page.page_export("%d\\nLocation" % page, page, controls, local_cfg)
|
||||
|
||||
|
||||
def select_action(cid, mode=""):
|
||||
return [{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "select"), "definitionId": "custom_variable_set_value",
|
||||
"connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid.upper())}, "children": {}},
|
||||
{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "mode"), "definitionId": "custom_variable_set_value",
|
||||
"connectionId": "internal", "options": {"name": make_page.prop(MODE), "value": make_page.prop(mode)}, "children": {}}]
|
||||
|
||||
|
||||
def campus_key(cid, name, names, actions, note, local_cfg):
|
||||
"""One LOCATIONS key: that location's ALL key with its name on top."""
|
||||
opts = make_page.settings(local_cfg)
|
||||
tag = short(cid, name)
|
||||
stale_text = "`%s\\n?`" % tag
|
||||
layers = make_page.layers("all", {}, names, stale_text, opts)
|
||||
text = next(layer for layer in layers if layer["type"] == "text")
|
||||
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], opts["stale"])
|
||||
text["text"] = make_page.prop("%s ? %s : `%s\\n${jsonpath($(custom:%s), '$.all.x')}`" % (stale, stale_text, tag, names["state"]), True)
|
||||
text["fontsize"] = make_page.prop("auto")
|
||||
return {"type": "button-layered", "style": {"layers": layers},
|
||||
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
|
||||
"notes": "STATUS tile 'location %s' - %s's ALL key. %s" % (cid, name, note)},
|
||||
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
|
||||
"localVariables": []}
|
||||
|
||||
|
||||
def build_overview(local_cfg, campuses, page, view_page):
|
||||
"""Row 0: each location's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page)."""
|
||||
controls = make_page.nav_controls(local_cfg)
|
||||
mine = (local_cfg.get("site") or {}).get("id")
|
||||
rp = (local_cfg.get("restart_page") or {}).get("page")
|
||||
for col, cid in enumerate(sorted(campuses, key=lambda c: (c != mine, c))[:7], start=1):
|
||||
name = campuses[cid].get("name", cid)
|
||||
tag = short(cid, name)
|
||||
if cid == mine:
|
||||
key = campus_key(cid, name, local_cfg["variables"], [make_page.set_page_action("campus_" + cid, "go", local_cfg["page"])],
|
||||
"Tap: its status page (page %d)." % local_cfg["page"], local_cfg)
|
||||
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, rp,
|
||||
"Tap: this location's Restart page (page %s)." % rp, "STATUS jump key") if rp else None
|
||||
else:
|
||||
key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid},
|
||||
select_action(cid) + [make_page.set_page_action("campus_" + cid, "go", view_page)],
|
||||
"Tap: its status page, live (page %d)." % view_page, local_cfg)
|
||||
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, view_page,
|
||||
"Tap: %s's Restart page, live (page %d); hold a key there to restart it at %s." % (name, view_page, name),
|
||||
"STATUS jump key")
|
||||
restart["steps"]["0"]["action_sets"]["up"] = select_action(cid, "RESTART") + restart["steps"]["0"]["action_sets"]["up"]
|
||||
make_page.place(controls, (0, col), key)
|
||||
if restart:
|
||||
make_page.place(controls, (1, col), restart)
|
||||
make_page.place(controls, ALL_AF_POS, all_autofix_key(local_cfg))
|
||||
return make_page.page_export("%d\\nLocations" % page, page, controls, local_cfg)
|
||||
|
||||
|
||||
def all_autofix_key(local_cfg):
|
||||
"""Tap: pause auto-fix at every location at once (NA too); tap again: every location back to its hours
|
||||
(2026-10-21, the user: "add a key on NA to pause auto-fix at all locations"). Blue while none is paused,
|
||||
grey naming the paused ones."""
|
||||
v = "$(custom:%s)" % ALL_AF
|
||||
stale = "$(internal:time_unix) - jsonpath(%s, '$.t') > 30" % v
|
||||
key = make_page.static_button("all_autofix", "AUTO-FIX\\nALL", 0x0B57D0, 0xFFFFFF, 1,
|
||||
"Tap: pause auto-fix at every location (alarms stay on, holds still fix); tap again: all "
|
||||
"back to their auto-fix hours.", "STATUS tile 'all autofix'")
|
||||
for layer in key["style"]["layers"]:
|
||||
if layer["type"] == "box":
|
||||
layer["color"] = make_page.prop("%s ? %d : (jsonpath(%s, '$.paused') > 0 ? %d : %d)" % (stale, make_page.PURPLE, v, 0x262626, 0x0B57D0), True)
|
||||
elif layer["type"] == "text":
|
||||
layer["text"] = make_page.prop("%s ? `AUTO-FIX\\nALL ?` : `AUTO-FIX\\n${jsonpath(%s, '$.x')}`" % (stale, v), True)
|
||||
key["steps"]["0"]["action_sets"]["up"] = [make_page.command_action("all_autofix", "press", ALL_CMD, "toggle $(internal:time_unix)")]
|
||||
return key
|
||||
|
||||
|
||||
def autofix_paused(state_json):
|
||||
"""True when that location's AUTO-FIX key says it is paused ("AUTO-FIX\\nPAUSED\\nTIL ..."), None without one."""
|
||||
try:
|
||||
af = json.loads(state_json or "{}").get("autofix")
|
||||
except ValueError:
|
||||
return None
|
||||
if not af:
|
||||
return None
|
||||
lines = str(af.get("x") or "").split("\n")
|
||||
return len(lines) > 1 and lines[1] == "PAUSED"
|
||||
|
||||
|
||||
def all_autofix_summary(states, now):
|
||||
"""states: {id: that location's /api/state "state" JSON}. -> what NA's ALL AUTO-FIX key shows."""
|
||||
paused = sorted(cid.upper() for cid, s in states.items() if autofix_paused(s))
|
||||
return {"t": int(now), "paused": len(paused), "x": ("%s\nPAUSED" % " ".join(paused)) if paused else "ALL ON"}
|
||||
|
||||
|
||||
def ensure_variables(cfg, ids):
|
||||
from settings import campus
|
||||
have, _ = campus.companion_state(cfg)
|
||||
want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have]
|
||||
want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD, MODE, REMOTE, ALL_CMD, ALL_AF) if n not in have]
|
||||
if not want:
|
||||
return
|
||||
comp = cfg.get("companion") or {}
|
||||
ws = push_page.WebSocket(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), "/trpc")
|
||||
try:
|
||||
api = push_page.Trpc(ws)
|
||||
for n in want:
|
||||
api.mutation("customVariables.create", {"name": n, "defaultVal": "0" if n.startswith("sp_hb_") else ""})
|
||||
finally:
|
||||
ws.close()
|
||||
|
||||
|
||||
def free_page(cfg, taken, start=21):
|
||||
"""The first page from `start` that holds nothing but Companion's own page keys."""
|
||||
comp = cfg.get("companion") or {}
|
||||
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
|
||||
for page in range(start, 100):
|
||||
if page in taken:
|
||||
continue
|
||||
if not exists(host, port, page):
|
||||
break
|
||||
try:
|
||||
export = push_page.fetch_page(host, port, page)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
ctrls = (export.get("page") or {}).get("controls") or {}
|
||||
if not any(ctl and ctl.get("type") not in push_page.NAV_TYPES for row in ctrls.values() for ctl in (row or {}).values()):
|
||||
return page
|
||||
# None empty: a new page at the end (Companion's own page keys on it), never in between.
|
||||
last = max(p for p in range(1, 100) if exists(host, port, p))
|
||||
if last >= 99:
|
||||
raise RuntimeError("Companion has 99 pages and none is empty: free one for the locations")
|
||||
ws = push_page.WebSocket(host, port, "/trpc")
|
||||
try:
|
||||
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["LOCATIONS"]})
|
||||
finally:
|
||||
ws.close()
|
||||
return last + 1
|
||||
|
||||
|
||||
def exists(host, port, page):
|
||||
try:
|
||||
push_page.fetch_page(host, port, page)
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def push(cfg, page, export):
|
||||
comp = cfg.get("companion") or {}
|
||||
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
|
||||
ok, why = push_page.safe_target(push_page.fetch_page(host, port, page))
|
||||
if not ok:
|
||||
raise RuntimeError("page %d: %s" % (page, why))
|
||||
with tempfile.NamedTemporaryFile(suffix=".companionconfig", delete=False) as f:
|
||||
f.write(gzip.compress(json.dumps(export).encode()))
|
||||
path = f.name
|
||||
try:
|
||||
push_page.push(host, port, page, path, export["page"]["name"])
|
||||
finally:
|
||||
os.remove(path)
|
||||
|
||||
|
||||
# --- the loops --------------------------------------------------------------------------
|
||||
|
||||
class Mirror:
|
||||
def __init__(self, site):
|
||||
self.site = site
|
||||
self.remote = {} # id -> last /api/state answer
|
||||
self.keys = {} # id -> [{"id", "pos"}] of its status page (from its /api/config)
|
||||
self.rkeys = {} # id -> the same for its Restart page
|
||||
|
||||
@staticmethod
|
||||
def is_hub(data):
|
||||
return not data.get("home") and not data.get("standalone")
|
||||
|
||||
def sync_registry(self):
|
||||
"""A location: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
|
||||
cfg = self.site.load()
|
||||
data = load(self.site.data_dir)
|
||||
mine = me(cfg)
|
||||
home = data.get("home")
|
||||
if home and home.rstrip("/") != mine["url"]:
|
||||
try:
|
||||
http_json(home.rstrip("/") + "/api/campus/hello", mine)
|
||||
data.pop("error", None)
|
||||
except (OSError, ValueError) as e:
|
||||
data["error"] = "hub %s: %s" % (home, e)
|
||||
data["list"] = {mine["id"]: mine}
|
||||
else:
|
||||
data.setdefault("list", {})[mine["id"]] = mine
|
||||
save(self.site.data_dir, data)
|
||||
return data
|
||||
|
||||
def taken_pages(self, cfg):
|
||||
t = {cfg.get("page"), (cfg.get("restart_page") or {}).get("page"), (cfg.get("ready_page") or {}).get("page")}
|
||||
return {p for p in t if p}
|
||||
|
||||
def refresh_pages(self):
|
||||
"""The hub: each location's layout (when it changed), the LOCATION page once, the LOCATIONS page when the roster changes."""
|
||||
cfg = self.site.load()
|
||||
data = self.sync_registry()
|
||||
if not self.is_hub(data):
|
||||
return data
|
||||
mine = (cfg.get("site") or {}).get("id")
|
||||
others = {cid: e for cid, e in data.get("list", {}).items() if cid != mine}
|
||||
if not others and not (data.get("pages") or {}).get("overview"):
|
||||
return data # no other location yet: no pages needed
|
||||
pages = data.setdefault("pages", {})
|
||||
layouts = data.setdefault("layouts", {})
|
||||
for cid, e in sorted(others.items()):
|
||||
st = self.remote.get(cid)
|
||||
if st and (layouts.get(cid) != st.get("layout") or cid not in self.keys):
|
||||
remote_cfg = http_json(e["url"].rstrip("/") + "/api/config", timeout=8)["config"]
|
||||
self.keys[cid] = [{"id": t["id"], "pos": t["pos"]} for t in remote_cfg.get("tiles", []) if t.get("pos")]
|
||||
self.rkeys[cid] = [{"id": k["id"], "pos": k["pos"]} for k in (remote_cfg.get("restart_page") or {}).get("keys", []) if k.get("pos")]
|
||||
layouts[cid] = st.get("layout")
|
||||
ensure_variables(cfg, list(others))
|
||||
taken = self.taken_pages(cfg) | {p for p in pages.values() if p}
|
||||
for which in ("overview", "view"):
|
||||
if not pages.get(which):
|
||||
pages[which] = free_page(cfg, taken)
|
||||
taken.add(pages[which])
|
||||
built = layout_sha(cfg)
|
||||
if data.get("view_built") != built:
|
||||
push(cfg, pages["view"], build_view(cfg, pages["view"], pages["overview"]))
|
||||
data["view_built"] = built
|
||||
roster = sorted(data["list"])
|
||||
if data.get("overview_roster") != roster or data.get("overview_built") != built:
|
||||
push(cfg, pages["overview"], build_overview(cfg, data["list"], pages["overview"], pages["view"]))
|
||||
data["overview_roster"], data["overview_built"] = roster, built
|
||||
data.pop("error", None)
|
||||
save(self.site.data_dir, data)
|
||||
return data
|
||||
|
||||
def all_autofix(self, cfg, mine, others):
|
||||
"""NA's ALL AUTO-FIX key: show which locations are paused; a tap pauses them all, or resumes them all."""
|
||||
states = {cid: (self.remote.get(cid) or {}).get("state") for cid in others}
|
||||
states[mine] = self.site.comp.get_custom((cfg.get("variables") or {}).get("state", "sp_state"))
|
||||
summary = all_autofix_summary(states, time.time())
|
||||
self.site.comp.set_custom(ALL_AF, json.dumps(summary, separators=(",", ":")))
|
||||
text = (self.site.comp.get_custom(ALL_CMD) or "").strip(" ;")
|
||||
if not text:
|
||||
return
|
||||
self.site.comp.set_custom(ALL_CMD, "")
|
||||
stamps = [w for w in text.replace(";", " ").split() if w.isdigit()]
|
||||
if not stamps or time.time() - int(stamps[-1]) > 30:
|
||||
return # pressed while this was down: not acted on later
|
||||
word = "auto" if summary["paused"] else "off"
|
||||
hosts = {cid: urllib.parse.urlparse(e["url"]).hostname for cid, e in others.items()}
|
||||
hosts[mine] = "127.0.0.1"
|
||||
for cid, host in sorted(hosts.items()):
|
||||
try:
|
||||
comp = self.site.comp if cid == mine else sp.Companion(host, 8000, timeout=3)
|
||||
before = (comp.get_custom("sp_cmd") or "").strip()
|
||||
comp.set_custom("sp_cmd", ";".join([before, "autofix " + word] if before else ["autofix " + word]))
|
||||
log.info("ALL AUTO-FIX: %s %s", cid, "paused" if word == "off" else "back to its hours")
|
||||
except OSError as e:
|
||||
log.warning("ALL AUTO-FIX: %s not reached: %s", cid, e)
|
||||
|
||||
def pass_on(self, pick, others, restart=False):
|
||||
"""A tap or hold on the LOCATION page goes to the shown location's watchdog, through its Companion."""
|
||||
text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
|
||||
if not text:
|
||||
return
|
||||
self.site.comp.set_custom(VIEW_CMD, "")
|
||||
keys = (self.rkeys if restart else self.keys).get(pick)
|
||||
cmds = campus_command(keys, text, time.time(), restart) if pick else []
|
||||
if not cmds:
|
||||
return
|
||||
host = urllib.parse.urlparse(others[pick]["url"]).hostname
|
||||
try:
|
||||
remote = sp.Companion(host, 8000, timeout=3)
|
||||
before = (remote.get_custom("sp_cmd") or "").strip()
|
||||
remote.set_custom("sp_cmd", ";".join([before] + cmds if before else cmds))
|
||||
log.info("LOCATION %s: %s (from %s's LOCATION page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
|
||||
except OSError as e:
|
||||
log.warning("LOCATION %s: could not pass on %s: %s", pick, cmds, e)
|
||||
|
||||
def poll(self):
|
||||
"""The hub: copy each other location's key states into this Companion."""
|
||||
cfg = self.site.load()
|
||||
data = load(self.site.data_dir)
|
||||
if not self.is_hub(data):
|
||||
return
|
||||
mine = (cfg.get("site") or {}).get("id")
|
||||
others = {cid: e for cid, e in (data.get("list") or {}).items() if cid != mine and e.get("url")}
|
||||
threads = []
|
||||
|
||||
def one(cid, url):
|
||||
try:
|
||||
st = http_json(url.rstrip("/") + "/api/state", timeout=1.5)
|
||||
except (OSError, ValueError):
|
||||
return # not answering: its heartbeat goes stale, its keys purple
|
||||
self.remote[cid] = st
|
||||
if (data.get("pages") or {}).get("overview"):
|
||||
self.site.comp.set_custom("sp_state_" + cid, st.get("state") or "{}")
|
||||
self.site.comp.set_custom("sp_hb_" + cid, str(st.get("hb") or "0"))
|
||||
for cid, e in others.items():
|
||||
t = threading.Thread(target=one, args=(cid, e["url"]), daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
for t in threads:
|
||||
t.join(2)
|
||||
if (data.get("pages") or {}).get("overview"):
|
||||
self.site.comp.set_custom(REMOTE, json.dumps(remote_summary(others, self.remote, time.time()), separators=(",", ":")))
|
||||
self.all_autofix(cfg, mine, others)
|
||||
# The LOCATION page shows the tapped location: its keys moved onto their cells.
|
||||
pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it
|
||||
if pick not in others and others:
|
||||
# Nothing tapped yet (or a location that left): show the first one rather than a purple page.
|
||||
pick = sorted(others)[0]
|
||||
self.site.comp.set_custom(SELECT, pick.upper())
|
||||
restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART"
|
||||
self.pass_on(pick if pick in others else None, others, restart)
|
||||
st = self.remote.get(pick)
|
||||
keys = (self.rkeys if restart else self.keys).get(pick)
|
||||
if st and keys is not None and (data.get("pages") or {}).get("view"):
|
||||
try:
|
||||
state = json.loads(st.get("state") or "{}")
|
||||
except ValueError:
|
||||
state = {}
|
||||
self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, keys), separators=(",", ":")))
|
||||
self.site.comp.set_custom(VIEW["heartbeat"], str(st.get("hb") or "0"))
|
||||
|
||||
|
||||
def remote_summary(others, remote, now, stale=30):
|
||||
"""{"t", "n", "x", "f"}: the other locations with something down (their ALL key not "ALL OK", or not
|
||||
answering: "SC?"), named by id; "f" = 1 while any of them is still flashing (not acknowledged there)."""
|
||||
names, flashing = [], False
|
||||
for cid in sorted(others):
|
||||
st = remote.get(cid)
|
||||
tag = cid.upper()
|
||||
if not st or now - float(st.get("hb") or 0) > stale:
|
||||
names.append(tag + "?")
|
||||
flashing = True
|
||||
continue
|
||||
try:
|
||||
all_ = json.loads(st.get("state") or "{}").get("all") or {}
|
||||
except ValueError:
|
||||
all_ = {}
|
||||
if not all_ or str(all_.get("x") or "").startswith("ALL OK"):
|
||||
continue
|
||||
names.append(tag)
|
||||
flashing = flashing or all_.get("b") != all_.get("b2")
|
||||
return {"t": int(now), "n": len(names), "x": " ".join(names), "f": 1 if flashing else 0}
|
||||
|
||||
|
||||
def campus_command(keys, text, now, restart=False):
|
||||
"""The LOCATION page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that location's own
|
||||
commands ("ack hd1", "restart hd1 <time>"; on its Restart page a hold is "force rs_x <time>" and a
|
||||
tap does nothing, as on its own deck), for the keys at those cells. Stale ones are dropped."""
|
||||
at = {tuple(k["pos"]): k["id"] for k in keys or []}
|
||||
out = []
|
||||
for part in (text or "").split(";"):
|
||||
w = part.split()
|
||||
if len(w) != 4 or w[0] not in ("tap", "hold") or not (w[1].isdigit() and w[2].isdigit() and w[3].isdigit()):
|
||||
continue
|
||||
tid = at.get((int(w[1]), int(w[2])))
|
||||
if not tid or now - int(w[3]) > 15:
|
||||
continue
|
||||
if restart:
|
||||
if w[0] == "hold":
|
||||
out.append("force %s %s" % (tid, w[3]))
|
||||
continue
|
||||
out.append("ack %s" % tid if w[0] == "tap" else "restart %s %s" % (tid, w[3]))
|
||||
return out
|
||||
|
||||
|
||||
def loop(site, stop):
|
||||
m = Mirror(site)
|
||||
site.mirror = m
|
||||
last = 0.0
|
||||
stop.wait(15)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
m.poll()
|
||||
if time.time() - last > REFRESH:
|
||||
last = time.time()
|
||||
m.refresh_pages()
|
||||
except Exception as e: # noqa: BLE001 - the page must keep running
|
||||
data = load(site.data_dir)
|
||||
data["error"] = str(e)[:300]
|
||||
save(site.data_dir, data)
|
||||
stop.wait(POLL)
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Auto-setup's eyes: what this location has, read from Companion, Dante, the network MIDI sessions,
|
||||
this Mac's own network, and the computers that joined (each one ran the join line once).
|
||||
|
||||
look(cfg, data_dir) the inventory autoconf.py turns into keys
|
||||
register(data_dir, hello) a computer's join report, kept in computers.json
|
||||
|
||||
Read-only everywhere: nothing here changes Companion, a session or a computer. Every remote
|
||||
command is a fixed string; names from the network only ever come back as data.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
|
||||
import status_panel as sp
|
||||
|
||||
TIMEOUT = 8
|
||||
# find, not a glob: in zsh (the Macs' login shell) a pattern that matches nothing aborts the whole line.
|
||||
MAC_LOOK = "echo SP_PROCS; ps -axo comm= 2>/dev/null; echo SP_APPS; find /Applications -maxdepth 3 -name '*.app' -prune 2>/dev/null; echo SP_END"
|
||||
WIN_LOOK = "echo SP_PROCS & tasklist /FO CSV /NH 2>nul & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_END"
|
||||
HOST_FIELDS = ("host", "bonjourHost", "serverHost", "ip", "address")
|
||||
|
||||
|
||||
# --- the computers that joined ---------------------------------------------------------
|
||||
|
||||
def registry_path(data_dir):
|
||||
return os.path.join(data_dir, "computers.json")
|
||||
|
||||
|
||||
def registered(data_dir):
|
||||
try:
|
||||
with open(registry_path(data_dir)) as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def clean_hello(hello, peer_ip):
|
||||
"""A join report, kept to the fields auto-setup uses and checked: it comes from the network."""
|
||||
def s(v, n=80):
|
||||
return re.sub(r"[^\w .@:\-()'+]", "", str(v or ""))[:n]
|
||||
ips = [str(ipaddress.ip_address(i)) for i in (hello.get("ips") or []) if _is_ip(i)][:12]
|
||||
if peer_ip and _is_ip(peer_ip) and peer_ip not in ips:
|
||||
ips.insert(0, peer_ip)
|
||||
user = str(hello.get("user") or "")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._\-][A-Za-z0-9._\- ]{0,39}", user): # as given: a name to log in with is never "cleaned up"
|
||||
raise ValueError("no usable user name")
|
||||
return {"name": s(hello.get("name")) or user, "hostname": s(hello.get("hostname"), 60), "user": user,
|
||||
"os": "windows" if str(hello.get("os", "")).lower().startswith("win") else "mac", "ips": ips,
|
||||
"macs": [m for m in (str(x).lower().replace("-", ":") for x in hello.get("macs") or []) if re.fullmatch(r"([0-9a-f]{2}:){5}[0-9a-f]{2}", m)][:4],
|
||||
"tasks": [s(t, 60) for t in hello.get("tasks") or []][:20], "at": int(time.time())}
|
||||
|
||||
|
||||
def _is_ip(v):
|
||||
try:
|
||||
ipaddress.ip_address(str(v))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def register(data_dir, hello, peer_ip=None):
|
||||
entry = clean_hello(hello, peer_ip)
|
||||
reg = registered(data_dir)
|
||||
key = entry["name"]
|
||||
reg[key] = entry
|
||||
sp.write_json(registry_path(data_dir), reg)
|
||||
return entry
|
||||
|
||||
|
||||
# --- this Mac --------------------------------------------------------------------------
|
||||
|
||||
def run(argv, timeout=TIMEOUT):
|
||||
try:
|
||||
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
|
||||
def wifi_ports():
|
||||
out = run(["/usr/sbin/networksetup", "-listallhardwareports"])
|
||||
ports, last = set(), ""
|
||||
for line in out.splitlines():
|
||||
if line.startswith("Hardware Port:"):
|
||||
last = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("Device:") and last in ("Wi-Fi", "AirPort"):
|
||||
ports.add(line.split(":", 1)[1].strip())
|
||||
return ports
|
||||
|
||||
|
||||
def interfaces():
|
||||
"""{interface: (ip, network)} for every IPv4 address on this Mac, from ifconfig."""
|
||||
out, cur = {}, None
|
||||
for line in run(["/sbin/ifconfig"]).splitlines():
|
||||
m = re.match(r"^(\S+?):", line)
|
||||
if m:
|
||||
cur = m.group(1)
|
||||
continue
|
||||
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+) netmask 0x([0-9a-f]{8})", line)
|
||||
if m and cur:
|
||||
mask = ipaddress.ip_address(int(m.group(2), 16))
|
||||
net = ipaddress.ip_network("%s/%s" % (m.group(1), mask), strict=False)
|
||||
out.setdefault(cur, (m.group(1), str(net)))
|
||||
return out
|
||||
|
||||
|
||||
def gateway_of(interface):
|
||||
out = run(["/usr/sbin/netstat", "-rn", "-f", "inet"])
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == "default" and parts[-1] == interface:
|
||||
return parts[1]
|
||||
return ""
|
||||
|
||||
|
||||
def this_mac():
|
||||
"""Its names and addresses, and which network is the wired one: the interface with a gateway that
|
||||
is neither Wi-Fi nor a link-local (Dante) address, preferring the one the default route uses."""
|
||||
ifs = interfaces()
|
||||
wifi = wifi_ports()
|
||||
route = run(["/sbin/route", "-n", "get", "default"])
|
||||
m = re.search(r"interface: (\S+)", route)
|
||||
default_if = m.group(1) if m else ""
|
||||
wired = None
|
||||
for name in [default_if] + sorted(ifs):
|
||||
if name in ifs and name not in wifi and not ifs[name][0].startswith(("127.", "169.254.")) and gateway_of(name):
|
||||
wired = name
|
||||
break
|
||||
if wired is None:
|
||||
wired = next((n for n in sorted(ifs) if n not in wifi and not ifs[n][0].startswith(("127.", "169.254."))), None)
|
||||
info = {"name": run(["/usr/sbin/scutil", "--get", "ComputerName"]).strip(),
|
||||
"hostname": (run(["/usr/sbin/scutil", "--get", "LocalHostName"]).strip() + ".local").lstrip("."),
|
||||
"ips": [ip for n, (ip, _) in sorted(ifs.items()) if not ip.startswith("127.")]}
|
||||
# What runs on this Mac itself (ProPresenter on Woonsocket's Companion Mac), with each app's path.
|
||||
ps = run(["/bin/ps", "-axo", "comm="]).splitlines()
|
||||
info["procs"] = sorted({os.path.basename(p.strip()) for p in ps if p.strip()})
|
||||
info["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app" 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:
|
||||
ip, net = ifs[wired]
|
||||
# A /24 or smaller on a location usually sits inside a /16 the rest of the gear shares: use what is configured.
|
||||
info["wired"] = {"interface": wired, "ip": ip, "net": net, "gateway": gateway_of(wired)}
|
||||
return info
|
||||
|
||||
|
||||
# --- Companion -------------------------------------------------------------------------
|
||||
|
||||
def companion_get(cfg, path, timeout=5):
|
||||
c = cfg.get("companion") or {}
|
||||
url = "http://%s:%s%s" % (c.get("host", "127.0.0.1"), c.get("port", 8000), path)
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def companion_var(cfg, label, name):
|
||||
try:
|
||||
return companion_get(cfg, "/api/variable/%s/%s/value" % (urllib.parse.quote(label, safe=""), urllib.parse.quote(name, safe="")), 3)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def connection_hosts(cfg):
|
||||
"""{label: address} from Companion's own database, opened read-only (the host field differs by module)."""
|
||||
import sqlite3
|
||||
path = sp.companion_db_path(cfg)
|
||||
if not path:
|
||||
return {}
|
||||
db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2)
|
||||
try:
|
||||
rows = db.execute("SELECT value FROM instances").fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
out = {}
|
||||
for (value,) in rows:
|
||||
try:
|
||||
inst = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not isinstance(inst, dict) or not inst.get("label"):
|
||||
continue
|
||||
conf = inst.get("config") or {}
|
||||
host = next((str(conf[k]).strip() for k in HOST_FIELDS if isinstance(conf.get(k), str) and conf[k].strip()), "")
|
||||
out[inst["label"]] = host.split("|")[-1].rsplit(":", 1)[0] if host.count(":") == 1 else host
|
||||
return out
|
||||
|
||||
|
||||
def connections(cfg):
|
||||
raw = json.loads(companion_get(cfg, "/api/connections"))
|
||||
items = raw if isinstance(raw, list) else list(raw.values()) if isinstance(raw, dict) else []
|
||||
hosts = connection_hosts(cfg)
|
||||
out = []
|
||||
for c in items:
|
||||
if not isinstance(c, dict) or not c.get("label"):
|
||||
continue
|
||||
entry = {"label": c["label"], "module": c.get("moduleId") or c.get("instance_type") or "", "enabled": c.get("enabled", True) is not False,
|
||||
"host": hosts.get(c["label"], ""), "status": ((c.get("status") or {}).get("category") if isinstance(c.get("status"), dict) else "")}
|
||||
if entry["module"] in ("shure-wireless", "shure-psm1000"):
|
||||
probe = "ch_%d_battery_bars" if entry["module"] == "shure-wireless" else "ch_%d_rf_mute"
|
||||
n = 0
|
||||
for ch in range(1, 9):
|
||||
if companion_var(cfg, c["label"], probe % ch).strip() in ("", "undefined"):
|
||||
break
|
||||
n = ch
|
||||
entry["channels"] = n or None
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def dante(cfg, conns):
|
||||
label = next((c["label"] for c in conns if c["module"] == "audinate-dantecontroller" and c["enabled"]), None)
|
||||
if not label:
|
||||
return {}
|
||||
try:
|
||||
devices = json.loads(companion_var(cfg, label, "devices") or "[]")
|
||||
except ValueError:
|
||||
devices = []
|
||||
return {"conn": label, "devices": sorted(d for d in devices if isinstance(d, str))}
|
||||
|
||||
|
||||
# --- the computers ---------------------------------------------------------------------
|
||||
|
||||
def sections(out):
|
||||
parts, cur = {}, None
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("SP_") and line[3:].isupper():
|
||||
cur = line[3:]
|
||||
parts[cur] = []
|
||||
elif cur:
|
||||
parts[cur].append(line)
|
||||
return parts
|
||||
|
||||
|
||||
def look_at(cfg, alias, host):
|
||||
"""What runs on one computer: its programs (and, on a Mac, its network MIDI sessions)."""
|
||||
os_name = host.get("os", "mac")
|
||||
try:
|
||||
r = subprocess.run(sp.ssh_cmd(cfg, alias) + [MAC_LOOK if os_name == "mac" else WIN_LOOK], capture_output=True, text=True, timeout=TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"reachable": False}
|
||||
if r.returncode != 0 or "SP_END" not in r.stdout:
|
||||
return {"reachable": False, "error": (r.stderr or r.stdout).strip()[-160:]}
|
||||
got = sections(r.stdout)
|
||||
out = {"reachable": True}
|
||||
if os_name == "mac":
|
||||
out["procs"] = sorted({os.path.basename(p.strip()) for p in got.get("PROCS", []) if p.strip()})
|
||||
# Where each running program's app is, from its own path (".../SuperRack SoundGrid.app/Contents/MacOS/...").
|
||||
out["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app"
|
||||
for p in got.get("PROCS", []) if ".app/Contents/MacOS/" in p}
|
||||
out["apps"] = sorted(a.strip() for a in got.get("APPS", []) if a.strip().endswith(".app"))
|
||||
try:
|
||||
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
|
||||
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
|
||||
out["python"] = m.returncode != 97
|
||||
if not out["python"]:
|
||||
out["midi"] = sp.jxa_midi(cfg, alias) or {}
|
||||
except subprocess.TimeoutExpired:
|
||||
out["midi"] = {}
|
||||
else:
|
||||
out["procs"] = sorted({row.split('","')[0].strip('"') for row in got.get("PROCS", []) if row.startswith('"')})
|
||||
out["tasks"] = sorted({row.split('","')[0].strip('"').split("\\")[-1] for row in got.get("TASKS", [])
|
||||
if row.startswith('"\\StatusPanel\\')})
|
||||
return out
|
||||
|
||||
|
||||
def computers(cfg, data_dir):
|
||||
"""The joined computers and the ones already in the settings, each looked at over SSH."""
|
||||
found = {}
|
||||
hosts = dict(cfg.get("hosts") or {})
|
||||
for alias, h in hosts.items():
|
||||
if h.get("self") or h.get("disabled") or not h.get("ssh"):
|
||||
continue
|
||||
ip = str(h["ssh"]).split("@")[-1]
|
||||
found[alias] = {"name": h.get("name", alias), "os": h.get("os", "mac"), "user": str(h["ssh"]).split("@")[0], "ip": ip,
|
||||
"ips": [ip], "macs": [h["mac"]] if h.get("mac") else []}
|
||||
known_ips = {c["ip"] for c in found.values()}
|
||||
taken = set(found) | set(hosts)
|
||||
nets = [(this_mac().get("wired") or {}).get("net")]
|
||||
for name, j in registered(data_dir).items():
|
||||
ip = next((i for i in j["ips"] if any(n and ipaddress.ip_address(i) in ipaddress.ip_network(n) for n in nets)), j["ips"][0] if j["ips"] else None)
|
||||
if not ip or ip in known_ips:
|
||||
continue
|
||||
from settings.autoconf import slug
|
||||
alias = slug(j["name"].replace(" ", "_"), taken)
|
||||
taken.add(alias)
|
||||
found[alias] = {"name": j["name"], "hostname": j.get("hostname", ""), "os": j["os"], "user": j["user"], "ip": ip, "ips": j["ips"],
|
||||
"macs": j.get("macs") or [], "tasks": j.get("tasks") or []}
|
||||
probe_cfg = dict(cfg, hosts=dict(hosts, **{a: {"ssh": "%s@%s" % (c["user"], c["ip"]), "os": c["os"]} for a, c in found.items()}))
|
||||
for alias, c in found.items():
|
||||
seen = look_at(probe_cfg, alias, probe_cfg["hosts"][alias])
|
||||
if c.get("tasks") and "tasks" in seen:
|
||||
seen["tasks"] = sorted(set(seen["tasks"]) | set(c["tasks"]))
|
||||
c.update(seen)
|
||||
return found
|
||||
|
||||
|
||||
# --- everything ------------------------------------------------------------------------
|
||||
|
||||
def look(cfg, data_dir):
|
||||
inv = {"ingest": this_mac(), "at": int(time.time())}
|
||||
# A location whose MIDI rule names its wired network keeps it (it may be wider than this Mac's subnet).
|
||||
rule = [n for t in sp.all_tiles(cfg) for c in t.get("checks", []) if c.get("type") == "midi_wired" for n in c.get("nets", [])]
|
||||
if rule and inv["ingest"].get("wired"):
|
||||
inv["ingest"]["wired"]["net"] = rule[0]
|
||||
try:
|
||||
inv["connections"] = connections(cfg)
|
||||
except (OSError, ValueError) as e:
|
||||
inv["connections"], inv["companion_error"] = [], str(e)
|
||||
inv["dante"] = dante(cfg, inv["connections"])
|
||||
try:
|
||||
import coremidi
|
||||
inv["midi"] = {"": coremidi.sessions()}
|
||||
except Exception as e: # noqa: BLE001 - no CoreMIDI (tests, Linux): no sessions here
|
||||
inv["midi"], inv["midi_error"] = {}, str(e)
|
||||
inv["computers"] = computers(cfg, data_dir)
|
||||
for alias, c in inv["computers"].items():
|
||||
if c.get("midi"):
|
||||
inv["midi"][alias] = c.pop("midi")
|
||||
return inv
|
||||
@@ -69,6 +69,8 @@ def check_sentence(chk, cfg):
|
||||
if t == "midi_peer":
|
||||
where = ("on %s" % who(chk["host"])) if chk.get("host") else "on this Mac"
|
||||
return "the network MIDI session %s %s has %s in it" % (e(chk["session"]), where, e(chk["peer"]))
|
||||
if t == "midi_wired":
|
||||
return "every peer in the network MIDI sessions (here and on the other Macs) comes over the wired network %s, not Wi-Fi or Dante" % e(", ".join(chk.get("nets") or []))
|
||||
if t == "pref":
|
||||
return "the setting %s in %s is %s" % (e(chk["key"]), e(chk["domain"]), e(chk["want"]))
|
||||
if t == "spotify":
|
||||
@@ -161,6 +163,14 @@ def key_guide(cfg):
|
||||
parts.append("<p>Shows the worst of: %s. Hold: %s.</p>" % (", ".join(e(tiles[m]["label"]) for m in t["members"]), hold_words(t, cfg)))
|
||||
if t.get("kind") == "service":
|
||||
parts.append("<p>%s</p>" % e(ph["alarms"]))
|
||||
if t.get("kind") == "autofix":
|
||||
parts.append("<p>Press: pause auto-fix through the hours it is in now, or through the next ones (auto-fix %s); press "
|
||||
"again to resume. It resumes by itself when those hours end. Holds always fix.</p>" % e(ph["auto_fix"]))
|
||||
if t.get("kind") == "bypass":
|
||||
b = t.get("bypass") or {}
|
||||
parts.append("<p>Hold: hand it to an operator - the MIDI links to %s are taken out%s, and the panel stops putting them "
|
||||
"back. Hold again: everything is put back as it was.</p>" % (
|
||||
e(", ".join(b.get("peers") or [])), (" and Companion's %s is switched off" % e(", ".join(b["conns"]))) if b.get("conns") else ""))
|
||||
if t.get("kind") == "network":
|
||||
n = cfg.get("network", {})
|
||||
parts.append("<p>Watches this Mac's network port%s, and calls it a blip when %d connections from %d groups drop within %d s "
|
||||
@@ -185,6 +195,9 @@ def key_guide(cfg):
|
||||
byself = sorted(f for f in own if f in auto)
|
||||
if byself:
|
||||
parts.append("<p class='muted'>Auto-fix %s: %s.</p>" % (e(ph["auto_fix"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in byself)))
|
||||
failover = sorted(f for f in own if f in cfg.get("fixes", {}) and cfg["fixes"][f].get("failover"))
|
||||
if failover:
|
||||
parts.append("<p class='muted'>Failover %s: %s.</p>" % (e(ph["failover"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in failover)))
|
||||
parts.append("</div>")
|
||||
rp = cfg.get("restart_page")
|
||||
if rp:
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Smart outlets the panel may switch (a hung recorder's mains, say), through Tuya's cloud with the
|
||||
campus's own project keys. Credentials and the outlet list live in outlets.json beside the config
|
||||
location's own project keys. Credentials and the outlet list live in outlets.json beside the config
|
||||
(0600, never in the repo). Also a command for the watchdog's fixes:
|
||||
|
||||
outlet.py list the outlets on the account (name, id, on/off)
|
||||
|
||||
+3
-1
@@ -22,7 +22,7 @@ TIMEOUT = 8
|
||||
|
||||
MAC_PROBE = (
|
||||
'echo "SP_NAME=$(uname -n)"; echo "SP_OSV=$(sw_vers -productVersion 2>/dev/null)"; '
|
||||
'echo "SP_PY=$(/usr/bin/python3 --version 2>&1)"; '
|
||||
'echo "SP_PY=$(xcode-select -p >/dev/null 2>&1 && /usr/bin/python3 --version 2>&1 || echo none)"; '
|
||||
'echo "SP_KEY=$(grep -c status-panel ~/.ssh/authorized_keys 2>/dev/null)"; '
|
||||
'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; '
|
||||
'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; '
|
||||
@@ -349,6 +349,8 @@ def probe_all(cfg, data_dir=None, only=None):
|
||||
for alias, host in cfg.get("hosts", {}).items():
|
||||
if only and alias not in only:
|
||||
continue
|
||||
if host.get("disabled"): # switched off in the settings: not looked at
|
||||
continue
|
||||
|
||||
def work(alias=alias, host=host):
|
||||
try:
|
||||
|
||||
+29
-1
@@ -44,15 +44,43 @@ def ps_quote(value, what):
|
||||
return "'" + value + "'"
|
||||
|
||||
|
||||
def admin_keys(cfg):
|
||||
"""The admin laptop's SSH keys (site.admin_keys), checked: one line each, no quotes."""
|
||||
out = []
|
||||
for k in (cfg.get("site") or {}).get("admin_keys") or []:
|
||||
k = str(k).strip()
|
||||
if not re.fullmatch(r"ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( [A-Za-z0-9@._-]+)?", k):
|
||||
raise ValueError("site.admin_keys: %r is not one public SSH key line" % k[:40])
|
||||
out.append(k)
|
||||
return out
|
||||
|
||||
|
||||
def site_values(cfg):
|
||||
site = cfg.get("site") or {}
|
||||
key, mark = public_key(cfg)
|
||||
# The admin laptop (site.admin_ips / admin_keys) gets in everywhere this Mac does, so running a set-up script
|
||||
# again never shuts it out (2026-10-21, the user: "keep SSH on at all times for windows pc, just like the macs").
|
||||
ips = list(dict.fromkeys(list(site.get("ingest_ips") or []) + list(site.get("admin_ips") or [])))
|
||||
return {"INGEST_NAME": site.get("ingest_name") or "the Ingest Mac",
|
||||
"INGEST_IPS": ", ".join("'%s'" % ip for ip in site.get("ingest_ips") or []) or "'127.0.0.1'",
|
||||
"INGEST_IPS": ", ".join("'%s'" % ip for ip in ips if re.fullmatch(r"[0-9a-fA-F.:]+", str(ip))) or "'127.0.0.1'",
|
||||
"ADMIN_KEYS": ", ".join("'%s'" % k for k in admin_keys(cfg)),
|
||||
"KEY": key or "<< this Mac has no login key yet: run install.sh, then come back >>",
|
||||
"KEY_COMMENT": mark or "status-panel", "DVS_HELPER": site.get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"}
|
||||
|
||||
|
||||
ADMIN_CHECK = """if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this as administrator: close this window, right-click Windows PowerShell > Run as administrator, then paste the line again. Nothing was changed.'
|
||||
}"""
|
||||
|
||||
|
||||
def with_admin_check(text):
|
||||
"""The script stops with plain words unless PowerShell runs as administrator: the check goes after the
|
||||
whole header comment line (inserting it mid-line left "} for ... - run ONCE" as code, 2026-10-21)."""
|
||||
lines = text.split("\n")
|
||||
at = next((i for i, ln in enumerate(lines) if ln.startswith("# Status panel remote access")), 0)
|
||||
return "\n".join(lines[:at + 1] + [ADMIN_CHECK] + lines[at + 1:])
|
||||
|
||||
|
||||
def windows_setup(cfg, alias):
|
||||
host = cfg["hosts"][alias]
|
||||
tasks = [r for r in host.get("runs") or [] if r.get("kind") == "task"]
|
||||
|
||||
+216
-9
@@ -40,6 +40,7 @@ from settings import builders
|
||||
from settings import scripts
|
||||
from settings import outlet as outlets
|
||||
from settings import keyguide, probes
|
||||
from settings import campus, campuses, discover
|
||||
|
||||
log = logging.getLogger("status-settings")
|
||||
UI = os.path.join(HERE, "ui")
|
||||
@@ -115,6 +116,7 @@ class Site:
|
||||
self.page_cache = {} # name -> (time, value)
|
||||
self.draft = applying.Draft(data_dir)
|
||||
self.applier = applying.Applier(self)
|
||||
self.discover_poke = threading.Event() # a computer joined: look again soon
|
||||
outlets.DATA_DIR, outlets.FILE = self.data_dir, os.path.join(self.data_dir, "outlets.json")
|
||||
|
||||
def load(self):
|
||||
@@ -148,6 +150,69 @@ class Site:
|
||||
self.page_cache[name] = (now, value)
|
||||
return value
|
||||
|
||||
# --- Companion's own pages, for picking a button by looking at it -----------------
|
||||
def companion_pages(self):
|
||||
"""[{"page", "name"}] for every page Companion has (read-only), all asked at once."""
|
||||
def one(n):
|
||||
try:
|
||||
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % n, timeout=3)
|
||||
except (OSError, urllib.error.URLError):
|
||||
return None
|
||||
if status != 200:
|
||||
return None
|
||||
name = " ".join(raw.decode("utf-8", "replace").replace("\\n", " ").split())
|
||||
return {"page": n, "name": re.sub(r"^%d\s+" % n, "", name) or name} # "4 Audio" -> "Audio"
|
||||
|
||||
def look():
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
with ThreadPoolExecutor(max_workers=16) as pool:
|
||||
found = list(pool.map(one, range(1, 100)))
|
||||
out = []
|
||||
for f in found: # up to the first page Companion does not have
|
||||
if f is None:
|
||||
break
|
||||
out.append(f)
|
||||
return out
|
||||
return self.cached("companion-pages", 60, look)
|
||||
|
||||
def companion_page(self, n):
|
||||
"""One Companion page as its buttons look: text, colours and image, by row and column (read-only)."""
|
||||
def look():
|
||||
status, raw = self.companion_get("/int/export/page/%d?includeSecrets=false" % n, timeout=6)
|
||||
try:
|
||||
raw = gzip.decompress(raw)
|
||||
except OSError:
|
||||
pass
|
||||
page = (json.loads(raw).get("page") or {})
|
||||
val = lambda p: p.get("value") if isinstance(p, dict) and not p.get("isExpression") else None
|
||||
buttons = []
|
||||
for r, row in (page.get("controls") or {}).items():
|
||||
for c, ctl in (row or {}).items():
|
||||
if not ctl:
|
||||
continue
|
||||
b = {"row": int(r), "col": int(c), "type": ctl.get("type")}
|
||||
st = ctl.get("style") or {}
|
||||
if ctl.get("type") == "button-layered":
|
||||
lay = {}
|
||||
for l in st.get("layers") or []:
|
||||
lay.setdefault(l.get("type"), l)
|
||||
text = (lay.get("text") or {}).get("text") or {}
|
||||
b["text"] = val(text) if val(text) is not None else ("…" if text.get("isExpression") else "")
|
||||
b["bg"] = val((lay.get("box") or {}).get("color") or {})
|
||||
b["fg"] = val((lay.get("text") or {}).get("color") or {})
|
||||
img = val((lay.get("image") or {}).get("base64Image") or {})
|
||||
if img and len(img) < 60000:
|
||||
b["img"] = img if img.startswith("data:") else "data:image/png;base64," + img
|
||||
elif ctl.get("type") == "button":
|
||||
b["text"], b["bg"], b["fg"] = st.get("text") or "", st.get("bgcolor"), st.get("color")
|
||||
if st.get("png64") and len(st["png64"]) < 60000:
|
||||
b["img"] = st["png64"] if st["png64"].startswith("data:") else "data:image/png;base64," + st["png64"]
|
||||
if isinstance(b.get("text"), str):
|
||||
b["text"] = b["text"].replace("\\n", "\n")
|
||||
buttons.append(b)
|
||||
return {"page": n, "name": (page.get("name") or "").replace("\\n", " "), "grid": page.get("gridSize"), "buttons": buttons}
|
||||
return self.cached("companion-page-%d" % n, 10, look)
|
||||
|
||||
# --- what the screens show ---------------------------------------------
|
||||
|
||||
def overview(self):
|
||||
@@ -211,6 +276,7 @@ class Site:
|
||||
"live": live, "running": running, "service_on": snap.get("service_on") if fresh else None,
|
||||
"alarms_override": snap.get("alarms_override") if fresh else None,
|
||||
"auto_fix": {"phrase": schema.phrases(cfg)["auto_fix"], "state": snap.get("auto_fix")},
|
||||
"bypass": (snap.get("bypass") or {}) if fresh else {},
|
||||
"problems": sorted(problems, key=lambda p: (p["level"] != "err", p["since"] or 0)), "needs": needs,
|
||||
"computers": self.computer_lines(snap), "pages": pages, "snapshot_age": (now - snap["time"]) if snap.get("time") else None,
|
||||
"last_change": self.last_change(), "phrases": schema.phrases(cfg), "version": started.get("version"),
|
||||
@@ -286,7 +352,9 @@ class Site:
|
||||
for f in fs:
|
||||
if f["level"] == "err" and alias in owners.get((tid, f.get("check")), set()):
|
||||
faults.append("%s %s" % (labels.get(tid, tid), f["msg"]))
|
||||
if probe and not probe.get("reachable") and not h.get("self"):
|
||||
if h.get("disabled"):
|
||||
word, reason = "Off", "switched off in the settings: not looked at, its checks and fixes skipped"
|
||||
elif probe and not probe.get("reachable") and not h.get("self"):
|
||||
word, reason = "Problem", "cannot be reached (%s)" % probe.get("error", "no answer")
|
||||
elif faults:
|
||||
word, reason = "Problem", "; ".join(faults[:2])
|
||||
@@ -306,7 +374,8 @@ class Site:
|
||||
def look():
|
||||
out = {}
|
||||
for which, page, name in (("status", cfg["page"], cfg["page_name"]),) + (
|
||||
(("restart", cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]),) if cfg.get("restart_page") else ()):
|
||||
(("restart", cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]),) if cfg.get("restart_page") else ()) + (
|
||||
(("ready", cfg["ready_page"]["page"], cfg["ready_page"]["page_name"]),) if cfg.get("ready_page") else ()):
|
||||
try:
|
||||
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % page)
|
||||
live = raw.decode().replace("\\n", "\n") if status == 200 else None
|
||||
@@ -618,6 +687,65 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
|
||||
if path == "/where.html":
|
||||
return self.send(200, keyguide.where_things_are(site.load(), site.data_dir, self.my_address()), "text/html; charset=utf-8")
|
||||
# ---- auto-setup: the install line for a new location, the join lines for its computers ----
|
||||
if path in ("/campus/install", "/location/install"):
|
||||
key, _ = scripts.public_key(site.load())
|
||||
return self.send(200, campus.install_script(self.base_url(), q.get("id", ["new"])[0], key), "text/plain; charset=utf-8")
|
||||
if path in ("/campus/program.tgz", "/location/program.tgz"):
|
||||
return self.send(200, campus.program_kit(site.data_dir), "application/gzip")
|
||||
if path in ("/campus/companion.zip", "/location/companion.zip"):
|
||||
# Companion 5.0.7 as this Mac has it, for `settings.campus upgrade-companion` on a location
|
||||
# still on an older one (packed once, kept beside the config, streamed).
|
||||
app = "/Applications/Companion 5.0.7.app"
|
||||
pack = os.path.join(site.data_dir, "companion-5.0.7.zip")
|
||||
if not os.path.exists(app):
|
||||
return self.send(404, {"error": "no Companion 5.0.7 on this Mac"})
|
||||
if not os.path.exists(pack):
|
||||
subprocess.run(["/usr/bin/ditto", "-c", "-k", "--keepParent", app, pack + ".tmp"], check=True, timeout=900)
|
||||
os.replace(pack + ".tmp", pack)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
self.send_header("Content-Length", str(os.path.getsize(pack)))
|
||||
self.end_headers()
|
||||
with open(pack, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
return
|
||||
if path == "/join":
|
||||
return self.send(200, campus.join_mac(site.load(), self.base_url()), "text/plain; charset=utf-8")
|
||||
m = re.fullmatch(r"/setup/([A-Za-z0-9_]+)\.ps1", path)
|
||||
if m:
|
||||
# One computer's set-up script, for "irm http://<this Mac>:8020/setup/<name>.ps1 | iex" in an
|
||||
# administrator PowerShell on that PC (2026-10-21: the Bible TV PC's SSH had stopped).
|
||||
cfg = site.load()
|
||||
h = (cfg.get("hosts") or {}).get(m.group(1))
|
||||
if not h or h.get("os") != "windows":
|
||||
return self.send(404, {"error": "no Windows computer called %s here" % m.group(1)})
|
||||
text = "# Run in PowerShell as administrator: irm %s/setup/%s.ps1 | iex\n%s" % (
|
||||
self.base_url(), m.group(1), scripts.with_admin_check(scripts.windows_setup(cfg, m.group(1))))
|
||||
return self.send(200, text, "text/plain; charset=utf-8")
|
||||
if path == "/join.ps1":
|
||||
return self.send(200, campus.join_windows(site.load(), self.base_url()), "text/plain; charset=utf-8")
|
||||
if path == "/api/state":
|
||||
return self.send(200, campuses.state_now(site))
|
||||
if path == "/api/campuses":
|
||||
# The hub's own view (its settings page). A location only ever lists itself.
|
||||
data = campuses.load(site.data_dir)
|
||||
hub = campuses.Mirror.is_hub(data)
|
||||
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if hub else {},
|
||||
"error": data.get("error"), "hub": hub, "standalone": bool(data.get("standalone"))})
|
||||
if path == "/api/fleet":
|
||||
return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())})
|
||||
if path == "/api/discover":
|
||||
cfg = site.load()
|
||||
base = self.base_url()
|
||||
return self.send(200, {"auto": bool((cfg.get("discover") or {}).get("auto")), "last": campus.load_last(site.data_dir),
|
||||
"joined": discover.registered(site.data_dir),
|
||||
"join": {"mac": "curl -fsSL %s/join | sh" % base, "pc": "irm %s/join.ps1 | iex" % base},
|
||||
"base": base, "hub": campuses.Mirror.is_hub(campuses.load(site.data_dir))})
|
||||
if path == "/api/config":
|
||||
cfg = site.load()
|
||||
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
|
||||
@@ -628,6 +756,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(200, site.overview())
|
||||
if path == "/api/companion":
|
||||
return self.send(200, site.companion_view())
|
||||
if path == "/api/companion/pages":
|
||||
try:
|
||||
return self.send(200, {"pages": site.companion_pages()})
|
||||
except (OSError, ValueError) as e:
|
||||
return self.send(200, {"pages": [], "error": "Companion did not answer: %s" % e})
|
||||
if path == "/api/companion/page":
|
||||
n = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("n") or ["0"])[0]
|
||||
if not n.isdigit() or not 1 <= int(n) <= 99:
|
||||
return self.send(400, {"error": "which page?"})
|
||||
try:
|
||||
return self.send(200, site.companion_page(int(n)))
|
||||
except (OSError, ValueError) as e:
|
||||
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
|
||||
if path == "/api/machines":
|
||||
return self.send(200, site.machines_view())
|
||||
if path == "/api/gear":
|
||||
@@ -694,7 +835,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if os.path.exists(fp):
|
||||
z.write(fp, name)
|
||||
z.writestr("README.txt", "Site bundle of %s, exported %s from the settings page.\nDrop it in the repo as sites/%s/ or feed it to "
|
||||
"'New campus from a bundle' on another campus's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
|
||||
"'New location from a bundle' on another location's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
|
||||
data = buf.getvalue()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/zip")
|
||||
@@ -766,6 +907,45 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(403, {"error": "wrong origin"})
|
||||
site = self.site
|
||||
try:
|
||||
if path == "/api/hello":
|
||||
# A computer ran the join line: keep what it says (checked), and look again soon.
|
||||
try:
|
||||
entry = discover.register(site.data_dir, body, self.client_address[0])
|
||||
except (ValueError, TypeError) as e:
|
||||
return self.send(400, {"error": str(e)})
|
||||
log.info("joined: %s (%s, %s) from %s", entry["name"], entry["os"], entry["user"], self.client_address[0])
|
||||
site.discover_poke.set()
|
||||
return self.send(200, {"ok": True})
|
||||
if path == "/api/campus/hello":
|
||||
# Another location says where its settings page is (the hub keeps the list for everyone).
|
||||
cid = str(body.get("id") or "")
|
||||
url = str(body.get("url") or "")
|
||||
if not re.fullmatch(r"[a-z0-9]{1,8}", cid) or not re.fullmatch(r"http://\d{1,3}(\.\d{1,3}){3}:\d{2,5}", url) \
|
||||
or not allowed_client(url[7:].split(":")[0]):
|
||||
return self.send(400, {"error": "an id like ws and an address like http://10.5.1.1:8020"})
|
||||
data = campuses.load(site.data_dir)
|
||||
if not campuses.Mirror.is_hub(data):
|
||||
return self.send(403, {"error": "this settings page keeps no list of locations"})
|
||||
lst = data.setdefault("list", {})
|
||||
for other in [k for k, e in lst.items() if k != cid and (e or {}).get("url") == url]:
|
||||
lst.pop(other) # the same Mac under its old name (before its first setup)
|
||||
user = str(body.get("user") or "")
|
||||
lst[cid] = dict({"id": cid, "name": str(body.get("name") or cid)[:40], "url": url},
|
||||
**({"user": user} if re.fullmatch(r"[A-Za-z0-9._-]{1,40}", user) else {}))
|
||||
campuses.save(site.data_dir, data)
|
||||
return self.send(200, {"ok": True})
|
||||
if path == "/api/fleet/hello":
|
||||
# A machine anywhere (any location) says it now takes the admin laptop's key: kept for the
|
||||
# laptop's SSH shortcuts (tools/fleet.py). Nothing here acts on it.
|
||||
try:
|
||||
entry = discover.clean_hello(body, self.client_address[0])
|
||||
except (ValueError, TypeError) as e:
|
||||
return self.send(400, {"error": str(e)})
|
||||
fleet = site.read_json("fleet.json", {}) or {}
|
||||
fleet["%s|%s" % (entry["name"], (entry["ips"] or ["?"])[0])] = entry
|
||||
sp.write_json(os.path.join(site.data_dir, "fleet.json"), fleet)
|
||||
log.info("fleet: %s (%s, %s) from %s", entry["name"], entry["os"], entry["user"], self.client_address[0])
|
||||
return self.send(200, {"ok": True})
|
||||
if path == "/api/editing/check":
|
||||
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
|
||||
if path == "/api/machines/probe":
|
||||
@@ -782,14 +962,32 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
ok, who = self.gate()
|
||||
if not ok:
|
||||
return
|
||||
if path == "/api/discover/now":
|
||||
# Look now and add what is new (also when adding by itself is off); never during a service.
|
||||
if getattr(site, "looking", None) and site.looking.is_alive():
|
||||
return self.send(200, {"started": False, "already": True})
|
||||
site.looking = threading.Thread(target=campus.look_and_add, args=(site,), kwargs={"force": True}, daemon=True)
|
||||
site.looking.start()
|
||||
log.info("auto-setup: look now, asked by %s", who)
|
||||
return self.send(200, {"started": True})
|
||||
if path == "/api/draft" and self.command == "PUT":
|
||||
cfg = schema.unmask(body.get("config") or {}, site.load())
|
||||
# A page that loaded the settings before they changed here (an Apply from another tab, a
|
||||
# program update) sends what it started from: only its own changes are put on top of the
|
||||
# live settings, so newer values are never quietly undone (2026-10-15).
|
||||
rebased = False
|
||||
if body.get("base") and body["base"] != site.cfg_sha and isinstance(body.get("base_config"), dict):
|
||||
base_cfg = schema.unmask(body["base_config"], site.load())
|
||||
cfg, _ = applying.rebase({"config": cfg}, site.load(), base_cfg)
|
||||
rebased = True
|
||||
log.info("draft from %s rebased: the page started from %s, the settings are now %s",
|
||||
who, str(body["base"])[:10], (site.cfg_sha or "")[:10])
|
||||
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
|
||||
if conflict:
|
||||
changed = applying.changed_paths(cfg, conflict["config"])
|
||||
return self.send(409, {"error": "%s changed this a moment ago" % conflict.get("by", "someone"), "draft": self.masked_draft(conflict),
|
||||
"changed": [applying.words_for(p, a, b, cfg, conflict["config"])["where"] for p, a, b in changed[:5]]})
|
||||
return self.send(200, {"draft": self.masked_draft(draft)})
|
||||
return self.send(200, {"draft": self.masked_draft(draft), "rebased": rebased, "live_sha": site.cfg_sha})
|
||||
if path == "/api/draft" and self.command == "DELETE":
|
||||
site.draft.clear()
|
||||
self.discard_atem()
|
||||
@@ -844,7 +1042,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if path == "/api/command":
|
||||
text = str(body.get("text", "")).strip()
|
||||
verb = text.split(" ")[0].lower() if text else ""
|
||||
if verb not in ("test", "resurvey", "service"):
|
||||
if verb not in ("test", "resurvey", "service", "autofix"):
|
||||
return self.send(400, {"error": "not a command the page may send"})
|
||||
if verb == "test":
|
||||
live, reason = site.applier.live()
|
||||
@@ -911,7 +1109,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
|
||||
cfg = site.load()
|
||||
mine = cfg.get("site") or {}
|
||||
src = (new_cfg.get("site") or {}).get("name") or "the other campus"
|
||||
src = (new_cfg.get("site") or {}).get("name") or "the other location"
|
||||
new_cfg["site"] = dict(new_cfg.get("site") or {}, **{k: mine[k] for k in ("id", "name", "ingest_name", "ingest_hostname", "ingest_ips", "editing_code", "settings_port") if k in mine})
|
||||
flag = "copied from %s - check it" % src
|
||||
for alias, h in (new_cfg.get("hosts") or {}).items():
|
||||
@@ -923,7 +1121,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
f["label"] = f.get("label", "")
|
||||
errors, warnings = schema.validate(new_cfg)
|
||||
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha)
|
||||
log.info("%s started a new campus from a bundle of %s", who, src)
|
||||
log.info("%s started a new location from a bundle of %s", who, src)
|
||||
return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
|
||||
if path == "/api/atem-reference/pick-newest":
|
||||
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
|
||||
@@ -961,7 +1159,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
def names_check(self, cfg):
|
||||
"""Every device name the keys use, against what this Companion, the Dante network and the Macs report now:
|
||||
the mapping step of a copied campus."""
|
||||
the mapping step of a copied location."""
|
||||
site = self.site
|
||||
try:
|
||||
live = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
|
||||
@@ -1130,7 +1328,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
if plan["restart"] or atem_pending:
|
||||
parts.append("the deck goes purple for about 5 seconds and comes back with your changes")
|
||||
if plan["pages"]:
|
||||
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart")))
|
||||
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart", "ready")))
|
||||
if plan["nav"]:
|
||||
parts.append("the page-number keys are recoloured")
|
||||
return (", ".join(parts) + ".").capitalize() if parts else "Nothing on the deck changes."
|
||||
@@ -1200,6 +1398,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
except OSError:
|
||||
return self.send(404, {"error": "missing %s" % name})
|
||||
|
||||
def base_url(self):
|
||||
"""This page as the caller reached it (an address, across locations: names do not cross subnets)."""
|
||||
host = (self.headers.get("Host") or "").strip() or "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
|
||||
if not re.fullmatch(r"[A-Za-z0-9.\-]+(:\d+)?", host):
|
||||
host = "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
|
||||
return "http://" + host
|
||||
|
||||
def my_address(self):
|
||||
cfg = self.site.cfg
|
||||
host = cfg.get("site", {}).get("ingest_hostname") or (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
|
||||
@@ -1311,6 +1516,8 @@ def main(argv=None):
|
||||
site, servers = serve(args.data_dir, port, args.bind, args.config)
|
||||
stop = threading.Event()
|
||||
threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start()
|
||||
threading.Thread(target=campus.loop, args=(site, stop, site.discover_poke), name="auto-setup", daemon=True).start()
|
||||
threading.Thread(target=campuses.loop, args=(site, stop), name="locations", daemon=True).start()
|
||||
try:
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
|
||||
+617
-86
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,7 @@ th { color: var(--muted); font-weight: 600; font-size: .78rem; text-transform: u
|
||||
.problems { list-style: none; padding: 0; margin: 0; } .problems li { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line); }
|
||||
.problems .lvl { display: inline-block; width: 10px; height: 10px; border-radius: 3px; }
|
||||
.pill { display: inline-block; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); font-size: .82rem; font-family: var(--mono); }
|
||||
.pill.err { background: transparent; color: var(--problem); border: 1px solid var(--problem); }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; } .chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; background: var(--surface); border: 1px solid var(--line); font-size: .85rem; }
|
||||
.chip.ok { border-color: var(--ok); } .chip.bad { border-color: var(--problem); color: var(--problem); }
|
||||
.deck { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 5px; background: var(--deck); padding: 8px; border-radius: 12px; max-width: 640px; width: 100%; }
|
||||
@@ -137,3 +138,18 @@ details.script textarea { width: 100%; font-size: .8rem; }
|
||||
.checks { margin-top: 8px; }
|
||||
.chkgroup { margin: 10px 0 2px; font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
||||
.chk { margin: 3px 0; }
|
||||
|
||||
#dialog .box.wide { max-width: min(760px, 96vw); width: 760px; }
|
||||
.cp-deck { max-width: 100%; margin: 6px 0; }
|
||||
.cp-deck .key { cursor: pointer; font-size: .62rem; text-shadow: 0 0 2px #000; }
|
||||
.cp-deck .key.cp-key:hover { border-color: #9be7a6; }
|
||||
.cp-deck .key.sel { border-color: #fff; box-shadow: 0 0 0 2px #2ecc71; }
|
||||
.trig { border-top: 1px solid var(--line); padding: 10px 0 4px; }
|
||||
.trig:first-of-type { border-top: 0; }
|
||||
|
||||
.onoff { display: inline-flex; align-items: center; cursor: pointer; vertical-align: middle; user-select: none; }
|
||||
.onoff input { display: none; }
|
||||
.onoff span { font: 700 .62rem/1 var(--font); letter-spacing: .04em; padding: 4px 8px; border-radius: 999px; background: #4a4f4c; color: #cfd3d1; min-width: 2.6em; text-align: center; }
|
||||
.onoff.on span { background: #1f8f4e; color: #fff; }
|
||||
.onoff input:disabled + span { opacity: .75; cursor: default; }
|
||||
tr.off td, .trig.off, .key.off { opacity: .55; }
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
# Sites
|
||||
|
||||
One folder per campus. `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
|
||||
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 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
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"site": {
|
||||
"id": "new",
|
||||
"name": "New campus",
|
||||
"name": "New location",
|
||||
"ingest_name": "Ingest Mac",
|
||||
"ingest_hostname": "",
|
||||
"ingest_ips": [],
|
||||
@@ -48,6 +48,15 @@
|
||||
],
|
||||
"kind": "service"
|
||||
},
|
||||
{
|
||||
"id": "autofix",
|
||||
"label": "AUTO-FIX",
|
||||
"pos": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"kind": "autofix"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"label": "NET",
|
||||
@@ -95,5 +104,10 @@
|
||||
"burst_groups": 3,
|
||||
"burst_window_seconds": 10,
|
||||
"remember_seconds": 300
|
||||
}
|
||||
},
|
||||
"nav_keys": [
|
||||
"pagenum"
|
||||
],
|
||||
"nav_hold_ms": 1000,
|
||||
"nav_tap_page": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
{
|
||||
"site": {
|
||||
"id": "sc",
|
||||
"name": "Southcoast",
|
||||
"ingest_name": "SC ProPresenter",
|
||||
"ingest_hostname": "SC-ProPresenter-423.local",
|
||||
"ingest_ips": [
|
||||
"10.5.2.60",
|
||||
"192.168.2.1",
|
||||
"169.254.161.214",
|
||||
"169.254.108.73"
|
||||
],
|
||||
"contacts": "",
|
||||
"live_services": [],
|
||||
"editing_code": "5690",
|
||||
"admin_ips": [
|
||||
"10.6.7.38"
|
||||
],
|
||||
"admin_keys": [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
|
||||
]
|
||||
},
|
||||
"companion": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000
|
||||
},
|
||||
"page": 19,
|
||||
"page_name": "19\\nStatus",
|
||||
"page_hold_ms": 2000,
|
||||
"variables": {
|
||||
"state": "sp_state",
|
||||
"heartbeat": "sp_hb",
|
||||
"command": "sp_cmd"
|
||||
},
|
||||
"timing": {
|
||||
"fix_cooldown_seconds": 60,
|
||||
"alert_after_seconds": 30
|
||||
},
|
||||
"auto_fix": {
|
||||
"after_seconds": 20,
|
||||
"max_tries": 2,
|
||||
"retry_seconds": 90,
|
||||
"pause_after_network_seconds": 60,
|
||||
"restart_connections": true,
|
||||
"windows": [
|
||||
{
|
||||
"days": [
|
||||
"tue"
|
||||
],
|
||||
"start": "14:00",
|
||||
"end": "22:00"
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
"wed"
|
||||
],
|
||||
"start": "09:00",
|
||||
"end": "21:00"
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
"sun"
|
||||
],
|
||||
"start": "05:00",
|
||||
"end": "14:00"
|
||||
}
|
||||
],
|
||||
"fixes": [
|
||||
"propresenter_ingest",
|
||||
"prop_port_propresenter_api",
|
||||
"hyperdeck",
|
||||
"midi_reconnect",
|
||||
"ableton_sc_tracks_new",
|
||||
"superrack_sc_foh",
|
||||
"x32edit_sc_foh",
|
||||
"vista_desktop_cucshqg",
|
||||
"rtpmidi_desktop_cucshqg"
|
||||
]
|
||||
},
|
||||
"tiles": [
|
||||
{
|
||||
"id": "all",
|
||||
"label": "ALL",
|
||||
"pos": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"kind": "master"
|
||||
},
|
||||
{
|
||||
"id": "service",
|
||||
"label": "ALARMS",
|
||||
"pos": [
|
||||
3,
|
||||
0
|
||||
],
|
||||
"kind": "service"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"label": "NET",
|
||||
"pos": [
|
||||
3,
|
||||
7
|
||||
],
|
||||
"kind": "network"
|
||||
},
|
||||
{
|
||||
"id": "hd",
|
||||
"label": "HD",
|
||||
"checks": [
|
||||
{
|
||||
"type": "hyperdeck",
|
||||
"conn": "hyperdeck",
|
||||
"fix": {
|
||||
"conn": "hyperdeck"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "foh_x32",
|
||||
"label": "FOH X32",
|
||||
"checks": [
|
||||
{
|
||||
"type": "conn",
|
||||
"conn": "FOH_X32"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
0,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "iem_x32",
|
||||
"label": "IEM X32",
|
||||
"checks": [
|
||||
{
|
||||
"type": "conn",
|
||||
"conn": "IEM_X32"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prop",
|
||||
"label": "PROP",
|
||||
"checks": [
|
||||
{
|
||||
"type": "conn",
|
||||
"conn": "Propresenter-API",
|
||||
"fix": "prop_port_propresenter_api",
|
||||
"ignore_warning": true
|
||||
},
|
||||
{
|
||||
"type": "process",
|
||||
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
|
||||
"name": "PROP",
|
||||
"fix": "propresenter_ingest"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
0,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "atem",
|
||||
"label": "ATEM",
|
||||
"checks": [
|
||||
{
|
||||
"type": "conn",
|
||||
"conn": "atem"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
0,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "resi",
|
||||
"label": "RESI",
|
||||
"checks": [
|
||||
{
|
||||
"type": "conn",
|
||||
"conn": "resi"
|
||||
},
|
||||
{
|
||||
"type": "ping",
|
||||
"conn": "resi"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
0,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "midi",
|
||||
"label": "MIDI",
|
||||
"checks": [
|
||||
{
|
||||
"type": "conn",
|
||||
"conn": "Midi-Lighting"
|
||||
},
|
||||
{
|
||||
"type": "midi_peer",
|
||||
"session": "ProPresenter",
|
||||
"peer": "Lighting",
|
||||
"address": "10.5.1.168:5004",
|
||||
"fix": "midi_reconnect",
|
||||
"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": "remote",
|
||||
"template": "win_program",
|
||||
"params": {
|
||||
"host": "desktop_cucshqg",
|
||||
"exe": "rtpMIDI.exe"
|
||||
},
|
||||
"msg": "RTPMIDI NOT RUNNING",
|
||||
"fix": "rtpmidi_desktop_cucshqg",
|
||||
"host": "desktop_cucshqg",
|
||||
"cmd": "tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul"
|
||||
},
|
||||
{
|
||||
"type": "midi_wired",
|
||||
"nets": [
|
||||
"10.5.0.0/16"
|
||||
],
|
||||
"fix": "midi_reconnect"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ableton",
|
||||
"label": "ABLETON",
|
||||
"checks": [
|
||||
{
|
||||
"type": "remote",
|
||||
"template": "mac_program",
|
||||
"params": {
|
||||
"host": "sc_tracks_new",
|
||||
"process": "Live"
|
||||
},
|
||||
"msg": "ABLETON NOT RUNNING",
|
||||
"fix": "ableton_sc_tracks_new",
|
||||
"host": "sc_tracks_new",
|
||||
"cmd": "pgrep -xq Live"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "autofix",
|
||||
"label": "AUTO-FIX",
|
||||
"kind": "autofix",
|
||||
"pos": [
|
||||
3,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lights",
|
||||
"label": "LIGHTS",
|
||||
"kind": "bypass",
|
||||
"pos": [
|
||||
1,
|
||||
3
|
||||
],
|
||||
"bypass": {
|
||||
"peers": [
|
||||
"Lighting"
|
||||
],
|
||||
"hosts": [
|
||||
"",
|
||||
"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
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "vista",
|
||||
"label": "VISTA",
|
||||
"checks": [
|
||||
{
|
||||
"type": "remote",
|
||||
"template": "win_program",
|
||||
"params": {
|
||||
"host": "desktop_cucshqg",
|
||||
"exe": "Vista.exe"
|
||||
},
|
||||
"msg": "VISTA NOT RUNNING",
|
||||
"fix": "vista_desktop_cucshqg",
|
||||
"host": "desktop_cucshqg",
|
||||
"cmd": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul"
|
||||
}
|
||||
],
|
||||
"pos": [
|
||||
1,
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"fixes": {
|
||||
"propresenter_ingest": {
|
||||
"template": "mac_start",
|
||||
"params": {
|
||||
"host": "ingest",
|
||||
"process": "ProPresenter",
|
||||
"app": "$APP",
|
||||
"newest_of": "/Applications/ProPresenter*.app"
|
||||
},
|
||||
"label": "start ProPresenter on this Mac if it is not running",
|
||||
"short": "PROP",
|
||||
"kind": "local",
|
||||
"run": "pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); open \"$APP\"; }"
|
||||
},
|
||||
"restart_propresenter_ingest": {
|
||||
"template": "mac_restart",
|
||||
"params": {
|
||||
"host": "ingest",
|
||||
"process": "ProPresenter",
|
||||
"app": "$APP",
|
||||
"wait_seconds": 10,
|
||||
"force": true,
|
||||
"background": false,
|
||||
"stop_children": false,
|
||||
"newest_of": "/Applications/ProPresenter*.app"
|
||||
},
|
||||
"label": "restart ProPresenter on this Mac (closes it first)",
|
||||
"short": "PROP",
|
||||
"kind": "local",
|
||||
"run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\""
|
||||
},
|
||||
"prop_port_propresenter_api": {
|
||||
"kind": "propresenter_port",
|
||||
"template": "propresenter_port",
|
||||
"params": {},
|
||||
"conn": "Propresenter-API",
|
||||
"short": "PROP PORT",
|
||||
"label": "find ProPresenter's network port and reconnect Companion's Propresenter-API to it"
|
||||
},
|
||||
"hyperdeck": {
|
||||
"kind": "hyperdeck",
|
||||
"template": "hyperdeck",
|
||||
"params": {},
|
||||
"short": "HYPERDECK",
|
||||
"verify_seconds": 15,
|
||||
"hung_note": "STILL OFFLINE\nHOLD AGAIN",
|
||||
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck"
|
||||
},
|
||||
"midi_reconnect": {
|
||||
"kind": "midi_connect",
|
||||
"template": "midi_connect",
|
||||
"params": {},
|
||||
"short": "RECONNECT",
|
||||
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address"
|
||||
},
|
||||
"ableton_sc_tracks_new": {
|
||||
"template": "ableton",
|
||||
"params": {
|
||||
"host": "sc_tracks_new",
|
||||
"app": "Ableton Live 11 Suite",
|
||||
"restart": false,
|
||||
"wait_seconds": 15
|
||||
},
|
||||
"label": "start Ableton on SC Tracks NEW if it is not running",
|
||||
"short": "ABLETON",
|
||||
"kind": "ssh",
|
||||
"host": "sc_tracks_new",
|
||||
"run": "pgrep -xq Live || { 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/'); if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi; }"
|
||||
},
|
||||
"restart_ableton_sc_tracks_new": {
|
||||
"template": "ableton",
|
||||
"params": {
|
||||
"host": "sc_tracks_new",
|
||||
"app": "Ableton Live 11 Suite",
|
||||
"restart": true,
|
||||
"wait_seconds": 15
|
||||
},
|
||||
"label": "restart Ableton on SC Tracks NEW (closes it first)",
|
||||
"short": "ABLETON",
|
||||
"kind": "ssh",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"vista_desktop_cucshqg": {
|
||||
"template": "win_start",
|
||||
"params": {
|
||||
"host": "desktop_cucshqg",
|
||||
"task": "Start Vista",
|
||||
"exe": "Vista.exe"
|
||||
},
|
||||
"label": "start Vista on SC Lighting PC if it is not running",
|
||||
"short": "VISTA",
|
||||
"kind": "ssh",
|
||||
"host": "desktop_cucshqg",
|
||||
"run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)"
|
||||
},
|
||||
"restart_vista_desktop_cucshqg": {
|
||||
"template": "win_restart",
|
||||
"params": {
|
||||
"host": "desktop_cucshqg",
|
||||
"task": "Start Vista",
|
||||
"exe": "Vista.exe"
|
||||
},
|
||||
"label": "restart Vista on SC Lighting PC (closes it first)",
|
||||
"short": "VISTA",
|
||||
"kind": "ssh",
|
||||
"host": "desktop_cucshqg",
|
||||
"run": "taskkill /F /T /IM \"Vista.exe\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul"
|
||||
},
|
||||
"rtpmidi_desktop_cucshqg": {
|
||||
"template": "win_start",
|
||||
"params": {
|
||||
"host": "desktop_cucshqg",
|
||||
"task": "Start rtpMIDI",
|
||||
"exe": "rtpMIDI.exe"
|
||||
},
|
||||
"label": "start rtpMIDI on SC Lighting PC if it is not running",
|
||||
"short": "RTPMIDI",
|
||||
"kind": "ssh",
|
||||
"host": "desktop_cucshqg",
|
||||
"run": "tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start rtpMIDI\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul)"
|
||||
}
|
||||
},
|
||||
"hosts": {
|
||||
"ingest": {
|
||||
"self": true,
|
||||
"name": "SC ProPresenter",
|
||||
"role": "the watchdog, Companion, the settings page",
|
||||
"os": "mac",
|
||||
"networks": [],
|
||||
"runs": [
|
||||
{
|
||||
"kind": "launchagent",
|
||||
"label": "org.waterschurch.status-panel",
|
||||
"name": "the watchdog"
|
||||
},
|
||||
{
|
||||
"kind": "launchagent",
|
||||
"label": "org.waterschurch.status-settings",
|
||||
"name": "the settings page"
|
||||
},
|
||||
{
|
||||
"kind": "app",
|
||||
"process": "Companion",
|
||||
"name": "Companion",
|
||||
"http": "http://127.0.0.1:8000/api/connections"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sc_tracks_new": {
|
||||
"name": "SC Tracks NEW",
|
||||
"os": "mac",
|
||||
"ssh": "tracks@10.5.3.72",
|
||||
"role": "found by auto-setup",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "sshd"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sc_foh": {
|
||||
"name": "SC FOH",
|
||||
"os": "mac",
|
||||
"ssh": "media@10.5.2.96",
|
||||
"role": "found by auto-setup",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "sshd"
|
||||
}
|
||||
]
|
||||
},
|
||||
"desktop_cucshqg": {
|
||||
"name": "SC Lighting PC",
|
||||
"os": "windows",
|
||||
"ssh": "Lighting@10.5.1.168",
|
||||
"role": "found by auto-setup",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "sshd"
|
||||
}
|
||||
],
|
||||
"mac": "40:a8:f0:69:ce:c9"
|
||||
}
|
||||
},
|
||||
"gear": {},
|
||||
"debounce_seconds": 3,
|
||||
"network": {
|
||||
"interface": "en11",
|
||||
"gateway": "10.5.0.1",
|
||||
"burst_connections": 4,
|
||||
"burst_groups": 3,
|
||||
"burst_window_seconds": 10,
|
||||
"remember_seconds": 300
|
||||
},
|
||||
"nav_keys": [
|
||||
"pagenum"
|
||||
],
|
||||
"nav_hold_ms": 1000,
|
||||
"nav_tap_page": 1,
|
||||
"discover": {
|
||||
"auto": true
|
||||
},
|
||||
"restart_page": {
|
||||
"page": 20,
|
||||
"page_name": "20\\nRestart",
|
||||
"jump_pos": [
|
||||
3,
|
||||
6
|
||||
],
|
||||
"back_pos": [
|
||||
3,
|
||||
0
|
||||
],
|
||||
"keys": [
|
||||
{
|
||||
"id": "rs_prop",
|
||||
"label": "PROP",
|
||||
"fix": "restart_propresenter_ingest",
|
||||
"pos": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rs_ableton",
|
||||
"label": "ABLETON",
|
||||
"fix": "restart_ableton_sc_tracks_new",
|
||||
"pos": [
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rs_superrack",
|
||||
"label": "SUPERRACK",
|
||||
"fix": "restart_superrack_sc_foh",
|
||||
"pos": [
|
||||
0,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rs_vista",
|
||||
"label": "VISTA",
|
||||
"fix": "restart_vista_desktop_cucshqg",
|
||||
"pos": [
|
||||
0,
|
||||
4
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"service_windows": [
|
||||
{
|
||||
"days": [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun"
|
||||
],
|
||||
"start": "00:00",
|
||||
"end": "23:59"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
{
|
||||
"auto_fix": {
|
||||
"after_seconds": 20,
|
||||
"fixes": [
|
||||
"superrack_ws_foh",
|
||||
"x32edit_ws_foh",
|
||||
"vista_ws_lights",
|
||||
"ableton_ws_tracks",
|
||||
"propresenter_ingest",
|
||||
"midi_reconnect"
|
||||
],
|
||||
"max_tries": 2,
|
||||
"pause_after_network_seconds": 60,
|
||||
"restart_connections": true,
|
||||
"retry_seconds": 90,
|
||||
"windows": [
|
||||
{
|
||||
"days": [
|
||||
"tue"
|
||||
],
|
||||
"end": "22:00",
|
||||
"start": "14:00"
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
"wed"
|
||||
],
|
||||
"end": "21:00",
|
||||
"start": "09:00"
|
||||
},
|
||||
{
|
||||
"days": [
|
||||
"sun"
|
||||
],
|
||||
"end": "14:00",
|
||||
"start": "05:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"companion": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8000
|
||||
},
|
||||
"debounce_seconds": 3,
|
||||
"discover": {
|
||||
"auto": true
|
||||
},
|
||||
"fixes": {
|
||||
"ableton_ws_tracks": {
|
||||
"host": "ws_tracks",
|
||||
"kind": "ssh",
|
||||
"label": "start Ableton on WS Tracks if it is not running",
|
||||
"params": {
|
||||
"app": "Ableton Live 11 Suite",
|
||||
"host": "ws_tracks",
|
||||
"restart": false,
|
||||
"wait_seconds": 15
|
||||
},
|
||||
"run": "pgrep -xq Live || { 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/'); if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi; }",
|
||||
"short": "ABLETON",
|
||||
"template": "ableton"
|
||||
},
|
||||
"midi_reconnect": {
|
||||
"kind": "midi_connect",
|
||||
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address",
|
||||
"params": {},
|
||||
"short": "RECONNECT",
|
||||
"template": "midi_connect"
|
||||
},
|
||||
"propresenter_ingest": {
|
||||
"kind": "local",
|
||||
"label": "start ProPresenter on this Mac if it is not running",
|
||||
"params": {
|
||||
"app": "$APP",
|
||||
"host": "ingest",
|
||||
"process": "ProPresenter",
|
||||
"newest_of": "/Applications/ProPresenter*.app"
|
||||
},
|
||||
"short": "PROP",
|
||||
"template": "mac_start",
|
||||
"run": "pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); open \"$APP\"; }"
|
||||
},
|
||||
"restart_ableton_ws_tracks": {
|
||||
"host": "ws_tracks",
|
||||
"kind": "ssh",
|
||||
"label": "restart Ableton on WS Tracks (closes it first)",
|
||||
"params": {
|
||||
"app": "Ableton Live 11 Suite",
|
||||
"host": "ws_tracks",
|
||||
"restart": true,
|
||||
"wait_seconds": 15
|
||||
},
|
||||
"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",
|
||||
"short": "ABLETON",
|
||||
"template": "ableton"
|
||||
},
|
||||
"restart_propresenter_ingest": {
|
||||
"kind": "local",
|
||||
"label": "restart ProPresenter on this Mac (closes it first)",
|
||||
"params": {
|
||||
"app": "$APP",
|
||||
"background": false,
|
||||
"force": true,
|
||||
"host": "ingest",
|
||||
"newest_of": "/Applications/ProPresenter*.app",
|
||||
"process": "ProPresenter",
|
||||
"stop_children": false,
|
||||
"wait_seconds": 10
|
||||
},
|
||||
"short": "PROP",
|
||||
"template": "mac_restart",
|
||||
"run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\""
|
||||
},
|
||||
"restart_superrack_ws_foh": {
|
||||
"host": "ws_foh",
|
||||
"kind": "ssh",
|
||||
"label": "restart SuperRack on WS FOH (closes it first)",
|
||||
"params": {
|
||||
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
|
||||
"background": false,
|
||||
"force": true,
|
||||
"host": "ws_foh",
|
||||
"newest_of": "",
|
||||
"process": "SuperRack SoundGrid",
|
||||
"stop_children": false,
|
||||
"wait_seconds": 10
|
||||
},
|
||||
"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'",
|
||||
"short": "SUPERRACK",
|
||||
"template": "mac_restart"
|
||||
},
|
||||
"restart_vista_ws_lights": {
|
||||
"host": "ws_lights",
|
||||
"kind": "ssh",
|
||||
"label": "restart Vista on WS Lights (closes it first)",
|
||||
"params": {
|
||||
"app": "/Applications/Vista.app",
|
||||
"background": false,
|
||||
"force": true,
|
||||
"host": "ws_lights",
|
||||
"newest_of": "",
|
||||
"process": "Vista",
|
||||
"stop_children": false,
|
||||
"wait_seconds": 10
|
||||
},
|
||||
"run": "pkill -TERM -x Vista; i=0; while pgrep -xq Vista && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Vista && pkill -KILL -x Vista; sleep 1; open /Applications/Vista.app",
|
||||
"short": "VISTA",
|
||||
"template": "mac_restart"
|
||||
},
|
||||
"superrack_ws_foh": {
|
||||
"host": "ws_foh",
|
||||
"kind": "ssh",
|
||||
"label": "start SuperRack on WS FOH if it is not running",
|
||||
"params": {
|
||||
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
|
||||
"host": "ws_foh",
|
||||
"process": "SuperRack SoundGrid"
|
||||
},
|
||||
"run": "pgrep -xq 'SuperRack SoundGrid' || open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'",
|
||||
"short": "SUPERRACK",
|
||||
"template": "mac_start"
|
||||
},
|
||||
"vista_ws_lights": {
|
||||
"host": "ws_lights",
|
||||
"kind": "ssh",
|
||||
"label": "start Vista on WS Lights if it is not running",
|
||||
"params": {
|
||||
"app": "/Applications/Vista.app",
|
||||
"host": "ws_lights",
|
||||
"process": "Vista"
|
||||
},
|
||||
"run": "pgrep -xq Vista || open /Applications/Vista.app",
|
||||
"short": "VISTA",
|
||||
"template": "mac_start"
|
||||
},
|
||||
"x32edit_ws_foh": {
|
||||
"host": "ws_foh",
|
||||
"kind": "ssh",
|
||||
"label": "start X32-Edit on WS FOH if it is not running",
|
||||
"params": {
|
||||
"app": "/Applications/X32-Edit.app",
|
||||
"host": "ws_foh",
|
||||
"process": "X32-Edit"
|
||||
},
|
||||
"run": "pgrep -xq X32-Edit || open /Applications/X32-Edit.app",
|
||||
"short": "X32 EDIT",
|
||||
"template": "mac_start"
|
||||
}
|
||||
},
|
||||
"gear": {},
|
||||
"hosts": {
|
||||
"ingest": {
|
||||
"name": "WS ProPresenter",
|
||||
"networks": [],
|
||||
"os": "mac",
|
||||
"role": "the watchdog, Companion, the settings page",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "launchagent",
|
||||
"label": "org.waterschurch.status-panel",
|
||||
"name": "the watchdog"
|
||||
},
|
||||
{
|
||||
"kind": "launchagent",
|
||||
"label": "org.waterschurch.status-settings",
|
||||
"name": "the settings page"
|
||||
},
|
||||
{
|
||||
"http": "http://127.0.0.1:8000/api/connections",
|
||||
"kind": "app",
|
||||
"name": "Companion",
|
||||
"process": "Companion"
|
||||
}
|
||||
],
|
||||
"self": true
|
||||
},
|
||||
"ws_foh": {
|
||||
"name": "WS FOH",
|
||||
"os": "mac",
|
||||
"role": "found by auto-setup",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "sshd"
|
||||
}
|
||||
],
|
||||
"ssh": "watersmedia@10.2.1.12"
|
||||
},
|
||||
"ws_lights": {
|
||||
"name": "WS Lights",
|
||||
"os": "mac",
|
||||
"role": "found by auto-setup",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "sshd"
|
||||
}
|
||||
],
|
||||
"ssh": "wslighting@10.2.2.2"
|
||||
},
|
||||
"ws_tracks": {
|
||||
"name": "WS Tracks",
|
||||
"os": "mac",
|
||||
"role": "found by auto-setup",
|
||||
"runs": [
|
||||
{
|
||||
"kind": "sshd"
|
||||
}
|
||||
],
|
||||
"ssh": "wstracks@10.2.1.219"
|
||||
}
|
||||
},
|
||||
"nav_hold_ms": 1000,
|
||||
"nav_keys": [
|
||||
"pagenum"
|
||||
],
|
||||
"nav_tap_page": 1,
|
||||
"network": {
|
||||
"burst_connections": 4,
|
||||
"burst_groups": 3,
|
||||
"burst_window_seconds": 10,
|
||||
"gateway": "10.2.0.1",
|
||||
"interface": "en14",
|
||||
"remember_seconds": 300
|
||||
},
|
||||
"page": 19,
|
||||
"page_hold_ms": 2000,
|
||||
"page_name": "19\\nStatus",
|
||||
"restart_page": {
|
||||
"back_pos": [
|
||||
3,
|
||||
0
|
||||
],
|
||||
"jump_pos": [
|
||||
3,
|
||||
6
|
||||
],
|
||||
"keys": [
|
||||
{
|
||||
"fix": "restart_superrack_ws_foh",
|
||||
"id": "rs_superrack",
|
||||
"label": "SUPERRACK",
|
||||
"pos": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"fix": "restart_vista_ws_lights",
|
||||
"id": "rs_vista",
|
||||
"label": "VISTA",
|
||||
"pos": [
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"fix": "restart_ableton_ws_tracks",
|
||||
"id": "rs_ableton",
|
||||
"label": "ABLETON",
|
||||
"pos": [
|
||||
0,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"fix": "restart_propresenter_ingest",
|
||||
"id": "rs_prop",
|
||||
"label": "PROP",
|
||||
"pos": [
|
||||
0,
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"page": 20,
|
||||
"page_name": "20\\nRestart"
|
||||
},
|
||||
"service_windows": [
|
||||
{
|
||||
"days": [
|
||||
"mon",
|
||||
"tue",
|
||||
"wed",
|
||||
"thu",
|
||||
"fri",
|
||||
"sat",
|
||||
"sun"
|
||||
],
|
||||
"end": "23:59",
|
||||
"start": "00:00"
|
||||
}
|
||||
],
|
||||
"site": {
|
||||
"contacts": "",
|
||||
"editing_code": "9248",
|
||||
"id": "ws",
|
||||
"ingest_hostname": "WS-ProPresenter-3.local",
|
||||
"ingest_ips": [
|
||||
"10.2.2.210",
|
||||
"169.254.104.16"
|
||||
],
|
||||
"ingest_name": "WS ProPresenter",
|
||||
"live_services": [],
|
||||
"name": "Woonsocket",
|
||||
"admin_ips": [
|
||||
"10.6.7.38"
|
||||
],
|
||||
"admin_keys": [
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
|
||||
]
|
||||
},
|
||||
"tiles": [
|
||||
{
|
||||
"id": "all",
|
||||
"kind": "master",
|
||||
"label": "ALL",
|
||||
"pos": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "service",
|
||||
"kind": "service",
|
||||
"label": "ALARMS",
|
||||
"pos": [
|
||||
3,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"kind": "network",
|
||||
"label": "NET",
|
||||
"pos": [
|
||||
3,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"cmd": "pgrep -xq 'SuperRack SoundGrid'",
|
||||
"fix": "superrack_ws_foh",
|
||||
"host": "ws_foh",
|
||||
"msg": "SUPERRACK NOT RUNNING",
|
||||
"params": {
|
||||
"host": "ws_foh",
|
||||
"process": "SuperRack SoundGrid"
|
||||
},
|
||||
"template": "mac_program",
|
||||
"type": "remote"
|
||||
},
|
||||
{
|
||||
"cmd": "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'",
|
||||
"host": "ws_foh",
|
||||
"msg": "SOUNDGRID LOST",
|
||||
"params": {
|
||||
"host": "ws_foh"
|
||||
},
|
||||
"template": "soundgrid",
|
||||
"type": "remote"
|
||||
}
|
||||
],
|
||||
"id": "superrack",
|
||||
"label": "SUPERRACK",
|
||||
"pos": [
|
||||
0,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"cmd": "pgrep -xq X32-Edit",
|
||||
"fix": "x32edit_ws_foh",
|
||||
"host": "ws_foh",
|
||||
"msg": "X32 EDIT NOT RUNNING",
|
||||
"params": {
|
||||
"host": "ws_foh",
|
||||
"process": "X32-Edit"
|
||||
},
|
||||
"template": "mac_program",
|
||||
"type": "remote"
|
||||
}
|
||||
],
|
||||
"id": "x32_edit",
|
||||
"label": "X32 EDIT",
|
||||
"pos": [
|
||||
0,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"conn": "m32",
|
||||
"type": "conn"
|
||||
}
|
||||
],
|
||||
"id": "m32",
|
||||
"label": "M32",
|
||||
"pos": [
|
||||
0,
|
||||
4
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"cmd": "pgrep -xq Vista",
|
||||
"fix": "vista_ws_lights",
|
||||
"host": "ws_lights",
|
||||
"msg": "VISTA NOT RUNNING",
|
||||
"params": {
|
||||
"host": "ws_lights",
|
||||
"process": "Vista"
|
||||
},
|
||||
"template": "mac_program",
|
||||
"type": "remote"
|
||||
}
|
||||
],
|
||||
"id": "vista",
|
||||
"label": "VISTA",
|
||||
"pos": [
|
||||
0,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"cmd": "pgrep -xq Live",
|
||||
"fix": "ableton_ws_tracks",
|
||||
"host": "ws_tracks",
|
||||
"msg": "ABLETON NOT RUNNING",
|
||||
"params": {
|
||||
"host": "ws_tracks",
|
||||
"process": "Live"
|
||||
},
|
||||
"template": "mac_program",
|
||||
"type": "remote"
|
||||
}
|
||||
],
|
||||
"id": "ableton",
|
||||
"label": "ABLETON",
|
||||
"pos": [
|
||||
0,
|
||||
6
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"conn": "Propresenter-API",
|
||||
"type": "conn",
|
||||
"ignore_warning": true
|
||||
},
|
||||
{
|
||||
"fix": "propresenter_ingest",
|
||||
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
|
||||
"name": "PROP",
|
||||
"type": "process"
|
||||
}
|
||||
],
|
||||
"id": "prop",
|
||||
"label": "PROP",
|
||||
"pos": [
|
||||
0,
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"address": "10.2.1.219:5004",
|
||||
"fix": "midi_reconnect",
|
||||
"host": "ws_foh",
|
||||
"peer": "WS Tracks",
|
||||
"session": "WS FOH Mac mini",
|
||||
"tag": "WS TRACKS",
|
||||
"type": "midi_peer"
|
||||
},
|
||||
{
|
||||
"address": "10.2.1.219:5004",
|
||||
"fix": "midi_reconnect",
|
||||
"host": "ws_lights",
|
||||
"peer": "WS Tracks",
|
||||
"session": "Lighting",
|
||||
"tag": "WS TRACKS",
|
||||
"type": "midi_peer"
|
||||
},
|
||||
{
|
||||
"address": "10.2.2.2:5004",
|
||||
"fix": "midi_reconnect",
|
||||
"peer": "Lighting",
|
||||
"session": "ProPresenter",
|
||||
"tag": "PROP LIGHTS",
|
||||
"type": "midi_peer"
|
||||
},
|
||||
{
|
||||
"fix": "midi_reconnect",
|
||||
"nets": [
|
||||
"10.2.0.0/16"
|
||||
],
|
||||
"type": "midi_wired"
|
||||
}
|
||||
],
|
||||
"id": "midi",
|
||||
"label": "MIDI",
|
||||
"pos": [
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"conn": "ATEM",
|
||||
"type": "conn"
|
||||
}
|
||||
],
|
||||
"id": "atem",
|
||||
"label": "ATEM",
|
||||
"pos": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"conn": "Resi",
|
||||
"type": "conn"
|
||||
},
|
||||
{
|
||||
"conn": "Resi",
|
||||
"type": "ping"
|
||||
}
|
||||
],
|
||||
"id": "resi",
|
||||
"label": "RESI",
|
||||
"pos": [
|
||||
1,
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "autofix",
|
||||
"label": "AUTO-FIX",
|
||||
"kind": "autofix",
|
||||
"pos": [
|
||||
3,
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"timing": {
|
||||
"alert_after_seconds": 30,
|
||||
"fix_cooldown_seconds": 60
|
||||
},
|
||||
"variables": {
|
||||
"command": "sp_cmd",
|
||||
"heartbeat": "sp_hb",
|
||||
"state": "sp_state"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
+1184
-104
File diff suppressed because it is too large
Load Diff
@@ -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(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):
|
||||
new = json.loads(json.dumps(self.cfg))
|
||||
new["gear"] = {"UHDJr": {"location": "lobby"}}
|
||||
|
||||
+22
-1
@@ -35,7 +35,7 @@ class TestTodaysFixes(unittest.TestCase):
|
||||
if c.get("template"):
|
||||
self.assertEqual(b.build_check(CFG, c)["cmd"], c["cmd"])
|
||||
n += 1
|
||||
self.assertEqual(n, 5)
|
||||
self.assertEqual(n, 6)
|
||||
|
||||
def test_a_changed_command_is_refused_by_validate(self):
|
||||
cfg = json.loads(json.dumps(CFG))
|
||||
@@ -126,6 +126,27 @@ if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestCompanionPress(unittest.TestCase):
|
||||
def test_a_press_builds_from_its_fields(self):
|
||||
f = b.build(CFG, {"template": "companion_press", "params": {"page": 4, "row": 0, "col": "5", "button": "FOH Backup"}, "short": "FOH BACKUP"})
|
||||
self.assertEqual((f["kind"], f["page"], f["row"], f["col"], f["short"]), ("companion_press", 4, 0, 5, "FOH BACKUP"))
|
||||
self.assertEqual(f["params"]["col"], 5) # copied as a number
|
||||
self.assertNotIn("run", f); self.assertNotIn("host", f)
|
||||
self.assertEqual(b.suggest_label(CFG, f), "press the FOH Backup button in Companion (page 4, row 0, column 5)")
|
||||
self.assertEqual(b.suggest_label(CFG, {"template": "companion_press", "params": {"page": 4, "row": 0, "col": 5}}),
|
||||
"press the button in Companion (page 4, row 0, column 5)")
|
||||
for bad in ({"page": 4, "row": 0}, {"page": "four", "row": 0, "col": 5}, {"page": 4, "row": 1.5, "col": 5},
|
||||
{"page": True, "row": 0, "col": 5}):
|
||||
with self.assertRaises(ValueError):
|
||||
b.build(CFG, {"template": "companion_press", "params": bad})
|
||||
|
||||
def test_a_changed_button_is_caught(self):
|
||||
cfg = json.loads(json.dumps(CFG))
|
||||
cfg["fixes"]["stream_foh_backup"]["col"] = 7 # the field moved, the params did not
|
||||
self.assertTrue(any("stream_foh_backup" in e and "its col no longer matches" in e for e in b.mismatches(cfg)), b.mismatches(cfg))
|
||||
self.assertEqual([e for e in b.mismatches(CFG) if "stream_" in e], [])
|
||||
|
||||
|
||||
class TestOutlet(unittest.TestCase):
|
||||
def test_outlet_cycle_renders_and_is_local(self):
|
||||
f = b.build(CFG, {"template": "outlet_cycle", "params": {"outlet": "tascam", "address": "10.1.11.74", "wait_seconds": 8}})
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
"""Auto-setup for the other locations: an inventory becomes keys that pass every rule, laid out like
|
||||
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."""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
sys.path.insert(0, os.path.join(ROOT, "tests"))
|
||||
import schema # noqa: E402
|
||||
import make_page # noqa: E402
|
||||
from settings import autoconf as ac, campus, discover, server # noqa: E402
|
||||
from test_settings import get # noqa: E402
|
||||
from test_status_panel import FakeCompanion, healthy_snapshot # noqa: E402
|
||||
|
||||
BASE = json.load(open(os.path.join(ROOT, "sites", "_template", "config.json")))
|
||||
|
||||
|
||||
def small():
|
||||
return {
|
||||
"ingest": {"name": "SC Ingest", "hostname": "SC-Ingest.local", "ips": ["10.4.1.1", "10.6.20.5"],
|
||||
"wired": {"interface": "en0", "ip": "10.4.1.1", "net": "10.4.0.0/16", "gateway": "10.4.0.1"}},
|
||||
"connections": [
|
||||
{"label": "HyperDeck_1", "module": "bmd-hyperdeck", "host": "10.4.10.11", "enabled": True},
|
||||
{"label": "HyperDeck_2", "module": "bmd-hyperdeck", "host": "10.4.10.12", "enabled": True},
|
||||
{"label": "x32", "module": "behringer-x32", "host": "10.4.1.112", "enabled": True},
|
||||
{"label": "dante", "module": "audinate-dantecontroller", "host": "", "enabled": True},
|
||||
{"label": "ProP", "module": "renewedvision-propresenter-api", "host": "10.4.14.20", "enabled": True},
|
||||
{"label": "Govee", "module": "govee-lights", "host": "", "enabled": True},
|
||||
{"label": "old_obs", "module": "obs-studio", "host": "10.4.9.9", "enabled": False}],
|
||||
"dante": {"conn": "dante", "devices": ["SC-X32", "SC-ProP-Mac"]},
|
||||
"midi": {"": {"SC Ingest": {"port": 5004, "peers": [
|
||||
{"name": "Lighting PC", "address": "10.4.10.206:5004", "state": "running"},
|
||||
{"name": "Phone", "address": "10.6.20.77:5004", "state": "running"}]}}}, # Wi-Fi: not made a key
|
||||
"computers": {
|
||||
"prop": {"name": "ProPresenter Mac", "os": "mac", "user": "media", "ip": "10.4.14.20", "ips": ["10.4.14.20"], "reachable": True,
|
||||
"procs": ["ProPresenter", "Finder"], "apps": ["/Applications/ProPresenter.app", "/Applications/Safari.app"]},
|
||||
"lighting_pc": {"name": "Lighting PC", "os": "windows", "user": "media", "ip": "10.4.10.206", "ips": ["10.4.10.206"],
|
||||
"reachable": True, "procs": ["Vista.exe", "rtpMIDI.exe", "Arena.exe"], "tasks": ["Start Vista", "Start rtpMIDI"],
|
||||
"macs": ["aa:bb:cc:dd:ee:ff"]},
|
||||
"off_pc": {"name": "Office", "os": "windows", "user": "x", "ip": "10.4.3.3", "ips": ["10.4.3.3"], "reachable": False}}}
|
||||
|
||||
|
||||
def big():
|
||||
inv = small()
|
||||
inv["connections"] += [{"label": "HyperDeck_%d" % n, "module": "bmd-hyperdeck", "host": "10.4.10.%d" % (10 + n), "enabled": True} for n in range(3, 7)]
|
||||
inv["connections"] += [{"label": "Axient_%d" % n, "module": "shure-wireless", "host": "10.4.1.%d" % (100 + n), "enabled": True, "channels": 4} for n in range(1, 4)]
|
||||
inv["connections"] += [{"label": "PSM_%d" % n, "module": "shure-psm1000", "host": "10.4.1.%d" % (110 + n), "enabled": True, "channels": 2} for n in range(1, 9)]
|
||||
inv["connections"] += [{"label": m, "module": mod, "host": "10.4.5.%d" % i, "enabled": True}
|
||||
for i, (m, mod) in enumerate([("ATEM", "bmd-atem"), ("VX1000", "novastar-controller"), ("OBS", "obs-studio"),
|
||||
("RESI", "resi-decoders"), ("tascam", "tascam-da-6400"), ("Arena", "resolume-arena")])]
|
||||
return inv
|
||||
|
||||
|
||||
def layout_of(cfg):
|
||||
return {tuple(t["pos"]): t["label"] for t in cfg["tiles"] if t.get("pos")}
|
||||
|
||||
|
||||
class TestBuild(unittest.TestCase):
|
||||
def test_a_small_campus_gets_one_key_per_thing_in_order_and_passes_the_rules(self):
|
||||
cfg, seen = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "4821", (21, 22))
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
labels = [t["label"] for t in cfg["tiles"] if not t.get("kind")]
|
||||
self.assertEqual(labels, ["HD1", "HD2", "DANTE", "X32", "PROP", "VISTA", "MIDI"]) # REC, AUDIO, SHOW; no group keys
|
||||
self.assertNotIn("RTPMIDI", labels) # rides in the MIDI key
|
||||
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
|
||||
self.assertEqual([c["peer"] for c in midi["checks"] if c["type"] == "midi_peer"], ["Lighting PC"]) # the Wi-Fi phone is not
|
||||
self.assertEqual(midi["checks"][-2]["type"], "midi_wired")
|
||||
self.assertEqual(midi["checks"][-1]["params"]["exe"], "rtpMIDI.exe")
|
||||
prop = next(t for t in cfg["tiles"] if t["label"] == "PROP")
|
||||
self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "remote"]) # Companion connection + the program
|
||||
self.assertEqual(prop["checks"][1]["cmd"], "pgrep -xq ProPresenter")
|
||||
self.assertNotIn("ARENA", labels) # running, but no start task on that PC
|
||||
self.assertNotIn("off_pc", cfg["hosts"]) # not reachable: not added
|
||||
self.assertEqual(cfg["hosts"]["lighting_pc"]["ssh"], "media@10.4.10.206")
|
||||
self.assertEqual((cfg["page"], cfg["restart_page"]["page"]), (21, 22))
|
||||
self.assertEqual([k["label"] for k in cfg["restart_page"]["keys"]], ["PROP", "VISTA"])
|
||||
self.assertEqual(cfg["site"]["editing_code"], "4821")
|
||||
self.assertEqual(cfg["site"]["ingest_ips"][0], "10.4.1.1")
|
||||
self.assertTrue(cfg["discover"]["auto"])
|
||||
self.assertIn("hyperdeck", cfg["auto_fix"]["fixes"])
|
||||
self.assertFalse(any(f.startswith("restart_") for f in cfg["auto_fix"]["fixes"])) # restarts stay a hold
|
||||
for t in cfg["tiles"]: # every key's hold does something
|
||||
if not t.get("kind") and not t.get("members"):
|
||||
fixable = any(c.get("fix") or c["type"] in ("conn", "hyperdeck", "tascam", "shure", "psm") for c in t["checks"])
|
||||
self.assertTrue(fixable, t["label"])
|
||||
make_page.build(cfg)
|
||||
make_page.build_restart(cfg)
|
||||
self.assertIn("conn:x32", seen)
|
||||
self.assertIn("dante:SC-X32", seen)
|
||||
|
||||
def test_a_programs_app_comes_from_its_running_path(self):
|
||||
"""Woonsocket's FOH Mac: SuperRack lives three folders down, and zsh had hidden the app list."""
|
||||
inv = small()
|
||||
inv["computers"]["foh"] = {"name": "WS FOH", "os": "mac", "user": "watersmedia", "ip": "10.4.1.12", "ips": ["10.4.1.12"], "reachable": True,
|
||||
"procs": ["SuperRack SoundGrid", "X32-Edit"], "apps": [],
|
||||
"paths": {"SuperRack SoundGrid": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
|
||||
"X32-Edit": "/Applications/X32-Edit.app"}}
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
sr = next(t for t in cfg["tiles"] if t["label"] == "SUPERRACK")
|
||||
self.assertEqual(sr["checks"][0]["cmd"], "pgrep -xq 'SuperRack SoundGrid'")
|
||||
self.assertIn("/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app", cfg["fixes"][sr["checks"][0]["fix"]]["run"])
|
||||
self.assertIn("X32 EDIT", [t["label"] for t in cfg["tiles"]])
|
||||
self.assertEqual([c.get("msg") for c in sr["checks"]], ["SUPERRACK NOT RUNNING", "SOUNDGRID LOST"])
|
||||
self.assertIn(":19000 (ESTABLISHED)", sr["checks"][1]["cmd"])
|
||||
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "wslighting", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True,
|
||||
"procs": ["Vista", "VistaPlaybackNode"], "apps": ["/Applications/Vista.app"], "paths": {"Vista": "/Applications/Vista.app"}}
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
vista = [t for t in cfg["tiles"] if t["label"] == "VISTA" and t["checks"][0].get("host") == "lights"]
|
||||
self.assertEqual(vista[0]["checks"][0]["cmd"], "pgrep -xq Vista") # Vista on a Mac, too
|
||||
self.assertIn("SUPERRACK", [k["label"] for k in cfg["restart_page"]["keys"]])
|
||||
|
||||
def test_links_by_name_or_ipv6_get_the_wired_address_of_the_mac_whose_session_it_is(self):
|
||||
"""Woonsocket: Tracks lists "Lighting" by name and the FOH Mac by an IPv6 address."""
|
||||
inv = small()
|
||||
inv["computers"]["tracks"] = {"name": "WS Tracks", "os": "mac", "user": "t", "ip": "10.4.1.219", "ips": ["169.254.45.114", "10.4.1.219"], "reachable": True, "procs": []}
|
||||
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "l", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True, "procs": []}
|
||||
inv["computers"]["foh"] = {"name": "WS FOH", "os": "mac", "user": "f", "ip": "10.4.1.12", "ips": ["10.4.1.12"], "reachable": True, "procs": []}
|
||||
inv["midi"] = {"": {}, "tracks": {"WS Tracks": {"port": 5004, "peers": [{"name": "Lighting", "address": "\tLighting\tlocal."},
|
||||
{"name": "WS FOH Mac mini", "address": "[fe80::4c3:64d1:835f:f61f]:5004"}]}},
|
||||
"lights": {"Lighting": {"port": 5006, "peers": [{"name": "WS Tracks", "address": "[fe80::188d]:5004", "state": "running"}]}},
|
||||
"foh": {"WS FOH Mac mini": {"port": 5004, "peers": [{"name": "WS Tracks", "address": "\tWS Tracks\tlocal."}]}}}
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
|
||||
got = sorted((c.get("host"), c["peer"], c["address"]) for c in midi["checks"] if c["type"] == "midi_peer")
|
||||
inv["midi"][""] = {"ProPresenter": {"port": 5004, "peers": [{"name": "Lighting", "address": "10.4.2.2:5006", "state": "running"}]}}
|
||||
inv["midi"]["lights"]["Lighting"]["peers"].append({"name": "ProPresenter", "address": "10.4.1.1:5004", "state": "running"})
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
|
||||
pairs = [(c.get("host"), c["peer"]) for c in midi["checks"] if c["type"] == "midi_peer"]
|
||||
self.assertEqual(len([p for p in pairs if "ProPresenter" in p or ("lights" not in p and "Lighting" in p and None in p)]), 1) # the Companion Mac's link counted once
|
||||
inv["midi"][""] = {}
|
||||
inv["midi"]["lights"]["Lighting"]["peers"].pop()
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
|
||||
# One check per link: Tracks-Lights from Lights (it reports the state), Tracks-FOH from one end only.
|
||||
self.assertEqual(got, [("lights", "WS Tracks", "10.4.1.219:5004"), ("tracks", "WS FOH Mac mini", "10.4.1.12:5004")])
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
import status_panel as sp
|
||||
snap = sp.Snapshot()
|
||||
snap.remote_midi["tracks"], snap.remote_midi_time["tracks"] = inv["midi"]["tracks"], time.time()
|
||||
chk = {"type": "midi_peer", "host": "tracks", "session": "WS Tracks", "peer": "Lighting"}
|
||||
self.assertEqual(sp.check_midi_peer(chk, snap, {"now": time.time()})[0][1].level, sp.OK) # no state: older macOS, configured
|
||||
|
||||
def test_a_program_on_the_companion_mac_gets_a_local_check_and_fixes(self):
|
||||
"""Woonsocket: ProPresenter runs on the Mac that runs Companion."""
|
||||
inv = small()
|
||||
inv["computers"] = {}
|
||||
exe = "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter"
|
||||
inv["ingest"].update(procs=["ProPresenter"], paths={"ProPresenter": "/Applications/ProPresenter.app"}, exes={"ProPresenter": exe})
|
||||
inv["connections"] = [dict(c, host="10.4.1.1") if c["label"] == "ProP" else c for c in inv["connections"]]
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
prop = next(t for t in cfg["tiles"] if t["label"] == "PROP")
|
||||
import re
|
||||
self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "process"])
|
||||
match = prop["checks"][1]["match"] # any installed copy counts (an update beside the old)
|
||||
self.assertTrue(re.search(match, exe) and re.search(match, "/Applications/ProPresenter 21.4.app/Contents/MacOS/ProPresenter"))
|
||||
fix = cfg["fixes"][prop["checks"][1]["fix"]]
|
||||
self.assertEqual(fix["kind"], "local")
|
||||
self.assertTrue(fix["run"].startswith("pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app"), fix["run"])
|
||||
self.assertTrue(fix["run"].endswith('open "$APP"; }'), fix["run"]) # the newest version opens
|
||||
self.assertIn(prop["checks"][1]["fix"], cfg["auto_fix"]["fixes"])
|
||||
self.assertEqual([k["label"] for k in cfg["restart_page"]["keys"]], ["PROP"])
|
||||
self.assertEqual(cfg["fixes"][cfg["restart_page"]["keys"][0]["fix"]]["kind"], "local")
|
||||
|
||||
def test_every_campus_gets_a_restart_page_and_north_attleboros_auto_fix_hours(self):
|
||||
inv = small()
|
||||
inv["computers"] = {}
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "sc", "name": "Southcoast"}, "1234")
|
||||
self.assertEqual(cfg["restart_page"]["keys"], []) # computers join later: their keys come then
|
||||
na = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
self.assertEqual(cfg["auto_fix"]["windows"], na["auto_fix"]["windows"])
|
||||
self.assertEqual(cfg["service_windows"], na["service_windows"])
|
||||
self.assertEqual(cfg["nav_keys"], na["nav_keys"])
|
||||
make_page.build_restart(cfg)
|
||||
|
||||
def test_an_m32_is_called_m32(self):
|
||||
inv = small()
|
||||
inv["connections"] = [c for c in inv["connections"] if c["label"] != "x32"] + [
|
||||
{"label": "m32", "module": "behringer-x32", "host": "10.4.1.146", "enabled": True}]
|
||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
self.assertIn("M32", [t["label"] for t in cfg["tiles"]])
|
||||
|
||||
def test_a_big_campus_gets_group_keys_and_the_receivers_fold_first(self):
|
||||
cfg, _ = ac.build(BASE, big(), {"id": "ab", "name": "Apollo Beach"}, "1111")
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
top = [layout_of(cfg).get((0, c)) for c in range(2, 8)]
|
||||
self.assertEqual(top, ["REC", "AUDIO", "RF", "SHOW", "VIDEO", "STREAM"])
|
||||
unplaced = [t["label"] for t in cfg["tiles"] if not t.get("pos")]
|
||||
self.assertTrue(unplaced and all(u.startswith(("AXIENT", "PSM")) for u in unplaced), unplaced)
|
||||
rf = next(t for t in cfg["tiles"] if t["label"] == "RF")
|
||||
self.assertEqual(len(rf["members"]), 11)
|
||||
self.assertEqual(layout_of(cfg)[(3, 7)], "NET")
|
||||
self.assertEqual(layout_of(cfg)[(3, 0)], "ALARMS")
|
||||
make_page.build(cfg)
|
||||
|
||||
|
||||
class TestExtend(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cfg, self.seen = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "4821")
|
||||
|
||||
def test_nothing_new_adds_nothing(self):
|
||||
new, added, _ = ac.extend(self.cfg, small(), self.seen)
|
||||
self.assertEqual(added, [])
|
||||
self.assertEqual(new, self.cfg)
|
||||
|
||||
def test_new_gear_is_added_into_free_cells_and_merged_into_its_key(self):
|
||||
inv = small()
|
||||
inv["connections"].append({"label": "HyperDeck_3", "module": "bmd-hyperdeck", "host": "10.4.10.13", "enabled": True})
|
||||
inv["dante"]["devices"].append("SC-Tracks")
|
||||
inv["midi"][""]["SC Ingest"]["peers"].append({"name": "SuperRack", "address": "10.4.13.140:5004", "state": "running"})
|
||||
new, added, now_seen = ac.extend(self.cfg, inv, self.seen)
|
||||
self.assertEqual(sorted(added), ["Dante device SC-Tracks", "HD3 key", "MIDI link SuperRack"])
|
||||
self.assertEqual(schema.validate(new)[0], [])
|
||||
hd = [t for t in new["tiles"] if t["label"] == "HD3"][0]
|
||||
self.assertTrue(hd.get("pos"))
|
||||
self.assertIn("SC-Tracks", next(c for t in new["tiles"] for c in t.get("checks", []) if c["type"] == "dante")["devices"])
|
||||
# Nothing that was there changed place.
|
||||
self.assertTrue(set(layout_of(self.cfg).items()) <= set(layout_of(new).items()))
|
||||
again, added2, _ = ac.extend(new, inv, set(self.seen) | set(now_seen))
|
||||
self.assertEqual(added2, [])
|
||||
|
||||
def test_a_program_found_later_joins_its_connections_key(self):
|
||||
"""Woonsocket got a second PROP key when ProPresenter on the Companion Mac was found after the connection."""
|
||||
inv = small()
|
||||
inv["computers"] = {}
|
||||
cfg, seen = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
exe = "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter"
|
||||
inv["ingest"].update(procs=["ProPresenter"], paths={"ProPresenter": "/Applications/ProPresenter.app"}, exes={"ProPresenter": exe})
|
||||
inv["connections"] = [dict(c, host="127.0.0.1") if c["label"] == "ProP" else c for c in inv["connections"]]
|
||||
new, added, _ = ac.extend(cfg, inv, seen)
|
||||
self.assertEqual([t["label"] for t in new["tiles"]].count("PROP"), 1)
|
||||
prop = next(t for t in new["tiles"] if t["label"] == "PROP")
|
||||
self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "process"])
|
||||
self.assertIn("PROP checks on the PROP key", added)
|
||||
self.assertEqual(schema.validate(new)[0], [])
|
||||
|
||||
def test_a_rebuild_keeps_what_is_down_right_now(self):
|
||||
"""Southcoast's rebuild dropped its MIDI key: the ProPresenter-to-Lighting link was down at that moment."""
|
||||
old, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "1234")
|
||||
now = small()
|
||||
now["midi"] = {"": {"SC Ingest": {"port": 5004, "peers": []}}} # the link dropped
|
||||
now["computers"]["lighting_pc"]["reachable"] = False # and the Lighting PC is off
|
||||
new, _ = ac.build(BASE, now, {"id": "sc", "name": "Southcoast"}, "1234")
|
||||
self.assertNotIn("MIDI", [t["label"] for t in new["tiles"]])
|
||||
kept, words = ac.keep_from(new, old)
|
||||
self.assertIn("MIDI", [t["label"] for t in kept["tiles"]])
|
||||
self.assertIn("VISTA", [t["label"] for t in kept["tiles"]])
|
||||
self.assertIn("lighting_pc", kept["hosts"])
|
||||
self.assertIn("MIDI link Lighting PC", words)
|
||||
self.assertEqual(schema.validate(kept)[0], [])
|
||||
|
||||
def test_a_key_someone_took_off_is_not_added_back(self):
|
||||
cfg = json.loads(json.dumps(self.cfg))
|
||||
cfg["tiles"] = [t for t in cfg["tiles"] if t["label"] != "X32"]
|
||||
_, added, _ = ac.extend(cfg, small(), self.seen)
|
||||
self.assertNotIn("X32 key", added)
|
||||
_, added, _ = ac.extend(cfg, small(), []) # never seen: it would be
|
||||
self.assertIn("X32 key", added)
|
||||
|
||||
def test_north_attleboro_like_config_only_gets_what_it_does_not_watch(self):
|
||||
cfg = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
inv = {"ingest": {"wired": {"net": "10.1.0.0/16"}},
|
||||
"connections": [{"label": "HyperDeck_1", "module": "bmd-hyperdeck", "host": "10.1.10.11", "enabled": True},
|
||||
{"label": "x32", "module": "behringer-x32", "host": "10.1.1.112", "enabled": True}],
|
||||
"dante": {}, "midi": {}, "computers": {}}
|
||||
_, added, _ = ac.extend(cfg, inv, [])
|
||||
self.assertEqual(added, [])
|
||||
|
||||
|
||||
class TestJoin(unittest.TestCase):
|
||||
def test_a_join_report_is_checked(self):
|
||||
e = discover.clean_hello({"user": "media", "name": "Lighting PC", "os": "Windows", "ips": ["10.4.10.206", "bad"],
|
||||
"macs": ["AA-BB-CC-DD-EE-FF", "nope"], "tasks": ["Start Vista"]}, "10.4.10.206")
|
||||
self.assertEqual((e["os"], e["ips"], e["macs"], e["tasks"]), ("windows", ["10.4.10.206"], ["aa:bb:cc:dd:ee:ff"], ["Start Vista"]))
|
||||
for bad in ({"name": "x"}, {"user": "a;rm -rf /"}, {"user": ""}):
|
||||
with self.assertRaises(ValueError):
|
||||
discover.clean_hello(bad, "10.4.1.2")
|
||||
self.assertEqual(discover.clean_hello({"user": "u", "name": "<b>Mac</b>"}, "10.4.1.2")["name"], "bMacb")
|
||||
|
||||
def test_the_lines_people_paste(self):
|
||||
line = campus.install_script("http://10.1.1.1:8020", "ab; rm", "ssh-ed25519 AAAA na")
|
||||
self.assertIn("zsh install.sh --site ab", line) # the id is cleaned
|
||||
self.assertIn("--name 'Apollo Beach'", line)
|
||||
self.assertIn("SUPPORT='ssh-ed25519 AAAA na'", line)
|
||||
cfg = {"site": {"ingest_name": "SC Ingest", "ingest_ips": ["10.4.1.1"]}}
|
||||
ps = campus.join_windows(cfg, "http://10.4.1.1:8020")
|
||||
self.assertIn("'Vista.exe' = 'Vista'", ps)
|
||||
self.assertIn("Invoke-RestMethod -Method Post -Uri 'http://10.4.1.1:8020/api/hello'", ps)
|
||||
self.assertNotIn("{{", ps)
|
||||
|
||||
|
||||
class TestRoutes(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
snap = healthy_snapshot(time.time())
|
||||
FakeCompanion.store = {"conns": snap.conns, "vars": dict(snap.vars), "custom": {}, "restarts": []}
|
||||
cls.fake = ThreadingHTTPServer(("127.0.0.1", 0), FakeCompanion)
|
||||
threading.Thread(target=cls.fake.serve_forever, daemon=True).start()
|
||||
cls.data = tempfile.mkdtemp(prefix="sp-campus-")
|
||||
key = os.path.join(cls.data, "id_test")
|
||||
open(key + ".pub", "w").write("ssh-ed25519 AAAAC3NzaTESTKEY status-panel@sc\n")
|
||||
cfg, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "4821")
|
||||
cfg["companion"] = {"host": "127.0.0.1", "port": cls.fake.server_address[1]}
|
||||
cfg["ssh_key"] = key
|
||||
json.dump(cfg, open(os.path.join(cls.data, "config.json"), "w"))
|
||||
json.dump({"pid": 1, "started_at": time.time(), "config_sha": "x", "version": "test"}, open(os.path.join(cls.data, "started.json"), "w"))
|
||||
open(os.path.join(cls.data, "status_panel.py"), "w").write("# code\n")
|
||||
os.makedirs(os.path.join(cls.data, "kit", "sites", "_template"))
|
||||
open(os.path.join(cls.data, "kit", "install.sh"), "w").write("# installer\n")
|
||||
open(os.path.join(cls.data, "kit", "sites", "_template", "config.json"), "w").write("{}")
|
||||
open(os.path.join(cls.data, "outlets.json"), "w").write('{"tuya": {"access_secret": "never-shipped"}}')
|
||||
sock = socket.socket(); sock.bind(("127.0.0.1", 0)); cls.port = sock.getsockname()[1]; sock.close()
|
||||
cls.site, cls.servers = server.serve(cls.data, cls.port, ["127.0.0.1"])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
for s in cls.servers:
|
||||
s.shutdown()
|
||||
cls.fake.shutdown()
|
||||
|
||||
def test_hello_registers_a_computer_without_the_code(self):
|
||||
status, body = get(self.port, "/api/hello", {"user": "media", "name": "Tracks Mac", "os": "mac", "ips": ["10.4.3.227"]})
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("Tracks Mac", discover.registered(self.data))
|
||||
self.assertTrue(self.site.discover_poke.is_set())
|
||||
self.assertEqual(get(self.port, "/api/hello", {"name": "no user"})[0], 400)
|
||||
status, body = get(self.port, "/api/discover")
|
||||
d = json.loads(body)
|
||||
self.assertEqual(d["join"]["mac"], "curl -fsSL http://127.0.0.1:%d/join | sh" % self.port)
|
||||
self.assertIn("Tracks Mac", d["joined"])
|
||||
|
||||
def test_join_lines_carry_this_macs_key_and_address(self):
|
||||
status, body = get(self.port, "/join")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("KEY='ssh-ed25519 AAAAC3NzaTESTKEY status-panel@sc'", body.decode())
|
||||
self.assertIn("http://127.0.0.1:%d/api/hello" % self.port, body.decode())
|
||||
status, body = get(self.port, "/join.ps1")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("$IngestIPs = @('10.4.1.1', '10.6.20.5')", body.decode())
|
||||
|
||||
def test_the_program_kit_has_the_code_and_never_the_settings(self):
|
||||
status, body = get(self.port, "/campus/program.tgz")
|
||||
self.assertEqual(status, 200)
|
||||
names = tarfile.open(fileobj=io.BytesIO(body), mode="r:gz").getnames()
|
||||
self.assertIn("status_panel.py", names)
|
||||
self.assertIn("install.sh", names)
|
||||
self.assertIn("sites/_template/config.json", names)
|
||||
self.assertFalse([n for n in names if n.endswith(("config.json",)) and not n.startswith("sites/_template")])
|
||||
self.assertNotIn("outlets.json", names)
|
||||
status, body = get(self.port, "/campus/install?id=ws")
|
||||
self.assertIn("--site ws", body.decode())
|
||||
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):
|
||||
self.assertEqual(get(self.port, "/api/discover/now", {})[0], 401)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestCampusesView(unittest.TestCase):
|
||||
"""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):
|
||||
from settings import campuses as cv
|
||||
import push_page
|
||||
na = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
export = cv.build_view(na, 99, 98)
|
||||
ctrls = export["page"]["controls"]
|
||||
self.assertEqual(export["page"]["name"], "99\\nLocation")
|
||||
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 location is shown
|
||||
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)
|
||||
acts = cells[0]["steps"]["0"]["action_sets"]
|
||||
self.assertEqual(sorted(acts), ["2000", "down", "up"]) # tap = acknowledge, hold = fix, there
|
||||
self.assertIn("sp_view_cmd", json.dumps(acts))
|
||||
self.assertEqual(cv.campus_command([{"id": "hd1", "pos": [0, 2]}, {"id": "midi", "pos": [1, 3]}],
|
||||
"tap 0 2 1000;hold 1 3 1000;hold 3 3 1000;hold 1 3 900", 1005),
|
||||
["ack hd1", "restart midi 1000"]) # an empty cell and a stale hold are dropped
|
||||
self.assertIn("sp_state_view", json.dumps(cells[0]["style"]))
|
||||
self.assertTrue(push_page.safe_target(export)[0]) # a rebuild may overwrite it
|
||||
|
||||
def test_a_campus_state_lands_on_its_cells(self):
|
||||
from settings import campuses as cv
|
||||
ws, _ = ac.build(BASE, small(), {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||
keys = [{"id": t["id"], "pos": t["pos"]} for t in ws["tiles"] if t.get("pos")]
|
||||
out = cv.view_state({"hd1": {"x": "HD1\nREC", "b": 1}, "all": {"x": "ALL OK"}}, keys)
|
||||
hd1 = next(k for k in keys if k["id"] == "hd1")
|
||||
self.assertEqual(out[cv.cell_id(*hd1["pos"])]["x"], "HD1\nREC")
|
||||
self.assertEqual(out["c_0_1"]["x"], "ALL OK")
|
||||
self.assertEqual(out["c_2_7"]["x"], "") # nothing there: blank
|
||||
|
||||
def test_the_campuses_page_puts_this_campus_first_and_taps_to_each(self):
|
||||
from settings import campuses as cv
|
||||
na = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
roster = {"na": {"name": "North Attleboro"}, "ws": {"name": "Woonsocket"}, "ab": {"name": "Apollo Beach"}}
|
||||
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 "'location " in (ctl.get("options") or {}).get("notes", "")]
|
||||
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
|
||||
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(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
|
||||
self.assertIn("'location na'", keys[0][2]["options"]["notes"])
|
||||
self.assertIn("page 19", keys[0][2]["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"]
|
||||
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))
|
||||
|
||||
def test_a_campus_tells_the_hub_and_learns_nothing_back(self):
|
||||
from settings import campuses as cv
|
||||
data_dir = tempfile.mkdtemp(prefix="sp-cv-")
|
||||
cv.save(data_dir, {"home": "http://10.1.1.1:8020", "list": {"na": {"url": "x"}, "ab": {"url": "y"}}})
|
||||
|
||||
class FakeSite:
|
||||
def __init__(self):
|
||||
self.data_dir = data_dir
|
||||
|
||||
def load(self):
|
||||
return {"site": {"id": "ws", "name": "Woonsocket"}}
|
||||
sent = []
|
||||
real = cv.http_json
|
||||
cv.http_json = lambda url, body=None, timeout=3: sent.append((url, body)) or {"ok": True}
|
||||
real_url = cv.my_url
|
||||
cv.my_url = lambda cfg: "http://10.5.1.1:8020"
|
||||
try:
|
||||
m = cv.Mirror(FakeSite())
|
||||
data = m.sync_registry()
|
||||
m.poll()
|
||||
listed = m.refresh_pages()["list"]
|
||||
self.assertEqual({k: {f: v for f, v in e.items() if f != "user"} for k, e in listed.items()},
|
||||
{"ws": {"id": "ws", "name": "Woonsocket", "url": "http://10.5.1.1:8020"}})
|
||||
self.assertTrue(listed["ws"]["user"]) # the account the hub backs it up with
|
||||
finally:
|
||||
cv.http_json, cv.my_url = real, real_url
|
||||
self.assertEqual(list(data["list"]), ["ws"]) # the others are forgotten
|
||||
self.assertTrue(all(u.endswith("/api/campus/hello") for u, _ in sent)) # it only ever says hello
|
||||
|
||||
|
||||
class TestCampusHello(TestRoutes):
|
||||
def test_campus_hello_is_checked(self):
|
||||
self.assertEqual(get(self.port, "/api/campus/hello", {"id": "ws", "name": "Woonsocket", "url": "http://10.5.1.1:8020"})[0], 200)
|
||||
for bad in ({"id": "WS; rm", "url": "http://10.5.1.1:8020"}, {"id": "ws", "url": "http://evil.example.com:80"},
|
||||
{"id": "ws", "url": "http://8.8.8.8:8020"}):
|
||||
self.assertEqual(get(self.port, "/api/campus/hello", bad)[0], 400, bad)
|
||||
from settings import campuses as cv
|
||||
self.assertEqual(cv.load(self.data)["list"]["ws"]["url"], "http://10.5.1.1:8020")
|
||||
get(self.port, "/api/campus/hello", {"id": "new", "url": "http://10.5.1.9:8020"})
|
||||
get(self.port, "/api/campus/hello", {"id": "sc", "name": "Southcoast", "url": "http://10.5.1.9:8020"})
|
||||
self.assertNotIn("new", cv.load(self.data)["list"]) # same Mac, now named: the old entry goes
|
||||
|
||||
|
||||
class TestStandalone(unittest.TestCase):
|
||||
"""Apollo Beach runs its own panel (2026-10-21): it tells nobody, keeps no location list, backs itself up."""
|
||||
|
||||
def test_a_standalone_campus_tells_nobody_and_is_no_hub(self):
|
||||
from settings import campuses as cv
|
||||
data_dir = tempfile.mkdtemp(prefix="sp-sa-")
|
||||
cv.save(data_dir, {"standalone": True})
|
||||
self.assertFalse(cv.Mirror.is_hub(cv.load(data_dir)))
|
||||
self.assertTrue(cv.Mirror.is_hub({})) # NA: no home
|
||||
self.assertFalse(cv.Mirror.is_hub({"home": "http://10.1.1.1:8020"}))
|
||||
|
||||
class FakeSite:
|
||||
def __init__(self):
|
||||
self.data_dir = data_dir
|
||||
|
||||
def load(self):
|
||||
return {"site": {"id": "ab", "name": "Apollo Beach"}}
|
||||
sent = []
|
||||
real, real_url = cv.http_json, cv.my_url
|
||||
cv.http_json = lambda url, body=None, timeout=3: sent.append(url) or {"ok": True}
|
||||
cv.my_url = lambda cfg: "http://10.4.1.1:8020"
|
||||
try:
|
||||
m = cv.Mirror(FakeSite())
|
||||
data = m.sync_registry()
|
||||
m.poll()
|
||||
m.refresh_pages()
|
||||
finally:
|
||||
cv.http_json, cv.my_url = real, real_url
|
||||
self.assertEqual(sent, [])
|
||||
self.assertEqual(list(data["list"]), ["ab"])
|
||||
|
||||
def test_setup_marks_it_standalone_and_drops_any_home(self):
|
||||
from settings import campuses as cv
|
||||
data_dir = tempfile.mkdtemp(prefix="sp-sa-")
|
||||
cv.save(data_dir, {"home": "http://10.1.1.1:8020"})
|
||||
real = campus.is_fresh
|
||||
campus.is_fresh = lambda cfg: False # set up already: only the look runs
|
||||
real_look = campus.look_and_add
|
||||
campus.look_and_add = lambda site, say=print, force=False: None
|
||||
real_site = server.Site
|
||||
server.Site = lambda d: type("S", (), {"load": lambda self: {}})()
|
||||
try:
|
||||
campus.setup(data_dir, "ab", "Apollo Beach", say=lambda *a: None, standalone=True)
|
||||
finally:
|
||||
campus.is_fresh, campus.look_and_add, server.Site = real, real_look, real_site
|
||||
data = cv.load(data_dir)
|
||||
self.assertTrue(data.get("standalone"))
|
||||
self.assertNotIn("home", data)
|
||||
|
||||
def test_the_standalone_zip_has_the_program_an_installer_and_no_settings(self):
|
||||
import zipfile
|
||||
sys.path.insert(0, os.path.join(ROOT, "tools"))
|
||||
import standalone
|
||||
out = tempfile.mkdtemp(prefix="sp-zip-")
|
||||
path = standalone.build("ab", "Apollo Beach", out)
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names = z.namelist()
|
||||
inst = z.read("Apollo Beach Status Panel/install.command").decode()
|
||||
mode = z.getinfo("Apollo Beach Status Panel/install.command").external_attr >> 16
|
||||
top = "Apollo Beach Status Panel/program/"
|
||||
for f in ("status_panel.py", "install.sh", "settings/campus.py", "sites/_template/config.json", "backup/backup-campuses.sh"):
|
||||
self.assertIn(top + f, names)
|
||||
for never in ("config.json", "outlets.json", "campuses.json", "sites/ws/config.json", "sites/sc/config.json"):
|
||||
self.assertNotIn(top + never, names)
|
||||
self.assertFalse([n for n in names if "__pycache__" in n or "/._" in n])
|
||||
self.assertIn("Apollo Beach Status Panel/READ ME FIRST.txt", names)
|
||||
self.assertTrue(mode & 0o100)
|
||||
self.assertIn("--standalone", inst)
|
||||
self.assertNotIn("10.1.1.1", inst)
|
||||
self.assertNotIn("authorized_keys", inst) # no one else's key is let in
|
||||
|
||||
|
||||
class TestSwitchKeys(unittest.TestCase):
|
||||
def test_a_new_campus_gets_auto_fix_by_alarms_and_a_rebuild_keeps_its_own_switches(self):
|
||||
new, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "1234")
|
||||
af = [t for t in new["tiles"] if t.get("kind") == "autofix"]
|
||||
self.assertEqual([t["pos"] for t in af], [[3, 1]]) # beside ALARMS
|
||||
self.assertEqual(schema.validate(new)[0], [])
|
||||
old = json.loads(json.dumps(new))
|
||||
old["tiles"].append({"id": "lights", "label": "LIGHTS", "kind": "bypass", "pos": [2, 7], "bypass": {"peers": ["Lighting"]}})
|
||||
again, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "1234")
|
||||
kept_cfg, kept = ac.keep_from(again, old)
|
||||
lights = [t for t in kept_cfg["tiles"] if t["id"] == "lights"]
|
||||
self.assertEqual((len(lights), lights[0]["pos"], "LIGHTS" in kept), (1, [2, 7], True))
|
||||
self.assertEqual(len([t for t in kept_cfg["tiles"] if t.get("kind") == "autofix"]), 1)
|
||||
|
||||
|
||||
class TestJoinWindowsAdmin(unittest.TestCase):
|
||||
def test_the_pc_line_stops_with_plain_words_when_not_administrator(self):
|
||||
cfg = json.load(open(os.path.join(ROOT, "sites", "sc", "config.json")))
|
||||
text = campus.join_windows(cfg, "http://10.5.2.60:8020")
|
||||
check = text.index("IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)")
|
||||
self.assertLess(check, text.index("Get-WindowsCapability")) # before anything needs it
|
||||
self.assertIn("Run as administrator", text)
|
||||
|
||||
|
||||
class TestHelperJoinsLater(unittest.TestCase):
|
||||
def test_rtpmidi_on_a_pc_that_joins_later_lands_on_the_midi_key(self):
|
||||
inv = small()
|
||||
inv["computers"].pop("lighting_pc")
|
||||
cfg, seen = ac.build(BASE, inv, {"id": "sc", "name": "Southcoast"}, "1234")
|
||||
midi = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None)
|
||||
self.assertIsNotNone(midi) # the MIDI key is there first
|
||||
new, added, now_seen = ac.extend(cfg, small(), seen)
|
||||
midi = next(t for t in new["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", [])))
|
||||
self.assertTrue(any((c.get("params") or {}).get("exe") == "rtpMIDI.exe" for c in midi["checks"]), added)
|
||||
self.assertTrue(any("RTPMIDI" in a for a in added), added)
|
||||
self.assertEqual(schema.validate(new)[0], [])
|
||||
|
||||
|
||||
class TestLocationsBand(unittest.TestCase):
|
||||
"""NA's page keys name the other locations with a problem in blue; red stays NA's own (2026-10-21)."""
|
||||
|
||||
def test_the_summary_names_locations_down_and_flashes_until_acknowledged(self):
|
||||
from settings import campuses as cv
|
||||
now = 1000.0
|
||||
st = lambda x, b, b2: {"hb": now - 1, "state": json.dumps({"all": {"x": x, "b": b, "b2": b2}})}
|
||||
remote = {"ws": st("ALL OK", 1, 1), "sc": st("2 DOWN\nPROP LIGHTS", 0xFF0000, 0)}
|
||||
self.assertEqual(cv.remote_summary(["sc", "ws", "ab"], remote, now), {"t": 1000, "n": 2, "x": "AB? SC", "f": 1})
|
||||
remote["sc"] = st("1 DOWN\nX", 0x8B0000, 0x8B0000) # acknowledged at SC: steady
|
||||
self.assertEqual(cv.remote_summary(["sc", "ws"], remote, now), {"t": 1000, "n": 1, "x": "SC", "f": 0})
|
||||
remote["sc"] = dict(remote["sc"], hb=now - 60) # stopped answering
|
||||
self.assertEqual(cv.remote_summary(["sc"], remote, now)["x"], "SC?")
|
||||
|
||||
def test_only_a_page_key_set_for_it_carries_the_band(self):
|
||||
na = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
names = lambda cfg: [l.get("name") for l in make_page.nav_key("pagenum", cfg["variables"], 19, cfg=cfg)["style"]["layers"]]
|
||||
self.assertIn("Locations", names(dict(na, nav_locations_band=True)))
|
||||
self.assertNotIn("Locations", names(dict(na, nav_locations_band=False)))
|
||||
band = [l for l in make_page.nav_key("pagenum", na["variables"], 19, cfg=dict(na, nav_locations_band=True))["style"]["layers"]
|
||||
if l.get("name") == "Locations"][0]
|
||||
self.assertNotIn(str(0xFF0000), json.dumps(band)) # never red
|
||||
|
||||
|
||||
class TestAllAutoFix(unittest.TestCase):
|
||||
def test_the_key_names_the_paused_locations_and_sits_where_auto_fix_does(self):
|
||||
from settings import campuses as cv
|
||||
st = lambda x: json.dumps({"autofix": {"x": x}})
|
||||
states = {"na": st("AUTO-FIX\nON\nTIL 9PM"), "sc": st("AUTO-FIX\nPAUSED\nTIL SUN 2PM"), "ws": st("AUTO-FIX\nOFF\nON SUN 5AM"), "ab": None}
|
||||
self.assertEqual(cv.all_autofix_summary(states, 100.0), {"t": 100, "paused": 1, "x": "SC\nPAUSED"}) # WS: outside its hours
|
||||
self.assertEqual(cv.all_autofix_summary({"na": st("AUTO-FIX\nON\nTIL 9PM")}, 100.0)["x"], "ALL ON")
|
||||
na = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
page = cv.build_overview(na, {"na": {"name": "North Attleboro"}, "sc": {"name": "Southcoast"}}, 98, 99)
|
||||
key = page["page"]["controls"]["3"]["1"]
|
||||
self.assertIn("all autofix", key["options"]["notes"])
|
||||
self.assertIn("sp_all_cmd", json.dumps(key["steps"]))
|
||||
|
||||
|
||||
class TestLongerHolds(unittest.TestCase):
|
||||
def test_na_page_key_goes_to_status_at_2s_and_locations_at_3s(self):
|
||||
na = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
cfg = dict(na, nav_hold_ms=2000, nav_hold_more=[{"ms": 3000, "page": 98}])
|
||||
st = make_page.nav_key("pagenum", cfg["variables"], 19, 2000, 1, cfg)["steps"]["0"]
|
||||
pages = {k: [a["options"]["page"]["value"] for a in v] for k, v in st["action_sets"].items()}
|
||||
self.assertEqual(pages, {"down": [], "up": [1], "2000": [19], "3000": [98]})
|
||||
self.assertEqual(st["options"]["runWhileHeld"], [2000, 3000])
|
||||
self.assertEqual(schema.validate(cfg)[0], [])
|
||||
|
||||
|
||||
class TestPcScripts(unittest.TestCase):
|
||||
"""The PC scripts: the admin check after the whole header line, the admin laptop let in, one line per PC."""
|
||||
|
||||
def cfg(self):
|
||||
cfg = json.load(open(os.path.join(ROOT, "config.json")))
|
||||
cfg["site"]["admin_ips"] = ["10.6.7.38"]
|
||||
cfg["site"]["admin_keys"] = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY christian-laptop@waters"]
|
||||
return cfg
|
||||
|
||||
def test_the_admin_check_never_splits_a_line(self):
|
||||
from settings import scripts
|
||||
text = scripts.with_admin_check(scripts.windows_setup(self.cfg(), "bibletv"))
|
||||
lines = text.split("\n")
|
||||
i = next(i for i, ln in enumerate(lines) if ln.startswith("if (-not ([Security.Principal"))
|
||||
self.assertTrue(lines[i - 1].startswith("# Status panel remote access for "))
|
||||
self.assertEqual(lines[i + 2], "}") # nothing left after the brace
|
||||
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222', '10.6.7.38')", text) # the laptop through the firewall
|
||||
self.assertIn("christian-laptop@waters", text)
|
||||
join = campus.join_windows(self.cfg(), "http://10.5.2.60:8020").split("\n")
|
||||
self.assertTrue(join[1].startswith("# Status panel remote access for "))
|
||||
self.assertEqual(join[4], "}")
|
||||
|
||||
def test_a_bad_admin_key_is_refused(self):
|
||||
from settings import scripts
|
||||
cfg = self.cfg()
|
||||
cfg["site"]["admin_keys"] = ["ssh-ed25519 AAAA'; rm -rf / #"]
|
||||
with self.assertRaises(ValueError):
|
||||
scripts.admin_keys(cfg)
|
||||
|
||||
def test_the_mac_join_line_takes_the_admin_key_too(self):
|
||||
from settings import scripts
|
||||
real = scripts.public_key
|
||||
scripts.public_key = lambda cfg: ("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPANELKEY status-panel@sc", "status-panel@sc")
|
||||
try:
|
||||
text = campus.join_mac(self.cfg(), "http://10.5.2.60:8020")
|
||||
finally:
|
||||
scripts.public_key = real
|
||||
self.assertIn("for A in 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY christian-laptop@waters'; do", text)
|
||||
@@ -0,0 +1,626 @@
|
||||
"""The READY page, offline: readiness.py's verdicts on this weekend's checklist (from trimmed copies of
|
||||
what PCO, ProPresenter, the Ableton set, ProdDash and Resolume really said on 2026-10-14), and how the
|
||||
watchdog and the page builder show them. Run: python3 -m unittest discover -s tests"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import make_page # noqa: E402
|
||||
import readiness as rd # noqa: E402
|
||||
import status_panel as sp # noqa: E402
|
||||
|
||||
sp.log.addHandler(logging.NullHandler())
|
||||
sp.log.propagate = False
|
||||
|
||||
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||
CONFIG["tiles"] = [t for t in CONFIG["tiles"] if t.get("kind") != "autofix"] # ALARMS doing both, as these tests expect
|
||||
# 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"}]
|
||||
RC = CONFIG["ready"]
|
||||
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
|
||||
SATURDAY = datetime(2026, 10, 17, 16, 0).timestamp() # inside "due": gaps are red
|
||||
SUNDAY_PLAN = datetime(2026, 10, 18, 9, 30).timestamp()
|
||||
|
||||
|
||||
def item(title, type_="item", position="during", description="", notes=None):
|
||||
return {"id": "i-" + title, "note_ids": {k: "n-" + title for k in (notes or {})}, "title": title, "type": type_,
|
||||
"position": position, "description": description, "notes": notes or {}}
|
||||
|
||||
|
||||
def plan():
|
||||
return {"id": "92013595", "title": "Part 8", "dates": "October 18, 2026", "time": SUNDAY_PLAN,
|
||||
"items": [
|
||||
item("Doors Open", "header"),
|
||||
item("Preservice", position="pre"),
|
||||
item("MC Announcements", "media", "pre", "- Celebrate recovery\n- WK Team night\n- Peru missions trip\n- Family skate night"),
|
||||
item("Worship", "header"),
|
||||
item("Rejoice Again", "song", notes={"LTC": "00:01:17:26"}),
|
||||
item("Can't Help But Praise (Live)", "song", notes={"LTC": "00:06:43:21"}),
|
||||
item("Holy Forever", "song", notes={"LTC": "00:13:33:07"}),
|
||||
item("Baptisms", "media", description="730 930 None\n1130 Samantha Lopez"),
|
||||
item("Message", "header"),
|
||||
item("Message", description="Pastor Tim Live"),
|
||||
item("Covered By The Blood TAG", "song", notes={"LTC": "00:24:13:07"}),
|
||||
item("Postservice", "song", "post"),
|
||||
],
|
||||
"team": [
|
||||
{"name": "Jonah Perdue", "position": "[Leader]", "team": "Band", "status": "C"},
|
||||
{"name": "Krista Perdue", "position": "Vocalist", "team": "Band", "status": "C"},
|
||||
{"name": "Joshua Tineo", "position": "Vocalist", "team": "Band", "status": "D"},
|
||||
{"name": "Kevin Michel", "position": "Piano/Pad", "team": "Band", "status": "C"},
|
||||
{"name": "Luis Decamps", "position": "Drums", "team": "Band", "status": "D"},
|
||||
{"name": "Anthony Hoyt", "position": "MC", "team": "Communication", "status": "C"},
|
||||
{"name": "Tim Hatch", "position": "Speaker", "team": "Communication", "status": "C"},
|
||||
{"name": "Chris Meireles", "position": "Audio - FOH", "team": "Sunday Production", "status": "C"},
|
||||
]}
|
||||
|
||||
|
||||
def slide(label="", text="", notes="", enabled=True):
|
||||
return {"label": label, "text": text, "notes": notes, "enabled": enabled}
|
||||
|
||||
|
||||
def pres(name, slides, timeline=False, cues=None):
|
||||
return {"name": name, "path": "/Volumes/MEDIA/ProPresenter/Libraries/%s.pro" % name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
|
||||
"cues": len(slides) if cues is None else cues, "slides": slides}
|
||||
|
||||
|
||||
OLD_NOTES = "- Operation Christmas child\n- 5k\n- Movie night\n- Flourish"
|
||||
|
||||
|
||||
def prop():
|
||||
song = lambda name, bg="Heavenly_Lights_1_4k.mp4": pres(name, [slide(bg, "Verse"), slide("", "Chorus")], timeline=True)
|
||||
return {"playlist": "Where is Your Faith? - Part 8 - October 18, 2026", "items": [
|
||||
{"name": "Doors Open", "type": "header", "pco": True, "pres": None},
|
||||
{"name": "Preservice", "type": "presentation", "pco": True, "pres": pres("Preservice", [slide("CLICK THIS TO START")])},
|
||||
{"name": "MC Announcements", "type": "presentation", "pco": True,
|
||||
"pres": pres("MC Announcements", [slide("ANNOUNCEMENTS", notes=OLD_NOTES)] + [slide(notes=OLD_NOTES)] * 4)},
|
||||
{"name": "Worship", "type": "header", "pco": True, "pres": None},
|
||||
{"name": "Rejoice Again - [ Full ]", "type": "presentation", "pco": True, "pres": song("Rejoice Again")},
|
||||
{"name": "Can't Help But Praise LM - [ Full ]", "type": "presentation", "pco": True, "pres": song("Can't Help But Praise LM")},
|
||||
{"name": "Holy Forever - [ Full ]", "type": "presentation", "pco": True, "pres": song("Holy Forever", bg="")},
|
||||
{"name": "Baptisms", "type": "presentation", "pco": True,
|
||||
"pres": pres("Baptisms", [slide("Zoey 1130"), slide("LIVE BAPTISM"), slide("Bobby 1130")])},
|
||||
{"name": "Message", "type": "header", "pco": True, "pres": None},
|
||||
{"name": "Message Bumper", "type": "presentation", "pco": True, "pres": pres("Message Bumper", [slide("Bumper.mp4")])},
|
||||
{"name": "Covered By The Blood TAG - [ TAG ]", "type": "presentation", "pco": True,
|
||||
"pres": pres("Covered By The Blood TAG", [slide("Grainyents_Dark_2_4k.mp4")], timeline=False, cues=0)},
|
||||
{"name": "Postservice", "type": "presentation", "pco": True, "pres": pres("Postservice", [slide("24_Prayer-1.png")])},
|
||||
]}
|
||||
|
||||
|
||||
def track(id_, name, kind="AudioTrack", group="-1", vol=1.0, ext=True, clips=((0, 100, None),)):
|
||||
return {"id": id_, "kind": kind, "name": name, "group": group, "vol": vol, "ext": ext,
|
||||
"out": "Ext. Out 5/6 Keys" if ext else "Master", "clips": [list(c) for c in clips]}
|
||||
|
||||
|
||||
def ableton(lighting=((0, 50, 12), (100, 150, 8))):
|
||||
tracks = [
|
||||
track("1", "LIGHTING CUES", "MidiTrack", clips=lighting),
|
||||
track("10", "REJOICE AGAIN", "GroupTrack", clips=()),
|
||||
track("11", "21-Piano", group="10", vol=0.25, clips=((0, 90, None),)),
|
||||
track("12", "22-Keys", group="10", clips=((0, 90, None),)),
|
||||
track("20", "CAN'T HELP BUT PRAISE", "GroupTrack", clips=()),
|
||||
track("21", "44-Piano", group="20", clips=((100, 190, None),)),
|
||||
track("22", "45-Piano 2", group="20", clips=((100, 190, None),)),
|
||||
track("23", "Sub group", "GroupTrack", group="20", clips=()),
|
||||
track("24", "46-EG", group="23", clips=((100, 190, None),), ext=False),
|
||||
track("30", "HOLY FOREVER", "GroupTrack", clips=()),
|
||||
track("31", "72-Piano", group="30", vol=0.5, clips=((200, 290, None),)),
|
||||
track("40", "COVERED BY THE BLOOD", "GroupTrack", clips=()),
|
||||
track("41", "80-Bass", group="40", vol=0.7, clips=((300, 390, None),)),
|
||||
]
|
||||
path = "/Projects/NA/261018 NA Project/261018 NA Project.als"
|
||||
# The real tempo map and locators of 261018 NA Project (Rejoice 151 BPM, Can't Help 143, Holy Forever 72, Covered 76).
|
||||
tempo = {"manual": 68.3, "events": [[-63072000, 143], [0, 143], [0, 151], [1016, 151], [1016, 143], [1992, 143],
|
||||
[1992, 72], [2760, 72], [2760, 76], [5599.5, 76], [5599.5, 71]]}
|
||||
locators = [{"name": "1- CALL TO WORSHIP ", "time": 0}, {"name": "LOOP", "time": 76},
|
||||
{"name": "2 - REJOICE AGAIN - Bb", "time": 196}, {"name": "3- CAN'T HELP BUT PRAISE - Eb", "time": 1016},
|
||||
{"name": "4- HOLY FOREVER - B", "time": 1992}, {"name": "5- COVERED BY THE BLOOD- B", "time": 2760}]
|
||||
ltc = {"start": 0.0, "name": "LTC_00000000_60mins_30fps_48000x24", "warped": False, "offset": 0.0}
|
||||
return {"set": path, "live_set": path, "mtime": WEDNESDAY,
|
||||
"summary": {"tracks": tracks, "locators": locators, "tempo": tempo, "ltc": ltc},
|
||||
"bundles": {"AB": {"folder": "/Projects/AB/261018 AB Project", "als": True, "copying": False},
|
||||
"SC": {"folder": "/Projects/SC/261018 SC Project", "als": False, "copying": False}}}
|
||||
|
||||
|
||||
def lineup(names, plan_id="92013595"):
|
||||
return {"state": {"plan": {"id": plan_id, "dates": "October 18, 2026"}, "lastError": "",
|
||||
"slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}}
|
||||
|
||||
|
||||
def composition(deck="Service", empty=(), ignored=(), columns=12):
|
||||
"""Resolume's "Crosses": a clip in every column of layer 1 but the `empty` ones; layer 2 all empty."""
|
||||
clip = lambda n, full, ign: {"name": {"value": "Clip %d" % n if full else ""},
|
||||
"connected": {"value": "Disconnected" if full else "Empty"},
|
||||
"ignorecolumntrigger": {"value": "On" if ign else "Layer Determined"}}
|
||||
return {"name": {"value": "Crosses"},
|
||||
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
|
||||
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
|
||||
"columns": [{"name": {"value": "Theme 10 White" if n == 11 else "Column %d" % n}} for n in range(1, columns + 1)],
|
||||
"layers": [{"clips": [clip(n, n not in empty, n in ignored) for n in range(1, columns + 1)]},
|
||||
{"clips": [clip(n, False, False) for n in range(1, columns + 1)]}]}
|
||||
|
||||
|
||||
# Companion as the chain really is: page 17 "White" presses 6/1/4, page 6's 1/4 connects column 11;
|
||||
# page 17 "Salvation" presses 6/1/5 (column 12) and 17/2/4 (White again); 17/0/1 presses itself.
|
||||
COMPANION = {
|
||||
17: {"controls": {"2": {"4": {"steps": {"0": {"action_sets": {"down": [
|
||||
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/4"}}}]}}}}},
|
||||
"4": {"3": {"steps": {"0": {"action_sets": {"down": [
|
||||
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/5"}}},
|
||||
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/2/4"}}}]}}}}},
|
||||
"0": {"1": {"steps": {"0": {"action_sets": {"down": [
|
||||
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/0/1"}}}]}}}}}}},
|
||||
6: {"controls": {"1": {"4": {"steps": {"0": {"action_sets": {"down": [
|
||||
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/11/connect"}}}]}}}},
|
||||
"5": {"steps": {"0": {"action_sets": {"down": [
|
||||
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/12/connect"}}}]}}}}}}},
|
||||
}
|
||||
MACROS = {"U-WHITE": {"name": "White", "cc": [["Stream Deck", "CC x", "17/2/4"]], "calls": []},
|
||||
"U-SALV": {"name": "Salvation Moment", "cc": [["Stream Deck", "CC x", "17/4/3"]], "calls": []},
|
||||
"U-LOOP": {"name": "Loop", "cc": [["Stream Deck", "CC x", "17/0/1"]], "calls": ["U-WHITE"]}}
|
||||
|
||||
|
||||
def macros_found(paths):
|
||||
"""Holy Forever and Rejoice Again fire White; Baptisms fires the macro that runs White too."""
|
||||
use = {"Holy Forever": ["U-WHITE"], "Rejoice Again": ["U-WHITE"], "Baptisms": ["U-LOOP"]}
|
||||
return {"macros": MACROS, "used": {p: use.get(p.rsplit("/", 1)[-1][:-4], []) for p in paths}}
|
||||
|
||||
|
||||
def msgs(line):
|
||||
return [m for _, m, _ in line.faults]
|
||||
|
||||
|
||||
class TestNames(unittest.TestCase):
|
||||
def test_titles_match_across_pco_propresenter_and_ableton(self):
|
||||
self.assertTrue(rd.same("Can't Help But Praise (Live)", "Can't Help But Praise LM - [ Full ]"))
|
||||
self.assertTrue(rd.same("Can't Help But Praise (Live)", "3- CAN’T HELP BUT PRAISE - Eb"))
|
||||
self.assertTrue(rd.same("Covered By The Blood TAG", "COVERED BY THE BLOOD"))
|
||||
self.assertTrue(rd.same("Message", "Message Bumper"))
|
||||
self.assertFalse(rd.same("Holy Forever", "Holy Spirit"))
|
||||
self.assertFalse(rd.same("", "Anything"))
|
||||
|
||||
def test_lists_in_descriptions(self):
|
||||
self.assertEqual(rd.bullets("- Celebrate recovery\n- WK Team night\nnot a bullet"), ["Celebrate recovery", "WK Team night"])
|
||||
self.assertEqual(rd.listed_names("730 930 None\n1130 Samantha Lopez, Bob Ray & Ann Lee"),
|
||||
["Samantha Lopez", "Bob Ray", "Ann Lee"])
|
||||
|
||||
def test_the_plan_is_todays_or_the_next(self):
|
||||
p = lambda pid, iso: {"id": pid, "attributes": {"sort_date": iso}}
|
||||
plans = [p("old", "2026-10-11T13:30:00Z"), p("next", "2026-10-18T13:30:00Z"), p("later", "2026-10-25T13:30:00Z")]
|
||||
self.assertEqual(rd.pick_plan(plans, WEDNESDAY)[0]["id"], "next")
|
||||
self.assertEqual(rd.pick_plan(plans, datetime(2026, 10, 11, 12, 0).timestamp())[0]["id"], "old") # Sunday itself
|
||||
self.assertEqual(rd.pick_plan([], WEDNESDAY), (None, None))
|
||||
|
||||
def test_week_starts_monday_midnight(self):
|
||||
self.assertEqual(rd.week_start(WEDNESDAY), datetime(2026, 10, 12).timestamp())
|
||||
self.assertEqual(rd.week_start(datetime(2026, 10, 12, 0, 0).timestamp()), datetime(2026, 10, 12).timestamp())
|
||||
|
||||
|
||||
class TestPropresenter(unittest.TestCase):
|
||||
def test_pco_against_the_playlist(self):
|
||||
out = rd.eval_prop(RC, plan(), prop())
|
||||
self.assertEqual(msgs(out["prop"]), ["MC SLIDES OLD?", "BAPTISM: NO SAMANTHA"])
|
||||
self.assertEqual(msgs(out["timecode"]), ["NO TC COVERED BY THE."])
|
||||
self.assertEqual(msgs(out["backgrounds"]), ["NO BG HOLY FOREVER"])
|
||||
|
||||
def test_too_few_announcement_slides_and_a_missing_item(self):
|
||||
p = plan()
|
||||
p["items"][2]["description"] += "\n- Fifth thing\n- Sixth thing"
|
||||
p["items"].insert(4, item("Welcome Video", "media"))
|
||||
pp = prop()
|
||||
pp["items"][2]["pres"]["slides"] = [slide(notes=p["items"][2]["description"])] * 3
|
||||
out = rd.eval_prop(RC, p, pp)
|
||||
self.assertIn("MC 3/6 SLIDES", msgs(out["prop"]))
|
||||
self.assertIn("MISSING WELCOME VIDEO", msgs(out["prop"]))
|
||||
self.assertNotIn("MC SLIDES OLD?", msgs(out["prop"])) # the notes are this week's
|
||||
|
||||
def test_no_playlist_for_the_weekend(self):
|
||||
out = rd.eval_prop(RC, plan(), {"playlist": None, "items": []})
|
||||
self.assertTrue(all(msgs(line)[0].startswith("NO PLAYLIST") for line in out.values()))
|
||||
|
||||
|
||||
class TestAbleton(unittest.TestCase):
|
||||
def test_mixed_routed_and_lit(self):
|
||||
out = rd.eval_ableton(RC, plan(), ableton())
|
||||
self.assertEqual(msgs(out["tracks"]), [])
|
||||
# every Can't Help track is at 0 dB, and one of them goes to Master; Holy Forever has no lighting cue
|
||||
self.assertEqual(msgs(out["mix_foh"]), ["NOT MIXED CANT HELP BUT P.", "TO MASTER CANT HELP BUT P."])
|
||||
self.assertEqual(out["mix_foh"].faults[0][0], rd.WARN)
|
||||
self.assertEqual(msgs(out["lights"]), ["NO LIGHTS HOLY FOREVER", "NO LIGHTS COVERED BY THE."])
|
||||
|
||||
def test_a_song_with_no_tracks_and_an_old_set_open(self):
|
||||
al = ableton()
|
||||
al["summary"]["tracks"] = [t for t in al["summary"]["tracks"] if t["id"] not in ("30", "31")]
|
||||
al["live_set"] = "/Projects/NA/261011 NA Project/261011 NA Project.als"
|
||||
out = rd.eval_ableton(RC, plan(), al)
|
||||
self.assertEqual(msgs(out["tracks"]), ["NO TRACKS HOLY FOREVER", "LIVE HAS OLD SET"])
|
||||
|
||||
def test_no_set_for_the_weekend(self):
|
||||
out = rd.eval_ableton(RC, plan(), {"set": None, "bundles": {}})
|
||||
self.assertEqual(msgs(out["tracks"]), ["NO SET 261018"])
|
||||
|
||||
def test_tracks_sent_to_the_locations(self):
|
||||
self.assertEqual(msgs(rd.eval_track_bundle(RC, ableton())), ["NOT SENT SC"])
|
||||
|
||||
def test_the_remote_scripts_are_valid_python(self):
|
||||
params = json.dumps(rd.ableton_params(RC, plan()))
|
||||
for script in (rd.ALS_SCRIPT % params, rd.COPY_SCRIPT % params, rd.BUNDLE_SCRIPT % json.dumps({"folder": "x", "pattern": "*"})):
|
||||
compile(script, "remote", "exec")
|
||||
self.assertEqual(json.loads(params)["date"], "261018")
|
||||
|
||||
|
||||
class TestTimecode(unittest.TestCase):
|
||||
def test_seconds_through_the_tempo_map(self):
|
||||
tempo = ableton()["summary"]["tempo"]
|
||||
self.assertAlmostEqual(rd.seconds_at(tempo, 1016), 403.7086, places=3) # Live's own warp marker
|
||||
self.assertAlmostEqual(rd.seconds_at(tempo, 2760), 1453.2191, places=3)
|
||||
self.assertAlmostEqual(rd.seconds_at({"manual": 120, "events": []}, 8), 4.0)
|
||||
self.assertAlmostEqual(rd.seconds_at({"manual": 0, "events": [[0, 60], [4, 120]]}, 4), 60 * 4 / 60 * __import__("math").log(2))
|
||||
self.assertEqual(rd.ltc_clip_info("LTC_01000000_60mins_25fps"), (3600.0, 25.0))
|
||||
self.assertEqual(rd.timecode(813.2191, 30), "00:13:33:07")
|
||||
|
||||
def test_tracks_timecode_is_what_pco_says(self):
|
||||
out = [(s["title"], s["tracks"]) for s in rd.song_ltc(RC, plan(), ableton())]
|
||||
self.assertEqual(out, [("Rejoice Again", "00:01:17:26"), ("Can't Help But Praise (Live)", "00:06:43:21"),
|
||||
("Holy Forever", "00:13:33:07"), ("Covered By The Blood TAG", "00:24:13:07")])
|
||||
self.assertEqual(msgs(rd.eval_ltc(RC, plan(), ableton(), rd.Line())), [])
|
||||
|
||||
def test_wrong_or_missing_ltc_in_pco(self):
|
||||
p = plan()
|
||||
p["items"][4]["notes"] = {} # Rejoice: no LTC note
|
||||
p["items"][6]["notes"]["LTC"] = "00:13:30:00" # Holy Forever: moved in the tracks
|
||||
self.assertEqual(msgs(rd.eval_ltc(RC, p, ableton(), rd.Line())), ["NO LTC REJOICE AGAIN", "LTC WRONG HOLY FOREVER"])
|
||||
|
||||
def test_hold_writes_the_tracks_timecode_into_pco(self):
|
||||
p = plan()
|
||||
p["items"][4]["notes"], p["items"][4]["note_ids"] = {}, {}
|
||||
p["items"][6]["notes"]["LTC"] = "00:13:30:00"
|
||||
songs = rd.song_ltc(RC, p, ableton())
|
||||
calls, real_get, real_login, real_http = [], rd.pco_get, rd.pco_login, rd.http_json
|
||||
rd.pco_login = lambda rc: ("Basic x", "275974")
|
||||
rd.pco_get = lambda auth, path, pages=10: ([{"id": "77", "attributes": {"name": "LTC"}}], [])
|
||||
rd.http_json = lambda url, headers=None, method="GET", data=None, timeout=10: calls.append(
|
||||
(method, url.split("/plans/")[1], json.loads(data)["data"]["attributes"])) or {}
|
||||
try:
|
||||
self.assertTrue(rd.write_ltc(RC, songs, "275974", "92013595"))
|
||||
finally:
|
||||
rd.pco_get, rd.pco_login, rd.http_json = real_get, real_login, real_http
|
||||
self.assertEqual(calls, [
|
||||
("POST", "92013595/items/i-Rejoice Again/item_notes", {"content": "00:01:17:26", "item_note_category_id": "77"}),
|
||||
("PATCH", "92013595/items/i-Holy Forever/item_notes/n-Holy Forever", {"content": "00:13:33:07"})])
|
||||
self.assertTrue(rd.write_ltc(RC, rd.song_ltc(RC, plan(), ableton()), "275974", "92013595")) # all right: no writes
|
||||
|
||||
|
||||
class TestLineupCrossBundle(unittest.TestCase):
|
||||
def test_lineup_against_pco(self):
|
||||
line = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "DJ McLean", "Luis Decamps",
|
||||
"Anthony Hoyt", "Tim Hatch", None]))
|
||||
self.assertEqual(msgs(line), ["DJ MCLEAN NOT ON PLAN", "LUIS DECAMPS DECLINED", "KEVIN MICHEL MISSING"])
|
||||
good = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]))
|
||||
self.assertEqual(msgs(good), [])
|
||||
old = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"], "91"))
|
||||
self.assertEqual(msgs(old), ["OLD PLAN"])
|
||||
|
||||
def test_short_names_on_the_board(self):
|
||||
p = plan()
|
||||
p["team"] += [{"name": "Alex Gaitan", "position": "Drums", "team": "Band", "status": "C"},
|
||||
{"name": "Alexander Zinni", "position": "Guitar [Bass]", "team": "Band", "status": "C"}]
|
||||
board = ["Jonah Perdue", "KRISTA", "Kevin M", "AlexG", "Alex Zinni", "Anthony Hoyt", "Tim Hatch", "JONAH", "Luis"]
|
||||
self.assertEqual(msgs(rd.eval_lineup(RC, p, lineup(board))), ["LUIS DECLINED"])
|
||||
self.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure
|
||||
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
|
||||
|
||||
def test_columns_follow_macro_companion_resolume(self):
|
||||
get = lambda n: COMPANION.get(n, {})
|
||||
self.assertEqual(rd.button_columns(get, "17/2/4"), {11})
|
||||
self.assertEqual(rd.button_columns(get, "17/4/3"), {11, 12}) # through two buttons
|
||||
self.assertEqual(rd.button_columns(get, "17/0/1"), set()) # presses itself: no loop
|
||||
self.assertEqual(rd.macro_columns(MACROS, "U-LOOP", get), {11}) # a macro that runs another
|
||||
found = macros_found([it["pres"]["path"] for it in prop()["items"] if it["pres"]])
|
||||
line = rd.eval_columns(RC, prop(), found, get, composition())
|
||||
self.assertEqual((msgs(line), line.info), ([], "1 COLUMNS OK"))
|
||||
line = rd.eval_columns(RC, prop(), found, get, composition(empty=(11,)))
|
||||
self.assertEqual(msgs(line), ["COL 11 EMPTY"])
|
||||
self.assertIn("Theme 10 White", line.faults[0][2])
|
||||
self.assertIn("Holy Forever", line.faults[0][2])
|
||||
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(ignored=(11,)))), ["COL 11 EMPTY"])
|
||||
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(columns=10))), ["COL 11 MISSING"])
|
||||
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition("Test"))), ["WRONG DECK"])
|
||||
self.assertEqual(rd.eval_columns(RC, prop(), {"macros": {}, "used": {}}, get, composition()).info, "NO COLUMNS USED")
|
||||
|
||||
def test_the_macro_reader_runs(self):
|
||||
compile(rd.MACRO_SCRIPT % json.dumps({"paths": ["/x.pro"]}), "remote", "exec")
|
||||
|
||||
def test_prop_bundle_in_this_weekends_folder(self):
|
||||
rc = dict(RC, prop_bundle_folder="~/Shared drives/Location Assets/{date}")
|
||||
plan = {"time": datetime(2026, 10, 25, 9, 0).timestamp()}
|
||||
folder = rd.prop_bundle_folder(rc, plan)
|
||||
self.assertEqual(folder, "~/Shared drives/Location Assets/261025")
|
||||
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["WORSHIP BUNDLE.probundle", WEDNESDAY - 86400 * 10]], WEDNESDAY, folder)), [])
|
||||
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [], WEDNESDAY, folder)), ["NOT IN 261025"])
|
||||
|
||||
def test_prop_bundle_this_week(self):
|
||||
since = rd.week_start(WEDNESDAY)
|
||||
rc = dict(RC, prop_bundle_folder="~/Shared drives/Location Assets/ProPresenter") # one folder for every week
|
||||
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["Message Templates.probundle", WEDNESDAY - 3600]], since)), [])
|
||||
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["Old.probundle", since - 86400]], since)), ["NOT SENT (LAST 10/11)"])
|
||||
|
||||
def test_mics_from_pco(self):
|
||||
self.assertEqual(rd.mics_needed(RC, plan()), "2 VOX MC TIM")
|
||||
|
||||
|
||||
class TestLook(unittest.TestCase):
|
||||
def sources(self, **over):
|
||||
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
|
||||
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
|
||||
resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]],
|
||||
macros=macros_found, companion=lambda n: COMPANION.get(n, {}))
|
||||
src.update(over)
|
||||
return src
|
||||
|
||||
def test_everything_at_once(self):
|
||||
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources())
|
||||
self.assertEqual(r["plan"]["dates"], "October 18, 2026")
|
||||
self.assertEqual(set(r["items"]), set(sp.READY_ITEMS))
|
||||
self.assertEqual(r["items"]["lineup"]["faults"], [])
|
||||
self.assertEqual(r["items"]["mics"]["info"], "2 VOX MC TIM")
|
||||
self.assertEqual(r["items"]["track_bundle"]["faults"], [["err", "NOT SENT SC"]])
|
||||
|
||||
def test_one_source_down_does_not_hide_the_others(self):
|
||||
def down(*a):
|
||||
raise rd.ReadyError("PROP UNREACHABLE")
|
||||
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(prop=down))
|
||||
self.assertEqual(r["items"]["prop"]["faults"], [["warn", "PROP UNREACHABLE"]])
|
||||
self.assertEqual(r["items"]["lineup"]["faults"], [])
|
||||
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(pco=lambda: (_ for _ in ()).throw(rd.ReadyError("NO PCO LOGIN"))))
|
||||
self.assertEqual(r["items"]["tracks"]["faults"], [["warn", "NO PCO LOGIN"]])
|
||||
self.assertEqual(r["items"]["led_cross"]["faults"], [["warn", "NO PCO LOGIN"]]) # no plan, no columns to look at
|
||||
|
||||
def test_a_campus_that_only_receives(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
for k in ("locations", "prop_bundle_host", "resolume_url", "lineup_url"):
|
||||
cfg["ready"].pop(k)
|
||||
cfg["ready"]["prop_url"] = ""
|
||||
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources())
|
||||
self.assertEqual(sorted(r["items"]), ["lights", "mics", "mix_foh", "timecode", "tracks"]) # LTC: tracks vs PCO
|
||||
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
|
||||
snap.ready = r
|
||||
tiles = engine.step(snap, WEDNESDAY)
|
||||
self.assertEqual(tiles["rd_trk_out"]["x"], "TRACKS OUT\nNOT SET UP")
|
||||
self.assertEqual(tiles["rd_prop"]["x"], "PROP\nNOT SET UP")
|
||||
|
||||
|
||||
class TestWatchdog(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.engine = sp.Engine(CONFIG, None)
|
||||
self.snap = sp.Snapshot()
|
||||
self.snap.conns_time = 0
|
||||
|
||||
def step(self, now):
|
||||
self.engine.step(self.snap, now)
|
||||
return self.engine.step(self.snap, now + 5)
|
||||
|
||||
def test_amber_until_due_then_red(self):
|
||||
self.snap.ready = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources())
|
||||
tiles = self.step(WEDNESDAY)
|
||||
self.assertEqual(tiles["rd_trk_out"]["x"], "TRACKS OUT\nNOT SENT SC")
|
||||
self.assertEqual(tiles["rd_trk_out"]["b"], sp.COLORS["warn"][0][0])
|
||||
self.assertEqual(tiles["rd_boh"]["x"], "BOH MIX\nTAP WHEN DONE")
|
||||
self.assertEqual(tiles["rd_cross"]["b"], sp.COLORS["ok"][0][0])
|
||||
self.assertTrue(tiles["ready"]["x"].startswith("READY\n"))
|
||||
self.assertIn("TO DO", tiles["ready"]["x"])
|
||||
# the checklist never reaches ALL: a weekday gap is not an alarm
|
||||
self.assertNotIn("rd_trk_out", self.engine.detail_ids)
|
||||
self.assertFalse(any(k.startswith("rd_") for k in self.engine.keys_for("all")))
|
||||
tiles = self.step(SATURDAY)
|
||||
self.assertEqual(tiles["rd_trk_out"]["b"], sp.COLORS["err"][0][0]) # due: red, blinking
|
||||
self.assertEqual(tiles["rd_boh"]["b"], sp.COLORS["err"][0][0])
|
||||
foh = {res.msg: lv for lv, res, _ in self.engine.items["rd_foh"]}
|
||||
self.assertEqual(foh, {"NOT MIXED CANT HELP BUT P.": "warn", "TO MASTER CANT HELP BUT P.": "err"}) # NOT MIXED stays a warning
|
||||
|
||||
def test_tap_done_lasts_the_week(self):
|
||||
self.snap.ready = {"time": WEDNESDAY, "items": {}}
|
||||
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY)
|
||||
tiles = self.step(WEDNESDAY + 10)
|
||||
self.assertEqual(tiles["rd_boh"]["x"], "BOH MIX\nDONE WED 15:00")
|
||||
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY + 20) # tapped again: taken back
|
||||
self.assertNotIn("mix_boh", self.engine.ready_done(WEDNESDAY + 30))
|
||||
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY + 40)
|
||||
next_monday = datetime(2026, 10, 19, 9, 0).timestamp()
|
||||
self.assertIn("mix_boh", self.engine.ready_done(SATURDAY))
|
||||
self.assertNotIn("mix_boh", self.engine.ready_done(next_monday)) # a new week starts clean
|
||||
self.assertIsNone(self.engine.toggle_done("rd_tracks", WEDNESDAY)) # not a tap key
|
||||
|
||||
def test_one_hold_fixes_everything_that_can_be_fixed(self):
|
||||
look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources(
|
||||
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test")))
|
||||
self.snap.ready = look
|
||||
self.step(WEDNESDAY)
|
||||
plan = self.engine.fix_plan("ready", self.snap)
|
||||
fixes = sorted(what for _, kind, what, _ in plan if kind == "fix")
|
||||
self.assertEqual(fixes, ["ready_copy_tracks", "ready_led_cross", "ready_lineup", "ready_ltc", "ready_recheck"])
|
||||
ran = []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
|
||||
self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10)
|
||||
for t in self.engine.fix_threads:
|
||||
t.join(2)
|
||||
self.assertEqual(sorted(ran), fixes)
|
||||
self.assertEqual(self.engine.fix_plan("rd_all", self.snap), plan) # the key on the READY page too
|
||||
|
||||
def test_checking_before_the_first_look(self):
|
||||
tiles = self.step(WEDNESDAY)
|
||||
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nCHECKING")
|
||||
self.assertEqual(tiles["ready"]["x"].split("\n")[0], "READY")
|
||||
|
||||
|
||||
class TestPages(unittest.TestCase):
|
||||
def actions(self, export, row, col):
|
||||
step = export["page"]["controls"][str(row)][str(col)]["steps"]["0"]["action_sets"]
|
||||
return {k: [(a["definitionId"], {o: v["value"] for o, v in a["options"].items()}) for a in v] for k, v in step.items()}
|
||||
|
||||
def test_status_page_ready_key_opens_the_page(self):
|
||||
acts = self.actions(make_page.build(CONFIG), 2, 7)
|
||||
self.assertEqual(acts["up"], [("set_page", {"surfaceId": "self", "page": 21})])
|
||||
self.assertEqual(acts["2000"][0][1]["value"], "$(custom:sp_cmd);restart ready $(internal:time_unix)")
|
||||
|
||||
def test_ready_page(self):
|
||||
export = make_page.build_ready(CONFIG)
|
||||
self.assertEqual(export["page"]["name"], "21\\nReady")
|
||||
self.assertEqual(self.actions(export, 0, 3)["up"][0][1]["value"], "$(custom:sp_cmd);done rd_boh")
|
||||
self.assertNotIn("2000", self.actions(export, 0, 3)) # a tap key has no hold
|
||||
self.assertEqual(self.actions(export, 2, 1)["up"][0][1]["value"], "$(custom:sp_cmd);ack rd_lineup")
|
||||
self.assertIn("2000", self.actions(export, 2, 1))
|
||||
self.assertEqual(self.actions(export, 3, 0)["up"], [("set_page", {"surfaceId": "self", "page": 19})])
|
||||
notes = [c["options"]["notes"] for row in export["page"]["controls"].values() for c in row.values() if c and "options" in c]
|
||||
self.assertTrue(all(any(m in n for m in make_page.MARKERS) for n in notes)) # push_page may overwrite it
|
||||
|
||||
def test_settings_page_applies_page_24(self):
|
||||
from settings import apply as ap
|
||||
self.assertIn("ready", ap.build_hashes(CONFIG))
|
||||
moved = json.loads(json.dumps(CONFIG))
|
||||
moved["ready_page"]["keys"][0]["pos"] = [3, 5]
|
||||
self.assertEqual(ap.what_apply_does(CONFIG, moved, ap.build_hashes(CONFIG))["pages"], ["ready"])
|
||||
|
||||
def test_rules(self):
|
||||
bad = json.loads(json.dumps(CONFIG))
|
||||
bad["ready_page"]["keys"][1]["pos"] = bad["ready_page"]["keys"][0]["pos"]
|
||||
bad["ready_page"]["keys"][0]["checks"][0]["item"] = "nonsense"
|
||||
bad["ready_page"]["page"] = 19
|
||||
bad["fixes"]["ready_lineup"]["action"] = "explode"
|
||||
import schema
|
||||
errors = "\n".join(schema.validate(bad)[0])
|
||||
for expect in ("share row 0 column 1", "unknown checklist line nonsense", "cannot be the Status or Restart page",
|
||||
"unknown READY action explode"):
|
||||
self.assertIn(expect, errors)
|
||||
|
||||
|
||||
class TestFixes(unittest.TestCase):
|
||||
def test_lineup_sync_waits_for_the_new_pull(self):
|
||||
calls, states = [], iter([{"lastFetched": 1}, {"lastFetched": 1}, {"lastFetched": 2, "lastError": ""}])
|
||||
real = rd.http_json
|
||||
|
||||
def fake(url, headers=None, method="GET", data=None, timeout=10):
|
||||
calls.append((method, url.rsplit("/", 1)[-1]))
|
||||
return {"ok": True} if method == "POST" else {"state": next(states)}
|
||||
rd.http_json = fake
|
||||
try:
|
||||
self.assertTrue(rd.run_action(CONFIG, "lineup_sync", sleep=lambda s: None))
|
||||
finally:
|
||||
rd.http_json = real
|
||||
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
|
||||
|
||||
def test_cross_hold_puts_resolume_back_on_its_deck(self):
|
||||
calls = []
|
||||
real = rd.http_json
|
||||
|
||||
def fake(url, headers=None, method="GET", data=None, timeout=10):
|
||||
calls.append((method, url.split("/api/v1/composition")[-1]))
|
||||
return composition("Test")
|
||||
rd.http_json = fake
|
||||
try:
|
||||
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
|
||||
finally:
|
||||
rd.http_json = real
|
||||
self.assertIn(("POST", "/decks/1/select"), calls)
|
||||
self.assertFalse(any("/clips/" in path for _, path in calls)) # never plays a clip by itself
|
||||
|
||||
|
||||
class TestSpotifyAndMute(unittest.TestCase):
|
||||
def test_alarms_muted_stops_spotify_too(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg["auto_fix"]["log_dir"] = __import__("tempfile").mkdtemp(prefix="sp-autolog-")
|
||||
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
|
||||
wed = datetime(2026, 10, 14, 20, 20).timestamp() # inside Wednesday's auto-fix window
|
||||
engine.service.set(False, wed) # ALARMS muted
|
||||
snap.spotify = {"running": True, "state": "paused"}
|
||||
snap.remote = {"tracks\0pgrep -xq Live": False} # a gentle fix that must still wait
|
||||
ran = []
|
||||
engine.executor = lambda c, fix_id, chk: ran.append(fix_id) or True
|
||||
for t in range(0, 40, 2):
|
||||
engine.step(snap, wed + t)
|
||||
engine.auto_fix(snap, None, wed + 40)
|
||||
for th in engine.fix_threads:
|
||||
th.join(2)
|
||||
self.assertEqual(ran, []) # muted: nothing, not even Spotify
|
||||
engine2 = sp.Engine(cfg, None)
|
||||
engine2.service.set(True, wed) # alarms on: it plays
|
||||
engine2.executor = engine.executor
|
||||
snap.remote = {}
|
||||
for t in range(0, 40, 2):
|
||||
engine2.step(snap, wed + t)
|
||||
engine2.auto_fix(snap, None, wed + 40)
|
||||
for th in engine2.fix_threads:
|
||||
th.join(2)
|
||||
self.assertEqual(ran, ["spotify_play"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestOtherCampuses(unittest.TestCase):
|
||||
"""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"}
|
||||
|
||||
def campus_plan(self, titles, notes=None):
|
||||
p = plan()
|
||||
p["id"], p["service_type"] = "ab-1", "1113969"
|
||||
p["items"] = [item(t, "song", notes=(notes or {}).get(t)) for t in titles]
|
||||
return p
|
||||
|
||||
def test_songs_match_in_home_order_and_out_of_order_ones_are_skipped(self):
|
||||
na = rd.song_ltc(RC, plan(), ableton())
|
||||
titles = [s["title"] for s in na]
|
||||
self.assertGreaterEqual(len(titles), 3)
|
||||
# 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"])
|
||||
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(res["skipped"], [titles[0], "Something Else"])
|
||||
self.assertEqual(res["songs"][0]["tracks"], na[1]["tracks"])
|
||||
line = rd.eval_campus_ltc(RC, res, rd.Line())
|
||||
self.assertEqual([m for _, m, _ in line.faults], ["AB NO LTC " + rd.short(titles[1], 12), "AB NO LTC " + rd.short(titles[2], 12)])
|
||||
self.assertTrue(any("left alone" in d for d in line.as_dict()["details"]))
|
||||
right = self.campus_plan(titles, notes={t: {"LTC": s["tracks"]} for t, s in zip(titles, na)})
|
||||
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):
|
||||
"""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()
|
||||
cp = self.campus_plan([it["title"] for it in rd.plan_songs(RC, p)])
|
||||
res = rd.campus_ltc(RC, p, [], self.CAMPUS, cp)
|
||||
self.assertEqual(res["skipped"], [])
|
||||
self.assertTrue(res["songs"] and all(s["tracks"] is None for s in res["songs"]))
|
||||
self.assertEqual(rd.eval_campus_ltc(RC, res, rd.Line()).faults, [])
|
||||
self.assertIs(rd.write_ltc(RC, res["songs"], "1113969", "ab-1"), True)
|
||||
|
||||
def test_the_fix_writes_every_campus(self):
|
||||
na = rd.song_ltc(RC, plan(), ableton())
|
||||
cp = self.campus_plan([s["title"] for s in na])
|
||||
res = rd.campus_ltc(RC, plan(), na, self.CAMPUS, cp)
|
||||
rd.LAST["ltc"], rd.LAST["service_type"], rd.LAST["plan_id"] = na, "275974", "92013595"
|
||||
rd.LAST["ltc_other"] = [res]
|
||||
calls = []
|
||||
real_http, real_get, real_login = rd.http_json, rd.pco_get, rd.pco_login
|
||||
rd.pco_login = lambda rc: ("Basic x", "275974")
|
||||
rd.pco_get = lambda auth, path, pages=10: ([{"id": "77", "attributes": {"name": "LTC"}}], [])
|
||||
rd.http_json = lambda url, headers=None, method="GET", data=None, timeout=10: calls.append((method, url)) or {}
|
||||
try:
|
||||
cfg = dict(CONFIG, ready=dict(RC, ltc_campuses=[self.CAMPUS]))
|
||||
self.assertTrue(rd.run_action(cfg, "ltc_to_pco", sleep=lambda s: None))
|
||||
finally:
|
||||
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 location song
|
||||
|
||||
|
||||
class TestRunHere(unittest.TestCase):
|
||||
def test_a_script_for_this_mac_runs_here_without_ssh(self):
|
||||
cfg = {"hosts": {"ingest": {"self": True}}}
|
||||
self.assertEqual(rd.run_remote(cfg, "ingest", "import json; print(json.dumps({'ok': 1}))"), {"ok": 1})
|
||||
+25
-13
@@ -16,26 +16,28 @@ import make_page # noqa: E402
|
||||
import schema # noqa: E402
|
||||
import status_panel as sp # noqa: E402
|
||||
|
||||
WALKED = ["status_panel.py", "make_page.py", "push_page.py", "nav_keys.py", "netgear.py"]
|
||||
WALKED = ["status_panel.py", "make_page.py", "push_page.py", "nav_keys.py", "netgear.py", "readiness.py"]
|
||||
|
||||
# What a variable holds, by name, when nothing in the function says otherwise (parameters mostly).
|
||||
NAMES = {"cfg": "top", "config": "top", "chk": "check", "c": "check", "fix": "fix", "f": "fix", "t": "tile",
|
||||
"tile": "tile", "k": "restart_key", "key": "restart_key", "rp": "restart_page", "w": "window",
|
||||
"names": "variables", "host": "host", "h": "host", "windows": "windows", "site": "site"}
|
||||
"names": "variables", "host": "host", "h": "host", "windows": "windows", "site": "site", "rc": "ready"}
|
||||
ATTRS = {"self.cfg": "top", "engine.cfg": "top", "self.net": "network", "self.auto": "auto", "self.site": "site",
|
||||
"self.atem": "atem",
|
||||
"self.atem": "atem", "self.ready_cfg": "ready", "self.failover_cfg": "failover",
|
||||
"self.tiles": "tiles", "engine.tiles": "tiles", "self.restart_keys": "restart_keys",
|
||||
"engine.restart_keys": "restart_keys"}
|
||||
# A key of a top-level dict that holds a record of another kind.
|
||||
TOP = {"companion": "companion", "network": "network", "variables": "variables", "auto_fix": "auto", "site": "site",
|
||||
"atem": "atem", "spotify": "spotify", "timing": "timing", "flap": "flap", "hosts": "hosts", "fixes": "fixes",
|
||||
"tiles": "tiles", "restart_page": "restart_page", "gear": "gears", "service_windows": "windows"}
|
||||
"tiles": "tiles", "restart_page": "restart_page", "gear": "gears", "service_windows": "windows",
|
||||
"ready": "ready", "ready_page": "ready_page", "failover": "failover"}
|
||||
SUB = {("auto", "windows"): "windows", ("site", "live_services"): "windows", ("tile", "checks"): "checks",
|
||||
("restart_page", "keys"): "restart_keys", ("tile", "members"): "names"}
|
||||
("restart_page", "keys"): "restart_keys", ("tile", "members"): "names", ("ready", "due"): "windows",
|
||||
("ready_page", "keys"): "tiles", ("failover", "windows"): "windows"}
|
||||
ELEMENT = {"tiles": "tile", "checks": "check", "restart_keys": "restart_key", "windows": "window", "fixes": "fix",
|
||||
"hosts": "host", "gears": "gear"}
|
||||
# Keys the code reads that are the watchdog's own working data, not settings.
|
||||
NOT_SETTINGS = {("check", "ip")}
|
||||
NOT_SETTINGS = {("check", "ip"), ("check", "repoint")} # set by the watchdog on a fix's copy of a check
|
||||
|
||||
|
||||
def dotted(node):
|
||||
@@ -67,6 +69,10 @@ def collect(path):
|
||||
return step(base, key)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("items", "values"):
|
||||
return kind_of(node.func.value, local)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "effective_config":
|
||||
return "top" # the config with switched-off keys and checks taken out
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("all_tiles", "ready_keys"):
|
||||
return "tiles" # the Status page's tiles and the READY page's keys
|
||||
if isinstance(node, ast.Call) and dotted(node.func) == "json.load":
|
||||
return "jsonfile" # a config file when it lands in cfg / config
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("enumerate", "dict", "sorted", "list") and node.args:
|
||||
@@ -141,7 +147,8 @@ def path_of(kind, key):
|
||||
"restart_key": "restart_page.keys[].", "restart_page": "restart_page.", "auto": "auto_fix.",
|
||||
"network": "network.", "variables": "variables.", "site": "site.", "atem": "atem.", "spotify": "spotify.",
|
||||
"companion": "companion.", "timing": "timing.", "flap": "flap.", "gear": "gear.*.",
|
||||
"window": "auto_fix.windows[]."}.get(kind)
|
||||
"window": "auto_fix.windows[].", "ready": "ready.", "ready_page": "ready_page.",
|
||||
"failover": "failover."}.get(kind)
|
||||
return None if prefix is None else prefix + key
|
||||
|
||||
|
||||
@@ -170,20 +177,22 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
|
||||
def test_every_declared_key_is_read_somewhere(self):
|
||||
"""Dead settings are caught; keys only the settings page uses are listed here."""
|
||||
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.contacts", "site.editing_code",
|
||||
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.admin_ips", "site.admin_keys", "site.contacts", "site.editing_code",
|
||||
"site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role",
|
||||
"hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done",
|
||||
"gear", "gear.*.location", "gear.*.notes", "atem.source_folder", "fixes.*.template", "fixes.*.params",
|
||||
"gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "ready.ltc_campuses[].name", "ready.ltc_campuses[].short", "ready.ltc_campuses[].service_type", "atem.source_folder", "fixes.*.template", "fixes.*.params",
|
||||
"tiles[].checks[].template", "tiles[].checks[].params", "spotify.name", "site", "timing",
|
||||
"fixes", "hosts", "tiles", "flap", "auto_fix", "network", "variables", "companion", "atem", "spotify",
|
||||
"restart_page", "service_windows", "restart_page.keys", "tiles[].checks", "auto_fix.windows",
|
||||
"site.live_services", "tiles[].checks[].type", "fixes.*.playlist", "tiles[].checks[].playlist"}
|
||||
"site.live_services", "tiles[].checks[].type", "fixes.*.playlist", "tiles[].checks[].playlist",
|
||||
"ready_page", "ready_page.keys", "ready", "discover", "discover.auto"} # discover: settings/campus.py
|
||||
read = {path_of(kind, key) for _, kind, key, _, _ in all_reads()}
|
||||
read |= {"service_windows[].days", "service_windows[].start", "service_windows[].end",
|
||||
"site.live_services[].days", "site.live_services[].start", "site.live_services[].end"}
|
||||
for w in ("days", "start", "end"):
|
||||
if "auto_fix.windows[]." + w in read:
|
||||
read |= {"service_windows[].%s" % w, "site.live_services[].%s" % w}
|
||||
read |= {"service_windows[].%s" % w, "site.live_services[].%s" % w, "ready.due[].%s" % w,
|
||||
"failover.windows[].%s" % w}
|
||||
dead = sorted(p for p in schema.F if p not in read and p not in ui_only)
|
||||
self.assertEqual(dead, [], "declared settings nothing reads")
|
||||
|
||||
@@ -230,7 +239,8 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
if ctl and "options" in ctl:
|
||||
ctl["options"].pop("notes", None)
|
||||
return export
|
||||
for build, name in ((make_page.build, "status-page.companionconfig"), (make_page.build_restart, "restart-page.companionconfig")):
|
||||
for build, name in ((make_page.build, "status-page.companionconfig"), (make_page.build_restart, "restart-page.companionconfig"),
|
||||
(make_page.build_ready, "ready-page.companionconfig")):
|
||||
on_disk = json.loads(gzip.open(os.path.join(ROOT, name)).read())
|
||||
self.assertEqual(strip(build(cfg)), strip(on_disk), name)
|
||||
self.assertNotIn("restart_page", {k for k in cfg if k == "nothing"})
|
||||
@@ -285,7 +295,9 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
def test_phrases(self):
|
||||
cfg = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||
p = schema.phrases(cfg)
|
||||
self.assertEqual(p["auto_fix"], "may run by itself on Sundays 06:30-13:30")
|
||||
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"],
|
||||
"may run by itself on Sundays 06:30-13:30")
|
||||
self.assertEqual(p["live"], "changes wait while a HyperDeck is recording")
|
||||
self.assertEqual(p["hold"], "HOLD 2s")
|
||||
self.assertEqual(sp.windows_phrase([{"days": ["sat", "sun"], "start": "18:00", "end": "00:30"}]),
|
||||
|
||||
+49
-1
@@ -227,7 +227,7 @@ class TestServer(unittest.TestCase):
|
||||
self.assertIn("config.json", z.namelist())
|
||||
bundle_cfg = json.loads(z.read("config.json"))
|
||||
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"
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z2:
|
||||
@@ -291,6 +291,27 @@ class TestServer(unittest.TestCase):
|
||||
self.assertEqual(len(v["changes"]), 1); self.assertTrue(v["changes"][0]["text"].startswith("computer Lighting PC › notes"))
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
|
||||
def test_a_page_that_loaded_before_a_change_never_undoes_it(self):
|
||||
"""2026-10-15: a tab loaded the settings, a program update then added ready.prop_host, the tab
|
||||
changed one window and saved its whole (older) copy - Apply took prop_host out again. Now the
|
||||
page says what it started from and only its own change goes on top of the live settings."""
|
||||
import hashlib
|
||||
before = self.site.load(); base_sha = self.site.cfg_sha
|
||||
newer = json.loads(json.dumps(before)); newer["debounce_seconds"] = (before.get("debounce_seconds") or 3) + 2
|
||||
sp.write_json(self.site.config_path, newer) # changed under the page
|
||||
self.site.load()
|
||||
page = json.loads(json.dumps(before)); page["flap"] = dict(before.get("flap") or {}, count=7) # the page's own edit
|
||||
st, r = self.put("/api/draft", {"config": page, "base": base_sha, "base_config": before}, who="christian")
|
||||
self.assertTrue(r["rebased"])
|
||||
d = self.site.draft.read()["config"]
|
||||
self.assertEqual(d["flap"]["count"], 7) # the page's change
|
||||
self.assertEqual(d["debounce_seconds"], newer["debounce_seconds"]) # the newer value kept
|
||||
st, r = self.put("/api/draft", {"config": d, "base": self.site.cfg_sha, "base_config": d}, who="christian")
|
||||
self.assertFalse(r["rebased"]) # up to date: nothing to do
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
sp.write_json(self.site.config_path, before)
|
||||
self.site.load()
|
||||
|
||||
def test_probe_is_refused_while_a_fix_runs(self):
|
||||
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
||||
try:
|
||||
@@ -355,3 +376,30 @@ class TestBindingAndProbes(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestNewestCopy(unittest.TestCase):
|
||||
"""An update installed beside the old copy: the newest version opens, whatever the file names."""
|
||||
|
||||
@unittest.skipUnless(sys.platform == "darwin", "reads app versions with macOS's defaults")
|
||||
def test_the_newest_version_wins_not_the_last_name(self):
|
||||
import subprocess
|
||||
from settings import builders as b
|
||||
for names in ((("Show 20.0.1.app", "20.0.1"), ("Show.app", "20.1.0")), (("Show 20.0.1.app", "20.0.1"), ("Show.app", "19.4"))):
|
||||
root = tempfile.mkdtemp(prefix="sp-apps-")
|
||||
for name, ver in names:
|
||||
os.makedirs(os.path.join(root, name, "Contents"))
|
||||
subprocess.run(["/usr/bin/defaults", "write", os.path.join(root, name, "Contents", "Info"),
|
||||
"CFBundleShortVersionString", ver], check=True)
|
||||
out = subprocess.run(["/bin/sh", "-c", b.newest_app(root + "/Show*.app") + 'echo "$APP"'],
|
||||
capture_output=True, text=True).stdout.strip()
|
||||
want = max(names, key=lambda n: tuple(int(x) for x in n[1].split(".")))[0]
|
||||
self.assertEqual(os.path.basename(out), want)
|
||||
|
||||
def test_any_copy_counts_as_running_but_never_a_helper(self):
|
||||
import re
|
||||
from settings import builders as b
|
||||
m = b.app_match("/Applications/ProPresenter*.app", "ProPresenter")
|
||||
self.assertTrue(re.search(m, "/Applications/ProPresenter 20.0.1.app/Contents/MacOS/ProPresenter"))
|
||||
self.assertTrue(re.search(m, "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter"))
|
||||
self.assertFalse(re.search(m, "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter Helper (Network)"))
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
"""The generated set-up scripts and the campus template: every value comes from the record, the
|
||||
templates hold no site-shaped value, and the empty campus validates and builds its pages."""
|
||||
"""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 location validates and builds its pages."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -29,7 +29,7 @@ class TestScripts(unittest.TestCase):
|
||||
text = scripts.windows_setup(CFG, "lighting")
|
||||
self.assertIn("$Key = '%s'" % KEY[0], text)
|
||||
self.assertIn("$KeyMark = 'status-panel@NA-Ingest'", text)
|
||||
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222')", text)
|
||||
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222', '10.6.7.38')", text) # NA Ingest, then the admin laptop
|
||||
for exe in ("Vista.exe", "Arena.exe", "rtpMIDI.exe"):
|
||||
self.assertIn("(Find-Exe '%s')" % exe, text)
|
||||
self.assertIn("foreach ($svc in @('rtpMIDIService'))", text)
|
||||
@@ -85,7 +85,7 @@ class TestSites(unittest.TestCase):
|
||||
errors, _ = schema.validate(cfg)
|
||||
self.assertEqual(errors, [])
|
||||
make_page.build(cfg)
|
||||
self.assertEqual(sorted(t["kind"] for t in cfg["tiles"]), ["master", "network", "service"])
|
||||
self.assertEqual(sorted(t["kind"] for t in cfg["tiles"]), ["autofix", "master", "network", "service"])
|
||||
self.assertEqual([a for a, h in cfg["hosts"].items() if h.get("self")], ["ingest"])
|
||||
self.assertEqual(cfg["hosts"]["ingest"]["networks"], [])
|
||||
self.assertNotIn("Attleboro", json.dumps(cfg))
|
||||
|
||||
+818
-9
@@ -27,9 +27,14 @@ CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||
# The tests pin their own schedule so they do not depend on the site's choice.
|
||||
CONFIG["service_windows"] = [{"days": ["sun"], "start": "07:00", "end": "12:30"}]
|
||||
CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t in CONFIG["tiles"]]
|
||||
# NA has an AUTO-FIX key since 2026-10-21; most tests here cover ALARMS doing both (a page without one),
|
||||
# TestSwitches puts it back.
|
||||
CONFIG["tiles"] = [t for t in CONFIG["tiles"] if t.get("kind") != "autofix"]
|
||||
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
|
||||
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
|
||||
CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop
|
||||
# ...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"}]
|
||||
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
|
||||
|
||||
|
||||
@@ -68,9 +73,10 @@ def healthy_snapshot(now):
|
||||
snap.ping = {"10.1.3.227": 0, "10.6.7.13": 0, "10.1.0.1": 0}
|
||||
snap.link = True
|
||||
snap.launchd = {"org.waterschurch.resi-propresenter": 884, "org.waterschurch.lobby-captions": 900,
|
||||
"org.waterschurch.lobby-captions-display": 901}
|
||||
"org.waterschurch.lobby-captions-display": 901, "org.waterschurch.digico-relay": 902}
|
||||
snap.launchd_time = now
|
||||
snap.procs = {"/Applications/ProdDash.app/Contents/MacOS/ProdDash": True}
|
||||
snap.remote = {"studiob\0pgrep -xq 'Pro Tools'": True} # the failover's check has a verdict
|
||||
snap.http = {"https://central.resi.io/": True, "http://10.6.7.13:8080/": True, "http://10.1.14.225:1025/": True}
|
||||
snap.disk = {"/": 97}
|
||||
return snap
|
||||
@@ -415,6 +421,7 @@ class TestChecks(unittest.TestCase):
|
||||
engine = sp.Engine(CONFIG)
|
||||
self.assertEqual(engine.step(self.snap, self.now)["midi"]["x"], "MIDI\nOK") # not read yet: no verdict
|
||||
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
|
||||
{"name": "NA Ingest", "address": "", "state": "running"},
|
||||
{"name": "SuperRack-LiveBox", "address": "", "state": "running"},
|
||||
{"name": "Studio B Mac Studio", "address": "", "state": "running"},
|
||||
{"name": "Lighting PC", "address": "", "state": "running"}]}}
|
||||
@@ -455,6 +462,39 @@ class TestChecks(unittest.TestCase):
|
||||
self.assertEqual(sp.check_midi_peer({"type": "midi_peer", "host": "tracks", "session": "NA Tracks",
|
||||
"peer": "Lighting PC"}, self.snap, self.ctx)[0][1].level, sp.OK)
|
||||
|
||||
def test_a_midi_peer_on_wi_fi_is_named(self):
|
||||
"""2026-10-20: ProdDash joined Tracks' session over both Macs' Wi-Fi."""
|
||||
chk = {"type": "midi_wired", "nets": ["10.1.0.0/16"]}
|
||||
self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK])
|
||||
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
|
||||
{"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"},
|
||||
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}, # a name: no verdict
|
||||
{"name": "NA Ingest", "address": "[fe80::1]:5004", "state": "running"}]}}
|
||||
self.snap.remote_midi_time["tracks"] = self.now
|
||||
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
|
||||
out = sp.check_midi_wired(chk, self.snap, self.ctx)
|
||||
self.assertEqual([r.msg for _, r in out], ["PRODDASH LIGH. NOT WIRED", "STUDIO B MAC. BY NAME"]) # once each, not per session
|
||||
self.snap.remote_midi_time["tracks"] = self.now - 30 # a stale stream: no verdict
|
||||
self.snap.midi["NA Ingest"]["peers"].pop()
|
||||
self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK])
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
midi = next(t for t in cfg["tiles"] if t["id"] == "midi")
|
||||
midi["checks"] = [c for c in midi["checks"] if c["type"] != "midi_wired"] + [dict(chk)] # one, wherever it came from
|
||||
import schema
|
||||
self.assertEqual([e for e in schema.validate(cfg)[0] if "wired" in e], [])
|
||||
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["wifi"]
|
||||
self.assertTrue(any("not a network" in e for e in schema.validate(cfg)[0]))
|
||||
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
|
||||
peer = next(c for c in midi["checks"] if c.get("peer") == "Studio B Mac Studio")
|
||||
for bad in ("\tStudio B Mac Studio\tlocal.", "10.6.9.193:5004"): # a name, Wi-Fi: refused
|
||||
peer["address"] = bad
|
||||
self.assertTrue(any("Studio B Mac Studio" in e and "not on the wired network" in e for e in schema.validate(cfg)[0]), bad)
|
||||
peer["address"] = "10.1.13.175:5004"
|
||||
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
|
||||
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
|
||||
tiles, _ = run_steps(sp.Engine(cfg), self.snap, self.now, 4)
|
||||
self.assertEqual(tiles["midi"]["x"], "MIDI\nPRODDASH LIGH. NOT WIRED")
|
||||
|
||||
def test_midi_peer_joining_is_not_an_error(self):
|
||||
for p in self.snap.midi["NA Ingest"]["peers"]:
|
||||
p["state"] = "connected" # joined, clock sync not finished yet
|
||||
@@ -607,7 +647,7 @@ class TestBehaviour(unittest.TestCase):
|
||||
self.assertEqual(level(tiles["x32"]), "quiet")
|
||||
self.assertEqual(tiles["all"]["x"], "1 DOWN\nX32 OFFLINE")
|
||||
self.assertEqual(level(tiles["all"]), "quiet")
|
||||
self.assertEqual(tiles["service"]["x"], "SERVICE\nOFF")
|
||||
self.assertEqual(tiles["service"]["x"], "SERVICE OFF\nAUTO-FIX OFF")
|
||||
for t in tiles.values():
|
||||
self.assertEqual((t["b"], t["f"]), (t["b2"], t["f2"]), t["x"]) # no blinking anywhere
|
||||
|
||||
@@ -972,6 +1012,64 @@ class TestFixes(unittest.TestCase):
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, weekday + 30), [])
|
||||
self.assertEqual(ran, [])
|
||||
|
||||
def test_a_ready_fix_marked_anytime_runs_on_a_weekday(self):
|
||||
"""The LTC write, ticked for auto-fix and marked anytime, fills an amber TIMECODE gap on a
|
||||
Thursday morning; the window's fixes (Ableton closed) still wait for the window."""
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg["auto_fix"]["fixes"] = cfg["auto_fix"]["fixes"] + ["ready_ltc"]
|
||||
cfg["fixes"]["ready_ltc"]["anytime"] = True
|
||||
thursday = SUNDAY - 3 * 86400
|
||||
self.engine = sp.Engine(cfg)
|
||||
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
|
||||
self.engine.service.set(True, thursday) # ALARMS on all week, as at NA
|
||||
ran, comp = self.auto_setup()
|
||||
self.snap.ready = {"items": {"timecode": {"faults": [[sp.ERR, "NO LTC JEHOVAH"]], "info": ""}}}
|
||||
self.snap.remote["tracks\0pgrep -xq Live"] = False
|
||||
run_steps(self.engine, self.snap, thursday, 10)
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 10), []) # not 20 s yet
|
||||
done = self.engine.auto_fix(self.snap, comp, thursday + 30)
|
||||
time.sleep(0.2)
|
||||
self.assertEqual((done, ran), (["rd_timecode"], ["ready_ltc"])) # never "ableton"
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 60), []) # 90 s between tries
|
||||
self.engine.service.set(False, thursday + 61) # ALARMS muted: nothing at all
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 200), [])
|
||||
self.engine.service.set(True, thursday + 201) # back on: it catches up
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 210), ["rd_timecode"])
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(ran, ["ready_ltc", "ready_ltc"]) # and still never "ableton"
|
||||
del cfg["fixes"]["ready_ltc"]["anytime"] # not marked: the window only
|
||||
self.engine = sp.Engine(cfg)
|
||||
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
|
||||
self.engine.service.set(True, thursday)
|
||||
ran, comp = self.auto_setup()
|
||||
run_steps(self.engine, self.snap, thursday, 25)
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 30), [])
|
||||
run_steps(self.engine, self.snap, SUNDAY, 25)
|
||||
done = self.engine.auto_fix(self.snap, comp, SUNDAY + 30)
|
||||
time.sleep(0.2)
|
||||
self.assertIn("rd_timecode", done) # in the window it acts
|
||||
self.assertIn("ready_ltc", ran)
|
||||
import schema
|
||||
bad = json.loads(json.dumps(CONFIG))
|
||||
bad["fixes"]["ableton"]["anytime"] = True
|
||||
self.assertTrue(any("anytime" in e for e in schema.validate(bad)[0])) # only a READY fix may
|
||||
|
||||
def test_alarms_key_says_alarms_and_auto_fix_and_when_they_come_back(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg["service_windows"] = [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}] # NA: armed all day
|
||||
engine = sp.Engine(cfg)
|
||||
monday = datetime(2026, 10, 19, 14, 30).timestamp()
|
||||
tiles = engine.step(self.snap, monday)
|
||||
self.assertEqual(tiles["service"]["x"], "SERVICE ON\nAUTO-FIX ON")
|
||||
engine.service.set(False, monday) # pressed
|
||||
tiles = engine.step(self.snap, monday + 1)
|
||||
self.assertEqual(tiles["service"]["x"], "SERVICE OFF\nAUTO-FIX OFF\nTIL 12AM")
|
||||
self.assertTrue(engine.service.on(datetime(2026, 10, 20, 0, 0, 30).timestamp())) # back by itself
|
||||
note = [ctl for row in make_page.build(cfg)["page"]["controls"].values() for ctl in row.values()
|
||||
if ctl and "'service'" in (ctl.get("options") or {}).get("notes", "")][0]["options"]["notes"]
|
||||
self.assertIn("stop every auto-fix", note)
|
||||
self.assertIn("at midnight", note)
|
||||
|
||||
def test_restart_keys_flash_when_their_thing_is_down(self):
|
||||
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
|
||||
devices.remove("NA-Tracks") # DVS on the Tracks Mac gone
|
||||
@@ -1081,7 +1179,8 @@ class TestFixes(unittest.TestCase):
|
||||
|
||||
def test_midi_reconnect_where_the_peer_was_last_seen(self):
|
||||
others = [{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
|
||||
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}]
|
||||
{"name": "NA Ingest", "address": "10.1.1.1:5009", "state": "running"},
|
||||
{"name": "Studio B Mac Studio", "address": "10.1.13.175:5004", "state": "running"}]
|
||||
tracks = {"NA Tracks": {"port": 5004, "peers": others + [
|
||||
{"name": "Lighting PC", "address": "10.1.10.77:5004", "state": "running"}]}}
|
||||
sp.remember_peers(self.snap, "tracks", tracks) # seen at a new address earlier
|
||||
@@ -1105,7 +1204,7 @@ class TestFixes(unittest.TestCase):
|
||||
self.assertTrue(sp.execute_fix(CONFIG, "midi_reconnect", chk))
|
||||
argv, kw = run.call_args.args[0], run.call_args.kwargs
|
||||
self.assertIn("waterschurchmedia@10.1.3.227", argv)
|
||||
self.assertIn("ns['connect_peer']('NA Tracks', 'Lighting PC', '10.1.10.206:5004')", kw["input"])
|
||||
self.assertIn("ns['connect_peer']('NA Tracks', 'Lighting PC', '10.1.10.206:5004', repoint=False)", kw["input"])
|
||||
failed = mock.Mock(returncode=255, stdout="", stderr="ssh: timed out")
|
||||
with mock.patch.object(sp.subprocess, "run", return_value=failed):
|
||||
self.assertFalse(sp.execute_fix(CONFIG, "midi_reconnect", chk))
|
||||
@@ -1142,6 +1241,60 @@ class TestFixes(unittest.TestCase):
|
||||
self.assertEqual([[p["name"] for p in w["peers"]] for w in writes],
|
||||
[["Lighting PC"], ["Lighting PC", "SuperRack-LiveBox"]]) # dropped, then added back
|
||||
|
||||
def test_a_peer_listed_elsewhere_is_moved_to_its_wired_address(self):
|
||||
import coremidi
|
||||
from unittest import mock
|
||||
sess = {"dns-sd-name": "NA Tracks", "port": 5004, "peers": [
|
||||
{"name": "Lighting PC", "address": "10.1.10.206:5004", "state": "running"},
|
||||
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}]}
|
||||
writes = []
|
||||
with mock.patch.object(coremidi, "_session_entities", return_value=[(1, sess)]), \
|
||||
mock.patch.object(coremidi, "_string_prop", return_value=None), \
|
||||
mock.patch.object(coremidi, "_set_dict_prop", side_effect=lambda e, k, v: writes.append(v) or 0):
|
||||
self.assertEqual(coremidi.connect_peer("NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004"), "already")
|
||||
self.assertEqual(writes, []) # a plain reconnect never moves a live link
|
||||
self.assertEqual(coremidi.connect_peer("NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004",
|
||||
settle=lambda s: None, repoint=True), "moved")
|
||||
self.assertEqual(coremidi.connect_peer("NA Tracks", "Lighting PC", "10.1.10.206:5004", repoint=True), "already")
|
||||
self.assertEqual([[p["address"] for p in w["peers"]] for w in writes],
|
||||
[["10.1.10.206:5004"], ["10.1.10.206:5004", "10.1.13.175:5004"]]) # dropped, then at the wired address
|
||||
|
||||
def test_midi_reconnects_stay_on_the_wired_network(self):
|
||||
nets = [sp.ipaddress.ip_network("10.1.0.0/16")]
|
||||
self.assertEqual(sp.pick_midi_address(nets, "10.1.10.77:5004", "10.1.10.206:5004"), "10.1.10.77:5004") # moved, still wired
|
||||
self.assertEqual(sp.pick_midi_address(nets, "10.6.3.222:5040", "10.1.1.1:5040"), "10.1.1.1:5040") # last seen on Wi-Fi
|
||||
self.assertEqual(sp.pick_midi_address(nets, "\tNA Ingest\tlocal.", "10.1.1.1:5009"), "10.1.1.1:5009") # last seen by name
|
||||
self.assertEqual(sp.pick_midi_address(nets, "\tNA Ingest\tlocal.", None), "\tNA Ingest\tlocal.") # nothing better known
|
||||
self.assertEqual(sp.pick_midi_address([], "10.6.3.222:5040", "10.1.1.1:5040"), "10.6.3.222:5040") # no rule: as before
|
||||
|
||||
def test_a_link_joined_by_name_is_flagged_and_a_hold_moves_it(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
midi = next(t for t in cfg["tiles"] if t["id"] == "midi")
|
||||
midi["checks"] = [c for c in midi["checks"] if c["type"] != "midi_wired"] + [
|
||||
{"type": "midi_wired", "nets": ["10.1.0.0/16"], "fix": "midi_reconnect"}]
|
||||
for c in midi["checks"]:
|
||||
if c.get("peer") == "Studio B Mac Studio":
|
||||
c["address"] = "10.1.13.175:5004"
|
||||
engine = sp.Engine(cfg)
|
||||
engine.executor = engine.poe_executor = lambda *a: True
|
||||
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
|
||||
{"name": "Lighting PC", "address": "10.1.10.206:5004", "state": "running"},
|
||||
{"name": "NA Ingest", "address": "10.1.1.1:5009", "state": "running"},
|
||||
{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
|
||||
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"},
|
||||
{"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"}]}}
|
||||
now = SUNDAY
|
||||
for t in range(9): # the stream stays fresh
|
||||
self.snap.remote_midi_time["tracks"] = now + t
|
||||
tiles = engine.step(self.snap, now + t)
|
||||
self.assertIn("STUDIO B MAC. BY NAME", tiles["midi"]["x"])
|
||||
plan = [(kind, what, c) for _, kind, what, c in engine.fix_plan("midi", self.snap)]
|
||||
moves = [c for kind, what, c in plan if c.get("repoint")]
|
||||
self.assertEqual([(c["host"], c["session"], c["peer"], c["address"]) for c in moves],
|
||||
[("tracks", "NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004")]) # ProdDash's own: left to it
|
||||
auto = engine.fix_plan("midi", self.snap, SUNDAY + 30, 0, True)
|
||||
self.assertFalse(any(c.get("repoint") for _, _, _, c in auto)) # never by itself: a hold
|
||||
|
||||
def test_an_off_pc_is_woken(self):
|
||||
from unittest import mock
|
||||
woke = []
|
||||
@@ -1446,7 +1599,8 @@ class TestFixes(unittest.TestCase):
|
||||
for t in self.engine.fix_threads:
|
||||
t.join(2)
|
||||
self.assertEqual(self.engine.running, {})
|
||||
self.assertEqual(sp.exit_timeout(CONFIG), 90) # the longest fix + its verify + 10
|
||||
# the longest fix + its verify + 10: the Tascam outlet power-cycle (timeout 300)
|
||||
self.assertEqual(sp.exit_timeout(CONFIG), 310)
|
||||
|
||||
def test_a_hyperdeck_switched_off_is_always_switched_back_on(self):
|
||||
calls = []
|
||||
@@ -1521,6 +1675,380 @@ class TestFixes(unittest.TestCase):
|
||||
self.assertIn(f.get("kind", "ssh"), sp.FIX_KINDS)
|
||||
|
||||
|
||||
def run_failover(engine, snap, start, seconds, step=0.5):
|
||||
"""Like run_steps, but the failover looks after every step, as the run loop does."""
|
||||
tiles, t = None, start
|
||||
while t <= start + seconds:
|
||||
snap.conns_time = t
|
||||
tiles = engine.step(snap, t)
|
||||
engine.failover(snap, None, t)
|
||||
t += step
|
||||
return tiles, t
|
||||
|
||||
|
||||
STB_PING, STB_PT = "10.1.13.175", "studiob\0pgrep -xq 'Pro Tools'"
|
||||
|
||||
|
||||
class TestSwitchedOff(unittest.TestCase):
|
||||
"""Anything can be switched off in the settings and stay there, like a connection in Companion:
|
||||
a key goes grey OFF and checks nothing, a check is skipped, a fix never runs."""
|
||||
|
||||
def cfg(self, **changes):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
for path, value in changes.items():
|
||||
node = cfg
|
||||
*parts, last = path.split("/")
|
||||
for p in parts:
|
||||
node = node[int(p)] if isinstance(node, list) else node[p]
|
||||
node[last] = value
|
||||
return cfg
|
||||
|
||||
def test_a_key_switched_off_is_grey_and_checks_nothing(self):
|
||||
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
|
||||
engine = sp.Engine(self.cfg(**{"tiles/%d/disabled" % i: True}))
|
||||
snap = healthy_snapshot(SUNDAY)
|
||||
snap.conns.pop("OBS-Youtube", None) # OBS gone: would be red
|
||||
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
|
||||
self.assertEqual(tiles["obs"]["x"], "OBS\nOFF")
|
||||
self.assertEqual(level(tiles["obs"]), "off")
|
||||
self.assertEqual(tiles["all"]["x"], "ALL OK") # not part of ALL any more
|
||||
self.assertEqual(tiles["stream"]["x"], "STREAM\nOK") # nor of its group
|
||||
|
||||
def test_a_check_switched_off_is_skipped(self):
|
||||
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "stb_audio")
|
||||
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/disabled" % i: True}))
|
||||
snap = healthy_snapshot(SUNDAY)
|
||||
snap.ping[STB_PING] = 5 # the ping check would fail
|
||||
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
|
||||
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nOK")
|
||||
self.assertEqual(len(engine.tiles["stb_audio"]["checks"]), 2)
|
||||
self.assertEqual(len(CONFIG["tiles"][i]["checks"]), 3) # the settings keep all three
|
||||
|
||||
def test_a_fix_switched_off_never_runs(self):
|
||||
cfg = self.cfg(**{"fixes/obs/disabled": True, "fixes/stream_foh_backup/disabled": True})
|
||||
self.assertEqual(sp.execute_fix(cfg, "obs", {}), "TURNED\nOFF")
|
||||
engine = sp.Engine(cfg)
|
||||
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
|
||||
engine.notify = lambda url, text: None
|
||||
snap = healthy_snapshot(SUNDAY)
|
||||
snap.ping[STB_PING] = 5 # Studio B dies on a Sunday
|
||||
snap.conns.pop("OBS-Youtube", None)
|
||||
run_failover(engine, snap, SUNDAY, 40)
|
||||
self.assertEqual(engine.failed, {}) # no auto switch
|
||||
self.assertEqual(engine.auto_fix(snap, mock_comp(), SUNDAY + 41), []) # no auto-fix
|
||||
|
||||
def test_a_restart_key_switched_off(self):
|
||||
rp = self.cfg()["restart_page"]
|
||||
j = next(n for n, k in enumerate(rp["keys"]) if k["id"] == "rs_tascam")
|
||||
engine = sp.Engine(self.cfg(**{"restart_page/keys/%d/disabled" % j: True}))
|
||||
tiles = engine.step(healthy_snapshot(SUNDAY), SUNDAY)
|
||||
self.assertEqual(tiles["rs_tascam"]["x"], "TASCAM\nOFF")
|
||||
self.assertIsNone(engine.run_force("rs_tascam", healthy_snapshot(SUNDAY), None, SUNDAY))
|
||||
self.assertEqual(engine.notes["rs_tascam"]["text"], "TURNED\nOFF")
|
||||
|
||||
def test_a_computer_switched_off(self):
|
||||
cfg = self.cfg(**{"hosts/studiob/disabled": True})
|
||||
engine = sp.Engine(cfg)
|
||||
stb = engine.tiles["stb_audio"]["checks"]
|
||||
self.assertEqual([c["type"] for c in stb], ["dante"]) # its ping and Pro Tools checks are gone
|
||||
self.assertTrue(engine.cfg["fixes"]["solo_afl"]["disabled"]) # and a fix that logs in to it
|
||||
self.assertFalse(any(c.get("host") == "studiob" for t in sp.all_tiles(engine.cfg) for c in t.get("checks", [])))
|
||||
self.assertNotIn("studiob", sp.remote_midi_hosts(engine.cfg))
|
||||
engine = sp.Engine(self.cfg(**{"hosts/bibletv/disabled": True})) # its web page is not fetched either
|
||||
self.assertNotIn("http://10.6.7.13:8080/", [u for u, _ in sp.http_checks(engine.cfg)])
|
||||
self.assertTrue(engine.cfg["fixes"]["bibleapp"]["disabled"])
|
||||
|
||||
def test_a_dante_device_switched_off(self):
|
||||
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "dante")
|
||||
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/devices_off" % i: ["STUDIO-B-Tascam"]}))
|
||||
snap = healthy_snapshot(SUNDAY)
|
||||
devices = json.loads(snap.vars[("audinate-dantecontroller", "devices")])
|
||||
snap.vars[("audinate-dantecontroller", "devices")] = json.dumps([d for d in devices if d != "STUDIO-B-Tascam"])
|
||||
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
|
||||
self.assertNotIn("GONE", tiles["dante"]["x"]) # gone, but switched off: no alarm
|
||||
self.assertIn("STUDIO-B-Tascam", CONFIG["tiles"][i]["checks"][0]["devices"]) # still in the list
|
||||
|
||||
def test_whole_features_switched_off(self):
|
||||
cfg = self.cfg(**{"auto_fix/disabled": True, "failover/disabled": True, "ready/disabled": True})
|
||||
engine = sp.Engine(cfg)
|
||||
self.assertEqual(engine.cfg["auto_fix"]["windows"], [])
|
||||
self.assertEqual(engine.failover_windows, [])
|
||||
self.assertTrue(all(k.get("disabled") for k in sp.ready_keys(engine.cfg)))
|
||||
snap = healthy_snapshot(SUNDAY)
|
||||
snap.ping[STB_PING] = 5
|
||||
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
|
||||
engine.notify = lambda url, text: None
|
||||
tiles, _ = run_failover(engine, snap, SUNDAY, 20)
|
||||
self.assertEqual(engine.failed, {})
|
||||
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nOFF")
|
||||
|
||||
def test_the_rules_accept_switched_off_things(self):
|
||||
import schema
|
||||
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
|
||||
cfg = self.cfg(**{"tiles/%d/disabled" % i: True, "tiles/%d/checks/0/disabled" % i: True, "fixes/obs/disabled": True})
|
||||
errors, warnings = schema.validate(cfg)
|
||||
self.assertEqual(errors, [])
|
||||
self.assertFalse(any("disabled" in w for w in warnings), warnings)
|
||||
|
||||
|
||||
class TestFailover(unittest.TestCase):
|
||||
"""Studio B dies on a Sunday: the FOH Backup button is pressed by itself, once, and the keys say so.
|
||||
Nothing here presses a real button, posts a real alert or writes a real log."""
|
||||
|
||||
def setUp(self):
|
||||
self.snap = healthy_snapshot(SUNDAY)
|
||||
self.engine = sp.Engine(CONFIG)
|
||||
self.ran, self.sent = [], []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: self.ran.append(fix_id) or True
|
||||
self.engine.poe_executor = lambda *a: True
|
||||
self.engine.notify = lambda url, text: self.sent.append(text)
|
||||
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
|
||||
|
||||
def fired_at(self):
|
||||
self.engine.fix_threads and [t.join(2) for t in self.engine.fix_threads]
|
||||
rec = self.engine.failed.get("stream_foh_backup")
|
||||
return rec and rec["at"]
|
||||
|
||||
def test_config_wires_studio_b_to_foh_backup(self):
|
||||
tile = next(t for t in CONFIG["tiles"] if t["id"] == "stb_audio")
|
||||
self.assertNotIn("pos", tile) # hidden: AUDIO shows it
|
||||
self.assertIn("stb_audio", next(t for t in CONFIG["tiles"] if t["id"] == "audio")["members"])
|
||||
for c in tile["checks"]:
|
||||
fixes = set(c["fix"].values()) if isinstance(c["fix"], dict) else {c["fix"]}
|
||||
self.assertEqual(fixes, {"stream_foh_backup"}, c["type"])
|
||||
fix = CONFIG["fixes"]["stream_foh_backup"]
|
||||
self.assertEqual((fix["kind"], fix["page"], fix["row"], fix["col"]), ("companion_press", 4, 0, 5))
|
||||
self.assertTrue(fix["failover"])
|
||||
self.assertEqual(fix["undo"], "stream_studio_b")
|
||||
self.assertFalse(CONFIG["fixes"]["stream_studio_b"].get("failover"))
|
||||
self.assertNotIn("stream_foh_backup", CONFIG["auto_fix"]["fixes"])
|
||||
self.assertEqual(CONFIG["failover"]["after_seconds"], 2)
|
||||
self.assertEqual(self.engine.failover_windows, [{"days": ["sun"], "start": "06:30", "end": "13:30"}])
|
||||
|
||||
def test_studio_b_dies_foh_backup_is_pressed_once(self):
|
||||
t0 = SUNDAY
|
||||
run_failover(self.engine, self.snap, t0 - 5, 4) # all fine for a while
|
||||
self.snap.ping[STB_PING] = 2 # two 1-second pings missed
|
||||
tiles, t = run_failover(self.engine, self.snap, t0, 6)
|
||||
at = self.fired_at()
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"])
|
||||
key = next(k for k, (tid, chk, sub) in self.engine.meta.items() if chk.get("host") == STB_PING)
|
||||
since = self.engine.trackers[key].since
|
||||
self.assertEqual(since, t0 + 1) # 1 s debounce
|
||||
self.assertEqual(at, since + 2) # + 2 s after_seconds
|
||||
self.assertLessEqual(at - t0, 5)
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 60) # the fault lasts a minute
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"]) # still once
|
||||
self.assertNotIn("stream_studio_b", self.ran) # never switched back by itself
|
||||
# the keys say where the stream is
|
||||
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING\nON FOH BACKUP")
|
||||
self.assertEqual(level(tiles["audio"]), "err")
|
||||
self.engine.notes.pop("stb_audio", None) # under the fix's own feedback
|
||||
tiles = self.engine.step(self.snap, t)
|
||||
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nSTUDIO B NO PING\nON FOH BACKUP")
|
||||
self.assertEqual(tiles["all"]["x"], "1 DOWN\nSTUDIO B NO PING")
|
||||
# the log, the alert, and a hold right after
|
||||
body = [l for l in open(self.engine.autolog.path(t0)).read().splitlines() if l[:2].isdigit()]
|
||||
self.assertTrue(any("STB AUDIO FAILOVER: STUDIO B NO PING - press the FOH Backup button" in l for l in body), body)
|
||||
self.assertTrue(any("-> done, checking it comes back" in l for l in body), body)
|
||||
self.assertEqual(len(self.sent), 1)
|
||||
self.assertTrue(self.sent[0].startswith("NA Ingest: STB AUDIO STUDIO B NO PING - switched over by itself: press the FOH Backup"))
|
||||
self.assertIn("Switch back by hand (press the Studio B button in Companion", self.sent[0])
|
||||
self.engine.run_fixes("audio", self.snap, None, at + 10)
|
||||
self.assertEqual(self.engine.notes["audio"]["text"], "JUST TRIED\nWAIT 1 MIN")
|
||||
out = sp.snapshot_summary(self.snap, self.engine, t)
|
||||
json.dumps(out)
|
||||
self.assertEqual(out["failover"]["stream_foh_backup"]["note"], "ON FOH BACKUP")
|
||||
self.assertEqual(out["failover"]["stream_foh_backup"]["faults"], ["STUDIO B NO PING"])
|
||||
|
||||
def test_pro_tools_crash_is_as_fast(self):
|
||||
t0 = SUNDAY
|
||||
self.snap.remote[STB_PT] = False
|
||||
run_failover(self.engine, self.snap, t0, 6)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"])
|
||||
self.assertLessEqual(self.fired_at() - t0, 5)
|
||||
tiles = self.engine.step(self.snap, t0 + 7)
|
||||
self.assertEqual(tiles["audio"]["x"], "AUDIO\nPRO TOOLS NOT RUNNING\nON FOH BACKUP")
|
||||
|
||||
def test_fires_with_alarms_muted(self):
|
||||
self.engine.service.set(False, SUNDAY) # maintenance: auto-fix stops
|
||||
self.snap.ping[STB_PING] = 2
|
||||
run_failover(self.engine, self.snap, SUNDAY, 6)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"]) # the failover does not
|
||||
|
||||
def test_only_inside_the_window(self):
|
||||
for when in (WEDNESDAY, datetime(2026, 10, 11, 15, 0).timestamp()): # a weekday; Sunday after the window
|
||||
snap = healthy_snapshot(when)
|
||||
snap.ping[STB_PING] = 2
|
||||
run_failover(self.engine, snap, when, 10)
|
||||
self.assertEqual(self.ran, [], datetime.fromtimestamp(when))
|
||||
self.assertEqual(self.engine.failed, {})
|
||||
|
||||
def test_rearms_when_the_fault_clears_and_the_note_fades(self):
|
||||
t0 = SUNDAY
|
||||
self.snap.ping[STB_PING] = 2
|
||||
tiles, t = run_failover(self.engine, self.snap, t0, 6)
|
||||
self.snap.ping[STB_PING] = 0 # Studio B answers again
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 5)
|
||||
self.assertEqual(tiles["audio"]["x"], "AUDIO\nOK\nON FOH BACKUP") # still on the backup
|
||||
self.assertEqual(level(tiles["audio"]), "ok")
|
||||
self.assertIsNotNone(self.engine.failed["stream_foh_backup"]["cleared"])
|
||||
self.snap.ping[STB_PING] = 2 # ...and dies again 5 s later
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 4)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"]) # was clear under 10 s: not armed yet
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 3) # 10 s since it cleared: armed, fires
|
||||
self.assertEqual(self.ran, ["stream_foh_backup", "stream_foh_backup"])
|
||||
self.snap.ping[STB_PING] = 0
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 2)
|
||||
tiles, t = run_failover(self.engine, self.snap, t + 1000, 1) # note_seconds later: forgotten
|
||||
self.assertEqual(tiles["audio"]["x"], "AUDIO\nOK")
|
||||
self.assertEqual(self.engine.failed, {})
|
||||
|
||||
def test_a_check_that_cannot_ask_is_not_a_clear(self):
|
||||
t0 = SUNDAY
|
||||
self.snap.remote[STB_PT] = False # Pro Tools gone
|
||||
tiles, t = run_failover(self.engine, self.snap, t0, 6)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"])
|
||||
self.snap.remote[STB_PT] = None # ssh could not ask for a while
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 30)
|
||||
self.assertIsNone(self.engine.failed["stream_foh_backup"]["cleared"]) # not re-armed on a hiccup
|
||||
self.snap.remote[STB_PT] = False # the answer is back: still gone
|
||||
tiles, t = run_failover(self.engine, self.snap, t, 10)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"]) # no second press
|
||||
|
||||
def test_studio_b_rednet_is_never_power_cycled_by_itself(self):
|
||||
"""Only STUDIO-B-RedNet-TNX vanishes: the failover presses FOH Backup; Sunday auto-fix on the DANTE
|
||||
key must not power-cycle the RedNet's PoE port (that stays a hold), nor touch anything else."""
|
||||
comp = mock_comp()
|
||||
self.snap.dante_map = {"STUDIO-B-RedNet-TNX": {"mac": "00:1d:c1:aa:bb:cc", "poe": True, "switch": "10.1.60.2", "port": "0/14"}}
|
||||
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
|
||||
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps([d for d in devices if d != "STUDIO-B-RedNet-TNX"])
|
||||
poe = []
|
||||
self.engine.poe_executor = lambda cfg, name, entry: poe.append(name) or True
|
||||
tiles, t = run_failover(self.engine, self.snap, SUNDAY, 30)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"])
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, comp, t), [])
|
||||
time.sleep(0.2)
|
||||
self.assertEqual((poe, comp.restarted, self.ran), ([], [], ["stream_foh_backup"]))
|
||||
self.assertEqual([(k, w) for _, k, w, _ in self.engine.fix_plan("dante", self.snap)], [("poe", "STUDIO-B-RedNet-TNX")]) # a hold would
|
||||
|
||||
def test_switching_back_by_hand_clears_the_note(self):
|
||||
t0 = SUNDAY
|
||||
gate = threading.Event() # the Studio B press takes a moment
|
||||
|
||||
def press(cfg, fix_id, chk):
|
||||
if fix_id == "stream_studio_b":
|
||||
gate.wait(2)
|
||||
self.ran.append(fix_id)
|
||||
return True
|
||||
self.engine.executor = press
|
||||
self.snap.ping[STB_PING] = 2
|
||||
tiles, t = run_failover(self.engine, self.snap, t0, 6)
|
||||
self.assertIn("ON FOH BACKUP", tiles["audio"]["x"])
|
||||
# the undo, run through the watchdog (a Restart key or the settings page's Run the fix)
|
||||
self.assertEqual(self.engine.run_force("stb_audio", self.snap, None, t, fix_id="stream_studio_b", chk={}), "stream_studio_b")
|
||||
self.assertEqual(self.engine.notes["stb_audio"]["text"], "PRESSING")
|
||||
gate.set()
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(self.engine.notes["stb_audio"]["text"], "DONE")
|
||||
self.assertEqual(self.engine.failed, {})
|
||||
tiles = self.engine.step(self.snap, t + 0.5)
|
||||
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING")
|
||||
tiles, t = run_failover(self.engine, self.snap, t + 1, 4) # still dead: it fires again
|
||||
self.assertEqual(self.ran, ["stream_foh_backup", "stream_studio_b", "stream_foh_backup"])
|
||||
self.assertIn("ON FOH BACKUP", tiles["audio"]["x"])
|
||||
|
||||
def test_held_back_when_several_dante_devices_vanish(self):
|
||||
t0 = SUNDAY
|
||||
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
|
||||
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
|
||||
[d for d in devices if d not in ("STUDIO-B-RedNet-TNX", "NA-Tracks", "BOH-WING")])
|
||||
tiles, t = run_failover(self.engine, self.snap, t0, 12)
|
||||
self.assertEqual(self.ran, []) # Ingest's view of Dante: hands off
|
||||
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nSTB TNX GONE") # seen, no note
|
||||
body = open(self.engine.autolog.path(t0)).read()
|
||||
self.assertIn("failover held back: 3 Dante devices gone at once", body)
|
||||
self.assertEqual(body.count("held back"), 1) # said once, not every half second
|
||||
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
|
||||
[d for d in devices if d != "STUDIO-B-RedNet-TNX"]) # only Studio B's own box
|
||||
run_failover(self.engine, self.snap, t, 3)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"])
|
||||
|
||||
def test_state_survives_a_restart(self):
|
||||
state = os.path.join(tempfile.mkdtemp(prefix="sp-state-"), "state.json")
|
||||
engine = sp.Engine(CONFIG, state)
|
||||
engine.executor = lambda cfg, fix_id, chk: self.ran.append(fix_id) or True
|
||||
engine.notify = lambda url, text: None
|
||||
engine.autolog = sp.AutoLog(tempfile.mkdtemp())
|
||||
self.snap.ping[STB_PING] = 2
|
||||
tiles, t = run_failover(engine, self.snap, SUNDAY, 6)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"])
|
||||
self.assertEqual(json.load(open(state))["failover"]["stream_foh_backup"]["tile"], "stb_audio")
|
||||
again = sp.Engine(CONFIG, state) # the watchdog restarts mid-fault
|
||||
again.executor = lambda cfg, fix_id, chk: self.ran.append(fix_id) or True
|
||||
again.notify = lambda url, text: None
|
||||
again.autolog = sp.AutoLog(tempfile.mkdtemp())
|
||||
tiles, t = run_failover(again, self.snap, t, 10)
|
||||
self.assertEqual(self.ran, ["stream_foh_backup"]) # not pressed a second time
|
||||
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING\nON FOH BACKUP")
|
||||
|
||||
def test_nothing_new_starts_while_stopping(self):
|
||||
self.snap.ping[STB_PING] = 2
|
||||
self.engine.stopping = True
|
||||
run_failover(self.engine, self.snap, SUNDAY, 6)
|
||||
self.assertEqual(self.ran, [])
|
||||
|
||||
def test_load_config_checks_the_button_and_the_undo(self):
|
||||
def load(change):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
change(cfg)
|
||||
path = os.path.join(tempfile.mkdtemp(prefix="sp-cfg-"), "config.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(cfg, f)
|
||||
return sp.load_config(path)
|
||||
load(lambda c: None)
|
||||
for bad in (lambda c: c["fixes"]["stream_foh_backup"].update(page=0),
|
||||
lambda c: c["fixes"]["stream_foh_backup"].update(row=True),
|
||||
lambda c: c["fixes"]["stream_foh_backup"].update(col="5"),
|
||||
lambda c: c["fixes"]["stream_foh_backup"].pop("col"),
|
||||
lambda c: c["fixes"]["stream_foh_backup"].update(never_auto=True),
|
||||
lambda c: c["fixes"]["stream_foh_backup"].update(undo="nope"),
|
||||
lambda c: c["failover"]["windows"].append({"days": ["sun"], "start": "6:30", "end": "13:30"})):
|
||||
with self.assertRaises(ValueError):
|
||||
load(bad)
|
||||
|
||||
def test_studio_b_is_pinged_every_second(self):
|
||||
self.assertEqual(sp.ping_hosts(CONFIG, fast=True), [STB_PING])
|
||||
slow = sp.ping_hosts(CONFIG)
|
||||
self.assertNotIn(STB_PING, slow)
|
||||
self.assertIn("10.1.0.1", slow) # the gateway stays in the slow list
|
||||
self.assertIn("10.1.3.227", slow)
|
||||
self.assertEqual(sp.PING_INTERVAL, 3)
|
||||
|
||||
def test_remote_groups_run_when_due_each_on_its_own(self):
|
||||
from unittest import mock
|
||||
groups = sp.remote_groups(CONFIG)
|
||||
self.assertEqual(groups[("studiob", 2)], ["pgrep -xq 'Pro Tools'"])
|
||||
self.assertTrue(all(k[1] == 10 for k in groups if k != ("studiob", 2)))
|
||||
calls = []
|
||||
fake = lambda argv, **kw: calls.append(argv) or mock.Mock(returncode=0, stdout="SP0=0\nSP1=0\n", stderr="")
|
||||
t = SUNDAY + 100
|
||||
with mock.patch.object(sp.subprocess, "run", fake):
|
||||
last = {k: t - 3 for k in groups} # every group ran 3 s ago
|
||||
for th in sp.poll_remote(self.snap, CONFIG, now=t, last=last):
|
||||
th.join(2)
|
||||
self.assertEqual(len(calls), 1) # only the 2-second group is due
|
||||
self.assertIn("studiob@10.1.13.175", calls[0])
|
||||
self.assertIn("pgrep -xq 'Pro Tools'", calls[0][-1])
|
||||
self.assertTrue(self.snap.remote[STB_PT])
|
||||
self.assertEqual(last[("studiob", 2)], t)
|
||||
self.assertEqual(sp.poll_remote(self.snap, CONFIG, now=t + 1, last=last), []) # nothing due yet
|
||||
calls.clear()
|
||||
sp.poll_remote(self.snap, CONFIG) # run_once: everything, and waits
|
||||
self.assertEqual(len(calls), len(groups))
|
||||
self.assertEqual(sp.REMOTE_INTERVAL, 10)
|
||||
|
||||
|
||||
class TestNetgearParsing(unittest.TestCase):
|
||||
BIG = """ VLAN ID MAC Address Interface IfIndex Status
|
||||
66 00:1D:C1:51:34:C8 0/14 14 Learned
|
||||
@@ -1603,6 +2131,13 @@ class FakeCompanion(BaseHTTPRequestHandler):
|
||||
if m:
|
||||
s["restarts"].append(m.group(1))
|
||||
return self._send(200, "{}")
|
||||
m = re.match(r"^/api/location/(\d+)/(\d+)/(\d+)/press$", u.path)
|
||||
if m:
|
||||
where = tuple(int(x) for x in m.groups())
|
||||
s["presses"].append(where)
|
||||
if where in s["buttons"]:
|
||||
return self._send(200, "ok")
|
||||
return self._send(204, "No control") # like Companion 5.0.7 for an empty cell
|
||||
self._send(404, "")
|
||||
|
||||
|
||||
@@ -1615,6 +2150,8 @@ class TestAgainstFakeCompanion(unittest.TestCase):
|
||||
"vars": dict(snap.vars),
|
||||
"custom": {"sp_state": "", "sp_hb": "", "sp_cmd": "", "flash_tick": "0"},
|
||||
"restarts": [],
|
||||
"presses": [],
|
||||
"buttons": {(4, 0, 5), (4, 0, 6)}, # the Audio page's FOH Backup and Studio B
|
||||
}
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeCompanion)
|
||||
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
||||
@@ -1637,6 +2174,20 @@ class TestAgainstFakeCompanion(unittest.TestCase):
|
||||
self.assertEqual(comp.set_custom("missing", "x"), (False, 404))
|
||||
print("\n sp_state is %d bytes, %d in the URL" % (len(js), len(urllib.parse.quote(js, safe=""))))
|
||||
|
||||
def test_press_a_button(self):
|
||||
comp = sp.Companion("127.0.0.1", self.port)
|
||||
store = FakeCompanion.store
|
||||
self.assertEqual(comp.press(4, 0, 5), 200)
|
||||
self.assertEqual(comp.press(4, 1, 1), 204) # an empty cell
|
||||
self.assertEqual(store["presses"][-2:], [(4, 0, 5), (4, 1, 1)])
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg["companion"]["port"] = self.port
|
||||
self.assertTrue(sp.execute_fix(cfg, "stream_foh_backup", {}))
|
||||
self.assertEqual(store["presses"][-1], (4, 0, 5))
|
||||
cfg["fixes"]["stream_foh_backup"]["col"] = 7
|
||||
self.assertEqual(sp.execute_fix(cfg, "stream_foh_backup", {}), "NO BUTTON\nTHERE")
|
||||
self.assertEqual(store["presses"][-1], (4, 0, 7))
|
||||
|
||||
def test_commands(self):
|
||||
comp = sp.Companion("127.0.0.1", self.port)
|
||||
store = FakeCompanion.store
|
||||
@@ -1679,7 +2230,7 @@ class TestAgainstFakeCompanion(unittest.TestCase):
|
||||
sp.poll_mdns = lambda snap, seconds=3: None
|
||||
sp.poll_system = lambda snap, cfg: None
|
||||
sp.poll_midi = lambda snap, cfg: None # CoreMIDI has its own test
|
||||
sp.poll_remote = lambda snap, cfg: None # never SSH anywhere from a test
|
||||
sp.poll_remote = lambda snap, cfg, **k: [] # never SSH anywhere from a test
|
||||
sp.start_remote_midi_streams = lambda snap, cfg, stop: []
|
||||
stop = threading.Event()
|
||||
loop = threading.Thread(target=sp.run, args=(cfg, data_dir), kwargs={"stop": stop}, daemon=True)
|
||||
@@ -1723,10 +2274,11 @@ class TestPage(unittest.TestCase):
|
||||
self.assertTrue(key["options"]["notes"].startswith("STATUS nav key (%s)" % kind))
|
||||
step = key["steps"]["0"]
|
||||
self.assertEqual(step["action_sets"]["down"], [])
|
||||
tap, hold = step["action_sets"]["up"][0], step["action_sets"]["1000"][0]
|
||||
tap, hold = step["action_sets"]["up"][0], step["action_sets"]["2000"][0] # NA: 2 s ...
|
||||
self.assertEqual((tap["definitionId"], tap["options"]["page"]["value"]), (action, 1)) # page 1 outright, not "startup"
|
||||
self.assertEqual((hold["definitionId"], hold["options"]["page"]["value"]), ("set_page", 19))
|
||||
self.assertEqual(step["options"]["runWhileHeld"], [1000])
|
||||
self.assertEqual(step["action_sets"]["3000"][0]["options"]["page"]["value"], 98) # ... then 3 s: LOCATIONS
|
||||
self.assertEqual(step["options"]["runWhileHeld"], [2000, 3000])
|
||||
box = [l for l in key["style"]["layers"] if l["type"] == "box"][0]
|
||||
self.assertTrue(box["color"]["isExpression"])
|
||||
self.assertIn("'$.all.b'", box["color"]["value"]) # follows the ALL button
|
||||
@@ -1746,7 +2298,7 @@ class TestPage(unittest.TestCase):
|
||||
for r in c.values():
|
||||
for ctl in r.values():
|
||||
sets = ctl.get("steps", {}).get("0", {}).get("action_sets", {})
|
||||
if "2000" in sets:
|
||||
if "2000" in sets and sets["2000"][0]["definitionId"] != "set_page": # not the page-number key's hold
|
||||
holds[sets["2000"][0]["options"]["value"]["value"]] = sets["up"]
|
||||
self.assertEqual(len(holds), len(CONFIG["restart_page"]["keys"]))
|
||||
self.assertIn("$(custom:sp_cmd);force rs_tascam $(internal:time_unix)", holds)
|
||||
@@ -1854,3 +2406,260 @@ class TestCoreMIDI(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestDanteNewcomers(unittest.TestCase):
|
||||
"""A Dante device the module never listed before gets the Dante Controller connection restarted once."""
|
||||
|
||||
def setUp(self):
|
||||
self.snap = healthy_snapshot(time.time())
|
||||
self.engine = sp.Engine(CONFIG, None)
|
||||
self.restarts = []
|
||||
me = self
|
||||
|
||||
class Comp:
|
||||
def restart_connection(self, cid):
|
||||
me.restarts.append(cid); return True
|
||||
self.comp = Comp()
|
||||
self.label = sp.dante_label(CONFIG)
|
||||
self.snap.conns[self.label] = {"id": "dante-1", "enabled": True, "category": "good"}
|
||||
|
||||
def devices(self, names):
|
||||
self.snap.vars[(self.label, "devices")] = json.dumps(names)
|
||||
|
||||
def test_first_list_is_remembered_then_a_newcomer_restarts_once(self):
|
||||
now = time.time()
|
||||
self.devices(["A", "B"])
|
||||
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now))
|
||||
self.assertEqual(self.engine.state.get("dante_known"), ["A", "B"])
|
||||
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 60)) # nothing new
|
||||
self.devices(["A", "B", "BOH-AD4Q-A"])
|
||||
self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 120))
|
||||
self.assertEqual(self.restarts, ["dante-1"])
|
||||
self.assertEqual(self.engine.state.get("dante_known"), ["A", "B", "BOH-AD4Q-A"])
|
||||
self.devices(["A", "B", "BOH-AD4Q-A", "X"])
|
||||
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 180)) # ten-minute cooldown
|
||||
self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 800))
|
||||
self.assertEqual(len(self.restarts), 2)
|
||||
self.devices(["A", "B"]) # devices going away never restart
|
||||
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 2000))
|
||||
self.devices(["A", "B", "BOH-AD4Q-A"]) # nor coming back
|
||||
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 2600))
|
||||
|
||||
def test_waits_for_the_service_to_end(self):
|
||||
now = time.time()
|
||||
self.devices(["A"])
|
||||
sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now)
|
||||
self.devices(["A", "NEW"])
|
||||
self.engine.any_recording = True
|
||||
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 100))
|
||||
self.engine.any_recording = False
|
||||
self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 200))
|
||||
|
||||
|
||||
class TestSwitches(unittest.TestCase):
|
||||
"""ALARMS and AUTO-FIX as two keys (2026-10-21), and a key that hands a device to an operator
|
||||
(Southcoast's LIGHTS: a lighting op runs the desk, so nothing may send it MIDI)."""
|
||||
|
||||
def setUp(self):
|
||||
self.snap = healthy_snapshot(SUNDAY)
|
||||
self.cfg = json.loads(json.dumps(CONFIG))
|
||||
for t in self.cfg["tiles"]:
|
||||
if t.get("pos") == [3, 7]:
|
||||
t.pop("pos") # NA's spot for it (ARENA moved off)
|
||||
self.cfg["tiles"].append({"id": "autofix", "label": "AUTO-FIX", "kind": "autofix", "pos": [3, 7]})
|
||||
self.midi = next(c for t in self.cfg["tiles"] for c in t.get("checks", []) if c["type"] == "midi_peer")
|
||||
self.cfg["tiles"].append({"id": "lights", "label": "LIGHTS", "kind": "bypass",
|
||||
"bypass": {"peers": [self.midi["peer"]], "hosts": [""], "conns": ["x32"]}})
|
||||
self.state = os.path.join(tempfile.mkdtemp(), "state.json")
|
||||
self.engine = self.new_engine()
|
||||
|
||||
def new_engine(self):
|
||||
e = sp.Engine(self.cfg, self.state)
|
||||
e.executor = e.poe_executor = lambda *a: True
|
||||
e.autolog = sp.AutoLog(tempfile.mkdtemp())
|
||||
return e
|
||||
|
||||
def test_alarms_silence_only_and_auto_fix_has_its_own_key(self):
|
||||
import schema
|
||||
self.assertEqual(schema.validate(self.cfg)[0], [])
|
||||
ran = []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
|
||||
self.snap.remote["tracks\0pgrep -xq Live"] = False # Ableton closed
|
||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 4)
|
||||
self.assertEqual(tiles["service"]["x"], "SERVICE\nON")
|
||||
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nON\nTIL 1:30PM") # Sunday's hours end then
|
||||
self.engine.service.set(False, SUNDAY + 5) # ALARMS: silence only
|
||||
run_steps(self.engine, self.snap, SUNDAY + 5, 25)
|
||||
self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 30)
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(ran, ["ableton"]) # auto-fix carried on
|
||||
self.engine.autofix.pause(SUNDAY + 40) # AUTO-FIX pressed in the hours: paused
|
||||
tiles = self.engine.step(self.snap, SUNDAY + 41)
|
||||
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nPAUSED\nTIL 1:30PM") # through Sunday's hours
|
||||
self.assertEqual(level(tiles["autofix"]), "off")
|
||||
self.assertEqual(self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 200), [])
|
||||
self.assertTrue(self.new_engine().autofix.paused) # survives a restart
|
||||
self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # Wednesday's hours: back by itself
|
||||
self.assertFalse(self.engine.autofix.paused)
|
||||
# Pressed outside the hours (Thursday): the key said OFF / ON SUN 6:30AM; the pause holds through Sunday's hours.
|
||||
thursday = SUNDAY - 3 * 86400
|
||||
tiles = self.engine.step(self.snap, thursday)
|
||||
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nOFF\nON SUN 6:30AM")
|
||||
self.engine.autofix.pause(thursday)
|
||||
self.assertEqual(self.engine.step(self.snap, thursday + 1)["autofix"]["x"], "AUTO-FIX\nPAUSED\nTIL SUN 1:30PM")
|
||||
self.assertFalse(self.engine.autofix.on(SUNDAY)) # Sunday 8:00, inside the hours: still paused
|
||||
self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # and over after them
|
||||
self.engine.autofix.pause(thursday)
|
||||
self.engine.autofix.resume(thursday + 60) # pressed again: back to the hours
|
||||
self.assertTrue(self.engine.autofix.on(SUNDAY))
|
||||
page = make_page.build(self.cfg)["page"]["controls"]
|
||||
notes = {(ctl.get("options") or {}).get("notes", "") for row in page.values() for ctl in row.values() if ctl}
|
||||
self.assertTrue(any("Auto-fix is the AUTO-FIX key" in n for n in notes))
|
||||
self.assertTrue(any("pause auto-fix" in n for n in notes))
|
||||
|
||||
def test_a_hold_from_north_attleboro_flips_a_switch(self):
|
||||
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY)
|
||||
self.assertTrue(self.engine.autofix.paused)
|
||||
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY + 2)
|
||||
self.assertFalse(self.engine.autofix.paused)
|
||||
self.assertTrue(self.engine.autofix.on(SUNDAY + 3))
|
||||
|
||||
def test_the_operator_key_takes_the_midi_out_and_puts_it_back(self):
|
||||
calls = []
|
||||
here = {self.midi["session"]: {"port": 5004, "peers": [{"name": self.midi["peer"], "address": self.midi["address"]},
|
||||
{"name": "Other", "address": "10.9.9.9:5004"}]}}
|
||||
real = (sp.midi_sessions, sp.midi_peer)
|
||||
sp.midi_sessions = lambda cfg, host: here
|
||||
sp.midi_peer = lambda cfg, host, action, session, peer, address=None, repoint=False: calls.append((action, host, session, peer)) or ("asked", None)
|
||||
|
||||
class Comp:
|
||||
def __init__(self):
|
||||
self.on = True
|
||||
self.log = []
|
||||
def connections(self):
|
||||
return [{"label": "x32", "id": "id-x32", "enabled": self.on}]
|
||||
def disable_connection(self, cid):
|
||||
self.on = False; self.log.append("off"); return True
|
||||
def enable_connection(self, cid):
|
||||
self.on = True; self.log.append("on"); return True
|
||||
comp = Comp()
|
||||
try:
|
||||
if self.midi.get("host"):
|
||||
self.snap.remote_midi[self.midi["host"]] = {} # its Mac reports: no peers
|
||||
self.snap.remote_midi_time[self.midi["host"]] = SUNDAY + 100
|
||||
else:
|
||||
self.snap.midi, self.snap.midi_time = {self.midi["session"]: {"port": 5004, "peers": []}}, SUNDAY + 100
|
||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 100, 3)
|
||||
self.assertEqual(level(tiles["lights"]), "ok")
|
||||
self.engine.run_fixes("lights", self.snap, comp, SUNDAY + 104) # hold: an operator has it
|
||||
for _ in range(50):
|
||||
if not self.engine.bypass_busy:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(calls, [("disconnect", "", self.midi["session"], self.midi["peer"])]) # never "Other"
|
||||
self.assertEqual(comp.log, ["off"])
|
||||
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 105, 3)
|
||||
midi_tile = next(t["id"] for t in self.cfg["tiles"] if self.midi in t.get("checks", []))
|
||||
tag = self.midi.get("tag") or self.midi["peer"]
|
||||
self.assertNotIn(tag + " MIDI", tiles[midi_tile]["x"]) # taken out on purpose: no alarm
|
||||
self.assertEqual(tiles["lights"]["x"], "LIGHTS\nTAKING\nMIDI OUT")
|
||||
tiles = self.engine.step(self.snap, SUNDAY + 120)
|
||||
self.assertEqual(tiles["lights"]["x"], "LIGHTS\nOPERATOR\nMIDI OFF")
|
||||
tr = next(t for k, t in self.engine.trackers.items() if self.engine.meta.get(k, (None, {}))[1] is self.midi
|
||||
or (self.engine.meta.get(k, (None, {}))[1].get("peer") == self.midi["peer"]
|
||||
and self.engine.meta[k][1].get("session") == self.midi["session"]))
|
||||
tr.starts.extend([SUNDAY + 1, SUNDAY + 2, SUNDAY + 3]) # it had dropped three times before
|
||||
self.engine.step(self.snap, SUNDAY + 121)
|
||||
self.assertEqual(len(tr.starts), 0) # not called flapping once taken out
|
||||
self.assertIn("lights", self.new_engine().bypass) # survives a restart
|
||||
calls.clear()
|
||||
self.engine.run_fixes("lights", self.snap, comp, SUNDAY + 130) # hold again: put back
|
||||
for _ in range(50):
|
||||
if not self.engine.bypass_busy:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
self.assertEqual(calls[0], ("connect", "", self.midi["session"], self.midi["peer"])) # what was taken out
|
||||
self.assertEqual({c[0] for c in calls}, {"connect"}) # and every link the checks watch
|
||||
self.assertEqual(comp.log, ["off", "on"])
|
||||
self.assertNotIn("lights", self.engine.bypass)
|
||||
finally:
|
||||
sp.midi_sessions, sp.midi_peer = real
|
||||
|
||||
def test_a_peer_without_an_address_is_never_called(self):
|
||||
res, why = sp.midi_peer(self.cfg, "", "connect", "SC Tracks NEW", "Lighting", ":5004")
|
||||
self.assertIsNone(res)
|
||||
self.assertIn("calls in", why)
|
||||
|
||||
def test_a_bypass_key_needs_its_peers(self):
|
||||
import schema
|
||||
bad = json.loads(json.dumps(self.cfg))
|
||||
next(t for t in bad["tiles"] if t["id"] == "lights")["bypass"] = {"hosts": ["nowhere"]}
|
||||
errors = schema.validate(bad)[0]
|
||||
self.assertTrue(any("bypass.peers" in e for e in errors))
|
||||
self.assertTrue(any("nowhere" in e for e in errors))
|
||||
|
||||
|
||||
class TestCompanionKeeper(unittest.TestCase):
|
||||
"""After a restart the panel opens the Companion that ran last, and only then (Southcoast, 2026-10-21)."""
|
||||
|
||||
def setUp(self):
|
||||
self.engine = sp.Engine(CONFIG, os.path.join(tempfile.mkdtemp(), "state.json"))
|
||||
self.data = tempfile.mkdtemp()
|
||||
self.app = tempfile.mkdtemp(suffix=".app") # stands in for /Applications/Companion 5.0.7.app
|
||||
self.opened = []
|
||||
self.now = 1000.0
|
||||
self.running = self.app
|
||||
|
||||
def keeper(self):
|
||||
return sp.CompanionKeeper(self.engine, self.data, started=self.now, running=lambda: self.running,
|
||||
opener=self.opened.append)
|
||||
|
||||
def test_it_remembers_the_running_copy_and_opens_it_after_a_restart(self):
|
||||
self.keeper().check(self.now) # Companion running: remembered
|
||||
self.assertEqual(self.engine.state.get("companion_app"), self.app)
|
||||
self.running = None # the Mac restarted without Companion
|
||||
k = self.keeper()
|
||||
self.assertEqual(k.check(self.now + 30), self.app)
|
||||
self.assertEqual(k.check(self.now + 60), None) # not again straight away
|
||||
self.assertEqual(self.opened, [self.app])
|
||||
|
||||
def test_it_never_acts_later_on_or_during_an_upgrade_or_without_knowing_the_copy(self):
|
||||
k = self.keeper()
|
||||
self.running = None
|
||||
self.assertIsNone(k.check(self.now + 30)) # never seen one: no guessing
|
||||
self.engine.state.set("companion_app", self.app)
|
||||
self.assertIsNone(k.check(self.now + 700)) # someone quit it later on: left alone
|
||||
open(os.path.join(self.data, "companion-upgrading"), "w").write("x")
|
||||
self.assertIsNone(self.keeper().check(self.now + 30)) # an upgrade is going on
|
||||
self.assertEqual(self.opened, [])
|
||||
|
||||
|
||||
class TestMidiNoState(unittest.TestCase):
|
||||
"""macOS 26 gives each live link a state, macOS 14/15 none: a link with no state is judged by which (2026-10-21)."""
|
||||
|
||||
def snap(self, peers, now):
|
||||
s = healthy_snapshot(now)
|
||||
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": peers}}}
|
||||
s.remote_midi_time = {"tracks": now}
|
||||
return s
|
||||
|
||||
def check(self, s, now):
|
||||
chk = {"type": "midi_peer", "host": "tracks", "session": "T", "peer": "Lighting", "address": "10.5.1.168:5004"}
|
||||
s.remote_midi_time = {"tracks": now} # a fresh reading every 2 s
|
||||
return sp.check_midi_peer(chk, s, {"now": now})[0][1]
|
||||
|
||||
def test_on_a_mac_that_reports_states_no_state_means_no_answer(self):
|
||||
s = self.snap([{"name": "FOH", "address": "x", "state": "running"}, {"name": "Lighting", "address": "y", "state": ""}], 100.0)
|
||||
self.assertEqual(self.check(s, 100.0).level, sp.ERR)
|
||||
self.assertEqual(self.check(s, 100.0).msg, "MIDI NO ANSWER") # there at the first reading: long-standing
|
||||
|
||||
def test_on_an_older_mac_a_link_put_back_counts_only_once_it_stays(self):
|
||||
s = self.snap([{"name": "Lighting", "address": "y"}], 100.0)
|
||||
self.assertEqual(self.check(s, 100.0).level, sp.OK) # there from the start: configured, fine
|
||||
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": []}}}
|
||||
self.assertEqual(self.check(s, 101.0).msg, "MIDI LOST") # dropped
|
||||
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": [{"name": "Lighting", "address": "y"}]}}}
|
||||
self.assertEqual(self.check(s, 110.0).msg, "MIDI CONNECTING") # put back: not trusted yet
|
||||
self.assertEqual(self.check(s, 125.0).msg, "MIDI CONNECTING")
|
||||
self.assertEqual(self.check(s, 131.0).level, sp.OK) # stayed 20 s: it answered
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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 config write ~/.ssh/config.d/waters from the machines that reported back
|
||||
|
||||
The ARD command, on each Mac: turns on Remote Login, lets the logged-in user's account take this
|
||||
laptop's key (~/.ssh/id_ed25519_waters), and reports name, user and addresses to North Attleboro's
|
||||
settings page (/api/fleet/hello). `config` turns that list into shortcuts: ssh na-tracks-mac.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HUB = os.environ.get("FLEET_HUB", "http://10.1.1.1:8020")
|
||||
KEY = os.path.expanduser("~/.ssh/id_ed25519_waters")
|
||||
OUT = os.path.expanduser("~/.ssh/config.d/waters")
|
||||
|
||||
|
||||
def pubkey():
|
||||
return open(KEY + ".pub").read().strip()
|
||||
|
||||
|
||||
def ard():
|
||||
key = pubkey()
|
||||
return r"""U=$(stat -f%Su /dev/console); if [ "$U" = root ] || [ -z "$U" ]; then echo "SKIPPED $(scutil --get ComputerName): nobody is logged in"; exit 0; fi
|
||||
H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}'); KEY='""" + key + r"""'
|
||||
mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && (grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys") && chown -R "$U:staff" "$H/.ssh" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys"
|
||||
nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1 || systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1
|
||||
dseditgroup -o read com.apple.access_ssh >/dev/null 2>&1 && dseditgroup -o edit -a "$U" -t user com.apple.access_ssh >/dev/null 2>&1
|
||||
IPS=$(ifconfig | awk '/inet /{print $2}' | grep -v '^127\.' | sed 's/.*/"&"/' | paste -sd, -); N=$(scutil --get ComputerName | tr -d '"\\')
|
||||
curl -s -m 5 -X POST -H 'Content-Type: application/json' -d "{\"user\":\"$U\",\"name\":\"$N\",\"hostname\":\"$(scutil --get LocalHostName).local\",\"os\":\"mac\",\"ips\":[$IPS]}" """ + HUB + r"""/api/fleet/hello >/dev/null
|
||||
echo "OK $N user=$U ssh=$(nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1 && echo on || echo OFF)"
|
||||
"""
|
||||
|
||||
|
||||
def slug(s):
|
||||
s = re.sub(r"\s*\(\d+\)$", "", s) # macOS's "Name (2)" after a rename clash
|
||||
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "mac"
|
||||
|
||||
|
||||
def config():
|
||||
with urllib.request.urlopen(HUB + "/api/fleet", timeout=10) as r:
|
||||
machines = json.load(r)["machines"]
|
||||
# The locations' wired networks first (10.1 NA, 10.2 WS, 10.3 AB, 10.4 NW, 10.5 SC); NA Ingest also has a
|
||||
# 10.66 address the laptop cannot reach (2026-10-21), and 10.6 is staff Wi-Fi.
|
||||
site = lambda ip: re.match(r"10\.[1-5]\.", ip) is not None
|
||||
wired = lambda ip: not ip.startswith(("10.6.", "169.254.", "127."))
|
||||
# A record registered from this laptop carries the laptop's own address too (the hub adds the sender's).
|
||||
own = set(re.findall(r"inet (\d+\.\d+\.\d+\.\d+)", subprocess.run(["/sbin/ifconfig"], capture_output=True, text=True).stdout))
|
||||
lines, used = ["# Written by tools/fleet.py from %s/api/fleet - every machine that took this laptop's key." % HUB], set()
|
||||
for m in sorted(machines, key=lambda m: m["name"].lower()):
|
||||
ips = [i for i in m["ips"] if i not in own]
|
||||
ip = next((i for i in ips if site(i)), next((i for i in ips if wired(i)), ips[0] if ips else None))
|
||||
if not ip:
|
||||
continue
|
||||
alias = slug(m["name"])
|
||||
while alias in used:
|
||||
alias += "-2"
|
||||
used.add(alias)
|
||||
lines += ["", "Host %s" % alias, " HostName %s" % ip, " User %s" % m["user"],
|
||||
" IdentityFile %s" % KEY, " IdentitiesOnly yes", " ConnectTimeout 8"]
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
open(OUT, "w").write("\n".join(lines) + "\n")
|
||||
cfg = os.path.expanduser("~/.ssh/config")
|
||||
text = open(cfg).read() if os.path.exists(cfg) else ""
|
||||
if "Include ~/.ssh/config.d/*" not in text:
|
||||
open(cfg, "w").write("Include ~/.ssh/config.d/*\n\n" + text)
|
||||
print("%d machines -> %s: %s" % (len(used), OUT, ", ".join(sorted(used))))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
what = (sys.argv[1:] or ["ard"])[0]
|
||||
print(ard()) if what == "ard" else config()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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
|
||||
|
||||
Apollo Beach, 2026-10-21 (the user): "they want to take care of it on their own, so make a sorta
|
||||
standalone version that i can send them. i dont need to monitor AB from NA like i do for SC and WS".
|
||||
The zip holds the program (the same files a location downloads from NA, never NA's settings, plug keys
|
||||
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
|
||||
no other Mac in, and backs itself up every night into ~/Documents/Status Panel Backups.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
from settings.campus import KIT_FILES, KIT_DIRS # noqa: E402
|
||||
|
||||
INSTALL = r"""#!/bin/zsh
|
||||
# Status panel for %(name)s: installs on the Mac that runs Companion and sets itself up from what it finds.
|
||||
# Safe to run again: it then updates the program and looks for new gear.
|
||||
set -e
|
||||
HERE=${0:A:h}
|
||||
if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
|
||||
echo "First the Command Line Tools: a window opens - click Install, wait for it, then run this again."
|
||||
/usr/bin/xcode-select --install 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
DIR="$HOME/status-panel-install"
|
||||
rm -rf "$DIR" && mkdir -p "$DIR"
|
||||
/usr/bin/ditto "$HERE/program" "$DIR"
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$DIR" 2>/dev/null || true
|
||||
[[ -f "$HOME/.ssh/id_status_panel" ]] || { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" && /usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName)" -f "$HOME/.ssh/id_status_panel"; }
|
||||
cd "$DIR"
|
||||
zsh install.sh --site %(cid)s
|
||||
cd "$HOME/Library/Application Support/status-panel"
|
||||
/usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --standalone
|
||||
# This Mac's wired address (the default route's port).
|
||||
IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}')
|
||||
ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname)
|
||||
echo
|
||||
echo "Settings page: http://$ME:8020 (the editing code is printed above)"
|
||||
echo
|
||||
echo "Next, on each other computer here (ProPresenter, Tracks, Lighting, FOH...):"
|
||||
echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh"
|
||||
echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)"
|
||||
echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex"
|
||||
echo "Then on the settings page: Auto-setup > Look now."
|
||||
"""
|
||||
|
||||
README = """%(name)s - Status Panel
|
||||
%(rule)s
|
||||
|
||||
What it is
|
||||
A status page on the Stream Deck (Bitfocus Companion) that watches your gear - ProPresenter, the
|
||||
ATEM, Resi, consoles, Tracks, Lighting, MIDI links, the network - and fixes what it can. It runs on
|
||||
the Mac that runs Companion. It is yours: nothing is sent to any other location.
|
||||
|
||||
Before you start
|
||||
- Companion 5.0.7 on that Mac, with its HTTP API on (Companion > Settings > Protocols).
|
||||
If yours is older, the installer says so and how to upgrade (download 5.0.7 for Mac from
|
||||
https://bitfocus.io/companion; your buttons come along).
|
||||
- Remote Login on for the other Macs (System Settings > General > Sharing), so the panel can check
|
||||
and restart programs on them.
|
||||
|
||||
Install (about 5 minutes)
|
||||
1. Copy this folder to the Companion Mac (Downloads is fine).
|
||||
2. Open Terminal, type zsh and a space, drag "install.command" from this folder into the
|
||||
window, press Return.
|
||||
3. It finds what you have, makes the Stream Deck pages (19 status, 20 restart, or the first free
|
||||
pages), and prints the settings page address and its editing code (PIN). Keep the PIN.
|
||||
4. On each other computer, paste the line it prints (Mac: Terminal; PC: PowerShell as
|
||||
administrator). Then on the settings page: Auto-setup > Look now. New gear gets keys by itself.
|
||||
|
||||
Using it (the Stream Deck)
|
||||
- Green means fine. Flashing red names the problem, for example "PROP OFFLINE".
|
||||
- Press a red key to acknowledge it: it stops flashing and stays dark red.
|
||||
- Hold any key for 2 seconds to fix it: it reconnects, restarts or reopens whatever is failing.
|
||||
- Purple means the panel itself is not running: restart the Companion Mac, or run the installer again.
|
||||
- ALARMS (bottom-left) silences alarms and stops automatic fixes until midnight. Use it during
|
||||
maintenance.
|
||||
- The page-number key on every page turns red when something is wrong. Hold it for 1 second to
|
||||
jump to the status page.
|
||||
- RESTART PAGE opens the restart page. Hold a key there for 2 seconds to restart that program even
|
||||
if it looks fine.
|
||||
- Automatic fixes run Tuesday 2-10 pm, Wednesday 9 am-9 pm and Sunday 5 am-2 pm. Change the hours
|
||||
on the settings page (Auto-fix) to match your services.
|
||||
- Nothing is ever restarted while the HyperDeck is recording.
|
||||
|
||||
The settings page (http://<Companion Mac>:8020)
|
||||
Everything is changed there: keys, checks, fixes, auto-fix hours, the Restart page. Every Apply is
|
||||
kept, and History puts back any earlier one. Changing gear? Auto-setup > Look now adds what is new.
|
||||
|
||||
Backups
|
||||
Every night at 4:30 am the settings are copied to ~/Documents/Status Panel Backups/%(cid)s on the
|
||||
Companion Mac (the newest 30 are kept). Copy that folder somewhere safe now and then, or point it at
|
||||
a Google Drive folder (ask whoever installed it).
|
||||
|
||||
Run it again
|
||||
Running install.command again updates the program and looks for new gear; your settings stay.
|
||||
"""
|
||||
|
||||
|
||||
def build(cid, name, out_dir):
|
||||
cid = re.match(r"[a-z0-9]{1,8}", cid.lower()).group(0)
|
||||
top = "%s Status Panel" % name
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
path = os.path.join(out_dir, top + ".zip")
|
||||
|
||||
def add_file(z, src, arc, mode=0o644):
|
||||
info = zipfile.ZipInfo.from_file(src, arc)
|
||||
info.external_attr = (0o100000 | mode) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
with open(src, "rb") as f:
|
||||
z.writestr(info, f.read())
|
||||
|
||||
def add_text(z, text, arc, mode=0o644):
|
||||
info = zipfile.ZipInfo(arc, date_time=(2026, 10, 21, 0, 0, 0))
|
||||
info.external_attr = (0o100000 | mode) << 16
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
z.writestr(info, text)
|
||||
|
||||
with zipfile.ZipFile(path, "w") as z:
|
||||
prog = top + "/program/"
|
||||
for f in KIT_FILES + ("install.sh", "README.md"):
|
||||
p = os.path.join(ROOT, f)
|
||||
if os.path.exists(p):
|
||||
add_file(z, p, prog + f, 0o755 if f.endswith(".sh") else 0o644)
|
||||
for d in KIT_DIRS + ("sites/_template",):
|
||||
for base, dirs, files in os.walk(os.path.join(ROOT, d)):
|
||||
dirs[:] = [x for x in dirs if x != "__pycache__"]
|
||||
for f in files:
|
||||
if f.endswith(".pyc") or f.startswith("._") or f == ".DS_Store":
|
||||
continue
|
||||
p = os.path.join(base, f)
|
||||
add_file(z, p, prog + os.path.relpath(p, ROOT), 0o755 if f.endswith(".sh") else 0o644)
|
||||
q = "'" + name.replace("'", "'\"'\"'") + "'"
|
||||
add_text(z, INSTALL % {"name": name, "cid": cid, "qname": q}, top + "/install.command", 0o755)
|
||||
add_text(z, README % {"name": name, "cid": cid, "rule": "=" * (len(name) + 15)}, top + "/READ ME FIRST.txt")
|
||||
return path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(__doc__)
|
||||
print(build(sys.argv[1], sys.argv[2], os.path.join(ROOT, "dist")))
|
||||
Reference in New Issue
Block a user