29 Commits
Author SHA1 Message Date
christianandClaude Opus 5.5 6d1cfea2f7 Every campus matches NA, tailored: Restart page, Companion-Mac programs, NA's auto-fix hours, nightly backups, restart from NA
- Auto-setup: programs on the Companion Mac itself (ProPresenter at Woonsocket) get a local process
  check and local start/restart fixes; every campus gets a Restart page (keys arrive as computers
  join); NA's auto-fix hours (Tue, Wed, Sun); a program found after its Companion connection joins
  that key instead of making a second one. settings.campus rebuild remakes a campus from what is
  found now, keeping its name, PIN and pages; upgrade-companion brings Companion to 5.0.7 from NA's
  copy (/campus/companion.zip), as Woonsocket was; setup stops with that advice on an older one.
- NA's CAMPUSES page has a RESTART key per campus; the CAMPUS page shows that campus's Restart page
  and a hold there restarts it at that campus.
- Backups: backup/backup-campuses.sh, nightly 04:30 on NA (installed everywhere, acts on the hub),
  into Drive > Waters Creative > Apps > Status Panel Backups/<campus>, 30 kept, alert email via the
  checklist's alert.js. Campuses report the account to back up with. sites/ws/config.json in the repo.
- Woonsocket rebuilt: 9 keys (PROP with ProPresenter on its Mac), Restart page 20 (SUPERRACK, VISTA,
  ABLETON, PROP), auto-fix on NA's hours; all OK. Both campuses backed up to Drive by the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:17:04 -04:00
christianandClaude Opus 5.5 b793490d90 Hold Woonsocket's keys from NA; MIDI links moved and fixed on Macs without python; one check per link
- NA's CAMPUS page (99): tap = acknowledge, hold = fix, at the campus shown. NA's settings page
  turns the cell into that campus's key and writes the command into that campus's Companion
  (sp_cmd), exactly as a press on its own deck does. Logged on both sides.
- coremidi.JXA_CONNECT: connect_peer in JavaScript for Automation, used when a Mac has no python
  (no Command Line Tools): a MIDI hold now reconnects Woonsocket's Tracks, FOH and Lights.
- Woonsocket's MIDI links are on wired addresses now (Tracks to Lighting 10.2.2.2, Tracks to FOH
  10.2.1.12; the other ends list Tracks at 10.2.1.219), and the wired-network rule is back on there.
- Auto-setup keeps one check per MIDI link (the end that reports the link's state): two pulled
  links read 4 DOWN before.

Tried live: a hold sent from NA's page 99 reconnected the FOH link at Woonsocket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:00:31 -04:00
christianandClaude Opus 5.5 457335bc96 Woonsocket's startup list covered: M32, SoundGrid link, MIDI on Macs without python
From the WS Production Startup checklist (check.watersmedia.org):
- M32 (the "X32" there is a Midas M32 at 10.2.1.146): Woonsocket's Companion got the X32/M32
  module (imported from NA's copy through Companion's own API) and an m32 connection; auto-setup
  names a single M32 connection's key M32.
- SuperRack's link to the Waves SoundGrid server: a soundgrid check builder (SuperRack's control
  connection on port 19000) inside every SUPERRACK key.
- MIDI ("Open Audio MIDI"): Macs without the Command Line Tools have their sessions read with
  JavaScript for Automation (coremidi.JXA_SESSIONS) - by the watchdog every 2 s and by discovery. A
  peer with no state (older macOS) counts as configured. A peer joined by name or IPv6 gets the
  wired address of the Mac whose session it is (matched by session name), at that session's port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:48:28 -04:00
christianandClaude Opus 5.5 e1c9b2f974 Auto-setup knows Vista on a Mac (Woonsocket's Lights Mac mini)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:37:10 -04:00
christianandClaude Opus 5.5 93805cb723 No developer-tools pop-ups on the computers; apps found where they really are; SuperRack and X32-Edit
On a Mac without the Command Line Tools, /usr/bin/python3 is a stub that opens Apple's installer on
that Mac's screen: Woonsocket's Tracks and FOH Macs showed it after joining (and would every ten
minutes). Python now runs on another Mac only when xcode-select says it is there (MIDI reads, MIDI
reconnects, the Computers probe); without it those Macs' own MIDI sessions are simply not read.

The app list used a glob that zsh (the Macs' login shell) turns into an error when one pattern
matches nothing, so no app was ever found there: find instead, and a running program's app path is
taken from the program itself. New recipes: SuperRack SoundGrid and X32-Edit (AUDIO).
Woonsocket now has ABLETON, SUPERRACK and X32 EDIT keys, all OK, and NA's page 99 shows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:33:28 -04:00
christianandClaude Opus 5.5 777afac0b1 NA's CAMPUS page always shows a campus, and says which
Opened directly (nothing tapped), page 99 was all purple: no campus chosen. It now shows the first
other campus until one is tapped, and the back key reads WS / ALL SITES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:28:21 -04:00
christianandClaude Opus 5.5 91cabfb887 New campuses colour only the page-number key, as NA does
The template had no nav_keys, so Woonsocket's UP and DOWN keys followed ALL on all 98 pages.
Woonsocket set to ["pagenum"] on its page (UP and DOWN put back); the kit NA serves now carries it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:24:01 -04:00
christianandClaude Opus 5.5 4dac76f703 Campus hello: the same address under a new name replaces its old entry
Woonsocket reported as "new" before its first setup finished, then as ws: NA kept both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:18:15 -04:00
christianandClaude Opus 5.5 d7dc931bd4 NA sees every campus; SSH from the admin laptop to every machine
Campuses set up from NA's install line tell NA where they are and learn nothing back (no campus
ever shows another). NA alone reads each campus's key states every two seconds and keeps two pages
in its Companion (added at the end, 98 and 99, since every other page is in use): CAMPUSES, a key
per campus (its ALL key, purple when it stops answering), and CAMPUS, the tapped campus's status
page drawn live in the same places, view only. Tried on NA with a test campus pointing at NA
itself, then removed.

tools/fleet.py: an Apple Remote Desktop command that turns on Remote Login, adds the laptop's key
and reports each Mac to NA, and the laptop SSH shortcuts made from those reports (tried on NA
Translation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:57:18 -04:00
christianandClaude Opus 5.5 3976667bba Other campuses set themselves up: one install line, one join line per computer, keys made from what is found
settings/discover.py reads Companion's connections (and their addresses and channel counts), the
Dante devices, the network MIDI sessions here and on joined Macs, this Mac's wired network, and
what runs on each joined computer. settings/autoconf.py turns that into keys, checks, start and
restart fixes and a page layout the way North Attleboro's were made (group keys only when not
everything fits; receivers fold first), all through the existing builders and rules.
settings/campus.py prepares Companion through its internal API (the panel's custom variables and
the blink trigger), picks free pages, applies the first config, serves the program kit, the
campus install line and the Mac/PC join lines, and looks every ten minutes to add new gear
(discover.auto; never during a service or a recording, never removes, never re-adds what someone
took off). Settings > Auto-setup shows what was found and the lines to paste. install.sh keeps the
kit and makes the panel's login key.

Checked on North Attleboro read-only: discovery finds all 43 connections, 19 Dante devices, four
Macs' MIDI sessions and seven computers, and a from-scratch build reproduces NA's layout. The
Companion calls were tried with a throwaway variable and a disabled trigger, then removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:31:32 -04:00
christianandClaude Opus 5.5 348dc43b4d Settings > MIDI links: the wired network and every link's address, editable, with a not-wired flag
An address change also updates the same link on the READY page. Without a wired rule, a button adds
one. The add-a-link screen shares the same wired-network helpers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:07:53 -04:00
christianandClaude Opus 5.5 6ae17e5b90 Every MIDI link by wired address: flag links joined by name, hold MIDI moves them, refuse non-wired addresses
Tracks had joined NA Ingest, Lighting PC and Studio B by Bonjour name: macOS picks the network
each time, so a reconnect could land on Wi-Fi or Dante. The wired check now also names a link
joined by name; holding MIDI moves it to the wired address its own MIDI check names (a hold
only: auto-fix never moves a working link). Reconnects prefer a wired address over a Wi-Fi or
by-name one they last saw. The settings refuse a MIDI link whose address is a name or off the
wired network, and the add-a-link screen fills in the wired address. Studio B's link now has its
wired address, and Tracks' link to NA Ingest is watched (TRK INGEST). All links on the four Macs
were moved and checked: wired IPv4, running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:38:22 -04:00
christianandClaude Opus 5.5 340100b5bb Wired-MIDI test: one check, whether or not config.json already has it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:10:51 -04:00
christianandClaude Opus 5.5 79318976c5 MIDI key: every network MIDI peer must come over the wired network
The Macs are also on Wi-Fi and the Dante networks, and Bonjour lists each MIDI session once per
network. ProdDash's Vista Cues module had Tracks and Companion set to their Wi-Fi addresses, so
lighting MIDI rode Wi-Fi. A midi_wired check now names any peer, in NA Ingest's session or the
other Macs', whose address is outside the wired network (10.1.0.0/16 at NA).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:09:34 -04:00
christianandClaude Opus 5.5 b41b8a49d2 ALARMS key stops every auto-fix too, and says so
Muted, nothing fixes itself at all: no Spotify exception (while_muted is gone) and no any-hour
READY fix; holds still fix and the Sunday stream-audio failover still switches. The key reads
ALARMS OFF / AUTO-FIX OFF / TIL 12AM, and its note says both. The settings page's switch says the same.

The mute's end is worked out at the press and kept: a watchdog that was not running at 23:59 no
longer leaves the alarms muted until the next schedule change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:46:20 -04:00
christianandClaude Fable 5.1 5d918d60af An any-hour READY fix runs while ALARMS is muted too
The LTC write only puts a note in Planning Center; nothing in the room notices, so the mute (pressed
Sunday 12:24 at NA and never lifted) must not hold it back. The window's fixes still stop when muted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:13:17 -04:00
christianandClaude Fable 5.1 b7af31ea6f The LTC write happens by itself: READY fixes ticked for auto-fix act on the checklist, any hour for ready_ltc
Auto-fix now looks at the READY page's keys too, for the READY fixes ticked under Settings > Auto-fix,
and acts on a checklist gap while it is still amber. A fix marked anytime (only a READY fix may be)
runs outside the auto-fix hours as well, never while ALARMS is muted; the window's own fixes keep
their hours and start fresh each window. ready_ltc is ticked and marked anytime at North Attleboro:
once the weekend's set is on Tracks, every song's timecode goes into all four campuses' plans.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 14:08:33 -04:00
christianandClaude Fable 5.1 f1cd42a437 Other campuses' LTC: match against North Attleboro's plan order, not only the songs with timecode
With the weekend's set not on Tracks yet, every campus song looked out of order. The campuses now
pair with the home plan itself; a song the home set has no timecode for yet is matched but neither
faulted nor written, and the campuses are not looked at at all until the home set gives some timecode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 10:30:43 -04:00
christianandClaude Fable 5.1 3ccaea0076 LTC timecode goes to the other campuses' plans too: Apollo Beach, Southcoast, Woonsocket
The TIMECODE key now reads each campus's plan for the same weekend, pairs its songs with North
Attleboro's by title in North Attleboro's order (a song out of order, or one NA does not play, is
left alone and said so), checks their LTC notes, and the hold writes the timecode into every
campus's plan. ready.ltc_campuses lists the campuses (name, two letters, PCO service type).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-19 10:26:13 -04:00
christianandClaude Fable 5.1 87bfe7c162 A Dante device Companion's module never listed before gets the Dante Controller connection restarted once
The module only reads a device's channels when it starts, so a receiver added later is missing
from the crosspoint lists until the connection restarts (the two AD4Q receivers today). The
watchdog now remembers every Dante device it has seen in state.json and, when a newcomer appears,
restarts that connection once: never during a live service, at most every ten minutes, and never
for a device that merely dropped out and came back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 06:38:27 -04:00
christianandClaude Fable 5.1 efd9b973eb Campus settings from NA Ingest: Sunday auto-fix from 5 am; READY Wednesday hours to 9 pm
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 06:08:32 -04:00
christianandClaude Fable 5.1 4f2a5f17a2 Remote MIDI reader restarts after a minute of an empty session list; one temp file per JSON writer
A reader that reconnects while the other Mac is still at the login window never sees the user's
MIDI sessions and reports none for ever (four false NO SESSION alarms after the Tracks and
ProPresenter Macs rebooted on Sunday morning). Two threads writing fixes-running.json shared one
temp file and one rename failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-18 05:59:30 -04:00
christianandClaude Opus 5.5 98739bd041 Never undo settings applied on the page: sync from NA Ingest, rebase stale drafts, guard the installer
- config.json is NA Ingest's live settings: the READY hours (Tue 14-22, Wed 9-19, Sun 5-14) and the
  auto-fix hours (Tue 14-22, Wed 9-21, Sun 6:30-14) set on the settings page.
- Settings page: a draft carries what its page started from. A tab that loaded the settings before
  they changed (an Apply elsewhere, a program update) now has only its own edits put on top of the
  live settings; before, it saved its whole older copy and Apply quietly undid the newer values
  (it took ready.prop_host out twice today).
- install.sh refuses (exit 4) to replace live settings that came from an Apply (their fingerprint is
  a history entry's sha_after) unless --replace-config; --keep-config still updates code only.
- Tests pin their own auto-fix and READY hours, so the campus can change its own; the exit-timeout
  test expects 310 s (the Tascam outlet power-cycle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:02:17 -04:00
christianandClaude Opus 5.5 c9ffbc6828 READY page: LED CROSS checks the Resolume columns Sunday's slides call up
- For every item in the plan's ProPresenter playlist: the macros its slides fire (read on the
  ProP Mac from Configuration/Macros and the .pro files), the RossTalk "CC page/row/col" each
  macro sends to Companion, the Companion buttons that one presses in turn, down to Resolume's
  /composition/columns/N/connect. Each such column must hold a clip on some layer (one whose
  ignore-column-trigger is not On), and Resolume must be on the Service deck.
- COL N EMPTY / COL N MISSING name the column and the items that use it. The hold only puts
  Resolume back on its deck; it never plays a clip. "Something is playing" is no longer checked.
- ready.prop_host replaces ready.led_clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:43:49 -04:00
christianandClaude Opus 5.5 5adab6c518 Settings page: ON/OFF switches everywhere, the Auto switch screen, a Companion button picker
- ON/OFF on every key, check, fix, Restart and READY key, computer, MIDI link, HyperDeck,
  Dante device, watched Companion connection, switcher and Spotify check, outlet fix, and one
  main switch each for auto-fix, the auto switch and the READY page. Switched off = kept in
  the settings, grey OFF on the deck, never checked or run (effective_config in the watchdog).
- Settings › Auto switch: a plain summary, what counts as down (each trigger editable in place:
  address, program and computer, Dante device, the button it presses, timings), the hours
  folded away, the buttons; + Button, + What counts as down, Remove.
- Pick a Companion button by clicking it on its page, drawn like Companion (read-only API:
  /api/companion/pages and /api/companion/page).
- STREAM STB / STREAM FOH Restart keys removed (the Studio B page has its own); the alert names
  the switch-back button instead.
- Dante screen: shows the DANTE key's device list again (it picked STB AUDIO's one device).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:30:41 -04:00
christianandClaude Fable 5.1 14fce2cf9a Audio failover: press FOH Backup by itself when Studio B dies; Companion button presses as fixes
A new fix kind "companion_press" presses any Companion button over its HTTP API (page, row, col).
A fix marked "failover" runs by itself within seconds of its check failing: inside the "failover"
windows (Sunday 06:30-13:30), even while ALARMS is muted, once per fault, never back the other way.
The hidden STB AUDIO key (inside AUDIO) watches Studio B's ping every second, Pro Tools over SSH
every 2 s and its Dante device; each check's fix is the FOH Backup press. The keys say ON FOH BACKUP
until STREAM STB on the Restart page switches back. Settings page: the press builder, the failover
tick and undo, the Failover calendar, help entry "A fallback button".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:40:24 -04:00
christianandClaude Opus 5.5 422e92f814 Settings page: add and edit everything by hand, READY page included
- Keys › READY page: + READY key (tap to confirm, a checklist line the page checks itself, or
  any check), and the same check/fix editor the Status page keys have; delete a key.
- Settings › READY page: when gaps turn red (calendar), every address and folder it looks at,
  and the lists the slides must match.
- Help: "How do I add…", step by step: a Companion connection, a ping, a program, a fix,
  auto-fix, a checklist line, where READY looks, a computer, undo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:40:05 -04:00
christianandClaude Opus 5.5 309bc5fab7 Watch the DiGiCo FOH console inside AUDIO; "+ Key" asks for a group when the deck is full
- DIGICO (in the AUDIO group): Companion's DiGiCo_FOH connection, the digico-relay helper on
  NA Ingest (hold AUDIO restarts it), and a ping to the console at 10.1.60.100 (the OSC
  connection reads good even with the console off).
- Settings page: "+ Key" on an unwatched Companion connection asks which group key to put it
  in when the Status page has no free spot (it used to make a key nobody could see); the
  connection field of a check suggests Companion's connection names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:33:40 -04:00
christian d1ae84e4cc Merge pull request 'READY page: the weekly worship checklist, checked against Planning Center' (#1) from ready-page into main 2026-10-14 20:47:09 -04:00
31 changed files with 7612 additions and 1820 deletions
+102 -6
View File
@@ -19,7 +19,7 @@ There are two parts:
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. SPOTIFY has no key of its own any more; it shows inside SHOW, and its
force-play key is on the Restart page.
@@ -58,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) |
@@ -67,7 +68,7 @@ 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 **and stop every auto-fix**, for example during maintenance; press it again to turn both back on. Both come back by themselves at midnight. The key says both states (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`). Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch.
## Auto-fix on Sundays (06:30–13:30)
@@ -77,7 +78,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 campuses 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)
@@ -94,6 +114,8 @@ 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 24): the weekly checklist
@@ -129,9 +151,9 @@ everything on the page at once.
| 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. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again |
| TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start; the other campuses' plans (`ready.ltc_campuses`: Apollo Beach, Southcoast, Woonsocket) get the same timecode for the same songs in this campus's order, and a song that is missing or out of order there is left alone. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again |
| BKGDS | every song has at least one slide with a background | looks again |
| LED CROSS | Resolume is on the **Service** deck and a clip is playing | selects the Service deck and plays the cross background again (the one in `"led_clip"`, else the last one seen playing) |
| 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 |
@@ -177,6 +199,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". |
@@ -199,10 +223,82 @@ With Editing on every screen grows inputs. Changes go into one shared draft on t
**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).
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 campuses: auto-setup
A campus sets itself up. All a person does is let the panel in: one line on the campus's Mac that runs Companion,
one line on each other computer.
1. **The Companion Mac.** Terminal, paste the campus's line from Settings › Auto-setup on any running campus (North
Attleboro's page shows the Apollo Beach, Southcoast and Woonsocket lines), for example
`curl -fsSL "http://10.1.1.1:8020/campus/install?id=ab" | zsh`. It downloads the program from that page (code only,
never that campus's settings or keys), installs it, makes the panel's login key, and runs
`python3 -m settings.campus setup`: Companion gets the panel's custom variables and the blink-clock trigger (through
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 campus'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 campus, 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 campuses at a glance
| Campus | Settings page (bookmark these) | Companion Mac | Status / Restart pages |
|---|---|---|---|
| North Attleboro (hub) | http://10.1.1.1:8020/ | NA Ingest | 19 / 20, READY 24, CAMPUSES 98, CAMPUS 99 |
| Woonsocket | http://10.2.2.210:8020/ | WS ProPresenter Mac (wsprop) | 19 / 20 |
Every campus: alarms armed all day; auto-fix on NA's hours (Tue 14:00–22:00, Wed 09:00–21:00, Sun 05:00–14:00);
a hold on any key fixes it at any time, on that campus's own deck or from NA's CAMPUS page; ALARMS stops auto-fix.
`python3 -m settings.campus rebuild` (on a campus's Companion Mac, in the panel folder) makes its setup again from
what is found now, keeping its name, PIN and pages. `python3 -m settings.campus upgrade-companion` brings an older
Companion to 5.0.7 from NA's copy (settings come along; the old version stays installed).
## Backups
Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every
campus's settings, Apply history and page-key record into Google Drive: Shared drives > Waters Creative > Apps >
Status Panel Backups > <campus> > <campus>-<date>.tgz, newest 30 kept, the same pattern and alert email as the
checklist's backup-to-drive.sh. Other campuses are reached over SSH as the account they report. The repo keeps each
campus'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.
## Every campus on North Attleboro's deck (NA only)
Each campus set up from NA's install line tells NA's settings page where it is (`/api/campus/hello`) and learns
nothing back: no campus ever shows another, and only NA (the hub: no "home" in `campuses.json`) does
(`settings/campuses.py`). NA's settings page reads each campus's key states every two seconds (`/api/state`) and
keeps two pages in NA's Companion, added at the end when no page is empty (today 98 and 99):
- **CAMPUSES (98):** row 0, one key per campus, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Row 1, its RESTART key. Tap NA's: NA's own pages. Tap another's: the CAMPUS page showing it.
- **CAMPUS (99):** the tapped campus's status (or Restart) page, same places, colours and words, live. Tap = acknowledge, hold = fix, at that campus, exactly as on its own deck (a Restart key's hold restarts it there). Bottom-left ("WS / ALL SITES") goes back.
## 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:
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
# backup-campuses.sh - every campus'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 > <campus>), keeps
# the newest 30 per campus, and emails an alert (through the checklist's alert.js) when something
# fails, because a backup nobody hears about failing is no backup.
#
# Only the hub (North Attleboro: no "home" in campuses.json) does anything; elsewhere it exits.
# Each campus is reached over SSH with the panel's own key, as the account it reported. What is
# copied: config.json (the settings), history/ (every Apply), the page-key record and the campus
# lists. Never outlets.json (the smart-plug keys stay on their Mac).
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
}
/usr/bin/python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if d.get("list") and not d.get("home") else 1)' \
"$DEST/campuses.json" 2>/dev/null || { log "not the hub: nothing to back up from here"; exit 0; }
DRIVE=""
for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do
[ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; }
done
if [ -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 campuses were backed up. A campus missing here is usually off, off the network, or no longer letting this Mac log in."
exit 1
fi
log "── done ──"
+1686 -1520
View File
File diff suppressed because it is too large Load Diff
+76 -4
View File
@@ -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,71 @@ 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);
"""
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 = {}
+60
View File
@@ -4,6 +4,7 @@
#
# 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 --replace-config replace the live config even when it came from an Apply on the settings page
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
# zsh install.sh --rollback put the previous code back (kept in prev/) and restart
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
@@ -26,6 +27,7 @@ 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 ;;
@@ -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 campus installs from (the settings page serves it as /campus/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 campus'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)
+6 -2
View File
@@ -29,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, ready_keys # 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
@@ -196,7 +196,11 @@ 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")
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"))
else:
stale_text = "'%s\\n?'" % label
press = "ack " + tid
+273 -74
View File
@@ -15,14 +15,15 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
the tracks play at that song's start (worked out from the set's LTC clip and tempo map)
backgrounds every song's slides have a background (ProPresenter labels a slide with
its media's file name)
led_cross Resolume is on the Service deck and something is playing on the cross
led_cross every Resolume column Sunday's slides call up holds content, on the Service deck
(slide -> macro -> RossTalk "CC page/row/col" to Companion -> Resolume column)
lineup the Band Lineup board (ProdDash) shows this plan and exactly the people on it
mics (a tap-to-confirm key) which mics this weekend needs, from PCO
track_bundle every location has this weekend's Ableton project folder on the shared drive
prop_bundle a ProPresenter bundle was saved to the locations' folder this week
What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync
the lineup from PCO, put the cross background back up, copy the tracks to a location that
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
has none, or simply look again now.
Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read
@@ -52,8 +53,6 @@ MEDIA = re.compile(r"\.(mp4|mov|m4v|mxf|mpg|mpeg|avi|webm|jpg|jpeg|png|gif|heic|
WAKE = threading.Event() # set by a hold: look again now instead of waiting for the interval
LAST = {} # the latest look (run_action needs the plan's date)
MEMORY = {} # what is worth remembering across looks: the cross's clip ("led_clip")
MEMORY_FILE = "ready-memory.json"
class ReadyError(Exception):
@@ -202,9 +201,12 @@ def pick_plan(plans, now):
return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None)
def pco_plan(rc, now):
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}."""
def pco_plan(rc, now, service_type=None, team=True):
"""This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.
`service_type` reads another campus's plan for the same weekend (its team is not needed)."""
auth, st = pco_login(rc)
if service_type:
st = str(service_type)
try:
future, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1)
past, _ = pco_get(auth, "/service_types/%s/plans?filter=past&order=-sort_date&per_page=2" % st, pages=1)
@@ -228,7 +230,7 @@ def pco_plan(rc, now):
out_items.append({"id": it.get("id"), "note_ids": note_ids, "title": a.get("title") or "", "type": a.get("item_type") or "",
"position": a.get("service_position") or "", "description": a.get("description") or "",
"notes": ns})
members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid))
members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) if team else ([], [])
teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"}
team = []
for m in members:
@@ -291,7 +293,7 @@ def prop_look(rc, plan):
try:
p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {}
slides = [s for g in p.get("groups") or [] for s in g.get("slides") or []]
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"),
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), "path": p.get("presentation_path"),
"timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0,
"slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "",
"text": s.get("text") or "", "notes": s.get("notes") or ""} for s in slides]}
@@ -638,6 +640,52 @@ def song_ltc(rc, plan, look):
return out
def match_in_order(na_songs, campus_songs):
"""Pair a campus's songs with the home campus's by title, keeping the home order: a campus song
whose place breaks that order (or that the home campus does not play) is skipped, that song only.
Returns ([(campus item, home song)], [skipped titles])."""
pairs, skipped, last = [], [], -1
for it in campus_songs:
idx = next((i for i, s in enumerate(na_songs) if i > last and same(it["title"], s["title"])), None)
if idx is None:
skipped.append(it["title"])
continue
pairs.append((it, na_songs[idx]))
last = idx
return pairs, skipped
def campus_ltc(rc, na_plan, na_songs, campus, plan):
"""One other campus's plan against the home timecode: its songs in the home plan's order, each
with the timecode the home set gives that song (None while the set has none) and what its LTC
note says now."""
note = rc.get("ltc_note", "LTC")
pairs, skipped = match_in_order(plan_songs(rc, na_plan), plan_songs(rc, plan))
songs = []
for it, home in pairs:
tc = next((s["tracks"] for s in na_songs if same(home["title"], s["title"])), None)
songs.append({"item": it, "title": it["title"], "tracks": tc, "pco": (it["notes"].get(note) or "").strip()})
return {"name": campus.get("name") or campus.get("short") or str(campus.get("service_type")), "short": campus.get("short") or (campus.get("name") or "?")[:2].upper(),
"service_type": str(campus.get("service_type")), "plan_id": plan["id"], "dates": plan.get("dates", ""), "songs": songs, "skipped": skipped}
def eval_campus_ltc(rc, result, line):
"""The other campus's notes must say the home timecode for every matched song."""
tag = result["short"]
for s in result["songs"]:
if s["tracks"] is None: # the home set has no timecode for it yet: the home line says so
continue
if not s["pco"]:
line.fault(ERR, "%s NO LTC %s" % (tag, short(s["title"], 12)), "%s: %s has no %s note; the tracks start it at %s" % (
result["name"], s["title"], rc.get("ltc_note", "LTC"), s["tracks"]))
elif s["pco"] != s["tracks"]:
line.fault(ERR, "%s LTC WRONG %s" % (tag, short(s["title"], 10)), "%s: %s says %s, the tracks start it at %s" % (
result["name"], s["title"], s["pco"], s["tracks"]))
if result["skipped"]:
line.notes.append("%s: not in this campus's order, left alone: %s" % (result["name"], ", ".join(result["skipped"])))
return line
def eval_ltc(rc, plan, look, line):
"""PCO's LTC note for every song must be the timecode the tracks play at that song's start."""
for s in song_ltc(rc, plan, look):
@@ -735,31 +783,181 @@ def eval_lineup(rc, plan, state):
return line
# --- Resolume (the LED cross) ------------------------------------------------------------------
# --- Resolume: the columns Sunday's slides call up -------------------------------------------
# A ProPresenter slide fires a macro (the .pro holds the macro's UUID); the macro sends RossTalk
# "CC page/row/column" to Companion; that Companion button (and the buttons it presses in turn)
# sends Resolume "/composition/columns/N/connect". Every column found that way must hold content.
def connected_clips(comp):
"""[(layer number, clip number, name)] of every clip playing in the composition (1-based, as the API wants)."""
out = []
for li, layer in enumerate(comp.get("layers") or []):
for ci, clip in enumerate(layer.get("clips") or []):
state = ((clip.get("connected") or {}).get("value") or "")
if state.startswith("Connected"):
out.append((li + 1, ci + 1, ((clip.get("name") or {}).get("value") or "")))
MACRO_SCRIPT = r"""
import json, os, subprocess
P = json.loads(%r)
def show_dir():
try:
d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"],
capture_output=True, text=True, timeout=5).stdout.strip()
if d:
return os.path.expanduser(d)
except Exception:
pass
return os.path.expanduser("~/Documents/ProPresenter")
def varint(b, i):
s = r = 0
while True:
x = b[i]; i += 1; r |= (x & 0x7f) << s
if not x & 0x80:
return r, i
s += 7
def fields(b):
out, i = [], 0
while i < len(b):
k, i = varint(b, i); num, wt = k >> 3, k & 7
if wt == 0: v, i = varint(b, i)
elif wt == 1: v, i = b[i:i + 8], i + 8
elif wt == 5: v, i = b[i:i + 4], i + 4
elif wt == 2:
n, i = varint(b, i); v = b[i:i + n]; i += n
else: raise ValueError(wt)
out.append((num, wt, v))
return out
def get(b, *path):
for p in path:
if not isinstance(b, bytes):
return None
f = [v for n, w, v in fields(b) if n == p]
if not f:
return None
b = f[0]
return b if isinstance(b, bytes) else None
macros, raw = {}, {}
for num, wt, v in fields(open(os.path.join(show_dir(), "Configuration", "Macros"), "rb").read()):
if num != 2 or wt != 2:
continue
try:
uuid = get(v, 1, 1).decode(); name = (get(v, 2) or b"").decode()
except Exception:
continue
cc = []
for n, w, act in fields(v):
comm = get(act, 32) if n == 4 and w == 2 else None
if comm is None:
continue
dev, fmt = (get(comm, 1, 2) or b"").decode(), (get(comm, 2) or b"").decode()
for n2, w2, cmd in fields(comm):
if n2 == 4 and w2 == 2:
cc.append([dev, fmt, (get(cmd, 2) or b"").decode()])
macros[uuid] = {"name": name, "cc": cc}
raw[uuid] = v
for u, v in raw.items(): # a macro that runs other macros
macros[u]["calls"] = [o for o in raw if o != u and o.encode() in v]
used = {}
for p in P["paths"]:
try:
b = open(p, "rb").read()
except OSError:
used[p] = None
continue
used[p] = [u for u in macros if u.encode() in b]
print(json.dumps({"macros": macros, "used": used}))
"""
COLUMN = re.compile(r"/composition/columns/(\d+)/connect")
LOCATION = re.compile(r"^\s*(\d+)/(\d+)/(\d+)\s*$")
PRESSES = ("button_pressrelease", "button_press", "button_release", "button_pressrelease_condition")
def eval_resolume(rc, comp, memory):
def companion_page(cfg, n):
"""One Companion page as Companion exports it (read-only)."""
comp = cfg.get("companion") or {}
url = "http://%s:%d/int/export/page/%d?includeSecrets=false" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), n)
with urllib.request.urlopen(url, timeout=6) as r:
raw = r.read()
try:
raw = __import__("gzip").decompress(raw)
except OSError:
pass
return json.loads(raw).get("page") or {}
def button_columns(get_page, location, seen=None):
"""The Resolume columns a Companion button connects, following the buttons it presses."""
seen = set() if seen is None else seen
m = LOCATION.match(location or "")
if not m or location in seen or len(seen) > 40:
return set()
seen.add(location)
page, row, col = (int(x) for x in m.groups())
ctl = (((get_page(page) or {}).get("controls") or {}).get(str(row)) or {}).get(str(col)) or {}
cols = set()
def walk(actions):
for a in actions or []:
opts = {k: (v.get("value") if isinstance(v, dict) else v) for k, v in (a.get("options") or {}).items()}
for v in opts.values():
for hit in COLUMN.findall(str(v)):
cols.add(int(hit))
if "olumn" in str(a.get("definitionId")) and str(opts.get("column", "")).isdigit():
cols.add(int(opts["column"]))
if a.get("connectionId") == "internal" and a.get("definitionId") in PRESSES:
cols.update(button_columns(get_page, str(opts.get("location", "")), seen))
for kids in (a.get("children") or {}).values():
walk(kids)
for step in (ctl.get("steps") or {}).values():
for actions in (step.get("action_sets") or {}).values():
walk(actions)
return cols
def macro_columns(macros, uuid, get_page, seen=None):
"""The columns one ProPresenter macro calls up (through the Companion buttons it presses)."""
seen = set() if seen is None else seen
if uuid in seen or uuid not in macros:
return set()
seen.add(uuid)
cols = set()
for dev, fmt, value in macros[uuid].get("cc") or []:
if fmt.strip().upper().startswith("CC"):
cols |= button_columns(get_page, value.replace(":", "/"))
for other in macros[uuid].get("calls") or []:
cols |= macro_columns(macros, other, get_page, seen)
return cols
def column_has_content(comp, n):
"""Column n (1-based) holds a clip on some layer that a column trigger plays."""
for layer in comp.get("layers") or []:
clips = layer.get("clips") or []
if n - 1 < len(clips):
c = clips[n - 1]
ignore = (c.get("ignorecolumntrigger") or {}).get("value") # "Layer Determined" / "On" / "Off"
if ((c.get("connected") or {}).get("value") or "Empty") != "Empty" and ignore not in ("On", True):
return True
return False
def eval_columns(rc, prop, found, get_page, comp):
"""Every Resolume column the plan's ProPresenter items call up holds content, on the right deck."""
line = Line()
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
want = rc.get("resolume_deck", "")
if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want):
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s" % (((deck or {}).get("name") or {}).get("value"), want))
playing = connected_clips(comp)
if not playing:
line.fault(ERR, "NOTHING ON", "no clip is playing in %s" % ((comp.get("name") or {}).get("value") or "the composition"))
else:
memory["led_clip"] = playing[0][2]
line.info = short(playing[0][2], 30)
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % (
((deck or {}).get("name") or {}).get("value"), want))
macros, used = found.get("macros") or {}, found.get("used") or {}
wanted = {} # column -> [items that call it up]
for it in prop.get("items") or []:
path = (it.get("pres") or {}).get("path")
for uuid in used.get(path) or []:
for col in macro_columns(macros, uuid, get_page):
wanted.setdefault(col, []).append(it["name"])
columns = comp.get("columns") or []
for col in sorted(wanted):
name = ((columns[col - 1].get("name") or {}).get("value") if col <= len(columns) else "") or ""
who = ", ".join(dict.fromkeys(wanted[col]))
if col > len(columns):
line.fault(ERR, "COL %d MISSING" % col, "%s call up column %d; Resolume has %d columns" % (who, col, len(columns)))
elif not column_has_content(comp, col):
line.fault(ERR, "COL %d EMPTY" % col, "column %d (%s) is empty in Resolume; %s call it up" % (col, name, who))
line.info = "%d COLUMNS OK" % len(wanted) if wanted else "NO COLUMNS USED"
return line
@@ -801,51 +999,32 @@ class Line:
"""One checklist line: its faults [(level, msg, detail)] and, when it is fine, what to show."""
def __init__(self):
self.faults, self.info = [], ""
self.faults, self.info, self.notes = [], "", []
def fault(self, level, msg, detail=""):
if all(m != msg for _, m, _ in self.faults):
self.faults.append((level, msg, detail))
def as_dict(self):
return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d],
return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d] + list(self.notes),
"info": self.info}
def load_memory(data_dir):
try:
return json.load(open(os.path.join(data_dir, MEMORY_FILE))) if data_dir else {}
except (OSError, ValueError):
return {}
def save_memory(data_dir, memory):
if not data_dir:
return
try:
tmp = os.path.join(data_dir, MEMORY_FILE + ".tmp")
with open(tmp, "w") as f:
json.dump(memory, f)
os.replace(tmp, os.path.join(data_dir, MEMORY_FILE))
except OSError as e:
log.warning("%s: %s", MEMORY_FILE, e)
def look(cfg, now=None, data_dir=None, sources=None):
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
`sources` replaces the readers (tests)."""
rc = cfg.get("ready") or {}
now = now or time.time()
src = dict(pco=lambda: pco_plan(rc, now), prop=lambda plan: prop_look(rc, plan),
src = dict(pco=lambda: pco_plan(rc, now), pco_campus=lambda st: pco_plan(rc, now, st, team=False), prop=lambda plan: prop_look(rc, plan),
ableton=lambda plan: ableton_look(cfg, rc, plan),
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6),
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
companion=lambda n: companion_page(cfg, n),
prop_bundle=lambda: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
{"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
src.update(sources or {})
lines = {}
if not MEMORY:
MEMORY.update(load_memory(data_dir))
def failed(names, err):
msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED"
@@ -862,22 +1041,54 @@ def look(cfg, now=None, data_dir=None, sources=None):
("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True),
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host"))) if on], e)
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")),
("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e)
if plan is not None:
prop = None
if rc.get("prop_url"):
try:
lines.update(eval_prop(rc, plan, src["prop"](plan)))
prop = src["prop"](plan)
lines.update(eval_prop(rc, plan, prop))
except Exception as e: # noqa: BLE001
failed(["prop", "timecode", "backgrounds"], e)
failed(["prop", "timecode", "backgrounds"] + (["led_cross"] if rc.get("resolume_url") and rc.get("prop_host") else []), e)
if rc.get("resolume_url") and rc.get("prop_host") and prop is not None:
try:
pages = {}
def get_page(n):
if n not in pages:
try:
pages[n] = src["companion"](n)
except (OSError, ValueError):
pages[n] = {}
return pages[n]
paths = sorted({(it.get("pres") or {}).get("path") for it in prop["items"]} - {None})
lines["led_cross"] = eval_columns(rc, prop, src["macros"](paths), get_page, src["resolume"]())
except Exception as e: # noqa: BLE001
failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE"))
if rc.get("tracks_host"):
try:
al = src["ableton"](plan)
lines.update(eval_ableton(rc, plan, al))
if rc.get("ltc_note", "LTC"):
eval_ltc(rc, plan, al, lines.setdefault("timecode", Line()))
LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]})
for s in song_ltc(rc, plan, al)]
na_songs = song_ltc(rc, plan, al)
LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in na_songs]
LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"]
others = []
for campus in (rc.get("ltc_campuses") or []) if na_songs else []: # nothing to give the others yet
if not campus.get("service_type"):
continue
try:
cplan = src["pco_campus"](campus["service_type"])
res = campus_ltc(rc, plan, na_songs, campus, cplan)
eval_campus_ltc(rc, res, lines["timecode"])
others.append(dict(res, songs=[dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) for s in res["songs"]]))
except Exception as e: # noqa: BLE001 - one campus's plan down must not hide the home one
tag = campus.get("short") or (campus.get("name") or "?")[:2].upper()
msg = str(e) if isinstance(e, ReadyError) else "PCO UNREACHABLE"
lines["timecode"].fault(WARN, "%s %s" % (tag, msg), "%s: %s" % (campus.get("name") or tag, e))
LAST["ltc_other"] = others
if rc.get("locations"):
lines["track_bundle"] = eval_track_bundle(rc, al)
except Exception as e: # noqa: BLE001
@@ -895,14 +1106,6 @@ def look(cfg, now=None, data_dir=None, sources=None):
lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon")))
except Exception as e: # noqa: BLE001
failed(["prop_bundle"], e)
if rc.get("resolume_url"):
try:
before = dict(MEMORY)
lines["led_cross"] = eval_resolume(rc, src["resolume"](), MEMORY)
if MEMORY != before:
save_memory(data_dir, MEMORY)
except Exception as e: # noqa: BLE001
failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE"))
out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}}
if plan is not None:
out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]}
@@ -943,7 +1146,7 @@ class Poller(threading.Thread):
def write_ltc(rc, songs, service_type, plan_id):
"""Write the tracks' timecode into PCO's LTC note of every song where it is missing or different."""
todo = [s for s in songs if s["pco"] != s["tracks"]]
todo = [s for s in songs if s["tracks"] and s["pco"] != s["tracks"]]
if not todo:
return True
if not (service_type and plan_id):
@@ -998,18 +1201,14 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
log.info("FIX Resolume deck %s selected", want)
sleep(1)
comp = http_json(base, timeout=6) or {}
if connected_clips(comp):
return True
name = rc.get("led_clip") or MEMORY.get("led_clip") # set in config, else the last one seen playing
for li, layer in enumerate(comp.get("layers") or []):
for ci, clip in enumerate(layer.get("clips") or []):
if name and ((clip.get("name") or {}).get("value") or "") == name:
http_json("%s/layers/%d/clips/%d/connect" % (base, li + 1, ci + 1), method="POST", timeout=6)
log.info("FIX LED cross: %s playing again (layer %d clip %d)", name, li + 1, ci + 1)
return True
return "NO CLIP KNOWN"
return True # an empty column is filled by hand in Resolume
if action == "ltc_to_pco":
return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
result = write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
for other in LAST.get("ltc_other") or []:
r = write_ltc(rc, other["songs"], other["service_type"], other["plan_id"])
if r is not True and result is True:
result = "%s: %s" % (other["short"], r)
return result
if action == "copy_tracks":
plan = LAST.get("plan")
if not plan:
Binary file not shown.
Binary file not shown.
+126 -18
View File
@@ -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
@@ -90,7 +91,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,8 +112,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("auto_fix.while_muted", "list", [], "Auto-fixes that keep running inside the window even while ALARMS is "
"muted (harmless ones, like playing the Spotify playlist).", "Schedules")
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,
@@ -128,6 +132,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")
@@ -173,6 +190,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")
@@ -201,6 +220,7 @@ field("fixes.*.reconnect", "bool", False, "Reconnect the Companion connection af
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.*.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)
@@ -218,7 +238,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)
@@ -232,6 +261,10 @@ field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARM
options=["master", "service", "network", "ready"], apply="pages")
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")
@@ -262,12 +295,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")
@@ -297,6 +332,8 @@ 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")
@@ -310,6 +347,8 @@ field("ready_page.back_pos", "list", None, "Where the 'back to Status' key sits
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")
@@ -324,6 +363,11 @@ field("ready.pco_module", "str", "pco-plan", "The ProdDash module whose Planning
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 campuses that play the same songs: their PCO plans get this campus's "
"timecode too, song by song in this campus's order (a song out of order is left alone).", "Ready")
field("ready.ltc_campuses[].name", "str", None, "The campus.", "Ready", required=True)
field("ready.ltc_campuses[].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)
@@ -347,7 +391,8 @@ field("ready.lineup_teams", "list", ["Band", "Communication"], "PCO teams whose
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.led_clip", "str", "", "The cross background a hold plays; blank = the last one seen playing.", "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.", "Ready")
field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True)
@@ -363,7 +408,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."},
@@ -371,7 +416,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."},
@@ -383,6 +428,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,
@@ -391,7 +438,7 @@ 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."},
@@ -402,13 +449,13 @@ CHECKS = {
"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": [],
"netgear_poe": ["switch", "port", "mac"], "hyperdeck": ["verify_seconds", "off_seconds", "hung_note"],
"spotify": ["playlist"], "ready": ["action"],
"spotify": ["playlist"], "ready": ["action"], "companion_press": ["page", "row", "col"],
}
EXPECTED_MODULE = {t: spec["module"] for t, spec in CHECKS.items() if spec["module"]}
@@ -432,7 +479,7 @@ GLOSSARY = {
"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", "NO CLIP KNOWN", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
"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": [
@@ -442,7 +489,8 @@ 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",
],
}
@@ -478,6 +526,8 @@ def key_record():
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."):
@@ -568,6 +618,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")
@@ -621,10 +681,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)
@@ -640,6 +718,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:
@@ -648,11 +749,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)
for f in a.get("while_muted", []):
if f not in a.get("fixes", []):
errors.append("auto-fix: %s runs while ALARMS is muted but is not an auto-fix" % 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:
@@ -752,6 +853,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:
@@ -804,11 +910,13 @@ 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 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"),
}
+580
View File
@@ -0,0 +1,580 @@
"""Auto-setup: what a campus has (settings/discover.py's inventory) becomes its keys, checks, fixes
and page layout - the same building blocks North Attleboro's were made of by hand.
Pure functions, no I/O:
build(base, inv, campus, pin) a whole config for a new campus, from the empty template
extend(cfg, inv, seen) a running campus: only what is new (never removes anything)
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 campus, 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 campus: 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"]]
start = {"template": "mac_start", "params": {"host": "ingest", "process": a["process"], "app": app_path},
"label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]}
again = {"template": "mac_restart", "params": {"host": "ingest", "process": a["process"], "app": app_path, "wait_seconds": 10, "force": True},
"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}]
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["also"].append(ident)
continue
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "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 for alias, sessions in (inv.get("midi") or {}).items() if alias for sname in (sessions or {})}
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"))
ip = alias and next((i for i in comps[alias].get("ips") or [] 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(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:
other = owner.get(c["peer"]) or computer_by_ip(inv, ip_of(c["address"])) 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"])
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 campus: 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 campus 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 campus: 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 campus. 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"]))
return words
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") or (f.get("template") in ("mac_start", "win_start", "ableton")
and not (f.get("params") or {}).get("restart")):
out.append(fid)
return out
+37 -2
View File
@@ -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)
@@ -221,6 +228,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 +277,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 +322,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 +394,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 []:
+543
View File
@@ -0,0 +1,543 @@
"""A campus that sets itself up.
On the campus's Mac that runs Companion, one line installs the panel from another campus's settings
page and runs `setup`: Companion gets the panel's custom variables and blink clock, two free pages
are picked, the gear is found (settings/discover.py) and turned into keys (settings/autoconf.py),
and that config is applied like any Apply on the page. Each other computer runs one join line
(Terminal on a Mac, an administrator PowerShell on a PC); from then on the settings page looks again
every ten minutes and adds what is new by itself (Settings > Site: "Add new gear by itself").
python3 -m settings.campus setup --id ab [--name "Apollo Beach"]
python3 -m settings.campus look what it finds (nothing changes)
python3 -m settings.campus add add what is new now
"""
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 campus 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):
"""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)
return ("This Companion is older than version 5 (Woonsocket ran 4.0.3). The panel needs 5.0.7, as North "
"Attleboro runs. Upgrade it from NA's copy (its settings come along; the old version stays installed):\n"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.campus upgrade-companion\n"
"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):
"""The first setup of a campus (or, when it is set up already, a look for what is new)."""
from settings import server, campuses
if home: # where it was installed from: the campus list lives there
data = campuses.load(data_dir)
data["home"] = home
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 campus"))
return look_and_add(site, say=say, force=True)
name = name or CAMPUSES.get(cid) or cid.upper()
problem = companion_version_problem(cfg)
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):
"""A campus'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"])
new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), (cfg["page"], rp))
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", "")))
say("Keys: %s" % ", ".join(t["label"] for t in new["tiles"] if not t.get("kind")))
say("Restart page %s: %s" % (rp, ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet"))
return new
def upgrade_companion(base, say=print):
"""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"
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.")
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 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):
"""Why auto-setup may not apply right now, or None: never during a service or a recording."""
now = now or time.time()
live, why = applier.live()
if live:
return "live now (%s)" % why
if 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 campus 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)
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 campus'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/campus/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.campus 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
echo
echo "Next, on each other computer here (ProPresenter, Tracks, Lighting...):"
echo " Mac: open Terminal and paste curl -fsSL http://$(ipconfig getifaddr en0 2>/dev/null || hostname):8020/join | sh"
echo " PC: open PowerShell as administrator and paste irm http://$(ipconfig getifaddr en0 2>/dev/null || hostname):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
mkdir -p "$HOME/.ssh" && touch "$HOME/.ssh/authorized_keys" && chmod 700 "$HOME/.ssh" && chmod 600 "$HOME/.ssh/authorized_keys"
grep -qF "$KEY" "$HOME/.ssh/authorized_keys" || echo "$KEY" >> "$HOME/.ssh/authorized_keys"
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 "$(id -un)")\\",\\"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}
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)
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 campus was installed from (the campus list lives there)")
a = ap.parse_args(argv)
if a.what == "setup":
setup(a.data_dir, a.id, a.name, home=a.home)
elif a.what == "rebuild":
rebuild(a.data_dir)
elif a.what == "upgrade-companion":
upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"))
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()
+434
View File
@@ -0,0 +1,434 @@
"""Every campus's status, on North Attleboro's Companion only.
Each other campus's settings page tells North Attleboro's (the hub: a campus with no "home") where it
is, and learns nothing back: no other campus ever sees another campus (2026-10-20, the user: "NA
should be the only one that sees every single location's status"). Only the hub, every two seconds,
copies each campus's key states (what that campus's watchdog draws, /api/state) into its own
Companion as custom variables sp_state_<id> / sp_hb_<id>, and keeps two kinds of page there:
CAMPUSES page one key per campus: that campus's ALL key, with its name; tap = its status page
CAMPUS page whichever campus was tapped, as its own status page looks (same places, same
colours, live), view only: holds and presses are for that campus's own deck.
The ALARMS corner goes back to the CAMPUSES page.
Two pages, whatever the number of campuses (NA's Companion has 97 pages of its own; Companion stops
at 99): the CAMPUS page's keys are cells ("c_1_3"), and the hub fills them from the tapped campus's
layout (sp_view says which). A campus that stops answering goes purple. Pages are only written
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.
"""
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 campus", "url": url or my_url(cfg),
"user": pwd.getpwuid(os.getuid()).pw_name}
def layout_sha(cfg):
keys = [(t.get("id"), t.get("label"), t.get("pos"), t.get("kind"), t.get("members")) for t in cfg.get("tiles", [])]
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables")], sort_keys=True, default=str).encode()).hexdigest()[:16]
def state_now(site):
"""What /api/state answers: this campus'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"}
SELECT = "sp_view"
VIEW_CMD = "sp_view_cmd" # a tap or hold on the CAMPUS page, passed on to that campus's own watchdog
MODE = "sp_view_mode" # "" = that campus's status page, "RESTART" = its Restart page
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 campus'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 CAMPUS page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
that campus (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
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 'campus view %d/%d' - the tapped campus's key here. Press: acknowledge it there. "
"Hold %g s: fix it there, like a hold on that campus's own deck." % (r, c, hold_ms / 1000.0)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [tap], str(hold_ms): [hold]},
"options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []})
back = make_page.static_button("campus_back", "ALL\\nSITES", 0x37474F, 0xFFFFFF, overview_page,
"Tap: back to the CAMPUSES page (page %d)." % overview_page, "STATUS jump key")
for layer in back["style"]["layers"]:
if layer["type"] == "text": # which campus this page shows, then the way back
layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True)
make_page.place(controls, (3, 0), back)
return make_page.page_export("%d\\nCampus" % page, page, controls, local_cfg)
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 CAMPUSES key: that campus'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 'campus %s' - %s's ALL key. %s" % (cid, name, note)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
"localVariables": []}
def build_overview(local_cfg, campuses, page, view_page):
"""Row 0: each campus's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page)."""
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 campus'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)
return make_page.page_export("%d\\nCampuses" % page, page, controls, local_cfg)
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) 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 campuses")
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["CAMPUSES"]})
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")
def sync_registry(self):
"""A campus: 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 campus's layout (when it changed), the CAMPUS page once, the CAMPUSES 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 campus 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 pass_on(self, pick, others, restart=False):
"""A tap or hold on the CAMPUS page goes to the shown campus's watchdog, through its Companion."""
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("CAMPUS %s: %s (from %s's CAMPUS page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
except OSError as e:
log.warning("CAMPUS %s: could not pass on %s: %s", pick, cmds, e)
def poll(self):
"""The hub: copy each other campus'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)
# The CAMPUS page shows the tapped campus: its keys moved onto their cells.
pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it
if pick not in others and others:
# Nothing tapped yet (or a campus that left): show the first one rather than a purple page.
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 campus_command(keys, text, now, restart=False):
"""The CAMPUS page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that campus's own
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)
+324
View File
@@ -0,0 +1,324 @@
"""Auto-setup's eyes: what this campus 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 campus 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 campus 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
+5
View File
@@ -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":
@@ -185,6 +187,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:
+3 -1
View File
@@ -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:
+193 -2
View File
@@ -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):
@@ -286,7 +351,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])
@@ -619,6 +686,53 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
if path == "/where.html":
return self.send(200, keyguide.where_things_are(site.load(), site.data_dir, self.my_address()), "text/html; charset=utf-8")
# ---- auto-setup: the install line for a new campus, the join lines for its computers ----
if path == "/campus/install":
key, _ = scripts.public_key(site.load())
return self.send(200, campus.install_script(self.base_url(), q.get("id", ["new"])[0], key), "text/plain; charset=utf-8")
if path == "/campus/program.tgz":
return self.send(200, campus.program_kit(site.data_dir), "application/gzip")
if path == "/campus/companion.zip":
# Companion 5.0.7 as this Mac has it, for `settings.campus upgrade-companion` on a campus
# 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")
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 campus only ever lists itself.
data = campuses.load(site.data_dir)
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if not data.get("home") else {},
"error": data.get("error"), "hub": not data.get("home")})
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})
if path == "/api/config":
cfg = site.load()
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
@@ -629,6 +743,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":
@@ -767,6 +894,43 @@ 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 campus 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)
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 campus) 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":
@@ -783,14 +947,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()
@@ -1201,6 +1383,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 campuses: 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"
@@ -1312,6 +1501,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="campuses", daemon=True).start()
try:
while True:
time.sleep(3600)
+556 -73
View File
@@ -77,12 +77,15 @@
async function loadAll() {
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
// What this page's draft was made from: a save carries it, so the Mac can put the change on top of
// settings that moved on meanwhile (an Apply elsewhere, a program update) instead of undoing them.
if (state.draft && !state.draft.base && !d.stale) { state.draft.base = c.sha; state.draft.baseConfig = deep(c.config); }
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
badge();
}
function ensureDraft() {
if (!needEditing()) return false;
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name };
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name, base: state.liveSha, baseConfig: deep(state.live) };
return true;
}
function changed() {
@@ -110,8 +113,15 @@
state.saving = new Promise((done) => {
state.saveTimer = setTimeout(async () => {
try {
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version, base: state.draft.base, base_config: state.draft.base ? state.draft.baseConfig : undefined }, "PUT");
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
if (r.rebased) { // the settings moved on since this page loaded: keep both
const c = await api("/api/config");
state.live = c.config; state.liveSha = c.sha;
state.draft.config = r.draft.config; state.draft.base = c.sha; state.draft.baseConfig = deep(c.config);
badge(); route();
alertBox("The settings changed on the Mac since this page loaded (an Apply elsewhere, or a program update). Your changes were put on top of the new settings; nothing of either was lost.");
} else if (r.live_sha) { state.draft.base = r.live_sha; }
} catch (e) {
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
@@ -141,7 +151,7 @@
if (f.type === "dict") { try { return raw.trim() ? JSON.parse(raw) : undefined; } catch (e) { return undefined; } }
return raw === "" ? undefined : raw;
}
const LABELS = { editing_code: "PIN", ssh: "log-in address", mac: "hardware address", ingest_ips: "this Mac's addresses", ingest_name: "this Mac's name", ingest_hostname: "network name", hold_always: "hold also when green", page_hold_ms: "hold time (ms)", optional: "may have no disk" };
const LABELS = { auto: "add new gear by itself", after_seconds: "wait before acting (seconds)", note_seconds: "keep showing ON … after (seconds)", interval_seconds: "look every (seconds)", failures: "misses before it counts", debounce_seconds: "a fault must last (seconds)", editing_code: "PIN", ssh: "log-in address", mac: "hardware address", ingest_ips: "this Mac's addresses", ingest_name: "this Mac's name", ingest_hostname: "network name", hold_always: "hold also when green", page_hold_ms: "hold time (ms)", optional: "may have no disk" };
function inputHtml(path, f, value, extra) {
if (!f) return "";
const id = "f-" + path.replace(/[^A-Za-z0-9]/g, "-");
@@ -156,6 +166,36 @@
const dflt = f.default != null && typeof f.default !== "object" && f.default !== "" ? ` Default ${f.default}.` : "";
return `<div class="field"><span class="fl">${esc(LABELS[last] || last.replace(/_/g, " "))}${f.required ? " *" : ""}${hint((f.help || "") + dflt)}</span>${html}${extra || ""}</div>`;
}
// An On/Off switch like Companion's connection toggle: switched off = kept in the settings, not used.
// `path` is the object ("tiles.3", "fixes.obs", "tiles.3.checks.1"); off writes "disabled": true there.
function onOff(path, obj, what) {
const on = !(obj && obj.disabled);
return `<label class="onoff${on ? " on" : ""}" data-hint="${esc(on ? `On. Switch off to keep ${what || "it"} here without using it.` : `Off: kept, not used. Switch on to use ${what || "it"} again.`)}"><input type="checkbox" data-onoff="${esc(path)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}><span>${on ? "ON" : "OFF"}</span></label>`;
}
function wireOnOff(root) {
for (const el of root.querySelectorAll("[data-onoff]")) {
el.closest("label").onclick = (e) => e.stopPropagation(); // inside a <summary>: do not fold it
el.onchange = () => { setPath(el.dataset.onoff + ".disabled", el.checked ? undefined : true); route(); };
}
for (const el of root.querySelectorAll("[data-danteoff]")) {
el.closest("label").onclick = (e) => e.stopPropagation();
el.onchange = () => {
if (!ensureDraft()) return;
const ch = danteCheck(state.draft.config), n = el.dataset.danteoff, offs = new Set(ch.devices_off || []);
el.checked ? offs.delete(n) : offs.add(n);
if (offs.size) ch.devices_off = [...offs]; else delete ch.devices_off;
saveDraft(); route();
};
}
for (const el of root.querySelectorAll("[data-connoff]")) {
el.closest("label").onclick = (e) => e.stopPropagation();
el.onchange = () => {
if (!ensureDraft()) return;
for (const [, , ch] of connChecks(state.draft.config, el.dataset.connoff)) { if (el.checked) delete ch.disabled; else ch.disabled = true; }
saveDraft(); route();
};
}
}
function wireInputs(root) {
for (const el of root.querySelectorAll("[data-path]")) {
if (el.onchange) continue;
@@ -169,9 +209,9 @@
function fieldFor(path) {
const F = state.schema.fields;
if (F[path]) return F[path];
const generic = path.replace(/^tiles\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^tiles\.\d+\./, "tiles[].")
const generic = path.replace(/^(tiles|ready_page\.keys)\.\d+\.checks\.\d+\./, "tiles[].checks[].").replace(/^(tiles|ready_page\.keys)\.\d+\./, "tiles[].")
.replace(/^restart_page\.keys\.\d+\./, "restart_page.keys[].").replace(/^fixes\.[^.]+\./, "fixes.*.").replace(/^hosts\.[^.]+\./, "hosts.*.")
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|site\.live_services|service_windows)\.\d+\./, "$1[].");
.replace(/^gear\.[^.]+\./, "gear.*.").replace(/^(auto_fix\.windows|failover\.windows|site\.live_services|service_windows|ready\.due)\.\d+\./, "$1[].");
return F[generic] || null;
}
const F = (name) => state.schema.fields[name];
@@ -206,6 +246,7 @@
const fn = screens[name] || overview;
await fn(rest);
wireInputs(main);
wireOnOff(main);
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
@@ -248,7 +289,7 @@
<div>${deckHtml(o.deck, cfg(), "status")}
<div class="row" style="margin-top:10px">
<button id="restartwd" ${dis} data-hint="${w.running ? "Restart the watchdog: the deck goes purple for about 5 s; a running fix finishes first" : "Start the watchdog"}">${w.running ? "Restart watchdog" : "Start watchdog"}</button>
<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms back on now" : "Same as the ALARMS key: quiet until midnight"}">${o.service_on === false ? "Alarms on" : "Silence alarms"}</button>
<button id="alarms" ${dis} data-hint="${o.service_on === false ? "Alarms and auto-fix back on now" : "Same as the ALARMS key: no alarms and no auto-fix until midnight"}">${o.service_on === false ? "Alarms + auto-fix on" : "Silence + stop auto-fix"}</button>
${w.running ? `<button id="stopwd" ${dis} data-hint="Stop the watchdog: every key purple, no alarms until it is started again">Stop</button>` : ""}
</div></div>
<div><div class="tablewrap"><table>${comps}</table></div></div>
@@ -261,7 +302,7 @@
}
function canRunNow(fixId) {
const f = cfg().fixes[fixId]; if (!f) return false;
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
if (["reconnect", "hyperdeck", "midi_connect", "spotify", "companion_press"].includes(f.kind)) return true;
if (f.kind === "ready") return !f.never_auto; // the READY page's fixes (not the tracks copy)
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
return false;
@@ -276,9 +317,9 @@
function deckHtml(deck, c, which, selected, draggable) {
const cells = {};
const rp = c.restart_page, dp = c.ready_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else if (which === "ready" && dp) { for (const k of dp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[dp.back_pos] = { nav: "STATUS\nPAGE", back: "dback:" }; }
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label, off: k.disabled }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else if (which === "ready" && dp) { for (const k of dp.keys) cells[k.pos] = { id: k.id, label: k.label, off: k.disabled }; cells[dp.back_pos] = { nav: "STATUS\nPAGE", back: "dback:" }; }
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label, off: t.disabled }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : which === "ready" && dp ? dp.page : c.page), "2,0": "DOWN" };
const prefix = { restart: "rkey:", ready: "dkey:" }[which] || "tile:";
const route_ = { restart: "restart/", ready: "ready/" }[which] || "";
@@ -292,7 +333,7 @@
const s = deck && deck[cell.id];
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
const blink = s && s.b !== s.b2 ? " blink" : "";
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}"${drag(prefix + cell.id)} onclick="location.hash='#keys/${route_}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}${cell.off ? " off" : ""}" style="${style}"${drag(prefix + cell.id)} onclick="location.hash='#keys/${route_}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
} else out += `<div class="key empty"${editing() && draggable ? ` data-drop="${r},${col}"` : ""}></div>`;
}
return out + `</div>`;
@@ -311,20 +352,48 @@
const tabs = `<div class="tabs"><button class="${restart || ready ? "" : "on"}" onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page</button>` : ""}${c.ready_page ? `<button class="${ready ? "on" : ""}" onclick="location.hash='#keys/ready'" data-hint="The weekly worship checklist, checked against Planning Center">READY page</button>` : ""}<button onclick="location.hash='#keys/fixes'">Fixes</button>
${editing() ? `<span class="small muted" style="margin-left:auto">page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}" data-hint="The Status page's number in Companion">${c.restart_page ? ` · restart page <input class="pagenum" data-which="restart" type="number" min="1" value="${c.restart_page.page}">` : ""} <label class="inline"><input type="checkbox" id="hasrestart" ${c.restart_page ? "checked" : ""}> Restart page${hint("A second page where each key restarts one thing, even when green")}</label></span>` : ""}</div>`;
let panel = `<p class="muted small">Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}</p>`;
if (ready) { const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; panel = await readyKeyPanel(i, hold); }
if (ready) {
const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1;
panel = await readyKeyPanel(i, hold);
if (i >= 0 && editing() && c.ready_page.keys[i].kind !== "ready") panel += await keyPanel(i, hold, "ready_page.keys");
}
else if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
const tray = trayKeys.map((t) => `<button class="small"${editing() ? ` draggable="true" data-drag="tile:${esc(t.id)}"` : ""} onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
let note = "";
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `<div class="notice">Layout changed: Apply re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.</div>`; } catch (e) {} }
const adders = editing() && !ready ? (restart ? `<button id="newrkey" class="small">+ Restart key</button>` : `<button id="newkey" class="small">+ Key</button>`) : "";
const adders = editing() ? (ready ? (c.ready_page ? `<button id="newdkey" class="small">+ READY key</button>` : "") : restart ? `<button id="newrkey" class="small">+ Restart key</button>` : `<button id="newkey" class="small">+ Key</button>`) : "";
main.innerHTML = `<h1>Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")}</h1>${tabs}${note}
<div class="cols keycols"><div>${restart && !c.restart_page ? "<p class='muted'>No Restart page.</p>" : ready && !c.ready_page ? "<p class='muted'>No READY page.</p>" : deckHtml(o.deck, c, restart ? "restart" : ready ? "ready" : "status", sel, true)}
${!restart && !ready ? `<p class="small muted tray" id="tray" style="margin-top:8px">In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "<span class='muted'>none</span>"}</p>` : ""}
<div class="row" style="margin-top:8px">${adders}</div></div><div id="panel">${panel}</div></div>`;
wireKeyPanel(sel, restart || ready);
wireKeyPanel(sel, restart, ready ? "ready_page.keys" : "tiles");
wireLayout(restart);
const nd = main.querySelector("#newdkey"); if (nd) nd.onclick = () => newReadyKey();
}
// A new READY page key: a checklist line the page checks itself, a tap-to-confirm line, or any check.
function newReadyKey() {
const c = cfg();
dialog(`<h3>New READY key</h3><input id="nlabel" placeholder="label, as the key shows it (GREEN ROOM)">
<select id="nkind"><option value="manual">Tap to confirm: a person taps it done (resets every week)</option><option value="ready">A checklist line the page checks by itself</option><option value="any">Something else: I pick its checks next (a Companion connection, a ping, a program...)</option></select>
<select id="nitem" hidden>${Object.entries(READY_WORDS).map(([k, w]) => `<option value="${k}">${esc(k)} – ${esc(w.slice(0, 60))}</option>`).join("")}</select>
<input id="npos" placeholder="row, column" value="${(firstEmpty(c, "ready") || []).join(", ")}"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
const kind = b.querySelector("#nkind"), item = b.querySelector("#nitem");
kind.onchange = () => { item.hidden = kind.value !== "ready"; };
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => {
const label = b.querySelector("#nlabel").value.trim().toUpperCase();
if (!label) return alertBox("Give it a label.");
const pos = parsePos(b.querySelector("#npos").value, cfg(), "ready", false); if (!pos) return;
if (!ensureDraft()) return;
const keys = state.draft.config.ready_page.keys;
let id = "rd_" + (slug(label) || "key"), n = 2; while (keys.some((k) => k.id === id) || state.draft.config.tiles.some((t) => t.id === id)) id = "rd_" + slug(label) + "_" + n++;
const checks = kind.value === "manual" ? [{ type: "ready_manual", item: slug(label) || id }]
: kind.value === "ready" ? [{ type: "ready", item: item.value, fix: "ready_recheck" }] : [];
keys.push({ id, label, pos, checks }); saveDraft(); closeDialog(); location.hash = "#keys/ready/" + id;
};
});
}
function takenCells(c, which) {
const taken = new Set(["0,0", "1,0", "2,0"]);
@@ -416,28 +485,40 @@
}); };
}
async function keyPanel(i, hold) {
const c = cfg(), t = c.tiles[i];
// A Status page tile ("tiles") or a READY page key ("ready_page.keys"): the same editor for both.
const keyList = (c, list) => (list === "ready_page.keys" ? (c.ready_page || { keys: [] }).keys : c.tiles);
const READY_ONLY = ["ready", "ready_manual"];
async function keyPanel(i, hold, list = "tiles") {
const c = cfg(), t = keyList(c, list)[i], onReady = list !== "tiles";
const k = await api("/api/keys");
const kt = k.tiles.find((x) => x.id === t.id) || { says: [], holding: "", checks: [] };
// A Companion connection is picked from the ones Companion has (typing still works).
const cc = editing() ? await api("/api/companion").catch(() => ({})) : {};
const connList = `<datalist id="connlist">${(cc.connections || []).map((r) => `<option value="${esc(r.label)}">${esc(r.module || "")}</option>`).join("")}</datalist>`;
const fixOptions = (cur) => `<option value="">none</option><option value="restart-connection" ${cur === "restart-connection" ? "selected" : ""}>reconnect its Companion connection</option><option value="dante-auto" ${cur === "dante-auto" ? "selected" : ""}>Dante auto (PoE port or the Dante module)</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${cur === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}`;
const short = (text, n) => { text = String(text || ""); return text.length > n ? text.slice(0, n - 1) + "…" : text; };
let lastQ = null;
const checks = (t.checks || []).map((chk, j) => {
const spec = state.schema.checks[chk.type] || { fields: [], question: "" };
const sentence = (kt.checks[j] || {}).sentence || chk.type;
const what = chk.type === "midi_peer" ? `${chk.peer || "?"} <span class="muted">in ${esc(chk.session || "?")}</span>` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40));
const what = chk.type === "midi_peer" ? `${chk.peer || "?"} <span class="muted">in ${esc(chk.session || "?")}</span>` : chk.type === "midi_wired" ? `every MIDI peer wired <span class="muted">${esc((chk.nets || []).join(", "))}</span>` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40));
const q = spec.question || chk.type;
const header = q !== lastQ ? `<div class="chkgroup">${esc(q)}</div>` : ""; lastQ = q;
const summary = `<summary><b>${what}</b>${hint(sentence)}</summary>`;
const summary = `<summary>${onOff(`${list}.${i}.checks.${j}`, chk, "this check")} <b class="${chk.disabled ? "muted" : ""}">${what}</b>${hint(sentence)}</summary>`;
if (!editing()) return `${header}<details class="chk">${summary}<p class="small">${esc(sentence)}</p></details>`;
const base = `tiles.${i}.checks.${j}.`;
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
const base = `${list}.${i}.checks.${j}.`;
let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => f === "conn"
? inputHtml(base + f, F("tiles[].checks[]." + f), chk[f], connList).replace("<input ", '<input list="connlist" ')
: f === "item" && chk.type === "ready"
? `<div class="field"><span class="fl">checklist line${hint("What the READY page looks at for this key")}</span><select data-path="${base}item"><option value="">–</option>${Object.entries(READY_WORDS).map(([k2, w]) => `<option value="${k2}" ${chk.item === k2 ? "selected" : ""}>${esc(k2)} – ${esc(w.slice(0, 70))}</option>`).join("")}</select></div>`
: f === "item" && chk.type === "ready_manual"
? inputHtml(base + f, Object.assign({}, F("tiles[].checks[].item"), { help: "A short name for what is tapped done, like green_room (its tap is remembered by this name for the week)" }), chk[f])
: inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("");
if (chk.type === "remote") {
const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]);
const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName);
fields = `<div class="field"><span class="fl">built as</span><select class="ctplsel" data-check="${j}">${ctpls.map((x) => `<option value="${x.name}" ${x.name === ctName ? "selected" : ""}>${esc(x.words)}</option>`).join("")}</select></div><div class="ctplform" data-check="${j}">${paramsHtml(ct, chk.params || {}, "ck" + j)}</div>`
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `<details><summary>the command</summary><pre class="log small">${esc(chk.cmd)}</pre></details>` : ""));
+ (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + inputHtml(base + "interval_seconds", F("tiles[].checks[].interval_seconds"), chk.interval_seconds) + (chk.cmd ? `<details><summary>the command</summary><pre class="log small">${esc(chk.cmd)}</pre></details>` : ""));
}
const fixSel = typeof chk.fix === "object" && chk.fix ? `<div class="field"><span class="fl">fix per device${hint('A table: device → fix, "*" for everything else')}</span><textarea data-path="${base}fix" rows="2" class="fixtable mono">${esc(JSON.stringify(chk.fix))}</textarea></div>`
: `<div class="field"><span class="fl">fix${hint("What holding the key runs for this check")}</span><select data-path="${base}fix" class="fixsel">${fixOptions(chk.fix)}</select></div>`;
@@ -446,33 +527,35 @@
<details><summary>more</summary>${["tag", "service_only", "debounce_seconds"].filter((f) => !(spec.fields || []).includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join("")}<p class="mono small">type ${esc(chk.type)}</p></details>
<div class="row">${run}<button class="small danger" data-removecheck="${j}">Remove</button></div></div></details>`;
}).join("");
const plain = !t.members && t.kind !== "master";
const members = t.members ? (editing() ? `<div class="field"><span class="fl">group members</span><span>${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => `<label class="inline"><input type="checkbox" data-member="${esc(x.id)}" ${t.members.includes(x.id) ? "checked" : ""}> ${esc(x.label)}</label>`).join(" ")}</span></div>` : `<p class="small"><b>Sums up</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>`) : "";
const addMenu = editing() && plain ? `<select id="addcheck" class="small" style="margin-top:8px"><option value="">+ check…</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select>` : "";
const label = editing() ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
const holdAlways = editing() && plain ? `<div class="field"><span class="fl">hold also when green${hint("A fix that runs on every hold, even with nothing failing")}</span><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${t.hold_always === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select></div>` : "";
const plain = !t.members && t.kind !== "master" && t.kind !== "ready";
const members = t.members && !onReady ? (editing() ? `<div class="field"><span class="fl">group members</span><span>${c.tiles.filter((x) => x.id !== t.id && !x.members && x.kind !== "master").map((x) => `<label class="inline"><input type="checkbox" data-member="${esc(x.id)}" ${t.members.includes(x.id) ? "checked" : ""}> ${esc(x.label)}</label>`).join(" ")}</span></div>` : `<p class="small"><b>Sums up</b> ${t.members.map((m) => `<a href="#keys/${esc(m)}">${esc((c.tiles.find((x) => x.id === m) || {}).label || m)}</a>`).join(", ")}</p>`) : "";
const addMenu = editing() && plain ? `<select id="addcheck" class="small" style="margin-top:8px"><option value="">+ check…</option>${Object.entries(groupBy(state.schema.checks, (s) => s.question || "Other")).map(([q, types]) => `<optgroup label="${esc(q)}">${types.filter((ty) => onReady || !READY_ONLY.includes(ty)).map((ty) => `<option value="${esc(ty)}">${esc(state.schema.checks[ty].words || ty)}</option>`).join("")}</optgroup>`).join("")}</select>` : "";
const label = editing() && !onReady ? inputHtml(`tiles.${i}.label`, F("tiles[].label"), t.label) : "";
const holdAlways = editing() && plain && !onReady ? `<div class="field"><span class="fl">hold also when green${hint("A fix that runs on every hold, even with nothing failing")}</span><select data-path="tiles.${i}.hold_always" class="holdsel"><option value="">no</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${t.hold_always === id ? "selected" : ""}>${esc(f.short || id)} – ${esc(f.label || "")}</option>`).join("")}</select></div>` : "";
const holdList = (kt.holding || "").split(/;\s*/).filter(Boolean);
const more = `<details><summary>more</summary><dl class="kv small"><dt>id</dt><dd class="mono">${esc(t.id)}</dd>${t.kind ? `<dt>kind</dt><dd>${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}</dd>` : ""}</dl>${editing() && !t.kind ? `<div class="field"><span class="fl">position${hint("row, column; blank = inside its group only")}</span><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}${editing() && (!t.kind || t.kind === "network") ? `<p><button class="small danger" id="delkey">Delete key</button></p>` : ""}</details>`;
return `<div class="card keycard"><h2 style="margin:4px 0 8px">${esc(t.label)} <span class="muted small">${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}</span></h2>
const more = `<details><summary>more</summary><dl class="kv small"><dt>id</dt><dd class="mono">${esc(t.id)}</dd>${t.kind ? `<dt>kind</dt><dd>${esc({ master: "sums up every key", service: "the ALARMS switch", network: "the network" }[t.kind] || t.kind)}</dd>` : ""}</dl>${editing() && !t.kind && !onReady ? `<div class="field"><span class="fl">position${hint("row, column; blank = inside its group only")}</span><input id="pos" value="${esc(t.pos ? t.pos.join(", ") : "")}" placeholder="row, column"></div>` : ""}${editing() && (!t.kind || t.kind === "network") ? `<p><button class="small danger" id="delkey">Delete key</button></p>` : ""}</details>`;
if (onReady) return `<div class="card keycard"><h3 style="margin:4px 0 8px">${t.kind ? "" : onOff(`ready_page.keys.${i}`, t, "this key") + " "}Edit ${esc(t.label)}</h3>${checks ? `<div class="checks">${checks}</div>` : ""}${addMenu}${more}</div>`;
return `<div class="card keycard"><h2 style="margin:4px 0 8px">${t.kind ? "" : onOff(`tiles.${i}`, t, "this key") + " "}${esc(t.label)} <span class="muted small">${t.pos ? `row ${t.pos[0]}, col ${t.pos[1]}` : "in a group"}${t.disabled ? " · off: grey on the deck, checks nothing" : ""}</span></h2>
${label}${members}${holdAlways}
${holdList.length ? `<details class="fold"><summary>Hold ${esc(hold)}: ${esc(short(holdList[0], 48))}${holdList.length > 1 ? ` <span class="muted">+${holdList.length - 1}</span>` : ""}${t.hold_always ? " <span class='pill'>also when green</span>" : ""}</summary><ol class="small">${holdList.map((h) => `<li>${esc(h)}</li>`).join("")}</ol></details>` : ""}
${kt.says && kt.says.length ? `<details class="fold"><summary>Words it can show <span class="muted">${kt.says.length}</span></summary><p class="mono small">${esc(kt.says.join(" · "))}</p></details>` : ""}
${checks ? `<div class="checks">${checks}</div>` : ""}${addMenu}${more}</div>`;
}
function groupBy(obj, fn) { const out = {}; for (const [k, v] of Object.entries(obj)) (out[fn(v)] = out[fn(v)] || []).push(k); return out; }
function wireKeyPanel(sel, restart) {
function wireKeyPanel(sel, restart, list = "tiles") {
const c = cfg();
const i = restart ? -1 : c.tiles.findIndex((x) => x.id === sel);
const i = restart ? -1 : keyList(c, list).findIndex((x) => x.id === sel);
const mine = () => keyList(state.draft.config, list)[i];
for (const b of main.querySelectorAll("[data-run]")) b.onclick = () => runNow(...b.dataset.run.split("|"));
const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${c.tiles[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const id = cc.tiles[i].id; cc.tiles.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = "#keys"; });
const dk = main.querySelector("#delkey"); if (dk) dk.onclick = () => confirmBox(`Delete the key ${keyList(c, list)[i].label}? Its checks go with it.`, "Delete", () => { if (!ensureDraft()) return; const cc = state.draft.config; const keys = keyList(cc, list); const id = keys[i].id; keys.splice(i, 1); for (const t of cc.tiles) if (t.members) t.members = t.members.filter((m) => m !== id); saveDraft(); location.hash = list === "tiles" ? "#keys" : "#keys/ready"; });
const dr = main.querySelector("#delrkey"); if (dr) dr.onclick = () => confirmBox("Delete this restart key?", "Delete", () => { if (!ensureDraft()) return; const ks = state.draft.config.restart_page.keys; ks.splice(ks.findIndex((x) => x.id === sel), 1); saveDraft(); location.hash = "#keys/restart"; });
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; state.draft.config.tiles[i].checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const b of main.querySelectorAll("[data-removecheck]")) b.onclick = () => confirmBox("Remove this check from the key?", "Remove", () => { if (!ensureDraft()) return; mine().checks.splice(+b.dataset.removecheck, 1); saveDraft(); route(); });
for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); };
const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = mine(); t.checks = t.checks || []; t.checks.push(add.value === "ready" ? { type: "ready", fix: "ready_recheck" } : { type: add.value }); saveDraft(); route(); };
const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { const t0 = cfg().tiles[i]; const was = t0.pos; delete t0.pos; const p2 = parsePos(pos.value, cfg(), "status", true); if (was) t0.pos = was; if (p2 === false) return; if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; if (p2) t.pos = p2; else delete t.pos; saveDraft(); route(); };
const rebuildCheck = async (j) => {
if (!ensureDraft()) return;
const t = state.draft.config.tiles[i], chk = t.checks[j];
const t = mine(), chk = t.checks[j];
const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value;
if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; }
const ct = state.builders.checks.find((x) => x.name === tplName);
@@ -496,7 +579,7 @@
prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
backgrounds: "Every song has at least one slide with a background.",
led_cross: "Resolume is on the Service deck and a clip is playing on the cross.",
led_cross: "Every Resolume column Sunday's slides call up (slide → macro → Companion → column) holds content, and Resolume is on the Service deck.",
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
@@ -529,7 +612,7 @@
const edit = editing() ? `${inputHtml(`restart_page.keys.${i}.label`, F("restart_page.keys[].label"), r.label)}
<div class="field"><span class="fl">hold runs</span><select data-path="restart_page.keys.${i}.fix">${Object.entries(c.fixes).map(([id, fx]) => `<option value="${esc(id)}" ${r.fix === id ? "selected" : ""}>${esc(fx.short || id)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>
<div class="field"><span class="fl">flash while down${hint("Keeps flashing HOLD TO FIX while these are down; hold cmd/ctrl to pick several")}</span><select multiple size="5" class="watchsel" data-path="restart_page.keys.${i}.watch">${Object.keys(c.fixes).map((id) => `<option value="${esc(id)}" ${(r.watch || []).includes(id) ? "selected" : ""}>fix ${esc(id)}</option>`).join("")}${[...conns].sort().map((cn) => `<option value="conn:${esc(cn)}" ${(r.watch || []).includes("conn:" + cn) ? "selected" : ""}>connection ${esc(cn)}</option>`).join("")}</select></div>` : "";
return `<div class="card"><h2 style="margin-top:4px">${esc(r.label)} <span class="muted small">row ${r.pos[0]}, col ${r.pos[1]}</span></h2><p class="small"><b>Hold ${esc(hold)}</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${edit}
return `<div class="card"><h2 style="margin-top:4px">${onOff(`restart_page.keys.${i}`, r, "this key")} ${esc(r.label)} <span class="muted small">row ${r.pos[0]}, col ${r.pos[1]}</span></h2><p class="small"><b>Hold ${esc(hold)}</b> ${esc(f.label || r.fix)}, even when the Status page says it is fine.</p>${edit}
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch && r.watch.length ? `<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>` : ""}</dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete key</button></p>` : ""}</div>`;
}
@@ -546,10 +629,10 @@
if (compTab === "gear") body = await gearHtml();
else if (compTab === "setup") body = setupHtml(m, (await api("/api/setup").catch(() => ({ computers: [] }))).computers);
else {
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
const rows = m.computers.slice().sort((a, b) => (a.word === "Problem" ? 0 : 1) - (b.word === "Problem" ? 0 : 1)).map((c) => `<tr class="click${selected === c.alias ? " sel" : ""}${(cfg().hosts[c.alias] || {}).disabled ? " off" : ""}" onclick="location.hash='#computers/${esc(c.alias)}'"><td>${c.self ? "" : onOff(`hosts.${c.alias}`, cfg().hosts[c.alias], "this computer")}</td><td>${wordOf(c.word)}</td><td>${esc(c.name)}${c.self ? ' <span class="muted small">(this Mac)</span>' : ""}</td><td class="small">${esc(c.role)}</td><td class="small">${(c.keys || []).map((k) => `<span class="pill">${esc((cfg().tiles.find((t) => t.id === k) || { label: k }).label)}</span>`).join(" ")}</td><td class="muted small">${esc(c.reason)}${c.looked ? `<br>looked ${esc(ago(Date.now() / 1000 - c.looked))}` : ""}</td></tr>`).join("");
const looked = Math.max(0, ...m.computers.map((c) => c.looked || 0));
body = `<div class="row" style="justify-content:flex-end;margin-bottom:6px"><span class="muted small">${looked ? "looked " + esc(ago(Date.now() / 1000 - looked)) : "not looked yet"}</span><button id="probe" class="small" ${m.probing ? "disabled" : ""} data-hint="Log in to every computer now and refresh the lights (read-only)">${m.probing ? "Looking…" : "Check now"}</button></div>
<div class="tablewrap"><table><tr><th>status</th><th>computer</th><th>does</th><th>keys${hint("The keys that turn red if it dies")}</th><th></th></tr>${rows}</table></div>`;
<div class="tablewrap"><table><tr><th></th><th>status</th><th>computer</th><th>does</th><th>keys${hint("The keys that turn red if it dies")}</th><th></th></tr>${rows}</table></div>`;
const c = m.computers.find((x) => x.alias === selected);
if (c) body += computerCard(c);
}
@@ -575,7 +658,7 @@
<div class="field block"><span class="fl">what runs here${hint('One per line, as {"kind": ..., "name": ...}. Kinds: sshd, app (process), launchagent (label), daemon (process), task (name, exe), service (name), web (url), helper_dvs, helper_solo (label), midi_session (name, peer_of), artnet (source), folder (path), disabled (label). "watched": false = listed only.')}</span><textarea id="runs" rows="${Math.max(4, (getPath(base + "runs") || []).length + 1)}" class="mono">${esc((getPath(base + "runs") || []).map((r) => JSON.stringify(r)).join("\n"))}</textarea></div>
${c.os === "windows" ? `<button class="small" data-job="pcprogram">+ Program this PC can start</button>` : ""}</details>` : "";
const remove = editing() && !c.self ? `<p style="margin-top:12px"><button class="small danger" id="removehost" data-hint="Drops the fixes and checks that used it, and says which">Remove computer</button></p>` : "";
return `<div class="card" id="card"><h2 style="margin-top:4px">${esc(c.name)} ${wordOf(c.word)}</h2><p class="muted small">${esc(c.role)}${c.ssh ? ` · <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
return `<div class="card" id="card"><h2 style="margin-top:4px">${c.self ? "" : onOff(`hosts.${c.alias}`, cfg().hosts[c.alias], "this computer") + " "}${esc(c.name)} ${wordOf(c.word)}${(cfg().hosts[c.alias] || {}).disabled ? ' <span class="muted small">off: nothing logs in to it; its checks and fixes are skipped</span>' : ""}</h2><p class="muted small">${esc(c.role)}${c.ssh ? ` · <span class="mono">${esc(c.ssh)}</span>` : ""}${c.mac ? ` · wake-on-LAN ${esc(c.mac)}` : ""}${c.pingable === false ? " · does not answer pings" : c.ping != null ? ` · ping ${c.ping ? "OK" : "<b>no answer</b>"}` : ""}${c.error ? ` · <b>${esc(c.error)}</b>` : ""}</p>
${c.notes ? `<p class="notice grey">${esc(c.notes)}</p>` : ""}
<div class="tablewrap"><table>${items}</table></div>
${done ? `<ul class="small" style="list-style:none;padding:0">${done}</ul>` : ""}
@@ -760,6 +843,7 @@
const glossary = Object.entries(gl).filter(([g]) => !/while you hold/.test(g)).map(([g, words]) => `<details><summary><b>${esc(g)}</b></summary><p class="mono small">${esc(words.join(" · "))}</p></details>`).join("");
main.innerHTML = `<h1>Help</h1><div class="cards">${cards}</div>
<p style="margin-top:12px"><a class="btn" href="/key-guide.html" target="_blank" data-hint="One entry per key: what it checks, what holding does, every word it can show">What each key does</a> <a class="btn" href="/cheat.html" target="_blank" data-hint="One printable page: both decks, the colours, press = acknowledge, hold = fix">Cheat sheet</a> <a class="btn" href="/where.html" target="_blank" data-hint="Every file, folder, log and port per computer">Where things are</a></p>
<details open><summary><b>How do I add…</b> (everything is done here; nothing needs a terminal)</summary><ul class="small howto"><li><b>Watch a new Companion connection.</b> Edit (top right, the PIN) › Settings › Companion. Find it marked “not watched” › <b>+ Key</b>. When the Status page is full it asks which group key to put it in (AUDIO, SHOW…): its problems show on that key, and holding the key reconnects it. Then the badge › Apply.</li><li><b>Add a connection to a key that is already there.</b> Keys › click the key › <b>+ check…</b> › “A Companion connection is good” › pick the connection from the list › fix: “reconnect its Companion connection” › Apply.</li><li><b>Watch that a device is on the network.</b> Keys › the key › <b>+ check…</b> › “An address answers ping” › type the address and the name the key should show (it says “NAME NO PING”).</li><li><b>Watch a program on another computer, and restart it.</b> Keys › the key › <b>+ check…</b> › “A command on another computer succeeds” › built as “A program is running on a Mac” › pick the computer and the program (on a Windows PC: Custom, e.g. <span class=\"mono\">tasklist | find /I \"OBS.exe\"</span>). Then fix › a fix that starts it (make one first: next line).</li><li><b>Make a new fix (something a hold can do).</b> Keys › Fixes › <b>+ Fix</b> › pick how it is built (start a Windows program, restart a Mac program, restart a helper, reopen Ableton…) and fill in the fields; the command is written for you. Then choose it as a check's “fix”, and use “Run the fix” to try it.</li><li><b>Let a fix run by itself on Sundays.</b> Settings › Auto-fix › tick it, and paint the hours on the calendar. A fix that closes a running program asks first.</li><li><b>A fallback button (the stream audio switches to FOH Backup by itself when Studio B dies).</b> Keys › Fixes › <b>+ Fix</b> › built as “Press a Companion button” › page, row, column of the button in Companion (page 4, row 0, column 5 is FOH Backup) › under more, tick failover (and pick the fix that switches back as undo). Then Keys › <b>+ Key</b> (or an existing key) › <b>+ check…</b> for what must be alive (an address answers ping, a program is running on a Mac, a Dante device) › fix: that press. Then Settings › <b>Auto switch</b>: tick it, paint the hours, and tune what counts as down (how often, how many misses). The watchdog presses once per fault, within about 5 s, even while ALARMS is muted, and never switches back by itself.</li><li><b>Add a line to the weekly checklist.</b> Keys › READY page › <b>+ READY key</b> › “Tap to confirm” (a person taps it done; resets Monday), “a checklist line the page checks by itself”, or “something else” (then + check… like any key: a connection, a ping, a program). Drag it where you want it.</li><li><b>Change where the READY page looks.</b> Settings › READY page: the Planning Center service type, the ProPresenter and Resolume addresses, the Tracks Mac and its projects folder, which locations get tracks, the lineup TV, the bundle folder, and when gaps turn red.</li><li><b>A new computer.</b> Computers › <b>+ Computer</b> (its log-in address), then Computers › Setup › that computer › copy its set-up script and run it there once.</li><li><b>Made a mistake?.</b> Nothing is live until Apply. After Apply: History › Undo, then Apply again.</li></ul></details>
<details><summary><b>Try it</b> (ten minutes with Editing on; nothing is live until APPLY)</summary><ol class="small">
<li>Keys › HD2: untick "may have no disk"; the badge counts it; Apply reads it back. Throw away.</li>
<li>Keys: drag LED onto the tray and back. + Key "HD7", add a check (Is a recorder OK?), delete it.</li>
@@ -777,10 +861,13 @@
}
// ---- Settings: one page, every section ------------------------------------------
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code"];
const HAND_MADE = ["tiles", "fixes", "hosts", "gear", "auto_fix.windows", "service_windows", "site.live_services", "auto_fix.fixes", "atem.reference", "spotify.playlist", "spotify.name", "restart_page.keys", "site.editing_code",
"ready_page.keys", "ready_page.back_pos", "ready.prop_lists", "failover.windows", "failover.after_seconds", "failover.note_seconds"];
const SECTIONS = [
["playlist", "Playlist", "The Spotify playlist the SPOTIFY key and the play fix use"],
["autofix", "Auto-fix", "When the panel may fix things by itself, and which fixes"],
["failover", "Auto switch", "Press a Companion button by itself when something dies (Studio B -> FOH Backup): which button, when, and what counts as down"],
["ready", "READY page", "Where the weekly checklist looks: Planning Center, ProPresenter, the Tracks Mac, the lineup TV, Resolume, the bundles"],
["wait", "Changes wait", "When changes to the panel must wait (a recording deck always counts)"],
["hyperdecks", "HyperDecks", "Which decks are in use"],
["midi", "MIDI links", "Network MIDI sessions and the peers that must stay in them"],
@@ -790,6 +877,7 @@
["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"],
["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"],
["setup", "Auto-setup", "What this Mac found, the line each computer runs to join, and the line that sets up another campus"],
["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"],
["program", "Program & campus", "Update the code, export or start a campus"],
["advanced", "Advanced", "Every number and name, by screen"],
@@ -804,6 +892,8 @@
let body = "";
if (which === "playlist") body = spotifyHtml(c);
else if (which === "autofix") body = autofixHtml(c);
else if (which === "failover") body = autoSwitchHtml(c);
else if (which === "ready") body = readySettingsHtml(c);
else if (which === "wait") body = `<div class="card">${calendarHtml("site.live_services", (c.site || {}).live_services)}<p class="help">A recording HyperDeck always counts, whatever the clock says.</p></div>`;
else if (which === "hyperdecks") body = await hyperdecksHtml(c);
else if (which === "midi") body = midiHtml(c);
@@ -813,6 +903,7 @@
else if (which === "outlets") body = await outletsHtml();
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "setup") body = await setupHtml(c);
else if (which === "site") body = siteHtml(c);
else if (which === "program") body = await programHtml();
else if (which === "advanced") body = advancedHtml();
@@ -820,6 +911,14 @@
main.querySelector("#subsel").onchange = (e) => { location.hash = "#settings/" + e.target.value; };
wireSettings(which);
}
function readySettingsHtml(c) {
if (!c.ready_page) return "<p class='muted'>This campus has no READY page.</p>";
const lists = JSON.stringify((c.ready || {}).prop_lists || [], null, 1);
return `<div class="card"><p style="margin:0">${onOff("ready", c.ready || {}, "the READY page")} ${(c.ready || {}).disabled ? "<b>The READY page is off:</b> its keys show OFF and nothing is looked at. Everything is kept." : "<b>The READY page</b> looks at this weekend every 2 minutes."}</p></div><div class="card"><h3 style="margin-top:0">When gaps turn red${hint("Before this, a READY key that is not done is amber; inside it, still missing is red and blinks")}</h3>${calendarHtml("ready.due", (c.ready || {}).due)}</div>`
+ fieldsHtml("Ready")
+ `<div class="card"><div class="field"><span class="fl">lists the slides must match${hint('PCO items whose description lists things: {"match": a word in the item title, "kind": "count" (one slide per - line) or "names" (every name has a slide), "short": what the key calls it}')}</span><textarea data-jsonpath="ready.prop_lists" rows="${Math.min(12, lists.split("\n").length + 1)}" class="mono" ${editing() ? "" : "disabled"}>${esc(lists)}</textarea></div>
<p class="help">The keys themselves (add, move, delete, what each checks) are on Keys › READY page.</p></div>`;
}
function fieldsHtml(screen, includeAdvanced) {
const own = Object.entries(state.schema.fields).filter(([p, f]) => f.screen === screen && !p.includes("[]") && !p.includes("*") && !HAND_MADE.includes(p) && !HAND_MADE.some((h) => h.startsWith(p + ".")) && !Object.keys(state.schema.fields).some((k) => k.startsWith(p + ".") || k.startsWith(p + "[]")));
const plain = own.filter(([, f]) => !f.advanced), adv = own.filter(([, f]) => f.advanced);
@@ -831,19 +930,259 @@
const screens = [...new Set(Object.values(state.schema.fields).map((f) => f.screen))];
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `<h3>${esc(s)}</h3>${h}` : ""; }).join("") || "<p class='muted'>Nothing.</p>";
}
// Auto-setup: what was found and added, the join lines, and the install line for another campus.
async function setupHtml(c) {
const d = await api("/api/discover").catch(() => null);
if (!d) return "<p class='muted'>Auto-setup is not answering.</p>";
const last = d.last || {}, inv = last.inventory || {};
const line = (text, h) => `<div class="field"><span class="fl">${h}</span><input class="mono small copyline" readonly value="${esc(text)}"></div>`;
const comps = Object.entries(inv.computers || {}).map(([a, x]) => `<tr><td>${lightDot(x.reachable ? "ok" : "problem", x.reachable ? "logs in" : x.error || "does not let this Mac in yet")}${esc(x.name || a)}</td><td class="small">${esc(x.os || "")}</td><td class="mono small">${esc(x.ip || "")}</td><td class="small muted">${x.reachable ? "" : esc(x.error || "run the join line on it")}</td></tr>`).join("");
const joined = Object.entries(d.joined || {}).filter(([n]) => !Object.values(inv.computers || {}).some((x) => x.name === n)).map(([n, j]) => `<tr><td>${lightDot("warn", "joined; the next look sets it up")}${esc(n)}</td><td class="small">${esc(j.os)}</td><td class="mono small">${esc((j.ips || [])[0] || "")}</td><td class="small muted">joined ${esc(when(j.at))}</td></tr>`).join("");
const list = (xs) => xs && xs.length ? `<ul class="small">${xs.map((x) => `<li>${esc(x)}</li>`).join("")}</ul>` : "<p class='small muted'>nothing</p>";
const campuses = [["ab", "Apollo Beach"], ["sc", "Southcoast"], ["ws", "Woonsocket"], ["na", "North Attleboro"]].filter(([k]) => k !== (c.site || {}).id);
return `<div class="card"><p style="margin-top:0">${inputHtml("discover.auto", F("discover.auto"), (c.discover || {}).auto)}</p>
<dl class="kv small"><dt>last look</dt><dd>${last.at ? esc(when(last.at)) : "not yet"}${last.error ? ` · <span style="color:var(--problem)">${esc(last.error)}</span>` : ""}</dd>
<dt>found</dt><dd>${inv.connections != null ? `${inv.connections} Companion connections · ${inv.dante || 0} Dante devices · ${inv.midi_sessions || 0} MIDI sessions · wired network ${esc(inv.wired || "?")}` : "–"}</dd></dl>
<div class="row"><button class="small" id="looknow" ${editing() ? "" : "disabled"} data-hint="Look now and add what is new (also when adding by itself is off); never during a service or a recording">Look now</button></div></div>
<div class="card"><h3 style="margin-top:0">Added last time</h3>${list(last.added)}${last.waiting && last.waiting.length ? `<h3>Waiting${hint(last.why || "")}</h3>${list(last.waiting)}<p class="help">${esc(last.why || "")}</p>` : ""}
${inv.not_known && inv.not_known.length ? `<details><summary>Companion connections auto-setup has no recipe for (${inv.not_known.length})</summary>${list(inv.not_known)}<p class="help">Add a key for one by hand (Keys › + Key) if it matters.</p></details>` : ""}</div>
<div class="card"><h3 style="margin-top:0">Computers${hint("Each computer runs the join line once: it lets this Mac log in with its key and says it is here. The next look gives its programs keys.")}</h3>
<div class="tablewrap"><table>${comps}${joined}${comps || joined ? "" : "<tr><td class='muted'>none yet</td></tr>"}</table></div>
${line(d.join.mac, "Mac: Terminal, paste")}${line(d.join.pc, "PC: PowerShell as administrator, paste")}</div>
<div class="card"><h3 style="margin-top:0">Set up another campus${hint("On that campus's Mac that runs Companion: Terminal, paste. It installs the panel from here, finds the gear and makes the pages. Then each computer there runs that Mac's join line.")}</h3>
${campuses.map(([k, n]) => line(`curl -fsSL "${d.base}/campus/install?id=${k}" | zsh`, n)).join("")}</div>`;
}
function siteHtml(c) {
const f = F("site.editing_code");
return `<div class="card">${inputHtml("site.editing_code", Object.assign({}, f, { help: "The PIN that switches Editing on. Digits are easiest. It lives in the settings file, so everyone with the repo has it." }), (c.site || {}).editing_code)}</div>` + fieldsHtml("Site");
}
function spotifyHtml(c) {
const s = c.spotify || {};
return `<div class="card"><div class="field"><span class="fl">playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")}</span><span class="row"><input id="splink" value="${esc(s.playlist || "")}" placeholder="https://open.spotify.com/playlist/…" ${editing() ? "" : "disabled"} style="flex:1;min-width:200px"> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}</span></div>
const sw = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "spotify") sw.push(`<p style="margin-top:0">${onOff(`tiles.${ti}.checks.${j}`, ch, "the Spotify check")} Watch that Spotify plays this playlist</p>`); }));
return `<div class="card">${sw.join("")}<div class="field"><span class="fl">playlist link${hint("Paste the link from Spotify (Share › Copy link). Look it up fills the name.")}</span><span class="row"><input id="splink" value="${esc(s.playlist || "")}" placeholder="https://open.spotify.com/playlist/…" ${editing() ? "" : "disabled"} style="flex:1;min-width:200px"> ${editing() ? `<button class="small" id="splook">Look it up</button>` : ""}</span></div>
<div class="field"><span class="fl">name${hint("As the keys say it")}</span><input id="spname" value="${esc(s.name || "")}" ${editing() ? "" : "disabled"}></div><p id="spinfo" class="help"></p></div>` + fieldsHtml("Spotify");
}
function autofixHtml(c) {
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline" data-hint="${esc(f.label || id)}${cl ? " · closes a program: ticking it allows that by itself" : f.never_auto ? " · marked never by itself: ticking it lifts that" : ""}"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}> ${esc(f.short || id)}${cl || f.restart_ok ? " ⚠" : ""}</label>`; }).join(" ");
return `<div class="card">${calendarHtml("auto_fix.windows", c.auto_fix.windows)}</div>
<div class="card"><h3 style="margin-top:0">Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}</h3><div class="chips">${fixes}</div></div>` + fieldsHtml("Schedules");
const fixes = Object.entries(c.fixes).map(([id, f]) => { const cl = closes(f); const on = (c.auto_fix.fixes || []).includes(id); return `<label class="inline" data-hint="${esc(f.label || id)}${cl ? " · closes a program: ticking it allows that by itself" : f.never_auto ? " · marked never by itself: ticking it lifts that" : f.anytime ? " · any hour, not only these" : ""}"><input type="checkbox" data-autofix="${esc(id)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}> ${esc(f.short || id)}${cl || f.restart_ok ? " ⚠" : ""}</label>`; }).join(" ");
return `<div class="card"><p style="margin-top:0">${onOff("auto_fix", c.auto_fix, "auto-fix")} <b>Auto-fix</b> ${c.auto_fix.disabled ? "is off: nothing is fixed by itself (holds still work)." : "fixes the ticked fixes by itself in the painted hours."}</p>${calendarHtml("auto_fix.windows", c.auto_fix.windows)}</div>
<div class="card"><h3 style="margin-top:0">Fixes it may run${hint("Ticked fixes run by themselves inside the painted hours. ⚠ = it closes a running program to do its job; ticking it allows that without a hold.")}</h3><div class="chips">${fixes}</div></div><p class="help">Pressing a Companion button by itself when something dies is under Settings › <a href="#settings/failover">Auto switch</a>.</p>` + fieldsHtml("Schedules");
}
// Which keys name each fix (on a check, as hold-also-when-green, or on a restart key).
function fixUsers(c) {
const used = {};
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
for (const t of c.tiles.concat((c.ready_page || { keys: [] }).keys)) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); }
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
return used;
}
// ---- A Companion page as Companion draws it: pick a button by clicking it ----
const cssHex = (n) => (n == null || typeof n !== "number" ? null : "#" + (n & 0xffffff).toString(16).padStart(6, "0"));
async function companionPicker(box, init, onSelect) {
// init: {page, row, col}; onSelect({page, row, col, text}) on every click.
box.innerHTML = `<p class="muted small">Reading Companion's pages…</p>`;
const pages = (await api("/api/companion/pages").catch(() => ({ pages: [] }))).pages || [];
if (!pages.length) { box.innerHTML = `<p class="notice small">Companion's pages could not be read; type the numbers below instead.</p>`; return; }
let cur = Object.assign({}, init || {});
if (!cur.page) cur.page = pages[0].page;
const draw = async () => {
box.innerHTML = `<div class="field"><span class="fl">Companion page</span><select class="cp-page">${pages.map((p) => `<option value="${p.page}" ${p.page === +cur.page ? "selected" : ""}>${p.page}: ${esc(p.name)}</option>`).join("")}</select></div><div class="cp-grid"><p class="muted small">Loading page ${esc(cur.page)}…</p></div><p class="small cp-pick muted">Click the button.</p>`;
box.querySelector(".cp-page").onchange = (e) => { cur = { page: +e.target.value }; draw(); };
const pg = await api("/api/companion/page?n=" + cur.page).catch(() => ({ buttons: [] }));
const at = {}; let maxR = 3, maxC = 7;
for (const b of pg.buttons || []) { at[b.row + "," + b.col] = b; maxR = Math.max(maxR, Math.min(b.row, 5)); maxC = Math.max(maxC, Math.min(b.col, 8)); }
let html = `<div class="deck cp-deck" style="grid-template-columns: repeat(${maxC + 1}, minmax(0, 1fr))">`;
for (let r = 0; r <= maxR; r++) for (let c = 0; c <= maxC; c++) {
const b = at[r + "," + c];
const sel = +cur.row === r && +cur.col === c && cur.row !== undefined && cur.row !== "";
if (!b) { html += `<div class="key empty" title="row ${r}, column ${c}: empty"></div>`; continue; }
if (["pageup", "pagedown", "pagenum"].includes(b.type)) { html += `<div class="key nav">${{ pageup: "UP", pagedown: "DOWN", pagenum: String(cur.page) }[b.type]}</div>`; continue; }
const bg = cssHex(b.bg) || "#000", fg = cssHex(b.fg) || "#fff";
const img = b.img ? `background-image:url('${b.img}');background-size:contain;background-repeat:no-repeat;background-position:center;` : "";
html += `<div class="key cp-key${sel ? " sel" : ""}" data-r="${r}" data-c="${c}" data-t="${esc(b.text || "")}" title="row ${r}, column ${c}" style="background-color:${bg};color:${fg};${img}">${esc(b.text || "")}</div>`;
}
box.querySelector(".cp-grid").innerHTML = html + "</div>";
const say = () => { const p = box.querySelector(".cp-pick"); if (cur.row !== undefined && cur.row !== "") { p.classList.remove("muted"); p.innerHTML = `Picked: <b>${esc((cur.text || "").replace(/\n/g, " ") || "a button")}</b> (page ${esc(cur.page)}, row ${esc(cur.row)}, column ${esc(cur.col)})`; } };
say();
for (const k of box.querySelectorAll(".cp-key")) k.onclick = () => {
for (const o of box.querySelectorAll(".cp-key.sel")) o.classList.remove("sel");
k.classList.add("sel");
cur = { page: +cur.page, row: +k.dataset.r, col: +k.dataset.c, text: k.dataset.t };
say(); onSelect(Object.assign({}, cur));
};
};
await draw();
}
function pickButtonDialog(title, init, onDone) {
let chosen = null;
dialog(`<h3>${esc(title)}</h3><div class="cp-box"></div><div class="row"><button class="primary" id="dok">Use this button</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.classList.add("wide");
companionPicker(b.querySelector(".cp-box"), init, (p) => { chosen = p; });
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = () => { if (!chosen) return alertBox("Click a button on the page first."); closeDialog(); onDone(chosen); };
});
}
// ---- Auto switch: add a button / add what counts as down ----
function addSwitchButton() {
const c = cfg(), presses = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press");
dialog(`<h3>New Companion button</h3><p class="small">Pick the page, then click the button, just as it looks in Companion.</p>
<div class="cp-box"></div>
<input id="bname" placeholder="what to call it (filled from the button)">
<details class="small"><summary>type the numbers instead</summary><div class="row"><label class="inline small">page <input id="bpage" type="number" min="1" style="width:5em"></label><label class="inline small">row <input id="brow" type="number" min="0" style="width:5em"></label><label class="inline small">column <input id="bcol" type="number" min="0" style="width:5em"></label></div></details>
<label class="inline"><input type="checkbox" id="bauto" checked> press it by itself when something dies (you pick what next)</label>
<div class="field"><span class="fl">switch back with (by hand)</span><select id="bundo"><option value="">nothing</option>${presses.map(([u, g]) => `<option value="${esc(u)}">${esc((g.params || {}).button || g.short || u)} (page ${g.page}, row ${g.row}, col ${g.col})</option>`).join("")}</select></div>
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.classList.add("wide");
companionPicker(b.querySelector(".cp-box"), {}, (p) => {
b.querySelector("#bpage").value = p.page; b.querySelector("#brow").value = p.row; b.querySelector("#bcol").value = p.col;
const nm = b.querySelector("#bname"); if (!nm.value || nm.dataset.auto) { nm.value = (p.text || "").replace(/\n/g, " ").trim(); nm.dataset.auto = "1"; }
});
b.querySelector("#bname").oninput = (e) => { delete e.target.dataset.auto; };
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
if (b.querySelector("#brow").value === "") return alertBox("Click the button on the page (or type the numbers).");
const name = b.querySelector("#bname").value.trim();
if (!name) return alertBox("What does the button say?");
const params = { page: b.querySelector("#bpage").value, row: b.querySelector("#brow").value, col: b.querySelector("#bcol").value, button: name };
const r = await post("/api/build", { fix: { template: "companion_press", params, short: name.toUpperCase().slice(0, 14) } });
if (!r.ok) return alertBox(r.error);
if (!ensureDraft()) return;
const fixes = state.draft.config.fixes;
let id = "press_" + (slug(name) || "button"), n = 2; while (fixes[id]) id = "press_" + slug(name) + "_" + n++;
const fx = r.fix; if (r.label) fx.label = r.label;
if (b.querySelector("#bauto").checked) fx.failover = true;
if (b.querySelector("#bundo").value) fx.undo = b.querySelector("#bundo").value;
fixes[id] = fx; saveDraft(); closeDialog(); route();
};
});
}
function addSwitchTrigger() {
const c = cfg();
const switches = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press" && f.failover);
if (!switches.length) return alertBox("First add a button that switches by itself (+ Button, with “press it by itself” ticked).");
const users = fixUsers(c);
const home = c.tiles.find((t) => (t.checks || []).some((ch) => switches.some(([id]) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id))));
const keys = c.tiles.filter((t) => t.checks && !t.members && !t.kind);
const groups = c.tiles.filter((t) => t.members);
const dante = (c.tiles.flatMap((t) => t.checks || []).find((ch) => ch.type === "dante") || {});
const devices = Object.keys(dante.devices || {});
const macs = Object.entries(c.hosts).filter(([, h]) => !h.self);
dialog(`<h3>What counts as down</h3>
<select id="tkind"><option value="ping">A computer or device stops answering ping</option><option value="program">A program stops running on a computer</option><option value="dante">A Dante device disappears</option><option value="conn">A Companion connection fails</option></select>
<div data-k="ping"><input id="taddr" placeholder="its address (10.1.13.175)"><input id="tname" placeholder="what the key calls it (STUDIO B)"></div>
<div data-k="program" hidden><select id="thost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)} (${esc(h.os || "mac")})</option>`).join("")}</select><input id="tproc" placeholder="the program, as Activity Monitor / Task Manager names it (Pro Tools, OBS64.exe)"></div>
<div data-k="dante" hidden><input id="tdev" list="tdevs" placeholder="the Dante device name"><datalist id="tdevs">${devices.map((d) => `<option value="${esc(d)}">`).join("")}</datalist></div>
<div data-k="conn" hidden><input id="tconn" list="tconns" placeholder="the Companion connection"><datalist id="tconns"></datalist></div>
<div class="field"><span class="fl">then press</span><select id="tfix">${switches.map(([id, f]) => `<option value="${esc(id)}">${esc((f.params || {}).button || f.short || id)}</option>`).join("")}</select></div>
<div class="field"><span class="fl">show it on</span><select id="tkey">${keys.map((t) => `<option value="${esc(t.id)}" ${home && home.id === t.id ? "selected" : ""}>${esc(t.label)}</option>`).join("")}<option value="">a new key inside a group…</option></select></div>
<div class="field" id="tnewkey" hidden><input id="tklabel" placeholder="the new key's name (STA AUDIO)"><select id="tgroup">${groups.map((g) => `<option value="${esc(g.id)}">inside ${esc(g.label)}</option>`).join("")}</select></div>
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, async (b) => {
const kind = b.querySelector("#tkind"), keySel = b.querySelector("#tkey");
const show = () => { for (const el of b.querySelectorAll("[data-k]")) el.hidden = el.dataset.k !== kind.value; b.querySelector("#tnewkey").hidden = keySel.value !== ""; };
kind.onchange = show; keySel.onchange = show; show();
api("/api/companion").then((cc) => { b.querySelector("#tconns").innerHTML = (cc.connections || []).map((r) => `<option value="${esc(r.label)}">`).join(""); }).catch(() => {});
b.querySelector("#dcancel").onclick = closeDialog;
b.querySelector("#dok").onclick = async () => {
const fix = b.querySelector("#tfix").value;
let chk;
if (kind.value === "ping") {
const host = b.querySelector("#taddr").value.trim(), name = b.querySelector("#tname").value.trim().toUpperCase();
if (!/^[0-9A-Za-z.\-]+$/.test(host)) return alertBox("Type the address, like 10.1.13.175.");
chk = { type: "ping", host, name: name || host, interval_seconds: 1, failures: 2, debounce_seconds: 1, fix };
} else if (kind.value === "program") {
const host = b.querySelector("#thost").value, proc = b.querySelector("#tproc").value.trim();
if (!proc) return alertBox("Which program?");
const h = c.hosts[host] || {};
if (h.os === "windows") {
const exe = /\.exe$/i.test(proc) ? proc : proc + ".exe";
if (!/^[A-Za-z0-9 ._()\-]+$/.test(exe)) return alertBox("That program name has characters a Windows check cannot use.");
chk = { type: "remote", host, cmd: `tasklist /NH /FI "IMAGENAME eq ${exe}" | find /I "${exe}"`, msg: exe.replace(/\.exe$/i, "").toUpperCase() + " NOT RUNNING", interval_seconds: 2, debounce_seconds: 1, fix };
} else {
const r = await post("/api/build-check", { check: { type: "remote", template: "mac_program", params: { host, process: proc }, msg: proc.toUpperCase() + " NOT RUNNING", fix, debounce_seconds: 1 } });
if (!r.ok) return alertBox(r.error);
chk = Object.assign(r.check, { interval_seconds: 2 });
}
} else if (kind.value === "dante") {
const dev = b.querySelector("#tdev").value.trim();
if (!dev) return alertBox("Which Dante device?");
chk = { type: "dante", conn: dante.conn || "audinate-dantecontroller", devices: { [dev]: dev.replace(/^.*?-/, "").slice(0, 10).toUpperCase() }, fix: { [dev]: fix } };
} else {
const conn = b.querySelector("#tconn").value.trim();
if (!conn) return alertBox("Which Companion connection?");
chk = { type: "conn", conn, tag: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 12), fix };
}
if (!ensureDraft()) return;
const d = state.draft.config;
let tile = keySel.value ? d.tiles.find((t) => t.id === keySel.value) : null;
if (!tile) {
const label = b.querySelector("#tklabel").value.trim().toUpperCase();
if (!label) return alertBox("Name the new key.");
let id = slug(label) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(label) + "_" + n++;
tile = { id, label, checks: [] }; d.tiles.push(tile);
const g = d.tiles.find((t) => t.id === b.querySelector("#tgroup").value); if (g) g.members.push(id);
}
tile.checks = tile.checks || []; tile.checks.push(chk); saveDraft(); closeDialog(); route();
};
});
}
// ---- Auto switch: everything about the failover on one screen ----
function autoSwitchHtml(c) {
const fo = c.failover || {};
const windows = "windows" in fo ? fo.windows : (F("failover.windows").default || []);
const dis = editing() ? "" : "disabled";
const presses = Object.entries(c.fixes).filter(([, f]) => f.kind === "companion_press");
const switches = presses.filter(([, f]) => f.failover);
const where = (id, f) => `<span class="small">page ${esc(f.page)} · row ${esc(f.row)} · col ${esc(f.col)}</span>${editing() ? ` <button class="small" data-changepress="${esc(id)}">Change…</button>` : ""}`;
const undoSel = (id, f) => `<select data-path="fixes.${esc(id)}.undo" ${dis}><option value="">nothing</option>${presses.filter(([u]) => u !== id).map(([u, g]) => `<option value="${esc(u)}" ${f.undo === u ? "selected" : ""}>${esc((g.params || {}).button || g.short || u)} (page ${g.page}, row ${g.row}, col ${g.col})</option>`).join("")}</select>`;
const buttons = presses.map(([id, f]) => `<tr class="${f.disabled ? "off" : ""}"><td>${onOff(`fixes.${id}`, f, "this button")} <label class="inline"><input type="checkbox" data-path="fixes.${esc(id)}.failover" ${f.failover ? "checked" : ""} ${dis}> <b>${esc((f.params || {}).button || f.short || id)}</b></label></td>
<td>${where(id, f)}</td><td class="small">${f.failover ? undoSel(id, f) : "<span class='muted'>–</span>"}</td>
<td>${canRunNow(id) && editing() ? `<button class="small" data-runpress="${esc(id)}">Press it now</button>` : ""}${editing() ? ` <button class="small danger" data-delpress="${esc(id)}" data-hint="Remove this button from the panel (Companion keeps it)">Remove</button>` : ""}</td></tr>`).join("");
// What counts as down: every check whose fix is a switch, on whichever key it sits.
const rows = [];
c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => {
const fx = typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix];
const sw = fx.find((f) => switches.some(([id]) => id === f));
if (!sw) return;
const base = `tiles.${ti}.checks.${j}.`;
const host = (c.hosts[ch.host] || {}).name || ch.host;
const proc = (ch.params || {}).process || (/IMAGENAME eq ([^"]+)"/.exec(ch.cmd || "") || [])[1] || "";
const what = ch.type === "ping" ? `${esc(ch.name || ch.host)} stops answering (ping ${esc(ch.host)})`
: ch.type === "remote" && proc ? `${esc(proc)} closes, crashes or is being restarted on ${esc(host)}`
: ch.type === "remote" ? `${esc(ch.msg || "a command fails")} on ${esc(host)}`
: ch.type === "dante" ? `${esc(Object.keys(ch.devices || {}).join(", "))} disappears from Dante`
: ch.type === "conn" ? `Companion's ${esc(ch.conn)} connection fails` : esc(ch.type);
const dis = editing() ? "" : "disabled";
const fld = (label, html, help) => `<div class="field"><span class="fl">${label}${help ? hint(help) : ""}</span>${html}</div>`;
// What it watches, editable in place
let edit = "";
if (ch.type === "ping") edit = fld("address", `<input data-path="${base}host" value="${esc(ch.host || "")}" ${dis}>`, "The computer or device it pings")
+ fld("name on the key", `<input data-path="${base}name" value="${esc(ch.name || "")}" ${dis}>`, "The key says NAME NO PING");
else if (ch.type === "remote" && proc) edit = fld("program", `<input data-trigproc="${ti}|${j}" value="${esc(proc)}" ${dis}>`, "As Activity Monitor (Mac) or Task Manager (PC) names it")
+ fld("on the computer", `<select data-trighost="${ti}|${j}" ${dis}>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${a === ch.host ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select>`);
else if (ch.type === "dante") edit = fld("Dante device", `<input data-trigdev="${ti}|${j}" value="${esc(Object.keys(ch.devices || {})[0] || "")}" ${dis}>`);
else if (ch.type === "conn") edit = fld("Companion connection", `<input data-path="${base}conn" value="${esc(ch.conn || "")}" ${dis}>`);
const swSel = fld("then press", `<select data-trigfix="${ti}|${j}" ${dis}>${switches.map(([id, f]) => `<option value="${esc(id)}" ${id === sw ? "selected" : ""}>${esc((f.params || {}).button || f.short || id)}</option>`).join("")}</select>`);
const tune = ["ping", "remote"].includes(ch.type) ? inputHtml(base + "interval_seconds", F("tiles[].checks[].interval_seconds"), ch.interval_seconds) : "";
const fails = ch.type === "ping" ? inputHtml(base + "failures", F("tiles[].checks[].failures"), ch.failures) : "";
rows.push(`<div class="trig${ch.disabled ? " off" : ""}"><p class="small" style="margin:0 0 6px">${onOff(`tiles.${ti}.checks.${j}`, ch, "this trigger")} <b>${what}</b> <span class="muted">· shows on <a href="#keys/${esc(t.id)}">${esc(t.label)}</a></span>${editing() ? ` <button class="small danger" style="float:right" data-rmswitch="${ti}|${j}">Remove</button>` : ""}</p>${edit}${swSel}${tune}${fails}${inputHtml(base + "debounce_seconds", F("tiles[].checks[].debounce_seconds"), ch.debounce_seconds)}</div>`);
}));
const words = (windows || []).length ? windows.map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${h12(w.start)} – ${w.end === "24:00" ? "midnight" : h12(w.end)}`).join(" · ") : "never";
const wait = fo.after_seconds != null ? fo.after_seconds : F("failover.after_seconds").default;
const names = switches.map(([id, f]) => (f.params || {}).button || f.short || id);
const summary = switches.length
? `It presses <b>${esc(names.join(" / "))}</b> by itself when anything below goes down, once it has been down <b>${esc(wait)} s</b>, during <b>${esc(words)}</b>. It never switches back by itself.`
: "Nothing switches by itself yet: tick a button below.";
return `<div class="card"><p style="margin:0">${onOff("failover", fo, "the auto switch")} ${fo.disabled ? "<b>The auto switch is off:</b> nothing is pressed by itself. Everything below is kept." : summary}</p>${editing() ? "" : `<p class="help">Edit (top right, your PIN) to change anything on this screen; then Apply.</p>`}</div>
<div class="card"><h3 style="margin-top:0">What counts as down${hint("Any one of these switches. Faster checks switch sooner: ping every 1 s with 2 misses, a program every 2 s.")}</h3>
<p class="help" style="margin-top:0">How sure it must be before switching: <b>look every</b> = how often it checks; <b>misses before it counts</b> = pings that must fail in a row; <b>a fault must last</b> = seconds the fault must hold; then <b>wait before acting</b> (below) and it presses.</p>
${rows.join("") || "<p class='muted'>Nothing switches anything yet.</p>"}
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" id="as_addcheck">+ What counts as down</button></div>` : ""}</div>
<div class="card"><h3 style="margin-top:0">When it may switch${hint("Outside these hours a failure only shows on the keys")}</h3>
${inputHtml("failover.after_seconds", F("failover.after_seconds"), fo.after_seconds)}${inputHtml("failover.note_seconds", F("failover.note_seconds"), fo.note_seconds)}
<details><summary>Hours: <b>${esc(words)}</b> <span class="muted small">(open to paint)</span></summary>${calendarHtml("failover.windows", windows)}</details></div>
<div class="card"><h3 style="margin-top:0">The buttons${hint("Companion buttons the panel may press. Ticked = it presses it by itself when something above fails, once per fault; it never switches back by itself.")}</h3>
${presses.length ? `<div class="tablewrap"><table><tr><th>switch by itself</th><th>Companion button</th><th>switch back with (by hand)</th><th></th></tr>${buttons}</table></div>` : "<p class='muted'>No Companion buttons yet.</p>"}
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" id="as_addbtn">+ Button</button></div>` : ""}</div>
<p class="help">Changes take effect after Apply (the badge, top right).</p>`;
}
// --- the weekly calendar: half-hour cells; click or drag to paint ---
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
@@ -906,19 +1245,32 @@
const keyFor = (conn) => c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "hyperdeck" && ch.conn === conn));
const known = new Set(decks.map((d) => d.label));
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "hyperdeck" && !known.has(ch.conn)) { decks.push({ label: ch.conn, missing: true }); known.add(ch.conn); }
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); return `<tr><td><label class="inline"><input type="checkbox" data-deck="${esc(d.label)}" ${k ? "checked" : ""} ${editing() ? "" : "disabled"}> ${esc(d.label)}</label>${d.missing ? ` <span class="muted small" data-hint="A key watches it but Companion has no such connection">not in Companion</span>` : ""}</td><td>${k ? `<span data-hint="${k.pos ? `row ${k.pos[0]}, column ${k.pos[1]}` : "in a group only"}">${esc(k.label)}</span>` : "<span class='muted'>no key</span>"}</td><td><label class="inline" data-hint="NO DISK is a warning, not an alarm"><input type="checkbox" data-nodisk="${esc(d.label)}" ${chk && chk.optional ? "checked" : ""} ${editing() && k ? "" : "disabled"}> no disk OK</label></td></tr>`; }).join("");
return `<div class="card">${!cc ? "<p class='notice grey'>Companion is not answering; the list is from the keys.</p>" : ""}<div class="tablewrap"><table><tr><th>in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")}</th><th>key</th><th></th></tr>${rows || "<tr><td colspan=3 class='muted'>No HyperDeck connections in Companion.</td></tr>"}</table></div>
const rows = decks.map((d) => { const k = keyFor(d.label); const chk = k && k.checks.find((ch) => ch.conn === d.label); const ki = k ? c.tiles.indexOf(k) : -1; return `<tr class="${k && k.disabled ? "off" : ""}"><td>${k ? onOff(`tiles.${ki}`, k, "this deck's key") : ""}</td><td><label class="inline"><input type="checkbox" data-deck="${esc(d.label)}" ${k ? "checked" : ""} ${editing() ? "" : "disabled"}> ${esc(d.label)}</label>${d.missing ? ` <span class="muted small" data-hint="A key watches it but Companion has no such connection">not in Companion</span>` : ""}</td><td>${k ? `<span data-hint="${k.pos ? `row ${k.pos[0]}, column ${k.pos[1]}` : "in a group only"}">${esc(k.label)}</span>` : "<span class='muted'>no key</span>"}</td><td><label class="inline" data-hint="NO DISK is a warning, not an alarm"><input type="checkbox" data-nodisk="${esc(d.label)}" ${chk && chk.optional ? "checked" : ""} ${editing() && k ? "" : "disabled"}> no disk OK</label></td></tr>`; }).join("");
return `<div class="card">${!cc ? "<p class='notice grey'>Companion is not answering; the list is from the keys.</p>" : ""}<div class="tablewrap"><table><tr><th></th><th>in use${hint("Tick a deck and it gets an HD key in the REC group; untick and the key goes (its cell stays empty)")}</th><th>key</th><th></th></tr>${rows || "<tr><td colspan=3 class='muted'>No HyperDeck connections in Companion.</td></tr>"}</table></div>
${editing() && rows ? `<div class="row" style="margin-top:8px"><button class="primary small" id="decksave">Use these decks</button></div>` : ""}</div>`;
}
// The wired network(s) MIDI must ride: the MIDI key's wired check (empty = no rule).
function midiNets(c) { return c.tiles.concat((c.ready_page || {}).keys || []).flatMap((t) => (t.checks || []).filter((ch) => ch.type === "midi_wired" && !ch.disabled).flatMap((ch) => ch.nets || [])); }
function ip4(a) { const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?::\d+)?$/.exec(String(a || "").trim()); return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null; }
function onWired(nets, a) { const v = ip4(a); return v !== null && nets.some((n) => { const [b, bits] = String(n).split("/"); const k = Number(bits || 32), base = ip4(b); return base !== null && Math.floor(v / 2 ** (32 - k)) === Math.floor(base / 2 ** (32 - k)); }); }
function midiHtml(c) {
const nets = midiNets(c);
let rule = null;
c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (!rule && ch.type === "midi_wired") rule = { ti, j, ch }; }));
const netRow = rule
? `<div class="field"><span class="fl">wired network${hint("Every MIDI link must come over this network (several: comma between). A link on Wi-Fi or Dante, or joined by name, turns the MIDI key red; holding MIDI moves it here.")}</span><input data-path="tiles.${rule.ti}.checks.${rule.j}.nets" value="${esc((rule.ch.nets || []).join(", "))}" placeholder="10.1.0.0/16" ${editing() ? "" : "disabled"}></div>`
: `<p class="small muted">No wired-network rule: MIDI links may come over any network.${editing() ? ` <button class="small" id="addwired">Require wired</button>` : ""}</p>`;
const rows = [];
c.tiles.forEach((t) => (t.checks || []).forEach((ch, j) => { if (ch.type === "midi_peer") rows.push(`<tr><td>${esc(t.label)}</td><td>${esc(ch.host ? (c.hosts[ch.host] || {}).name || ch.host : (c.site || {}).ingest_name || "this Mac")}</td><td class="mono small">${esc(ch.session)}</td><td class="mono small">${esc(ch.peer)}</td><td class="small">${esc(ch.tag || "")}</td><td>${editing() ? `<button class="small danger" data-rmcheck="${esc(t.id)}|${j}">Remove</button>` : ""}</td></tr>`); }));
return `<div class="card"><div class="tablewrap"><table><tr><th>key</th><th>Mac</th><th>session</th><th>peer</th><th>key text</th><th></th></tr>${rows.join("") || "<tr><td colspan=6 class='muted'>none</td></tr>"}</table></div>
c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "midi_peer") {
const bad = nets.length && !onWired(nets, ch.address);
const addr = editing() ? `<input class="mono small" data-midiaddr="${ti}|${j}" value="${esc(ch.address || "")}" placeholder="10.1.x.x:5004" size="16">` : `<span class="mono small">${esc(ch.address || "–")}</span>`;
rows.push(`<tr class="${ch.disabled || t.disabled ? "off" : ""}"><td>${onOff(`tiles.${ti}.checks.${j}`, ch, "this MIDI link")}</td><td>${esc(t.label)}</td><td>${esc(ch.host ? (c.hosts[ch.host] || {}).name || ch.host : (c.site || {}).ingest_name || "this Mac")}</td><td class="mono small">${esc(ch.session)}</td><td class="mono small">${esc(ch.peer)}</td><td>${addr}${bad ? ` <span class="pill err" data-hint="Not on the wired network: give its wired address">not wired</span>` : ""}</td><td class="small">${esc(ch.tag || "")}</td><td>${editing() ? `<button class="small danger" data-rmcheck="${esc(t.id)}|${j}">Remove</button>` : ""}</td></tr>`); } }));
return `<div class="card">${netRow}<div class="tablewrap"><table><tr><th></th><th>key</th><th>Mac</th><th>session</th><th>peer</th><th>address${hint("Where a reconnect joins it. Must be on the wired network.")}</th><th>key text</th><th></th></tr>${rows.join("") || "<tr><td colspan=8 class='muted'>none</td></tr>"}</table></div>
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" data-job="midi">+ MIDI link</button></div>` : ""}</div>`;
}
function computersHtml(c) {
const rows = Object.entries(c.hosts).map(([a, h]) => `<tr class="click" onclick="location.hash='#computers/${esc(a)}'"><td>${esc(h.name || a)}${h.self ? " <span class='muted small'>(this Mac)</span>" : ""}</td><td class="small">${esc(h.self ? "Mac" : h.os === "windows" ? "Windows" : "Mac")}</td><td class="mono small">${esc(h.ssh || "")}</td><td class="small">${esc(h.role || "")}</td><td class="small">${(h.runs || []).filter((r) => r.kind === "task").map((r) => esc(r.name.replace(/^Start /, ""))).join(", ")}</td></tr>`).join("");
return `<div class="card"><div class="tablewrap"><table><tr><th>computer</th><th>kind</th><th>log-in</th><th>does</th><th>programs it can start</th></tr>${rows}</table></div>
const rows = Object.entries(c.hosts).map(([a, h]) => `<tr class="click${h.disabled ? " off" : ""}" onclick="location.hash='#computers/${esc(a)}'"><td>${h.self ? "" : onOff(`hosts.${a}`, h, "this computer")}</td><td>${esc(h.name || a)}${h.self ? " <span class='muted small'>(this Mac)</span>" : ""}</td><td class="small">${esc(h.self ? "Mac" : h.os === "windows" ? "Windows" : "Mac")}</td><td class="mono small">${esc(h.ssh || "")}</td><td class="small">${esc(h.role || "")}</td><td class="small">${(h.runs || []).filter((r) => r.kind === "task").map((r) => esc(r.name.replace(/^Start /, ""))).join(", ")}</td></tr>`).join("");
return `<div class="card"><div class="tablewrap"><table><tr><th></th><th>computer</th><th>kind</th><th>log-in</th><th>does</th><th>programs it can start</th></tr>${rows}</table></div>
${editing() ? `<div class="row" style="margin-top:8px"><button class="small" data-job="computer">+ Computer</button><button class="small" data-job="pcprogram" data-hint="A program on a Windows PC the panel may start (a scheduled task)">+ PC program</button></div>` : ""}</div>`;
}
async function programHtml() {
@@ -935,7 +1287,7 @@
const o = await api("/api/outlets");
const dis = editing() ? "" : "disabled";
const rows = Object.entries(o.outlets || {}).map(([n, v]) => `<tr><td><b>${esc(n)}</b></td><td class="small">${esc(v.name || "")}</td><td class="small" id="ost-${esc(n)}"><span class="muted">…</span></td><td>${editing() ? `<button class="small" data-oswitch="${esc(n)}|1" data-hint="Switch the outlet on">On</button> <button class="small danger" data-oswitch="${esc(n)}|0" data-hint="Switch the outlet off: whatever is on it loses power">Off</button> <button class="small" data-oremove="${esc(n)}">Remove</button>` : ""}</td></tr>`).join("");
const used = Object.entries(cfg().fixes).filter(([, f]) => f.template === "outlet_cycle").map(([id, f]) => `<span class="pill" data-hint="${esc(f.label || "")}">${esc(f.short || id)} → ${esc((f.params || {}).outlet || "?")}</span>`).join(" ");
const used = Object.entries(cfg().fixes).filter(([, f]) => f.template === "outlet_cycle").map(([id, f]) => `<span class="pill" data-hint="${esc(f.label || "")}">${onOff(`fixes.${id}`, f, "this power-cycle")} ${esc(f.short || id)} → ${esc((f.params || {}).outlet || "?")}</span>`).join(" ");
return `<div class="card"><div class="field"><span class="fl">SmartLife / Tuya keys${hint("Access ID and Access Secret of a Cloud Project on iot.tuya.com with your SmartLife app linked to it. They stay on this Mac in a private file, never in the settings file.")}</span><span class="row"><input id="oid" placeholder="Access ID" value="${esc(o.access_id || "")}" ${dis} style="width:14em"><input id="osecret" type="password" placeholder="${o.configured ? "Access Secret (kept)" : "Access Secret"}" ${dis} style="width:16em"><select id="oregion" ${dis}>${["us", "eu", "in", "cn"].map((r) => `<option ${o.region === r ? "selected" : ""}>${r}</option>`).join("")}</select>${editing() ? `<button class="small" id="okeys">Save & test</button>` : ""}</span></div>
${o.configured ? "" : `<details><summary>how to get the keys</summary><ol class="small"><li>iot.tuya.com, free account, Cloud › Create Cloud Project (Smart Home, your region's data center).</li><li>In the project: Devices › Link App Account › scan the code with the SmartLife app (Me › scan icon).</li><li>Overview tab: copy Access ID and Access Secret here.</li></ol></details>`}</div>
<div class="card"><div class="tablewrap"><table><tr><th>outlet</th><th>plug</th><th>now</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>none yet</td></tr>"}</table></div>
@@ -976,12 +1328,23 @@
async function companionHtml() {
const cc = await api("/api/companion");
const lights = Object.entries(cc.lights || {}).map(([k, v]) => `<span class="chip ${v === true ? "ok" : v === false ? "bad" : ""}">${lightDot(v === true ? "ok" : v === false ? "problem" : "unknown")}${esc(k)}</span>`).join("");
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed", r.enabled ? r.category || "" : "off")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small muted">${esc(r.message || "")}</td><td class="small">${(r.watched_by || []).length ? r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : `<span class="muted">not watched</span>${editing() ? ` <button class="small" data-addconn="${esc(r.label)}" data-hint="A new key that watches this connection">+ Key</button>` : ""}`}</td></tr>`).join("");
const conns = (cc.connections || []).map((r) => `<tr><td>${lightDot(r.category === "good" ? "ok" : r.enabled ? "problem" : "not_needed", r.enabled ? r.category || "" : "off")}${esc(r.label)}</td><td class="mono small">${esc(r.module || "")}</td><td class="small muted">${esc(r.message || "")}</td><td class="small">${(r.watched_by || []).length ? connSwitch(r.label) + " " + r.watched_by.map((k) => `<a href="#keys/${esc(k)}">${esc(k.toUpperCase())}</a>`).join(", ") : `<span class="muted">not watched</span>${editing() ? ` <button class="small" data-addconn="${esc(r.label)}" data-hint="A new key that watches this connection">+ Key</button>` : ""}`}</td></tr>`).join("");
return `<div class="card"><div class="chips">${lights}${cc.build ? `<span class="chip">${esc(cc.build)}</span>` : ""}${cc.page_number_buttons ? `<span class="chip" data-hint="Page-number keys coloured, recorded in ${esc(cc.page_number_buttons.recorded_in)}">${cc.page_number_buttons.pages_coloured} pages coloured</span>` : ""}</div>
${(cc.missing || []).length ? `<p class="notice">Keys watch connections Companion does not have: ${esc(cc.missing.join(", "))}</p>` : ""}
<div class="tablewrap" style="margin-top:8px"><table><tr><th>connection</th><th>module</th><th></th><th>watched by</th></tr>${conns}</table></div></div>` + fieldsHtml("Companion");
}
function danteCheck(c) { const t = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "dante")); return t ? t.checks.find((ch) => ch.type === "dante") : null; }
// Every check that watches one Companion connection, switched on or off together.
function connChecks(c, label) { const out = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.conn === label) out.push([ti, j, ch]); })); return out; }
function connSwitch(label) {
const list = connChecks(cfg(), label), on = list.some(([, , ch]) => !ch.disabled);
return `<label class="onoff${on ? " on" : ""}" data-hint="${on ? "Watched. Switch off to stop watching it (the checks stay in the settings)." : "Not watched (switched off). Switch on to watch it again."}"><input type="checkbox" data-connoff="${esc(label)}" ${on ? "checked" : ""} ${editing() ? "" : "disabled"}><span>${on ? "ON" : "OFF"}</span></label>`;
}
// The DANTE key's own device list: the Dante check with the most devices (STB AUDIO also watches one Dante device).
function danteCheck(c) {
let best = null;
for (const t of c.tiles) for (const ch of t.checks || []) if (ch.type === "dante" && (!best || Object.keys(ch.devices || {}).length > Object.keys(best.devices || {}).length)) best = ch;
return best;
}
async function danteHtml(c) {
const chk = danteCheck(c);
if (!chk) return "";
@@ -989,16 +1352,18 @@
const map = snap.dante_map || {};
const fixOf = (n) => typeof chk.fix === "object" && chk.fix ? (chk.fix[n] || "") : "";
const fixSel = (n) => `<select data-dantefix="${esc(n)}" ${editing() ? "" : "disabled"}><option value="">(everything else)</option><option value="dante-auto" ${fixOf(n) === "dante-auto" ? "selected" : ""}>Dante auto</option>${Object.entries(c.fixes).map(([id, f]) => `<option value="${esc(id)}" ${fixOf(n) === id ? "selected" : ""}>${esc(f.short || id)}</option>`).join("")}</select>`;
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; return `<tr><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">Remove</button>` : ""}</td></tr>`; }).join("");
const offs = chk.devices_off || [];
const rows = Object.entries(chk.devices || {}).map(([n, a]) => { const e = map[n] || {}; const off = offs.includes(n); return `<tr class="${off ? "off" : ""}"><td><label class="onoff${off ? "" : " on"}" data-hint="${off ? "Off: kept in the list, not watched" : "On: watched. Switch off to keep it in the list without watching it."}"><input type="checkbox" data-danteoff="${esc(n)}" ${off ? "" : "checked"} ${editing() ? "" : "disabled"}><span>${off ? "OFF" : "ON"}</span></label></td><td class="mono small">${esc(n)}</td><td>${editing() ? `<input data-dantekey="${esc(n)}" value="${esc(a)}" size="10">` : esc(a)}</td><td class="mono small">${esc(e.ip || "")}</td><td class="small">${e.switch ? esc(e.switch + " port " + e.port) : ""}</td><td>${e.poe ? "yes" : ""}</td><td>${fixSel(n)}</td><td>${editing() ? `<button class="small danger" data-dantedel="${esc(n)}">Remove</button>` : ""}</td></tr>`; }).join("");
const extra = Object.keys(map).filter((n) => !((chk.devices || {})[n]));
return `<div class="card"><div class="tablewrap"><table><tr><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th><th>when gone, hold runs</th><th></th></tr>${rows}</table></div>
return `<div class="card"><div class="tablewrap"><table><tr><th></th><th>Dante name</th><th>key text</th><th>address</th><th>switch / port</th><th>PoE</th><th>when gone, hold runs</th><th></th></tr>${rows}</table></div>
<p class="small muted">Map from the last survey ${snap.dante_map_age != null ? esc(ago(snap.dante_map_age)) : "(none yet)"}.${editing() ? ` <button class="small" id="resurvey">Check the Dante network now</button>` : ""}</p>
${editing() ? `<div class="row"><input id="dantenew" placeholder="Dante name" list="dantefound" style="width:16em"><datalist id="dantefound">${extra.map((n) => `<option value="${esc(n)}">`).join("")}</datalist> <input id="dantenewkey" placeholder="key text" size="10"> <button class="small" id="danteadd">+ Device</button>${extra.length ? `<span class="help">seen, not listed: ${esc(extra.join(", "))}</span>` : ""}</div>` : ""}</div>`;
}
async function atemHtml(c) {
const folder = await api("/api/atem-reference/folder").catch(() => ({ files: [], mounted: false }));
const pending = state.atemPending ? `<div class="notice">A new saved switcher file is waiting for Apply. ${editing() ? `<button class="small" id="atemdiscard">Discard it</button>` : ""}</div>` : "";
return `<div class="card">${pending}
const atemChecks = []; c.tiles.forEach((t, ti) => (t.checks || []).forEach((ch, j) => { if (ch.type === "atem" || (ch.type === "pref" && /atem/i.test(ch.domain || ""))) atemChecks.push(`<label class="inline small" style="margin-right:14px">${onOff(`tiles.${ti}.checks.${j}`, ch, "this switcher check")} ${esc(ch.type === "atem" ? "switcher " + ch.part + " matches the file" : ch.msg || "ATEM Software Control setting")}</label>`); }));
return `<div class="card">${atemChecks.length ? `<p style="margin-top:0">${atemChecks.join("")}</p>` : ""}${pending}
${editing() ? `<div class="row"><label class="btn" data-hint="A file saved from ATEM Software Control">Upload a saved file <input type="file" id="atemfile" accept=".xml" hidden></label><button id="atemnewest" ${folder.mounted && (folder.files || []).length ? "" : "disabled"} data-hint="The newest .xml in the folder below">Pick the newest</button></div>` : ""}
<p class="small muted">${esc(folder.folder ? folder.folder.replace(/^.*\/Shared drives\//, "Shared drives/") : "(no folder set)")} ${folder.mounted ? `· newest: ${(folder.files || []).length ? esc(folder.files[0].name) + " (" + esc(when(folder.files[0].when)) + ")" : "none"}` : "· <b>not mounted</b>"}</p>
${folder.blocked ? `<div class="notice grey">The folder is there but this page cannot read it. Use Upload, or let the panel's key log in to this Mac (Computers › Setup).</div>` : ""}<p id="ateminfo" class="help"></p></div>`;
@@ -1006,6 +1371,102 @@
function wireSettings(which) {
const q = (s) => main.querySelector(s);
wireCalendars();
for (const el of main.querySelectorAll(".copyline")) el.onclick = () => el.select();
if (q("#looknow")) q("#looknow").onclick = async () => { try { await post("/api/discover/now", {}); alertBox("Looking now. This page fills in within a minute."); setTimeout(() => route(), 30000); } catch (e) { alertBox(e.message); } };
for (const b of main.querySelectorAll("[data-changepress]")) b.onclick = () => {
const id = b.dataset.changepress, f0 = cfg().fixes[id];
pickButtonDialog(`Which button is ${(f0.params || {}).button || id}?`, { page: f0.page, row: f0.row, col: f0.col }, async (p) => {
if (!ensureDraft()) return;
const f = state.draft.config.fixes[id];
const params = Object.assign({}, f.params || {}, { page: p.page, row: p.row, col: p.col }, p.text ? { button: p.text.replace(/\n/g, " ").trim() } : {});
const r = await post("/api/build", { fix: Object.assign({}, f, { template: "companion_press", params }) });
if (!r.ok) return alertBox(r.error);
const nf = r.fix; if (r.label) nf.label = r.label;
state.draft.config.fixes[id] = nf; saveDraft(); route();
});
};
for (const inp of main.querySelectorAll("[data-press]")) inp.onchange = async () => {
if (!ensureDraft()) return;
const [id, k] = inp.dataset.press.split("|"), f = state.draft.config.fixes[id];
const params = Object.assign({}, f.params || {}, { [k]: inp.value });
const r = await post("/api/build", { fix: Object.assign({}, f, { template: "companion_press", params }) });
if (!r.ok) return alertBox(r.error);
const nf = r.fix; if (r.label) nf.label = r.label;
state.draft.config.fixes[id] = nf; saveDraft(); route();
};
const ab = q("#as_addbtn"); if (ab) ab.onclick = () => addSwitchButton();
const ac = q("#as_addcheck"); if (ac) ac.onclick = () => addSwitchTrigger();
for (const b of main.querySelectorAll("[data-delpress]")) b.onclick = () => {
const id = b.dataset.delpress, d = state.draft ? state.draft.config : cfg();
const users = [...(fixUsers(d)[id] || [])];
const undoOf = Object.entries(d.fixes).filter(([, f]) => f.undo === id).map(([u]) => u);
confirmBox(`Remove the ${(d.fixes[id].params || {}).button || id} button from the panel?` + (users.length ? ` It is the switch for: ${users.join(", ")}; those checks lose their switch.` : "") + " Companion keeps the button itself.", "Remove", () => {
if (!ensureDraft()) return;
const c2 = state.draft.config;
for (const t of c2.tiles.concat((c2.ready_page || { keys: [] }).keys)) for (const ch of t.checks || []) {
if (ch.fix === id) delete ch.fix;
else if (ch.fix && typeof ch.fix === "object") for (const [k, v] of Object.entries(ch.fix)) if (v === id) delete ch.fix[k];
if (t.hold_always === id) delete t.hold_always;
}
if (c2.restart_page) c2.restart_page.keys = c2.restart_page.keys.filter((k) => k.fix !== id);
for (const u of undoOf) delete c2.fixes[u].undo;
c2.auto_fix.fixes = (c2.auto_fix.fixes || []).filter((f) => f !== id);
delete c2.fixes[id]; saveDraft(); route();
});
};
for (const el of main.querySelectorAll("[data-trigfix]")) el.onchange = () => {
if (!ensureDraft()) return;
const [ti, j] = el.dataset.trigfix.split("|").map(Number), ch = state.draft.config.tiles[ti].checks[j];
if (ch.fix && typeof ch.fix === "object") for (const k of Object.keys(ch.fix)) ch.fix[k] = el.value; else ch.fix = el.value;
saveDraft(); route();
};
const rebuildProc = async (ti, j, host, proc) => {
if (!ensureDraft()) return;
const ch = state.draft.config.tiles[ti].checks[j];
if (!proc) return alertBox("Which program?");
const h = state.draft.config.hosts[host] || {};
const keep = { fix: ch.fix, interval_seconds: ch.interval_seconds, debounce_seconds: ch.debounce_seconds };
let nc;
if (h.os === "windows") {
const exe = /\.exe$/i.test(proc) ? proc : proc + ".exe";
if (!/^[A-Za-z0-9 ._()\-]+$/.test(exe)) return alertBox("That program name has characters a Windows check cannot use.");
nc = { type: "remote", host, cmd: `tasklist /NH /FI "IMAGENAME eq ${exe}" | find /I "${exe}"`, msg: exe.replace(/\.exe$/i, "").toUpperCase() + " NOT RUNNING" };
} else {
const r = await post("/api/build-check", { check: { type: "remote", template: "mac_program", params: { host, process: proc }, msg: proc.toUpperCase() + " NOT RUNNING" } });
if (!r.ok) return alertBox(r.error);
nc = r.check;
}
for (const [k, v] of Object.entries(keep)) if (v !== undefined) nc[k] = v;
state.draft.config.tiles[ti].checks[j] = nc; saveDraft(); route();
};
for (const el of main.querySelectorAll("[data-trigproc]")) el.onchange = () => { const [ti, j] = el.dataset.trigproc.split("|").map(Number); rebuildProc(ti, j, cfg().tiles[ti].checks[j].host, el.value.trim()); };
for (const el of main.querySelectorAll("[data-trighost]")) el.onchange = () => { const [ti, j] = el.dataset.trighost.split("|").map(Number); const inp = main.querySelector(`[data-trigproc="${ti}|${j}"]`); rebuildProc(ti, j, el.value, inp ? inp.value.trim() : ""); };
for (const el of main.querySelectorAll("[data-trigdev]")) el.onchange = () => {
if (!ensureDraft()) return;
const [ti, j] = el.dataset.trigdev.split("|").map(Number), ch = state.draft.config.tiles[ti].checks[j], dev = el.value.trim();
if (!dev) return alertBox("Which Dante device?");
const fx = ch.fix && typeof ch.fix === "object" ? Object.values(ch.fix)[0] : ch.fix;
ch.devices = { [dev]: dev.replace(/^.*?-/, "").slice(0, 10).toUpperCase() }; ch.fix = { [dev]: fx }; saveDraft(); route();
};
for (const b of main.querySelectorAll("[data-rmswitch]")) b.onclick = () => confirmBox("Remove this from what counts as down?", "Remove", () => {
if (!ensureDraft()) return;
const [ti, j] = b.dataset.rmswitch.split("|").map(Number);
const d = state.draft.config, t = d.tiles[ti];
t.checks.splice(j, 1);
if (!t.checks.length && !t.pos && !t.kind && !t.members) { // an empty hidden key: gone with its last check
d.tiles.splice(ti, 1);
for (const g of d.tiles) if (g.members) g.members = g.members.filter((m) => m !== t.id);
}
saveDraft(); route();
});
for (const b of main.querySelectorAll("[data-runpress]")) b.onclick = () => {
const id = b.dataset.runpress, users = fixUsers(cfg())[id];
const key = cfg().tiles.find((t) => (t.checks || []).some((ch) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id)));
if (!key) return alertBox("No key uses this button yet, so it cannot be pressed from here; press it in Companion.");
const j = key.checks.findIndex((ch) => (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix]).includes(id));
runNow(key.id, id, j);
};
for (const ta of main.querySelectorAll("[data-jsonpath]")) ta.onchange = () => { try { setPath(ta.dataset.jsonpath, JSON.parse(ta.value)); } catch (e) { alertBox("That is not valid: " + e.message); } };
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } };
const oa = q("#oadd"); if (oa) oa.onclick = outletDialog;
@@ -1013,8 +1474,26 @@
for (const b of main.querySelectorAll("[data-oremove]")) b.onclick = () => confirmBox(`Remove the ${b.dataset.oremove} outlet from the list? Fixes that use it stop working.`, "Remove", async () => { try { await post("/api/outlets", { name: b.dataset.oremove, remove: true }); route(); } catch (e) { alertBox(e.message); } });
for (const td of main.querySelectorAll("[id^='ost-']")) api("/api/outlets/status?name=" + encodeURIComponent(td.id.slice(4))).then((r) => { td.innerHTML = r.ok ? `<span class="chip ${r.on ? "ok" : ""}">${r.on ? "on" : "off"}</span>` : `<span class="muted" data-hint="${esc(r.error)}">?</span>`; }).catch(() => {});
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status");
if (pos) { t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; return; }
// The Status page is full: put it inside a group key, where it shows and a hold on the group fixes it.
const groups = d.tiles.filter((g) => g.members);
dialog(`<h3>No free spot on the Status page</h3><p class="small">Put ${esc(conn)} inside a group key: its problems show on that key, and holding the key reconnects it.</p><select id="ngroup">${groups.map((g) => `<option value="${esc(g.id)}">${esc(g.label)} (${esc(g.members.join(", "))})</option>`).join("")}</select><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (bx) => {
bx.querySelector("#dcancel").onclick = closeDialog;
bx.querySelector("#dok").onclick = () => { const g = d.tiles.find((x) => x.id === bx.querySelector("#ngroup").value); d.tiles.push(t); if (g) g.members.push(id); saveDraft(); closeDialog(); location.hash = "#keys/" + id; };
}); };
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const d = state.draft.config, id = cb.dataset.autofix, f = d.fixes[id]; const set = new Set(d.auto_fix.fixes || []); if (cb.checked) { set.add(id); if (closes(f)) f.restart_ok = true; if (f.never_auto) delete f.never_auto; } else set.delete(id); setPath("auto_fix.fixes", [...set]); };
for (const el of main.querySelectorAll("[data-midiaddr]")) el.onchange = () => {
if (!ensureDraft()) return;
const d = state.draft.config, [ti, j] = el.dataset.midiaddr.split("|").map(Number), ch = d.tiles[ti].checks[j], v = el.value.trim();
for (const t of d.tiles.concat((d.ready_page || {}).keys || [])) for (const x of t.checks || [])
if (x.type === "midi_peer" && (x.host || "") === (ch.host || "") && x.session === ch.session && x.peer === ch.peer) { if (v) x.address = v; else delete x.address; }
saveDraft(); route();
};
const aw = main.querySelector("#addwired");
if (aw) aw.onclick = () => { if (!ensureDraft()) return; const d = state.draft.config; const t = d.tiles.find((x) => (x.checks || []).some((ch) => ch.type === "midi_peer")); if (!t) return alertBox("Add a MIDI link first.");
const own = d.hosts && Object.values(d.hosts).find((h) => h.self); const ip = own && (own.ip || ""); const guess = /^(\d+\.\d+)\./.exec(ip || ((d.site || {}).ingest_ips || [])[0] || "");
t.checks.push({ type: "midi_wired", nets: [guess ? guess[1] + ".0.0/16" : "10.1.0.0/16"], fix: Object.keys(d.fixes).find((id) => d.fixes[id].kind === "midi_connect") }); saveDraft(); route(); };
for (const b of main.querySelectorAll("[data-rmcheck]")) b.onclick = () => confirmBox("Remove this MIDI link from its key?", "Remove", () => { if (!ensureDraft()) return; const [tid, j] = b.dataset.rmcheck.split("|"); const t = state.draft.config.tiles.find((x) => x.id === tid); t.checks.splice(+j, 1); saveDraft(); route(); });
const ds = q("#decksave"); if (ds) ds.onclick = () => applyDecks(main);
const dc = () => danteCheck(state.draft.config);
@@ -1072,25 +1551,31 @@
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
// The wired network(s) MIDI must ride (the MIDI key's wired check): suggest and insist on an address on it.
const nets = midiNets(c), wired = (a) => onWired(nets, a);
const wiredFor = (peer) => { for (const src of [live.local || {}].concat(Object.values(live.remote || {}))) for (const s of Object.values(src)) for (const p of s.peers || []) if (p.name === peer && wired(p.address)) return p.address;
const h = Object.values(c.hosts).find((x) => x.name === peer || (x.midi_name || "") === peer); const ip = h && String(h.ssh || "").split("@").pop(); return ip && wired(ip) ? ip + ":5004" : ""; };
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
dialog(`<h3>MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}</h3>
<select id="jhost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}${h.self ? " (this Mac)" : ""}</option>`).join("")}</select>
<input id="jsession" list="jsessions" placeholder="session, as Audio MIDI Setup names it"><datalist id="jsessions"></datalist>
<input id="jpeer" list="jpeers" placeholder="peer (the other end)"><datalist id="jpeers"></datalist>
<input id="jtag" placeholder="key text when lost (LIGHT PC)" maxlength="10">
<input id="jaddr" placeholder="address, if it must be reconnected by address (optional)">
<input id="jaddr" placeholder="${nets.length ? "wired address, like 10.1.10.206:5004" : "address, if it must be reconnected by address (optional)"}">
<select id="jkey">${midiKey ? `<option value="${esc(midiKey.id)}">on the ${esc(midiKey.label)} key</option>` : ""}<option value="__new">on a new key</option></select>
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `<option value="${esc(n)}">`).join(""); const cur = ss.find(([n]) => n === b.querySelector("#jsession").value) || ss[0]; if (cur && !b.querySelector("#jsession").value) b.querySelector("#jsession").value = cur[0]; b.querySelector("#jpeers").innerHTML = ((cur || [])[1] || []).map((n) => `<option value="${esc(n)}">`).join(""); };
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
b.querySelector("#jsession").oninput = fill;
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; };
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; const w = wiredFor(b.querySelector("#jpeer").value.trim()); if (w && !b.querySelector("#jaddr").dataset.typed) b.querySelector("#jaddr").value = w; };
b.querySelector("#jaddr").oninput = (e) => { e.target.dataset.typed = "1"; };
fill();
b.querySelector("#dok").onclick = () => {
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
if (!session || !peer) return alertBox("A session and a peer, please.");
if (nets.length && !wired(addr)) return alertBox(`MIDI rides the wired network (${nets.join(", ")}). Give ${peer}'s wired address, like 10.1.10.206:5004: a name or a Wi-Fi address can drop mid-service.`);
if (!ensureDraft()) return;
const d = state.draft.config;
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
@@ -1172,13 +1657,11 @@
}
// ---- Fixes (a tab of Keys) ----------------------------------------------------------
let tplMsg = ""; // a builder's complaint, shown once after the next paint
async function fixes(rest) {
const c = cfg(), auto = new Set(c.auto_fix.fixes || []);
const used = {};
const use = (f, who) => { if (f) (used[f] = used[f] || new Set()).add(who); };
for (const t of c.tiles) { for (const ch of t.checks || []) for (const f of (typeof ch.fix === "object" && ch.fix ? Object.values(ch.fix) : [ch.fix])) use(f, t.label); use(t.hold_always, t.label); }
for (const k of (c.restart_page || { keys: [] }).keys) use(k.fix, "restart key " + k.label);
const rows = Object.entries(c.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}" onclick="location.hash='#keys/fixes/${esc(id)}'"><td><b>${esc(f.short || id)}</b></td><td class="small">${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${auto.has(id) ? "by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const used = fixUsers(c);
const rows = Object.entries(c.fixes).map(([id, f]) => `<tr class="click${rest[0] === id ? " sel" : ""}${f.disabled ? " off" : ""}" onclick="location.hash='#keys/fixes/${esc(id)}'"><td>${onOff(`fixes.${id}`, f, "this fix")}</td><td><b>${esc(f.short || id)}</b></td><td class="small">${esc(f.label || "")}</td><td class="small">${esc(f.kind || "ssh")}${f.host ? " on " + esc((c.hosts[f.host] || {}).name || f.host) : f.kind === "local" || f.kind === "spotify" ? " on this Mac" : ""}</td><td class="small">${[...(used[id] || [])].join(", ")}</td><td class="small">${f.failover ? "by itself (failover)" : auto.has(id) ? "by itself" : f.never_auto ? "never by itself" : "hold only"}</td></tr>`).join("");
const id = rest[0], f = c.fixes[id];
let detail = "";
if (f) {
@@ -1189,21 +1672,22 @@
let inputs = "";
if (editing()) {
inputs += `<div class="field"><span class="fl">built as</span><select id="tplsel">${tpls.map((t) => `<option value="${t.name}" ${t.name === tplName ? "selected" : ""}>${esc(t.words)}</option>`).join("")}</select></div>`;
inputs += `<div id="tplform">${paramsHtml(tpl, f.params || {}, "fx")}</div><p id="tplmsg" class="help"></p>`;
inputs += `<div id="tplform">${paramsHtml(tpl, f.params || {}, "fx")}</div><p id="tplmsg" class="help">${esc(tplMsg)}</p>`; tplMsg = "";
inputs += inputHtml(`fixes.${id}.label`, F("fixes.*.label"), f.label) + inputHtml(`fixes.${id}.short`, F("fixes.*.short"), f.short);
if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).filter((fl) => !tpl.params.some((p) => p.name === fl)).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("");
if (tplName === "custom") {
inputs += `<div class="field"><span class="fl">runs on</span><select data-path="fixes.${esc(id)}.kind" class="kindsel">${["ssh", "local"].map((k) => `<option ${kind === k ? "selected" : ""}>${k === "ssh" ? "another computer" : "this Mac"}</option>`).join("")}</select></div>`;
if (kind === "ssh") inputs += `<div class="field"><span class="fl">computer</span><select data-path="fixes.${esc(id)}.host"><option value="">–</option>${Object.entries(c.hosts).filter(([, h]) => !h.self).map(([a, h]) => `<option value="${esc(a)}" ${f.host === a ? "selected" : ""}>${esc(h.name || a)}</option>`).join("")}</select></div>`;
inputs += `<div class="field block"><span class="fl">command${hint("Typed by hand: zsh on a Mac, cmd.exe on a PC. Shown as a command at Apply; never run before Apply.")}</span><textarea data-path="fixes.${esc(id)}.run" rows="3" class="mono">${esc(f.run || "")}</textarea></div>`;
} else if (f.run) inputs += `<details><summary>the command it makes${f.kind === "local" ? "" : " (on " + esc((c.hosts[f.host] || {}).name || f.host) + ")"}</summary><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></details>`;
inputs += `<details><summary>more</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}</details>`;
const undoSel = `<div class="field"><span class="fl">undo${hint(F("fixes.*.undo").help)}</span><select data-path="fixes.${esc(id)}.undo"><option value="">none</option>${Object.entries(c.fixes).filter(([o]) => o !== id).map(([o, fx]) => `<option value="${esc(o)}" ${f.undo === o ? "selected" : ""}>${esc(fx.short || o)} – ${esc(fx.label || "")}</option>`).join("")}</select></div>`;
inputs += `<details ${f.failover ? "open" : ""}><summary>more</summary>${["reconnect", "conn", "restart_ok", "never_auto", "timeout", "failover"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}${undoSel}</details>`;
} else inputs = `<p class="small muted">Built as: ${esc(tpl.words)}</p>${f.run ? `<details><summary>the command</summary><pre class="log small" style="max-height:160px">${esc(f.run)}</pre></details>` : ""}`;
detail = `<div class="card"><h3 style="margin-top:0">${esc(f.short || id)} <span class="muted small">${esc(f.label || "")}</span></h3>${inputs}${keys.length ? `<p class="small muted">Used by ${esc(keys.join(", "))}.</p>` : ""}<details><summary>everything stored</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete fix</button>` : ""}</div>`;
detail = `<div class="card"><h3 style="margin-top:0">${onOff(`fixes.${id}`, f, "this fix")} ${esc(f.short || id)} <span class="muted small">${esc(f.label || "")}${f.disabled ? " · off: never runs" : ""}</span></h3>${inputs}${keys.length ? `<p class="small muted">Used by ${esc(keys.join(", "))}.</p>` : ""}<details><summary>everything stored</summary><dl class="kv">${Object.entries(f).map(([k, v]) => `<dt>${esc(k)}</dt><dd class="mono small">${esc(typeof v === "object" ? JSON.stringify(v) : v)}</dd>`).join("")}<dt>id</dt><dd class="mono">${esc(id)}</dd></dl></details>${editing() && !keys.length ? `<button class="danger small" id="delfix">Delete fix</button>` : ""}</div>`;
}
const add = editing() ? `<span class="row" style="margin-left:auto"><input id="newfix" placeholder="new fix id" style="width:10em;padding:5px 8px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--fg);font:inherit"><button id="addfix" class="small">+ Fix</button></span>` : "";
main.innerHTML = `<h1>Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}</h1><div class="tabs"><button onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button onclick="location.hash='#keys/restart'">Restart page</button>` : ""}<button class="on">Fixes</button>${add}</div>
<div class="cols"><div class="tablewrap"><table><tr><th>key text</th><th>does</th><th>built as</th><th>used by</th><th>runs</th></tr>${rows}</table></div><div>${detail}</div></div>`;
main.innerHTML = `<h1>Keys${hint("Everything a hold can run. Click a fix to see how it is built.")}</h1><div class="tabs"><button onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button onclick="location.hash='#keys/restart'">Restart page</button>` : ""}${c.ready_page ? `<button onclick="location.hash='#keys/ready'">READY page</button>` : ""}<button class="on">Fixes</button>${add}</div>
<div class="cols"><div class="tablewrap"><table><tr><th></th><th>key text</th><th>does</th><th>built as</th><th>used by</th><th>runs</th></tr>${rows}</table></div><div>${detail}</div></div>`;
const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); };
const rebuild = async () => {
if (!ensureDraft()) return;
@@ -1211,12 +1695,11 @@
const params = readParams(main.querySelector("#tplform"), tpl);
if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; }
const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) });
const msg = main.querySelector("#tplmsg");
if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; }
// Not buildable yet (a field is still blank): keep the choice, repaint so the builder's own fields show, say why.
if (!r.ok) { tplMsg = r.error; fx.template = tplName; fx.params = params; saveDraft(); route(); return; }
const keep = { label: fx.label, short: fx.short };
Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k];
if (!keep.label && r.label) fx.label = r.label;
msg.textContent = "";
saveDraft(); route();
};
const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; }
+16
View File
@@ -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; }
+6 -1
View File
@@ -95,5 +95,10 @@
"burst_groups": 3,
"burst_window_seconds": 10,
"remember_seconds": 300
}
},
"nav_keys": [
"pagenum"
],
"nav_hold_ms": 1000,
"nav_tap_page": 1
}
+581
View File
@@ -0,0 +1,581 @@
{
"site": {
"id": "ws",
"name": "Woonsocket",
"ingest_name": "WS ProPresenter",
"ingest_hostname": "WS-ProPresenter-3.local",
"ingest_ips": [
"10.2.2.210",
"169.254.104.16"
],
"contacts": "",
"live_services": [],
"editing_code": "9248"
},
"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": [
"superrack_ws_foh",
"x32edit_ws_foh",
"vista_ws_lights",
"ableton_ws_tracks",
"propresenter_ingest",
"midi_reconnect"
]
},
"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": "superrack",
"label": "SUPERRACK",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "ws_foh",
"process": "SuperRack SoundGrid"
},
"msg": "SUPERRACK NOT RUNNING",
"fix": "superrack_ws_foh",
"host": "ws_foh",
"cmd": "pgrep -xq 'SuperRack SoundGrid'"
},
{
"type": "remote",
"template": "soundgrid",
"params": {
"host": "ws_foh"
},
"msg": "SOUNDGRID LOST",
"host": "ws_foh",
"cmd": "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'"
}
],
"pos": [
0,
2
]
},
{
"id": "x32_edit",
"label": "X32 EDIT",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "ws_foh",
"process": "X32-Edit"
},
"msg": "X32 EDIT NOT RUNNING",
"fix": "x32edit_ws_foh",
"host": "ws_foh",
"cmd": "pgrep -xq X32-Edit"
}
],
"pos": [
0,
3
]
},
{
"id": "m32",
"label": "M32",
"checks": [
{
"type": "conn",
"conn": "m32"
}
],
"pos": [
0,
4
]
},
{
"id": "vista",
"label": "VISTA",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "ws_lights",
"process": "Vista"
},
"msg": "VISTA NOT RUNNING",
"fix": "vista_ws_lights",
"host": "ws_lights",
"cmd": "pgrep -xq Vista"
}
],
"pos": [
0,
5
]
},
{
"id": "ableton",
"label": "ABLETON",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "ws_tracks",
"process": "Live"
},
"msg": "ABLETON NOT RUNNING",
"fix": "ableton_ws_tracks",
"host": "ws_tracks",
"cmd": "pgrep -xq Live"
}
],
"pos": [
0,
6
]
},
{
"id": "prop",
"label": "PROP",
"checks": [
{
"type": "conn",
"conn": "Propresenter-API"
},
{
"type": "process",
"match": "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter",
"name": "PROP",
"fix": "propresenter_ingest"
}
],
"pos": [
0,
7
]
},
{
"id": "midi",
"label": "MIDI",
"checks": [
{
"type": "midi_peer",
"session": "WS FOH Mac mini",
"peer": "WS Tracks",
"address": "10.2.1.219:5004",
"fix": "midi_reconnect",
"tag": "WS TRACKS",
"host": "ws_foh"
},
{
"type": "midi_peer",
"session": "Lighting",
"peer": "WS Tracks",
"address": "10.2.1.219:5004",
"fix": "midi_reconnect",
"tag": "WS TRACKS",
"host": "ws_lights"
},
{
"type": "midi_wired",
"nets": [
"10.2.0.0/16"
],
"fix": "midi_reconnect"
}
],
"pos": [
1,
1
]
},
{
"id": "atem",
"label": "ATEM",
"checks": [
{
"type": "conn",
"conn": "ATEM"
}
],
"pos": [
1,
2
]
},
{
"id": "resi",
"label": "RESI",
"checks": [
{
"type": "conn",
"conn": "Resi"
},
{
"type": "ping",
"conn": "Resi"
}
],
"pos": [
1,
3
]
}
],
"fixes": {
"superrack_ws_foh": {
"template": "mac_start",
"params": {
"host": "ws_foh",
"process": "SuperRack SoundGrid",
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app"
},
"label": "start SuperRack on WS FOH if it is not running",
"short": "SUPERRACK",
"kind": "ssh",
"host": "ws_foh",
"run": "pgrep -xq 'SuperRack SoundGrid' || open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'"
},
"restart_superrack_ws_foh": {
"template": "mac_restart",
"params": {
"host": "ws_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 WS FOH (closes it first)",
"short": "SUPERRACK",
"kind": "ssh",
"host": "ws_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_ws_foh": {
"template": "mac_start",
"params": {
"host": "ws_foh",
"process": "X32-Edit",
"app": "/Applications/X32-Edit.app"
},
"label": "start X32-Edit on WS FOH if it is not running",
"short": "X32 EDIT",
"kind": "ssh",
"host": "ws_foh",
"run": "pgrep -xq X32-Edit || open /Applications/X32-Edit.app"
},
"vista_ws_lights": {
"template": "mac_start",
"params": {
"host": "ws_lights",
"process": "Vista",
"app": "/Applications/Vista.app"
},
"label": "start Vista on WS Lights if it is not running",
"short": "VISTA",
"kind": "ssh",
"host": "ws_lights",
"run": "pgrep -xq Vista || open /Applications/Vista.app"
},
"restart_vista_ws_lights": {
"template": "mac_restart",
"params": {
"host": "ws_lights",
"process": "Vista",
"app": "/Applications/Vista.app",
"wait_seconds": 10,
"force": true,
"background": false,
"stop_children": false,
"newest_of": ""
},
"label": "restart Vista on WS Lights (closes it first)",
"short": "VISTA",
"kind": "ssh",
"host": "ws_lights",
"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"
},
"ableton_ws_tracks": {
"template": "ableton",
"params": {
"host": "ws_tracks",
"app": "Ableton Live 11 Suite",
"restart": false,
"wait_seconds": 15
},
"label": "start Ableton on WS Tracks if it is not running",
"short": "ABLETON",
"kind": "ssh",
"host": "ws_tracks",
"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_ws_tracks": {
"template": "ableton",
"params": {
"host": "ws_tracks",
"app": "Ableton Live 11 Suite",
"restart": true,
"wait_seconds": 15
},
"label": "restart Ableton on WS Tracks (closes it first)",
"short": "ABLETON",
"kind": "ssh",
"host": "ws_tracks",
"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"
},
"propresenter_ingest": {
"template": "mac_start",
"params": {
"host": "ingest",
"process": "ProPresenter",
"app": "/Applications/ProPresenter.app"
},
"label": "start ProPresenter on this Mac if it is not running",
"short": "PROP",
"kind": "local",
"run": "pgrep -xq ProPresenter || open /Applications/ProPresenter.app"
},
"restart_propresenter_ingest": {
"template": "mac_restart",
"params": {
"host": "ingest",
"process": "ProPresenter",
"app": "/Applications/ProPresenter.app",
"wait_seconds": 10,
"force": true,
"background": false,
"stop_children": false,
"newest_of": ""
},
"label": "restart ProPresenter on this Mac (closes it first)",
"short": "PROP",
"kind": "local",
"run": "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 /Applications/ProPresenter.app"
},
"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"
}
},
"hosts": {
"ingest": {
"self": true,
"name": "WS 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"
}
]
},
"ws_foh": {
"name": "WS FOH",
"os": "mac",
"ssh": "watersmedia@10.2.1.12",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
]
},
"ws_lights": {
"name": "WS Lights",
"os": "mac",
"ssh": "wslighting@10.2.2.2",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
]
},
"ws_tracks": {
"name": "WS Tracks",
"os": "mac",
"ssh": "wstracks@10.2.1.219",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
]
}
},
"gear": {},
"debounce_seconds": 3,
"network": {
"interface": "en14",
"gateway": "10.2.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_superrack",
"label": "SUPERRACK",
"fix": "restart_superrack_ws_foh",
"pos": [
0,
1
]
},
{
"id": "rs_vista",
"label": "VISTA",
"fix": "restart_vista_ws_lights",
"pos": [
0,
2
]
},
{
"id": "rs_ableton",
"label": "ABLETON",
"fix": "restart_ableton_ws_tracks",
"pos": [
0,
3
]
},
{
"id": "rs_prop",
"label": "PROP",
"fix": "restart_propresenter_ingest",
"pos": [
0,
4
]
}
]
},
"service_windows": [
{
"days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"start": "00:00",
"end": "23:59"
}
]
}
Binary file not shown.
+630 -77
View File
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -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}})
+436
View File
@@ -0,0 +1,436 @@
"""Auto-setup for the other campuses: an inventory becomes keys that pass every rule, laid out like
North Attleboro's; a running campus only gains what is new (never loses anything, never gets back
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")
# 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")
self.assertEqual([(c["type"], c.get("match") or c.get("conn")) for c in prop["checks"]], [("conn", "ProP"), ("process", exe)])
fix = cfg["fixes"][prop["checks"][1]["fix"]]
self.assertEqual((fix["kind"], fix["run"]), ("local", "pgrep -xq ProPresenter || open /Applications/ProPresenter.app"))
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_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 campus can help the new one
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 campus: a copy of each status page (view only) and a CAMPUSES 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\\nCampus")
self.assertIn("CAMPUSES", json.dumps(ctrls["3"]["0"])) # where ALARMS sits on every status page
self.assertIn("$(custom:sp_view)", json.dumps(ctrls["3"]["0"]["style"])) # it says which campus is shown
cells = [ctl for row in ctrls.values() for ctl in row.values() if "campus 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 "'campus " 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("'campus na'", keys[0][2]["options"]["notes"])
self.assertIn("page 19", keys[0][2]["options"]["notes"])
ws_key = next(k for _, _, k in keys if "'campus ws'" in k["options"]["notes"])
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
+128 -24
View File
@@ -19,6 +19,8 @@ sp.log.addHandler(logging.NullHandler())
sp.log.propagate = False
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
# The tests pin their own READY hours (the campus sets its own on the settings page).
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
@@ -63,7 +65,7 @@ def slide(label="", text="", notes="", enabled=True):
def pres(name, slides, timeline=False, cues=None):
return {"name": name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
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}
@@ -131,12 +133,43 @@ def lineup(names, plan_id="92013595"):
"slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}}
def composition(deck="Service", playing=True):
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"}}],
"layers": [{"clips": [{"name": {"value": "SS_StainedGlass005"}, "connected": {"value": "Disconnected"}},
{"name": {"value": "Stained Glass 11"}, "connected": {"value": "Connected" if playing else "Disconnected"}}]}]}
"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):
@@ -283,11 +316,26 @@ class TestLineupCrossBundle(unittest.TestCase):
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_led_cross(self):
memory = {}
self.assertEqual(msgs(rd.eval_resolume(RC, composition(), memory)), [])
self.assertEqual(memory["led_clip"], "Stained Glass 11")
self.assertEqual(msgs(rd.eval_resolume(RC, composition("Test", False), {})), ["WRONG DECK", "NOTHING ON"])
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_this_week(self):
since = rd.week_start(WEDNESDAY)
@@ -302,7 +350,8 @@ 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: [["Message Templates.probundle", WEDNESDAY]])
resolume=composition, prop_bundle=lambda: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {}))
src.update(over)
return src
@@ -322,7 +371,7 @@ class TestLook(unittest.TestCase):
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"], []) # Resolume needs no plan
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))
@@ -381,7 +430,7 @@ class TestWatchdog(unittest.TestCase):
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", False)))
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test")))
self.snap.ready = look
self.step(WEDNESDAY)
plan = self.engine.fix_plan("ready", self.snap)
@@ -457,27 +506,24 @@ class TestFixes(unittest.TestCase):
rd.http_json = real
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
def test_cross_comes_back_with_the_clip_last_seen(self):
def test_cross_hold_puts_resolume_back_on_its_deck(self):
calls = []
real = rd.http_json
comp = composition("Test", False)
def fake(url, headers=None, method="GET", data=None, timeout=10):
calls.append((method, url.split("/api/v1/composition")[-1]))
return comp
return composition("Test")
rd.http_json = fake
rd.MEMORY["led_clip"] = "Stained Glass 11"
try:
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
finally:
rd.http_json = real
rd.MEMORY.clear()
self.assertIn(("POST", "/decks/1/select"), calls)
self.assertIn(("POST", "/layers/1/clips/2/connect"), calls)
self.assertFalse(any("/clips/" in path for _, path in calls)) # never plays a clip by itself
class TestSpotifyWhileMuted(unittest.TestCase):
def test_spotify_still_plays_with_alarms_muted(self):
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()
@@ -492,15 +538,73 @@ class TestSpotifyWhileMuted(unittest.TestCase):
engine.auto_fix(snap, None, wed + 40)
for th in engine.fix_threads:
th.join(2)
self.assertEqual(ran, ["spotify_play"])
cfg["auto_fix"]["while_muted"] = [] # without it: nothing while muted
self.assertEqual(ran, []) # muted: nothing, not even Spotify
engine2 = sp.Engine(cfg, None)
engine2.service.set(False, wed)
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)
self.assertEqual(engine2.auto_fix(snap, None, wed + 40), [])
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 campuses 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 campus plays song 2 first, then 1, then 3: song 1 breaks the order and is skipped; a song NA does not play is skipped too
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 campus's songs are still in order, nothing is wrong at the campus, 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 campus song
+15 -9
View File
@@ -23,21 +23,21 @@ NAMES = {"cfg": "top", "config": "top", "chk": "check", "c": "check", "fix": "fi
"tile": "tile", "k": "restart_key", "key": "restart_key", "rp": "restart_page", "w": "window",
"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.ready_cfg": "ready",
"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",
"ready": "ready", "ready_page": "ready_page"}
"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", ("ready", "due"): "windows",
("ready_page", "keys"): "tiles"}
("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):
@@ -69,6 +69,8 @@ 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":
@@ -145,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[].", "ready": "ready.", "ready_page": "ready_page."}.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
@@ -177,18 +180,19 @@ class TestSchemaMatchesCode(unittest.TestCase):
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "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", "gear.*.kind", "gear.*.address", "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",
"ready_page", "ready_page.keys", "ready"}
"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, "ready.due[].%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")
@@ -291,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 campus'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"}]),
+21
View File
@@ -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:
+603 -6
View File
@@ -30,6 +30,8 @@ CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
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 campus 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 +70,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 +418,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 +459,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 +644,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 +1009,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 +1176,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 +1201,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 +1238,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 +1596,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 +1672,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 +2128,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 +2147,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 +2171,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 +2227,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)
@@ -1854,3 +2402,52 @@ 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))
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""SSH from the admin laptop to every machine on every campus.
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 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"]
wired = lambda ip: not ip.startswith(("10.6.", "169.254.", "127."))
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()):
ip = next((i for i in m["ips"] if wired(i)), m["ips"][0] if m["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()