Compare commits

..
58 Commits
Author SHA1 Message Date
christianandClaude Opus 5.5 af4bfc868d Windows show-PC baseline (as the NA LiveStream PC) applied to NA Lighting, Bible TV and SC Lighting
- remote-access/windows-baseline.ps1: no driver updates, no restart while signed in, active hours 5-23, Game DVR off,
  Microsoft bundled apps removed, no sleep/hibernate/disk/USB power saving; never restarts or touches the NIC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:44:30 -04:00
christianandClaude Opus 5.5 b1c44fd287 Laptop SSH shortcuts never point at the laptop's own address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:31:09 -04:00
christianandClaude Opus 5.5 afd2758d87 PC set-up: one line per PC (/setup/<name>.ps1), the admin laptop always let in, and the admin check no longer breaks the script
- the inserted administrator check split the header comment, leaving '} for ... run ONCE' as code: WS and SC
  join.ps1 failed to parse (Windows' parser: Missing opening '(' after keyword 'for'); now after the whole line
- site.admin_ips / admin_keys (laptop 10.6.7.38, fixed in UniFi) go into every PC script's firewall rule and key
  files, and into the Mac join line, so running a set-up again never shuts the laptop out
- scripts verified by Windows' own PowerShell parser on the NA Lighting PC

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:29:36 -04:00
christianandClaude Opus 5.5 0813734e78 NA page-number key: hold 2 s = status page, keep holding to 3 s = LOCATIONS page
- nav_hold_more: longer holds go further (tested on NA's Companion emulator: 2.5 s -> 19, 3.5 s -> 98, tap -> 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:06:04 -04:00
christianandClaude Opus 5.5 b9a0f3905f README: AUTO-FIX pause and resume
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:46:43 -04:00
christianandClaude Opus 5.5 e566c9558d Laptop SSH shortcuts prefer the locations' wired networks (NA Ingest's 10.66 address is unreachable)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:46:17 -04:00
christianandClaude Opus 5.5 99a25e5da2 AUTO-FIX pauses through the hours (also when pressed outside them); MIDI links without a state judged by the Mac
- AUTO-FIX: tap = pause / resume; a pause lasts through the current or the next auto-fix hours; key says ON TIL,
  OFF / ON <start>, PAUSED TIL; forcing auto-fix on outside the hours is gone
- MIDI: macOS 26 reports every live link's state, so no state there = NO ANSWER; on macOS 14/15 a link put back after
  a drop counts only after 20 s (macOS drops an unanswered one at ~12 s): no more OK/LOST flicker
- WS and SC PROP ignore the rc4 module's harmless warnings, as NA

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:38:56 -04:00
christianandClaude Opus 5.5 bf9afcbc84 Operator mode clears the flapping history of the links it takes out
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:18:43 -04:00
christianandClaude Opus 5.5 906f52e110 README: NA's location band and ALL AUTO-FIX key
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:14:02 -04:00
christianandClaude Opus 5.5 3428bd81a2 NA: ALL AUTO-FIX key pauses or resumes auto-fix at every location; page keys keep their words under the band; rc4 ProP module at WS/SC
- LOCATIONS page key at 3,1: shows ALL ON or the paused locations; tap pauses all, tap again sends each back to its hours
- autofix auto: back to the auto-fix hours
- the page name and number shrink into the top 60% while the locations band shows
- WS and SC Propresenter-API on renewedvision-propresenter-api 1.2.0-rc4 (pinned manual, as NA)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:12:10 -04:00
christianandClaude Opus 5.5 2a37cee10a README: READY is page 21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:01:38 -04:00
christianandClaude Opus 5.5 ef4a9e549d README: READY is page 21
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:01:18 -04:00
christianandClaude Opus 5.5 a201d51db2 NA: READY page on 21, archives on 30-31, location pages back on 98/99
- Apply's import message names the READY page by its own number

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:00:58 -04:00
christianandClaude Opus 5.5 48a754a118 NA's page keys name the other locations with a problem in a blue band; PROP OUT reads Chronicles
- campuses.remote_summary -> custom variable sp_remote; band flashes until acknowledged there, steady after, "SC?" when silent
- nav_locations_band (NA only); red stays for NA's own problems
- PROP OUT: /Volumes/Location Assets/{date} on NA ProP (Chronicles share)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:57:45 -04:00
christianandClaude Opus 5.5 ee7326a6eb PROP OUT looks in this weekend's Location Assets folder, read on NA Ingest
- prop_bundle_folder takes {date} (the plan's weekend, as the tracks folders): any bundle there counts
- readiness scripts for this Mac run here (NA ProP's Google Drive showed 261025 empty)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:40:18 -04:00
christianandClaude Opus 5.5 42b325e927 ProPresenter updates are safe: any installed copy counts as running, the newest version opens
- newest_of picks by CFBundleShortVersionString, not file name (ProPresenter.app sorted after ProPresenter 20.1.app)
- mac_start takes newest_of; auto-setup uses it for programs on the Companion Mac
- WS and SC switched over; NA's ProP restart re-rendered (same pick today)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:33:39 -04:00
christianandClaude Opus 5.5 5db1d93732 Companion keeper: after a restart the panel opens the Companion that ran last if it is not running
- only in the watchdog's first 10 minutes, never another version, never during an upgrade
- SC verified end to end; laptop SSH to the SC Macs; configs synced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:26:18 -04:00
christianandClaude Opus 5.5 268ac21715 SC Vista opens the newest Tiverton Lighting through its autoload preference
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:14:08 -04:00
christianandClaude Opus 5.5 176e8e9073 SC Lighting PC joined: VISTA key and Restart key, rtpMIDI watched on MIDI
- a helper program (rtpMIDI) on a computer that joins after the MIDI key exists is added to it
  (it was marked seen and never added)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:10:42 -04:00
christianandClaude Opus 5.5 a54b6b6276 The PC join line says to run it as administrator before anything else runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:58:24 -04:00
christianandClaude Opus 5.5 cc9d752287 Tests follow NA's AUTO-FIX key and moved ARENA; stored NA pages refreshed from NA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:49:06 -04:00
christianandClaude Opus 5.5 5007d3d85e NA: ARENA moved to the Restart page, AUTO-FIX in its place; SC: Midi-Lighting connection watched
- a key whose fix sits on the Restart page is not warned about as unseen (its checks still feed ALL and auto-fix)
- SC LIGHTS set to operator, as its checklist says (Lighting disconnected for the lighting op)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:46:23 -04:00
christianandClaude Opus 5.5 7068244ba4 "Location", never "campus", in everything people read; SC FOH joined with its MIDI links
- pages (NA 98 Locations / 99 Location, re-imported via PAGES_VERSION), keys, settings page, setup and
  install messages, alerts, docs, the Apollo Beach kit; python3 -m settings.location and /location/...
  addresses (the old names still answer for installed Macs)
- Apply reads the before settings from the file: an in-place edit skipped the watchdog restart
- SC: SUPERRACK (+SoundGrid) and X32 EDIT from SC FOH; FOH TRACKS and TRACKS LIGHTS MIDI links watched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:42:22 -04:00
christianandClaude Opus 5.5 d0c755824b ALARMS and AUTO-FIX as two keys; LIGHTS at Southcoast hands the lights to an operator
- kind autofix: its own switch on the auto-fix hours; ALARMS then only silences (NA keeps the old
  combined key until it has room for one)
- kind bypass: hold takes every network MIDI link to a peer out (here and on listed Macs), checks stop
  calling it lost, re-taken every 10 s; hold again puts back exactly what was taken plus every watched link
- a MIDI peer with no address is never called (it connected SC Tracks to itself)
- holds from NA's CAMPUS page flip switch keys; a rebuild keeps them; new campuses get AUTO-FIX
- key kind changes restart the watchdog; settings page has separate alarm / auto-fix buttons
- WS and SC configs: AUTO-FIX at 3,1; SC LIGHTS at 1,3; NA config synced from live

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:33:48 -04:00
christianandClaude Opus 5.5 2f8e3d47a3 Apollo Beach on its own: a standalone panel to send them
- tools/standalone.py builds dist/<Name> Status Panel.zip: program, install.command, READ ME FIRST.txt
- setup --standalone: campuses.json standalone, tells nobody, no hub, no NA key
- Standalone backs itself up nightly to ~/Documents/Status Panel Backups/<id>
- upgrade-companion --from <.dmg/.zip from bitfocus.io> (no NA needed)
- Only the hub lists campuses or shows other campuses' install lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:12:30 -04:00
christianandClaude Opus 5.5 bea587cfad Southcoast matches NA, per its startup checklist: X32s, ProPresenter to Lighting MIDI, pages 19/20
- An explicit look or rebuild may apply inside the auto-fix hours (never while live)
- A page move clears the page left behind when only our keys are on it
- A rebuild keeps MIDI links that are down now; first-setup import waits for the keys to land
- ProPresenter port drift fixed automatically (kind propresenter_port)
- sites/sc/config.json kept with the others

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:07:15 -04:00
christianandClaude Opus 5.5 ee4c97c699 Woonsocket: ProPresenter's MIDI session connected to Lights, and watched
ProPresenter (on the Companion Mac) now reaches Lighting at 10.2.2.2:5004; Lights sees ProPresenter at
10.2.2.210:5004, both running. The MIDI key watches it (PROP LIGHTS). Auto-setup counts a link to the
Companion Mac's own session once (it had listed it from both ends).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:29:14 -04:00
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
christianandClaude Opus 5.5 bf128e1bad READY page: drop the MICS and STAGE keys
BOH MIX is the one tap key left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:46:00 -04:00
christianandClaude Opus 5.5 d5ede0c91e READY page: the weekly worship checklist, checked against Planning Center
- readiness.py looks every 2 minutes (never while a HyperDeck records) at this weekend's PCO plan
  and what is in place for it: the Ableton set (songs, mix, routing, lighting cues), ProPresenter
  (every PCO item, MC announcements, baptism names, timelines, backgrounds), the LTC note of every
  song against the timecode the tracks play, the Band Lineup TV, Resolume's LED cross, and the
  tracks and ProP bundles for the locations.
- READY key on the Status page (row 2, col 7; SPOTIFY now shows inside SHOW) opens page 24.
  Amber until done, blinking red from Saturday noon; not part of ALL. Tap keys for BOH mix,
  mics and stage reset every Monday.
- Holds fix what can be fixed: re-sync the lineup, put the cross back up, write LTC into PCO,
  copy tracks to locations, look again. Hold READY fixes everything.
- Settings page: a READY page tab (live keys, drag to move, why each key is amber), and Apply
  re-imports page 24.
- Auto-fix: "while_muted" fixes keep running with ALARMS muted (Spotify playlist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:28:43 -04:00
49 changed files with 12682 additions and 2119 deletions
+1
View File
@@ -1,3 +1,4 @@
__pycache__/
*.pyc
.DS_Store
dist/
+211 -11
View File
@@ -13,15 +13,18 @@ There are two parts:
|--------|-------|-------|-------|-------|-------|-------|-------|-------|
| row 0 | UP | **ALL** | REC | AUDIO | RF | SHOW | VIDEO | STREAM |
| row 1 | page | HD1 | HD2 | HD3 | HD4 | HD5 | HD6 | TASCAM |
| row 2 | DOWN | X32 | DANTE | MIDI | APPS | VISTA | RESTART PAGE | SPOTIFY |
| row 2 | DOWN | X32 | DANTE | MIDI | APPS | VISTA | RESTART PAGE | **READY** |
| row 3 | ALARMS | ATEM | OBS | RESI | NET | INGEST | LED | ARENA |
Row 0 holds the group buttons. Each one shows the first problem in its group, plus "+N" if there are more:
- REC: HD1–6 and the Tascam.
- AUDIO: X32 and Dante.
- AUDIO: X32, DiGiCo, Dante and STB AUDIO (the Studio B audio source for the stream; no key of its own).
- RF: all six Shure units (AD4Q ×2, ULXD4, PSM1000 ×3).
- SHOW: MIDI, APPS, VISTA and SPOTIFY.
- SHOW: MIDI, APPS, VISTA and SPOTIFY. SPOTIFY has no key of its own any more; it shows inside SHOW, and its
force-play key is on the Restart page.
**READY** (row 2, col 7) is the weekly worship checklist. Tap it to open the READY page; see below.
- VIDEO: ATEM, LED and Arena.
- STREAM: OBS and Resi.
@@ -55,6 +58,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
| DANTE: PROP MAC / TRACKS / TRANSL / INGEST GONE | starts Dante Virtual Soundcard's engine on that Mac (`remote-access/mac-dvs-helper.sh`) |
| DANTE: any device powered by its switch port (today the four Studio B boxes) | power-cycles the port it is on **now**, found by its MAC on the Netgear Dante switches |
| DANTE: any other device GONE | restarts Companion's Dante module (hardware: if still gone, it is off or unplugged) |
| STB AUDIO (inside AUDIO): STUDIO B NO PING / PRO TOOLS NOT RUNNING / STB TNX GONE | presses FOH Backup on Companion's Audio page (the stream audio comes from the FOH console). The same press happens by itself on Sundays, see Audio failover. |
| INGEST: BRIDGE / CAPTIONS / CAPTION SCREEN / PRODCOM / PRODDASH | restarts that one on NA Ingest |
| TASCAM (always, even when it looks fine) | reconnects Companion to the recorder and looks again after 20 s. **HUNG - POWER CYCLE IT**: the recorder answers ping but its control port has hung (only a power-cycle at the rack fixes that; it has no network reboot). **NO NETWORK / CHECK POWER**: it does not answer at all. |
| any button whose Companion connection is **disabled** | turns the connection back on (nothing else) |
@@ -64,7 +68,9 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
**The Dante map is automatic.** Every 10 minutes the watchdog maps each Dante device: its name and IP from Companion, its MAC from NA Ingest's Dante port, and its switch port and PoE from the Netgear switches listed in `"dante_switches"`. It saves the map to `dante-map.json` in its data folder. Re-patching, adding or renaming a device needs no config change. A device that has been dead for a while ages out of the switch tables, so its last known port is used, but only if no other device has appeared there. A port carrying more than 2 devices (an uplink) is never power-cycled. The switch login is in NA Ingest's login keychain (service `netgear-status-panel`).
It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`.
- **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking, for example during maintenance. It re-arms itself at midnight.
- **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking; press it again to turn them back on; they come back by themselves at midnight. Holds still fix while muted; the Sunday stream-audio failover still switches by itself. The settings page's Overview has the same switch. On a page with no AUTO-FIX key (NA today), ALARMS also stops every auto-fix and the key says both (`ALARMS OFF` / `AUTO-FIX OFF` / `TIL 12AM`).
- **AUTO-FIX** (beside ALARMS; NA bottom-right; `kind: autofix`): its own switch since 2026-10-21. Shows `ON / TIL 9PM` in the auto-fix hours, `OFF / ON SUN 5AM` outside them, `PAUSED / TIL SUN 2PM` when paused. Tap: pause - through the hours it is in now, or through the next ones when tapped outside them (a Saturday-night tap keeps Sunday quiet); tap again: resume. With this key, ALARMS only silences. Holds always fix. From NA's LOCATION page a hold is the tap. The settings page has Pause / Resume auto-fix.
- **LIGHTS** (Southcoast; `kind: bypass`, `"bypass": {"peers": ["Lighting"], "hosts": ["", "sc_tracks_new"]}`): hold 2 s to hand the lights to a lighting operator. Every network MIDI link to Lighting (ProPresenter's session, Tracks' session, and Companion's 56 Midi-Lighting buttons, which go out through ProPresenter's session) is taken out, the MIDI checks stop calling it lost and auto-fix stops putting it back; the key says `LIGHTS / OPERATOR / MIDI OFF` (steady blue). A link that comes back while the operator has it is taken out again within 10 s. Hold again: everything taken out is put back as it was, and every watched link to Lighting is reconnected (`LIGHTS / AUTO`). A peer that had called in (no address, ":5004") is not called back: it rejoins when Lighting calls again. Survives a restart; a hold from NA's LOCATION page works too. `"conns"` can also switch Companion connections off for the operator.
## Auto-fix on Sundays (06:30–13:30)
@@ -74,7 +80,26 @@ During that window, with ALARMS on, the panel fixes a problem by itself once it
- It waits while the network is having a blip, and does nothing to Dante when several devices vanish at once (that is NA Ingest's view or the Dante network, not the Macs).
- Never automatic: restarting a running ProPresenter, Bible app, ProdCom (or DVS on NA Ingest, which restarts ProdCom), captions or the bridge, power-cycling a switch port, or turning on a connection someone switched off. `load_config` refuses an auto fix that closes a program.
- **Daily log:** everything auto-fix does is written as it happens to `~/Desktop/Auto-Fix Logs/<date> <weekday> Auto-Fix.log` on NA Ingest (e.g. `2026-10-11 Sunday Auto-Fix.log`): the problem, what it did, whether it worked, when the fault cleared, when it gave up or held back. Double-click it to open it in Console, which shows new lines live. One file per day; each is deleted a calendar month after its date (the 11 Oct file on 11 Nov).
- Press ALARMS to mute the panel for maintenance: that also stops auto-fix. The window and the list are `"auto_fix"` in `config.json`.
- **Stopping it:** press ALARMS (on the fly; nothing at all fixes itself until you press it again or midnight), or switch auto-fix off as a whole under Settings › Auto-fix (stays off until switched on). The window and the list are `"auto_fix"` in `config.json`.
- **READY page fixes** ticked under Settings › Auto-fix act on the checklist's gaps too, amber or red. A fix marked `"anytime": true` (only a READY fix may be; today the LTC write, `ready_ltc`) runs at any hour, not only in the auto-fix hours: once the weekend's set is on Tracks, every song's timecode is written into Planning Center for all four locations by itself. ALARMS muted stops it like every other auto-fix; it catches up once they are back on.
## Audio failover (Studio B -> FOH Backup)
The stream's audio comes in on the Dante device STUDIO-A-Resi-AJA-SDI, channels 1/2. Companion's Audio page (page 4) picks the source: **Studio B** (row 0, col 6: STUDIO-B-RedNet-TNX 63/64 to the AJA, X32 snippet 1) or **FOH Backup** (row 0, col 5: FOH-DiGiCo-DMI-2 63/64 to the AJA, X32 snippet 8). If Studio B crashes on a Sunday, the panel presses FOH Backup for you.
**STB AUDIO** (inside AUDIO, no key of its own) watches three things: Studio B answers ping (10.1.13.175, pinged every second, **STUDIO B NO PING** after 2 misses), Pro Tools is running on the Studio B Mac Studio (`pgrep -xq 'Pro Tools'` over SSH every 2 s, **PRO TOOLS NOT RUNNING**), and the Dante device STUDIO-B-RedNet-TNX is there (**STB TNX GONE**). Each check's fix is the FOH Backup press (`stream_foh_backup`, a `companion_press` fix: `POST /api/location/4/0/5/press` on Companion's HTTP API).
- **When:** inside the `"failover"` windows (Sunday 06:30–13:30 today), even while ALARMS is muted. Once per fault: it re-arms once the fault has been clear for 10 s (a check that cannot ask, say an SSH hiccup, does not count as clear).
- **How fast:** about 5–6 s from a crash to the press (2–3 s to notice, 1 s debounce, 2 s `after_seconds`). A vanished Dante device takes as long as Companion's Dante module needs to notice it.
- **Unlike auto-fix:** it does not wait 20 s, does not stop for a network blip, and is not limited to 2 tries per 10 minutes. The one guard: when several Dante devices vanish at once (that is NA Ingest's view of Dante, not Studio B) it holds back, like auto-fix.
- **What the keys show:** AUDIO (and the hidden STB AUDIO) carry an extra line **ON FOH BACKUP** while the fault lasts and for 15 minutes after (`note_seconds`). Hold AUDIO right after and it says JUST TRIED.
- **Log and alert:** every press goes to the daily auto-fix log as `FAILOVER:` lines, to the watchdog log, and to `alert_webhook` if set.
- **Switching back is yours.** It never switches back by itself: when Studio B is up again, press **Studio B** on Companion's Studio B page (page 7) or Audio page (page 4). The alert names that button (the fix's `"undo"`).
- **Never:** a press outside the window, a second press for the same fault, or a press back to Studio B.
In `config.json`: the `"failover"` block (`windows`, `after_seconds` 2, `note_seconds` 900); `"failover": true` and `"undo"` on the fix (`stream_foh_backup` undoes with `stream_studio_b`); `"interval_seconds"` on ping checks (default 3) and remote checks (default 10). The remote poll runs each computer's checks in its own thread, so a dead Studio B never delays the checks on another Mac.
**Another fallback button:** any Companion button can be a fix. On the settings page, Keys › Fixes › + Fix › "Press a Companion button" (page, row, column and what the button says), tick failover under more and pick its undo; **Settings › Auto switch** has it all on one screen: which buttons switch by themselves (tick, page/row/column, the button to switch back with, Press it now), the hours (calendar), `after_seconds` and `note_seconds`, and what counts as down (how often each check looks, how many misses, how long a fault must last). Keys › + Key / + check… adds more checks that call it (a ping, a program on a Mac, a Dante device) with fix = the press. The Help page "How do I add…" has an entry "A fallback button".
## Restart page (page 20)
@@ -91,8 +116,73 @@ Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **e
| TASCAM | reconnects Companion to the recorder (see TASCAM above) |
| SPOTIFY | plays the Christian EDM 2026 playlist on NA Ingest (opens Spotify first if it is closed) |
A pressing key says PRESSING, then DONE; a press that finds no button there says NO BUTTON THERE.
Add or move keys in `"restart_page"` in `config.json`, then `python3 make_page.py` and `python3 push_page.py --which restart`. `push_page.py` refuses any page that holds real buttons unless it already is that page.
## READY page (page 21): the weekly checklist
Tap **READY** on the status page (row 2, col 7). Each key is one line of the worship weekly checklist, checked
against this weekend's plan in Planning Center by `readiness.py` (it looks every 2 minutes, and never while a
HyperDeck is recording). **STATUS PAGE** (bottom left) goes back.
| | col 1 | col 2 | col 3 | col 4 | col 5 |
|--------|-------|-------|-------|-------|-------|
| row 0 (Ableton) | TRACKS | FOH MIX | BOH MIX (tap) | LIGHTS | MIDI |
| row 1 (ProP / Resolume) | PROP | TIMECODE | BKGDS | LED CROSS | |
| row 2 (backstage) | LINEUP TV | | | | |
| row 3 (outgoing) | TRACKS OUT | PROP OUT | | | |
The READY key at row 3 col 7 (and the one on the status page) shows how many keys still have something to do and
the first of them.
**Colours:** amber means not done yet. From **Saturday 12:00 until Sunday 13:30** (`"due"` in `"ready"`), what is
still missing turns **blinking red**. A song that is not mixed stays amber, because it is only a guess. These keys
are not part of ALL, so an unfinished checklist on a Tuesday never turns the page-number keys red.
**BOH MIX** is a tap key: only a person can judge it. Tap once when it is done; the key shows `DONE THU 14:02`.
Tap again to take it back. It resets on Monday at midnight.
**Hold any key for 2 s** to fix what can be fixed, or to look again now if there is no fix. **Hold READY** to fix
everything on the page at once.
| Key | Checks | Hold does |
|---|---|---|
| TRACKS | this weekend's set exists (`Ableton/Projects/NA/261018 NA Project`), it has a song group for every PCO song, and Live last opened it ("LIVE HAS OLD SET") | looks again |
| FOH MIX | **NOT MIXED** (amber): every track of a song is still at 0 dB. **TO MASTER**: a stem goes to Master instead of out to the console | looks again |
| LIGHTS | the `LIGHTING CUES` track has a clip under every song | looks again |
| MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped |
| PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again |
| TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start; the other locations' plans (`ready.ltc_campuses`: Apollo Beach, Southcoast, Woonsocket) get the same timecode for the same songs in this location's order, and a song that is missing or out of order there is left alone. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again |
| BKGDS | every song has at least one slide with a background | looks again |
| LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) |
| LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center |
| TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. |
| PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again |
**Where the answers come from:**
- **Planning Center:** ProdDash's own login on this Mac (`~/Library/Application Support/ProdDash/modules.json`, module `pco-plan`). The plan is today's, else the next one, which is the same plan the lineup TV picks.
- **ProPresenter:** its network API.
- **The Ableton set:** read over SSH on the Tracks Mac at low priority, and only re-read when the file changed. It reads the saved file, so save in Live, then hold the key to look again.
- **Resolume:** its web server on port 8080.
Nothing on the READY page writes to Companion, ProPresenter or the Ableton set.
**Another location:** copy `"ready_page"`, `"ready"`, the four `ready_*` fixes and the READY tile from this config,
then change these in `"ready"`:
- `pco_service_type`
- `prop_url`
- `tracks_host` and `site_folder`
- `lineup_url`
- `resolume_url`
For a location that receives tracks rather than sending them, leave `locations` empty and take out the `prop_bundle_*`
lines; TRACKS OUT and PROP OUT then show `NOT SET UP`. A line whose address is blank shows `NOT SET UP` instead of
an alarm.
`python3 make_page.py` writes `ready-page.companionconfig`; `python3 push_page.py --which ready` imports it into
page 21 (moved there from 24 on 2026-10-21; the Archive pages are 30 and 31).
## Page keys on every page
The **page number key** on every page follows the ALL button. It's green when everything is fine, flashes red when something is wrong, and turns purple if the watchdog stops. UP and DOWN stay normal. Which keys follow ALL is `"nav_keys"` in `config.json` (`["pagenum"]`; add `"pageup"` / `"pagedown"` to colour those too). The coloured key is Companion's own key converted to a regular button (`nav_templates.json`).
@@ -111,6 +201,8 @@ The **page number key** on every page follows the ALL button. It's green when ev
| | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. |
| TASCAM | connection; the recorder's own Error / Caution. Use the **1.3.1-quiet** Tascam module (see below) so Companion's log isn't flooded.; pinged at the address on its Companion connection. |
| X32, ARENA, OBS | Companion connection |
| DIGICO (inside AUDIO) | Companion's `DiGiCo_FOH` connection; the relay helper `org.waterschurch.digico-relay` on NA Ingest (`~/digico-bridge/relay.py`: Companion and the DiGiCo tablets share the console's one External Control slot through it), **DIGICO RELAY STOPPED** / **NOT LOADED**, hold AUDIO restarts it; the console itself answers ping at 10.1.60.100, **DIGICO NO PING**. Companion's OSC connection reads "good" even with the console off, so the ping is what catches that. |
| STB AUDIO (inside AUDIO) | the Studio B audio source for the stream: Studio B answers ping (10.1.13.175, every second, **STUDIO B NO PING** after 2 misses); Pro Tools running on the Studio B Mac Studio (over SSH every 2 s, **PRO TOOLS NOT RUNNING**); the Dante device STUDIO-B-RedNet-TNX (**STB TNX GONE**). Hold 2 s presses FOH Backup; on Sundays that press happens by itself (see Audio failover). |
| ATEM | Companion connection; the switcher's **audio** (every Fairlight input's on/off/AFV and fader, e.g. Resi on input 16 ON, TRS off) and **camera settings** (iris, white balance, shutter, gain, detail, ND, lift/gamma/gain/offset, contrast, saturation) must match the restore file `atem-reference.xml` (a copy of `4k plus 2026-10-07 14-59-55.xml` from Google Drive Backup/NA/ATEM): **ATEM AUDIO WRONG** / **ATEM CAMERA WRONG**; ATEM Software Control's *Enable MIDI control* must be off: **ATEM SW MIDI ON**. Hold 2 s (and Sunday auto-fix) sends back only what differs; MIDI is fixed by quitting the app, switching it off and reopening it (the switcher is not affected). `atem_watch.js` keeps one connection to the switcher. New restore file: copy it over `atem-reference.xml` and re-run install.sh. |
| LED | VX1000 and UHD Jr: Companion connection **and** each one answers ping (the NovaStar module keeps saying "Connected" after a processor is switched off). "UHDJR NO PING" = processor off or unreachable; "UHDJR OFFLINE" = Companion's connection failed; "UHDJR DISABLED" (amber) = connection turned off in Companion. |
| DANTE | All 19 Dante devices ("19/19"), named when one drops: "WING GONE", "TRACKS GONE", "PROP MAC GONE". |
@@ -131,12 +223,116 @@ A problem has to last 3 seconds before it shows, so one bad reading doesn't trig
With Editing on every screen grows inputs. Changes go into one shared draft on the Ingest Mac (two people editing see each other's version; a stale tab is told); the badge at the top counts them and opens **Review & Apply**, which reads every change back in plain words, says what will happen (the deck goes purple for about 5 s only when something the watchdog reads changed; pages re-import only when the layout changed) and runs the pipeline: check the config, keep the old version in `history/`, write, restart the watchdog and wait for its start stamp, re-import the pages (refused if any unmarked key sits on them), recolour the page-number keys. If the watchdog does not come back the previous version is put back by itself. Apply is refused while a HyperDeck is recording (the Emergency button needs a reason and the word LIVE, and is logged). History keeps every version with who/when/note; **Go back** and **Undo** make the chosen version the draft.
**Keys** draws both pages as the deck; with Editing on a key is a chip you drag to move (or drop on the tray to hide), + New key / + New restart key add one, Delete this key removes one, the page numbers and "this campus has a Restart page" sit under the tabs. **Overview** carries the jobs people actually do: Change the playlist (paste the link; the key, the play fix and the key guide follow), Add or remove a HyperDeck (tick the decks in use; the HD keys and the REC group follow, "may have no disk" per deck), Add a MIDI link (the Mac, its session and peer from the live list or typed, which key), and under More jobs: Add a computer, Make a PC program startable. Site setup › Companion offers "Add a key for it" on every connection nothing watches. **Computers › Setup** is the one-time checklist per computer with a light and a [how] per line and the set-up scripts generated from that computer's record (`settings/scripts.py` fills the templates in `remote-access/`: the login key, this Mac's addresses, one scheduled task per program listed); Remove this computer drops the fixes and checks that used it and says so. **Site setup › Site** shows the running and staged code with **Install the staged folder** (install.sh runs as a one-shot launchd job with the config left alone), **Export site bundle** (a zip to drop in as `sites/<id>/`) and **New campus from a bundle** (this campus's name, addresses and code stay; every copied computer is flagged until touched).
**Keys** draws both pages as the deck; with Editing on a key is a chip you drag to move (or drop on the tray to hide), + New key / + New restart key add one, Delete this key removes one, the page numbers and "this location has a Restart page" sit under the tabs. **Overview** carries the jobs people actually do: Change the playlist (paste the link; the key, the play fix and the key guide follow), Add or remove a HyperDeck (tick the decks in use; the HD keys and the REC group follow, "may have no disk" per deck), Add a MIDI link (the Mac, its session and peer from the live list or typed, which key), and under More jobs: Add a computer, Make a PC program startable. Site setup › Companion offers "Add a key for it" on every connection nothing watches. **Computers › Setup** is the one-time checklist per computer with a light and a [how] per line and the set-up scripts generated from that computer's record (`settings/scripts.py` fills the templates in `remote-access/`: the login key, this Mac's addresses, one scheduled task per program listed); Remove this computer drops the fixes and checks that used it and says so. **Site setup › Site** shows the running and staged code with **Install the staged folder** (install.sh runs as a one-shot launchd job with the config left alone), **Export site bundle** (a zip to drop in as `sites/<id>/`) and **New location from a bundle** (this location's name, addresses and code stay; every copied computer is flagged until touched).
Fixes and the remote checks are built from fields (`settings/builders.py`): a builder (Start a Windows program, Restart a Mac program, Restart a helper, Reopen Ableton with its last set, Restart DVS...) and its fields make the command; the stored command must equal what the fields build or the config is refused. Every Mac value is shell-quoted, every Windows value is regex-checked. A Custom command (Advanced) is the one place a command is typed by hand; Review shows commands in red.
Fixes and the remote checks are built from fields (`settings/builders.py`): a builder (Start a Windows program, Restart a Mac program, Restart a helper, Reopen Ableton with its last set, Restart DVS, Press a Companion button (page, row, column)...) and its fields make the command; the stored command must equal what the fields build or the config is refused. Every Mac value is shell-quoted, every Windows value is regex-checked. A Custom command (Advanced) is the one place a command is typed by hand; Review shows commands in red. A fix built with Press a Companion button can be ticked failover (under more) and given an undo; the Failover calendar and its `after_seconds` sit under Settings › Auto-fix, under the auto-fix window.
The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs.
## Other locations: auto-setup
A location sets itself up. All a person does is let the panel in: one line on the location's Mac that runs Companion,
one line on each other computer.
1. **The Companion Mac.** Terminal, paste the location's line from Settings › Auto-setup on any running location (North
Attleboro's page shows the Apollo Beach, Southcoast and Woonsocket lines), for example
`curl -fsSL "http://10.1.1.1:8020/location/install?id=ab" | zsh`. It downloads the program from that page (code only,
never that location's settings or keys), installs it, makes the panel's login key, and runs
`python3 -m settings.location setup`: Companion gets the panel's custom variables and the blink-clock trigger (through
Companion's own internal API, as its web UI does), the first two pages from 19 that hold nothing made by hand are
used, the gear is found and the config is applied like any Apply. It prints the editing PIN. North Attleboro's key is
added so it can help remotely (turn on Remote Login for that). Running the line again updates the program.
2. **Each other computer.** Mac: Terminal, `curl -fsSL http://<that Mac>:8020/join | sh` (adds the key, turns on
Remote Login when it can, says it is here). PC: PowerShell as administrator, `irm http://<that Mac>:8020/join.ps1 | iex`
(OpenSSH, the key, a StatusPanel start task for each known program running on it, Wake-on-LAN, then says it is here).
The lines are on that location's Settings › Auto-setup.
**What gets a key** (`settings/autoconf.py`): each Companion connection of a known kind (HyperDecks, Tascam, consoles,
Dante with every device it sees, Shure receivers and PSMs with their channel count, ATEM, Videohub, Resolume, NovaStar,
OBS, Resi, ProPresenter, Ableton/AbleSet), each known program running on a joined computer (ProPresenter, Ableton,
Arena, OBS, Church Translate, Pro Tools on a Mac; Vista, Arena, OBS, ProPresenter on a PC), and every network MIDI peer at
its wired address (with the wired-network rule). A program's key also carries its Companion connection, its start fix
(auto-fixable) and a Restart page key. rtpMIDI rides inside the MIDI key. When everything fits there are no group keys;
when it does not, REC AUDIO RF SHOW VIDEO STREAM group keys take the top row and wireless receivers fold into RF first.
Generic connections and unknown programs are listed under Settings › Auto-setup, not guessed at.
**After that** (`discover.auto`, "add new gear by itself", on for a new location, off at North Attleboro): the settings page
looks every ten minutes and soon after a computer joins, and adds what is new (a key in the first free cell, a Dante
device or MIDI link into its key). Never during the auto-fix hours or a recording; nothing is ever removed; a thing that
was there before and was taken off by someone is not added back (`discover-seen.json`). "Look now" adds what is new at
once. Everything it makes is ordinary settings, changed on the page like North Attleboro's.
## The locations at a glance
| Location | Settings page (bookmark these) | Companion Mac | Status / Restart pages |
|---|---|---|---|
| North Attleboro (hub) | http://10.1.1.1:8020/ | NA Ingest | 19 / 20, READY 21, LOCATIONS 98, LOCATION 99 |
| Woonsocket | http://10.2.2.210:8020/ | WS ProPresenter Mac (wsprop) | 19 / 20 |
| Southcoast | http://10.5.2.60:8020/ | SC ProPresenter Mac (watersmedia) | 19 / 20 |
| Apollo Beach (standalone) | their own | their Companion Mac | 19 / 20 or the first free |
Every location: alarms armed all day; auto-fix on NA's hours (Tue 14:00–22:00, Wed 09:00–21:00, Sun 05:00–14:00);
a hold on any key fixes it at any time, on that location's own deck or from NA's LOCATION page; ALARMS silences and AUTO-FIX pauses auto-fix, separately.
`python3 -m settings.location rebuild` (on a location's Companion Mac, in the panel folder) makes its setup again from
what is found now, keeping its name, PIN and pages. `python3 -m settings.location upgrade-companion` brings an older
Companion to 5.0.7 from NA's copy (settings come along; the old version stays installed).
## Southcoast's Vista show
SC's Lighting PC (SC Lighting PC, 10.5.1.168) keeps several copies of `Tiverton Lighting.v3s`; Vista opens the
**newest** (the user's choice, 2026-10-21), never an autosave from `_Backups`. Its `\StatusPanel\Start Vista` task runs
`C:\ProgramData\StatusPanel\start-vista.ps1` (`remote-access/start-vista-newest.ps1`), which sets Vista's
`<autoloadpath>` in `Documents\Vista 3\.preferences` to that copy (the old file kept as `.preferences.before-status-panel`)
and starts Vista; a running Vista is left alone. `-WhatIf` shows the pick without changing anything. Running the PC join
line again resets the task to plain `Vista.exe`: point it back at the script afterwards.
## A location on its own (Apollo Beach)
Apollo Beach runs its panel itself: not on NA's deck, not in NA's backups, no NA key on its Macs.
`python3 tools/standalone.py ab "Apollo Beach"` makes `dist/Apollo Beach Status Panel.zip` (the program, an
`install.command` and a `READ ME FIRST.txt`; never anyone's settings, plug keys or login keys) to send them.
Installed, it sets itself up like any location (`setup --standalone`: `campuses.json` gets `"standalone": true`
instead of a home, so it tells nobody and is no hub) and backs itself up nightly into
`~/Documents/Status Panel Backups/<id>` on its Companion Mac (or `$STATUS_PANEL_BACKUPS`). With an older
Companion: `python3 -m settings.location upgrade-companion --from <the 5.0.7 .dmg from bitfocus.io>`.
## Backups
Nightly at 04:30 NA's LaunchAgent `org.waterschurch.status-backup` (`backup/backup-campuses.sh`) copies every
location's settings, Apply history and page-key record into Google Drive: Shared drives > Waters Creative > Apps >
Status Panel Backups > <location> > <location>-<date>.tgz, newest 30 kept, the same pattern and alert email as the
checklist's backup-to-drive.sh. Other locations are reached over SSH as the account they report. The repo keeps each
location's settings too: `config.json` (NA) and `sites/<id>/config.json`; a reinstall with `--site <id>` starts from it.
Smart-plug keys (outlets.json) are never copied.
## Other locations' problems on NA's page keys, and ALL AUTO-FIX
- **Blue band** (NA only, `nav_locations_band`): when another location has a problem, NA's page-number key on every
page gets a blue band naming it ("SC", "SC WS", "SC?" when it stops answering). It flashes until that location
acknowledges, then stays; the page's own name and number shrink into the top 60% so they stay readable. Red stays
for NA's own problems.
- **Page-number key holds at NA** (`nav_hold_ms` 2000 + `nav_hold_more`): tap = page 1, hold 2 s = the status page (19), keep holding to 3 s = the LOCATIONS page (98) to pick a location. WS and SC keep a 1 s hold to their status page.
- **ALL AUTO-FIX** (LOCATIONS page 98, bottom-left): blue "ALL ON", or grey naming the paused locations. Tap: pause
auto-fix at NA, WS and SC at once; tap again: every one back to its own auto-fix hours (`autofix auto`). Alarms stay
on and holds still fix.
## Every location on North Attleboro's deck (NA only)
Each location set up from NA's install line tells NA's settings page where it is (`/api/campus/hello`) and learns
nothing back: no location ever shows another, and only NA (the hub: no "home" in `campuses.json`) does
(`settings/campuses.py`). NA's settings page reads each location's key states every two seconds (`/api/state`) and
keeps two pages in NA's Companion, added at the end when no page is empty (today 98 and 99):
- **LOCATIONS (98):** row 0, one key per location, its ALL key with its name ("WS / ALL OK"); purple "WS ?" when it stops answering. Row 1, its RESTART key. Tap NA's: NA's own pages. Tap another's: the LOCATION page showing it.
- **LOCATION (99):** the tapped location's status (or Restart) page, same places, colours and words, live. Tap = acknowledge, hold = fix, at that location, exactly as on its own deck (a Restart key's hold restarts it there). Bottom-left ("WS / ALL SITES") goes back.
## SSH from the admin laptop to every machine
`python3 tools/fleet.py ard` prints a command for Apple Remote Desktop (Send UNIX Command, as root): on each Mac it
turns Remote Login on, lets the logged-in user take the laptop's key (`~/.ssh/id_ed25519_waters`) and reports to
NA's settings page (`/api/fleet/hello`). `python3 tools/fleet.py config` then writes `~/.ssh/config.d/waters`:
`ssh na-translation`, `ssh ws-tracks`... A Mac where nobody is logged in is skipped (run it again later).
## One-time Companion setup
1. **Variables → Custom Variables:** add three variables. Leave the start values empty and "persist" off:
@@ -151,7 +347,7 @@ The page reads what the watchdog writes next to its config (`snapshot.json` ever
This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest:
```bash
scp -r status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml settings checklist-ingest:status-panel-install/
scp -r status_panel.py readiness.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml settings checklist-ingest:status-panel-install/
```
Then run the installer there. It checks the config with the new code first (a bad config never gets as far as the watchdog), keeps the code that was running in `prev/`, backs up the old config, writes the LaunchAgent with the exit timeout this config needs (the longest fix plus its verify time, so a restart never cuts a fix in half; launchd honours at most 60 s of it) and restarts the watchdog:
@@ -160,7 +356,7 @@ Then run the installer there. It checks the config with the new code first (a ba
ssh checklist-ingest 'zsh ~/status-panel-install/install.sh'
```
`install.sh --keep-config` updates the code and leaves the live config alone; `install.sh --rollback` puts the previous code back; `install.sh --site <id>` (a new campus) installs `sites/<id>/config.json` when the folder exists, otherwise `sites/_template/config.json`, and never replaces a config that is already installed.
`install.sh --keep-config` updates the code and leaves the live config alone; `install.sh --rollback` puts the previous code back; `install.sh --site <id>` (a new location) installs `sites/<id>/config.json` when the folder exists, otherwise `sites/_template/config.json`, and never replaces a config that is already installed.
If you add or move tiles, rebuild the page:
@@ -211,6 +407,9 @@ To go back, pick 1.3.0 on the connection again.
## Windows PCs
`remote-access/windows-baseline.ps1` is the show-PC baseline (NA LiveStream, 2026-10-19; NA Lighting, Bible TV and SC Lighting, 2026-10-21): Windows Update installs no drivers and never restarts while the user is signed in (active hours 5 AM-11 PM too), Game DVR off, Microsoft's bundled apps removed, no sleep, hibernate, disk or USB power saving. It never restarts the PC or touches its network card. Run it as an administrator (copied to `C:\ProgramData\StatusPanel\`); safe to run again.
`remote-access/windows-setup.ps1` sets each PC up once (SSH for NA Ingest, start tasks, Wake-on-LAN). An app ticked "Run this program as an administrator" (OBS on the LiveStream PC) gets a start task that runs with highest privileges; otherwise Windows refuses to start it (error 0x800702E4). Windows restarts the SSH service and rtpMIDI by itself if they stop.
## Files
@@ -220,11 +419,12 @@ To go back, pick 1.3.0 on the connection again.
| `status_panel.py` | the watchdog |
| `netgear.py` | reads the Netgear Dante switches (MAC tables, PoE) and power-cycles one PoE port |
| `coremidi.py` | reads network MIDI sessions and listens to MIDI sources (ctypes, no extra installs) |
| `config.json` | tiles, positions, checks, fixes, hosts, windows: every setting (the `site` block names the campus; `spotify` holds the playlist; `timing` and `page_hold_ms` the numbers behind HOLD 2s and WAIT 1 MIN) |
| `config.json` | tiles, positions, checks, fixes, hosts, windows: every setting (the `site` block names the location; `spotify` holds the playlist; `timing` and `page_hold_ms` the numbers behind HOLD 2s and WAIT 1 MIN) |
| `schema.py` | the registry of every setting: type, default, help, which screen of the settings page owns it, and the rules a config must pass (`tests/test_schema.py` keeps it in step with the code) |
| `make_page.py` | builds `status-page.companionconfig` from `config.json` |
| `install.sh` | installs or updates the LaunchAgent on NA Ingest |
| `push_page.py` | imports the status page into page 19 over Companion's API |
| `push_page.py` | imports the status page into page 19 over Companion's API (`--which restart` / `--which ready` for pages 20 and 24) |
| `readiness.py` | the READY page: looks at Planning Center, ProPresenter, the Ableton set, ProdDash's lineup, Resolume and the shared drive, and runs the READY fixes |
| `nav_keys.py` | colours (or restores) the page nav keys on every page |
| `nav_templates.json` | Companion's own nav key designs, coloured by `make_page.py` |
| `remote-access/` | one-time setup scripts for SSH to the Macs and Windows PCs |
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# backup-campuses.sh - every location's status panel settings, off-site in Google Drive, nightly.
#
# The same pattern as the checklist's backup-to-drive.sh on this Mac: a LaunchAgent runs it at 04:30,
# it copies into the shared Drive (Waters Creative > Apps > Status Panel Backups > <location>), keeps
# the newest 30 per location, and emails an alert (through the checklist's alert.js) when something
# fails, because a backup nobody hears about failing is no backup.
#
# Only the hub (North Attleboro: no "home" in campuses.json) does anything; elsewhere it exits, except
# on a standalone location (Apollo Beach), which backs up only itself into ~/Documents/Status Panel Backups.
# Each location is reached over SSH with the panel's own key, as the account it reported. What is
# copied: config.json (the settings), history/ (every Apply), the page-key record and the location
# lists. Never outlets.json (the smart-plug keys stay on their Mac).
set -uo pipefail
DEST="$HOME/Library/Application Support/status-panel"
LOG="$HOME/Library/Logs/status-panel-backup.log"
KEEP=30
KEY="$HOME/.ssh/id_status_panel"
FILES="config.json history nav_locations.json campuses.json computers.json discover-seen.json"
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG"; echo "$*"; }
alert() {
log "ALERT: $1"
local js="$HOME/checklist-app/scripts/alert.js"
[ -f "$js" ] && node "$js" "Status panel backup: $1" "${2:-}$(printf '\n\nLog: %s' "$LOG")" >> "$LOG" 2>&1 || true
}
MODE=$(/usr/bin/python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("standalone" if d.get("standalone") else "location" if d.get("home") else "hub" if d.get("list") else "")' \
"$DEST/campuses.json" 2>/dev/null)
[ "$MODE" = hub ] || [ "$MODE" = standalone ] || { log "not the hub: nothing to back up from here"; exit 0; }
DRIVE=""
if [ "$MODE" = standalone ]; then
# A location on its own (Apollo Beach) keeps its own copies, in this Mac's Documents (or wherever
# STATUS_PANEL_BACKUPS points, e.g. a Google Drive folder).
DRIVE="${STATUS_PANEL_BACKUPS:-$HOME/Documents/Status Panel Backups}"
else
for acct in "$HOME/Library/CloudStorage"/GoogleDrive-*; do
[ -d "$acct/Shared drives/Waters Creative/Apps" ] && { DRIVE="$acct/Shared drives/Waters Creative/Apps/Status Panel Backups"; break; }
done
fi
if [ -z "$DRIVE" ]; then
alert "tonight's backup did not run - the Google Drive folder is missing" \
"Shared drives > Waters Creative > Apps could not be found on this Mac. Usually Google Drive is not running or signed out."
exit 1
fi
mkdir -p "$DRIVE" || { alert "could not create $DRIVE"; exit 1; }
log "── run start ── into $DRIVE"
STAMP="$(date '+%Y-%m-%d')"
FAILED=""
while IFS=$'\t' read -r id user host; do
[ -z "$id" ] && continue
mkdir -p "$DRIVE/$id"
OUT="$DRIVE/$id/$id-$STAMP.tgz"
TMP="$(mktemp -t status-backup)"
if [ "$host" = "self" ]; then
(cd "$DEST" && tar -czf "$TMP" $(ls -d $FILES 2>/dev/null)) 2>>"$LOG"
else
ssh -n -i "$KEY" -o BatchMode=yes -o ConnectTimeout=15 -o StrictHostKeyChecking=accept-new "$user@$host" \
"cd 'Library/Application Support/status-panel' && tar -czf - \$(ls -d $FILES 2>/dev/null)" > "$TMP" 2>>"$LOG"
fi
# Listing it proves it whole (not gzip -t: macOS tar pads a piped archive, which gzip calls "trailing garbage").
if [ -s "$TMP" ] && tar -tzf "$TMP" 2>/dev/null | grep -qx 'config.json'; then
cp "$TMP" "$OUT.part" && mv "$OUT.part" "$OUT" && log "✓ $id -> $(basename "$OUT") ($(du -k "$OUT" | cut -f1) KB)"
ls -1t "$DRIVE/$id"/"$id"-*.tgz 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r old; do rm -f "$old"; done
else
FAILED="$FAILED $id"
log "✗ $id: nothing usable came back from $user@$host"
fi
rm -f "$TMP"
done < <(/usr/bin/python3 - "$DEST/campuses.json" "$DEST/config.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1])); me = (json.load(open(sys.argv[2])).get("site") or {}).get("id")
for cid, e in sorted((d.get("list") or {}).items()):
if cid == me:
print("%s\t-\tself" % cid)
elif e.get("user") and e.get("url"):
print("%s\t%s\t%s" % (cid, e["user"], e["url"].split("//")[1].split(":")[0]))
else:
print("%s\t?\t?" % cid)
PY
)
if [ -n "$FAILED" ]; then
alert "no backup tonight for:$FAILED" "The other locations were backed up. A location missing here is usually off, off the network, or no longer letting this Mac log in."
exit 1
fi
log "── done ──"
+1783 -1247
View File
File diff suppressed because it is too large Load Diff
+109 -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,104 @@ def disconnect_peer(session_name, peer_name):
return "not connected" if result == "unchanged" else result
# The same read as sessions(), for a Mac without python (no Command Line Tools): macOS's own
# JavaScript for Automation calls CoreMIDI directly, nothing to install. Prints the sessions as JSON.
# Older macOS keeps no "state" for a peer; a peer without one is listed as configured, no verdict.
JXA_SESSIONS = r"""
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
var out = {}, n = $.MIDIGetNumberOfDevices();
for (var i = 0; i < n; i++) {
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
if (!s) continue;
out[s['dns-sd-name'] || str(ent, $('name')) || '?'] = {port: s.port, peers: (s.peers || []).map(function (p) {
var q = {name: p.name, address: p.address}; if (p.state !== undefined) q.state = p.state; return q; })};
}
}
'SPMIDI' + JSON.stringify(out);
"""
# Connect, for a Mac without python: what connect_peer does, in JavaScript for Automation. Filled in
# with jxa_connect(); prints SPMIDI"already" / "asked" / "kicked" / "moved", like connect_peer.
JXA_CONNECT = r"""
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
var SESSION = __SESSION__, PEER = __PEER__, ADDRESS = __ADDRESS__, REPOINT = __REPOINT__;
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
function put(ent, s) { var e = $.MIDIObjectSetDictionaryProperty(ent, $('apple.midirtp.session'), $(s)); if (e !== 0) throw new Error('MIDIObjectSetDictionaryProperty failed: ' + e); }
var found = null, n = $.MIDIGetNumberOfDevices();
for (var i = 0; i < n && !found; i++) {
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
if (s && (s['dns-sd-name'] || str(ent, $('name'))) === SESSION) { found = [ent, s]; break; }
}
}
if (!found) throw new Error('no network MIDI session named ' + SESSION + ' on this Mac');
var ent = found[0], s = found[1], peers = s.peers || [], result;
var mine = peers.filter(function (p) { return p.name === PEER; });
var stuck = mine.some(function (p) { return p.state !== undefined && p.state !== 'running' && p.state !== 'connected'; });
var elsewhere = mine.some(function (p) { return p.address !== ADDRESS; });
if (mine.length && (stuck || (REPOINT && elsewhere))) {
s.peers = peers.filter(function (p) { return p.name !== PEER; });
put(ent, s); delay(1);
s.peers = s.peers.concat([{address: ADDRESS, name: PEER}]); put(ent, s);
result = (REPOINT && elsewhere) ? 'moved' : 'kicked';
} else if (mine.length || peers.some(function (p) { return p.address === ADDRESS; })) {
result = 'already';
} else {
s.peers = peers.concat([{address: ADDRESS, name: PEER}]); put(ent, s); result = 'asked';
}
'SPMIDI' + JSON.stringify(result);
"""
# Disconnect, for a Mac without python: what disconnect_peer does. Prints SPMIDI"asked" / "not connected".
JXA_DISCONNECT = r"""
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
var SESSION = __SESSION__, PEER = __PEER__;
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
var found = null, n = $.MIDIGetNumberOfDevices();
for (var i = 0; i < n && !found; i++) {
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
if (s && (s['dns-sd-name'] || str(ent, $('name'))) === SESSION) { found = [ent, s]; break; }
}
}
if (!found) throw new Error('no network MIDI session named ' + SESSION + ' on this Mac');
var ent = found[0], s = found[1], peers = s.peers || [], result = 'not connected';
var left = peers.filter(function (p) { return p.name !== PEER; });
if (left.length !== peers.length) {
s.peers = left;
var e = $.MIDIObjectSetDictionaryProperty(ent, $('apple.midirtp.session'), $(s));
if (e !== 0) throw new Error('MIDIObjectSetDictionaryProperty failed: ' + e);
result = 'asked';
}
'SPMIDI' + JSON.stringify(result);
"""
def jxa_disconnect(session_name, peer_name):
import json as _json
return JXA_DISCONNECT.replace("__SESSION__", _json.dumps(session_name)).replace("__PEER__", _json.dumps(peer_name))
def jxa_connect(session_name, peer_name, address, repoint=False):
"""The JXA_CONNECT script for one peer (values go in as JSON, never as code)."""
import json as _json
return (JXA_CONNECT.replace("__SESSION__", _json.dumps(session_name)).replace("__PEER__", _json.dumps(peer_name))
.replace("__ADDRESS__", _json.dumps(address)).replace("__REPOINT__", "true" if repoint else "false"))
def sessions():
"""{session name: {"port": int, "peers": [{"name", "address", "state"}]}}"""
found = {}
+547 -547
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -51,7 +51,7 @@ Weaknesses
Strengths
- Wizards that map one-to-one onto the user's own examples: Add a MIDI link with live pickers, Add or remove a HyperDeck adjusting the REC members too, Change the playlist by pasting a link, Add a computer, Add a Windows app task, New campus; plus a demo-mode walkthrough.
- Wizards that map one-to-one onto the user's own examples: Add a MIDI link with live pickers, Add or remove a HyperDeck adjusting the REC members too, Change the playlist by pasting a link, Add a computer, Add a Windows app task, New location; plus a demo-mode walkthrough.
- The live Stream Deck picture on the Overview from sp_state with click-through to the key, a global search box, and a "Stream Deck" (where it sits) versus "Keys" (what it does) split a volunteer gets in one sentence.
- The safest Apply order (restart the watchdog, prove it, then pages and nav with a dry run shown), the key guide regenerated on every Apply, History never auto-pruned, and "Undo last apply" on the Overview.
- Makes alert_after_seconds and the fix cooldown editable with the key texts following; validates fix kinds in load_config; requires host or conn on ping checks; the settings server imports only load_config, CHECKS and make_page so a page bug cannot take the panel down.
@@ -68,7 +68,7 @@ Weaknesses
### Ideas the judges asked to graft (and where they landed)
- From teaching: restart the watchdog and prove it started before pages are rebuilt or imported, and roll pages back too (K10).
- From teaching: the job wizards for the named asks (K4, P4) and "New campus from this one" (K17).
- From teaching: the job wizards for the named asks (K4, P4) and "New location from this one" (K17).
- From teaching: the live deck on the Overview from sp_state with click-through (K4); the "what this key will say" preview (K5); the key guide rebuilt on every Apply (K15); History never auto-pruned (K14); Undo the last change (K14); "done by hand" ticks (K8 manual_done); alert_after_seconds and the fix cooldown editable with the key texts following (K12); a global search box (D10).
- From simplest: the AST-based schema sync test and "defaults in code must equal the schema" instead of removing the defaults from the watchdog (K16); the Phase 0 round-trip test (K16, P0).
- From simplest: the password-free edit gate, later reduced from allow-list plus unlock plus typed APPLY to one editing code (K2, C3).
@@ -156,7 +156,7 @@ Round 1 critics read the first full plan, round 2 the revision that answered the
| 2 | Simplicity | major | The settings page needs its own Spotify permission, a Test button and an extra Allow step. | ADOPTED | K9 one permission light; K7 Run now goes through the watchdog so the page never drives Spotify; the P1 spike checks the grant. |
| 2 | Simplicity | minor | Map cells are walls of text. | ADOPTED | K4 and K8: one status word per row, what it does, the keys that turn red. |
| 2 | Simplicity | minor | Six different "test" buttons with unclear effects. | ADOPTED | K9 read-only buttons say "Check ..." ; K7 "Run this fix now" is the one that acts. |
| 2 | Simplicity | minor | Update the program and the campus export sit on the normal Site screen; pull_site.py is a terminal step. | SIMPLIFIED | C12 Install the staged folder and Export site bundle sit under Site setup (set up once); the K17 export replaces pull_site.py; scp remains (K13). |
| 2 | Simplicity | minor | Update the program and the location export sit on the normal Site screen; pull_site.py is a terminal step. | SIMPLIFIED | C12 Install the staged folder and Export site bundle sit under Site setup (set up once); the K17 export replaces pull_site.py; scp remains (K13). |
| 2 | Simplicity | minor | Per-editor drafts with take-over and discard add a multi-user merge concept. | ADOPTED | K10 one shared draft; the 409 names who changed what. |
| 2 | Simplicity | minor | The Keys grid draws three overlays; "+ New key" offers ALL, ALARMS and NET. | ADOPTED | K5 draws the 8x4 deck; fixed chips for ALL / ALARMS / NET. |
| 2 | Simplicity | minor | Overview carries admin wizards, gear, a log tail and duplicate lights. | ADOPTED | K4 problems, the deck, computers, three job buttons plus More jobs, status lines (round 3 Simplicity set the three). |
@@ -193,7 +193,7 @@ Round 1 critics read the first full plan, round 2 the revision that answered the
| 3 | Completeness | major | install.sh's deploy list omits the remote-access templates and companion-objects. | ADOPTED | K13 deploys both; K17 nav_templates/<version>.json. |
| 3 | Completeness | major | Windows batching with %ERRORLEVEL% expands before the command runs, so every check reports OK. | ADOPTED | K12 generates per-command conditional echo pairs (SPn=0 on success, SPn=1 otherwise), never a bare %ERRORLEVEL%, verified once on the Lighting PC. |
| 3 | Completeness | minor | No expected module per check type; a HyperDeck check on a generic TCP connection is silently wrong. | ADOPTED | K12 EXPECTED_MODULE filters the pickers and warns on mismatch; it lives in the K16 registry. |
| 3 | Completeness | minor | restart_page is optional but the Keys screen always has the tab and chips. | ADOPTED | K9 "this campus has no Restart page" switch (built in P3). |
| 3 | Completeness | minor | restart_page is optional but the Keys screen always has the tab and chips. | ADOPTED | K9 "this location has no Restart page" switch (built in P3). |
| 3 | Completeness | minor | Windows cannot cross midnight. | ADOPTED | K18. |
| 3 | Completeness | minor | Add a MIDI link has no typed fallback when the Mac is off. | ADOPTED | P4. |
| 3 | Completeness | minor | The Ingest self record can be named by ssh fixes, remote checks and midi_peer (a runtime KeyError). | ADOPTED | K12 host validated (exists, has ssh, not self); builders switch to local for the self record. |
@@ -280,7 +280,7 @@ C14, preview run. Review has no "Preview the deck with these settings" button th
- Raw Custom commands (K6) are a remote-command surface on seven computers, guarded by one code over plain HTTP on a LAN. Mitigation: LAN-only binding narrowed by hosts.ingest.networks (K1), origin check and lockout (K2), the code asked again and the command shown red in Review (K6), zsh -n / sh -n, History naming the editor (K14), and the private repo's access list deciding who holds the code.
- Probes are parameterised, not fixed (K8): names and paths from the config reach ssh lines. Mitigation: shlex-quoted and regex-checked parameters, a fixed command set, the metacharacter test (K16), never run during a fix, 6 s timeout, never wake a PC.
- Windows DefaultShell (K12): the batched checks assume cmd.exe. Mitigation: a light on the PC's card and refusal of cmd-batched checks when the shell is not cmd; verified once on the Lighting PC; setting DefaultShell stays a by-hand system change for the user.
- Python 3.13 removes telnetlib, which netgear.py uses (K12 self-check for 3.9-3.12). Mitigation: the self-check light; a newer macOS at another campus would lose the Dante map and PoE cycling until telnetlib is replaced, which is not in this plan.
- Python 3.13 removes telnetlib, which netgear.py uses (K12 self-check for 3.9-3.12). Mitigation: the self-check light; a newer macOS at another location would lose the Dante map and PoE cycling until telnetlib is replaced, which is not in this plan.
- Companion tRPC spike unknowns (P1): custom-variable creation, trigger import, resetControl blanking a cell, the OBS variable, the page walk, whether one Automation grant covers both agents. Mitigation: recorded answers before any Apply step depends on them, with fallbacks (by hand, "unknown", schedule-only).
- Reads are open on the LAN (K2): logs, history bodies and the fleet map are visible to anyone who joins the network. Mitigation: the webhook is masked everywhere, no secret is in History, the bind is narrowed to the named LAN interfaces (K1).
- The only live signal is a recording HyperDeck (K11, C7), and a stale live-now.json falls back to the schedule. Mitigation: Review says "schedule only" when stale, Q2 fills the windows, OBS follows the spike.
+11 -11
View File
@@ -19,13 +19,13 @@ Values are typed once: every string that names a program, path, task or helper l
| `site.id` NEW | Site setup > Site (Advanced) | Site id | Short name for folders and files ("na"). | `[a-z0-9_-]+`; restart |
| `site.name`, `site.ingest_name`, `site.ingest_hostname`, `site.ingest_ips[]` NEW | Site setup > Site | Site name, this Mac's name, network name, addresses | Used in log headers, alert text, key notes and generated setup scripts (replaces "NA Ingest" and the IPs in windows-setup.ps1). [Check this Mac's network ports] fills the addresses. | non-empty; IPs valid; restart (a changed note alone never forces a re-import, D11) |
| `site.settings_port` NEW | Site setup > Site (Advanced) | Port | Where this page listens: loopback plus every private-address port, narrowed by this Mac's LAN list. Default 8020. | 1024-65535; the dialog states the consequence (the page moves to the new address); settings |
| `site.editing_code` NEW | Site setup > Site (Advanced) | Editing code | The code that switches Editing on. It is a setting in this campus's `config.json`, kept in the private git repo, so everyone with the repo has it (the user's choice, 2026-10-13); browsers remember it with the person's first name. Masked in every API response unless the request carries it. The one code: no admin code, no address list, no page-generated code. | non-empty; changing it: the dialog says every browser must enter the new code; none |
| `site.editing_code` NEW | Site setup > Site (Advanced) | Editing code | The code that switches Editing on. It is a setting in this location's `config.json`, kept in the private git repo, so everyone with the repo has it (the user's choice, 2026-10-13); browsers remember it with the person's first name. Masked in every API response unless the request carries it. The one code: no admin code, no address list, no page-generated code. | non-empty; changing it: the dialog says every browser must enter the new code; none |
| `site.live_services[]` NEW | Site setup > Schedules | Live services | When a service is on: Apply, Run now, Restart the watchdog, Re-import, Check the switcher and Install are refused and your draft stays saved; auto-fix may run; alarms are always on. Defaults to `auto_fix.windows`; the Phase 0 migration writes it. | days in mon..sun; `HH:MM`; end <= start = runs past midnight (allowed); warn if empty; restart (ServiceMode is built at start) |
| built-in live signal: a watched HyperDeck that "follows the recording" is recording | Site setup > Schedules ("Also counts as live", read-only line with "Live now?") | - | Derived from every hyperdeck check with "follows the recording"; the watchdog writes `live-now.json` on change / every 5 s with a timestamp; older than 30 s = schedule only (said in Review). No connection/variable/value triple on the page; "OBS is streaming" is added as a built-in kind only if the Phase 1 spike finds the module variable. | DNE; warn when no watched deck follows the recording |
| `site.contacts` NEW | Site setup > Site | Contacts | Printed on the cheat sheet. | free text; none |
| `site.dvs_helper`, `site.solo_dir` NEW | Site setup > Site (Advanced) | DVS helper path, Studio B helper folder | One owner for paths that fixes, checks and scripts repeat; builders and templates regenerate from them. | absolute path; restart |
| `alert_webhook`, `site.alert_prefix` NEW, `timing.alert_after_seconds` NEW | Site setup > Site / Schedules (Advanced) | Alert message (web hook), message starts with, alert after | Posts "<prefix>: Companion has not answered for N s" and the all-clear; also a line for every command edit applied. Blank = off. Shown masked everywhere ("set, 44 chars"); [Change] [Send a test alert]. | http(s) or blank; 5-600; restart (captured in run()) |
| `ssh_key` | Site setup > Site (Advanced) | Login key | Private key on this Mac for every other computer; light + public half with [Copy]. [Make the login key] on the Setup tab makes the panel's own pair at a new campus (asks first). | file exists, 0600; `.pub` readable; restart |
| `ssh_key` | Site setup > Site (Advanced) | Login key | Private key on this Mac for every other computer; light + public half with [Copy]. [Make the login key] on the Setup tab makes the panel's own pair at a new location (asks first). | file exists, 0600; `.pub` readable; restart |
| `companion.host/port/db` | Site setup > Companion (Advanced) | Address, port, settings database | Where Companion answers. The DB is read read-only for labels and addresses (module version only if the Phase 1 spike finds it trivially); blank = newest `vN.N/db.sqlite`. push_page and nav_keys read these too. | host non-empty; 1-65535; path exists; live probe; restart |
| `variables.state/heartbeat/command`, `variables.flash` NEW | Site setup > Companion (Advanced) | Variable names (state `sp_state`, heartbeat `sp_hb`, command `sp_cmd`, blink `flash_tick`) | Must exist in Companion: a hard error when Companion answers, a warning ("could not check") when it does not. Renaming the blink variable means renaming it inside the trigger in Companion too (said beside the field). | `[A-Za-z0-9_]+`; exists; pages + restart |
| `page`, `page_name`; `restart_page.page`, `restart_page.page_name` | Keys tab header (only home; the Companion section links here) | Page numbers and names | Name derived "<n>\nStatus" / "<n>\nRestart" unless overridden. Number change: the old page keeps its keys; Apply resets our marked keys on it (asks; if the spike shows `controls.resetControl` cannot blank a cell, Review lists "delete these N keys on page 19 by hand") and re-runs the page-number buttons. Name-only change on an existing page: rename it in Companion first (live name shown). "Pages exist" light. | int >= 1; distinct; name contains the number; live checks; pages |
@@ -68,7 +68,7 @@ Values are typed once: every string that names a program, path, task or helper l
| `tiles[].id` | Keys (Advanced) | Id | Used inside Companion; master is `all`. | `[A-Za-z0-9_]+`; unique vs restart keys; pages |
| `tiles[].label` | Keys | Label | First line and prefix of faults. | <= 10 warn; pages |
| `tiles[].pos` | Keys (drag) | Position / tray | Blank = in the tray "Shown inside the RF group (no key of their own)". | in grid; not col 0 rows 0-2; no overlap incl. jump/back pos; pages |
| `tiles[].kind` | Keys | Kind | single key / group; ALL and ALARMS are fixed chips (moved and edited, never created or deleted); NET may be removed at a campus that does not want it (at most one; D13). | exactly one ALL, one ALARMS, at most one NET; pages |
| `tiles[].kind` | Keys | Kind | single key / group; ALL and ALARMS are fixed chips (moved and edited, never created or deleted); NET may be removed at a location that does not want it (at most one; D13). | exactly one ALL, one ALARMS, at most one NET; pages |
| `tiles[].members[]` | Keys | Group members | Worst member shown ("worst member + N"); hold fixes all. | ids exist; not self; a hidden key must be in a group (warn); restart (pages when hold-ability changes, detected by the build) |
| `tiles[].checks[]` | Keys | Checks | See per type. Changing a key's check list drops that key's remembered acknowledgements and auto-fix tries (said in Review). | type in CHECKS; restart (pages when the build differs) |
| `tiles[].hold_always` | Keys > When held | also when the key is green | Even when green (HD, TASCAM, SPOTIFY). | fix exists with label; pages |
@@ -96,12 +96,12 @@ Values are typed once: every string that names a program, path, task or helper l
| artnet `source/max_silence_seconds/msg` | check row | Sender, silence, text | Port 6454 is fixed. | IP; 1-60; restart |
| atem check `part/msg` | check row | Part, text | Needs the ATEM section. | restart |
| `atem.host/node/library/reference`, `atem.compare` NEW, `atem.source_folder` NEW | Site setup > ATEM | Switcher address, patterns (resolved paths shown), saved file (upload or pick the newest from the folder), folder, what to keep in line | The upload (capped at 20 MB; XML with a DOCTYPE or entity declarations refused; parsed in the settings process with the watchdog's own parser) replaces `atem-reference.xml` atomically together with the config; its hash goes into History; the old file is kept dated under `history/atem/`; refused during a live service; Review shows "product name matches / N audio and M camera values differ from the current file" and needs the tick "I checked this is the right file for this switcher"; [Check the switcher now] reads only (3 s, a second connection, guarded). | IP; globs resolve; XML parses with the watchdog's own parser, no DOCTYPE or entity declarations, <= 20 MB; compare is a subset of MIX_OPTIONS/CCU_PARAMS; folder exists (light); restart |
| "this campus has no ATEM" switch (the `atem` section absent) | Site setup > ATEM | tick box | Hides the section; an atem check or a saved-switcher-file fix then fails validation with the key named. | cascade listed; restart |
| "this location has no ATEM" switch (the `atem` section absent) | Site setup > ATEM | tick box | Hides the section; an atem check or a saved-switcher-file fix then fails validation with the key named. | cascade listed; restart |
| `restart_page.jump_pos/back_pos`, `restart_page.keys[].id/label/pos/fix/watch[]` | Keys (Restart tab) | jump/back chips; restart keys: label, position, fix, "keep flashing HOLD TO FIX while these are down" | watch = fix ids and "conn:<label>". | id unique vs tiles; fix exists; labels live-checked; pages (fix/watch: restart) |
| "this campus has no Restart page" switch (the `restart_page` section absent) | Keys (Restart tab) | tick box | make_page builds no Restart page and no jump key; the restart keys and the jump chip go with it (cascade listed). | cascade listed; pages |
| "this location has no Restart page" switch (the `restart_page` section absent) | Keys (Restart tab) | tick box | make_page builds no Restart page and no jump key; the restart keys and the jump chip go with it (cascade listed). | cascade listed; pages |
| Companion connection labels | Site setup > Companion | live list with module id, status, watched by; rename-to for a vanished label; [Add a key for it] on unwatched ones | | warning only |
| custom variables / blink trigger / modules / pages exist / our pages / page-number buttons | Site setup > Companion | lights; "coloured on N of M pages" (counted when the section is opened) + [Colour them now] + [Undo]; [Re-import the pages now] | Set up once by the person who installed Companion. | by hand except nav revert and re-import |
| `companion-objects/` (trigger export, quiet Tascam module, nav templates per version) NEW | Help > Set up a new campus | shown with the import steps | Captured from NA once (Phase 1); not editable on the page. | repo files, deployed to the data dir |
| `companion-objects/` (trigger export, quiet Tascam module, nav templates per version) NEW | Help > Set up a new location | shown with the import steps | Captured from NA once (Phase 1); not editable on the page. | repo files, deployed to the data dir |
| Windows program table (`runs[].kind=task`) | Computers (card) | task name, exe candidates, args, at logon | Generates windows-setup.ps1, task names in fixes and the "is running on a Windows PC" check. | cmd-safe regex; none (dependents -> restart) |
| `$Key`, `$IngestIPs` in scripts | Computers > Setup | from `ssh_key.pub` and `site.ingest_ips` | | - |
| `{{PT_BUNDLE_ID}}`, `{{SOLO_MENU_PATH}}` in the solo agent | Computers (Studio B card, Advanced) | Pro Tools bundle id, solo menu path | Differ per Pro Tools version/language; defaults are today's values. | non-empty; regenerating shows the agent needs a rebuild + Accessibility re-grant |
@@ -171,7 +171,7 @@ The other NA records: **prop** (sshd, DVS daemon NA-ProPresenter-Mac, helper_dvs
- Every Mac: Remote Login on; Ingest login key authorised (generated `mac-authorize.sh`); host key known (with "changed since last time" + [Forget the old login key]); `/usr/bin/python3` present (for MIDI reads); DVS helper + sudoers (computers with `helper_dvs`); Audio MIDI Setup sessions named as the checks expect (expected beside live; a peer added by the Add a MIDI link job shows "will be added on first hold"; a typed session shows NO SESSION until it exists); apps at the listed paths; Studio B: solo agent running, `solo-mode.json` fresh, `trusted: true` (Accessibility), swiftc present for a rebuild.
- Every Windows PC: sshd Automatic + running; OpenSSH DefaultShell is cmd.exe (light); firewall rule allows this site's Ingest IPs; key login works (both authorized_keys files); a `\StatusPanel\` task per `task` run; services' failure-restart; WoL on the adapter; BIOS WoL (Done by hand); Public profile when rtpMIDI is listed (light + Done by hand); Vista autoload (Done by hand); Wi-Fi power (Done by hand); RESULT block pasted (fills ssh user, MAC, IP); the host-key-changed line as on Macs.
- This Mac: Command Line Tools / python 3.9-3.12; watchdog agent (loaded, running, ExitTimeOut set to the derived value); settings agent; Companion checklist (variables, blink trigger, pages exist, our pages, quiet Tascam module, ATEM module, "use last page" noted); keychain item (if `dante_switches`); the watchdog's Spotify permission (if a spotify check); DVS helper + sudoers (if dvs_ingest); login key pair ([Make the login key] at a new campus); editing code present; ports for LAN / Dante / switch management up; Art-Net port bound (if artnet); saved-file folder mounted (if `atem.source_folder` is set); Remote Login on (for the MacBook's `checklist-<id>` alias); Church Translate not loaded.
- This Mac: Command Line Tools / python 3.9-3.12; watchdog agent (loaded, running, ExitTimeOut set to the derived value); settings agent; Companion checklist (variables, blink trigger, pages exist, our pages, quiet Tascam module, ATEM module, "use last page" noted); keychain item (if `dante_switches`); the watchdog's Spotify permission (if a spotify check); DVS helper + sudoers (if dvs_ingest); login key pair ([Make the login key] at a new location); editing code present; ports for LAN / Dante / switch management up; Art-Net port bound (if artnet); saved-file folder mounted (if `atem.source_folder` is set); Remote Login on (for the MacBook's `checklist-<id>` alias); Church Translate not loaded.
**Generated scripts** (`settings/generate.py`): "Generate" fills `remote-access/mac-authorize.sh.template`, `mac-dvs-helper.sh.template`, `windows-setup.ps1.template`, `install-solo-mode.sh.template` and `solo-mode-agent.swift.template` with the placeholders `{{PUBLIC_KEY}}`, `{{INGEST_IPS}}`, `{{APPS}}`, `{{DVS_HELPER}}`, `{{SOLO_DIR}}`, `{{SOLO_LABEL}}`, `{{PT_BUNDLE_ID}}`, `{{SOLO_MENU_PATH}}` and `{{SITE}}` from the site's public key, Ingest IPs, program table, helper paths and site name; every Mac value is shell-quoted, every Windows value passes the cmd-safe regex (and contains no `!` or `^`); the Windows script also sets the OpenSSH DefaultShell to cmd.exe. Each script is shown with Copy / Download and the exact place to paste it (ARD "Send UNIX Command as root" / PowerShell as administrator / scp + ssh for Studio B; a rebuilt solo agent needs Accessibility re-granted on that Mac). The page never runs them (guideline 12). The schema check compares `site.solo_dir` against the generated strings of the Studio B checks, the solo fix and the swift template, so all three agree by construction. The templates are deployed to the data dir by `install.sh`, and `test_generate_from_data_dir` (Phase 5) proves generate.py finds and fills every one of them there.
@@ -332,10 +332,10 @@ Every inventory item, and where the plan handles it or why it is deliberately no
| mdns instance/name (picker); Bonjour type fixed | Keys; type DNE |
| midi_rx source/name/max_silence_seconds | Keys |
| remote host/cmd/msg/shell NEW (Macs and Windows PCs); artnet source/max_silence/msg (port DNE); atem check part/msg | Keys; remote built from "A program is running on a Mac" / "on a Windows PC" / "A helper file is fresh" builders, Custom under Advanced with the editing code typed again, `sh -n` on the batched Mac line, Windows pairs with no `%ERRORLEVEL%` (verified on the Lighting PC), DefaultShell light, never run before Apply; host not self |
| atem section/host/node/library (resolved)/reference (upload, dated history)/source_folder NEW; MIX_OPTIONS/CCU_PARAMS; parser; atem_watch.js constants; atem_state.js | Site setup > ATEM incl. `atem.compare`, pick-newest from the folder, upload cap/DOCTYPE/diff/tick, live refusal, the saved file as part of the applied set (atomic, hashed in History, restored by rollback and Go back, never overwritten by install.sh), guarded "Check the switcher now" with the client-limit note; "this campus has no ATEM" switch; js constants DNE |
| atem section/host/node/library (resolved)/reference (upload, dated history)/source_folder NEW; MIX_OPTIONS/CCU_PARAMS; parser; atem_watch.js constants; atem_state.js | Site setup > ATEM incl. `atem.compare`, pick-newest from the folder, upload cap/DOCTYPE/diff/tick, live refusal, the saved file as part of the applied set (atomic, hashed in History, restored by rollback and Go back, never overwritten by install.sh), guarded "Check the switcher now" with the client-limit note; "this location has no ATEM" switch; js constants DNE |
| auto_fix section/windows/after/tries/retry/pause/restart_connections/fixes/forget_seconds/log_dir/retention/header text | Site setup > Schedules (tick list and restart_connections main; numbers, folder and `keep_months` Advanced with defaults); windows derived from live services; header and "may run by itself" phrase built from site + windows; restart (AutoLog in __init__) |
| site.live_services NEW (and the former site.live_signals idea) | Site setup > Schedules (one schedule incl. overnight windows); defaults to `auto_fix.windows`, the migration writes it; "Also counts as live" shows only the built-in recording-deck signal (no conn/var/value triple; "OBS is streaming" only as a built-in kind if the Phase 1 spike finds the module variable); stale handling (30 s); no quiet time and no queue: Apply is refused while live and the draft is kept |
| restart_page section/page/page_name/jump_pos/back_pos/keys id/label/pos/fix/watch; section absent | Keys Restart tab (jump/back chips, "this campus has no Restart page" switch with its cascade; make_page builds nothing when absent) |
| restart_page section/page/page_name/jump_pos/back_pos/keys id/label/pos/fix/watch; section absent | Keys Restart tab (jump/back chips, "this location has no Restart page" switch with its cascade; make_page builds nothing when absent) |
| make_page options/steps/style, nav templates, stale texts, PURPLE, button style, action ids, notes markers | DNE; notes wording from the site (+ the page address on ALL, markers on the jump keys, one `MARKERS` constant); stale seconds editable; nav templates per version in `companion-objects/nav_templates/` (root file dropped) with the recapture recipe |
| HOLD_MS, GRID, STALE_SECONDS, NAV_LOCATIONS, EXPORT_VERSION/COMPANION_BUILD | `page_hold_ms` (read by the watchdog for "HOLD 2s"), `page_grid`, `page_stale_seconds`, `companion.export_version/build` (picks the nav template); NAV_LOCATIONS DNE (locked cells) |
| push_page/nav_keys --host defaults; "on NA Ingest" wording | read the config; site block (Phase 0) |
@@ -382,7 +382,7 @@ Every inventory item, and where the plan handles it or why it is deliberately no
|---|---|
| MacBook project folder, Gitea remote, checklist-ingest alias, memory files, gitea keychain item | DNE (dev side); scp staging (code only) + Export site bundle; `checklist-<id>` alias in the bootstrap list; no git credential on Ingest |
| NA Ingest LaunchAgent (exit timeout), staging folder, data dir, python3 3.9 (3.13 telnetlib risk), logs, auto-fix logs, state, dante-map, atem-reference.json, config backups, ssh sockets, MIDI streams, ATEM watcher, Art-Net listener, Bonjour, local CoreMIDI sessions, local probes, Spotify osascript, Companion db read | Ingest card lights incl. the python-version self-check, launchd agent state with the derived exit timeout and started.json (§B); Logs; History; Where things are; nothing copied to the staging folder by Apply |
| Companion app/version, custom variables, flash trigger, page 19, page 20, nav keys on 97 pages (gap at 96), quiet Tascam module, module folders, Companion Satellite, Stream Deck surfaces | Site setup > Companion (version, variables, pages exist / ours, page-number buttons "coloured on N of M" counted when the section is opened with the gap listed, module id per connection, version only if trivial); surfaces not shown; Satellite on the Ingest card (`watched:false`); Companion set-up by hand; `companion-objects/` for a new campus |
| Companion app/version, custom variables, flash trigger, page 19, page 20, nav keys on 97 pages (gap at 96), quiet Tascam module, module folders, Companion Satellite, Stream Deck surfaces | Site setup > Companion (version, variables, pages exist / ours, page-number buttons "coloured on N of M" counted when the section is opened with the gap listed, module id per connection, version only if trivial); surfaces not shown; Satellite on the Ingest card (`watched:false`); Companion set-up by hand; `companion-objects/` for a new location |
| every Companion connection (HyperDeck_1-6, tascam, x32, ATEM, dante, Axient_1/2, ULXD-4, PSM_1-3, ableton, Propresenter-API, BibleTV, lighting-midi, OBS-Youtube, arena, RESI_Decoder, resi-studio-cues, VX1000, UHDJr) and unwatched ones (Govee x7, DiGiCo_OSC, m32, AbleSet, tcp-HyperDeck-1..6) | Site setup > Companion live list with module id, watched-by, module-mismatch warning, "Add a key for it"; the HyperDeck job offers only bmd-hyperdeck connections; Open question Q4 |
| Resi bridge, lobby captions, caption screen, ProdCom (start method unknown), ProdDash, Spotify.app, ATEM Software Control + midiEnabled, DVS on Ingest, Church Translate disabled, Google Drive, free disk | Ingest `runs` (`disabled` kind for Translate; Drive folder probe via `atem.source_folder`) + INGEST key checks; bridge "never by itself"; captions fix with two helpers; dvs_ingest with prodcom rendered inline; prodcom/proddash without restart_ok unless ticked |
| other Ingest agents (checklist runner, backups, Itsycal, Alfred, Signiant) **and anything installed later** | Ingest `runs` with `watched:false`; the inventory probe lists every agent, login item and listening port under the collapsed "Everything found on this computer" list on the card, raw and unfiltered; no Add/Ignore, no counts on Overview |
@@ -417,4 +417,4 @@ Every inventory item, and where the plan handles it or why it is deliberately no
| service_windows + ALARMS, auto_fix window/list, poller periods, SSH identity/options, hosts table, Mac/Windows external setup, Public profile, Vista autoload, keychain, Spotify Automation, MIDI sessions local/remote, Ingest services, ATEM Software Control pref, hidden tiles, groups, restart keys, fix catalogue, network, alert_webhook, colours, cooldown/hold age, project folder, variables not persisted, full change sequence | covered by §A-§D; one schedule on the page; the one alarms sentence and the Silence alarms until midnight button (not guarded); the full change sequence collapses to: change -> Review & Apply -> done (refused while live with the draft kept; Emergency override), with the repo snapshot optional |
| History / Undo reachable from a clean state; pending work visible; vendor noise; two edit homes; four meanings of "key"; two meanings of "Settings" | History from Overview and its own menu entry, list and bodies open on the LAN; no pending work exists (a change refused while live stays a saved draft, said on Overview and in Review); raw inventory list on the card and no Overview count; fixes edited from the key, the catalogue under the "Set up once" divider; one owner screen per value with read-only links; editing code / login key / page-number buttons / connection; the page is "Status Panel", the section is "Site setup" |
| too many places; admin actions hidden under Help; three status vocabularies; contradictions about alarms; thresholds on main screens; hidden jobs; confusing tray; two watchdog homes; mixed confirmations; walkthrough behind the code | eight entries, the two set-up-once screens under a divider and readable by anyone; Help read-only, actions under Site setup > Site (Install the staged folder, Export site bundle); the one status legend printed once under the top bar's `?`; one alarms sentence; numbers under Advanced with defaults; three job buttons on Overview + More jobs; the labelled tray; watchdog buttons on Overview only; one confirmation style; the walkthrough needs no code because reads are open (the Training copy is deferred) |
| the user's four named asks: a new MIDI link (incl. a brand-new peer, or a Mac that is off), a different Spotify playlist (name follows everywhere), HyperDecks 2 and 4 only (both set to "must have a disk", generic TCP connections not offered), a new campus (own windows incl. overnight, own wording, reachable page before the LAN port is named, optional Restart page, optional ATEM) | the Overview jobs (first three, one click each) and the "New campus from this one" job (started from a site bundle you pick, exported from the source campus's page and copied over with the folder, or from any sites/<x>/ folder in the copied repo; `install.sh --site <id>` uses sites/<id>/ when present, else sites/_template/; D12) incl. bootstrap, the template's self record with `networks: []` and `companion-objects/` in the data dir; every copied value flagged "copied from <site> - check it" until touched |
| the user's four named asks: a new MIDI link (incl. a brand-new peer, or a Mac that is off), a different Spotify playlist (name follows everywhere), HyperDecks 2 and 4 only (both set to "must have a disk", generic TCP connections not offered), a new location (own windows incl. overnight, own wording, reachable page before the LAN port is named, optional Restart page, optional ATEM) | the Overview jobs (first three, one click each) and the "New location from this one" job (started from a site bundle you pick, exported from the source location's page and copied over with the folder, or from any sites/<x>/ folder in the copied repo; `install.sh --site <id>` uses sites/<id>/ when present, else sites/_template/; D12) incl. bootstrap, the template's self record with `networks: []` and `companion-objects/` in the data dir; every copied value flagged "copied from <site> - check it" until touched |
+18 -18
View File
@@ -1,12 +1,12 @@
# Status Panel settings page — the plan
One web page on the Ingest Mac that holds every setting the panel has, shows what runs on which computer, and works the same at every campus. Nobody edits `config.json` or a script by hand any more.
One web page on the Ingest Mac that holds every setting the panel has, shows what runs on which computer, and works the same at every location. Nobody edits `config.json` or a script by hand any more.
Companion files: [settings-plan-details.md](settings-plan-details.md) (every field, the data model, the apply pipeline, tests), [settings-plan-audit.md](settings-plan-audit.md) (how the plan was audited and what was cut), [settings-inventory.md](settings-inventory.md) (the 544 things the system has today, each with where it lives and how it is changed now).
## 1. What it is
The Status Panel is the Stream Deck page that is green when the room is fine and flashes red naming the problem. A watchdog program on the Ingest Mac runs it from one settings file. This plan adds a **settings page** on that same Mac, opened in any browser on the church network. Every setting is a labelled field with help: which computers exist and what runs on each, which keys are on the deck, what each key checks, what holding it does, when auto-fix may act, which Spotify playlist, switcher file, Dante devices, HyperDecks and MIDI links are in use. The page checks a change, shows in plain words what it will do, keeps a copy of every version, applies it safely, and the same page runs at every campus with that campus's own computers and gear.
The Status Panel is the Stream Deck page that is green when the room is fine and flashes red naming the problem. A watchdog program on the Ingest Mac runs it from one settings file. This plan adds a **settings page** on that same Mac, opened in any browser on the church network. Every setting is a labelled field with help: which computers exist and what runs on each, which keys are on the deck, what each key checks, what holding it does, when auto-fix may act, which Spotify playlist, switcher file, Dante devices, HyperDecks and MIDI links are in use. The page checks a change, shows in plain words what it will do, keeps a copy of every version, applies it safely, and the same page runs at every location with that location's own computers and gear.
Four words are used everywhere on it: a **key** (on the Stream Deck), a **computer** (anything we log in to, this Mac included), a **fix** (what holding a key runs) and a **connection** (Companion's link to one device: a HyperDeck, the Tascam, the switcher). The code you type to switch Editing on is the **editing code**. The file this Mac uses to log in to the other computers is the **login key**.
@@ -14,8 +14,8 @@ Four words are used everywhere on it: a **key** (on the Stream Deck), a **comput
- It runs on the Ingest Mac as a second background program beside the watchdog, installed by the same `install.sh`. Plain Python, nothing to install. It is a separate program so a bug in the page can never stop the panel, and the page keeps working while the watchdog restarts.
- Open **http://na-ingest.local:8020/** from any Mac, PC, iPad or phone on the church network. The address is printed on the cheat sheet and written into the ALL key's note in Companion, so anyone who finds the deck finds the page.
- **Anyone on the network can look** at everything: the deck, the computers, the logs, the history, the help. **Changing anything needs the editing code**: switch **Editing** on in the top bar, type the code once and your first name; the browser remembers both. The code is a setting in the campus's `config.json`, which lives in the private git repo, so everyone who has the repo has it; change it on Site setup › Site. The page never shows it to anyone who is not already editing. It is not a password for anything else. Five wrong tries lock the browser out for ten minutes.
- It edits the live settings file the watchdog reads (in `~/Library/Application Support/status-panel/`). From then on the Ingest Mac's copy is the truth for that campus; the git repo keeps a snapshot you export from the page.
- **Anyone on the network can look** at everything: the deck, the computers, the logs, the history, the help. **Changing anything needs the editing code**: switch **Editing** on in the top bar, type the code once and your first name; the browser remembers both. The code is a setting in the location's `config.json`, which lives in the private git repo, so everyone who has the repo has it; change it on Site setup › Site. The page never shows it to anyone who is not already editing. It is not a password for anything else. Five wrong tries lock the browser out for ten minutes.
- It edits the live settings file the watchdog reads (in `~/Library/Application Support/status-panel/`). From then on the Ingest Mac's copy is the truth for that location; the git repo keeps a snapshot you export from the page.
## 3. What you see
@@ -67,7 +67,7 @@ The first screen answers "is the room OK, and what do I do". In this order: the
### Keys
Two tabs, **Status page** and **Restart page**, each drawn as the 8×4 deck people hold. Every key is a chip you can drag to move, drag off the deck to hide, or click to edit. Column 0 (UP / page number / DOWN) is locked. ALL and ALARMS can be moved but not deleted; NET can be removed at a campus that does not want it. Keys with no spot of their own (AXIENT 1, AXIENT 2, ULXD, PSM 1–3) sit in a tray labelled "Shown inside the RF group (no key of their own)". Each tab header holds its page number; the Restart tab's also holds the switch "this campus has no Restart page". A yellow note says "Layout changed: Apply will re-import the Status page" when it will.
Two tabs, **Status page** and **Restart page**, each drawn as the 8×4 deck people hold. Every key is a chip you can drag to move, drag off the deck to hide, or click to edit. Column 0 (UP / page number / DOWN) is locked. ALL and ALARMS can be moved but not deleted; NET can be removed at a location that does not want it. Keys with no spot of their own (AXIENT 1, AXIENT 2, ULXD, PSM 1–3) sit in a tray labelled "Shown inside the RF group (no key of their own)". Each tab header holds its page number; the Restart tab's also holds the switch "this location has no Restart page". A yellow note says "Layout changed: Apply will re-import the Status page" when it will.
Click a key and its editor opens beside the grid. Two lines at the top answer the only two things a person needs to know: **WHAT THIS KEY WILL SAY** (every word the checks can put on it) and **HOLDING 2 s WILL:** ("switch HyperDeck_2's connection off and on, also when green"). Then only the fields that matter: the label, the group members (for a group key), the **checks** as a short list, and **When held** (which fix, and "also when the key is green"). "Add a check" asks a question first, not a type: *Is a device answering? Is a program running? Is a MIDI link up? Is a recorder OK? Is a setting right? Is Spotify playing? Is Dante complete? Is the lighting console sending?* You pick the computer or device from a live list (Companion's connections, the Dante devices, that Mac's MIDI sessions and peers, its programs) and the page builds the check. The raw check types and ids live under Advanced.
@@ -121,11 +121,11 @@ COMPUTERS - MAP [Check computers now] looked 2
Six sections, each opening on the few things a person changes:
- **Site:** site name, this Mac's name and addresses, contacts for the cheat sheet, the alert message hook. Advanced: page port, the login key (present / not, public half to copy), helper paths, the editing code, install the staged folder, export the site bundle.
- **Schedules:** when **auto-fix may act by itself** (Sunday 06:30–13:30 today) and the tick list of fixes it may run; alarms are always on. Changes to the panel wait while a HyperDeck is recording, not by the clock (a campus can add clock windows here too; NA has none). Advanced: the timing numbers (a fault must last 3 s, flapping, the 60 s cooldown, the 2 s hold).
- **Schedules:** when **auto-fix may act by itself** (Sunday 06:30–13:30 today) and the tick list of fixes it may run; alarms are always on. Changes to the panel wait while a HyperDeck is recording, not by the clock (a location can add clock windows here too; NA has none). Advanced: the timing numbers (a fault must last 3 s, flapping, the 60 s cooldown, the 2 s hold).
- **Companion:** the lights (variables exist, blink trigger, our pages exist, quiet Tascam module, ATEM module), the live list of connections with their module and which key watches each, "Add a key for it" for the ones nothing watches, the page-number buttons ("coloured on 96 of 97 pages" + Colour them now / Undo). Advanced: address, variable names, nav settings, grid, purple-after, export stamps.
- **Network & Dante:** the port and gateway the NET key watches, the Dante switches, and the **Dante device list** (the one place the DANTE key's devices are edited, with key text and per-device fix), "Check the Dante network now". Advanced: the blip rule, survey interval.
- **Spotify:** one field for the playlist (paste the link; name and song count fill in), the two waits, a light saying the watchdog may control Spotify.
- **ATEM:** the switcher address, the saved file (upload, or pick the newest from the Google Drive folder; you see what differs before it is applied), what to keep in line (audio, each camera setting), "Check the switcher now". A switch "this campus has no ATEM".
- **ATEM:** the switcher address, the saved file (upload, or pick the newest from the Google Drive folder; you see what differs before it is applied), what to keep in line (audio, each camera setting), "Check the switcher now". A switch "this location has no ATEM".
### Review & Apply
@@ -148,21 +148,21 @@ Grouped here; every single field is in [the details table](settings-plan-details
| Spotify | playlist, name, waits | Site setup → Spotify |
| ATEM | address, saved file, what to compare, node/library patterns | Site setup → ATEM |
| Site | name, this Mac's name/addresses, contacts, alert hook, port, helper paths, editing code | Site setup → Site |
| Not editable on purpose | colours, the words on keys, the 3 s/1 s/0 s debounce defaults, poll rates, the hold/ack mechanics, Companion's API paths, the shell commands the page builds — these are how the panel *works*, the same at every campus (guideline 1), and are listed in Help › Glossary and Where things are | Help |
| Not editable on purpose | colours, the words on keys, the 3 s/1 s/0 s debounce defaults, poll rates, the hold/ack mechanics, Companion's API paths, the shell commands the page builds — these are how the panel *works*, the same at every location (guideline 1), and are listed in Help › Glossary and Where things are | Help |
The four things you named, end to end: **A new MIDI link** = Add a MIDI link → pick the Mac → pick its session and peer from the live list (or type them if that Mac is off) → pick the key it goes on (MIDI if there is one, otherwise a new key) → it gains the check and the reconnect fix → Review & Apply. **A different playlist** = Change the playlist → paste the link → Apply (the key, the play fix and the key guide follow; only the watchdog restarts, and the key's note catches up at the next page re-import). **HyperDecks 2 and 4 only** = Add or remove a HyperDeck → untick 1, 3, 5, 6 → the HD keys and the REC group change (the emptied cells stay empty, or one click moves HD4 next to HD2), the page re-imports → Apply. **A new campus** = section 6.
The four things you named, end to end: **A new MIDI link** = Add a MIDI link → pick the Mac → pick its session and peer from the live list (or type them if that Mac is off) → pick the key it goes on (MIDI if there is one, otherwise a new key) → it gains the check and the reconnect fix → Review & Apply. **A different playlist** = Change the playlist → paste the link → Apply (the key, the play fix and the key guide follow; only the watchdog restarts, and the key's note catches up at the next page re-import). **HyperDecks 2 and 4 only** = Add or remove a HyperDeck → untick 1, 3, 5, 6 → the HD keys and the REC group change (the emptied cells stay empty, or one click moves HD4 next to HD2), the page re-imports → Apply. **A new location** = section 6.
## 5. Computers and what runs where
Each computer is a record the page owns: name, kind, how to log in, what runs there and whether the panel watches it. Today's eight are seeded from the inventory: NA Ingest (watchdog, settings page, Companion, Resi bridge, lobby captions and screen, ProdCom, ProdDash, Spotify, ATEM Software Control, DVS + helper, Google Drive for the switcher files, plus the other background tasks that are not the panel's), ProPresenter Mac, Tracks Mac, NA Translation, Studio B Mac Studio (the solo-mode helper, Accessibility), Lighting PC (Vista, Arena, rtpMIDI task + service, firewall, Public profile), LiveStream PC (OBS task with admin rights, wake-on-LAN), Bible TV PC (Bible app at logon, Wi-Fi power settings). The live look runs a fixed set of read-only commands on each computer (what launchd has loaded, which tasks and services exist, which programs run, is the login key accepted, is the helper installed), never during a fix, never waking a PC, with every name quoted so a typed name can never run as a command. The devices that are not computers are listed from the checks that watch them, with a note per device for where it lives.
The Setup tab generates each computer's one-time script (the Mac authorise script, the DVS helper, the Windows setup, the Studio B solo-mode agent) with that campus's key and addresses filled in. You still run them yourself (section 12); the page shows which lines are done.
The Setup tab generates each computer's one-time script (the Mac authorise script, the DVS helper, the Windows setup, the Studio B solo-mode agent) with that location's key and addresses filled in. You still run them yourself (section 12); the page shows which lines are done.
## 6. The other campuses
## 6. The other locations
Apollo Beach, Woonsocket and Southcoast each already have a Mac Studio running Companion; the page and the watchdog go on that Mac. One settings file per campus = one site block plus that campus's computers, keys, checks, fixes, windows and gear. The code never contains a site name, an address, a label, a playlist, a path or a day name; everything site-shaped is data. Differences between campuses are plain differences in that data: fewer HD keys, no Studio B, another playlist, other Dante names, an extra PC, other page numbers, a Saturday evening service that runs past midnight.
Apollo Beach, Woonsocket and Southcoast each already have a Mac Studio running Companion; the page and the watchdog go on that Mac. One settings file per location = one site block plus that location's computers, keys, checks, fixes, windows and gear. The code never contains a site name, an address, a label, a playlist, a path or a day name; everything site-shaped is data. Differences between locations are plain differences in that data: fewer HD keys, no Studio B, another playlist, other Dante names, an extra PC, other page numbers, a Saturday evening service that runs past midnight.
Setting up a new campus: install Companion and the command-line tools on its Ingest Mac, copy the folder over, run `install.sh --site <name>` once (it uses the repo's `sites/<name>/` if there is one, otherwise the empty template), do the Companion one-time steps (section 12). Then on the page: **New campus from this one** starts from a site bundle (export NA's from its page and copy it over with the folder, or pick any `sites/` folder in the copied repo) and walks you through it — keep, rename or drop each computer (dropping one drops every key, check and fix that used it and lists them), map the connection labels, Dante devices and MIDI sessions from that Companion's live lists, set the playlist, switcher, network and schedule, Review & Apply. Everything copied from NA is flagged "copied from North Attleboro — check it" until touched. The repo keeps one folder per site (`sites/na/`, `sites/<new>/`); the page's **Export site bundle** gives you the zip to drop in.
Setting up a new location: install Companion and the command-line tools on its Ingest Mac, copy the folder over, run `install.sh --site <name>` once (it uses the repo's `sites/<name>/` if there is one, otherwise the empty template), do the Companion one-time steps (section 12). Then on the page: **New location from this one** starts from a site bundle (export NA's from its page and copy it over with the folder, or pick any `sites/` folder in the copied repo) and walks you through it — keep, rename or drop each computer (dropping one drops every key, check and fix that used it and lists them), map the connection labels, Dante devices and MIDI sessions from that Companion's live lists, set the playlist, switcher, network and schedule, Review & Apply. Everything copied from NA is flagged "copied from North Attleboro — check it" until touched. The repo keeps one folder per site (`sites/na/`, `sites/<new>/`); the page's **Export site bundle** gives you the zip to drop in.
## 7. Saving a change
@@ -170,7 +170,7 @@ Setting up a new campus: install Companion and the command-line tools on its Ing
2. **Check.** On every change and again at Apply: the rules (ids, positions, windows, addresses, every fix's computer exists…), the watchdog's own loader on a temporary copy, a build of both pages in memory, and live warnings (a connection label not in Companion, a computer unreachable, a private playlist).
3. **Review.** The plain change list, warnings, anything built as a command in red, what Apply will do.
4. **Apply**, each step only after the previous one worked: keep a copy of the current version in History → write the new file in one go (and the new switcher file with it, if there is one) → wait for any fix that is running to finish → restart the watchdog (a change to key notes alone skips this) → **prove it came back** (its start stamp with the new file's fingerprint, no error, still alive after 10 s) → only then re-import the pages if the layout changed (key notes alone never trigger a re-import; the page refuses to overwrite a key someone added by hand in Companion and names its position) → recolour the page-number buttons if pages changed → rebuild "What each key does". If the watchdog does not come back, the previous version (and switcher file) is restored automatically, once, and Overview shows a red note until the next good Apply.
5. **While a HyperDeck is recording** (that is what "live" means at NA; a campus may add clock windows) Apply is refused with the reason and when it ends; the draft stays saved. The same goes for Run this fix now, Restart the watchdog, re-imports, the switcher check and program installs. An **Emergency** button states the consequence, asks you to type LIVE and a reason, and is logged. Starting a watchdog that is down is always allowed. Silencing alarms is never blocked.
5. **While a HyperDeck is recording** (that is what "live" means at NA; a location may add clock windows) Apply is refused with the reason and when it ends; the draft stays saved. The same goes for Run this fix now, Restart the watchdog, re-imports, the switcher check and program installs. An **Emergency** button states the consequence, asks you to type LIVE and a reason, and is logged. Starting a watchdog that is down is always allowed. Silencing alarms is never blocked.
6. **Going back** = History → Go back to this (or Undo the last change) through the same steps.
## 8. Safety
@@ -192,7 +192,7 @@ Help › four cards, then **What each key does** (regenerated on every Apply, so
- `schema.py`: the registry of every setting (type, default, range, help, example, which screen owns it, advanced or not). Tests keep it honest: every key the watchdog reads must be declared; every default in code must equal the schema's; every word a key can show must be in the glossary; today's config must still build today's pages exactly (apart from the key notes, which gain the page address and the markers); every one of today's 27 fixes must regenerate from its builder exactly.
- `settings/`: the server (routes for the draft, validate, apply, history, live lists, logs, probes), the probes, the apply pipeline, the script generator, the key-guide generator, and one HTML/JS page with no framework.
- Watchdog changes are additive: a start stamp, a restart that finishes the running fix, remembered acknowledgements, a "run this fix" verb, new settings read with today's values as defaults, Windows program checks that actually work (cmd.exe needs `&& echo` pairs, not `%ERRORLEVEL%`).
- `install.sh` validates the settings with the new code before touching anything, keeps the previous code for rollback, installs both programs, and never overwrites the campus's own files once the page exists.
- `install.sh` validates the settings with the new code before touching anything, keeps the previous code for rollback, installs both programs, and never overwrites the location's own files once the page exists.
- Full detail: [settings-plan-details.md](settings-plan-details.md).
## 11. Build order
@@ -207,14 +207,14 @@ Each step is deployed on its own, outside a service, with tests green, a live ch
| 3 Layout | Drag keys, add/delete keys, the Restart page, page numbers; Apply re-imports pages and recolours page-number buttons. | Move LED, add HD7, delete HD5, Apply, watch the deck change. | 2 days |
| 4 Jobs | Change the playlist, Add or remove a HyperDeck, Add a MIDI link, Add a computer, Make a PC program startable; "Add a key for it" on unwatched connections. | Untick HD1/3/5/6 and run on 2 and 4; add a MIDI link to a Mac that is off. | 2.5 days |
| 5 Setup + teaching | The Setup tab with lights and generated scripts, Where things are, Things only a person can do, Install the staged folder from the page. | Add a new PC, generate its script, run it, paste the result, watch the lights go green. | 2 days |
| 6 Other campuses | Site folders, the template, the bundle export, New campus from this one. | Build a demo campus on the MacBook with a Saturday night service and no OBS. | 1.5 days |
| 6 Other locations | Site folders, the template, the bundle export, New location from this one. | Build a demo location on the MacBook with a Saturday night service and no OBS. | 1.5 days |
Later, if wanted: a Training copy of the page where nothing is real; an "OBS is streaming" live signal (if the experiment in step 1 finds the variable).
## 12. What you still do by hand
- **A new Ingest Mac:** install Companion, `xcode-select --install`, Remote Login on, copy the folder over, run `install.sh --site <name>` once.
- **Companion, per campus:** create the custom variables `sp_state`, `sp_hb`, `sp_cmd`, `flash_tick` and the blink trigger (the page gives you the file to import, or a button if step 1 proves it safe); add pages up to the Status and Restart numbers; install the ATEM module; import the quiet Tascam module where there is a DA-6400; name the Stream Decks. Why: Companion only allows these in its own UI; a full import would wipe the variables.
- **Companion, per location:** create the custom variables `sp_state`, `sp_hb`, `sp_cmd`, `flash_tick` and the blink trigger (the page gives you the file to import, or a button if step 1 proves it safe); add pages up to the Status and Restart numbers; install the ATEM module; import the quiet Tascam module where there is a DA-6400; name the Stream Decks. Why: Companion only allows these in its own UI; a full import would wipe the variables.
- **Every other Mac:** Remote Login on; paste the generated authorise script (and the DVS helper where DVS runs) via ARD "Send UNIX Command as root"; make the Audio MIDI Setup sessions the page names; on Studio B run the generated solo-mode install and allow Accessibility on that screen. Why: root rights and permission prompts must come from a person at that Mac.
- **Every Windows PC:** run the generated setup script as administrator, paste its RESULT block into the page; BIOS wake-on-LAN; keep the Lighting PC on Public; Vista autoload; the Bible TV Wi-Fi power settings (or Ethernet). Why: system and firewall changes need your OK; BIOS has no remote path.
- **Ingest Mac:** type the Netgear login into the keychain yourself (the page shows the line); click Allow once on the Spotify prompt; keep Google Drive signed in for the switcher files.
@@ -225,7 +225,7 @@ Later, if wanted: a Training copy of the page where nothing is real; an "OBS is
1. **Who can edit:** the editing code lives in `config.json` in the private repo, so everyone with the repo has it (answered 2026-10-13).
2. **When changes must wait:** only while a HyperDeck is recording (answered 2026-10-13). No clock windows at NA; the Sunday 6:30–13:30 window is for auto-fix only.
3. **The other campuses:** Apollo Beach, Woonsocket and Southcoast, each with a Mac Studio already running Companion (answered 2026-10-13).
3. **The other locations:** Apollo Beach, Woonsocket and Southcoast, each with a Mac Studio already running Companion (answered 2026-10-13).
4. **Unwatched Companion connections** (7 Govee lights, DiGiCo_OSC, m32, AbleSet, tcp-HyperDeck-1..6): stay unwatched (answered 2026-10-13).
## 14. How this plan was audited
+64 -4
View File
@@ -4,7 +4,8 @@
#
# zsh install.sh install / update and (re)start
# zsh install.sh --keep-config update the code but leave the live config.json as it is
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
# zsh install.sh --replace-config replace the live config even when it came from an Apply on the settings page
# zsh install.sh --site <id> first install of a location: its config from sites/<id>/, else sites/_template/
# zsh install.sh --rollback put the previous code back (kept in prev/) and restart
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
#
@@ -18,7 +19,7 @@ SETTINGS_LABEL=org.waterschurch.status-settings
DEST="$HOME/Library/Application Support/status-panel"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
CODE=(status_panel.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json)
CODE=(status_panel.py readiness.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json)
KEEP_CONFIG=0
SITE=""
SITE_NEXT=0
@@ -26,18 +27,19 @@ for arg in "$@"; do
if [[ $SITE_NEXT == 1 ]]; then SITE="$arg"; SITE_NEXT=0; continue; fi
case "$arg" in
--keep-config) KEEP_CONFIG=1 ;;
--replace-config) REPLACE_CONFIG=1 ;;
--site) SITE_NEXT=1 ;;
--remove|--rollback|--now) ;;
*) echo "unknown option $arg" >&2; exit 2 ;;
esac
done
# The config to install: the campus folder when asked for, else this folder's config.json.
# The config to install: the location folder when asked for, else this folder's config.json.
SRC_CONFIG="$SRC/config.json"
if [[ -n "$SITE" ]]; then
if [[ -f "$SRC/sites/$SITE/config.json" ]]; then SRC_CONFIG="$SRC/sites/$SITE/config.json"
elif [[ -f "$SRC/sites/_template/config.json" ]]; then SRC_CONFIG="$SRC/sites/_template/config.json"; echo "no sites/$SITE/ in the folder: starting from the template"
else echo "no sites/ folder here" >&2; exit 2; fi
[[ -f "$DEST/config.json" ]] && KEEP_CONFIG=1 && echo "a config is already installed: left alone (the page's 'New campus from a bundle' replaces it)"
[[ -f "$DEST/config.json" ]] && KEEP_CONFIG=1 && echo "a config is already installed: left alone (the page's 'New location from a bundle' replaces it)"
fi
restart_agent() { # $1 = label, $2 = plist
@@ -151,6 +153,31 @@ then
exit 3
fi
# 0b. Never undo an Apply. Live settings that came from the settings page (their fingerprint is the
# "sha_after" of an entry in history/) and differ from the config this would install were changed on
# the page since this folder was made: replacing them would quietly undo that (2026-10-15: READY hours
# set on the page went back to the folder's defaults). Copy them into the folder first, or say so.
if [[ $KEEP_CONFIG == 0 && ${REPLACE_CONFIG:-0} == 0 && -f "$DEST/config.json" && -d "$DEST/history" ]] \
&& ! cmp -s "${SRC_CONFIG:-$SRC/config.json}" "$DEST/config.json" \
&& /usr/bin/python3 -c '
import glob, hashlib, json, os, sys
d = sys.argv[1]
live = hashlib.sha256(open(os.path.join(d, "config.json"), "rb").read()).hexdigest()
for m in glob.glob(os.path.join(d, "history", "*.meta.json")):
try:
meta = json.load(open(m))
except ValueError:
continue
if meta.get("sha_after") == live:
print("the live settings come from the settings page (%s, %s): not replacing them." % (meta.get("by", "?"), os.path.basename(m)[:15]))
sys.exit(0)
sys.exit(1)' "$DEST"
then
echo "Copy them into this folder first (scp checklist-ingest:'Library/Application Support/status-panel/config.json' config.json)," >&2
echo "or run with --keep-config (code only) or --replace-config (overwrite them)." >&2
exit 4
fi
# 1. The config that will run, checked with the code that will run it.
mkdir -p "$DEST"
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
@@ -185,6 +212,39 @@ fi
if [[ -d "$SRC/companion-objects" ]]; then
rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/"
fi
# The kit another location installs from (the settings page serves it as /location/program.tgz).
rm -rf "$DEST/kit" && mkdir -p "$DEST/kit/sites"
cp "$SRC/install.sh" "$DEST/kit/"
[[ -f "$SRC/README.md" ]] && cp "$SRC/README.md" "$DEST/kit/"
[[ -d "$SRC/sites/_template" ]] && cp -R "$SRC/sites/_template" "$DEST/kit/sites/"
# The nightly off-site backup of every location's settings (does anything only on the hub, NA).
if [[ -d "$SRC/backup" ]]; then
rm -rf "$DEST/backup" && cp -R "$SRC/backup" "$DEST/" && chmod +x "$DEST/backup/"*.sh
BACKUP_LABEL=org.waterschurch.status-backup
BACKUP_PLIST="$HOME/Library/LaunchAgents/$BACKUP_LABEL.plist"
cat > "$BACKUP_PLIST" <<EOF2
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$BACKUP_LABEL</string>
<key>ProgramArguments</key><array><string>/bin/bash</string><string>$DEST/backup/backup-campuses.sh</string></array>
<key>EnvironmentVariables</key><dict><key>PATH</key><string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>4</integer><key>Minute</key><integer>30</integer></dict>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel-backup.err.log</string>
<key>StandardOutPath</key><string>$HOME/Library/Logs/status-panel-backup.out.log</string>
</dict>
</plist>
EOF2
launchctl bootout "$DOMAIN/$BACKUP_LABEL" 2>/dev/null || true
launchctl bootstrap "$DOMAIN" "$BACKUP_PLIST" 2>/dev/null || true
fi
# The panel's own login key for the other computers (made once, never replaced).
if [[ ! -f "$HOME/.ssh/id_status_panel" ]]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
/usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName 2>/dev/null || hostname -s)" -f "$HOME/.ssh/id_status_panel"
echo "made the panel's login key ~/.ssh/id_status_panel"
fi
# 4. The LaunchAgent, with the exit timeout this config needs (a running fix finishes before a restart).
T=$(date +%s)
+102 -6
View File
@@ -15,8 +15,10 @@ Buttons write commands into sp_cmd, which status_panel.py reads:
press ack <tile> (the master acks everything)
hold 2 seconds restart <tile> (fixes what that tile is failing on)
Restart page force <key> (hold 2 s: restart that thing even when it looks fine)
READY page done <key> (tap a key only a person can check: done for the week)
python3 make_page.py -> status-page.companionconfig and restart-page.companionconfig
python3 make_page.py -> status-page.companionconfig, restart-page.companionconfig
and ready-page.companionconfig
python3 make_page.py --out DIR -> the same files in DIR (the data dir on the Ingest Mac)
"""
import gzip
@@ -27,7 +29,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from status_panel import RESTARTABLE, load_config # noqa: E402
from status_panel import DAYS, RESTARTABLE, load_config, ready_keys # noqa: E402
# Defaults; "companion", "page_grid", "page_stale_seconds" and "page_hold_ms" in config.json override them.
EXPORT_VERSION = 12 # Companion 5.0.7's export format
@@ -38,7 +40,7 @@ PURPLE = 0x6A1B9A
HOLD_MS = 2000
# Every key this tool makes carries one of these in its notes; push_page.py overwrites a page only
# when every key on it (besides Companion's own page keys) does.
MARKERS = ("STATUS tile", "RESTART key", "STATUS jump key", "RESTART back key", "STATUS nav key")
MARKERS = ("STATUS tile", "RESTART key", "STATUS jump key", "RESTART back key", "STATUS nav key", "READY back key")
def settings(cfg):
@@ -169,7 +171,24 @@ def button(tid, tile, cfg):
hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0)
kind = tile.get("kind")
label = tile["label"]
if kind == "master":
rp = cfg.get("ready_page") or {}
on_ready_page = any(k["id"] == tid for k in rp.get("keys", []))
go = None
if kind == "ready" and not on_ready_page:
stale_text = "'%s\\n?'" % label
press, hold, go = None, "restart " + tid, rp.get("page")
note = ("Tap: open the READY page (page %s). Hold %s s: fix everything on it that can be fixed "
"(re-sync the lineup, the cross background, look again)." % (rp.get("page"), secs))
elif kind == "ready":
stale_text = "'%s\\n?'" % label
press, hold = "ack " + tid, "restart " + tid
note = "Press: stop the blinking. Hold %s s: fix everything on this page that can be fixed." % secs
elif any(c["type"] == "ready_manual" for c in tile.get("checks", [])):
stale_text = "'%s\\n?'" % label
press, hold = "done " + tid, None
note = "Tap: done for this week (tap again to take it back). Resets every %s." % (
(cfg.get("ready") or {}).get("reset_day", "mon").capitalize())
elif kind == "master":
# Template string: '+' would add numbers, not join text (see above).
stale_text = "`${'WATCHDOG\\nDOWN\\n'}${$(internal:instance_errors)} ERR`"
press, hold = "ackall", None
@@ -177,7 +196,27 @@ def button(tid, tile, cfg):
elif kind == "service":
stale_text = "'%s\\n?'" % label
press, hold = "service toggle", None
note = "Press: turn service mode on/off until the next scheduled window starts or ends."
sw = cfg.get("service_windows") or []
always = len(sw) == 1 and set(sw[0].get("days", [])) == set(DAYS) and sw[0].get("start") == "00:00" and sw[0].get("end") in ("23:59", "00:00")
if any(t.get("kind") == "autofix" for t in cfg.get("tiles", [])):
note = ("Press: silence the alarms; press again to turn them back on. They come back by themselves %s. "
"Auto-fix is the AUTO-FIX key." % ("at midnight" if always else "when the alarm schedule next starts or ends"))
else:
note = ("Press: silence the alarms AND stop every auto-fix; press again to turn both back on. They come back by "
"themselves %s. Holds still fix. The stream-audio failover still switches by itself on Sundays."
% ("at midnight" if always else "when the alarm schedule next starts or ends"))
elif kind == "autofix":
stale_text = "'%s\\n?'" % label
press, hold = "autofix toggle", None
note = ("Press: pause auto-fix (alarms stay on) through the hours it is in now, or through the next ones when pressed "
"outside them; press again to resume. It resumes by itself when those hours end. Holds always fix.")
elif kind == "bypass":
stale_text = "'%s\\n?'" % label
press, hold = "ack " + tid, "restart " + tid
b = tile.get("bypass") or {}
note = ("Hold %s s: hand %s to an operator - its MIDI links (%s) are taken out%s and the panel stops putting them "
"back. Hold again: everything is put back." % (secs, label, ", ".join(b.get("peers") or []),
" and Companion's %s switched off" % ", ".join(b["conns"]) if b.get("conns") else ""))
else:
stale_text = "'%s\\n?'" % label
press = "ack " + tid
@@ -193,7 +232,7 @@ def button(tid, tile, cfg):
else:
note = "Press: acknowledge." + (" Hold %s s: fix what this button is failing on (nothing if it is green)."
% secs if hold else "")
action_sets = {"down": [], "up": [command_action(tid, "press", names["command"], press)]}
action_sets = {"down": [], "up": [set_page_action(tid, "go", go) if go else command_action(tid, "press", names["command"], press)]}
run_while_held = []
if hold:
# Stamped with Companion's clock, so a hold made while the watchdog was down is dropped later.
@@ -226,6 +265,33 @@ def nav_templates(cfg):
raise FileNotFoundError("no nav key templates for Companion %s" % version)
REMOTE_VAR = "sp_remote" # North Attleboro's summary of the other locations (settings/campuses.py writes it)
BAND_BLUE, BAND_DARK = 0x1E6FFF, 0x0B2A66
def band_on():
r = "$(custom:%s)" % REMOTE_VAR
return "$(internal:time_unix) - jsonpath(%s, '$.t') < 30 && jsonpath(%s, '$.n') > 0" % (r, r)
def locations_band(flash_var):
"""A blue band across the bottom of NA's page-number key naming the other locations with a problem
("SC", "SC WS"): it flashes until that location acknowledges, then stays. Red stays NA's own
(2026-10-21, the user: "i dont want the NA page buttons to flash red, that should he reserved for on site
problems")."""
r = "$(custom:%s)" % REMOTE_VAR
on = band_on()
color = "jsonpath(%s, '$.f') == 1 && $(custom:%s) == 1 ? %d : %d" % (r, flash_var, BAND_DARK, BAND_BLUE)
box = {"id": stable_id("nav", "band_box"), "name": "Locations", "usage": "auto", "type": "box", "enabled": prop(on, True),
"opacity": prop(100), "x": prop(0), "y": prop(60), "width": prop(100), "height": prop(40), "rotation": prop(0),
"color": prop(color, True), "borderWidth": prop(0), "borderColor": prop(0), "borderPosition": prop("inside")}
text = {"id": stable_id("nav", "band_text"), "name": "Locations text", "usage": "auto", "type": "text", "enabled": prop(on, True),
"opacity": prop(100), "x": prop(0), "y": prop(60), "width": prop(100), "height": prop(40), "rotation": prop(0),
"text": prop("jsonpath(%s, '$.x')" % r, True), "color": prop(0xFFFFFF), "halign": prop("center"), "valign": prop("center"),
"fontsize": prop("auto"), "fontsizeAllowShrink": prop(True), "font": prop("companion-sans"), "outlineColor": prop(4278190080)}
return [box, text]
def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
"""Companion's own UP / page number / DOWN key, coloured like the ALL button.
@@ -252,6 +318,12 @@ def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
layer["borderColor"] = prop(fg, True)
paint(layer.get("children", []))
paint(key["style"]["layers"])
if kind == "pagenum" and (cfg.get("nav_locations_band") or False):
# While the band shows, the page's own name and number shrink into the top 60%, so nothing is hidden.
for layer in key["style"]["layers"]:
if layer["type"] == "group":
layer["height"] = prop("(%s) ? 60 : 100" % band_on(), True)
key["style"]["layers"] += locations_band(opts["flash"])
if kind == "pagenum" and status_page:
step = key["steps"]["0"]
tap = step["action_sets"]["down"]
@@ -261,6 +333,14 @@ def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
"options": {"surfaceId": prop("self"), "page": prop(status_page)}, "children": {}}]
step["action_sets"] = {"down": [], "up": tap, str(hold_ms): hold}
step["options"]["runWhileHeld"] = [hold_ms]
# Longer holds go further (NA, 2026-10-21: 2 s the status page, still held at 3 s the LOCATIONS page).
for i, more in enumerate(sorted((cfg or {}).get("nav_hold_more") or [], key=lambda m: m["ms"])):
if more["ms"] > hold_ms:
step["action_sets"][str(more["ms"])] = [{"type": "action", "id": stable_id("nav", "hold%d" % (i + 2)),
"definitionId": "set_page", "connectionId": "internal",
"options": {"surfaceId": prop("self"), "page": prop(more["page"])},
"children": {}}]
step["options"]["runWhileHeld"].append(more["ms"])
key["options"]["notes"] = ("%s (%s): colour follows the ALL button on the status page. "
"Undo on every page with: python3 nav_keys.py --revert" % (NAV_MARKER, kind))
return key
@@ -322,6 +402,18 @@ def build_restart(cfg):
return page_export(rp["page_name"], rp["page"], controls, cfg)
def build_ready(cfg):
"""The READY page: the weekly checklist, one key per line, drawn by the watchdog like the Status page."""
rp = cfg["ready_page"]
controls = nav_controls(cfg)
place(controls, rp["back_pos"], static_button("ready_back", "STATUS\\nPAGE", 0x37474F, 0xFFFFFF, cfg["page"],
"Tap: back to the status page (page %d)." % cfg["page"],
"READY back key"))
for key in ready_keys(cfg):
place(controls, key["pos"], button(key["id"], key, cfg))
return page_export(rp["page_name"], rp["page"], controls, cfg)
def write(path, export):
with gzip.open(path, "wb") as f: # Companion's own exports are gzipped JSON
f.write(json.dumps(export, indent=1).encode())
@@ -343,6 +435,10 @@ def main(argv=None):
out = os.path.join(folder, "restart-page.companionconfig")
write(out, build_restart(cfg))
print("wrote %s (%d restart keys)" % (out, len(cfg["restart_page"]["keys"])))
if cfg.get("ready_page") and not args.status_file:
out = os.path.join(folder, "ready-page.companionconfig")
write(out, build_ready(cfg))
print("wrote %s (%d READY keys)" % (out, len(ready_keys(cfg))))
if __name__ == "__main__":
+6 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Import the status page (19) or the Restart page (20; both numbers come from config.json) into Companion without clicking through the UI.
"""Import the status page (19), the Restart page (20) or the READY page (24; the numbers come from config.json) into Companion without clicking through the UI.
Does exactly what Import/Export -> Import -> "import into page N" does, over the
same internal API the Companion web UI uses (tRPC over a WebSocket at /trpc):
@@ -12,6 +12,7 @@ A key someone added is named by its position, so it can be moved first.
python3 push_page.py # the status page, from config.json
python3 push_page.py --which restart # the Restart page
python3 push_page.py --which ready # the READY page (the weekly checklist)
python3 push_page.py --host 10.6.3.222 --page 19 --file status-page.companionconfig
Stdlib only (a minimal WebSocket client is included). Written against Companion 5.0.7.
@@ -210,7 +211,7 @@ def push(host, port, page, file_path, expected_name):
def main():
cfg = json.load(open(os.path.join(HERE, "config.json")))
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--which", choices=("status", "restart"), default="status")
ap.add_argument("--which", choices=("status", "restart", "ready"), default="status")
ap.add_argument("--host", default=cfg.get("site", {}).get("ingest_hostname") or cfg["companion"].get("host", "127.0.0.1"))
ap.add_argument("--port", type=int, default=cfg["companion"].get("port", 8000))
ap.add_argument("--page", type=int)
@@ -220,6 +221,9 @@ def main():
if args.which == "restart":
page, expected = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]
default_file = "restart-page.companionconfig"
elif args.which == "ready":
page, expected = cfg["ready_page"]["page"], cfg["ready_page"]["page_name"]
default_file = "ready-page.companionconfig"
else:
page, expected, default_file = cfg["page"], cfg["page_name"], "status-page.companionconfig"
args.page = args.page or page
+1245
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
# start-vista-newest.ps1 - the status panel's "Start Vista" task on a Lighting PC: Vista opens the
# newest copy of the location's show, through Vista's own autoload preference (as North Attleboro's
# Vista opens Prop Takeover). Southcoast keeps several copies of "Tiverton Lighting.v3s"; the user
# chose "whichever is newest" (2026-10-21). Autosave backups (_Backups) are never picked.
#
# Installed at C:\ProgramData\StatusPanel\start-vista.ps1; the task runs
# powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File C:\ProgramData\StatusPanel\start-vista.ps1 -Show "Tiverton Lighting.v3s"
# A running Vista is left alone: its show is never changed under it.
param(
[string]$Show = "Tiverton Lighting.v3s",
[string]$Vista = "C:\Program Files (x86)\Chroma-Q\Vista3\Vista.exe",
[switch]$WhatIf
)
if ((Get-Process -Name Vista -ErrorAction SilentlyContinue) -and -not $WhatIf) { "Vista is running: left alone"; exit 0 }
$roots = @("$env:USERPROFILE\Desktop", "$env:USERPROFILE\Documents") | Where-Object { Test-Path $_ }
$newest = Get-ChildItem -Path $roots -Recurse -Filter $Show -File -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -notmatch '\\_Backups\\' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1
$prefs = "$env:USERPROFILE\Documents\Vista 3\.preferences"
if ($newest -and (Test-Path $prefs)) {
$text = [IO.File]::ReadAllText($prefs)
$line = '<autoloadpath>' + [Security.SecurityElement]::Escape($newest.FullName) + '</autoloadpath>'
$new = [regex]::Replace($text, '<autoloadpath>.*?</autoloadpath>', [Text.RegularExpressions.MatchEvaluator]{ param($m) $line })
"newest: $($newest.FullName) ($($newest.LastWriteTime))"
if ($new -ne $text -and -not $WhatIf) {
Copy-Item $prefs "$prefs.before-status-panel" -Force
[IO.File]::WriteAllText($prefs, $new)
"autoload set"
}
} else {
"no $Show found, or no Vista preferences: Vista starts with its own choice"
}
if (-not $WhatIf) { Start-Process -FilePath $Vista -WorkingDirectory (Split-Path $Vista) }
+57
View File
@@ -0,0 +1,57 @@
# windows-baseline.ps1 - the settings every show PC gets so Windows never breaks a service, as on the NA
# LiveStream PC (2026-10-19): Windows Update installs no drivers and never restarts while the user is signed
# in (active hours 5 AM - 11 PM as well); Game DVR off; Microsoft's bundled apps removed; no disk or USB power
# saving, no sleep. Nothing here restarts the PC or touches its network card. Run as an administrator; safe to
# run again. Prints what it set.
$ErrorActionPreference = 'SilentlyContinue'; $ProgressPreference = 'SilentlyContinue'
function K($p) { if (-not (Test-Path $p)) { New-Item $p -Force | Out-Null } }
# Windows Update: no drivers (the 10/15 NVIDIA driver broke a stream), no restarts while signed in.
$wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'; $au = "$wu\AU"; K $wu; K $au
Set-ItemProperty $wu ExcludeWUDriversInQualityUpdate 1 -Type DWord
Set-ItemProperty $wu SetActiveHours 1 -Type DWord; Set-ItemProperty $wu ActiveHoursStart 5 -Type DWord; Set-ItemProperty $wu ActiveHoursEnd 23 -Type DWord
Set-ItemProperty $au NoAutoRebootWithLoggedOnUsers 1 -Type DWord
$ds = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DriverSearching'; K $ds
Set-ItemProperty $ds SearchOrderConfig 0 -Type DWord; Set-ItemProperty $ds DontSearchWindowsUpdate 1 -Type DWord
$dm = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Device Metadata'; K $dm; Set-ItemProperty $dm PreventDeviceMetadataFromNetwork 1 -Type DWord
Set-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\DriverSearching' SearchOrderConfig 0 -Type DWord
# Game DVR / Xbox capture off (machine policy and this user).
$gd = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\GameDVR'; K $gd; Set-ItemProperty $gd AllowGameDVR 0 -Type DWord
K 'HKCU:\System\GameConfigStore'; Set-ItemProperty 'HKCU:\System\GameConfigStore' GameDVR_Enabled 0 -Type DWord
K 'HKCU:\Software\Microsoft\Windows\CurrentVersion\GameDVR'; Set-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\GameDVR' AppCaptureEnabled 0 -Type DWord
# Microsoft's bundled apps (for every user, and not reinstalled for new ones).
$bloat = 'Microsoft.XboxGameOverlay','Microsoft.XboxGamingOverlay','Microsoft.XboxSpeechToTextOverlay','Microsoft.Xbox.TCUI',
'Microsoft.XboxIdentityProvider','Microsoft.GamingApp','Microsoft.Edge.GameAssist','Microsoft.BingNews','Microsoft.BingWeather',
'Microsoft.BingSearch','Microsoft.MicrosoftSolitaireCollection','Clipchamp.Clipchamp','MicrosoftTeams','MSTeams','Microsoft.Copilot',
'Microsoft.OutlookForWindows','microsoft.windowscommunicationsapps','Microsoft.MicrosoftOfficeHub','Microsoft.People',
'Microsoft.YourPhone','MicrosoftWindows.CrossDevice','Microsoft.WindowsMaps','Microsoft.WindowsFeedbackHub','Microsoft.GetHelp',
'Microsoft.Getstarted','Microsoft.Todos','Microsoft.Whiteboard','Microsoft.MicrosoftJournal','Microsoft.Windows.DevHome',
'MicrosoftCorporationII.MicrosoftFamily','Microsoft.MixedReality.Portal','Microsoft.PowerAutomateDesktop','Microsoft.ZuneMusic',
'Microsoft.ZuneVideo','Microsoft.549981C3F5F10'
$prov = Get-AppxProvisionedPackage -Online
foreach ($x in $bloat) {
Get-AppxPackage -AllUsers -Name $x | Remove-AppxPackage -AllUsers
$prov | Where-Object DisplayName -eq $x | Remove-AppxProvisionedPackage -Online -AllUsers | Out-Null
}
# Power: never sleep or hibernate, disks never off, USB never suspended (DMX interfaces, capture cards).
foreach ($m in 'ac', 'dc') {
powercfg /change "standby-timeout-$m" 0; powercfg /change "hibernate-timeout-$m" 0; powercfg /change "disk-timeout-$m" 0
}
powercfg /setacvalueindex SCHEME_CURRENT 2a737441-1930-4402-8d77-b2bebba308a3 48e6b7a6-50f5-4782-a5d4-53bb8f07e226 0
powercfg /setdcvalueindex SCHEME_CURRENT 2a737441-1930-4402-8d77-b2bebba308a3 48e6b7a6-50f5-4782-a5d4-53bb8f07e226 0
powercfg /setactive SCHEME_CURRENT
# What it is now.
$q = { param($sub, $set) ((powercfg /query SCHEME_CURRENT $sub $set | Select-String 'Current AC' | Select-Object -First 1).Line -replace '.*: ','') }
$w = Get-ItemProperty $wu
'RESULT ' + $env:COMPUTERNAME
' updates: drivers excluded=' + $w.ExcludeWUDriversInQualityUpdate + ', active hours ' + $w.ActiveHoursStart + '-' + $w.ActiveHoursEnd +
', no restart while signed in=' + (Get-ItemProperty $au).NoAutoRebootWithLoggedOnUsers
' game DVR policy=' + (Get-ItemProperty $gd).AllowGameDVR + ', user=' + (Get-ItemProperty 'HKCU:\System\GameConfigStore').GameDVR_Enabled
' power: sleep=' + (& $q SUB_SLEEP STANDBYIDLE) + ' hibernate=' + (& $q SUB_SLEEP HIBERNATEIDLE) + ' disk=' + (& $q SUB_DISK DISKIDLE) +
' usb suspend=' + (& $q 2a737441-1930-4402-8d77-b2bebba308a3 48e6b7a6-50f5-4782-a5d4-53bb8f07e226)
' bundled apps left: ' + ((($bloat | Where-Object { Get-AppxPackage -AllUsers -Name $_ }) -join ', ') -replace '^$', 'none')
' ssh: ' + (Get-Service sshd).Status + ' / ' + (Get-Service sshd).StartType
+6
View File
@@ -60,6 +60,12 @@ $userKeys = Join-Path $userSsh 'authorized_keys'
if (-not (Test-Path $userKeys) -or -not (Select-String -Path $userKeys -SimpleMatch $KeyMark -Quiet)) {
Add-Content -Path $userKeys -Value $Key -Encoding ascii
}
# The admin laptop's key too (it is let through the firewall above as well).
foreach ($k in @({{ADMIN_KEYS}})) {
foreach ($f in @($adminKeys, $userKeys)) {
if (-not (Select-String -Path $f -SimpleMatch $k -Quiet)) { Add-Content -Path $f -Value $k -Encoding ascii }
}
}
# 3. Start tasks for the apps found on this PC (they run on this user's desktop)
function Find-First($candidates) {
Binary file not shown.
+288 -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
@@ -29,9 +30,13 @@ def field(path, type, default=None, help="", screen="", advanced=False, secret=F
# -- site --
field("site", "dict", {}, "This campus: its name, this Mac, and when changes must wait.", "Site")
field("site", "dict", {}, "This location: its name, this Mac, and when changes must wait.", "Site")
field("site.id", "id", "na", "Short name used for folders and files.", "Site", advanced=True, regex=r"[a-z0-9_-]+")
field("site.name", "str", "", "The campus, as people say it.", "Site", example="North Attleboro", apply="none")
field("site.admin_ips", "list", [], "The admin laptop's fixed addresses: let through the Windows PCs' SSH firewall with this Mac.",
"Site", advanced=True, apply="none")
field("site.admin_keys", "list", [], "The admin laptop's public SSH keys: every PC and Mac set up from here takes them too.",
"Site", advanced=True, apply="none")
field("site.name", "str", "", "The location, as people say it.", "Site", example="North Attleboro", apply="none")
field("site.ingest_name", "str", "NA Ingest", "What this Mac is called in logs, notes and alerts.", "Site", apply="pages")
field("site.ingest_hostname", "str", "", "This Mac's network name, used in the page address and by the page tools.",
"Site", example="na-ingest.local", apply="none")
@@ -70,6 +75,8 @@ field("nav_keys", "list", ["pagenum"], "Which of Companion's page keys (page up,
apply="pages")
field("nav_hold_ms", "int", 1000, "Holding the page-number key this long jumps to the Status page.", "Companion",
advanced=True, lo=200, hi=5000, apply="pages")
field("nav_hold_more", "list", [], "Longer holds on the page-number key: [{\"ms\": 3000, \"page\": 98}] - still held at 3 s, "
"it goes on to page 98 (NA: 2 s the status page, 3 s the LOCATIONS page).", "Companion", advanced=True, apply="pages")
field("nav_tap_page", "int", 1, "Tapping the page-number key goes to this page.", "Companion", advanced=True, lo=1,
apply="pages")
field("variables", "dict", {}, "The custom variables the panel uses inside Companion.", "Companion", required=True)
@@ -90,7 +97,7 @@ field("page_hold_ms", "int", 2000, "How long a key is held to run its fix.", "Sc
# -- schedules and timing --
field("service_windows", "list", [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}],
"When alarms are armed: always (the ALARMS key silences them until midnight).", "Schedules", advanced=True)
"When alarms are armed: always (the ALARMS key silences them and stops auto-fix until midnight).", "Schedules", advanced=True)
field("service_windows[].days", "days", None, "", "Schedules", required=True)
field("service_windows[].start", "hhmm", None, "", "Schedules", required=True)
field("service_windows[].end", "hhmm", None, "", "Schedules", required=True)
@@ -111,6 +118,11 @@ field("auto_fix.windows", "list", [], "When auto-fix may act.", "Schedules")
field("auto_fix.windows[].days", "days", None, "", "Schedules", required=True)
field("auto_fix.windows[].start", "hhmm", None, "", "Schedules", required=True)
field("auto_fix.windows[].end", "hhmm", None, "", "Schedules", required=True)
field("discover", "dict", {}, "Auto-setup: this Mac looks for gear every ten minutes.", "Site", advanced=True)
field("discover.auto", "bool", False, "Add new gear by itself: a new Companion connection, Dante device, MIDI link, computer "
"or program gets its key (never during a service or a recording; nothing is ever removed).", "Site")
field("auto_fix.disabled", "bool", False, "Auto-fix switched off as a whole: nothing is fixed by itself (holds "
"still work).", "Schedules")
field("auto_fix.after_seconds", "int", 20, "A fault must last this long before auto-fix acts.", "Schedules",
advanced=True, lo=5, hi=600)
field("auto_fix.max_tries", "int", 2, "Tries per fault, then it is yours to hold.", "Schedules", advanced=True,
@@ -126,6 +138,19 @@ field("auto_fix.log_dir", "path", "~/Desktop/Auto-Fix Logs", "One file per day o
"Schedules", advanced=True)
field("auto_fix.keep_months", "int", 1, "Daily log files are deleted after this many months.", "Schedules",
advanced=True, lo=1, hi=12)
field("failover", "dict", {}, "Automatic fallback: when a fix marked failover may act (it acts even while ALARMS is "
"muted).", "Schedules")
field("failover.disabled", "bool", False, "The auto switch switched off as a whole: nothing is pressed by itself.",
"Schedules")
field("failover.windows", "list", [{"days": ["sun"], "start": "06:30", "end": "13:30"}], "When a failover may switch "
"by itself.", "Schedules")
field("failover.windows[].days", "days", None, "", "Schedules", required=True)
field("failover.windows[].start", "hhmm", None, "", "Schedules", required=True)
field("failover.windows[].end", "hhmm", None, "", "Schedules", required=True)
field("failover.after_seconds", "int", 2, "The fault must have lasted this long before the failover switches.",
"Schedules", lo=0, hi=60)
field("failover.note_seconds", "int", 900, "After the fault clears, the key keeps saying ON <the fallback> this long "
"(until the fix that switches back runs).", "Schedules", advanced=True, lo=60, hi=86400)
# -- network and Dante --
field("network", "dict", {}, "The NET key.", "Network & Dante")
@@ -171,6 +196,8 @@ field("hosts.*.ssh", "str", None, "How this Mac logs in: user@address.", "Comput
field("hosts.*.mac", "mac", None, "The PC's hardware address, to wake it when it is off.", "Computers")
field("hosts.*.wake_broadcast", "ip", None, "The broadcast address the wake-up goes to.", "Computers", advanced=True)
field("hosts.*.name", "str", None, "The computer, as people say it.", "Computers", apply="none")
field("hosts.*.disabled", "bool", False, "Switched off: kept, but nothing logs in to it - its checks and fixes are "
"skipped (a computer that is away or being rebuilt).", "Computers")
field("hosts.*.os", "enum", "mac", "Mac or Windows (a PC's checks are written for cmd.exe).", "Computers",
options=["mac", "windows", "other"])
field("hosts.*.role", "str", None, "What it is for.", "Computers", apply="none")
@@ -184,6 +211,8 @@ field("hosts.*.manual_done", "dict", {}, "Set-up steps ticked by hand (who, when
field("gear", "dict", {}, "Devices that are not computers: where each one is.", "Computers", apply="none")
field("gear.*.location", "str", None, "Where it is in the building.", "Computers", apply="none")
field("gear.*.notes", "str", None, "Notes for operators.", "Computers", apply="none")
field("gear.*.kind", "str", None, "What it is (recorder, switcher, wireless receiver...).", "Computers", apply="none")
field("gear.*.address", "str", None, "Its network address, if it has one.", "Computers", apply="none")
# -- fixes --
field("fixes", "dict", {}, "Everything a hold can run.", "Fixes")
@@ -196,7 +225,9 @@ field("fixes.*.label", "str", None, "Plain words: what it does.", "Fixes", requi
field("fixes.*.reconnect", "bool", False, "Reconnect the Companion connection afterwards.", "Fixes")
field("fixes.*.conn", "str", None, "The Companion connection it reconnects.", "Fixes")
field("fixes.*.restart_ok", "bool", False, "May close a running program (and still run by itself).", "Fixes")
field("fixes.*.on", "host", None, "The computer ProPresenter runs on, when not this Mac (follow its network port).", "Fixes")
field("fixes.*.never_auto", "bool", False, "Never runs by itself.", "Fixes")
field("fixes.*.anytime", "bool", False, "Ticked for auto-fix, runs by itself at any hour, not only in the auto-fix hours, unless ALARMS is muted (a READY fix that only writes to Planning Center).", "Fixes")
field("fixes.*.part", "enum", None, "Which part of the switcher.", "Fixes", options=["audio", "camera"])
field("fixes.*.verify_seconds", "int", 20, "Wait this long before judging a reconnect.", "Fixes", advanced=True,
lo=5, hi=120)
@@ -214,6 +245,16 @@ field("fixes.*.mac", "mac", None, "The device expected on that port.", "Fixes")
field("fixes.*.playlist", "str", None, "A playlist other than the site's (rare).", "Fixes", advanced=True)
field("fixes.*.template", "str", None, "The builder this fix was made with.", "Fixes", advanced=True, apply="none")
field("fixes.*.params", "dict", None, "The builder's fields.", "Fixes", advanced=True, apply="none")
field("fixes.*.disabled", "bool", False, "Switched off: kept, but never runs (a hold says TURNED OFF; auto-fix "
"and the auto switch skip it).", "Fixes")
field("fixes.*.action", "enum", None, "What a READY page fix does.", "Fixes", options=list(sp.READY_ACTIONS))
field("fixes.*.page", "int", None, "The Companion page the button is on.", "Fixes", lo=1)
field("fixes.*.row", "int", None, "The button's row on that page (the top row is 0).", "Fixes", lo=0)
field("fixes.*.col", "int", None, "The button's column (the left column is 0).", "Fixes", lo=0)
field("fixes.*.failover", "bool", False, "An automatic fallback: when a check that names this fix fails, the watchdog "
"runs it by itself within seconds (inside the failover hours, even while ALARMS is muted, once per fault). It "
"never switches back by itself.", "Fixes")
field("fixes.*.undo", "str", None, "The fix that switches back by hand (its key takes the ON ... note off).", "Fixes")
# -- tiles (keys) --
field("tiles", "list", [], "The keys on the Status page.", "Keys", required=True)
@@ -222,10 +263,22 @@ field("tiles[].id", "id", None, "Used inside Companion.", "Keys", advanced=True,
field("tiles[].label", "str", None, "The first line on the key.", "Keys", required=True, apply="pages")
field("tiles[].pos", "list", None, "[row, column]; absent = no key of its own (shown inside a group).", "Keys",
apply="pages")
field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARMS), network (NET).", "Keys",
advanced=True, options=["master", "service", "network"], apply="pages")
field("nav_locations_band", "bool", False, "North Attleboro: a blue band on the page-number keys names the other locations "
"with a problem (flashing until acknowledged there). Red stays for this location's own problems.", "Keys", advanced=True,
apply="pages")
field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARMS), network (NET), ready (READY: "
"opens the READY page and sums up its checklist), autofix (AUTO-FIX: its own switch, so ALARMS only "
"silences), bypass (hold: hand a device to an operator - its MIDI links out, its Companion connections off).",
"Keys", advanced=True, options=["master", "service", "network", "ready", "autofix", "bypass"])
field("tiles[].bypass", "dict", None, "A bypass key: peers (MIDI peer or session names to take out, e.g. Lighting), "
"hosts (the Macs whose sessions to look in; \"\" is this Mac) and conns (Companion connections to switch off).",
"Keys", advanced=True)
field("tiles[].members", "list", None, "A group: the keys it sums up.", "Keys")
field("tiles[].checks", "list", [], "What the key watches.", "Keys")
field("tiles[].disabled", "bool", False, "Switched off: the key stays on the page (grey, OFF) but checks nothing "
"and fixes nothing, like a connection switched off in Companion.", "Keys")
field("tiles[].checks[].devices_off", "list", [], "Dante devices switched off: kept in the list, not watched.", "Keys")
field("tiles[].checks[].disabled", "bool", False, "Switched off: this check is kept but not looked at.", "Keys")
field("tiles[].hold_always", "str", None, "A fix a hold runs even when the key is green.", "Keys", apply="pages")
field("tiles[].checks[].type", "enum", None, "What kind of check.", "Keys", required=True)
field("tiles[].checks[].conn", "str", None, "The Companion connection.", "Keys")
@@ -256,12 +309,14 @@ field("tiles[].checks[].failures", "int", 2, "Pings missed before NO PING.", "Ke
field("tiles[].checks[].label", "str", None, "The launchd helper.", "Keys")
field("tiles[].checks[].match", "str", None, "The program (pgrep -f pattern).", "Keys")
field("tiles[].checks[].url", "url", None, "The web address to fetch.", "Keys")
field("tiles[].checks[].interval_seconds", "int", 30, "Fetch it this often.", "Keys", advanced=True, lo=5, hi=3600)
field("tiles[].checks[].interval_seconds", "int", 30, "Look this often, in seconds: a web address every 30, a command "
"on another computer every 10, a ping every 3 (1 for a fast fallback check).", "Keys", advanced=True, lo=1, hi=3600)
field("tiles[].checks[].path", "path", None, "The disk to watch.", "Keys")
field("tiles[].checks[].warn_gb", "int", 20, "Amber below this many GB free.", "Keys", advanced=True)
field("tiles[].checks[].err_gb", "int", 5, "Red below this many GB free.", "Keys", advanced=True)
field("tiles[].checks[].session", "str", None, "The network MIDI session.", "Keys")
field("tiles[].checks[].peer", "str", None, "The peer that must be in it.", "Keys")
field("tiles[].checks[].nets", "list", None, "The wired network(s) every MIDI peer must come over, e.g. 10.1.0.0/16.", "Keys")
field("tiles[].checks[].address", "str", None, "Where to reconnect the peer if it was never seen.", "Keys", advanced=True)
field("tiles[].checks[].domain", "str", None, "The macOS preference domain.", "Keys")
field("tiles[].checks[].key", "str", None, "The preference key.", "Keys")
@@ -275,10 +330,11 @@ field("tiles[].checks[].shell", "enum", None, "sh (Macs) or cmd (PCs); blank = f
advanced=True, options=["sh", "cmd"])
field("tiles[].checks[].part", "enum", None, "Which part of the switcher.", "Keys", options=["audio", "camera"])
field("tiles[].checks[].template", "str", None, "The builder this check was made with.", "Keys", advanced=True, apply="none")
field("tiles[].checks[].item", "str", None, "Which line of the weekly checklist (READY page).", "Ready")
field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", advanced=True, apply="none")
# -- restart page --
field("restart_page", "dict", None, "The Restart page; absent = this campus has none.", "Keys", apply="pages")
field("restart_page", "dict", None, "The Restart page; absent = this location has none.", "Keys", apply="pages")
field("restart_page.page", "int", 20, "Its number in Companion.", "Keys", lo=1, apply="pages")
field("restart_page.page_name", "str", "20\\nRestart", "Its name in Companion.", "Keys", advanced=True, apply="pages")
field("restart_page.jump_pos", "list", None, "Where the 'go to Restart page' key sits on the Status page.", "Keys",
@@ -290,9 +346,74 @@ field("restart_page.keys[].id", "id", None, "", "Keys", advanced=True, required=
field("restart_page.keys[].label", "str", None, "", "Keys", required=True, apply="pages")
field("restart_page.keys[].pos", "list", None, "[row, column].", "Keys", required=True, apply="pages")
field("restart_page.keys[].fix", "str", None, "What holding it runs.", "Keys", required=True)
field("restart_page.keys[].disabled", "bool", False, "Switched off: the key stays on the page (OFF) but a hold does "
"nothing.", "Keys", apply="pages")
field("restart_page.keys[].watch", "list", [], "Keep flashing HOLD TO FIX while these fixes' checks or "
"'conn:<label>' connections are down.", "Keys")
# -- the READY page (the weekly checklist) --
field("ready_page", "dict", None, "The READY page: this weekend's checklist, checked against Planning Center; absent = "
"this location has none.", "Ready", apply="pages")
field("ready_page.page", "int", 24, "Its number in Companion.", "Ready", lo=1, apply="pages")
field("ready_page.page_name", "str", "24\\nReady", "Its name in Companion.", "Ready", advanced=True, apply="pages")
field("ready_page.back_pos", "list", None, "Where the 'back to Status' key sits on the READY page.", "Ready",
apply="pages")
field("ready_page.keys", "list", [], "The checklist keys (each like a Status page key, placed on the READY page).",
"Ready", apply="pages")
field("ready", "dict", {}, "Where the READY page looks.", "Ready")
field("ready.disabled", "bool", False, "The READY page switched off as a whole: its keys show OFF and nothing is "
"looked at.", "Ready")
field("ready.interval_seconds", "int", 120, "Look this often (never while a HyperDeck is recording; a hold looks at "
"once).", "Ready", advanced=True, lo=30, hi=3600)
field("ready.due", "list", [], "From when gaps are red instead of amber.", "Ready")
field("ready.due[].days", "days", None, "", "Ready", required=True)
field("ready.due[].start", "hhmm", None, "", "Ready", required=True)
field("ready.due[].end", "hhmm", None, "", "Ready", required=True)
field("ready.reset_day", "enum", "mon", "Taps (BOH mix) count from midnight on this day.", "Ready",
options=list(sp.DAYS))
field("ready.pco_credentials", "path", "~/Library/Application Support/ProdDash/modules.json", "ProdDash's settings "
"file, which holds the Planning Center login.", "Ready", advanced=True)
field("ready.pco_module", "str", "pco-plan", "The ProdDash module whose Planning Center login is used.", "Ready",
advanced=True)
field("ready.pco_service_type", "str", None, "The Planning Center service type; blank = ProdDash's first.", "Ready")
field("ready.skip_songs", "list", [], "PCO songs that have no tracks or slides of their own.", "Ready")
field("ready.ltc_campuses", "list", [], "Other locations that play the same songs: their PCO plans get this location's "
"timecode too, song by song in this location's order (a song out of order is left alone).", "Ready")
field("ready.ltc_campuses[].name", "str", None, "The location.", "Ready", required=True)
field("ready.ltc_campuses[].short", "str", None, "Two letters for the key (AB).", "Ready", required=True)
field("ready.ltc_campuses[].service_type", "str", None, "Its Planning Center service type.", "Ready", required=True)
field("ready.ltc_note", "str", "LTC", "The PCO note category that holds a song's timecode (checked against the "
"tracks, written from them by a hold); blank = not checked.",
"Ready", advanced=True)
field("ready.prop_url", "url", "", "ProPresenter's network API.", "Ready", example="http://10.1.14.225:1025")
field("ready.prop_lists", "list", [], "PCO items whose description lists things the slides must have: "
"{match, kind (count: one slide each / names: every name on a slide), short}.", "Ready", advanced=True)
field("ready.tracks_host", "str", None, "The computer Ableton runs on.", "Ready")
field("ready.projects", "path", "", "The shared Ableton Projects folder, as that computer sees it.", "Ready")
field("ready.site_folder", "str", "", "This location's folder inside it.", "Ready", example="NA")
field("ready.folder_format", "str", "{date} {loc} Project", "A weekend's project folder ({date} = YYMMDD).",
"Ready", advanced=True)
field("ready.ltc_track", "str", "LTC", "The Ableton track that plays the timecode (an LTC file named like "
"LTC_00000000_60mins_30fps: it starts at 00:00:00:00, 30 frames a second).", "Ready", advanced=True)
field("ready.lighting_track", "str", "LIGHTING CUES", "The Ableton track that holds the lighting cues.", "Ready")
field("ready.locations", "list", [], "The locations that get this location's tracks.", "Ready")
field("ready.remote_timeout", "int", 90, "Seconds allowed for reading the Ableton set.", "Ready", advanced=True,
lo=10, hi=600)
field("ready.lineup_url", "url", "", "ProdDash's Band Lineup module.", "Ready")
field("ready.lineup_teams", "list", ["Band", "Communication"], "PCO teams whose people must be on the lineup TV.",
"Ready")
field("ready.lineup_skip_positions", "list", [], "PCO positions the lineup TV does not show.", "Ready")
field("ready.resolume_url", "url", "", "Resolume Arena's web server.", "Ready")
field("ready.resolume_deck", "str", "", "The deck Resolume must be on.", "Ready")
field("ready.prop_host", "str", None, "The ProPresenter computer: its macros and presentation files are read to find "
"the Resolume columns Sunday's slides call up.", "Ready")
field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready")
field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes. {date} is this weekend's folder "
"(Location Assets/{date} -> Location Assets/261025): any bundle in it counts as sent.", "Ready")
field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True)
field("ready.vocal_positions", "list", ["Vocalist", "[Leader]"], "PCO positions that need a vocal mic.", "Ready")
field("ready.mic_positions", "dict", {}, "Other PCO positions and the mic they need (MC -> MC).", "Ready")
# --- Check types ---------------------------------------------------------------
# fields: the keys that type reads; subs: the sub-check names it returns (fix tables use them);
# identity: what it is about; module: the Companion module its connection is expected to use.
@@ -302,7 +423,7 @@ CHECKS = {
"hyperdeck": {"fields": ["conn", "optional", "disk_service_only", "full_minutes", "low_minutes", "rec_sync", "ip"],
"subs": ["conn", "disk", "rec"], "identity": ["conn"], "module": "bmd-hyperdeck",
"question": "Is a recorder OK?", "words": "A HyperDeck is connected, has a disk, and records with the others."},
"dante": {"fields": ["conn", "devices", "count_all", "service_only"], "subs": ["conn", "<device name>"], "identity": ["conn"],
"dante": {"fields": ["conn", "devices", "count_all", "service_only", "devices_off"], "subs": ["conn", "<device name>"], "identity": ["conn"],
"module": "audinate-dantecontroller", "question": "Is Dante complete?", "words": "Every listed Dante device is present."},
"shure": {"fields": ["conn", "channels", "battery_err_bars", "battery_warn_bars"], "subs": ["conn", "ch<N>_batt", "ch<N>_rf"],
"identity": ["conn"], "module": "shure-wireless", "question": "Is a device answering?", "words": "A Shure receiver: batteries and interference."},
@@ -310,7 +431,7 @@ CHECKS = {
"module": "shure-psm1000", "question": "Is a device answering?", "words": "A PSM transmitter: RF mute."},
"tascam": {"fields": ["conn"], "subs": ["conn", "error", "caution"], "identity": ["conn"], "module": "tascam-da-6400",
"question": "Is a recorder OK?", "words": "The Tascam recorder reports no error or caution."},
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "interval_seconds", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
"module": None, "question": "Is a device answering?", "words": "An address answers ping."},
"launchd": {"fields": ["label", "name"], "subs": ["svc"], "identity": ["label"], "module": None,
"question": "Is a program running?", "words": "A helper (LaunchAgent) on this Mac is loaded and running."},
@@ -322,6 +443,8 @@ CHECKS = {
"question": "Is a setting right?", "words": "Free space on a disk."},
"midi_peer": {"fields": ["session", "peer", "host", "address"], "subs": ["peer"], "identity": ["host", "session", "peer"],
"module": None, "question": "Is a MIDI link up?", "words": "A peer is in a network MIDI session (here or on another Mac)."},
"midi_wired": {"fields": ["nets"], "subs": ["wired", "<host|session|peer>"], "identity": [], "module": None,
"question": "Is a MIDI link up?", "words": "Every network MIDI peer comes over the wired network (not Wi-Fi or Dante)."},
"pref": {"fields": ["domain", "key", "want", "msg", "template", "params"], "subs": ["pref"], "identity": ["domain", "key"],
"module": None, "question": "Is a setting right?", "words": "A macOS preference has the value it should."},
"spotify": {"fields": ["playlist", "fix"], "subs": ["spotify"], "identity": [], "module": None,
@@ -330,20 +453,24 @@ CHECKS = {
"question": "Is a MIDI link up?", "words": "A network MIDI session is advertised on Bonjour."},
"midi_rx": {"fields": ["source", "name", "max_silence_seconds"], "subs": ["rx"], "identity": ["source"], "module": None,
"question": "Is a MIDI link up?", "words": "MIDI is arriving from a source."},
"remote": {"fields": ["host", "cmd", "msg", "shell", "template", "params"], "subs": ["remote"], "identity": ["host", "cmd"],
"remote": {"fields": ["host", "cmd", "msg", "shell", "interval_seconds", "template", "params"], "subs": ["remote"], "identity": ["host", "cmd"],
"module": None, "question": "Is a program running?", "words": "A command on another computer succeeds."},
"artnet": {"fields": ["source", "max_silence_seconds", "msg"], "subs": ["artnet"], "identity": ["source"], "module": None,
"question": "Is the lighting console sending?", "words": "Art-Net is arriving from the lighting program."},
"atem": {"fields": ["part", "msg"], "subs": ["audio", "camera"], "identity": ["part"], "module": "bmd-atem",
"question": "Is a setting right?", "words": "The switcher matches the saved file."},
"ready": {"fields": ["item"], "subs": ["ready", "<what is missing>"], "identity": ["item"], "module": None,
"question": "Is the weekend ready?", "words": "A line of the weekly checklist, checked against Planning Center."},
"ready_manual": {"fields": ["item", "msg"], "subs": ["done"], "identity": ["item"], "module": None,
"question": "Is the weekend ready?", "words": "A line of the weekly checklist a person taps done."},
}
COMMON_CHECK_FIELDS = ["type", "fix", "tag", "service_only", "debounce_seconds"]
COMMON_CHECK_FIELDS = ["type", "fix", "tag", "service_only", "debounce_seconds", "disabled"]
FIX_KINDS = sp.FIX_KINDS
FIX_KIND_FIELDS = {
"ssh": ["host", "run", "timeout", "reconnect", "conn"], "local": ["run", "timeout", "reconnect", "conn"],
"atem": ["part"], "reconnect": ["conn", "verify_seconds", "hung_note"], "midi_connect": [],
"atem": ["part"], "reconnect": ["conn", "verify_seconds", "hung_note"], "midi_connect": [], "propresenter_port": ["conn", "on"],
"netgear_poe": ["switch", "port", "mac"], "hyperdeck": ["verify_seconds", "off_seconds", "hung_note"],
"spotify": ["playlist"],
"spotify": ["playlist"], "ready": ["action"], "companion_press": ["page", "row", "col"],
}
EXPECTED_MODULE = {t: spec["module"] for t, spec in CHECKS.items() if spec["module"]}
@@ -361,6 +488,14 @@ GLOSSARY = {
"RF MUTE", "HUNG - POWER CYCLE IT", "NO NETWORK CHECK POWER", "STILL DOWN", "REFUSED", "LOGIN", "BLIP",
"NOT REACHABLE", "DISK",
],
"the READY page: what is not ready yet (hold the key to fix it or look again)": [
"TO DO", "ALL DONE", "TAP WHEN DONE", "TAP WHEN SET", "NOT DONE", "DONE", "NOT SET UP", "CHECKING",
"NO TRACKS", "EMPTY", "NOT MIXED", "TO MASTER", "NO LIGHTS", "NO CUE TRACK", "NO SET", "LIVE HAS OLD SET",
"MISSING", "SLIDES OLD?", "SLIDES", "NO TC", "NO LTC", "NO BG", "NO PLAYLIST", "OLD PLAN", "SYNC ERROR",
"NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN",
"PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE",
"UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
],
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
"what a key says while you hold it": [
"NOTHING TO FIX", "NO AUTO FIX", "JUST TRIED", "WAIT 1 MIN", "WAIT", "MIN", "S", "CHECKING", "FIXING",
@@ -369,7 +504,11 @@ GLOSSARY = {
"STILL OFF IN COMPANION", "DISABLED IN COMPANION", "TURN ON", "RECONNECT", "POWER CYCLE", "FIXES", "HOLD TO FIX",
"HOLD", "REC", "PLAYING", "OK", "OFF", "ON", "ALL OK", "WARN", "TRS", "GB", "NOT", "REACHABLE", "RECORDING",
"LEFT ALONE", "TO FIX", "TO CLEAR", "NO AUTO", "FIX", "AUTO", "IN", "COMPANION", "POWER", "CYCLE",
"STILL", "OFF IN", "DISABLED IN", "CONNECTING", "CHECK", "NETWORK", "NO",
"STILL", "OFF IN", "DISABLED IN", "CONNECTING", "CHECK", "NETWORK", "NO", "PRESSING", "NO BUTTON THERE",
"ON FOH BACKUP", "BUSY", "TAKING MIDI OUT", "PUTTING MIDI BACK", "TAKING", "PUTTING", "MIDI OUT", "MIDI BACK",
],
"a switch key (AUTO-FIX, or one like LIGHTS that hands a device to an operator)": [
"OPERATOR", "MIDI OFF", "OFF IN SETTINGS", "SETTINGS", "TIL", "PAUSED",
],
}
@@ -399,8 +538,14 @@ def key_record():
kind, parts = "restart_key", parts[2:]
elif p.startswith("restart_page."):
kind, parts = "restart_page", parts[1:]
elif p.startswith("ready_page."):
kind, parts = "ready_page", parts[1:]
elif p.startswith("ready."):
kind, parts = "ready", parts[1:]
elif p.startswith("auto_fix."):
kind, parts = "auto", parts[1:]
elif p.startswith("failover."):
kind, parts = "failover", parts[1:]
elif p.startswith("network."):
kind, parts = "network", parts[1:]
elif p.startswith("variables."):
@@ -455,7 +600,7 @@ def validate(cfg, live=None):
if errors:
return errors, warnings
ids, seen_pos = set(), {}
kinds = {"master": 0, "service": 0, "network": 0}
kinds = {"master": 0, "service": 0, "network": 0, "autofix": 0}
fixes, hosts = cfg.get("fixes", {}), cfg.get("hosts", {})
for t in cfg["tiles"]:
tid = t.get("id", "")
@@ -491,6 +636,16 @@ def validate(cfg, live=None):
errors.append("%s: a ping needs an address or a connection" % cw)
if c["type"] == "midi_peer" and not (c.get("session") and c.get("peer")):
errors.append("%s: a MIDI link needs a session and a peer" % cw)
if c["type"] == "midi_wired":
nets = c.get("nets")
if not (isinstance(nets, list) and nets):
errors.append("%s: a wired-MIDI check needs the wired network(s), e.g. 10.1.0.0/16" % cw)
else:
for n in nets:
try:
ipaddress.ip_network(str(n), strict=False)
except ValueError:
errors.append("%s: %r is not a network (like 10.1.0.0/16)" % (cw, n))
if c["type"] == "remote" and not (c.get("host") and c.get("cmd") and c.get("msg")):
errors.append("%s: a remote check needs a computer, a command and the words to show" % cw)
fx = c.get("fix")
@@ -512,7 +667,14 @@ def validate(cfg, live=None):
if m == t.get("id"):
errors.append("key %s: is its own member" % t.get("id"))
in_group = {m for t in cfg["tiles"] for m in t.get("members", [])}
# A key moved to the Restart page (NA's ARENA, 2026-10-21): its checks still feed ALL and auto-fix,
# and the Restart key watching it flashes, so it is seen.
rk = (cfg.get("restart_page") or {}).get("keys") or []
on_restart = {f for k in rk for f in [k.get("fix")] + list(k.get("watch") or [])}
for t in cfg["tiles"]:
fixes_of = {c.get("fix") for c in t.get("checks", []) if isinstance(c.get("fix"), str)} | {"conn:" + c["conn"] for c in t.get("checks", []) if c.get("conn")}
if fixes_of & on_restart:
continue
if "pos" not in t and "members" not in t and t.get("id") not in in_group and t.get("kind") != "master":
warnings.append("key %s has no place on the deck and is in no group: nobody will see it" % t.get("id"))
if kinds["master"] != 1:
@@ -521,6 +683,16 @@ def validate(cfg, live=None):
errors.append("there must be exactly one ALARMS key (kind service); found %d" % kinds["service"])
if kinds["network"] > 1:
errors.append("at most one NET key (kind network); found %d" % kinds["network"])
if kinds["autofix"] > 1:
errors.append("at most one AUTO-FIX key (kind autofix); found %d" % kinds["autofix"])
for t in cfg["tiles"]:
if t.get("kind") == "bypass":
b = t.get("bypass") if isinstance(t.get("bypass"), dict) else {}
if not b.get("peers"):
errors.append("key %s: a bypass key needs bypass.peers (the MIDI peers it takes out)" % t.get("id"))
for h in b.get("hosts") or []:
if h and h not in cfg.get("hosts", {}):
errors.append("key %s: bypass host %s is not a computer in the settings" % (t.get("id"), h))
for fid, fix in fixes.items():
where = "fix %s" % fid
kind = fix.get("kind", "ssh")
@@ -544,10 +716,28 @@ def validate(cfg, live=None):
errors.append("%s: needs the connection to reconnect" % where)
if kind == "atem" and fix.get("part") not in ("audio", "camera"):
errors.append("%s: part must be audio or camera" % where)
if kind == "companion_press" and not (_whole(fix.get("page"), 1) and _whole(fix.get("row"), 0) and _whole(fix.get("col"), 0)):
errors.append("%s: a Companion button needs its page, row and column" % where)
if fix.get("failover") and fix.get("never_auto"):
errors.append("%s: is a failover (runs by itself) and marked never to run by itself - one or the other" % where)
if fix.get("anytime") and kind != "ready":
errors.append("%s: only a READY page fix may run at any hour (anytime)" % where)
if fix.get("anytime") and fix.get("never_auto"):
errors.append("%s: runs at any hour (anytime) and never by itself - one or the other" % where)
if fix.get("undo") and fix["undo"] not in fixes:
errors.append("%s: the fix that switches back (undo) is unknown: %s" % (where, fix["undo"]))
for k in fix:
if k not in FIX_KIND_FIELDS.get(kind, []) and k not in ("kind", "short", "label", "restart_ok", "never_auto",
"template", "params", "timeout"):
if k not in FIX_KIND_FIELDS.get(kind, []) and k not in ("kind", "short", "label", "restart_ok", "never_auto", "anytime", "disabled",
"template", "params", "timeout", "failover", "undo"):
warnings.append("%s: %s is not a field of a %s fix" % (where, k, kind))
named = set()
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
for c in t.get("checks", []):
fx = c.get("fix")
named |= set(fx.values()) if isinstance(fx, dict) else ({fx} if fx else set())
for fid, fix in fixes.items():
if fix.get("failover") and fid not in named:
warnings.append("fix %s is a failover but no check runs it" % fid)
for alias, h in hosts.items():
if not re.fullmatch(r"[a-z0-9_]+", alias):
errors.append("computer %s: the short name may only hold lowercase letters, digits and _" % alias)
@@ -563,6 +753,29 @@ def validate(cfg, live=None):
errors.append("key %s check %d: unknown computer %s" % (t.get("id"), i + 1, c["host"]))
elif h.get("self"):
errors.append("key %s check %d: %s is this Mac" % (t.get("id"), i + 1, c["host"]))
# Network MIDI rides the wired network (2026-10-20): a link set to a Bonjour name or to an
# address off it can come back over Wi-Fi or Dante, so it is refused once the MIDI key has the rule.
nets = []
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
for c in t.get("checks", []):
if c.get("type") == "midi_wired":
for n in c.get("nets") or []:
try:
nets.append(ipaddress.ip_network(str(n), strict=False))
except ValueError:
pass
if nets:
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
for c in t.get("checks", []):
if c.get("type") != "midi_peer" or c.get("disabled"):
continue
where = "key %s, MIDI link %s" % (t.get("id"), c.get("peer"))
ip = sp.peer_ip(c.get("address"))
if not c.get("address"):
warnings.append("%s: no wired address - a reconnect may pick Wi-Fi or Dante" % where)
elif ip is None or not any(ip in n for n in nets):
errors.append("%s: %s is not on the wired network (%s) - give its wired address, like 10.1.10.206:5004"
% (where, str(c["address"]).strip() or "?", ", ".join(str(n) for n in nets)))
a = cfg.get("auto_fix") or {}
for f in a.get("fixes", []):
if f not in fixes:
@@ -571,8 +784,11 @@ def validate(cfg, live=None):
errors.append("auto-fix: %s is marked never to run by itself" % f)
elif re.search(r"pkill|taskkill|killall", fixes[f].get("run", "")) and not fixes[f].get("restart_ok"):
errors.append("auto-fix: %s closes a running program; tick 'may close a running program' or take it out" % f)
elif fixes[f].get("failover"):
warnings.append("auto-fix: %s is a failover - it already runs by itself as one" % f)
_windows(cfg.get("service_windows"), "alarms window", errors)
_windows(a.get("windows"), "auto-fix window", errors)
_windows((cfg.get("failover") or {}).get("windows"), "failover window", errors)
_windows((cfg.get("site") or {}).get("live_services"), "live services", errors)
rp = cfg.get("restart_page")
if rp:
@@ -600,6 +816,51 @@ def validate(cfg, live=None):
errors.append("the 'back to Status' key sits on %s" % rpos[tuple(rp["back_pos"])])
if rp.get("page") == cfg.get("page"):
errors.append("the Status page and the Restart page cannot be the same page")
rd = cfg.get("ready_page")
readies = [t for t in cfg["tiles"] if t.get("kind") == "ready"]
if len(readies) > 1:
errors.append("at most one READY key on the Status page; found %d" % len(readies))
if rd:
dpos = {}
for k in rd.get("keys", []):
kid = k.get("id", "")
where = "READY key %s" % (kid or "?")
if not re.fullmatch(r"[A-Za-z0-9_]+", str(kid)):
errors.append("%s: the id may only hold letters, digits and _" % where)
if kid in ids:
errors.append("%s: another key has that id" % where)
ids.add(kid)
if not k.get("label"):
errors.append("%s: no label" % where)
pos = pos_ok(k.get("pos"), where)
if pos in dpos:
errors.append("%s and %s share row %d column %d" % (dpos[pos], where, pos[0], pos[1]))
dpos[pos] = where
for i, c in enumerate(k.get("checks", [])):
cw = "%s check %d" % (where, i + 1)
if c.get("type") not in CHECKS:
errors.append("%s: unknown check type %r" % (cw, c.get("type")))
continue
if c["type"] in ("ready", "ready_manual") and not c.get("item"):
errors.append("%s: which checklist line (item)?" % cw)
if c["type"] == "ready" and c.get("item") and c["item"] not in sp.READY_ITEMS:
errors.append("%s: unknown checklist line %s" % (cw, c["item"]))
fx = c.get("fix")
for f in (fx.values() if isinstance(fx, dict) else [fx]):
if f and f not in fixes and f not in (sp.RESTART_CONNECTION, sp.DANTE_AUTO):
errors.append("%s: unknown fix %s" % (cw, f))
if "back_pos" in rd:
bp = pos_ok(rd["back_pos"], "the READY page's back key")
if bp in dpos:
errors.append("the 'back to Status' key sits on %s" % dpos[bp])
if rd.get("page") in (cfg.get("page"), (rp or {}).get("page")):
errors.append("the READY page cannot be the Status or Restart page")
if not readies:
warnings.append("the READY page has no READY key on the Status page: nobody can open it from there")
_windows((cfg.get("ready") or {}).get("due"), "READY due window", errors)
for fid, fix in fixes.items():
if fix.get("kind") == "ready" and fix.get("action") not in sp.READY_ACTIONS:
errors.append("fix %s: unknown READY action %s" % (fid, fix.get("action")))
spo = cfg.get("spotify") or {}
if any(c.get("type") == "spotify" for t in cfg["tiles"] for c in t.get("checks", [])):
if not re.fullmatch(r"spotify:playlist:[A-Za-z0-9]+", str(spo.get("playlist", ""))):
@@ -627,6 +888,11 @@ def validate(cfg, live=None):
return errors, warnings
def _whole(v, lo):
"""A whole number of at least lo (a bool is not one)."""
return isinstance(v, int) and not isinstance(v, bool) and v >= lo
def _get(cfg, path):
"""The value at a plain path (no [] or *); None when absent."""
if "[]" in path or ".*." in path:
@@ -679,11 +945,15 @@ def phrases(cfg):
"""The sentences the page and the logs build from the config."""
a = cfg.get("auto_fix") or {}
site = cfg.get("site") or {}
fo_windows = (cfg.get("failover") or {}).get("windows", F["failover.windows"]["default"])
return {
"auto_fix": "may run by itself %s" % sp.windows_phrase(a.get("windows", [])) if a.get("windows") else "never runs by itself",
"failover": "switches by itself %s" % sp.windows_phrase(fo_windows) if fo_windows else "never switches by itself",
"live": ("changes wait while a HyperDeck is recording" + (" or %s" % sp.windows_phrase(site["live_services"])
if site.get("live_services") else "")),
"alarms": "Alarms are on every day. The ALARMS key silences them until midnight, then they come back by themselves.",
"alarms": ("Alarms are on every day. The ALARMS key silences them until midnight; AUTO-FIX pauses or resumes "
"auto-fix on its own. Both come back by themselves." if any(t.get("kind") == "autofix" for t in cfg.get("tiles", []))
else "Alarms are on every day. The ALARMS key silences them and stops every auto-fix until midnight, then both come back by themselves."),
"hold": "HOLD %gs" % (cfg.get("page_hold_ms", 2000) / 1000.0),
"ingest": site.get("ingest_name", "NA Ingest"),
}
+21 -6
View File
@@ -49,6 +49,8 @@ def build_hashes(cfg):
out = {"status": sha(strip_notes(make_page.build(cfg)))}
if cfg.get("restart_page"):
out["restart"] = sha(strip_notes(make_page.build_restart(cfg)))
if cfg.get("ready_page"):
out["ready"] = sha(strip_notes(make_page.build_ready(cfg)))
return out
@@ -429,6 +431,9 @@ class Applier:
elif name == "restart" and cfg.get("restart_page"):
page, expected, fname = cfg["restart_page"]["page"], cfg["restart_page"]["page_name"], "restart-page.companionconfig"
export = make_page.build_restart(cfg)
elif name == "ready" and cfg.get("ready_page"):
page, expected, fname = cfg["ready_page"]["page"], cfg["ready_page"]["page_name"], "ready-page.companionconfig"
export = make_page.build_ready(cfg)
else:
continue
path = os.path.join(out_dir, fname)
@@ -438,13 +443,20 @@ class Applier:
if not ok:
return False, "page %d: %s" % (page, why)
push_page.push(host, port, page, path, expected)
after = push_page.fetch_page(host, port, page)
want = {(r, c) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if ctl and ctl.get("type") == "button-layered"}
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
# Companion places the keys a moment after the import call returns (Southcoast's first setup
# was judged too early, 2026-10-21): look again for up to 10 s before calling it a failure.
for _ in range(20):
after = push_page.fetch_page(host, port, page)
have = {(r, c) for r, row in (after.get("page") or {}).get("controls", {}).items() for c, ctl in (row or {}).items() if ctl and ctl.get("type") == "button-layered"}
if want == have:
break
time.sleep(0.5)
if want != have:
return False, "page %d: the import did not land as built (%d keys built, %d on the page)" % (page, len(want), len(have))
done.append(name)
return True, ", ".join("page %d" % (cfg["page"] if n == "status" else cfg["restart_page"]["page"]) for n in done) or "nothing to import"
number = {"status": cfg["page"], "restart": (cfg.get("restart_page") or {}).get("page"), "ready": (cfg.get("ready_page") or {}).get("page")}
return True, ", ".join("page %s" % number.get(n) for n in done) or "nothing to import"
def colour_nav_keys(self, cfg):
comp = cfg["companion"]
@@ -488,8 +500,11 @@ class Applier:
def _pipeline(self, new_cfg, note, by, address, atem_file):
site = self.site
old_cfg = site.load()
site.load()
sha_before = site.cfg_sha
# From the file, not the cached object: a caller that edited site.load()'s result in place would
# otherwise compare the new settings with themselves and skip the watchdog restart (2026-10-21).
old_cfg = sp.load_config(site.config_path)
if self.status.get("emergency"):
self.log("EMERGENCY apply by %s: %s" % (by, self.status["emergency"].get("reason")))
# 1. check
@@ -558,7 +573,7 @@ class Applier:
# 4. pages
if plan["pages"]:
self.step("pages", "running")
ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
ok, detail = self.import_pages(new_cfg, [p for p in plan["pages"] if p in ("status", "restart", "ready")])
if not ok:
self.step("pages", "failed", detail)
sp.write_json(os.path.join(site.data_dir, "pages-out-of-sync.json"), {"when": time.time(), "detail": detail})
@@ -593,7 +608,7 @@ class Applier:
ok, detail = self.restart_watchdog(site.cfg_sha)
pages_note = ""
if ok and plan.get("pages"):
pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart")])
pok, pdetail = self.import_pages(old_cfg, [p for p in plan["pages"] if p in ("status", "restart", "ready")])
pages_note = "; pages put back" if pok else "; the pages could not be put back: " + pdetail
self.step("rollback", "ok" if ok else "failed", ("previous settings restored, " + detail + pages_note) if ok else detail)
self.status["state"] = "failed"
+670
View File
@@ -0,0 +1,670 @@
"""Auto-setup: what a location has (settings/discover.py's inventory) becomes its keys, checks, fixes
and page layout - the same building blocks North Attleboro's were made of by hand.
Pure functions, no I/O:
build(base, inv, location, pin) a whole config for a new location, from the empty template
extend(cfg, inv, seen) a running location: only what is new (never removes anything)
Everything that comes out passes schema.validate and can be changed on the settings page like any
hand-made setting. Gear auto-setup does not know (a generic connection, an app it has no recipe
for) is reported, not guessed at.
"""
import copy
import ipaddress
import re
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
from settings import builders as b
import status_panel as sp
# --- what auto-setup knows -------------------------------------------------------------
# Group keys sum up their members on a big location, in this order along the top row.
GROUPS = ["REC", "AUDIO", "RF", "SHOW", "VIDEO", "STREAM"]
# Who gets a cell of its own when not everything fits (the rest show inside their group key only):
# a row of wireless receivers folds first, as at North Attleboro.
PRIORITY = ["REC", "SHOW", "AUDIO", "STREAM", "VIDEO", "RF"]
# Companion modules: which group, what kind of key, the key's text when there is only one.
COMPANION = {
"bmd-hyperdeck": {"group": "REC", "kind": "hyperdeck", "label": "HD"},
"tascam-da-6400": {"group": "REC", "kind": "tascam", "label": "TASCAM"},
"audinate-dantecontroller": {"group": "AUDIO", "kind": "dante", "label": "DANTE"},
"behringer-x32": {"group": "AUDIO", "kind": "conn", "label": "X32"},
"behringer-wing": {"group": "AUDIO", "kind": "conn", "label": "WING"},
"midas-m32": {"group": "AUDIO", "kind": "conn", "label": "M32"},
"allenheath-sq": {"group": "AUDIO", "kind": "conn", "label": "SQ"},
"allenheath-dlive": {"group": "AUDIO", "kind": "conn", "label": "DLIVE"},
"allenheath-avantis": {"group": "AUDIO", "kind": "conn", "label": "AVANTIS"},
"yamaha-rcp": {"group": "AUDIO", "kind": "conn", "label": "YAMAHA"},
"soundcraft-ui": {"group": "AUDIO", "kind": "conn", "label": "UI24"},
"digico-sd": {"group": "AUDIO", "kind": "conn", "label": "DIGICO"},
"shure-wireless": {"group": "RF", "kind": "shure", "label": "MICS"},
"shure-psm1000": {"group": "RF", "kind": "psm", "label": "PSM"},
"renewedvision-propresenter-api": {"group": "SHOW", "kind": "conn", "label": "PROP", "app": "propresenter"},
"ableton-osc": {"group": "SHOW", "kind": "conn", "label": "ABLETON", "app": "ableton"},
"leolabs-ableset": {"group": "SHOW", "kind": "conn", "label": "ABLESET", "app": "ableton"},
"bmd-atem": {"group": "VIDEO", "kind": "conn", "label": "ATEM"},
"bmd-videohub": {"group": "VIDEO", "kind": "conn", "label": "VIDEOHUB"},
"resolume-arena": {"group": "VIDEO", "kind": "conn", "label": "ARENA", "app": "arena"},
"novastar-controller": {"group": "VIDEO", "kind": "conn", "label": "LED", "ping": True},
"obs-studio": {"group": "STREAM", "kind": "conn", "label": "OBS", "app": "obs"},
"resi-decoders": {"group": "STREAM", "kind": "conn", "label": "RESI", "ping": True},
}
# Programs on the computers: by OS, the process (Mac, as pgrep sees it) or program file (Windows).
# "app" ties a program to a Companion connection of the same name; "restart" adds a Restart page key.
MAC_APPS = [
{"app": "propresenter", "process": "ProPresenter", "label": "PROP", "open": "/Applications/ProPresenter*.app", "restart": True},
{"app": "ableton", "process": "Live", "label": "ABLETON", "open": "/Applications/Ableton Live*.app", "ableton": True, "restart": True},
{"app": "arena", "process": "Arena", "label": "ARENA", "open": "/Applications/Resolume Arena*/Arena.app", "restart": True},
{"app": "obs", "process": "obs", "label": "OBS", "open": "/Applications/OBS.app", "restart": True},
{"app": "translate", "process": "Church Translate", "label": "TRANSLATE", "open": "/Applications/Church Translate.app", "restart": True},
{"app": "protools", "process": "Pro Tools", "label": "PRO TOOLS", "open": "/Applications/Pro Tools.app", "restart": True},
{"app": "vista", "process": "Vista", "label": "VISTA", "open": "/Applications/Vista*.app", "restart": True},
{"app": "superrack", "process": "SuperRack SoundGrid", "label": "SUPERRACK", "open": "/Applications/*SuperRack*.app", "restart": True, "group": "AUDIO"},
{"app": "x32edit", "process": "X32-Edit", "label": "X32 EDIT", "open": "/Applications/X32-Edit.app", "restart": False, "group": "AUDIO"},
]
WIN_APPS = [
{"app": "vista", "exe": "Vista.exe", "label": "VISTA", "task": "Start Vista", "restart": True},
{"app": "arena", "exe": "Arena.exe", "label": "ARENA", "task": "Start Arena", "restart": True},
{"app": "obs", "exe": "obs64.exe", "label": "OBS", "task": "Start OBS", "restart": True},
{"app": "propresenter", "exe": "ProPresenter.exe", "label": "PROP", "task": "Start ProPresenter", "restart": True},
{"app": "rtpmidi", "exe": "rtpMIDI.exe", "label": "RTPMIDI", "task": "Start rtpMIDI", "restart": False, "fold": "midi_wired"},
]
# The task names the Windows join script makes, by program file (it reads WIN_APPS through campus.py).
WIN_TASKS = {a["exe"]: a["task"] for a in WIN_APPS}
# Fixes shared by every key of a kind (the same words North Attleboro's carry).
SHARED_FIXES = {
"hyperdeck": {"kind": "hyperdeck", "template": "hyperdeck", "params": {}, "short": "HYPERDECK", "verify_seconds": 15,
"hung_note": "STILL OFFLINE\nHOLD AGAIN",
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck"},
"midi_reconnect": {"kind": "midi_connect", "template": "midi_connect", "params": {}, "short": "RECONNECT",
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address"},
}
STATUS_ROWS, COLS = 4, 8
# North Attleboro's auto-fix hours, for every location: Tuesday and Wednesday rehearsals, Sunday services.
DEFAULT_AUTO_FIX = {"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
"restart_connections": True, "windows": [{"days": ["tue"], "start": "14:00", "end": "22:00"},
{"days": ["wed"], "start": "09:00", "end": "21:00"},
{"days": ["sun"], "start": "05:00", "end": "14:00"}]}
# --- small helpers ---------------------------------------------------------------------
def slug(text, taken=()):
base = re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_") or "key"
out, n = base, 2
while out in taken:
out, n = "%s_%d" % (base, n), n + 1
return out
def key_text(label, limit=10):
t = re.sub(r"[_\s]+", " ", str(label)).strip().upper()
return t if len(t) <= limit else t[:limit].rstrip()
def tag_for(name):
return key_text(name, 12)
def ip_of(address):
ip = sp.peer_ip(address)
return str(ip) if ip else None
def in_nets(nets, address):
ip = sp.peer_ip(address)
return ip is not None and any(ip in ipaddress.ip_network(n, strict=False) for n in nets)
def computer_by_ip(inv, ip):
for alias, c in (inv.get("computers") or {}).items():
if ip and ip in (c.get("ips") or []):
return alias
return None
def referenced(cfg):
"""Everything a config already watches, by the same identities discovery uses."""
out = set()
for t in sp.all_tiles(cfg):
for c in t.get("checks", []):
if c.get("conn"):
out.add("conn:" + c["conn"])
if c.get("type") == "dante":
out.update("dante:" + d for d in (c.get("devices") or {}))
if c.get("type") == "midi_peer":
out.add("midi:%s|%s|%s" % (c.get("host", ""), c.get("session"), c.get("peer")))
if c.get("type") == "midi_wired":
out.add("midi_wired")
p = c.get("params") or {}
if c.get("type") == "remote" and p.get("host") and (p.get("process") or p.get("exe")):
out.add("app:%s|%s" % (p["host"], p.get("process") or p.get("exe")))
for alias, h in (cfg.get("hosts") or {}).items():
out.add("host:" + alias)
ip = str(h.get("ssh", "")).split("@")[-1]
if ip:
out.add("hostip:" + ip)
return out
# --- from the inventory to items ------------------------------------------------------
def items(inv, nets):
"""[{ident, group, label, checks, fixes, restart, also_idents}] for everything auto-setup can watch,
plus the computer records ({alias: host}). Each item is one key."""
out, hosts = [], {}
comps = inv.get("computers") or {}
for alias, c in comps.items():
if not c.get("reachable"):
continue
hosts[alias] = {"name": c.get("name") or alias, "os": c.get("os", "mac"), "ssh": "%s@%s" % (c["user"], c["ip"]),
"role": "found by auto-setup", "runs": [{"kind": "sshd"}]}
if c.get("macs"):
hosts[alias]["mac"] = c["macs"][0]
# Programs on the computers, by app, so a Companion connection to the same program joins its key.
apps_at = {} # (app, ip) -> item
for alias, c in comps.items():
if not c.get("reachable"):
continue
procs = set(c.get("procs") or [])
catalog = MAC_APPS if c.get("os", "mac") == "mac" else WIN_APPS
for a in catalog:
if c.get("os", "mac") == "mac":
if a["process"] not in procs:
continue
app_path = (c.get("paths") or {}).get(a["process"]) or newest_app(c.get("apps") or [], a["open"])
if not app_path:
continue
check = {"type": "remote", "template": "mac_program", "params": {"host": alias, "process": a["process"]},
"msg": "%s NOT RUNNING" % a["label"]}
if a.get("ableton"):
start = {"template": "ableton", "params": {"host": alias, "app": app_path.rsplit("/", 1)[-1][:-4], "restart": False}}
again = {"template": "ableton", "params": {"host": alias, "app": app_path.rsplit("/", 1)[-1][:-4], "restart": True}}
else:
start = {"template": "mac_start", "params": {"host": alias, "process": a["process"], "app": app_path}}
again = {"template": "mac_restart", "params": {"host": alias, "process": a["process"], "app": app_path,
"wait_seconds": 10, "force": True}}
ident = "app:%s|%s" % (alias, a["process"])
else:
if a["exe"] not in procs or a["task"] not in (c.get("tasks") or []):
continue # no start task on that PC: a hold could not start it
check = {"type": "remote", "template": "win_program", "params": {"host": alias, "exe": a["exe"]},
"msg": "%s NOT RUNNING" % a["label"]}
start = {"template": "win_start", "params": {"host": alias, "task": a["task"], "exe": a["exe"]}}
again = {"template": "win_restart", "params": {"host": alias, "task": a["task"], "exe": a["exe"]}}
ident = "app:%s|%s" % (alias, a["exe"])
fid = slug("%s_%s" % (a["app"], alias))
pretty = {"propresenter": "ProPresenter", "ableton": "Ableton", "arena": "Resolume Arena", "obs": "OBS", "translate": "Church Translate",
"protools": "Pro Tools", "vista": "Vista", "rtpmidi": "rtpMIDI", "superrack": "SuperRack", "x32edit": "X32-Edit"}.get(a["app"], a["label"].title())
where = c.get("name") or alias
start = dict(start, label="start %s on %s if it is not running" % (pretty, where), short=a["label"])
again = dict(again, label="restart %s on %s (closes it first)" % (pretty, where), short=a["label"])
item = {"ident": ident, "group": "SHOW" if a["app"] not in ("obs",) else "STREAM", "label": a["label"],
"checks": [dict(check, fix=fid)], "fixes": {fid: start}, "restart": [], "also": [], "fold": a.get("fold")}
if a.get("restart"):
rid = "restart_" + fid
item["fixes"][rid] = again
item["restart"].append({"label": a["label"], "fix": rid})
if a["app"] == "arena":
item["group"] = "VIDEO"
if a.get("group"):
item["group"] = a["group"]
if a["app"] == "superrack": # and its link to the Waves SoundGrid server
item["checks"].append({"type": "remote", "template": "soundgrid", "params": {"host": alias}, "msg": "SOUNDGRID LOST"})
out.append(item)
apps_at[(a["app"], c.get("ip"))] = item
for ip in c.get("ips") or []:
apps_at.setdefault((a["app"], ip), item)
# Programs on this Mac itself (the Companion Mac): a local process check and local start/restart fixes.
ing = inv.get("ingest") or {}
for a in MAC_APPS:
exe = (ing.get("exes") or {}).get(a["process"])
app_path = (ing.get("paths") or {}).get(a["process"])
if not exe or not app_path:
continue
fid = slug("%s_ingest" % a["app"])
pretty = a["label"].title() if a["app"] not in ("propresenter", "ableton") else {"propresenter": "ProPresenter", "ableton": "Ableton"}[a["app"]]
# Any installed copy counts, and the newest opens: an update that installs beside the old one
# ("ProPresenter 20.0.1.app" and "ProPresenter.app" at Woonsocket) never reads as stopped.
newest = a["open"] if "*" in a.get("open", "") else ""
if newest:
exe = b.app_match(newest, a["process"])
opens = {"app": "$APP", "newest_of": newest} if newest else {"app": app_path}
start = {"template": "mac_start", "params": dict({"host": "ingest", "process": a["process"]}, **opens),
"label": "start %s on this Mac if it is not running" % pretty, "short": a["label"]}
again = {"template": "mac_restart", "params": dict({"host": "ingest", "process": a["process"], "wait_seconds": 10, "force": True}, **opens),
"label": "restart %s on this Mac (closes it first)" % pretty, "short": a["label"]}
item = {"ident": "app:ingest|%s" % a["process"], "group": a.get("group") or ("VIDEO" if a["app"] == "arena" else "STREAM" if a["app"] == "obs" else "SHOW"),
"label": a["label"], "checks": [{"type": "process", "match": exe, "name": a["label"], "fix": fid}],
"fixes": {fid: start, "restart_" + fid: again}, "restart": [{"label": a["label"], "fix": "restart_" + fid}] if a.get("restart") else [],
"also": [], "fold": a.get("fold")}
out.append(item)
for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) + ["127.0.0.1"]:
if ip:
apps_at.setdefault((a["app"], ip), item)
# Companion connections.
counts = {}
for conn in inv.get("connections") or []:
counts[conn.get("module")] = counts.get(conn.get("module"), 0) + 1
hd_n = 0
for conn in sorted(inv.get("connections") or [], key=lambda c: c["label"]):
spec = COMPANION.get(conn.get("module"))
if not spec or not conn.get("enabled", True):
continue
label = conn["label"]
ident = "conn:" + label
many = counts.get(conn["module"], 0) > 1
text = spec["label"] if not many else key_text(label)
if conn.get("module") == "behringer-x32" and "m32" in label.lower() and not many:
text = "M32" # a Midas M32 on the same module (Woonsocket)
if spec["kind"] == "hyperdeck":
hd_n += 1
text = "HD%d" % hd_n if many else "HD"
out.append({"ident": ident, "group": "REC", "label": text, "restart": [], "also": [],
"checks": [{"type": "hyperdeck", "conn": label, "fix": {"conn": "hyperdeck"}}],
"fixes": {"hyperdeck": dict(SHARED_FIXES["hyperdeck"])}})
continue
if spec["kind"] == "tascam":
fid = slug("reconnect_" + label)
out.append({"ident": ident, "group": "REC", "label": text, "restart": [], "also": [],
"checks": [{"type": "tascam", "conn": label, "fix": fid}, {"type": "ping", "conn": label, "name": text}],
"fixes": {fid: {"kind": "reconnect", "template": "reconnect", "params": {}, "conn": label, "short": text,
"verify_seconds": 20, "label": "reconnect Companion to %s" % label}}})
continue
if spec["kind"] == "dante":
devices = {d: key_text(d, 12) for d in (inv.get("dante") or {}).get("devices") or []}
out.append({"ident": ident, "group": "AUDIO", "label": "DANTE", "restart": [], "also": ["dante:" + d for d in devices],
"checks": [{"type": "dante", "conn": label, "devices": devices, "fix": {"*": sp.DANTE_AUTO}}], "fixes": {}})
continue
if spec["kind"] in ("shure", "psm"):
n = int(conn.get("channels") or (4 if spec["kind"] == "shure" else 2))
out.append({"ident": ident, "group": "RF", "label": text if many else ("MICS" if spec["kind"] == "shure" else "PSM"),
"restart": [], "also": [],
"checks": [{"type": spec["kind"], "conn": label, "channels": n}, {"type": "ping", "conn": label}], "fixes": {}})
continue
checks = [{"type": "conn", "conn": label}]
fixes = {}
if conn.get("module") == "renewedvision-propresenter-api":
# ProPresenter changes its network port now and then: the fix finds the new one.
fid = slug("prop_port_" + label)
where = computer_by_ip(inv, ip_of(conn.get("host")) or conn.get("host"))
fixes[fid] = dict({"kind": "propresenter_port", "template": "propresenter_port", "params": {}, "conn": label, "short": "PROP PORT",
"label": "find ProPresenter's network port and reconnect Companion's %s to it" % label}, **({"on": where} if where else {}))
checks[0]["fix"] = fid
if spec.get("ping") and conn.get("host"):
checks.append({"type": "ping", "conn": label})
app_item = apps_at.get((spec.get("app"), ip_of(conn.get("host")))) if spec.get("app") else None
if app_item: # the program's own key carries its connection too
app_item["checks"] = checks + app_item["checks"]
app_item["fixes"].update(fixes)
app_item["also"].append(ident)
continue
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": fixes, "restart": [], "also": []})
# Network MIDI: every peer in the sessions here and on the other Macs, at its wired address.
midi_checks = []
# Whose session a peer is, by its name: a peer joined by name or by an IPv6 address gets that Mac's wired address.
owner = {sname: (alias or "ingest") for alias, sessions in (inv.get("midi") or {}).items() for sname in (sessions or {})}
ing = inv.get("ingest") or {}
ing_ips = [ip for ip in [(ing.get("wired") or {}).get("ip")] + list(ing.get("ips") or []) if ip]
for host, sessions in (inv.get("midi") or {}).items():
for sname, sess in (sessions or {}).items():
for p in sess.get("peers") or []:
addr = p.get("address") or ""
if not in_nets(nets, addr):
alias = owner.get(p.get("name")) or computer_named(inv, p.get("name"))
ips = ing_ips if alias == "ingest" else (comps.get(alias) or {}).get("ips") or [] if alias else []
ip = next((i for i in ips if in_nets(nets, i)), None)
if not ip:
continue # no wired address known: the wired check will name it
port = (((inv.get("midi") or {}).get("" if alias == "ingest" else alias) or {}).get(p.get("name")) or {}).get("port") or 5004
addr = "%s:%d" % (ip, port)
chk = {"type": "midi_peer", "session": sname, "peer": p["name"], "address": addr, "fix": "midi_reconnect",
"tag": tag_for(p["name"])}
if host:
chk["host"] = host
midi_checks.append(("midi:%s|%s|%s" % (host, sname, p["name"]), chk))
# One check per link, not one per end (2026-10-20: two links pulled at Woonsocket read "4 DOWN"):
# keep the end whose Mac says whether the link is live (newer macOS reports a peer's state).
def end_of(host):
return host or "ingest"
def reports_state(host, sname):
return any("state" in p for p in (((inv.get("midi") or {}).get(host) or {}).get(sname) or {}).get("peers") or [])
by_pair = {}
for ident, c in midi_checks:
ip = ip_of(c["address"])
other = owner.get(c["peer"]) or ("ingest" if ip and ip in ing_ips else None) or computer_by_ip(inv, ip) or c["peer"]
pair = frozenset((end_of(c.get("host", "")), other))
best = by_pair.get(pair)
if best is None or (reports_state(c.get("host", ""), c["session"]) and not reports_state(best[1].get("host", ""), best[1]["session"])):
by_pair[pair] = (ident, c)
midi_checks = [x for x in midi_checks if x in by_pair.values()]
if midi_checks:
checks = [c for _, c in midi_checks] + [{"type": "midi_wired", "nets": list(nets), "fix": "midi_reconnect"}]
out.append({"ident": "midi_wired", "group": "SHOW", "label": "MIDI", "checks": checks,
"fixes": {"midi_reconnect": dict(SHARED_FIXES["midi_reconnect"])}, "restart": [],
"also": [i for i, _ in midi_checks]})
# A helper program (rtpMIDI) rides inside the key it serves when that key exists.
for it in [x for x in out if x.get("fold")]:
home = next((x for x in out if x["ident"] == it["fold"]), None)
if home is not None:
home["checks"] += it["checks"]
home["fixes"].update(it["fixes"])
home["also"].append(it["ident"])
home.setdefault("folded", {})[it["ident"]] = {"checks": it["checks"], "fixes": it["fixes"], "label": it["label"]}
out.remove(it)
return out, hosts
def computer_named(inv, name):
n = str(name or "").lower()
for alias, c in (inv.get("computers") or {}).items():
if n and n in (str(c.get("name", "")).lower(), str(c.get("hostname", "")).lower().replace(".local", "")):
return alias
return None
def newest_app(apps, pattern):
"""The newest /Applications entry matching a glob-ish pattern ("/Applications/ProPresenter*.app")."""
rx = re.compile("^" + re.escape(pattern).replace(r"\*", ".*") + "$")
hits = sorted(a for a in apps if rx.match(a))
return hits[-1] if hits else None
# --- layout ----------------------------------------------------------------------------
def free_cells(taken, rows=STATUS_ROWS):
return [(r, c) for r in range(rows) for c in range(1, COLS) if (r, c) not in taken]
def layout(cfg, new_tiles, restart_items):
"""Put the keys on the Status page: everything fits, so no group keys; otherwise group keys along
the top row and the members after them (a member with no room left shows inside its group only)."""
fixed = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
if cfg.get("restart_page"):
fixed.add(tuple(cfg["restart_page"]["jump_pos"]))
cells = free_cells(fixed)
groups = [g for g in GROUPS if any(t["_group"] == g for t in new_tiles)]
if len(new_tiles) > len(cells) and len(groups) > 1:
for g in groups:
members = [t["id"] for t in new_tiles if t["_group"] == g]
cfg["tiles"].append({"id": slug(g, {t["id"] for t in cfg["tiles"]} | {t["id"] for t in new_tiles}),
"label": g, "members": members, "_group": g})
group_tiles = [t for t in cfg["tiles"] if t.get("_group")]
for t in group_tiles:
if cells:
t["pos"] = list(cells.pop(0))
# Who gets a cell: by priority while cells last; then placed in group order, so a group reads as a block.
chosen = set()
for g in PRIORITY:
for t in new_tiles:
if t["_group"] == g and len(chosen) < len(cells):
chosen.add(t["id"])
for t in new_tiles:
if t["id"] in chosen:
t["pos"] = list(cells.pop(0))
cfg["tiles"].append(t)
for t in cfg["tiles"]:
t.pop("_group", None)
def place_new(cfg, tile):
"""One more key on a running location: the first free cell, else inside its group, else on ALL only."""
taken = {tuple(t["pos"]) for t in cfg["tiles"] if t.get("pos")}
if cfg.get("restart_page"):
taken.add(tuple(cfg["restart_page"]["jump_pos"]))
cells = free_cells(taken)
group = next((t for t in cfg["tiles"] if t.get("label") == tile.get("_group") and t.get("members") is not None), None)
if cells:
tile["pos"] = list(cells[0])
if group is not None:
group["members"].append(tile["id"])
tile.pop("_group", None)
cfg["tiles"].append(tile)
def restart_layout(cfg, restart_items):
"""The Restart page always exists (a location set up before its computers joined gets their keys later)."""
rp = cfg.get("restart_page")
if not rp:
return
taken = {tuple(rp["back_pos"])} | {tuple(k["pos"]) for k in rp.get("keys", [])}
cells = free_cells(taken)
ids = {k["id"] for k in rp.get("keys", [])}
for it in restart_items:
kid = slug("rs_" + it["label"], ids)
ids.add(kid)
if not cells:
break
rp.setdefault("keys", []).append({"id": kid, "label": key_text(it["label"]), "fix": it["fix"], "pos": list(cells.pop(0))})
# --- the two entry points --------------------------------------------------------------
def nets_of(inv):
w = (inv.get("ingest") or {}).get("wired") or {}
return [w["net"]] if w.get("net") else []
def build(base, inv, campus, pin, pages=(19, 20)):
"""A whole config for a new location: the template, this Mac, and a key for everything found."""
cfg = copy.deepcopy(base)
ing = inv.get("ingest") or {}
wired = ing.get("wired") or {}
site = cfg["site"]
site.update({"id": campus["id"], "name": campus["name"], "ingest_name": ing.get("name") or "%s Ingest" % campus["name"],
"ingest_hostname": ing.get("hostname") or "", "ingest_ips": [ip for ip in [wired.get("ip")] + list(ing.get("ips") or []) if ip],
"editing_code": pin})
site["ingest_ips"] = list(dict.fromkeys(site["ingest_ips"]))
cfg["discover"] = {"auto": True}
cfg["page"], cfg["page_name"] = pages[0], "%d\\nStatus" % pages[0]
cfg["restart_page"] = {"page": pages[1], "page_name": "%d\\nRestart" % pages[1], "jump_pos": [3, 6], "back_pos": [3, 0], "keys": []}
cfg["network"] = dict(cfg.get("network") or {}, interface=wired.get("interface") or "en0", gateway=wired.get("gateway") or "")
cfg["service_windows"] = [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}]
cfg["hosts"]["ingest"]["name"] = site["ingest_name"]
for t in cfg["tiles"]:
if t.get("kind") == "network":
t["pos"] = [3, 7]
found, hosts = items(inv, nets_of(inv))
cfg["hosts"].update(hosts)
tiles, restart, taken = [], [], {t["id"] for t in cfg["tiles"]}
for it in found:
tid = slug(it["label"], taken)
taken.add(tid)
tiles.append({"id": tid, "label": key_text(it["label"]), "checks": it["checks"], "_group": it["group"]})
for fid, f in it["fixes"].items():
cfg["fixes"].setdefault(fid, f)
restart += it["restart"]
tiles.sort(key=lambda t: GROUPS.index(t["_group"]))
layout(cfg, tiles, restart)
restart_layout(cfg, restart)
finish(cfg)
a = dict(DEFAULT_AUTO_FIX, **{k: v for k, v in (cfg.get("auto_fix") or {}).items() if k not in ("windows", "fixes")})
a["fixes"] = gentle(cfg)
cfg["auto_fix"] = a
return cfg, [it["ident"] for it in found] + ["host:" + h for h in hosts] + [x for it in found for x in it["also"]]
def extend(cfg, inv, seen):
"""What is new since the last look, added to a running location. Never removes or changes what is
there; a thing seen before (and so deliberately taken off by someone) is not added again.
Returns (new config, [what was added, in words], [identities now seen])."""
cfg = copy.deepcopy(cfg)
have = referenced(cfg)
skip = have | set(seen)
nets = nets_of(inv) or [n for t in sp.all_tiles(cfg) for c in t.get("checks", []) if c.get("type") == "midi_wired" for n in c.get("nets", [])]
found, hosts = items(inv, nets)
added, now_seen = [], []
for alias, h in hosts.items():
if "host:" + alias in skip or "hostip:" + h["ssh"].split("@")[-1] in have:
continue
cfg["hosts"][alias] = h
added.append("computer %s" % h["name"])
now_seen.append("host:" + alias)
taken = {t["id"] for t in sp.all_tiles(cfg)}
for it in found:
# A key that already exists for this thing: only its new parts (a Dante device, a MIDI peer).
if it["ident"] in skip:
fresh = [x for x in it["also"] if x not in skip]
if fresh:
added += merge_parts(cfg, it, fresh)
now_seen += fresh
continue
if any(c.get("params", {}).get("host") not in cfg["hosts"] for c in it["checks"] if c.get("template")):
continue
# A program whose Companion connection already has a key (Woonsocket's PROP got a second key):
# its own checks join that key.
home = next((t for t in cfg["tiles"] if any(("conn:" + c.get("conn", "")) in it["also"] for c in t.get("checks", []) if c.get("conn"))), None)
if home is not None:
for fid, f in it["fixes"].items():
cfg["fixes"].setdefault(fid, f)
home["checks"] += [c for c in it["checks"] if not c.get("conn")]
if it["restart"] and cfg.get("restart_page"):
restart_layout(cfg, it["restart"])
added.append("%s checks on the %s key" % (key_text(it["label"]), home["label"]))
now_seen += [it["ident"]] + it["also"]
continue
tid = slug(it["label"], taken)
taken.add(tid)
for fid, f in it["fixes"].items():
cfg["fixes"].setdefault(fid, f)
place_new(cfg, {"id": tid, "label": key_text(it["label"]), "checks": it["checks"], "_group": it["group"]})
if it["restart"] and cfg.get("restart_page"):
restart_layout(cfg, it["restart"])
added.append("%s key" % key_text(it["label"]))
now_seen += [it["ident"]] + it["also"]
if added:
finish(cfg)
auto = cfg.setdefault("auto_fix", dict(DEFAULT_AUTO_FIX, fixes=[]))
auto["fixes"] = list(dict.fromkeys(list(auto.get("fixes") or []) + [f for f in gentle(cfg) if f not in auto.get("fixes", [])
and any(f in it["fixes"] for it in found)]))
return cfg, added, now_seen
def merge_parts(cfg, it, fresh):
"""New Dante devices and MIDI peers go into the key that already watches the rest."""
words = []
for t in sp.all_tiles(cfg):
for c in t.get("checks", []):
if c.get("type") == "dante" and it["ident"] == "conn:" + c.get("conn", ""):
for x in fresh:
if x.startswith("dante:"):
name = x[6:]
c.setdefault("devices", {})[name] = key_text(name, 12)
words.append("Dante device %s" % name)
if it["ident"] == "midi_wired":
midi = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None)
if midi is not None:
wired_at = next(i for i, c in enumerate(midi["checks"]) if c.get("type") == "midi_wired")
for c in it["checks"]:
if c["type"] == "midi_peer" and "midi:%s|%s|%s" % (c.get("host", ""), c["session"], c["peer"]) in fresh:
midi["checks"].insert(wired_at, c)
wired_at += 1
words.append("MIDI link %s" % c["peer"])
cfg["fixes"].setdefault("midi_reconnect", dict(SHARED_FIXES["midi_reconnect"]))
# A helper program found later (Southcoast's Lighting PC joined after its MIDI key existed: rtpMIDI was
# marked seen but never added, 2026-10-21) joins the key it rides in.
for ident, part in (it.get("folded") or {}).items():
if ident not in fresh:
continue
home = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None) \
if it["ident"] == "midi_wired" else None
if home is None:
continue
at = next((i for i, c in enumerate(home["checks"]) if c.get("type") == "midi_wired"), len(home["checks"]))
home["checks"][at:at] = [c for c in part["checks"] if c not in home["checks"]]
for fid, f in part["fixes"].items():
cfg["fixes"].setdefault(fid, f)
words.append("%s checks on the %s key" % (key_text(part["label"]), home["label"]))
return words
def keep_from(new, old):
"""A rebuild keeps what is down right now and so was not found: MIDI links that dropped (Southcoast's
ProPresenter to Lighting lost its MIDI key, 2026-10-21) and computers that are off, with their keys."""
cfg = copy.deepcopy(new)
have = referenced(cfg)
kept = []
# Switch keys made for this location by hand (Southcoast's LIGHTS) and an AUTO-FIX key: auto-setup makes neither.
for t in old.get("tiles", []):
if t.get("kind") not in ("bypass", "autofix") or t["id"] in {x["id"] for x in cfg["tiles"]}:
continue
if t["kind"] == "autofix" and any(x.get("kind") == "autofix" for x in cfg["tiles"]):
continue
used = {tuple(x["pos"]) for x in cfg["tiles"] if x.get("pos")} | (
{tuple(cfg["restart_page"]["jump_pos"])} if cfg.get("restart_page") else set())
if t.get("pos") and tuple(t["pos"]) not in used:
cfg["tiles"].append(copy.deepcopy(t))
else:
place_new(cfg, {k: v for k, v in copy.deepcopy(t).items() if k != "pos"})
kept.append(t["label"])
# Computers in the old setup that did not answer this time, and the keys that check them.
gone = {a: h for a, h in (old.get("hosts") or {}).items() if not h.get("self") and a not in cfg["hosts"]}
for alias, h in gone.items():
cfg["hosts"][alias] = h
for t in old.get("tiles", []):
if t.get("checks") and all(c.get("host") == alias or (c.get("params") or {}).get("host") == alias for c in t["checks"]) \
and t["id"] not in {x["id"] for x in cfg["tiles"]}:
for c in t["checks"]:
for fid in ([c["fix"]] if isinstance(c.get("fix"), str) else []):
if fid in old.get("fixes", {}):
cfg["fixes"].setdefault(fid, old["fixes"][fid])
place_new(cfg, {k: v for k, v in t.items() if k != "pos"})
kept.append(t["label"])
for k in (old.get("restart_page") or {}).get("keys", []):
f = old.get("fixes", {}).get(k.get("fix"), {})
if (f.get("host") == alias or (f.get("params") or {}).get("host") == alias) and cfg.get("restart_page"):
cfg["fixes"].setdefault(k["fix"], f)
restart_layout(cfg, [{"label": k["label"], "fix": k["fix"]}])
# MIDI links that are down now.
lost = [c for t in old.get("tiles", []) for c in t.get("checks", []) if c.get("type") == "midi_peer"
and "midi:%s|%s|%s" % (c.get("host", ""), c["session"], c["peer"]) not in have and (not c.get("host") or c["host"] in cfg["hosts"])]
if lost:
midi = next((t for t in cfg["tiles"] if any(c.get("type") in ("midi_peer", "midi_wired") for c in t.get("checks", []))), None)
if midi is None:
wired = [c for t in old.get("tiles", []) for c in t.get("checks", []) if c.get("type") == "midi_wired"]
midi = {"id": slug("midi", {t["id"] for t in cfg["tiles"]}), "label": "MIDI", "checks": wired[:1], "_group": "SHOW"}
place_new(cfg, midi)
midi = cfg["tiles"][-1]
at = next((i for i, c in enumerate(midi["checks"]) if c.get("type") == "midi_wired"), len(midi["checks"]))
midi["checks"][at:at] = lost
cfg["fixes"].setdefault("midi_reconnect", dict(SHARED_FIXES["midi_reconnect"]))
if "midi_reconnect" not in cfg["auto_fix"]["fixes"]:
cfg["auto_fix"]["fixes"].append("midi_reconnect")
kept += ["MIDI link %s" % c["peer"] for c in lost]
return cfg, kept
def finish(cfg):
"""Fixes and remote checks built by their builders, words filled in, as the page would."""
for fid, f in list(cfg["fixes"].items()):
if f.get("template") and "run" not in f and b.TEMPLATES.get(f["template"], {}).get("render"):
built = b.build(cfg, f)
built.setdefault("label", b.suggest_label(cfg, built) or fid)
short = key_text(fid.replace("restart_", "").split("_")[0], 10)
built.setdefault("short", short)
cfg["fixes"][fid] = built
elif f.get("template") and not f.get("label"):
f["label"] = b.suggest_label(cfg, f) or fid
for t in cfg["tiles"]:
for i, c in enumerate(t.get("checks", [])):
if c.get("template"):
t["checks"][i] = b.build_check(cfg, c)
def gentle(cfg):
"""The fixes auto-fix may run by itself: starting what is closed and reconnecting - never a restart."""
out = []
for fid, f in cfg["fixes"].items():
if fid.startswith("restart_") or f.get("restart_ok") or f.get("never_auto"):
continue
if f.get("kind") in ("hyperdeck", "midi_connect", "reconnect", "propresenter_port") or (f.get("template") in ("mac_start", "win_start", "ableton")
and not (f.get("params") or {}).get("restart")):
out.append(fid)
return out
+60 -5
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)
@@ -57,6 +64,22 @@ def mac_quit(process, wait_seconds, force, stop_children=False):
return s
def newest_app(pattern):
"""APP=... the newest installed copy by its own version number (CFBundleShortVersionString), not by
file name: "ProPresenter.app" sorts after "ProPresenter 20.1.app" by name (2026-10-21, before the
Southcoast/Woonsocket ProPresenter update)."""
return ("APP=$(for a in %s; do [ -d \"$a\" ] && printf '%%s|%%s\\n' \"$(defaults read \"$a/Contents/Info\" "
"CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); "
% str(pattern).strip())
def app_match(pattern, process):
"""A pgrep -f pattern for the program from any copy matching an /Applications glob (never its helpers)."""
import re as _re
parts = [_re.escape(x) for x in str(pattern).split("*")]
return "[^/]*".join(parts).replace("\\ ", " ") + "/Contents/MacOS/" + _re.escape(process).replace("\\ ", " ") + "$"
def mac_open(app, background=False, arg=None):
"""open -a Name / open /path/App.app (+ -g to stay in the background)."""
app = str(app)
@@ -102,8 +125,10 @@ TEMPLATES = {
"words": "Start a Mac program if it is not running",
"os": "mac", "start_only": True,
"params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None),
("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)],
"render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])),
("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None),
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
"render": lambda p, cfg: ("pgrep -xq %s || { %s%s; }" % (q(p["process"]), newest_app(p["newest_of"]), mac_open("$APP"))
if p.get("newest_of") else "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"]))),
"suggest": lambda p, cfg: "start %s if it is not running" % p["app"],
},
"mac_restart": {
@@ -116,7 +141,7 @@ TEMPLATES = {
("background", "Open it in the background", "No window comes to the front.", "bool", False),
("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False),
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
"render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "")
"render": lambda p, cfg: (newest_app(p["newest_of"]) if p.get("newest_of") else "")
+ mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False))
+ mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)),
"suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]),
@@ -212,6 +237,8 @@ TEMPLATES = {
"suggest": lambda p, cfg: "reconnect Companion to it"},
"hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"},
"propresenter_port": {"words": "Follow ProPresenter's network port (when it changes) and reconnect Companion", "kind": "propresenter_port",
"start_only": True, "params": [], "render": None, "suggest": lambda p, cfg: "find ProPresenter's network port and reconnect Companion to it"},
"midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"},
"atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None,
@@ -221,6 +248,17 @@ TEMPLATES = {
"spotify": {"words": "Play the service playlist in Spotify", "kind": "spotify", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "play the %s playlist in Spotify on %s (opens Spotify if it is closed)"
% ((cfg.get("spotify") or {}).get("name") or "service", (cfg.get("site") or {}).get("ingest_name") or "this Mac")},
# a press: "copy" names the params that are also fields of the fix itself (the watchdog reads those)
"companion_press": {
"words": "Press a Companion button (any page, row and column)", "kind": "companion_press", "start_only": True,
"params": [("page", "Page", "The Companion page the button is on (page 4 = Audio).", "int", None),
("row", "Row", "Top row is 0.", "int", None),
("col", "Column", "Left column is 0.", "int", None),
("button", "What the button says", "Only for the words: FOH Backup, say.", "str", "")],
"copy": ["page", "row", "col"], "render": None,
"suggest": lambda p, cfg: "press the %sbutton in Companion (page %s, row %s, column %s)"
% (("%s " % p["button"]) if p.get("button") else "", p["page"], p["row"], p["col"]),
},
"custom": {"words": "Custom command (Advanced: typed by hand)", "params": [], "render": None, "suggest": lambda p, cfg: ""},
}
@@ -259,6 +297,9 @@ def build(cfg, fix):
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
out = {k: v for k, v in fix.items() if k not in ("run", "host", "kind")}
out["template"], out["params"] = name, params
for n in t.get("copy", []):
words = next(w for a, w, _, _, _ in t["params"] if a == n)
out[n] = params[n] = whole(params.get(n), "%s: %s" % (t["words"], words.lower()))
if t.get("kind"):
out["kind"] = t["kind"]
return out
@@ -301,6 +342,17 @@ CHECK_TEMPLATES = {
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None)],
"render": lambda p, cfg: "pgrep -xq %s" % q(p["process"]),
},
"win_program": {
"words": "A program is running on a Windows PC", "type": "remote",
"params": [P_HOST, ("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
"render": lambda p, cfg: win_running(win(p["exe"], "program file")),
},
"soundgrid": {
"words": "SuperRack is connected to its SoundGrid server", "type": "remote",
"params": [P_HOST],
# SuperRack keeps a control link to the Waves server (port 19000, over the SoundGrid network).
"render": lambda p, cfg: "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'",
},
"helper_flag": {
"words": "A helper file does not say something is wrong", "type": "remote",
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
@@ -362,8 +414,11 @@ def mismatches(cfg):
except ValueError as e:
out.append("fix %s: %s" % (fid, e))
continue
for k in ("run", "kind", "host"):
if (b.get(k) or None) != (fix.get(k) or None):
for k in ("run", "kind", "host") + tuple(TEMPLATES[fix["template"]].get("copy", [])):
bv, fv = b.get(k), fix.get(k)
if k in ("run", "kind", "host"):
bv, fv = bv or None, fv or None # a blank and an absent command are the same
if bv != fv:
out.append("fix %s: its %s no longer matches its fields - open it on Fixes and press Save" % (fid, "command" if k == "run" else k))
break
for t in cfg.get("tiles") or []:
+641
View File
@@ -0,0 +1,641 @@
"""A location that sets itself up.
On the location's Mac that runs Companion, one line installs the panel from another location's settings
page and runs `setup`: Companion gets the panel's custom variables and blink clock, two free pages
are picked, the gear is found (settings/discover.py) and turned into keys (settings/autoconf.py),
and that config is applied like any Apply on the page. Each other computer runs one join line
(Terminal on a Mac, an administrator PowerShell on a PC); from then on the settings page looks again
every ten minutes and adds what is new by itself (Settings > Site: "Add new gear by itself").
python3 -m settings.location setup --id ab [--name "Apollo Beach"]
python3 -m settings.location look what it finds (nothing changes)
python3 -m settings.location add add what is new now
"""
import argparse
import base64
import gzip
import hashlib
import io
import json
import os
import random
import re
import tarfile
import threading
import time
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
from settings import autoconf, discover, scripts
import push_page
import schema
import status_panel as sp
CAMPUSES = {"na": "North Attleboro", "ab": "Apollo Beach", "sc": "Southcoast", "ws": "Woonsocket"}
VARIABLES = ("sp_state", "sp_hb", "sp_cmd", "flash_tick")
TRIGGER_NAME = "flash_tick_500"
LOOK_EVERY = 600
SEEN, LAST = "discover-seen.json", "discover-last.json"
# What the program kit carries (no settings, keys or history of the location that serves it).
KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py",
"nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json")
KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup")
# --- Companion: the panel's variables, its blink clock, and two free pages ---------------
def flash_trigger_export(build="5.0.7", name=TRIGGER_NAME, enabled=True):
"""A Companion export holding one trigger: flash_tick 1 for half a second, every second."""
def nid():
return base64.urlsafe_b64encode(os.urandom(15)).decode().rstrip("=")[:21]
def setvar(v):
return {"id": nid(), "definitionId": "custom_variable_set_value", "connectionId": "internal",
"options": {"name": {"isExpression": False, "value": "flash_tick"}, "value": {"isExpression": False, "value": v}},
"type": "action", "children": {}}
tid = nid()
trig = {"type": "trigger", "options": {"name": name, "enabled": enabled, "sortOrder": 0},
"actions": [setvar("1"), {"id": nid(), "definitionId": "wait", "connectionId": "internal",
"options": {"time": {"isExpression": False, "value": "500"}}, "type": "action", "children": {}}, setvar("0")],
"condition": [], "events": [{"id": nid(), "type": "interval", "enabled": True, "options": {"seconds": 1}}], "localVariables": []}
return tid, {"version": 12, "type": "full", "companionBuild": build, "triggers": {tid: trig}, "triggerCollections": [],
"custom_variables": {}, "customVariablesCollections": [], "instances": {}, "connectionCollections": []}
def companion_state(cfg):
raw = discover.companion_get(cfg, "/int/export/custom?buttons=false&connections=false&surfaces=&triggers=true&customVariables=true"
"&expressionVariables=false&includeSecrets=false&imageLibrary=false&format=json", 10)
d = json.loads(raw)
names = {(t.get("options") or {}).get("name") for t in (d.get("triggers") or {}).values()}
return set(d.get("custom_variables") or {}), names
def companion_prepare(cfg):
"""Create what the pages need inside Companion and is otherwise made by hand in its UI. Returns words."""
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
have_vars, triggers = companion_state(cfg)
done = []
missing = [v for v in VARIABLES if v not in have_vars]
if not missing and TRIGGER_NAME in triggers:
return done
ws = push_page.WebSocket(host, port, "/trpc")
try:
api = push_page.Trpc(ws)
for v in missing:
api.mutation("customVariables.create", {"name": v, "defaultVal": "0" if v == "flash_tick" else ""})
done.append("custom variable %s" % v)
if TRIGGER_NAME not in triggers:
tid, export = flash_trigger_export()
data = gzip.compress(json.dumps(export).encode())
session = api.mutation("importExport.prepareImport.start", {"name": "status-panel-blink.companionconfig", "size": len(data)})
api.mutation("importExport.prepareImport.uploadChunk", {"sessionId": session, "offset": 0, "data": base64.b64encode(data).decode()})
summary = api.mutation("importExport.prepareImport.complete", {"sessionId": session, "expectedChecksum": hashlib.sha1(data).hexdigest(), "userData": None})
if isinstance(summary, list) and summary and summary[0]:
raise RuntimeError("Companion could not read the blink trigger: %s" % summary[0])
api.mutation("importExport.importTriggers", {"selectedTriggerIds": [tid], "connectionIdRemapping": {}, "replaceExisting": False})
done.append("the %s trigger (the keys' blink clock)" % TRIGGER_NAME)
finally:
ws.close()
return done
def pick_pages(cfg, count=2, start=19):
"""Pages that hold nothing a person made (or only this panel's keys): the first `count` from `start`."""
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
out = []
for page in list(range(start, 100)) + list(range(2, start)):
try:
ok, _ = push_page.safe_target(push_page.fetch_page(host, port, page))
except (OSError, ValueError):
continue
if ok:
out.append(page)
if len(out) == count:
return out
raise RuntimeError("Companion has no %d empty pages for the status panel" % count)
# --- the first setup --------------------------------------------------------------------
def template(data_dir):
for path in (os.path.join(data_dir, "kit", "sites", "_template", "config.json"),
os.path.join(PARENT, "sites", "_template", "config.json")):
if os.path.exists(path):
with open(path) as f:
return json.load(f)
raise RuntimeError("no sites/_template/config.json to start from")
def is_fresh(cfg):
return (cfg.get("site") or {}).get("id") in ("new", "", None) and len(cfg.get("tiles") or []) <= 3
def load_seen(data_dir):
try:
with open(os.path.join(data_dir, SEEN)) as f:
return set(json.load(f))
except (OSError, ValueError):
return set()
def save_seen(data_dir, seen):
sp.write_json(os.path.join(data_dir, SEEN), sorted(seen))
def wait_applied(applier, limit=240):
t0 = time.time()
while time.time() - t0 < limit:
st = applier.status or {}
if st.get("state") not in ("running",):
return st
time.sleep(1)
return applier.status or {}
def companion_version_problem(cfg, standalone=False):
"""None when this Companion can take the panel (5.x), else what to tell the person."""
try:
discover.companion_get(cfg, "/api/connections", 5)
return None
except OSError:
pass
try:
discover.companion_get(cfg, "/socket.io/?EIO=4&transport=polling", 5)
if standalone:
return ("This Companion is older than version 5. The panel needs Companion 5.0.7: download it for Mac from\n"
" https://bitfocus.io/companion (5.0.7, the .dmg), then on this Mac run\n"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion --from ~/Downloads/<the .dmg>\n"
"(its settings come along; the old version stays installed), then run the installer again.")
return ("This Companion is older than version 5 (Woonsocket ran 4.0.3). The panel needs 5.0.7, as North "
"Attleboro runs. Upgrade it from NA's copy (its settings come along; the old version stays installed):\n"
" cd \"$HOME/Library/Application Support/status-panel\" && /usr/bin/python3 -m settings.location upgrade-companion\n"
"then paste the install line again.")
except OSError:
return "Companion does not answer on port 8000: open Companion (and its HTTP API, Settings > Protocols), then paste the line again."
def setup(data_dir, cid, name=None, say=print, home=None, standalone=False):
"""The first setup of a location (or, when it is set up already, a look for what is new).
Standalone: it reports to nobody and nobody else sees it (Apollo Beach runs its own)."""
from settings import server, campuses
if home or standalone: # where it was installed from: the location list lives there
data = campuses.load(data_dir)
if standalone:
data.pop("home", None)
data["standalone"] = True
else:
data["home"] = home
data.pop("standalone", None)
campuses.save(data_dir, data)
site = server.Site(data_dir)
cfg = site.load()
if not is_fresh(cfg):
say("%s is set up already: looking for what is new." % (cfg.get("site") or {}).get("name", "This location"))
return look_and_add(site, say=say, force=True)
name = name or CAMPUSES.get(cid) or cid.upper()
problem = companion_version_problem(cfg, standalone)
if problem:
raise RuntimeError(problem)
for line in companion_prepare(cfg):
say("Companion: added %s" % line)
pages = pick_pages(cfg)
say("Companion pages %d (status) and %d (restart) are free: using them." % tuple(pages))
inv = discover.look(cfg, data_dir)
if inv.get("companion_error"):
raise RuntimeError("Companion's HTTP API does not answer (%s): turn it on in Companion's settings, then run this again" % inv["companion_error"])
pin = "%04d" % random.SystemRandom().randrange(1000, 10000)
new, seen = autoconf.build(template(data_dir), inv, {"id": cid, "name": name}, pin, pages)
errors, _ = schema.validate(new)
if errors:
raise RuntimeError("the config auto-setup made does not pass the rules: %s" % "; ".join(errors[:4]))
r = site.applier.start(new, "auto-setup: first setup of %s" % name, "auto-setup", "127.0.0.1")
if not r.get("ok"):
raise RuntimeError(r.get("error") or "the Apply did not start")
st = wait_applied(site.applier)
save_seen(data_dir, seen)
keys = [t["label"] for t in new["tiles"] if not t.get("kind")]
record(data_dir, {"at": int(time.time()), "added": ["%d keys: %s" % (len(keys), ", ".join(keys))], "inventory": summary(inv)})
say("%s: %s" % (st.get("state"), st.get("outcome", "")))
say("Keys: %s" % ", ".join(keys))
say("Editing code (PIN) for the settings page: %s" % pin)
return new
def rebuild(data_dir, say=print, pages=None):
"""A location's setup made again from what is found now (today's recipes), keeping its name, PIN,
pages and site details. Applied like any Apply (it waits for a recording; history keeps the old one)."""
from settings import server
site = server.Site(data_dir)
cfg = site.load()
s = cfg.get("site") or {}
inv = discover.look(cfg, data_dir)
if inv.get("companion_error"):
raise RuntimeError("Companion does not answer: %s" % inv["companion_error"])
rp = (cfg.get("restart_page") or {}).get("page")
if not rp:
rp = next(p for p in pick_pages(cfg, 3) if p != cfg["page"])
pages = tuple(pages or (cfg["page"], rp))
if pages != (cfg["page"], rp): # moving (19/20 like every location): only onto pages that are free or ours
comp = cfg.get("companion") or {}
for p in pages:
ok, why = push_page.safe_target(push_page.fetch_page(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), p))
if not ok:
raise RuntimeError("page %d: %s" % (p, why))
new, seen = autoconf.build(template(data_dir), inv, {"id": s.get("id"), "name": s.get("name")}, s.get("editing_code"), pages)
new, kept = autoconf.keep_from(new, cfg)
if kept:
say("Kept (down right now, so not found): %s" % ", ".join(kept))
for k in ("contacts", "live_services", "ingest_name", "settings_port"):
if k in s:
new["site"][k] = s[k]
new["discover"] = dict(new.get("discover") or {}, **(cfg.get("discover") or {}))
errors, _ = schema.validate(new)
if errors:
raise RuntimeError("the rebuilt config does not pass the rules: %s" % "; ".join(errors[:4]))
r = site.applier.start(new, "auto-setup: rebuilt with today's recipes", "auto-setup", "127.0.0.1")
if not r.get("ok"):
raise RuntimeError(r.get("error") or "the Apply did not start")
st = wait_applied(site.applier)
save_seen(data_dir, load_seen(data_dir) | set(seen))
say("%s: %s" % (st.get("state"), st.get("outcome", "")))
if st.get("state") == "done":
for p in sorted({cfg["page"], rp} - set(pages)):
say("Old page %d: %s" % (p, clear_old_page(cfg, p)))
say("Keys: %s" % ", ".join(t["label"] for t in new["tiles"] if not t.get("kind")))
say("Restart page %s: %s" % (new.get("restart_page", {}).get("page"), ", ".join(k["label"] for k in new.get("restart_page", {}).get("keys", [])) or "no keys yet"))
return new
def clear_old_page(cfg, page):
"""A page left behind by a move is emptied, but only when it holds nothing a person made."""
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
ok, why = push_page.safe_target(push_page.fetch_page(host, port, page))
if not ok:
return "left as it is (%s)" % why
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.clearPage", {"pageNumber": page})
finally:
ws.close()
return "cleared"
def upgrade_companion(base, say=print, source=None):
"""Companion 5.0.7 from `base` (North Attleboro's settings page), as Woonsocket was upgraded on 2026-10-20:
the running Companion is quit, the newest settings folder is copied for 5.0 (and marked newest, or the
launcher stops on "Config version mismatch"), 5.0.7 is installed beside the old one and started."""
import glob
import shutil
import subprocess
import urllib.request
root = os.path.expanduser("~/Library/Application Support/companion")
app = "/Applications/Companion 5.0.7.app"
# The watchdog's CompanionKeeper must not reopen the old Companion in the middle of this.
with open(os.path.join(PARENT, "companion-upgrading"), "w") as f:
f.write(str(time.time()))
if not os.path.exists(app) and source: # standalone: the .dmg (or .zip) downloaded from bitfocus.io
install_companion_from(source, app, say)
if not os.path.exists(app):
say("Downloading Companion 5.0.7 from %s ..." % base)
tmp = "/tmp/companion-5.0.7.zip"
with urllib.request.urlopen(base.rstrip("/") + "/campus/companion.zip", timeout=600) as r, open(tmp, "wb") as f:
shutil.copyfileobj(r, f)
subprocess.run(["/usr/bin/ditto", "-x", "-k", tmp, "/Applications/"], check=True)
os.remove(tmp)
subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", app], check=True)
dbs = sorted(glob.glob(os.path.join(root, "v*", "db.sqlite")), key=os.path.getmtime)
if not os.path.exists(os.path.join(root, "v5.0", "db.sqlite")):
if not dbs:
raise RuntimeError("no Companion settings found to bring along")
newest = os.path.dirname(dbs[-1])
subprocess.run(["/usr/bin/pkill", "-TERM", "-f", "Companion.*\\.app/Contents/MacOS/Companion"])
for _ in range(40):
if subprocess.run(["/usr/bin/pgrep", "-f", "Companion.*\\.app/Contents/MacOS/Companion"], capture_output=True).returncode:
break
time.sleep(0.5)
shutil.copytree(newest, os.path.join(root, "v5.0"))
say("Settings copied from %s to v5.0 (the old folder stays as it was)" % os.path.basename(newest))
for f in ("db.sqlite", "cache.sqlite"):
p = os.path.join(root, "v5.0", f)
if os.path.exists(p):
os.utime(p)
subprocess.run(["/usr/bin/open", "-g", app], check=True)
for _ in range(90):
try:
discover.companion_get({"companion": {}}, "/api/connections", 2)
say("Companion 5.0.7 is running. In System Settings > General > Login Items, replace the old Companion with 5.0.7.")
try:
os.remove(os.path.join(PARENT, "companion-upgrading"))
except OSError:
pass
return True
except OSError:
time.sleep(1)
raise RuntimeError("Companion 5.0.7 did not start within 90 s: look at its window on this Mac")
def install_companion_from(source, app, say=print):
"""Companion copied from a downloaded .dmg or .zip to `app` (beside any older one)."""
import shutil
import subprocess
import tempfile
source = os.path.expanduser(source)
tmp = tempfile.mkdtemp(prefix="companion-")
try:
if source.lower().endswith(".dmg"):
mnt = os.path.join(tmp, "mnt")
subprocess.run(["/usr/bin/hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", mnt, source], check=True, capture_output=True)
try:
found = [n for n in os.listdir(mnt) if n.startswith("Companion") and n.endswith(".app")]
if not found:
raise RuntimeError("no Companion app inside %s" % source)
subprocess.run(["/usr/bin/ditto", os.path.join(mnt, found[0]), app], check=True)
finally:
subprocess.run(["/usr/bin/hdiutil", "detach", mnt], capture_output=True)
else:
subprocess.run(["/usr/bin/ditto", "-x", "-k", source, tmp], check=True)
found = [n for n in os.listdir(tmp) if n.startswith("Companion") and n.endswith(".app")]
if not found:
raise RuntimeError("no Companion app inside %s" % source)
subprocess.run(["/usr/bin/ditto", os.path.join(tmp, found[0]), app], check=True)
say("Companion installed as %s" % app)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def summary(inv):
known = set(autoconf.COMPANION)
return {"connections": len(inv.get("connections") or []),
"not_known": sorted("%s (%s)" % (c["label"], c["module"]) for c in inv.get("connections") or [] if c["module"] not in known and c["module"] != "internal"),
"dante": len((inv.get("dante") or {}).get("devices") or []),
"computers": {a: {"name": c.get("name"), "os": c.get("os"), "ip": c.get("ip"), "reachable": bool(c.get("reachable")),
"error": c.get("error", "")} for a, c in (inv.get("computers") or {}).items()},
"midi_sessions": sum(len(s or {}) for s in (inv.get("midi") or {}).values()),
"wired": ((inv.get("ingest") or {}).get("wired") or {}).get("net")}
def record(data_dir, result):
sp.write_json(os.path.join(data_dir, LAST), result)
# --- every ten minutes: what is new ------------------------------------------------------
def blocked(cfg, applier, now=None, asked=False):
"""Why auto-setup may not apply right now, or None: never during a recording; by itself, never in the
auto-fix hours either (a person asking - Look now, setting up on site - may)."""
now = now or time.time()
live, why = applier.live()
if live:
return "live now (%s)" % why
if not asked and sp.in_windows((cfg.get("auto_fix") or {}).get("windows") or [], now):
return "inside the auto-fix hours (a service day): waits until they end"
if applier.busy():
return "an Apply is running"
return None
def look_and_add(site, say=lambda s: None, force=False):
"""Look, and add what is new (when the location allows it, and not during a service)."""
cfg = site.load()
inv = discover.look(cfg, site.data_dir)
seen = load_seen(site.data_dir)
new, added, now_seen = autoconf.extend(cfg, inv, seen)
result = {"at": int(time.time()), "added": [], "waiting": [], "inventory": summary(inv)}
if added:
auto = (cfg.get("discover") or {}).get("auto")
why = blocked(cfg, site.applier, asked=force)
if not (auto or force):
result["waiting"] = added
result["why"] = "adding by itself is off (Settings > Site)"
elif why:
result["waiting"] = added
result["why"] = why
else:
errors, _ = schema.validate(new)
if errors:
result["waiting"], result["why"] = added, "would not pass the rules: %s" % "; ".join(errors[:3])
else:
r = site.applier.start(new, "auto-setup found: %s" % ", ".join(added)[:180], "auto-setup", "127.0.0.1")
if r.get("ok"):
st = wait_applied(site.applier)
if st.get("state") == "done":
save_seen(site.data_dir, seen | set(now_seen))
result["added"] = added
else:
result["waiting"], result["why"] = added, st.get("outcome") or "the Apply did not finish"
else:
result["waiting"], result["why"] = added, r.get("error", "the Apply did not start")
record(site.data_dir, result)
say("added: %s" % (", ".join(result["added"]) or "nothing"))
if result["waiting"]:
say("waiting: %s (%s)" % (", ".join(result["waiting"]), result.get("why")))
return result
def loop(site, stop, poke):
"""The settings page's own background look: every ten minutes, and soon after a computer joins."""
stop.wait(60)
while not stop.is_set():
try:
if (site.load().get("discover") or {}).get("auto") and not is_fresh(site.cfg):
look_and_add(site)
except Exception as e: # noqa: BLE001 - the page must keep running
record(site.data_dir, dict(load_last(site.data_dir), error=str(e), at=int(time.time())))
poke.wait(LOOK_EVERY)
if poke.is_set():
poke.clear()
stop.wait(20) # a computer just joined: give its SSH a moment
def load_campuses_home(data_dir):
try:
with open(os.path.join(data_dir, "campuses.json")) as f:
return json.load(f).get("home")
except (OSError, ValueError):
return None
def load_last(data_dir):
try:
with open(os.path.join(data_dir, LAST)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
# --- the lines people run ----------------------------------------------------------------
def program_kit(data_dir):
"""The program as a .tgz: the installed code, the settings page, the templates, install.sh."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
for f in KIT_FILES:
p = os.path.join(data_dir, f)
if os.path.exists(p):
tar.add(p, f)
for d in KIT_DIRS:
p = os.path.join(data_dir, d)
if os.path.isdir(p):
tar.add(p, d, filter=lambda ti: None if "__pycache__" in ti.name or ti.name.endswith(".pyc") else ti)
kit = os.path.join(data_dir, "kit")
for name in ("install.sh", "README.md"):
p = os.path.join(kit, name)
if os.path.exists(p):
tar.add(p, name)
p = os.path.join(kit, "sites", "_template")
if os.path.isdir(p):
tar.add(p, "sites/_template")
return buf.getvalue()
def shq(s):
return "'" + str(s).replace("'", "'\"'\"'") + "'"
def install_script(base, cid, support_key=None):
"""What a new location's Companion Mac runs: install from `base` (this page), then set itself up."""
m = re.match(r"[a-z0-9]+", str(cid).lower())
cid = m.group(0)[:8] if m else "new"
name = CAMPUSES.get(cid, cid.upper())
key = support_key or ""
return """#!/bin/zsh
# Status panel for %(name)s: installs on this Mac (the one that runs Companion) and sets itself up.
# Safe to run again: it then updates the program and looks for new gear.
set -e
SRC=%(base)s
if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
echo "First the Command Line Tools: a window opens - click Install, wait for it, then run this line again."
/usr/bin/xcode-select --install 2>/dev/null || true
exit 1
fi
DIR="$HOME/status-panel-install"
mkdir -p "$DIR" && cd "$DIR"
echo "Downloading the status panel from $SRC ..."
/usr/bin/curl -fsSL "$SRC/location/program.tgz" | /usr/bin/tar -xz
[[ -f "$HOME/.ssh/id_status_panel" ]] || { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" && /usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName)" -f "$HOME/.ssh/id_status_panel"; }
SUPPORT=%(key)s
if [[ -n "$SUPPORT" ]]; then
touch "$HOME/.ssh/authorized_keys" && chmod 600 "$HOME/.ssh/authorized_keys"
grep -qF "$SUPPORT" "$HOME/.ssh/authorized_keys" || echo "$SUPPORT" >> "$HOME/.ssh/authorized_keys"
fi
zsh install.sh --site %(cid)s
cd "$HOME/Library/Application Support/status-panel"
/usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --home "$SRC"
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
echo
echo "One more thing: turn on Remote Login (System Settings > General > Sharing), so North Attleboro can help remotely."
fi
# This Mac's wired address (the default route's port, not en0: at Southcoast en0 is a Dante-style link).
IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}')
ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname)
echo
echo "Next, on each other computer here (ProPresenter, Tracks, Lighting...):"
echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh"
echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)"
echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex"
""" % {"name": name, "base": shq(base), "key": shq(key), "cid": cid, "qname": shq(name)}
def join_mac(cfg, base):
key, _ = scripts.public_key(cfg)
if not key:
raise RuntimeError("this Mac has no login key yet (run the install line again)")
ingest = (cfg.get("site") or {}).get("ingest_name") or "the Companion Mac"
return """#!/bin/sh
# Joins this Mac to %(ingest)s's status panel: lets it log in with its key (no password) and says this Mac is here.
KEY=%(key)s
# Run as root (Apple Remote Desktop > Send UNIX Command): it is for whoever is logged in on this Mac.
U=$(id -un); H="$HOME"
if [ "$(id -u)" = 0 ]; then
U=$(stat -f%%Su /dev/console)
[ "$U" = root ] && { echo "Nobody is logged in on $(scutil --get ComputerName): log in, then send this again."; exit 1; }
H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}')
fi
mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys"
grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys"
for A in %(admin)s; do grep -qF "$A" "$H/.ssh/authorized_keys" || echo "$A" >> "$H/.ssh/authorized_keys"; done
[ "$(id -u)" = 0 ] && chown -R "$U:staff" "$H/.ssh"
[ "$(id -u)" = 0 ] && { /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1 || /bin/launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1; }
if ! /usr/bin/nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1; then
echo "Remote Login is off. Turning it on (it asks for this Mac's administrator password):"
if ! sudo /usr/sbin/systemsetup -setremotelogin on >/dev/null 2>&1; then
echo "Please turn it on in System Settings > General > Sharing > Remote Login, then paste this line again."
open "x-apple.systempreferences:com.apple.Sharing-Settings.extension" 2>/dev/null
exit 1
fi
fi
esc() { printf '%%s' "$1" | sed 's/[\\\\"]//g'; }
IPS=$(/sbin/ifconfig | awk '/inet /{print $2}' | grep -v '^127\\.' | sed 's/.*/"&"/' | paste -sd, -)
BODY="{\\"user\\":\\"$(esc "$U")\\",\\"name\\":\\"$(esc "$(scutil --get ComputerName)")\\",\\"hostname\\":\\"$(esc "$(scutil --get LocalHostName).local")\\",\\"os\\":\\"mac\\",\\"ips\\":[$IPS]}"
if /usr/bin/curl -fsS -X POST -H 'Content-Type: application/json' -d "$BODY" %(hello)s >/dev/null; then
echo "Joined. %(ingest)s sets this Mac up in about a minute: watch its status page."
else
echo "Could not reach %(ingest)s at %(base)s - is this Mac on the wired network?"
exit 1
fi
""" % {"ingest": ingest, "key": shq(key), "hello": shq(base + "/api/hello"), "base": base,
"admin": " ".join(shq(k) for k in scripts.admin_keys(cfg))}
def join_windows(cfg, base):
"""The Windows set-up script, with the programs found running on that PC instead of a list."""
text = scripts.fill(scripts.template("windows-setup.ps1.template"), dict(
scripts.site_values(cfg), PC_NAME="this PC", APPS=" # filled in below", PROGRAMS="the ones running now",
SERVICES="'rtpMIDIService'"))
known = "; ".join("'%s' = '%s'" % (a["exe"], a["task"].replace("Start ", "", 1)) for a in autoconf.WIN_APPS)
detect = ("$known = @{ %s }\n"
"$apps = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $known.ContainsKey((Split-Path $_.Path -Leaf)) } |\n"
" Sort-Object Path -Unique | ForEach-Object { @{ Name = $known[(Split-Path $_.Path -Leaf)]; Exe = $_.Path } })" % known)
text = re.sub(r"\$apps = @\(\n # filled in below\n\)", lambda m: detect, text)
if "$known = @{" not in text:
raise RuntimeError("the Windows template changed: cannot put the program finder in")
text = text.replace("# Status panel remote access", "# Run in PowerShell as administrator: irm %s/join.ps1 | iex\n# Status panel remote access" % base, 1)
# Not elevated: say how, before anything runs (SC's Lighting PC got a DISM error instead, 2026-10-21).
text = scripts.with_admin_check(text)
text += """
# 5. Tell %(ingest)s this PC is here: it sets it up within a minute.
$hello = @{ user = $env:USERNAME; name = $env:COMPUTERNAME; os = 'windows'
ips = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notlike '127.*' } | ForEach-Object IPAddress)
macs = @($nics | ForEach-Object MacAddress); tasks = @($apps | ForEach-Object { 'Start ' + $_.Name }) }
try {
Invoke-RestMethod -Method Post -Uri '%(hello)s' -ContentType 'application/json' -Body ($hello | ConvertTo-Json) | Out-Null
Write-Host 'Joined: %(ingest)s sets this PC up in about a minute.'
} catch { Write-Host ('Could not reach %(ingest)s: ' + $_.Exception.Message) }
""" % {"ingest": (cfg.get("site") or {}).get("ingest_name") or "the Companion Mac", "hello": base + "/api/hello"}
return text
# --- command line ------------------------------------------------------------------------
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("what", choices=("setup", "look", "add", "rebuild", "upgrade-companion"))
ap.add_argument("--id", default="new")
ap.add_argument("--name")
ap.add_argument("--data-dir", default=PARENT)
ap.add_argument("--home", help="the settings page this location was installed from (the location list lives there)")
ap.add_argument("--pages", help="rebuild onto these Status,Restart pages (19,20 on every location)")
ap.add_argument("--standalone", action="store_true", help="a location that runs on its own: reports to no other location")
ap.add_argument("--from", dest="source", help="upgrade-companion: a Companion .dmg or .zip downloaded from bitfocus.io")
a = ap.parse_args(argv)
if a.what == "setup":
setup(a.data_dir, a.id, a.name, home=a.home, standalone=a.standalone)
elif a.what == "rebuild":
rebuild(a.data_dir, pages=tuple(int(p) for p in a.pages.split(",")) if a.pages else None)
elif a.what == "upgrade-companion":
upgrade_companion(a.home or (load_campuses_home(a.data_dir) or "http://10.1.1.1:8020"), source=a.source)
elif a.what == "look":
cfg = sp.load_config(os.path.join(a.data_dir, "config.json"))
inv = discover.look(cfg, a.data_dir)
print(json.dumps(summary(inv), indent=1))
new, added, _ = autoconf.extend(cfg, inv, load_seen(a.data_dir))
print("would add:", ", ".join(added) or "nothing")
else:
from settings import server
look_and_add(server.Site(a.data_dir), say=print, force=True)
if __name__ == "__main__":
main()
+534
View File
@@ -0,0 +1,534 @@
"""Every location's status, on North Attleboro's Companion only.
Each other location's settings page tells North Attleboro's (the hub: a location with no "home") where it
is, and learns nothing back: no other location ever sees another location (2026-10-20, the user: "NA
should be the only one that sees every single location's status"). Only the hub, every two seconds,
copies each location's key states (what that location's watchdog draws, /api/state) into its own
Companion as custom variables sp_state_<id> / sp_hb_<id>, and keeps two kinds of page there:
LOCATIONS page one key per location: that location's ALL key, with its name; tap = its status page
LOCATION page whichever location was tapped, as its own status page looks (same places, same
colours, live), view only: holds and presses are for that location's own deck.
The ALARMS corner goes back to the LOCATIONS page.
Two pages, whatever the number of locations (NA's Companion has 97 pages of its own; Companion stops
at 99): the LOCATION page's keys are cells ("c_1_3"), and the hub fills them from the tapped location's
layout (sp_view says which). A location that stops answering goes purple. Pages are only written
where nothing a person made sits (push_page.safe_target): an empty page, else one added at the
end. State: campuses.json in the data folder.
A standalone location (Apollo Beach, 2026-10-21: "they want to take care of it on their own ... i dont
need to monitor AB from NA") has "standalone" set instead of a home: it tells nobody, is no hub, and
lists only itself.
"""
import gzip
import logging
import hashlib
import json
import os
import tempfile
import threading
import time
import urllib.parse
import urllib.request
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
from settings import discover
import make_page
import push_page
import status_panel as sp
FILE = "campuses.json"
log = logging.getLogger("status-settings") # the settings page's own log (status-settings.log)
POLL, REFRESH = 2.0, 60.0
SHORT = {"na": "NA", "ab": "AB", "sc": "SC", "ws": "WS"}
MIRROR_MARK = "STATUS tile" # copied keys keep the tile marker, so a later rebuild may overwrite them
def load(data_dir):
try:
with open(os.path.join(data_dir, FILE)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def save(data_dir, data):
sp.write_json(os.path.join(data_dir, FILE), data)
def short(cid, name=""):
return SHORT.get(cid) or (name or cid)[:4].upper()
def my_url(cfg):
w = (discover.this_mac().get("wired") or {}).get("ip") or "127.0.0.1"
return "http://%s:%d" % (w, int((cfg.get("site") or {}).get("settings_port", 8020)))
def me(cfg, url=None):
import pwd
site = cfg.get("site") or {}
# The account this panel runs as: the hub logs in with it for the nightly backup.
return {"id": site.get("id") or "new", "name": site.get("name") or "This location", "url": url or my_url(cfg),
"user": pwd.getpwuid(os.getuid()).pw_name}
# Bumped when the pages are built differently (2: "campus" became "location"; 3: ALL AUTO-FIX key, 2026-10-21), so the hub
# re-imports them.
PAGES_VERSION = 3
def layout_sha(cfg):
keys = [(t.get("id"), t.get("label"), t.get("pos"), t.get("kind"), t.get("members")) for t in cfg.get("tiles", [])]
return hashlib.sha256(json.dumps([keys, cfg.get("page_grid"), cfg.get("variables"), PAGES_VERSION], sort_keys=True, default=str).encode()).hexdigest()[:16]
def state_now(site):
"""What /api/state answers: this location's key states as its watchdog draws them, right now."""
cfg = site.load()
names = cfg.get("variables") or {}
comp = site.comp
return {"id": (cfg.get("site") or {}).get("id"), "name": (cfg.get("site") or {}).get("name"),
"state": comp.get_custom(names.get("state", "sp_state")) or "", "hb": comp.get_custom(names.get("heartbeat", "sp_hb")) or "",
"layout": layout_sha(cfg), "time": int(time.time())}
def http_json(url, body=None, timeout=3):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"} if data else {}, method="POST" if data else "GET")
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
# --- the pages (the hub only) -----------------------------------------------------------
VIEW = {"state": "sp_state_view", "heartbeat": "sp_hb_view"}
REMOTE = make_page.REMOTE_VAR # which other locations have a problem: the blue band on NA's page-number keys
ALL_CMD = "sp_all_cmd" # a tap on NA's ALL AUTO-FIX key (LOCATIONS page)
ALL_AF = "sp_all_af" # what that key shows: {"t", "x", "paused"}
ALL_AF_POS = (3, 1) # where AUTO-FIX sits on every location's own status page
SELECT = "sp_view"
VIEW_CMD = "sp_view_cmd" # a tap or hold on the LOCATION page, passed on to that location's own watchdog
MODE = "sp_view_mode" # "" = that location's status page, "RESTART" = its Restart page
CELLS = [(r, c) for r in range(4) for c in range(1, 8)]
def cell_id(r, c):
return "c_%d_%d" % (r, c)
def view_state(state, keys):
"""A location's key states, moved onto the cells its keys sit in (empty cells blank)."""
out = {cell_id(r, c): {"x": "", "b": 0, "b2": 0, "f": 0xFFFFFF, "f2": 0xFFFFFF, "s": 14} for r, c in CELLS}
for k in keys:
r, c = k["pos"]
if (r, c) in CELLS and k["id"] in state:
out[cell_id(r, c)] = state[k["id"]]
return out
def build_view(local_cfg, page, overview_page):
"""The LOCATION page: a key per cell, drawn from sp_state_view; tap = acknowledge, hold = fix, at
that location (2026-10-20: the user wants to test and fix Woonsocket from NA). The ALARMS corner goes back."""
opts = make_page.settings(local_cfg)
controls = make_page.nav_controls(local_cfg)
hold_ms = opts["hold_ms"]
for r, c in CELLS:
layers = make_page.layers(cell_id(r, c), {}, VIEW, "''", opts)
tap = make_page.command_action("view_%d_%d" % (r, c), "press", VIEW_CMD, "tap %d %d $(internal:time_unix)" % (r, c))
hold = make_page.command_action("view_%d_%d" % (r, c), "hold", VIEW_CMD, "hold %d %d $(internal:time_unix)" % (r, c))
make_page.place(controls, (r, c), {
"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
"notes": "STATUS tile 'location view %d/%d' - the tapped location's key here. Press: acknowledge it there. "
"Hold %g s: fix it there, like a hold on that location's own deck." % (r, c, hold_ms / 1000.0)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [tap], str(hold_ms): [hold]},
"options": {"runWhileHeld": [hold_ms]}}}, "localVariables": []})
back = make_page.static_button("campus_back", "ALL\\nSITES", 0x37474F, 0xFFFFFF, overview_page,
"Tap: back to the LOCATIONS page (page %d)." % overview_page, "STATUS jump key")
for layer in back["style"]["layers"]:
if layer["type"] == "text": # which location this page shows, then the way back
layer["text"] = make_page.prop("`${$(custom:%s)} ${$(custom:%s)}\\nALL SITES`" % (SELECT, MODE), True)
make_page.place(controls, (3, 0), back)
return make_page.page_export("%d\\nLocation" % page, page, controls, local_cfg)
def select_action(cid, mode=""):
return [{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "select"), "definitionId": "custom_variable_set_value",
"connectionId": "internal", "options": {"name": make_page.prop(SELECT), "value": make_page.prop(cid.upper())}, "children": {}},
{"type": "action", "id": make_page.stable_id("campus_" + cid + mode, "mode"), "definitionId": "custom_variable_set_value",
"connectionId": "internal", "options": {"name": make_page.prop(MODE), "value": make_page.prop(mode)}, "children": {}}]
def campus_key(cid, name, names, actions, note, local_cfg):
"""One LOCATIONS key: that location's ALL key with its name on top."""
opts = make_page.settings(local_cfg)
tag = short(cid, name)
stale_text = "`%s\\n?`" % tag
layers = make_page.layers("all", {}, names, stale_text, opts)
text = next(layer for layer in layers if layer["type"] == "text")
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], opts["stale"])
text["text"] = make_page.prop("%s ? %s : `%s\\n${jsonpath($(custom:%s), '$.all.x')}`" % (stale, stale_text, tag, names["state"]), True)
text["fontsize"] = make_page.prop("auto")
return {"type": "button-layered", "style": {"layers": layers},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True,
"notes": "STATUS tile 'location %s' - %s's ALL key. %s" % (cid, name, note)},
"feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": actions}, "options": {"runWhileHeld": []}}},
"localVariables": []}
def build_overview(local_cfg, campuses, page, view_page):
"""Row 0: each location's ALL key (tap: its status page). Row 1: its RESTART key (tap: its Restart page)."""
controls = make_page.nav_controls(local_cfg)
mine = (local_cfg.get("site") or {}).get("id")
rp = (local_cfg.get("restart_page") or {}).get("page")
for col, cid in enumerate(sorted(campuses, key=lambda c: (c != mine, c))[:7], start=1):
name = campuses[cid].get("name", cid)
tag = short(cid, name)
if cid == mine:
key = campus_key(cid, name, local_cfg["variables"], [make_page.set_page_action("campus_" + cid, "go", local_cfg["page"])],
"Tap: its status page (page %d)." % local_cfg["page"], local_cfg)
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, rp,
"Tap: this location's Restart page (page %s)." % rp, "STATUS jump key") if rp else None
else:
key = campus_key(cid, name, {"state": "sp_state_" + cid, "heartbeat": "sp_hb_" + cid},
select_action(cid) + [make_page.set_page_action("campus_" + cid, "go", view_page)],
"Tap: its status page, live (page %d)." % view_page, local_cfg)
restart = make_page.static_button("campus_rs_" + cid, "%s\\nRESTART" % tag, 0x37474F, 0xFFFFFF, view_page,
"Tap: %s's Restart page, live (page %d); hold a key there to restart it at %s." % (name, view_page, name),
"STATUS jump key")
restart["steps"]["0"]["action_sets"]["up"] = select_action(cid, "RESTART") + restart["steps"]["0"]["action_sets"]["up"]
make_page.place(controls, (0, col), key)
if restart:
make_page.place(controls, (1, col), restart)
make_page.place(controls, ALL_AF_POS, all_autofix_key(local_cfg))
return make_page.page_export("%d\\nLocations" % page, page, controls, local_cfg)
def all_autofix_key(local_cfg):
"""Tap: pause auto-fix at every location at once (NA too); tap again: every location back to its hours
(2026-10-21, the user: "add a key on NA to pause auto-fix at all locations"). Blue while none is paused,
grey naming the paused ones."""
v = "$(custom:%s)" % ALL_AF
stale = "$(internal:time_unix) - jsonpath(%s, '$.t') > 30" % v
key = make_page.static_button("all_autofix", "AUTO-FIX\\nALL", 0x0B57D0, 0xFFFFFF, 1,
"Tap: pause auto-fix at every location (alarms stay on, holds still fix); tap again: all "
"back to their auto-fix hours.", "STATUS tile 'all autofix'")
for layer in key["style"]["layers"]:
if layer["type"] == "box":
layer["color"] = make_page.prop("%s ? %d : (jsonpath(%s, '$.paused') > 0 ? %d : %d)" % (stale, make_page.PURPLE, v, 0x262626, 0x0B57D0), True)
elif layer["type"] == "text":
layer["text"] = make_page.prop("%s ? `AUTO-FIX\\nALL ?` : `AUTO-FIX\\n${jsonpath(%s, '$.x')}`" % (stale, v), True)
key["steps"]["0"]["action_sets"]["up"] = [make_page.command_action("all_autofix", "press", ALL_CMD, "toggle $(internal:time_unix)")]
return key
def autofix_paused(state_json):
"""True when that location's AUTO-FIX key says it is paused ("AUTO-FIX\\nPAUSED\\nTIL ..."), None without one."""
try:
af = json.loads(state_json or "{}").get("autofix")
except ValueError:
return None
if not af:
return None
lines = str(af.get("x") or "").split("\n")
return len(lines) > 1 and lines[1] == "PAUSED"
def all_autofix_summary(states, now):
"""states: {id: that location's /api/state "state" JSON}. -> what NA's ALL AUTO-FIX key shows."""
paused = sorted(cid.upper() for cid, s in states.items() if autofix_paused(s))
return {"t": int(now), "paused": len(paused), "x": ("%s\nPAUSED" % " ".join(paused)) if paused else "ALL ON"}
def ensure_variables(cfg, ids):
from settings import campus
have, _ = campus.companion_state(cfg)
want = [n for cid in ids for n in ("sp_state_" + cid, "sp_hb_" + cid) if n not in have]
want += [n for n in (VIEW["state"], VIEW["heartbeat"], SELECT, VIEW_CMD, MODE, REMOTE, ALL_CMD, ALL_AF) if n not in have]
if not want:
return
comp = cfg.get("companion") or {}
ws = push_page.WebSocket(comp.get("host", "127.0.0.1"), int(comp.get("port", 8000)), "/trpc")
try:
api = push_page.Trpc(ws)
for n in want:
api.mutation("customVariables.create", {"name": n, "defaultVal": "0" if n.startswith("sp_hb_") else ""})
finally:
ws.close()
def free_page(cfg, taken, start=21):
"""The first page from `start` that holds nothing but Companion's own page keys."""
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
for page in range(start, 100):
if page in taken:
continue
if not exists(host, port, page):
break
try:
export = push_page.fetch_page(host, port, page)
except (OSError, ValueError):
continue
ctrls = (export.get("page") or {}).get("controls") or {}
if not any(ctl and ctl.get("type") not in push_page.NAV_TYPES for row in ctrls.values() for ctl in (row or {}).values()):
return page
# None empty: a new page at the end (Companion's own page keys on it), never in between.
last = max(p for p in range(1, 100) if exists(host, port, p))
if last >= 99:
raise RuntimeError("Companion has 99 pages and none is empty: free one for the locations")
ws = push_page.WebSocket(host, port, "/trpc")
try:
push_page.Trpc(ws).mutation("pages.insert", {"asPageNumber": last + 1, "pageNames": ["LOCATIONS"]})
finally:
ws.close()
return last + 1
def exists(host, port, page):
try:
push_page.fetch_page(host, port, page)
return True
except (OSError, ValueError):
return False
def push(cfg, page, export):
comp = cfg.get("companion") or {}
host, port = comp.get("host", "127.0.0.1"), int(comp.get("port", 8000))
ok, why = push_page.safe_target(push_page.fetch_page(host, port, page))
if not ok:
raise RuntimeError("page %d: %s" % (page, why))
with tempfile.NamedTemporaryFile(suffix=".companionconfig", delete=False) as f:
f.write(gzip.compress(json.dumps(export).encode()))
path = f.name
try:
push_page.push(host, port, page, path, export["page"]["name"])
finally:
os.remove(path)
# --- the loops --------------------------------------------------------------------------
class Mirror:
def __init__(self, site):
self.site = site
self.remote = {} # id -> last /api/state answer
self.keys = {} # id -> [{"id", "pos"}] of its status page (from its /api/config)
self.rkeys = {} # id -> the same for its Restart page
@staticmethod
def is_hub(data):
return not data.get("home") and not data.get("standalone")
def sync_registry(self):
"""A location: tell the hub where this settings page is (it learns nothing back). The hub: know itself."""
cfg = self.site.load()
data = load(self.site.data_dir)
mine = me(cfg)
home = data.get("home")
if home and home.rstrip("/") != mine["url"]:
try:
http_json(home.rstrip("/") + "/api/campus/hello", mine)
data.pop("error", None)
except (OSError, ValueError) as e:
data["error"] = "hub %s: %s" % (home, e)
data["list"] = {mine["id"]: mine}
else:
data.setdefault("list", {})[mine["id"]] = mine
save(self.site.data_dir, data)
return data
def taken_pages(self, cfg):
t = {cfg.get("page"), (cfg.get("restart_page") or {}).get("page"), (cfg.get("ready_page") or {}).get("page")}
return {p for p in t if p}
def refresh_pages(self):
"""The hub: each location's layout (when it changed), the LOCATION page once, the LOCATIONS page when the roster changes."""
cfg = self.site.load()
data = self.sync_registry()
if not self.is_hub(data):
return data
mine = (cfg.get("site") or {}).get("id")
others = {cid: e for cid, e in data.get("list", {}).items() if cid != mine}
if not others and not (data.get("pages") or {}).get("overview"):
return data # no other location yet: no pages needed
pages = data.setdefault("pages", {})
layouts = data.setdefault("layouts", {})
for cid, e in sorted(others.items()):
st = self.remote.get(cid)
if st and (layouts.get(cid) != st.get("layout") or cid not in self.keys):
remote_cfg = http_json(e["url"].rstrip("/") + "/api/config", timeout=8)["config"]
self.keys[cid] = [{"id": t["id"], "pos": t["pos"]} for t in remote_cfg.get("tiles", []) if t.get("pos")]
self.rkeys[cid] = [{"id": k["id"], "pos": k["pos"]} for k in (remote_cfg.get("restart_page") or {}).get("keys", []) if k.get("pos")]
layouts[cid] = st.get("layout")
ensure_variables(cfg, list(others))
taken = self.taken_pages(cfg) | {p for p in pages.values() if p}
for which in ("overview", "view"):
if not pages.get(which):
pages[which] = free_page(cfg, taken)
taken.add(pages[which])
built = layout_sha(cfg)
if data.get("view_built") != built:
push(cfg, pages["view"], build_view(cfg, pages["view"], pages["overview"]))
data["view_built"] = built
roster = sorted(data["list"])
if data.get("overview_roster") != roster or data.get("overview_built") != built:
push(cfg, pages["overview"], build_overview(cfg, data["list"], pages["overview"], pages["view"]))
data["overview_roster"], data["overview_built"] = roster, built
data.pop("error", None)
save(self.site.data_dir, data)
return data
def all_autofix(self, cfg, mine, others):
"""NA's ALL AUTO-FIX key: show which locations are paused; a tap pauses them all, or resumes them all."""
states = {cid: (self.remote.get(cid) or {}).get("state") for cid in others}
states[mine] = self.site.comp.get_custom((cfg.get("variables") or {}).get("state", "sp_state"))
summary = all_autofix_summary(states, time.time())
self.site.comp.set_custom(ALL_AF, json.dumps(summary, separators=(",", ":")))
text = (self.site.comp.get_custom(ALL_CMD) or "").strip(" ;")
if not text:
return
self.site.comp.set_custom(ALL_CMD, "")
stamps = [w for w in text.replace(";", " ").split() if w.isdigit()]
if not stamps or time.time() - int(stamps[-1]) > 30:
return # pressed while this was down: not acted on later
word = "auto" if summary["paused"] else "off"
hosts = {cid: urllib.parse.urlparse(e["url"]).hostname for cid, e in others.items()}
hosts[mine] = "127.0.0.1"
for cid, host in sorted(hosts.items()):
try:
comp = self.site.comp if cid == mine else sp.Companion(host, 8000, timeout=3)
before = (comp.get_custom("sp_cmd") or "").strip()
comp.set_custom("sp_cmd", ";".join([before, "autofix " + word] if before else ["autofix " + word]))
log.info("ALL AUTO-FIX: %s %s", cid, "paused" if word == "off" else "back to its hours")
except OSError as e:
log.warning("ALL AUTO-FIX: %s not reached: %s", cid, e)
def pass_on(self, pick, others, restart=False):
"""A tap or hold on the LOCATION page goes to the shown location's watchdog, through its Companion."""
text = (self.site.comp.get_custom(VIEW_CMD) or "").strip()
if not text:
return
self.site.comp.set_custom(VIEW_CMD, "")
keys = (self.rkeys if restart else self.keys).get(pick)
cmds = campus_command(keys, text, time.time(), restart) if pick else []
if not cmds:
return
host = urllib.parse.urlparse(others[pick]["url"]).hostname
try:
remote = sp.Companion(host, 8000, timeout=3)
before = (remote.get_custom("sp_cmd") or "").strip()
remote.set_custom("sp_cmd", ";".join([before] + cmds if before else cmds))
log.info("LOCATION %s: %s (from %s's LOCATION page)", pick, "; ".join(cmds), (self.site.cfg.get("site") or {}).get("name", "here"))
except OSError as e:
log.warning("LOCATION %s: could not pass on %s: %s", pick, cmds, e)
def poll(self):
"""The hub: copy each other location's key states into this Companion."""
cfg = self.site.load()
data = load(self.site.data_dir)
if not self.is_hub(data):
return
mine = (cfg.get("site") or {}).get("id")
others = {cid: e for cid, e in (data.get("list") or {}).items() if cid != mine and e.get("url")}
threads = []
def one(cid, url):
try:
st = http_json(url.rstrip("/") + "/api/state", timeout=1.5)
except (OSError, ValueError):
return # not answering: its heartbeat goes stale, its keys purple
self.remote[cid] = st
if (data.get("pages") or {}).get("overview"):
self.site.comp.set_custom("sp_state_" + cid, st.get("state") or "{}")
self.site.comp.set_custom("sp_hb_" + cid, str(st.get("hb") or "0"))
for cid, e in others.items():
t = threading.Thread(target=one, args=(cid, e["url"]), daemon=True)
t.start()
threads.append(t)
for t in threads:
t.join(2)
if (data.get("pages") or {}).get("overview"):
self.site.comp.set_custom(REMOTE, json.dumps(remote_summary(others, self.remote, time.time()), separators=(",", ":")))
self.all_autofix(cfg, mine, others)
# The LOCATION page shows the tapped location: its keys moved onto their cells.
pick = (self.site.comp.get_custom(SELECT) or "").strip().lower() # held as "WS": the back key shows it
if pick not in others and others:
# Nothing tapped yet (or a location that left): show the first one rather than a purple page.
pick = sorted(others)[0]
self.site.comp.set_custom(SELECT, pick.upper())
restart = (self.site.comp.get_custom(MODE) or "").strip().upper() == "RESTART"
self.pass_on(pick if pick in others else None, others, restart)
st = self.remote.get(pick)
keys = (self.rkeys if restart else self.keys).get(pick)
if st and keys is not None and (data.get("pages") or {}).get("view"):
try:
state = json.loads(st.get("state") or "{}")
except ValueError:
state = {}
self.site.comp.set_custom(VIEW["state"], json.dumps(view_state(state, keys), separators=(",", ":")))
self.site.comp.set_custom(VIEW["heartbeat"], str(st.get("hb") or "0"))
def remote_summary(others, remote, now, stale=30):
"""{"t", "n", "x", "f"}: the other locations with something down (their ALL key not "ALL OK", or not
answering: "SC?"), named by id; "f" = 1 while any of them is still flashing (not acknowledged there)."""
names, flashing = [], False
for cid in sorted(others):
st = remote.get(cid)
tag = cid.upper()
if not st or now - float(st.get("hb") or 0) > stale:
names.append(tag + "?")
flashing = True
continue
try:
all_ = json.loads(st.get("state") or "{}").get("all") or {}
except ValueError:
all_ = {}
if not all_ or str(all_.get("x") or "").startswith("ALL OK"):
continue
names.append(tag)
flashing = flashing or all_.get("b") != all_.get("b2")
return {"t": int(now), "n": len(names), "x": " ".join(names), "f": 1 if flashing else 0}
def campus_command(keys, text, now, restart=False):
"""The LOCATION page's taps and holds ("tap 1 3 <time>;hold 0 2 <time>") as that location's own
commands ("ack hd1", "restart hd1 <time>"; on its Restart page a hold is "force rs_x <time>" and a
tap does nothing, as on its own deck), for the keys at those cells. Stale ones are dropped."""
at = {tuple(k["pos"]): k["id"] for k in keys or []}
out = []
for part in (text or "").split(";"):
w = part.split()
if len(w) != 4 or w[0] not in ("tap", "hold") or not (w[1].isdigit() and w[2].isdigit() and w[3].isdigit()):
continue
tid = at.get((int(w[1]), int(w[2])))
if not tid or now - int(w[3]) > 15:
continue
if restart:
if w[0] == "hold":
out.append("force %s %s" % (tid, w[3]))
continue
out.append("ack %s" % tid if w[0] == "tap" else "restart %s %s" % (tid, w[3]))
return out
def loop(site, stop):
m = Mirror(site)
site.mirror = m
last = 0.0
stop.wait(15)
while not stop.is_set():
try:
m.poll()
if time.time() - last > REFRESH:
last = time.time()
m.refresh_pages()
except Exception as e: # noqa: BLE001 - the page must keep running
data = load(site.data_dir)
data["error"] = str(e)[:300]
save(site.data_dir, data)
stop.wait(POLL)
+324
View File
@@ -0,0 +1,324 @@
"""Auto-setup's eyes: what this location has, read from Companion, Dante, the network MIDI sessions,
this Mac's own network, and the computers that joined (each one ran the join line once).
look(cfg, data_dir) the inventory autoconf.py turns into keys
register(data_dir, hello) a computer's join report, kept in computers.json
Read-only everywhere: nothing here changes Companion, a session or a computer. Every remote
command is a fixed string; names from the network only ever come back as data.
"""
import ipaddress
import json
import os
import re
import subprocess
import time
import urllib.parse
import urllib.request
from settings import PARENT # noqa: F401 (puts the project folder on sys.path)
import status_panel as sp
TIMEOUT = 8
# find, not a glob: in zsh (the Macs' login shell) a pattern that matches nothing aborts the whole line.
MAC_LOOK = "echo SP_PROCS; ps -axo comm= 2>/dev/null; echo SP_APPS; find /Applications -maxdepth 3 -name '*.app' -prune 2>/dev/null; echo SP_END"
WIN_LOOK = "echo SP_PROCS & tasklist /FO CSV /NH 2>nul & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_END"
HOST_FIELDS = ("host", "bonjourHost", "serverHost", "ip", "address")
# --- the computers that joined ---------------------------------------------------------
def registry_path(data_dir):
return os.path.join(data_dir, "computers.json")
def registered(data_dir):
try:
with open(registry_path(data_dir)) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def clean_hello(hello, peer_ip):
"""A join report, kept to the fields auto-setup uses and checked: it comes from the network."""
def s(v, n=80):
return re.sub(r"[^\w .@:\-()'+]", "", str(v or ""))[:n]
ips = [str(ipaddress.ip_address(i)) for i in (hello.get("ips") or []) if _is_ip(i)][:12]
if peer_ip and _is_ip(peer_ip) and peer_ip not in ips:
ips.insert(0, peer_ip)
user = str(hello.get("user") or "")
if not re.fullmatch(r"[A-Za-z0-9._\-][A-Za-z0-9._\- ]{0,39}", user): # as given: a name to log in with is never "cleaned up"
raise ValueError("no usable user name")
return {"name": s(hello.get("name")) or user, "hostname": s(hello.get("hostname"), 60), "user": user,
"os": "windows" if str(hello.get("os", "")).lower().startswith("win") else "mac", "ips": ips,
"macs": [m for m in (str(x).lower().replace("-", ":") for x in hello.get("macs") or []) if re.fullmatch(r"([0-9a-f]{2}:){5}[0-9a-f]{2}", m)][:4],
"tasks": [s(t, 60) for t in hello.get("tasks") or []][:20], "at": int(time.time())}
def _is_ip(v):
try:
ipaddress.ip_address(str(v))
return True
except ValueError:
return False
def register(data_dir, hello, peer_ip=None):
entry = clean_hello(hello, peer_ip)
reg = registered(data_dir)
key = entry["name"]
reg[key] = entry
sp.write_json(registry_path(data_dir), reg)
return entry
# --- this Mac --------------------------------------------------------------------------
def run(argv, timeout=TIMEOUT):
try:
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout).stdout
except (OSError, subprocess.TimeoutExpired):
return ""
def wifi_ports():
out = run(["/usr/sbin/networksetup", "-listallhardwareports"])
ports, last = set(), ""
for line in out.splitlines():
if line.startswith("Hardware Port:"):
last = line.split(":", 1)[1].strip()
elif line.startswith("Device:") and last in ("Wi-Fi", "AirPort"):
ports.add(line.split(":", 1)[1].strip())
return ports
def interfaces():
"""{interface: (ip, network)} for every IPv4 address on this Mac, from ifconfig."""
out, cur = {}, None
for line in run(["/sbin/ifconfig"]).splitlines():
m = re.match(r"^(\S+?):", line)
if m:
cur = m.group(1)
continue
m = re.search(r"inet (\d+\.\d+\.\d+\.\d+) netmask 0x([0-9a-f]{8})", line)
if m and cur:
mask = ipaddress.ip_address(int(m.group(2), 16))
net = ipaddress.ip_network("%s/%s" % (m.group(1), mask), strict=False)
out.setdefault(cur, (m.group(1), str(net)))
return out
def gateway_of(interface):
out = run(["/usr/sbin/netstat", "-rn", "-f", "inet"])
for line in out.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == "default" and parts[-1] == interface:
return parts[1]
return ""
def this_mac():
"""Its names and addresses, and which network is the wired one: the interface with a gateway that
is neither Wi-Fi nor a link-local (Dante) address, preferring the one the default route uses."""
ifs = interfaces()
wifi = wifi_ports()
route = run(["/sbin/route", "-n", "get", "default"])
m = re.search(r"interface: (\S+)", route)
default_if = m.group(1) if m else ""
wired = None
for name in [default_if] + sorted(ifs):
if name in ifs and name not in wifi and not ifs[name][0].startswith(("127.", "169.254.")) and gateway_of(name):
wired = name
break
if wired is None:
wired = next((n for n in sorted(ifs) if n not in wifi and not ifs[n][0].startswith(("127.", "169.254."))), None)
info = {"name": run(["/usr/sbin/scutil", "--get", "ComputerName"]).strip(),
"hostname": (run(["/usr/sbin/scutil", "--get", "LocalHostName"]).strip() + ".local").lstrip("."),
"ips": [ip for n, (ip, _) in sorted(ifs.items()) if not ip.startswith("127.")]}
# What runs on this Mac itself (ProPresenter on Woonsocket's Companion Mac), with each app's path.
ps = run(["/bin/ps", "-axo", "comm="]).splitlines()
info["procs"] = sorted({os.path.basename(p.strip()) for p in ps if p.strip()})
info["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app" for p in ps if ".app/Contents/MacOS/" in p}
info["exes"] = {os.path.basename(p.strip()): p.strip() for p in ps if ".app/Contents/MacOS/" in p}
if wired:
ip, net = ifs[wired]
# A /24 or smaller on a location usually sits inside a /16 the rest of the gear shares: use what is configured.
info["wired"] = {"interface": wired, "ip": ip, "net": net, "gateway": gateway_of(wired)}
return info
# --- Companion -------------------------------------------------------------------------
def companion_get(cfg, path, timeout=5):
c = cfg.get("companion") or {}
url = "http://%s:%s%s" % (c.get("host", "127.0.0.1"), c.get("port", 8000), path)
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
def companion_var(cfg, label, name):
try:
return companion_get(cfg, "/api/variable/%s/%s/value" % (urllib.parse.quote(label, safe=""), urllib.parse.quote(name, safe="")), 3)
except OSError:
return ""
def connection_hosts(cfg):
"""{label: address} from Companion's own database, opened read-only (the host field differs by module)."""
import sqlite3
path = sp.companion_db_path(cfg)
if not path:
return {}
db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2)
try:
rows = db.execute("SELECT value FROM instances").fetchall()
finally:
db.close()
out = {}
for (value,) in rows:
try:
inst = json.loads(value)
except (TypeError, ValueError):
continue
if not isinstance(inst, dict) or not inst.get("label"):
continue
conf = inst.get("config") or {}
host = next((str(conf[k]).strip() for k in HOST_FIELDS if isinstance(conf.get(k), str) and conf[k].strip()), "")
out[inst["label"]] = host.split("|")[-1].rsplit(":", 1)[0] if host.count(":") == 1 else host
return out
def connections(cfg):
raw = json.loads(companion_get(cfg, "/api/connections"))
items = raw if isinstance(raw, list) else list(raw.values()) if isinstance(raw, dict) else []
hosts = connection_hosts(cfg)
out = []
for c in items:
if not isinstance(c, dict) or not c.get("label"):
continue
entry = {"label": c["label"], "module": c.get("moduleId") or c.get("instance_type") or "", "enabled": c.get("enabled", True) is not False,
"host": hosts.get(c["label"], ""), "status": ((c.get("status") or {}).get("category") if isinstance(c.get("status"), dict) else "")}
if entry["module"] in ("shure-wireless", "shure-psm1000"):
probe = "ch_%d_battery_bars" if entry["module"] == "shure-wireless" else "ch_%d_rf_mute"
n = 0
for ch in range(1, 9):
if companion_var(cfg, c["label"], probe % ch).strip() in ("", "undefined"):
break
n = ch
entry["channels"] = n or None
out.append(entry)
return out
def dante(cfg, conns):
label = next((c["label"] for c in conns if c["module"] == "audinate-dantecontroller" and c["enabled"]), None)
if not label:
return {}
try:
devices = json.loads(companion_var(cfg, label, "devices") or "[]")
except ValueError:
devices = []
return {"conn": label, "devices": sorted(d for d in devices if isinstance(d, str))}
# --- the computers ---------------------------------------------------------------------
def sections(out):
parts, cur = {}, None
for line in out.splitlines():
line = line.strip()
if line.startswith("SP_") and line[3:].isupper():
cur = line[3:]
parts[cur] = []
elif cur:
parts[cur].append(line)
return parts
def look_at(cfg, alias, host):
"""What runs on one computer: its programs (and, on a Mac, its network MIDI sessions)."""
os_name = host.get("os", "mac")
try:
r = subprocess.run(sp.ssh_cmd(cfg, alias) + [MAC_LOOK if os_name == "mac" else WIN_LOOK], capture_output=True, text=True, timeout=TIMEOUT)
except subprocess.TimeoutExpired:
return {"reachable": False}
if r.returncode != 0 or "SP_END" not in r.stdout:
return {"reachable": False, "error": (r.stderr or r.stdout).strip()[-160:]}
got = sections(r.stdout)
out = {"reachable": True}
if os_name == "mac":
out["procs"] = sorted({os.path.basename(p.strip()) for p in got.get("PROCS", []) if p.strip()})
# Where each running program's app is, from its own path (".../SuperRack SoundGrid.app/Contents/MacOS/...").
out["paths"] = {os.path.basename(p.strip()): p.strip().split(".app/")[0] + ".app"
for p in got.get("PROCS", []) if ".app/Contents/MacOS/" in p}
out["apps"] = sorted(a.strip() for a in got.get("APPS", []) if a.strip().endswith(".app"))
try:
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
out["python"] = m.returncode != 97
if not out["python"]:
out["midi"] = sp.jxa_midi(cfg, alias) or {}
except subprocess.TimeoutExpired:
out["midi"] = {}
else:
out["procs"] = sorted({row.split('","')[0].strip('"') for row in got.get("PROCS", []) if row.startswith('"')})
out["tasks"] = sorted({row.split('","')[0].strip('"').split("\\")[-1] for row in got.get("TASKS", [])
if row.startswith('"\\StatusPanel\\')})
return out
def computers(cfg, data_dir):
"""The joined computers and the ones already in the settings, each looked at over SSH."""
found = {}
hosts = dict(cfg.get("hosts") or {})
for alias, h in hosts.items():
if h.get("self") or h.get("disabled") or not h.get("ssh"):
continue
ip = str(h["ssh"]).split("@")[-1]
found[alias] = {"name": h.get("name", alias), "os": h.get("os", "mac"), "user": str(h["ssh"]).split("@")[0], "ip": ip,
"ips": [ip], "macs": [h["mac"]] if h.get("mac") else []}
known_ips = {c["ip"] for c in found.values()}
taken = set(found) | set(hosts)
nets = [(this_mac().get("wired") or {}).get("net")]
for name, j in registered(data_dir).items():
ip = next((i for i in j["ips"] if any(n and ipaddress.ip_address(i) in ipaddress.ip_network(n) for n in nets)), j["ips"][0] if j["ips"] else None)
if not ip or ip in known_ips:
continue
from settings.autoconf import slug
alias = slug(j["name"].replace(" ", "_"), taken)
taken.add(alias)
found[alias] = {"name": j["name"], "hostname": j.get("hostname", ""), "os": j["os"], "user": j["user"], "ip": ip, "ips": j["ips"],
"macs": j.get("macs") or [], "tasks": j.get("tasks") or []}
probe_cfg = dict(cfg, hosts=dict(hosts, **{a: {"ssh": "%s@%s" % (c["user"], c["ip"]), "os": c["os"]} for a, c in found.items()}))
for alias, c in found.items():
seen = look_at(probe_cfg, alias, probe_cfg["hosts"][alias])
if c.get("tasks") and "tasks" in seen:
seen["tasks"] = sorted(set(seen["tasks"]) | set(c["tasks"]))
c.update(seen)
return found
# --- everything ------------------------------------------------------------------------
def look(cfg, data_dir):
inv = {"ingest": this_mac(), "at": int(time.time())}
# A location whose MIDI rule names its wired network keeps it (it may be wider than this Mac's subnet).
rule = [n for t in sp.all_tiles(cfg) for c in t.get("checks", []) if c.get("type") == "midi_wired" for n in c.get("nets", [])]
if rule and inv["ingest"].get("wired"):
inv["ingest"]["wired"]["net"] = rule[0]
try:
inv["connections"] = connections(cfg)
except (OSError, ValueError) as e:
inv["connections"], inv["companion_error"] = [], str(e)
inv["dante"] = dante(cfg, inv["connections"])
try:
import coremidi
inv["midi"] = {"": coremidi.sessions()}
except Exception as e: # noqa: BLE001 - no CoreMIDI (tests, Linux): no sessions here
inv["midi"], inv["midi_error"] = {}, str(e)
inv["computers"] = computers(cfg, data_dir)
for alias, c in inv["computers"].items():
if c.get("midi"):
inv["midi"][alias] = c.pop("midi")
return inv
+13
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":
@@ -161,6 +163,14 @@ def key_guide(cfg):
parts.append("<p>Shows the worst of: %s. Hold: %s.</p>" % (", ".join(e(tiles[m]["label"]) for m in t["members"]), hold_words(t, cfg)))
if t.get("kind") == "service":
parts.append("<p>%s</p>" % e(ph["alarms"]))
if t.get("kind") == "autofix":
parts.append("<p>Press: pause auto-fix through the hours it is in now, or through the next ones (auto-fix %s); press "
"again to resume. It resumes by itself when those hours end. Holds always fix.</p>" % e(ph["auto_fix"]))
if t.get("kind") == "bypass":
b = t.get("bypass") or {}
parts.append("<p>Hold: hand it to an operator - the MIDI links to %s are taken out%s, and the panel stops putting them "
"back. Hold again: everything is put back as it was.</p>" % (
e(", ".join(b.get("peers") or [])), (" and Companion's %s is switched off" % e(", ".join(b["conns"]))) if b.get("conns") else ""))
if t.get("kind") == "network":
n = cfg.get("network", {})
parts.append("<p>Watches this Mac's network port%s, and calls it a blip when %d connections from %d groups drop within %d s "
@@ -185,6 +195,9 @@ def key_guide(cfg):
byself = sorted(f for f in own if f in auto)
if byself:
parts.append("<p class='muted'>Auto-fix %s: %s.</p>" % (e(ph["auto_fix"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in byself)))
failover = sorted(f for f in own if f in cfg.get("fixes", {}) and cfg["fixes"][f].get("failover"))
if failover:
parts.append("<p class='muted'>Failover %s: %s.</p>" % (e(ph["failover"]), ", ".join(e(cfg["fixes"][f].get("short", f)) for f in failover)))
parts.append("</div>")
rp = cfg.get("restart_page")
if rp:
+6
View File
@@ -0,0 +1,6 @@
"""What people type: python3 -m settings.location setup | look | add | rebuild | upgrade-companion
(the same as settings.campus, the module's older name; installed Macs may still call that one)."""
from settings.campus import main
if __name__ == "__main__":
main()
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Smart outlets the panel may switch (a hung recorder's mains, say), through Tuya's cloud with the
campus's own project keys. Credentials and the outlet list live in outlets.json beside the config
location's own project keys. Credentials and the outlet list live in outlets.json beside the config
(0600, never in the repo). Also a command for the watchdog's fixes:
outlet.py list the outlets on the account (name, id, on/off)
+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:
+29 -1
View File
@@ -44,15 +44,43 @@ def ps_quote(value, what):
return "'" + value + "'"
def admin_keys(cfg):
"""The admin laptop's SSH keys (site.admin_keys), checked: one line each, no quotes."""
out = []
for k in (cfg.get("site") or {}).get("admin_keys") or []:
k = str(k).strip()
if not re.fullmatch(r"ssh-(ed25519|rsa) [A-Za-z0-9+/=]+( [A-Za-z0-9@._-]+)?", k):
raise ValueError("site.admin_keys: %r is not one public SSH key line" % k[:40])
out.append(k)
return out
def site_values(cfg):
site = cfg.get("site") or {}
key, mark = public_key(cfg)
# The admin laptop (site.admin_ips / admin_keys) gets in everywhere this Mac does, so running a set-up script
# again never shuts it out (2026-10-21, the user: "keep SSH on at all times for windows pc, just like the macs").
ips = list(dict.fromkeys(list(site.get("ingest_ips") or []) + list(site.get("admin_ips") or [])))
return {"INGEST_NAME": site.get("ingest_name") or "the Ingest Mac",
"INGEST_IPS": ", ".join("'%s'" % ip for ip in site.get("ingest_ips") or []) or "'127.0.0.1'",
"INGEST_IPS": ", ".join("'%s'" % ip for ip in ips if re.fullmatch(r"[0-9a-fA-F.:]+", str(ip))) or "'127.0.0.1'",
"ADMIN_KEYS": ", ".join("'%s'" % k for k in admin_keys(cfg)),
"KEY": key or "<< this Mac has no login key yet: run install.sh, then come back >>",
"KEY_COMMENT": mark or "status-panel", "DVS_HELPER": site.get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"}
ADMIN_CHECK = """if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this as administrator: close this window, right-click Windows PowerShell > Run as administrator, then paste the line again. Nothing was changed.'
}"""
def with_admin_check(text):
"""The script stops with plain words unless PowerShell runs as administrator: the check goes after the
whole header comment line (inserting it mid-line left "} for ... - run ONCE" as code, 2026-10-21)."""
lines = text.split("\n")
at = next((i for i, ln in enumerate(lines) if ln.startswith("# Status panel remote access")), 0)
return "\n".join(lines[:at + 1] + [ADMIN_CHECK] + lines[at + 1:])
def windows_setup(cfg, alias):
host = cfg["hosts"][alias]
tasks = [r for r in host.get("runs") or [] if r.get("kind") == "task"]
+216 -9
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):
@@ -211,6 +276,7 @@ class Site:
"live": live, "running": running, "service_on": snap.get("service_on") if fresh else None,
"alarms_override": snap.get("alarms_override") if fresh else None,
"auto_fix": {"phrase": schema.phrases(cfg)["auto_fix"], "state": snap.get("auto_fix")},
"bypass": (snap.get("bypass") or {}) if fresh else {},
"problems": sorted(problems, key=lambda p: (p["level"] != "err", p["since"] or 0)), "needs": needs,
"computers": self.computer_lines(snap), "pages": pages, "snapshot_age": (now - snap["time"]) if snap.get("time") else None,
"last_change": self.last_change(), "phrases": schema.phrases(cfg), "version": started.get("version"),
@@ -286,7 +352,9 @@ class Site:
for f in fs:
if f["level"] == "err" and alias in owners.get((tid, f.get("check")), set()):
faults.append("%s %s" % (labels.get(tid, tid), f["msg"]))
if probe and not probe.get("reachable") and not h.get("self"):
if h.get("disabled"):
word, reason = "Off", "switched off in the settings: not looked at, its checks and fixes skipped"
elif probe and not probe.get("reachable") and not h.get("self"):
word, reason = "Problem", "cannot be reached (%s)" % probe.get("error", "no answer")
elif faults:
word, reason = "Problem", "; ".join(faults[:2])
@@ -306,7 +374,8 @@ class Site:
def look():
out = {}
for which, page, name in (("status", cfg["page"], cfg["page_name"]),) + (
(("restart", cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]),) if cfg.get("restart_page") else ()):
(("restart", cfg["restart_page"]["page"], cfg["restart_page"]["page_name"]),) if cfg.get("restart_page") else ()) + (
(("ready", cfg["ready_page"]["page"], cfg["ready_page"]["page_name"]),) if cfg.get("ready_page") else ()):
try:
status, raw = self.companion_get("/api/variable/internal/page_number_%d_name/value" % page)
live = raw.decode().replace("\\n", "\n") if status == 200 else None
@@ -618,6 +687,65 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, keyguide.cheat_sheet(site.load(), self.my_address()), "text/html; charset=utf-8")
if path == "/where.html":
return self.send(200, keyguide.where_things_are(site.load(), site.data_dir, self.my_address()), "text/html; charset=utf-8")
# ---- auto-setup: the install line for a new location, the join lines for its computers ----
if path in ("/campus/install", "/location/install"):
key, _ = scripts.public_key(site.load())
return self.send(200, campus.install_script(self.base_url(), q.get("id", ["new"])[0], key), "text/plain; charset=utf-8")
if path in ("/campus/program.tgz", "/location/program.tgz"):
return self.send(200, campus.program_kit(site.data_dir), "application/gzip")
if path in ("/campus/companion.zip", "/location/companion.zip"):
# Companion 5.0.7 as this Mac has it, for `settings.campus upgrade-companion` on a location
# still on an older one (packed once, kept beside the config, streamed).
app = "/Applications/Companion 5.0.7.app"
pack = os.path.join(site.data_dir, "companion-5.0.7.zip")
if not os.path.exists(app):
return self.send(404, {"error": "no Companion 5.0.7 on this Mac"})
if not os.path.exists(pack):
subprocess.run(["/usr/bin/ditto", "-c", "-k", "--keepParent", app, pack + ".tmp"], check=True, timeout=900)
os.replace(pack + ".tmp", pack)
self.send_response(200)
self.send_header("Content-Type", "application/zip")
self.send_header("Content-Length", str(os.path.getsize(pack)))
self.end_headers()
with open(pack, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
self.wfile.write(chunk)
return
if path == "/join":
return self.send(200, campus.join_mac(site.load(), self.base_url()), "text/plain; charset=utf-8")
m = re.fullmatch(r"/setup/([A-Za-z0-9_]+)\.ps1", path)
if m:
# One computer's set-up script, for "irm http://<this Mac>:8020/setup/<name>.ps1 | iex" in an
# administrator PowerShell on that PC (2026-10-21: the Bible TV PC's SSH had stopped).
cfg = site.load()
h = (cfg.get("hosts") or {}).get(m.group(1))
if not h or h.get("os") != "windows":
return self.send(404, {"error": "no Windows computer called %s here" % m.group(1)})
text = "# Run in PowerShell as administrator: irm %s/setup/%s.ps1 | iex\n%s" % (
self.base_url(), m.group(1), scripts.with_admin_check(scripts.windows_setup(cfg, m.group(1))))
return self.send(200, text, "text/plain; charset=utf-8")
if path == "/join.ps1":
return self.send(200, campus.join_windows(site.load(), self.base_url()), "text/plain; charset=utf-8")
if path == "/api/state":
return self.send(200, campuses.state_now(site))
if path == "/api/campuses":
# The hub's own view (its settings page). A location only ever lists itself.
data = campuses.load(site.data_dir)
hub = campuses.Mirror.is_hub(data)
return self.send(200, {"list": data.get("list") or {}, "pages": data.get("pages") or {} if hub else {},
"error": data.get("error"), "hub": hub, "standalone": bool(data.get("standalone"))})
if path == "/api/fleet":
return self.send(200, {"machines": list((site.read_json("fleet.json", {}) or {}).values())})
if path == "/api/discover":
cfg = site.load()
base = self.base_url()
return self.send(200, {"auto": bool((cfg.get("discover") or {}).get("auto")), "last": campus.load_last(site.data_dir),
"joined": discover.registered(site.data_dir),
"join": {"mac": "curl -fsSL %s/join | sh" % base, "pc": "irm %s/join.ps1 | iex" % base},
"base": base, "hub": campuses.Mirror.is_hub(campuses.load(site.data_dir))})
if path == "/api/config":
cfg = site.load()
return self.send(200, {"config": schema.mask_secrets(cfg), "sha": site.cfg_sha, "mtime": site.cfg_mtime})
@@ -628,6 +756,19 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, site.overview())
if path == "/api/companion":
return self.send(200, site.companion_view())
if path == "/api/companion/pages":
try:
return self.send(200, {"pages": site.companion_pages()})
except (OSError, ValueError) as e:
return self.send(200, {"pages": [], "error": "Companion did not answer: %s" % e})
if path == "/api/companion/page":
n = (urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("n") or ["0"])[0]
if not n.isdigit() or not 1 <= int(n) <= 99:
return self.send(400, {"error": "which page?"})
try:
return self.send(200, site.companion_page(int(n)))
except (OSError, ValueError) as e:
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
if path == "/api/machines":
return self.send(200, site.machines_view())
if path == "/api/gear":
@@ -694,7 +835,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
if os.path.exists(fp):
z.write(fp, name)
z.writestr("README.txt", "Site bundle of %s, exported %s from the settings page.\nDrop it in the repo as sites/%s/ or feed it to "
"'New campus from a bundle' on another campus's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
"'New location from a bundle' on another location's page.\n" % ((cfg.get("site") or {}).get("name", "?"), time.strftime("%Y-%m-%d %H:%M"), (cfg.get("site") or {}).get("id", "site")))
data = buf.getvalue()
self.send_response(200)
self.send_header("Content-Type", "application/zip")
@@ -766,6 +907,45 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(403, {"error": "wrong origin"})
site = self.site
try:
if path == "/api/hello":
# A computer ran the join line: keep what it says (checked), and look again soon.
try:
entry = discover.register(site.data_dir, body, self.client_address[0])
except (ValueError, TypeError) as e:
return self.send(400, {"error": str(e)})
log.info("joined: %s (%s, %s) from %s", entry["name"], entry["os"], entry["user"], self.client_address[0])
site.discover_poke.set()
return self.send(200, {"ok": True})
if path == "/api/campus/hello":
# Another location says where its settings page is (the hub keeps the list for everyone).
cid = str(body.get("id") or "")
url = str(body.get("url") or "")
if not re.fullmatch(r"[a-z0-9]{1,8}", cid) or not re.fullmatch(r"http://\d{1,3}(\.\d{1,3}){3}:\d{2,5}", url) \
or not allowed_client(url[7:].split(":")[0]):
return self.send(400, {"error": "an id like ws and an address like http://10.5.1.1:8020"})
data = campuses.load(site.data_dir)
if not campuses.Mirror.is_hub(data):
return self.send(403, {"error": "this settings page keeps no list of locations"})
lst = data.setdefault("list", {})
for other in [k for k, e in lst.items() if k != cid and (e or {}).get("url") == url]:
lst.pop(other) # the same Mac under its old name (before its first setup)
user = str(body.get("user") or "")
lst[cid] = dict({"id": cid, "name": str(body.get("name") or cid)[:40], "url": url},
**({"user": user} if re.fullmatch(r"[A-Za-z0-9._-]{1,40}", user) else {}))
campuses.save(site.data_dir, data)
return self.send(200, {"ok": True})
if path == "/api/fleet/hello":
# A machine anywhere (any location) says it now takes the admin laptop's key: kept for the
# laptop's SSH shortcuts (tools/fleet.py). Nothing here acts on it.
try:
entry = discover.clean_hello(body, self.client_address[0])
except (ValueError, TypeError) as e:
return self.send(400, {"error": str(e)})
fleet = site.read_json("fleet.json", {}) or {}
fleet["%s|%s" % (entry["name"], (entry["ips"] or ["?"])[0])] = entry
sp.write_json(os.path.join(site.data_dir, "fleet.json"), fleet)
log.info("fleet: %s (%s, %s) from %s", entry["name"], entry["os"], entry["user"], self.client_address[0])
return self.send(200, {"ok": True})
if path == "/api/editing/check":
return self.send(200, site.check_code(self.client_address[0], str(body.get("code", ""))))
if path == "/api/machines/probe":
@@ -782,14 +962,32 @@ class Handler(http.server.BaseHTTPRequestHandler):
ok, who = self.gate()
if not ok:
return
if path == "/api/discover/now":
# Look now and add what is new (also when adding by itself is off); never during a service.
if getattr(site, "looking", None) and site.looking.is_alive():
return self.send(200, {"started": False, "already": True})
site.looking = threading.Thread(target=campus.look_and_add, args=(site,), kwargs={"force": True}, daemon=True)
site.looking.start()
log.info("auto-setup: look now, asked by %s", who)
return self.send(200, {"started": True})
if path == "/api/draft" and self.command == "PUT":
cfg = schema.unmask(body.get("config") or {}, site.load())
# A page that loaded the settings before they changed here (an Apply from another tab, a
# program update) sends what it started from: only its own changes are put on top of the
# live settings, so newer values are never quietly undone (2026-10-15).
rebased = False
if body.get("base") and body["base"] != site.cfg_sha and isinstance(body.get("base_config"), dict):
base_cfg = schema.unmask(body["base_config"], site.load())
cfg, _ = applying.rebase({"config": cfg}, site.load(), base_cfg)
rebased = True
log.info("draft from %s rebased: the page started from %s, the settings are now %s",
who, str(body["base"])[:10], (site.cfg_sha or "")[:10])
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
if conflict:
changed = applying.changed_paths(cfg, conflict["config"])
return self.send(409, {"error": "%s changed this a moment ago" % conflict.get("by", "someone"), "draft": self.masked_draft(conflict),
"changed": [applying.words_for(p, a, b, cfg, conflict["config"])["where"] for p, a, b in changed[:5]]})
return self.send(200, {"draft": self.masked_draft(draft)})
return self.send(200, {"draft": self.masked_draft(draft), "rebased": rebased, "live_sha": site.cfg_sha})
if path == "/api/draft" and self.command == "DELETE":
site.draft.clear()
self.discard_atem()
@@ -844,7 +1042,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
if path == "/api/command":
text = str(body.get("text", "")).strip()
verb = text.split(" ")[0].lower() if text else ""
if verb not in ("test", "resurvey", "service"):
if verb not in ("test", "resurvey", "service", "autofix"):
return self.send(400, {"error": "not a command the page may send"})
if verb == "test":
live, reason = site.applier.live()
@@ -911,7 +1109,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(400, {"error": "not a site bundle or settings file: %s" % e})
cfg = site.load()
mine = cfg.get("site") or {}
src = (new_cfg.get("site") or {}).get("name") or "the other campus"
src = (new_cfg.get("site") or {}).get("name") or "the other location"
new_cfg["site"] = dict(new_cfg.get("site") or {}, **{k: mine[k] for k in ("id", "name", "ingest_name", "ingest_hostname", "ingest_ips", "editing_code", "settings_port") if k in mine})
flag = "copied from %s - check it" % src
for alias, h in (new_cfg.get("hosts") or {}).items():
@@ -923,7 +1121,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
f["label"] = f.get("label", "")
errors, warnings = schema.validate(new_cfg)
draft, _ = site.draft.update(new_cfg, who, site.cfg_sha)
log.info("%s started a new campus from a bundle of %s", who, src)
log.info("%s started a new location from a bundle of %s", who, src)
return self.send(200, {"draft": self.masked_draft(draft), "from": src, "errors": errors, "warnings": warnings})
if path == "/api/atem-reference/pick-newest":
folder = os.path.expanduser((site.cfg.get("atem") or {}).get("source_folder") or "")
@@ -961,7 +1159,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
def names_check(self, cfg):
"""Every device name the keys use, against what this Companion, the Dante network and the Macs report now:
the mapping step of a copied campus."""
the mapping step of a copied location."""
site = self.site
try:
live = {c["label"]: c.get("moduleId") for c in site.comp.connections()}
@@ -1130,7 +1328,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
if plan["restart"] or atem_pending:
parts.append("the deck goes purple for about 5 seconds and comes back with your changes")
if plan["pages"]:
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart")))
parts.append("the %s re-imported" % " and ".join("%s page is" % p.capitalize() for p in plan["pages"] if p in ("status", "restart", "ready")))
if plan["nav"]:
parts.append("the page-number keys are recoloured")
return (", ".join(parts) + ".").capitalize() if parts else "Nothing on the deck changes."
@@ -1200,6 +1398,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
except OSError:
return self.send(404, {"error": "missing %s" % name})
def base_url(self):
"""This page as the caller reached it (an address, across locations: names do not cross subnets)."""
host = (self.headers.get("Host") or "").strip() or "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
if not re.fullmatch(r"[A-Za-z0-9.\-]+(:\d+)?", host):
host = "%s:%d" % (self.server.server_address[0], self.server.server_address[1])
return "http://" + host
def my_address(self):
cfg = self.site.cfg
host = cfg.get("site", {}).get("ingest_hostname") or (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
@@ -1311,6 +1516,8 @@ def main(argv=None):
site, servers = serve(args.data_dir, port, args.bind, args.config)
stop = threading.Event()
threading.Thread(target=rebind_loop, args=(site, servers, port, stop), daemon=True).start()
threading.Thread(target=campus.loop, args=(site, stop, site.discover_poke), name="auto-setup", daemon=True).start()
threading.Thread(target=campuses.loop, args=(site, stop), name="locations", daemon=True).start()
try:
while True:
time.sleep(3600)
+617 -86
View File
File diff suppressed because it is too large Load Diff
+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; }
+3 -3
View File
@@ -1,7 +1,7 @@
# Sites
One folder per campus. `na/config.json` is North Attleboro (a link to the repo's `config.json`, which install.sh
installs by default). `_template/config.json` is the empty campus: the three fixed keys, this Mac's record and
One folder per location. `na/config.json` is North Attleboro (a link to the repo's `config.json`, which install.sh
installs by default). `_template/config.json` is the empty location: the three fixed keys, this Mac's record and
nothing else; `install.sh --site <id>` installs `sites/<id>/config.json` when the folder exists, otherwise the
template. The settings page's **Export site bundle** (Site setup > Site) gives you a zip to drop in as `sites/<id>/`;
its **New campus from a bundle** starts a campus from another campus's bundle.
its **New location from a bundle** starts a location from another location's bundle.
+16 -2
View File
@@ -1,7 +1,7 @@
{
"site": {
"id": "new",
"name": "New campus",
"name": "New location",
"ingest_name": "Ingest Mac",
"ingest_hostname": "",
"ingest_ips": [],
@@ -48,6 +48,15 @@
],
"kind": "service"
},
{
"id": "autofix",
"label": "AUTO-FIX",
"pos": [
3,
1
],
"kind": "autofix"
},
{
"id": "net",
"label": "NET",
@@ -95,5 +104,10 @@
"burst_groups": 3,
"burst_window_seconds": 10,
"remember_seconds": 300
}
},
"nav_keys": [
"pagenum"
],
"nav_hold_ms": 1000,
"nav_tap_page": 1
}
+703
View File
@@ -0,0 +1,703 @@
{
"site": {
"id": "sc",
"name": "Southcoast",
"ingest_name": "SC ProPresenter",
"ingest_hostname": "SC-ProPresenter-423.local",
"ingest_ips": [
"10.5.2.60",
"192.168.2.1",
"169.254.161.214",
"169.254.108.73"
],
"contacts": "",
"live_services": [],
"editing_code": "5690",
"admin_ips": [
"10.6.7.38"
],
"admin_keys": [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
]
},
"companion": {
"host": "127.0.0.1",
"port": 8000
},
"page": 19,
"page_name": "19\\nStatus",
"page_hold_ms": 2000,
"variables": {
"state": "sp_state",
"heartbeat": "sp_hb",
"command": "sp_cmd"
},
"timing": {
"fix_cooldown_seconds": 60,
"alert_after_seconds": 30
},
"auto_fix": {
"after_seconds": 20,
"max_tries": 2,
"retry_seconds": 90,
"pause_after_network_seconds": 60,
"restart_connections": true,
"windows": [
{
"days": [
"tue"
],
"start": "14:00",
"end": "22:00"
},
{
"days": [
"wed"
],
"start": "09:00",
"end": "21:00"
},
{
"days": [
"sun"
],
"start": "05:00",
"end": "14:00"
}
],
"fixes": [
"propresenter_ingest",
"prop_port_propresenter_api",
"hyperdeck",
"midi_reconnect",
"ableton_sc_tracks_new",
"superrack_sc_foh",
"x32edit_sc_foh",
"vista_desktop_cucshqg",
"rtpmidi_desktop_cucshqg"
]
},
"tiles": [
{
"id": "all",
"label": "ALL",
"pos": [
0,
1
],
"kind": "master"
},
{
"id": "service",
"label": "ALARMS",
"pos": [
3,
0
],
"kind": "service"
},
{
"id": "net",
"label": "NET",
"pos": [
3,
7
],
"kind": "network"
},
{
"id": "hd",
"label": "HD",
"checks": [
{
"type": "hyperdeck",
"conn": "hyperdeck",
"fix": {
"conn": "hyperdeck"
}
}
],
"pos": [
0,
2
]
},
{
"id": "foh_x32",
"label": "FOH X32",
"checks": [
{
"type": "conn",
"conn": "FOH_X32"
}
],
"pos": [
0,
3
]
},
{
"id": "iem_x32",
"label": "IEM X32",
"checks": [
{
"type": "conn",
"conn": "IEM_X32"
}
],
"pos": [
0,
4
]
},
{
"id": "prop",
"label": "PROP",
"checks": [
{
"type": "conn",
"conn": "Propresenter-API",
"fix": "prop_port_propresenter_api",
"ignore_warning": true
},
{
"type": "process",
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
"name": "PROP",
"fix": "propresenter_ingest"
}
],
"pos": [
0,
5
]
},
{
"id": "atem",
"label": "ATEM",
"checks": [
{
"type": "conn",
"conn": "atem"
}
],
"pos": [
0,
6
]
},
{
"id": "resi",
"label": "RESI",
"checks": [
{
"type": "conn",
"conn": "resi"
},
{
"type": "ping",
"conn": "resi"
}
],
"pos": [
0,
7
]
},
{
"id": "midi",
"label": "MIDI",
"checks": [
{
"type": "conn",
"conn": "Midi-Lighting"
},
{
"type": "midi_peer",
"session": "ProPresenter",
"peer": "Lighting",
"address": "10.5.1.168:5004",
"fix": "midi_reconnect",
"tag": "PROP LIGHTS"
},
{
"type": "midi_peer",
"session": "SC Tracks NEW",
"peer": "SC FOH Mac mini",
"address": "10.5.2.96:5004",
"fix": "midi_reconnect",
"tag": "FOH TRACKS",
"host": "sc_tracks_new"
},
{
"type": "midi_peer",
"session": "SC Tracks NEW",
"peer": "Lighting",
"address": "10.5.1.168:5004",
"fix": "midi_reconnect",
"tag": "TRACKS LIGHTS",
"host": "sc_tracks_new"
},
{
"type": "remote",
"template": "win_program",
"params": {
"host": "desktop_cucshqg",
"exe": "rtpMIDI.exe"
},
"msg": "RTPMIDI NOT RUNNING",
"fix": "rtpmidi_desktop_cucshqg",
"host": "desktop_cucshqg",
"cmd": "tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul"
},
{
"type": "midi_wired",
"nets": [
"10.5.0.0/16"
],
"fix": "midi_reconnect"
}
],
"pos": [
1,
1
]
},
{
"id": "ableton",
"label": "ABLETON",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "sc_tracks_new",
"process": "Live"
},
"msg": "ABLETON NOT RUNNING",
"fix": "ableton_sc_tracks_new",
"host": "sc_tracks_new",
"cmd": "pgrep -xq Live"
}
],
"pos": [
1,
2
]
},
{
"id": "autofix",
"label": "AUTO-FIX",
"kind": "autofix",
"pos": [
3,
1
]
},
{
"id": "lights",
"label": "LIGHTS",
"kind": "bypass",
"pos": [
1,
3
],
"bypass": {
"peers": [
"Lighting"
],
"hosts": [
"",
"sc_tracks_new"
]
}
},
{
"id": "superrack",
"label": "SUPERRACK",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "sc_foh",
"process": "SuperRack SoundGrid"
},
"msg": "SUPERRACK NOT RUNNING",
"fix": "superrack_sc_foh",
"host": "sc_foh",
"cmd": "pgrep -xq 'SuperRack SoundGrid'"
},
{
"type": "remote",
"template": "soundgrid",
"params": {
"host": "sc_foh"
},
"msg": "SOUNDGRID LOST",
"host": "sc_foh",
"cmd": "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'"
}
],
"pos": [
1,
4
]
},
{
"id": "x32_edit",
"label": "X32 EDIT",
"checks": [
{
"type": "remote",
"template": "mac_program",
"params": {
"host": "sc_foh",
"process": "X32-Edit"
},
"msg": "X32 EDIT NOT RUNNING",
"fix": "x32edit_sc_foh",
"host": "sc_foh",
"cmd": "pgrep -xq X32-Edit"
}
],
"pos": [
1,
5
]
},
{
"id": "vista",
"label": "VISTA",
"checks": [
{
"type": "remote",
"template": "win_program",
"params": {
"host": "desktop_cucshqg",
"exe": "Vista.exe"
},
"msg": "VISTA NOT RUNNING",
"fix": "vista_desktop_cucshqg",
"host": "desktop_cucshqg",
"cmd": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul"
}
],
"pos": [
1,
6
]
}
],
"fixes": {
"propresenter_ingest": {
"template": "mac_start",
"params": {
"host": "ingest",
"process": "ProPresenter",
"app": "$APP",
"newest_of": "/Applications/ProPresenter*.app"
},
"label": "start ProPresenter on this Mac if it is not running",
"short": "PROP",
"kind": "local",
"run": "pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); open \"$APP\"; }"
},
"restart_propresenter_ingest": {
"template": "mac_restart",
"params": {
"host": "ingest",
"process": "ProPresenter",
"app": "$APP",
"wait_seconds": 10,
"force": true,
"background": false,
"stop_children": false,
"newest_of": "/Applications/ProPresenter*.app"
},
"label": "restart ProPresenter on this Mac (closes it first)",
"short": "PROP",
"kind": "local",
"run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\""
},
"prop_port_propresenter_api": {
"kind": "propresenter_port",
"template": "propresenter_port",
"params": {},
"conn": "Propresenter-API",
"short": "PROP PORT",
"label": "find ProPresenter's network port and reconnect Companion's Propresenter-API to it"
},
"hyperdeck": {
"kind": "hyperdeck",
"template": "hyperdeck",
"params": {},
"short": "HYPERDECK",
"verify_seconds": 15,
"hung_note": "STILL OFFLINE\nHOLD AGAIN",
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck"
},
"midi_reconnect": {
"kind": "midi_connect",
"template": "midi_connect",
"params": {},
"short": "RECONNECT",
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address"
},
"ableton_sc_tracks_new": {
"template": "ableton",
"params": {
"host": "sc_tracks_new",
"app": "Ableton Live 11 Suite",
"restart": false,
"wait_seconds": 15
},
"label": "start Ableton on SC Tracks NEW if it is not running",
"short": "ABLETON",
"kind": "ssh",
"host": "sc_tracks_new",
"run": "pgrep -xq Live || { L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi; }"
},
"restart_ableton_sc_tracks_new": {
"template": "ableton",
"params": {
"host": "sc_tracks_new",
"app": "Ableton Live 11 Suite",
"restart": true,
"wait_seconds": 15
},
"label": "restart Ableton on SC Tracks NEW (closes it first)",
"short": "ABLETON",
"kind": "ssh",
"host": "sc_tracks_new",
"run": "L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); pkill -TERM -x Live; i=0; while pgrep -xq Live && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Live && pkill -KILL -x Live; sleep 1; if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi"
},
"superrack_sc_foh": {
"template": "mac_start",
"params": {
"host": "sc_foh",
"process": "SuperRack SoundGrid",
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app"
},
"label": "start SuperRack on SC FOH if it is not running",
"short": "SUPERRACK",
"kind": "ssh",
"host": "sc_foh",
"run": "pgrep -xq 'SuperRack SoundGrid' || open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'"
},
"restart_superrack_sc_foh": {
"template": "mac_restart",
"params": {
"host": "sc_foh",
"process": "SuperRack SoundGrid",
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
"wait_seconds": 10,
"force": true,
"background": false,
"stop_children": false,
"newest_of": ""
},
"label": "restart SuperRack on SC FOH (closes it first)",
"short": "SUPERRACK",
"kind": "ssh",
"host": "sc_foh",
"run": "pkill -TERM -x 'SuperRack SoundGrid'; i=0; while pgrep -xq 'SuperRack SoundGrid' && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq 'SuperRack SoundGrid' && pkill -KILL -x 'SuperRack SoundGrid'; sleep 1; open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'"
},
"x32edit_sc_foh": {
"template": "mac_start",
"params": {
"host": "sc_foh",
"process": "X32-Edit",
"app": "/Applications/X32-Edit.app"
},
"label": "start X32-Edit on SC FOH if it is not running",
"short": "X32 EDIT",
"kind": "ssh",
"host": "sc_foh",
"run": "pgrep -xq X32-Edit || open /Applications/X32-Edit.app"
},
"vista_desktop_cucshqg": {
"template": "win_start",
"params": {
"host": "desktop_cucshqg",
"task": "Start Vista",
"exe": "Vista.exe"
},
"label": "start Vista on SC Lighting PC if it is not running",
"short": "VISTA",
"kind": "ssh",
"host": "desktop_cucshqg",
"run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)"
},
"restart_vista_desktop_cucshqg": {
"template": "win_restart",
"params": {
"host": "desktop_cucshqg",
"task": "Start Vista",
"exe": "Vista.exe"
},
"label": "restart Vista on SC Lighting PC (closes it first)",
"short": "VISTA",
"kind": "ssh",
"host": "desktop_cucshqg",
"run": "taskkill /F /T /IM \"Vista.exe\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul"
},
"rtpmidi_desktop_cucshqg": {
"template": "win_start",
"params": {
"host": "desktop_cucshqg",
"task": "Start rtpMIDI",
"exe": "rtpMIDI.exe"
},
"label": "start rtpMIDI on SC Lighting PC if it is not running",
"short": "RTPMIDI",
"kind": "ssh",
"host": "desktop_cucshqg",
"run": "tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start rtpMIDI\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul)"
}
},
"hosts": {
"ingest": {
"self": true,
"name": "SC ProPresenter",
"role": "the watchdog, Companion, the settings page",
"os": "mac",
"networks": [],
"runs": [
{
"kind": "launchagent",
"label": "org.waterschurch.status-panel",
"name": "the watchdog"
},
{
"kind": "launchagent",
"label": "org.waterschurch.status-settings",
"name": "the settings page"
},
{
"kind": "app",
"process": "Companion",
"name": "Companion",
"http": "http://127.0.0.1:8000/api/connections"
}
]
},
"sc_tracks_new": {
"name": "SC Tracks NEW",
"os": "mac",
"ssh": "tracks@10.5.3.72",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
]
},
"sc_foh": {
"name": "SC FOH",
"os": "mac",
"ssh": "media@10.5.2.96",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
]
},
"desktop_cucshqg": {
"name": "SC Lighting PC",
"os": "windows",
"ssh": "Lighting@10.5.1.168",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
],
"mac": "40:a8:f0:69:ce:c9"
}
},
"gear": {},
"debounce_seconds": 3,
"network": {
"interface": "en11",
"gateway": "10.5.0.1",
"burst_connections": 4,
"burst_groups": 3,
"burst_window_seconds": 10,
"remember_seconds": 300
},
"nav_keys": [
"pagenum"
],
"nav_hold_ms": 1000,
"nav_tap_page": 1,
"discover": {
"auto": true
},
"restart_page": {
"page": 20,
"page_name": "20\\nRestart",
"jump_pos": [
3,
6
],
"back_pos": [
3,
0
],
"keys": [
{
"id": "rs_prop",
"label": "PROP",
"fix": "restart_propresenter_ingest",
"pos": [
0,
1
]
},
{
"id": "rs_ableton",
"label": "ABLETON",
"fix": "restart_ableton_sc_tracks_new",
"pos": [
0,
2
]
},
{
"id": "rs_superrack",
"label": "SUPERRACK",
"fix": "restart_superrack_sc_foh",
"pos": [
0,
3
]
},
{
"id": "rs_vista",
"label": "VISTA",
"fix": "restart_vista_desktop_cucshqg",
"pos": [
0,
4
]
}
]
},
"service_windows": [
{
"days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"start": "00:00",
"end": "23:59"
}
]
}
+606
View File
@@ -0,0 +1,606 @@
{
"auto_fix": {
"after_seconds": 20,
"fixes": [
"superrack_ws_foh",
"x32edit_ws_foh",
"vista_ws_lights",
"ableton_ws_tracks",
"propresenter_ingest",
"midi_reconnect"
],
"max_tries": 2,
"pause_after_network_seconds": 60,
"restart_connections": true,
"retry_seconds": 90,
"windows": [
{
"days": [
"tue"
],
"end": "22:00",
"start": "14:00"
},
{
"days": [
"wed"
],
"end": "21:00",
"start": "09:00"
},
{
"days": [
"sun"
],
"end": "14:00",
"start": "05:00"
}
]
},
"companion": {
"host": "127.0.0.1",
"port": 8000
},
"debounce_seconds": 3,
"discover": {
"auto": true
},
"fixes": {
"ableton_ws_tracks": {
"host": "ws_tracks",
"kind": "ssh",
"label": "start Ableton on WS Tracks if it is not running",
"params": {
"app": "Ableton Live 11 Suite",
"host": "ws_tracks",
"restart": false,
"wait_seconds": 15
},
"run": "pgrep -xq Live || { L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi; }",
"short": "ABLETON",
"template": "ableton"
},
"midi_reconnect": {
"kind": "midi_connect",
"label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup), at its wired address",
"params": {},
"short": "RECONNECT",
"template": "midi_connect"
},
"propresenter_ingest": {
"kind": "local",
"label": "start ProPresenter on this Mac if it is not running",
"params": {
"app": "$APP",
"host": "ingest",
"process": "ProPresenter",
"newest_of": "/Applications/ProPresenter*.app"
},
"short": "PROP",
"template": "mac_start",
"run": "pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); open \"$APP\"; }"
},
"restart_ableton_ws_tracks": {
"host": "ws_tracks",
"kind": "ssh",
"label": "restart Ableton on WS Tracks (closes it first)",
"params": {
"app": "Ableton Live 11 Suite",
"host": "ws_tracks",
"restart": true,
"wait_seconds": 15
},
"run": "L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); pkill -TERM -x Live; i=0; while pgrep -xq Live && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Live && pkill -KILL -x Live; sleep 1; if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi",
"short": "ABLETON",
"template": "ableton"
},
"restart_propresenter_ingest": {
"kind": "local",
"label": "restart ProPresenter on this Mac (closes it first)",
"params": {
"app": "$APP",
"background": false,
"force": true,
"host": "ingest",
"newest_of": "/Applications/ProPresenter*.app",
"process": "ProPresenter",
"stop_children": false,
"wait_seconds": 10
},
"short": "PROP",
"template": "mac_restart",
"run": "APP=$(for a in /Applications/ProPresenter*.app; do [ -d \"$a\" ] && printf '%s|%s\\n' \"$(defaults read \"$a/Contents/Info\" CFBundleShortVersionString 2>/dev/null)\" \"$a\"; done | sort -t '|' -k1,1V | tail -1 | cut -d '|' -f2-); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\""
},
"restart_superrack_ws_foh": {
"host": "ws_foh",
"kind": "ssh",
"label": "restart SuperRack on WS FOH (closes it first)",
"params": {
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
"background": false,
"force": true,
"host": "ws_foh",
"newest_of": "",
"process": "SuperRack SoundGrid",
"stop_children": false,
"wait_seconds": 10
},
"run": "pkill -TERM -x 'SuperRack SoundGrid'; i=0; while pgrep -xq 'SuperRack SoundGrid' && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq 'SuperRack SoundGrid' && pkill -KILL -x 'SuperRack SoundGrid'; sleep 1; open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'",
"short": "SUPERRACK",
"template": "mac_restart"
},
"restart_vista_ws_lights": {
"host": "ws_lights",
"kind": "ssh",
"label": "restart Vista on WS Lights (closes it first)",
"params": {
"app": "/Applications/Vista.app",
"background": false,
"force": true,
"host": "ws_lights",
"newest_of": "",
"process": "Vista",
"stop_children": false,
"wait_seconds": 10
},
"run": "pkill -TERM -x Vista; i=0; while pgrep -xq Vista && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Vista && pkill -KILL -x Vista; sleep 1; open /Applications/Vista.app",
"short": "VISTA",
"template": "mac_restart"
},
"superrack_ws_foh": {
"host": "ws_foh",
"kind": "ssh",
"label": "start SuperRack on WS FOH if it is not running",
"params": {
"app": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
"host": "ws_foh",
"process": "SuperRack SoundGrid"
},
"run": "pgrep -xq 'SuperRack SoundGrid' || open '/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app'",
"short": "SUPERRACK",
"template": "mac_start"
},
"vista_ws_lights": {
"host": "ws_lights",
"kind": "ssh",
"label": "start Vista on WS Lights if it is not running",
"params": {
"app": "/Applications/Vista.app",
"host": "ws_lights",
"process": "Vista"
},
"run": "pgrep -xq Vista || open /Applications/Vista.app",
"short": "VISTA",
"template": "mac_start"
},
"x32edit_ws_foh": {
"host": "ws_foh",
"kind": "ssh",
"label": "start X32-Edit on WS FOH if it is not running",
"params": {
"app": "/Applications/X32-Edit.app",
"host": "ws_foh",
"process": "X32-Edit"
},
"run": "pgrep -xq X32-Edit || open /Applications/X32-Edit.app",
"short": "X32 EDIT",
"template": "mac_start"
}
},
"gear": {},
"hosts": {
"ingest": {
"name": "WS ProPresenter",
"networks": [],
"os": "mac",
"role": "the watchdog, Companion, the settings page",
"runs": [
{
"kind": "launchagent",
"label": "org.waterschurch.status-panel",
"name": "the watchdog"
},
{
"kind": "launchagent",
"label": "org.waterschurch.status-settings",
"name": "the settings page"
},
{
"http": "http://127.0.0.1:8000/api/connections",
"kind": "app",
"name": "Companion",
"process": "Companion"
}
],
"self": true
},
"ws_foh": {
"name": "WS FOH",
"os": "mac",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
],
"ssh": "watersmedia@10.2.1.12"
},
"ws_lights": {
"name": "WS Lights",
"os": "mac",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
],
"ssh": "wslighting@10.2.2.2"
},
"ws_tracks": {
"name": "WS Tracks",
"os": "mac",
"role": "found by auto-setup",
"runs": [
{
"kind": "sshd"
}
],
"ssh": "wstracks@10.2.1.219"
}
},
"nav_hold_ms": 1000,
"nav_keys": [
"pagenum"
],
"nav_tap_page": 1,
"network": {
"burst_connections": 4,
"burst_groups": 3,
"burst_window_seconds": 10,
"gateway": "10.2.0.1",
"interface": "en14",
"remember_seconds": 300
},
"page": 19,
"page_hold_ms": 2000,
"page_name": "19\\nStatus",
"restart_page": {
"back_pos": [
3,
0
],
"jump_pos": [
3,
6
],
"keys": [
{
"fix": "restart_superrack_ws_foh",
"id": "rs_superrack",
"label": "SUPERRACK",
"pos": [
0,
1
]
},
{
"fix": "restart_vista_ws_lights",
"id": "rs_vista",
"label": "VISTA",
"pos": [
0,
2
]
},
{
"fix": "restart_ableton_ws_tracks",
"id": "rs_ableton",
"label": "ABLETON",
"pos": [
0,
3
]
},
{
"fix": "restart_propresenter_ingest",
"id": "rs_prop",
"label": "PROP",
"pos": [
0,
4
]
}
],
"page": 20,
"page_name": "20\\nRestart"
},
"service_windows": [
{
"days": [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun"
],
"end": "23:59",
"start": "00:00"
}
],
"site": {
"contacts": "",
"editing_code": "9248",
"id": "ws",
"ingest_hostname": "WS-ProPresenter-3.local",
"ingest_ips": [
"10.2.2.210",
"169.254.104.16"
],
"ingest_name": "WS ProPresenter",
"live_services": [],
"name": "Woonsocket",
"admin_ips": [
"10.6.7.38"
],
"admin_keys": [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPKCGDLncJQfNPpoZUy/5BQWGNtyaU1/xKXwHQWx3ETa christian-laptop@waters"
]
},
"tiles": [
{
"id": "all",
"kind": "master",
"label": "ALL",
"pos": [
0,
1
]
},
{
"id": "service",
"kind": "service",
"label": "ALARMS",
"pos": [
3,
0
]
},
{
"id": "net",
"kind": "network",
"label": "NET",
"pos": [
3,
7
]
},
{
"checks": [
{
"cmd": "pgrep -xq 'SuperRack SoundGrid'",
"fix": "superrack_ws_foh",
"host": "ws_foh",
"msg": "SUPERRACK NOT RUNNING",
"params": {
"host": "ws_foh",
"process": "SuperRack SoundGrid"
},
"template": "mac_program",
"type": "remote"
},
{
"cmd": "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'",
"host": "ws_foh",
"msg": "SOUNDGRID LOST",
"params": {
"host": "ws_foh"
},
"template": "soundgrid",
"type": "remote"
}
],
"id": "superrack",
"label": "SUPERRACK",
"pos": [
0,
2
]
},
{
"checks": [
{
"cmd": "pgrep -xq X32-Edit",
"fix": "x32edit_ws_foh",
"host": "ws_foh",
"msg": "X32 EDIT NOT RUNNING",
"params": {
"host": "ws_foh",
"process": "X32-Edit"
},
"template": "mac_program",
"type": "remote"
}
],
"id": "x32_edit",
"label": "X32 EDIT",
"pos": [
0,
3
]
},
{
"checks": [
{
"conn": "m32",
"type": "conn"
}
],
"id": "m32",
"label": "M32",
"pos": [
0,
4
]
},
{
"checks": [
{
"cmd": "pgrep -xq Vista",
"fix": "vista_ws_lights",
"host": "ws_lights",
"msg": "VISTA NOT RUNNING",
"params": {
"host": "ws_lights",
"process": "Vista"
},
"template": "mac_program",
"type": "remote"
}
],
"id": "vista",
"label": "VISTA",
"pos": [
0,
5
]
},
{
"checks": [
{
"cmd": "pgrep -xq Live",
"fix": "ableton_ws_tracks",
"host": "ws_tracks",
"msg": "ABLETON NOT RUNNING",
"params": {
"host": "ws_tracks",
"process": "Live"
},
"template": "mac_program",
"type": "remote"
}
],
"id": "ableton",
"label": "ABLETON",
"pos": [
0,
6
]
},
{
"checks": [
{
"conn": "Propresenter-API",
"type": "conn",
"ignore_warning": true
},
{
"fix": "propresenter_ingest",
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
"name": "PROP",
"type": "process"
}
],
"id": "prop",
"label": "PROP",
"pos": [
0,
7
]
},
{
"checks": [
{
"address": "10.2.1.219:5004",
"fix": "midi_reconnect",
"host": "ws_foh",
"peer": "WS Tracks",
"session": "WS FOH Mac mini",
"tag": "WS TRACKS",
"type": "midi_peer"
},
{
"address": "10.2.1.219:5004",
"fix": "midi_reconnect",
"host": "ws_lights",
"peer": "WS Tracks",
"session": "Lighting",
"tag": "WS TRACKS",
"type": "midi_peer"
},
{
"address": "10.2.2.2:5004",
"fix": "midi_reconnect",
"peer": "Lighting",
"session": "ProPresenter",
"tag": "PROP LIGHTS",
"type": "midi_peer"
},
{
"fix": "midi_reconnect",
"nets": [
"10.2.0.0/16"
],
"type": "midi_wired"
}
],
"id": "midi",
"label": "MIDI",
"pos": [
1,
1
]
},
{
"checks": [
{
"conn": "ATEM",
"type": "conn"
}
],
"id": "atem",
"label": "ATEM",
"pos": [
1,
2
]
},
{
"checks": [
{
"conn": "Resi",
"type": "conn"
},
{
"conn": "Resi",
"type": "ping"
}
],
"id": "resi",
"label": "RESI",
"pos": [
1,
3
]
},
{
"id": "autofix",
"label": "AUTO-FIX",
"kind": "autofix",
"pos": [
3,
1
]
}
],
"timing": {
"alert_after_seconds": 30,
"fix_cooldown_seconds": 60
},
"variables": {
"command": "sp_cmd",
"heartbeat": "sp_hb",
"state": "sp_state"
}
}
Binary file not shown.
+1184 -104
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -160,6 +160,13 @@ class TestPipeline(unittest.TestCase):
self.assertEqual(ap.history_config(self.data, entries[0]["id"])["debounce_seconds"], 3) # the version before
self.assertEqual(oct(os.stat(os.path.join(self.data, "history", entries[0]["id"] + ".json")).st_mode)[-3:], "600")
def test_settings_edited_in_place_still_restart_the_watchdog(self):
cfg = self.site.load() # the cached object, edited as a script would
cfg["debounce_seconds"] = 5
self.run_apply(cfg, "in place")
names = {s["name"]: s["state"] for s in self.applier.status["steps"]}
self.assertEqual(names["restart"], "ok")
def test_page_only_data_needs_no_restart(self):
new = json.loads(json.dumps(self.cfg))
new["gear"] = {"UHDJr": {"location": "lobby"}}
+22 -1
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}})
+669
View File
@@ -0,0 +1,669 @@
"""Auto-setup for the other locations: an inventory becomes keys that pass every rule, laid out like
North Attleboro's; a running location only gains what is new (never loses anything, never gets back
what someone took off); a computer's join report is checked; the lines people paste are right."""
import io
import json
import os
import socket
import sys
import tarfile
import tempfile
import threading
import time
import unittest
from http.server import ThreadingHTTPServer
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "tests"))
import schema # noqa: E402
import make_page # noqa: E402
from settings import autoconf as ac, campus, discover, server # noqa: E402
from test_settings import get # noqa: E402
from test_status_panel import FakeCompanion, healthy_snapshot # noqa: E402
BASE = json.load(open(os.path.join(ROOT, "sites", "_template", "config.json")))
def small():
return {
"ingest": {"name": "SC Ingest", "hostname": "SC-Ingest.local", "ips": ["10.4.1.1", "10.6.20.5"],
"wired": {"interface": "en0", "ip": "10.4.1.1", "net": "10.4.0.0/16", "gateway": "10.4.0.1"}},
"connections": [
{"label": "HyperDeck_1", "module": "bmd-hyperdeck", "host": "10.4.10.11", "enabled": True},
{"label": "HyperDeck_2", "module": "bmd-hyperdeck", "host": "10.4.10.12", "enabled": True},
{"label": "x32", "module": "behringer-x32", "host": "10.4.1.112", "enabled": True},
{"label": "dante", "module": "audinate-dantecontroller", "host": "", "enabled": True},
{"label": "ProP", "module": "renewedvision-propresenter-api", "host": "10.4.14.20", "enabled": True},
{"label": "Govee", "module": "govee-lights", "host": "", "enabled": True},
{"label": "old_obs", "module": "obs-studio", "host": "10.4.9.9", "enabled": False}],
"dante": {"conn": "dante", "devices": ["SC-X32", "SC-ProP-Mac"]},
"midi": {"": {"SC Ingest": {"port": 5004, "peers": [
{"name": "Lighting PC", "address": "10.4.10.206:5004", "state": "running"},
{"name": "Phone", "address": "10.6.20.77:5004", "state": "running"}]}}}, # Wi-Fi: not made a key
"computers": {
"prop": {"name": "ProPresenter Mac", "os": "mac", "user": "media", "ip": "10.4.14.20", "ips": ["10.4.14.20"], "reachable": True,
"procs": ["ProPresenter", "Finder"], "apps": ["/Applications/ProPresenter.app", "/Applications/Safari.app"]},
"lighting_pc": {"name": "Lighting PC", "os": "windows", "user": "media", "ip": "10.4.10.206", "ips": ["10.4.10.206"],
"reachable": True, "procs": ["Vista.exe", "rtpMIDI.exe", "Arena.exe"], "tasks": ["Start Vista", "Start rtpMIDI"],
"macs": ["aa:bb:cc:dd:ee:ff"]},
"off_pc": {"name": "Office", "os": "windows", "user": "x", "ip": "10.4.3.3", "ips": ["10.4.3.3"], "reachable": False}}}
def big():
inv = small()
inv["connections"] += [{"label": "HyperDeck_%d" % n, "module": "bmd-hyperdeck", "host": "10.4.10.%d" % (10 + n), "enabled": True} for n in range(3, 7)]
inv["connections"] += [{"label": "Axient_%d" % n, "module": "shure-wireless", "host": "10.4.1.%d" % (100 + n), "enabled": True, "channels": 4} for n in range(1, 4)]
inv["connections"] += [{"label": "PSM_%d" % n, "module": "shure-psm1000", "host": "10.4.1.%d" % (110 + n), "enabled": True, "channels": 2} for n in range(1, 9)]
inv["connections"] += [{"label": m, "module": mod, "host": "10.4.5.%d" % i, "enabled": True}
for i, (m, mod) in enumerate([("ATEM", "bmd-atem"), ("VX1000", "novastar-controller"), ("OBS", "obs-studio"),
("RESI", "resi-decoders"), ("tascam", "tascam-da-6400"), ("Arena", "resolume-arena")])]
return inv
def layout_of(cfg):
return {tuple(t["pos"]): t["label"] for t in cfg["tiles"] if t.get("pos")}
class TestBuild(unittest.TestCase):
def test_a_small_campus_gets_one_key_per_thing_in_order_and_passes_the_rules(self):
cfg, seen = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "4821", (21, 22))
self.assertEqual(schema.validate(cfg)[0], [])
labels = [t["label"] for t in cfg["tiles"] if not t.get("kind")]
self.assertEqual(labels, ["HD1", "HD2", "DANTE", "X32", "PROP", "VISTA", "MIDI"]) # REC, AUDIO, SHOW; no group keys
self.assertNotIn("RTPMIDI", labels) # rides in the MIDI key
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
self.assertEqual([c["peer"] for c in midi["checks"] if c["type"] == "midi_peer"], ["Lighting PC"]) # the Wi-Fi phone is not
self.assertEqual(midi["checks"][-2]["type"], "midi_wired")
self.assertEqual(midi["checks"][-1]["params"]["exe"], "rtpMIDI.exe")
prop = next(t for t in cfg["tiles"] if t["label"] == "PROP")
self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "remote"]) # Companion connection + the program
self.assertEqual(prop["checks"][1]["cmd"], "pgrep -xq ProPresenter")
self.assertNotIn("ARENA", labels) # running, but no start task on that PC
self.assertNotIn("off_pc", cfg["hosts"]) # not reachable: not added
self.assertEqual(cfg["hosts"]["lighting_pc"]["ssh"], "media@10.4.10.206")
self.assertEqual((cfg["page"], cfg["restart_page"]["page"]), (21, 22))
self.assertEqual([k["label"] for k in cfg["restart_page"]["keys"]], ["PROP", "VISTA"])
self.assertEqual(cfg["site"]["editing_code"], "4821")
self.assertEqual(cfg["site"]["ingest_ips"][0], "10.4.1.1")
self.assertTrue(cfg["discover"]["auto"])
self.assertIn("hyperdeck", cfg["auto_fix"]["fixes"])
self.assertFalse(any(f.startswith("restart_") for f in cfg["auto_fix"]["fixes"])) # restarts stay a hold
for t in cfg["tiles"]: # every key's hold does something
if not t.get("kind") and not t.get("members"):
fixable = any(c.get("fix") or c["type"] in ("conn", "hyperdeck", "tascam", "shure", "psm") for c in t["checks"])
self.assertTrue(fixable, t["label"])
make_page.build(cfg)
make_page.build_restart(cfg)
self.assertIn("conn:x32", seen)
self.assertIn("dante:SC-X32", seen)
def test_a_programs_app_comes_from_its_running_path(self):
"""Woonsocket's FOH Mac: SuperRack lives three folders down, and zsh had hidden the app list."""
inv = small()
inv["computers"]["foh"] = {"name": "WS FOH", "os": "mac", "user": "watersmedia", "ip": "10.4.1.12", "ips": ["10.4.1.12"], "reachable": True,
"procs": ["SuperRack SoundGrid", "X32-Edit"], "apps": [],
"paths": {"SuperRack SoundGrid": "/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app",
"X32-Edit": "/Applications/X32-Edit.app"}}
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
self.assertEqual(schema.validate(cfg)[0], [])
sr = next(t for t in cfg["tiles"] if t["label"] == "SUPERRACK")
self.assertEqual(sr["checks"][0]["cmd"], "pgrep -xq 'SuperRack SoundGrid'")
self.assertIn("/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app", cfg["fixes"][sr["checks"][0]["fix"]]["run"])
self.assertIn("X32 EDIT", [t["label"] for t in cfg["tiles"]])
self.assertEqual([c.get("msg") for c in sr["checks"]], ["SUPERRACK NOT RUNNING", "SOUNDGRID LOST"])
self.assertIn(":19000 (ESTABLISHED)", sr["checks"][1]["cmd"])
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "wslighting", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True,
"procs": ["Vista", "VistaPlaybackNode"], "apps": ["/Applications/Vista.app"], "paths": {"Vista": "/Applications/Vista.app"}}
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
vista = [t for t in cfg["tiles"] if t["label"] == "VISTA" and t["checks"][0].get("host") == "lights"]
self.assertEqual(vista[0]["checks"][0]["cmd"], "pgrep -xq Vista") # Vista on a Mac, too
self.assertIn("SUPERRACK", [k["label"] for k in cfg["restart_page"]["keys"]])
def test_links_by_name_or_ipv6_get_the_wired_address_of_the_mac_whose_session_it_is(self):
"""Woonsocket: Tracks lists "Lighting" by name and the FOH Mac by an IPv6 address."""
inv = small()
inv["computers"]["tracks"] = {"name": "WS Tracks", "os": "mac", "user": "t", "ip": "10.4.1.219", "ips": ["169.254.45.114", "10.4.1.219"], "reachable": True, "procs": []}
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "l", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True, "procs": []}
inv["computers"]["foh"] = {"name": "WS FOH", "os": "mac", "user": "f", "ip": "10.4.1.12", "ips": ["10.4.1.12"], "reachable": True, "procs": []}
inv["midi"] = {"": {}, "tracks": {"WS Tracks": {"port": 5004, "peers": [{"name": "Lighting", "address": "\tLighting\tlocal."},
{"name": "WS FOH Mac mini", "address": "[fe80::4c3:64d1:835f:f61f]:5004"}]}},
"lights": {"Lighting": {"port": 5006, "peers": [{"name": "WS Tracks", "address": "[fe80::188d]:5004", "state": "running"}]}},
"foh": {"WS FOH Mac mini": {"port": 5004, "peers": [{"name": "WS Tracks", "address": "\tWS Tracks\tlocal."}]}}}
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
got = sorted((c.get("host"), c["peer"], c["address"]) for c in midi["checks"] if c["type"] == "midi_peer")
inv["midi"][""] = {"ProPresenter": {"port": 5004, "peers": [{"name": "Lighting", "address": "10.4.2.2:5006", "state": "running"}]}}
inv["midi"]["lights"]["Lighting"]["peers"].append({"name": "ProPresenter", "address": "10.4.1.1:5004", "state": "running"})
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
pairs = [(c.get("host"), c["peer"]) for c in midi["checks"] if c["type"] == "midi_peer"]
self.assertEqual(len([p for p in pairs if "ProPresenter" in p or ("lights" not in p and "Lighting" in p and None in p)]), 1) # the Companion Mac's link counted once
inv["midi"][""] = {}
inv["midi"]["lights"]["Lighting"]["peers"].pop()
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
# One check per link: Tracks-Lights from Lights (it reports the state), Tracks-FOH from one end only.
self.assertEqual(got, [("lights", "WS Tracks", "10.4.1.219:5004"), ("tracks", "WS FOH Mac mini", "10.4.1.12:5004")])
self.assertEqual(schema.validate(cfg)[0], [])
import status_panel as sp
snap = sp.Snapshot()
snap.remote_midi["tracks"], snap.remote_midi_time["tracks"] = inv["midi"]["tracks"], time.time()
chk = {"type": "midi_peer", "host": "tracks", "session": "WS Tracks", "peer": "Lighting"}
self.assertEqual(sp.check_midi_peer(chk, snap, {"now": time.time()})[0][1].level, sp.OK) # no state: older macOS, configured
def test_a_program_on_the_companion_mac_gets_a_local_check_and_fixes(self):
"""Woonsocket: ProPresenter runs on the Mac that runs Companion."""
inv = small()
inv["computers"] = {}
exe = "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter"
inv["ingest"].update(procs=["ProPresenter"], paths={"ProPresenter": "/Applications/ProPresenter.app"}, exes={"ProPresenter": exe})
inv["connections"] = [dict(c, host="10.4.1.1") if c["label"] == "ProP" else c for c in inv["connections"]]
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
self.assertEqual(schema.validate(cfg)[0], [])
prop = next(t for t in cfg["tiles"] if t["label"] == "PROP")
import re
self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "process"])
match = prop["checks"][1]["match"] # any installed copy counts (an update beside the old)
self.assertTrue(re.search(match, exe) and re.search(match, "/Applications/ProPresenter 21.4.app/Contents/MacOS/ProPresenter"))
fix = cfg["fixes"][prop["checks"][1]["fix"]]
self.assertEqual(fix["kind"], "local")
self.assertTrue(fix["run"].startswith("pgrep -xq ProPresenter || { APP=$(for a in /Applications/ProPresenter*.app"), fix["run"])
self.assertTrue(fix["run"].endswith('open "$APP"; }'), fix["run"]) # the newest version opens
self.assertIn(prop["checks"][1]["fix"], cfg["auto_fix"]["fixes"])
self.assertEqual([k["label"] for k in cfg["restart_page"]["keys"]], ["PROP"])
self.assertEqual(cfg["fixes"][cfg["restart_page"]["keys"][0]["fix"]]["kind"], "local")
def test_every_campus_gets_a_restart_page_and_north_attleboros_auto_fix_hours(self):
inv = small()
inv["computers"] = {}
cfg, _ = ac.build(BASE, inv, {"id": "sc", "name": "Southcoast"}, "1234")
self.assertEqual(cfg["restart_page"]["keys"], []) # computers join later: their keys come then
na = json.load(open(os.path.join(ROOT, "config.json")))
self.assertEqual(cfg["auto_fix"]["windows"], na["auto_fix"]["windows"])
self.assertEqual(cfg["service_windows"], na["service_windows"])
self.assertEqual(cfg["nav_keys"], na["nav_keys"])
make_page.build_restart(cfg)
def test_an_m32_is_called_m32(self):
inv = small()
inv["connections"] = [c for c in inv["connections"] if c["label"] != "x32"] + [
{"label": "m32", "module": "behringer-x32", "host": "10.4.1.146", "enabled": True}]
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
self.assertIn("M32", [t["label"] for t in cfg["tiles"]])
def test_a_big_campus_gets_group_keys_and_the_receivers_fold_first(self):
cfg, _ = ac.build(BASE, big(), {"id": "ab", "name": "Apollo Beach"}, "1111")
self.assertEqual(schema.validate(cfg)[0], [])
top = [layout_of(cfg).get((0, c)) for c in range(2, 8)]
self.assertEqual(top, ["REC", "AUDIO", "RF", "SHOW", "VIDEO", "STREAM"])
unplaced = [t["label"] for t in cfg["tiles"] if not t.get("pos")]
self.assertTrue(unplaced and all(u.startswith(("AXIENT", "PSM")) for u in unplaced), unplaced)
rf = next(t for t in cfg["tiles"] if t["label"] == "RF")
self.assertEqual(len(rf["members"]), 11)
self.assertEqual(layout_of(cfg)[(3, 7)], "NET")
self.assertEqual(layout_of(cfg)[(3, 0)], "ALARMS")
make_page.build(cfg)
class TestExtend(unittest.TestCase):
def setUp(self):
self.cfg, self.seen = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "4821")
def test_nothing_new_adds_nothing(self):
new, added, _ = ac.extend(self.cfg, small(), self.seen)
self.assertEqual(added, [])
self.assertEqual(new, self.cfg)
def test_new_gear_is_added_into_free_cells_and_merged_into_its_key(self):
inv = small()
inv["connections"].append({"label": "HyperDeck_3", "module": "bmd-hyperdeck", "host": "10.4.10.13", "enabled": True})
inv["dante"]["devices"].append("SC-Tracks")
inv["midi"][""]["SC Ingest"]["peers"].append({"name": "SuperRack", "address": "10.4.13.140:5004", "state": "running"})
new, added, now_seen = ac.extend(self.cfg, inv, self.seen)
self.assertEqual(sorted(added), ["Dante device SC-Tracks", "HD3 key", "MIDI link SuperRack"])
self.assertEqual(schema.validate(new)[0], [])
hd = [t for t in new["tiles"] if t["label"] == "HD3"][0]
self.assertTrue(hd.get("pos"))
self.assertIn("SC-Tracks", next(c for t in new["tiles"] for c in t.get("checks", []) if c["type"] == "dante")["devices"])
# Nothing that was there changed place.
self.assertTrue(set(layout_of(self.cfg).items()) <= set(layout_of(new).items()))
again, added2, _ = ac.extend(new, inv, set(self.seen) | set(now_seen))
self.assertEqual(added2, [])
def test_a_program_found_later_joins_its_connections_key(self):
"""Woonsocket got a second PROP key when ProPresenter on the Companion Mac was found after the connection."""
inv = small()
inv["computers"] = {}
cfg, seen = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
exe = "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter"
inv["ingest"].update(procs=["ProPresenter"], paths={"ProPresenter": "/Applications/ProPresenter.app"}, exes={"ProPresenter": exe})
inv["connections"] = [dict(c, host="127.0.0.1") if c["label"] == "ProP" else c for c in inv["connections"]]
new, added, _ = ac.extend(cfg, inv, seen)
self.assertEqual([t["label"] for t in new["tiles"]].count("PROP"), 1)
prop = next(t for t in new["tiles"] if t["label"] == "PROP")
self.assertEqual([c["type"] for c in prop["checks"]], ["conn", "process"])
self.assertIn("PROP checks on the PROP key", added)
self.assertEqual(schema.validate(new)[0], [])
def test_a_rebuild_keeps_what_is_down_right_now(self):
"""Southcoast's rebuild dropped its MIDI key: the ProPresenter-to-Lighting link was down at that moment."""
old, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "1234")
now = small()
now["midi"] = {"": {"SC Ingest": {"port": 5004, "peers": []}}} # the link dropped
now["computers"]["lighting_pc"]["reachable"] = False # and the Lighting PC is off
new, _ = ac.build(BASE, now, {"id": "sc", "name": "Southcoast"}, "1234")
self.assertNotIn("MIDI", [t["label"] for t in new["tiles"]])
kept, words = ac.keep_from(new, old)
self.assertIn("MIDI", [t["label"] for t in kept["tiles"]])
self.assertIn("VISTA", [t["label"] for t in kept["tiles"]])
self.assertIn("lighting_pc", kept["hosts"])
self.assertIn("MIDI link Lighting PC", words)
self.assertEqual(schema.validate(kept)[0], [])
def test_a_key_someone_took_off_is_not_added_back(self):
cfg = json.loads(json.dumps(self.cfg))
cfg["tiles"] = [t for t in cfg["tiles"] if t["label"] != "X32"]
_, added, _ = ac.extend(cfg, small(), self.seen)
self.assertNotIn("X32 key", added)
_, added, _ = ac.extend(cfg, small(), []) # never seen: it would be
self.assertIn("X32 key", added)
def test_north_attleboro_like_config_only_gets_what_it_does_not_watch(self):
cfg = json.load(open(os.path.join(ROOT, "config.json")))
inv = {"ingest": {"wired": {"net": "10.1.0.0/16"}},
"connections": [{"label": "HyperDeck_1", "module": "bmd-hyperdeck", "host": "10.1.10.11", "enabled": True},
{"label": "x32", "module": "behringer-x32", "host": "10.1.1.112", "enabled": True}],
"dante": {}, "midi": {}, "computers": {}}
_, added, _ = ac.extend(cfg, inv, [])
self.assertEqual(added, [])
class TestJoin(unittest.TestCase):
def test_a_join_report_is_checked(self):
e = discover.clean_hello({"user": "media", "name": "Lighting PC", "os": "Windows", "ips": ["10.4.10.206", "bad"],
"macs": ["AA-BB-CC-DD-EE-FF", "nope"], "tasks": ["Start Vista"]}, "10.4.10.206")
self.assertEqual((e["os"], e["ips"], e["macs"], e["tasks"]), ("windows", ["10.4.10.206"], ["aa:bb:cc:dd:ee:ff"], ["Start Vista"]))
for bad in ({"name": "x"}, {"user": "a;rm -rf /"}, {"user": ""}):
with self.assertRaises(ValueError):
discover.clean_hello(bad, "10.4.1.2")
self.assertEqual(discover.clean_hello({"user": "u", "name": "<b>Mac</b>"}, "10.4.1.2")["name"], "bMacb")
def test_the_lines_people_paste(self):
line = campus.install_script("http://10.1.1.1:8020", "ab; rm", "ssh-ed25519 AAAA na")
self.assertIn("zsh install.sh --site ab", line) # the id is cleaned
self.assertIn("--name 'Apollo Beach'", line)
self.assertIn("SUPPORT='ssh-ed25519 AAAA na'", line)
cfg = {"site": {"ingest_name": "SC Ingest", "ingest_ips": ["10.4.1.1"]}}
ps = campus.join_windows(cfg, "http://10.4.1.1:8020")
self.assertIn("'Vista.exe' = 'Vista'", ps)
self.assertIn("Invoke-RestMethod -Method Post -Uri 'http://10.4.1.1:8020/api/hello'", ps)
self.assertNotIn("{{", ps)
class TestRoutes(unittest.TestCase):
@classmethod
def setUpClass(cls):
snap = healthy_snapshot(time.time())
FakeCompanion.store = {"conns": snap.conns, "vars": dict(snap.vars), "custom": {}, "restarts": []}
cls.fake = ThreadingHTTPServer(("127.0.0.1", 0), FakeCompanion)
threading.Thread(target=cls.fake.serve_forever, daemon=True).start()
cls.data = tempfile.mkdtemp(prefix="sp-campus-")
key = os.path.join(cls.data, "id_test")
open(key + ".pub", "w").write("ssh-ed25519 AAAAC3NzaTESTKEY status-panel@sc\n")
cfg, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "4821")
cfg["companion"] = {"host": "127.0.0.1", "port": cls.fake.server_address[1]}
cfg["ssh_key"] = key
json.dump(cfg, open(os.path.join(cls.data, "config.json"), "w"))
json.dump({"pid": 1, "started_at": time.time(), "config_sha": "x", "version": "test"}, open(os.path.join(cls.data, "started.json"), "w"))
open(os.path.join(cls.data, "status_panel.py"), "w").write("# code\n")
os.makedirs(os.path.join(cls.data, "kit", "sites", "_template"))
open(os.path.join(cls.data, "kit", "install.sh"), "w").write("# installer\n")
open(os.path.join(cls.data, "kit", "sites", "_template", "config.json"), "w").write("{}")
open(os.path.join(cls.data, "outlets.json"), "w").write('{"tuya": {"access_secret": "never-shipped"}}')
sock = socket.socket(); sock.bind(("127.0.0.1", 0)); cls.port = sock.getsockname()[1]; sock.close()
cls.site, cls.servers = server.serve(cls.data, cls.port, ["127.0.0.1"])
@classmethod
def tearDownClass(cls):
for s in cls.servers:
s.shutdown()
cls.fake.shutdown()
def test_hello_registers_a_computer_without_the_code(self):
status, body = get(self.port, "/api/hello", {"user": "media", "name": "Tracks Mac", "os": "mac", "ips": ["10.4.3.227"]})
self.assertEqual(status, 200)
self.assertIn("Tracks Mac", discover.registered(self.data))
self.assertTrue(self.site.discover_poke.is_set())
self.assertEqual(get(self.port, "/api/hello", {"name": "no user"})[0], 400)
status, body = get(self.port, "/api/discover")
d = json.loads(body)
self.assertEqual(d["join"]["mac"], "curl -fsSL http://127.0.0.1:%d/join | sh" % self.port)
self.assertIn("Tracks Mac", d["joined"])
def test_join_lines_carry_this_macs_key_and_address(self):
status, body = get(self.port, "/join")
self.assertEqual(status, 200)
self.assertIn("KEY='ssh-ed25519 AAAAC3NzaTESTKEY status-panel@sc'", body.decode())
self.assertIn("http://127.0.0.1:%d/api/hello" % self.port, body.decode())
status, body = get(self.port, "/join.ps1")
self.assertEqual(status, 200)
self.assertIn("$IngestIPs = @('10.4.1.1', '10.6.20.5')", body.decode())
def test_the_program_kit_has_the_code_and_never_the_settings(self):
status, body = get(self.port, "/campus/program.tgz")
self.assertEqual(status, 200)
names = tarfile.open(fileobj=io.BytesIO(body), mode="r:gz").getnames()
self.assertIn("status_panel.py", names)
self.assertIn("install.sh", names)
self.assertIn("sites/_template/config.json", names)
self.assertFalse([n for n in names if n.endswith(("config.json",)) and not n.startswith("sites/_template")])
self.assertNotIn("outlets.json", names)
status, body = get(self.port, "/campus/install?id=ws")
self.assertIn("--site ws", body.decode())
self.assertIn("SUPPORT='ssh-ed25519 AAAAC3NzaTESTKEY", body.decode()) # this location can help the new one
status, body = get(self.port, "/location/install?id=ws") # the address people see now
self.assertEqual(status, 200)
self.assertIn("/location/program.tgz", body.decode())
self.assertIn("settings.location setup", body.decode())
def test_look_now_needs_the_code(self):
self.assertEqual(get(self.port, "/api/discover/now", {})[0], 401)
if __name__ == "__main__":
unittest.main()
class TestCampusesView(unittest.TestCase):
"""North Attleboro alone shows every location: a copy of each status page (view only) and a LOCATIONS page."""
def test_the_campus_page_is_view_only_cells_with_a_way_back(self):
from settings import campuses as cv
import push_page
na = json.load(open(os.path.join(ROOT, "config.json")))
export = cv.build_view(na, 99, 98)
ctrls = export["page"]["controls"]
self.assertEqual(export["page"]["name"], "99\\nLocation")
self.assertIn("LOCATIONS", json.dumps(ctrls["3"]["0"])) # where ALARMS sits on every status page
self.assertIn("$(custom:sp_view)", json.dumps(ctrls["3"]["0"]["style"])) # it says which location is shown
cells = [ctl for row in ctrls.values() for ctl in row.values() if "location view" in (ctl.get("options") or {}).get("notes", "")]
self.assertEqual(len(cells), 28)
acts = cells[0]["steps"]["0"]["action_sets"]
self.assertEqual(sorted(acts), ["2000", "down", "up"]) # tap = acknowledge, hold = fix, there
self.assertIn("sp_view_cmd", json.dumps(acts))
self.assertEqual(cv.campus_command([{"id": "hd1", "pos": [0, 2]}, {"id": "midi", "pos": [1, 3]}],
"tap 0 2 1000;hold 1 3 1000;hold 3 3 1000;hold 1 3 900", 1005),
["ack hd1", "restart midi 1000"]) # an empty cell and a stale hold are dropped
self.assertIn("sp_state_view", json.dumps(cells[0]["style"]))
self.assertTrue(push_page.safe_target(export)[0]) # a rebuild may overwrite it
def test_a_campus_state_lands_on_its_cells(self):
from settings import campuses as cv
ws, _ = ac.build(BASE, small(), {"id": "ws", "name": "Woonsocket"}, "1234")
keys = [{"id": t["id"], "pos": t["pos"]} for t in ws["tiles"] if t.get("pos")]
out = cv.view_state({"hd1": {"x": "HD1\nREC", "b": 1}, "all": {"x": "ALL OK"}}, keys)
hd1 = next(k for k in keys if k["id"] == "hd1")
self.assertEqual(out[cv.cell_id(*hd1["pos"])]["x"], "HD1\nREC")
self.assertEqual(out["c_0_1"]["x"], "ALL OK")
self.assertEqual(out["c_2_7"]["x"], "") # nothing there: blank
def test_the_campuses_page_puts_this_campus_first_and_taps_to_each(self):
from settings import campuses as cv
na = json.load(open(os.path.join(ROOT, "config.json")))
roster = {"na": {"name": "North Attleboro"}, "ws": {"name": "Woonsocket"}, "ab": {"name": "Apollo Beach"}}
export = cv.build_overview(na, roster, 98, 99)
keys = [(r, c, ctl) for r, row in export["page"]["controls"].items() for c, ctl in row.items() if "'location " in (ctl.get("options") or {}).get("notes", "")]
self.assertEqual([(r, c) for r, c, _ in keys], [("0", "1"), ("0", "2"), ("0", "3")])
rs = export["page"]["controls"]["1"]["3"] # WS's RESTART key, under its ALL key
acts = rs["steps"]["0"]["action_sets"]["up"]
self.assertEqual([a["options"].get("value", {}).get("value") for a in acts[:2]] + [acts[2]["options"]["page"]["value"]], ["WS", "RESTART", 99])
self.assertEqual(cv.campus_command([{"id": "rs_prop", "pos": [0, 1]}], "tap 0 1 1000;hold 0 1 1000", 1001, restart=True),
["force rs_prop 1000"]) # on its Restart page a hold forces, a tap does nothing
self.assertIn("'location na'", keys[0][2]["options"]["notes"])
self.assertIn("page 19", keys[0][2]["options"]["notes"])
ws_key = next(k for _, _, k in keys if "'location ws'" in k["options"]["notes"])
acts = ws_key["steps"]["0"]["action_sets"]["up"]
self.assertEqual([a["definitionId"] for a in acts], ["custom_variable_set_value", "custom_variable_set_value", "set_page"])
self.assertEqual((acts[0]["options"]["value"]["value"], acts[1]["options"]["value"]["value"], acts[2]["options"]["page"]["value"]), ("WS", "", 99))
def test_a_campus_tells_the_hub_and_learns_nothing_back(self):
from settings import campuses as cv
data_dir = tempfile.mkdtemp(prefix="sp-cv-")
cv.save(data_dir, {"home": "http://10.1.1.1:8020", "list": {"na": {"url": "x"}, "ab": {"url": "y"}}})
class FakeSite:
def __init__(self):
self.data_dir = data_dir
def load(self):
return {"site": {"id": "ws", "name": "Woonsocket"}}
sent = []
real = cv.http_json
cv.http_json = lambda url, body=None, timeout=3: sent.append((url, body)) or {"ok": True}
real_url = cv.my_url
cv.my_url = lambda cfg: "http://10.5.1.1:8020"
try:
m = cv.Mirror(FakeSite())
data = m.sync_registry()
m.poll()
listed = m.refresh_pages()["list"]
self.assertEqual({k: {f: v for f, v in e.items() if f != "user"} for k, e in listed.items()},
{"ws": {"id": "ws", "name": "Woonsocket", "url": "http://10.5.1.1:8020"}})
self.assertTrue(listed["ws"]["user"]) # the account the hub backs it up with
finally:
cv.http_json, cv.my_url = real, real_url
self.assertEqual(list(data["list"]), ["ws"]) # the others are forgotten
self.assertTrue(all(u.endswith("/api/campus/hello") for u, _ in sent)) # it only ever says hello
class TestCampusHello(TestRoutes):
def test_campus_hello_is_checked(self):
self.assertEqual(get(self.port, "/api/campus/hello", {"id": "ws", "name": "Woonsocket", "url": "http://10.5.1.1:8020"})[0], 200)
for bad in ({"id": "WS; rm", "url": "http://10.5.1.1:8020"}, {"id": "ws", "url": "http://evil.example.com:80"},
{"id": "ws", "url": "http://8.8.8.8:8020"}):
self.assertEqual(get(self.port, "/api/campus/hello", bad)[0], 400, bad)
from settings import campuses as cv
self.assertEqual(cv.load(self.data)["list"]["ws"]["url"], "http://10.5.1.1:8020")
get(self.port, "/api/campus/hello", {"id": "new", "url": "http://10.5.1.9:8020"})
get(self.port, "/api/campus/hello", {"id": "sc", "name": "Southcoast", "url": "http://10.5.1.9:8020"})
self.assertNotIn("new", cv.load(self.data)["list"]) # same Mac, now named: the old entry goes
class TestStandalone(unittest.TestCase):
"""Apollo Beach runs its own panel (2026-10-21): it tells nobody, keeps no location list, backs itself up."""
def test_a_standalone_campus_tells_nobody_and_is_no_hub(self):
from settings import campuses as cv
data_dir = tempfile.mkdtemp(prefix="sp-sa-")
cv.save(data_dir, {"standalone": True})
self.assertFalse(cv.Mirror.is_hub(cv.load(data_dir)))
self.assertTrue(cv.Mirror.is_hub({})) # NA: no home
self.assertFalse(cv.Mirror.is_hub({"home": "http://10.1.1.1:8020"}))
class FakeSite:
def __init__(self):
self.data_dir = data_dir
def load(self):
return {"site": {"id": "ab", "name": "Apollo Beach"}}
sent = []
real, real_url = cv.http_json, cv.my_url
cv.http_json = lambda url, body=None, timeout=3: sent.append(url) or {"ok": True}
cv.my_url = lambda cfg: "http://10.4.1.1:8020"
try:
m = cv.Mirror(FakeSite())
data = m.sync_registry()
m.poll()
m.refresh_pages()
finally:
cv.http_json, cv.my_url = real, real_url
self.assertEqual(sent, [])
self.assertEqual(list(data["list"]), ["ab"])
def test_setup_marks_it_standalone_and_drops_any_home(self):
from settings import campuses as cv
data_dir = tempfile.mkdtemp(prefix="sp-sa-")
cv.save(data_dir, {"home": "http://10.1.1.1:8020"})
real = campus.is_fresh
campus.is_fresh = lambda cfg: False # set up already: only the look runs
real_look = campus.look_and_add
campus.look_and_add = lambda site, say=print, force=False: None
real_site = server.Site
server.Site = lambda d: type("S", (), {"load": lambda self: {}})()
try:
campus.setup(data_dir, "ab", "Apollo Beach", say=lambda *a: None, standalone=True)
finally:
campus.is_fresh, campus.look_and_add, server.Site = real, real_look, real_site
data = cv.load(data_dir)
self.assertTrue(data.get("standalone"))
self.assertNotIn("home", data)
def test_the_standalone_zip_has_the_program_an_installer_and_no_settings(self):
import zipfile
sys.path.insert(0, os.path.join(ROOT, "tools"))
import standalone
out = tempfile.mkdtemp(prefix="sp-zip-")
path = standalone.build("ab", "Apollo Beach", out)
with zipfile.ZipFile(path) as z:
names = z.namelist()
inst = z.read("Apollo Beach Status Panel/install.command").decode()
mode = z.getinfo("Apollo Beach Status Panel/install.command").external_attr >> 16
top = "Apollo Beach Status Panel/program/"
for f in ("status_panel.py", "install.sh", "settings/campus.py", "sites/_template/config.json", "backup/backup-campuses.sh"):
self.assertIn(top + f, names)
for never in ("config.json", "outlets.json", "campuses.json", "sites/ws/config.json", "sites/sc/config.json"):
self.assertNotIn(top + never, names)
self.assertFalse([n for n in names if "__pycache__" in n or "/._" in n])
self.assertIn("Apollo Beach Status Panel/READ ME FIRST.txt", names)
self.assertTrue(mode & 0o100)
self.assertIn("--standalone", inst)
self.assertNotIn("10.1.1.1", inst)
self.assertNotIn("authorized_keys", inst) # no one else's key is let in
class TestSwitchKeys(unittest.TestCase):
def test_a_new_campus_gets_auto_fix_by_alarms_and_a_rebuild_keeps_its_own_switches(self):
new, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "1234")
af = [t for t in new["tiles"] if t.get("kind") == "autofix"]
self.assertEqual([t["pos"] for t in af], [[3, 1]]) # beside ALARMS
self.assertEqual(schema.validate(new)[0], [])
old = json.loads(json.dumps(new))
old["tiles"].append({"id": "lights", "label": "LIGHTS", "kind": "bypass", "pos": [2, 7], "bypass": {"peers": ["Lighting"]}})
again, _ = ac.build(BASE, small(), {"id": "sc", "name": "Southcoast"}, "1234")
kept_cfg, kept = ac.keep_from(again, old)
lights = [t for t in kept_cfg["tiles"] if t["id"] == "lights"]
self.assertEqual((len(lights), lights[0]["pos"], "LIGHTS" in kept), (1, [2, 7], True))
self.assertEqual(len([t for t in kept_cfg["tiles"] if t.get("kind") == "autofix"]), 1)
class TestJoinWindowsAdmin(unittest.TestCase):
def test_the_pc_line_stops_with_plain_words_when_not_administrator(self):
cfg = json.load(open(os.path.join(ROOT, "sites", "sc", "config.json")))
text = campus.join_windows(cfg, "http://10.5.2.60:8020")
check = text.index("IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)")
self.assertLess(check, text.index("Get-WindowsCapability")) # before anything needs it
self.assertIn("Run as administrator", text)
class TestHelperJoinsLater(unittest.TestCase):
def test_rtpmidi_on_a_pc_that_joins_later_lands_on_the_midi_key(self):
inv = small()
inv["computers"].pop("lighting_pc")
cfg, seen = ac.build(BASE, inv, {"id": "sc", "name": "Southcoast"}, "1234")
midi = next((t for t in cfg["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", []))), None)
self.assertIsNotNone(midi) # the MIDI key is there first
new, added, now_seen = ac.extend(cfg, small(), seen)
midi = next(t for t in new["tiles"] if any(c.get("type") == "midi_wired" for c in t.get("checks", [])))
self.assertTrue(any((c.get("params") or {}).get("exe") == "rtpMIDI.exe" for c in midi["checks"]), added)
self.assertTrue(any("RTPMIDI" in a for a in added), added)
self.assertEqual(schema.validate(new)[0], [])
class TestLocationsBand(unittest.TestCase):
"""NA's page keys name the other locations with a problem in blue; red stays NA's own (2026-10-21)."""
def test_the_summary_names_locations_down_and_flashes_until_acknowledged(self):
from settings import campuses as cv
now = 1000.0
st = lambda x, b, b2: {"hb": now - 1, "state": json.dumps({"all": {"x": x, "b": b, "b2": b2}})}
remote = {"ws": st("ALL OK", 1, 1), "sc": st("2 DOWN\nPROP LIGHTS", 0xFF0000, 0)}
self.assertEqual(cv.remote_summary(["sc", "ws", "ab"], remote, now), {"t": 1000, "n": 2, "x": "AB? SC", "f": 1})
remote["sc"] = st("1 DOWN\nX", 0x8B0000, 0x8B0000) # acknowledged at SC: steady
self.assertEqual(cv.remote_summary(["sc", "ws"], remote, now), {"t": 1000, "n": 1, "x": "SC", "f": 0})
remote["sc"] = dict(remote["sc"], hb=now - 60) # stopped answering
self.assertEqual(cv.remote_summary(["sc"], remote, now)["x"], "SC?")
def test_only_a_page_key_set_for_it_carries_the_band(self):
na = json.load(open(os.path.join(ROOT, "config.json")))
names = lambda cfg: [l.get("name") for l in make_page.nav_key("pagenum", cfg["variables"], 19, cfg=cfg)["style"]["layers"]]
self.assertIn("Locations", names(dict(na, nav_locations_band=True)))
self.assertNotIn("Locations", names(dict(na, nav_locations_band=False)))
band = [l for l in make_page.nav_key("pagenum", na["variables"], 19, cfg=dict(na, nav_locations_band=True))["style"]["layers"]
if l.get("name") == "Locations"][0]
self.assertNotIn(str(0xFF0000), json.dumps(band)) # never red
class TestAllAutoFix(unittest.TestCase):
def test_the_key_names_the_paused_locations_and_sits_where_auto_fix_does(self):
from settings import campuses as cv
st = lambda x: json.dumps({"autofix": {"x": x}})
states = {"na": st("AUTO-FIX\nON\nTIL 9PM"), "sc": st("AUTO-FIX\nPAUSED\nTIL SUN 2PM"), "ws": st("AUTO-FIX\nOFF\nON SUN 5AM"), "ab": None}
self.assertEqual(cv.all_autofix_summary(states, 100.0), {"t": 100, "paused": 1, "x": "SC\nPAUSED"}) # WS: outside its hours
self.assertEqual(cv.all_autofix_summary({"na": st("AUTO-FIX\nON\nTIL 9PM")}, 100.0)["x"], "ALL ON")
na = json.load(open(os.path.join(ROOT, "config.json")))
page = cv.build_overview(na, {"na": {"name": "North Attleboro"}, "sc": {"name": "Southcoast"}}, 98, 99)
key = page["page"]["controls"]["3"]["1"]
self.assertIn("all autofix", key["options"]["notes"])
self.assertIn("sp_all_cmd", json.dumps(key["steps"]))
class TestLongerHolds(unittest.TestCase):
def test_na_page_key_goes_to_status_at_2s_and_locations_at_3s(self):
na = json.load(open(os.path.join(ROOT, "config.json")))
cfg = dict(na, nav_hold_ms=2000, nav_hold_more=[{"ms": 3000, "page": 98}])
st = make_page.nav_key("pagenum", cfg["variables"], 19, 2000, 1, cfg)["steps"]["0"]
pages = {k: [a["options"]["page"]["value"] for a in v] for k, v in st["action_sets"].items()}
self.assertEqual(pages, {"down": [], "up": [1], "2000": [19], "3000": [98]})
self.assertEqual(st["options"]["runWhileHeld"], [2000, 3000])
self.assertEqual(schema.validate(cfg)[0], [])
class TestPcScripts(unittest.TestCase):
"""The PC scripts: the admin check after the whole header line, the admin laptop let in, one line per PC."""
def cfg(self):
cfg = json.load(open(os.path.join(ROOT, "config.json")))
cfg["site"]["admin_ips"] = ["10.6.7.38"]
cfg["site"]["admin_keys"] = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY christian-laptop@waters"]
return cfg
def test_the_admin_check_never_splits_a_line(self):
from settings import scripts
text = scripts.with_admin_check(scripts.windows_setup(self.cfg(), "bibletv"))
lines = text.split("\n")
i = next(i for i, ln in enumerate(lines) if ln.startswith("if (-not ([Security.Principal"))
self.assertTrue(lines[i - 1].startswith("# Status panel remote access for "))
self.assertEqual(lines[i + 2], "}") # nothing left after the brace
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222', '10.6.7.38')", text) # the laptop through the firewall
self.assertIn("christian-laptop@waters", text)
join = campus.join_windows(self.cfg(), "http://10.5.2.60:8020").split("\n")
self.assertTrue(join[1].startswith("# Status panel remote access for "))
self.assertEqual(join[4], "}")
def test_a_bad_admin_key_is_refused(self):
from settings import scripts
cfg = self.cfg()
cfg["site"]["admin_keys"] = ["ssh-ed25519 AAAA'; rm -rf / #"]
with self.assertRaises(ValueError):
scripts.admin_keys(cfg)
def test_the_mac_join_line_takes_the_admin_key_too(self):
from settings import scripts
real = scripts.public_key
scripts.public_key = lambda cfg: ("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPANELKEY status-panel@sc", "status-panel@sc")
try:
text = campus.join_mac(self.cfg(), "http://10.5.2.60:8020")
finally:
scripts.public_key = real
self.assertIn("for A in 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEY christian-laptop@waters'; do", text)
+626
View File
@@ -0,0 +1,626 @@
"""The READY page, offline: readiness.py's verdicts on this weekend's checklist (from trimmed copies of
what PCO, ProPresenter, the Ableton set, ProdDash and Resolume really said on 2026-10-14), and how the
watchdog and the page builder show them. Run: python3 -m unittest discover -s tests"""
import json
import logging
import os
import sys
import unittest
from datetime import datetime
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import make_page # noqa: E402
import readiness as rd # noqa: E402
import status_panel as sp # noqa: E402
sp.log.addHandler(logging.NullHandler())
sp.log.propagate = False
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
CONFIG["tiles"] = [t for t in CONFIG["tiles"] if t.get("kind") != "autofix"] # ALARMS doing both, as these tests expect
# The tests pin their own READY hours (the location sets its own on the settings page).
CONFIG["ready"]["due"] = [{"days": ["sat"], "start": "12:00", "end": "23:59"}, {"days": ["sun"], "start": "00:00", "end": "13:30"}]
RC = CONFIG["ready"]
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
SATURDAY = datetime(2026, 10, 17, 16, 0).timestamp() # inside "due": gaps are red
SUNDAY_PLAN = datetime(2026, 10, 18, 9, 30).timestamp()
def item(title, type_="item", position="during", description="", notes=None):
return {"id": "i-" + title, "note_ids": {k: "n-" + title for k in (notes or {})}, "title": title, "type": type_,
"position": position, "description": description, "notes": notes or {}}
def plan():
return {"id": "92013595", "title": "Part 8", "dates": "October 18, 2026", "time": SUNDAY_PLAN,
"items": [
item("Doors Open", "header"),
item("Preservice", position="pre"),
item("MC Announcements", "media", "pre", "- Celebrate recovery\n- WK Team night\n- Peru missions trip\n- Family skate night"),
item("Worship", "header"),
item("Rejoice Again", "song", notes={"LTC": "00:01:17:26"}),
item("Can't Help But Praise (Live)", "song", notes={"LTC": "00:06:43:21"}),
item("Holy Forever", "song", notes={"LTC": "00:13:33:07"}),
item("Baptisms", "media", description="730 930 None\n1130 Samantha Lopez"),
item("Message", "header"),
item("Message", description="Pastor Tim Live"),
item("Covered By The Blood TAG", "song", notes={"LTC": "00:24:13:07"}),
item("Postservice", "song", "post"),
],
"team": [
{"name": "Jonah Perdue", "position": "[Leader]", "team": "Band", "status": "C"},
{"name": "Krista Perdue", "position": "Vocalist", "team": "Band", "status": "C"},
{"name": "Joshua Tineo", "position": "Vocalist", "team": "Band", "status": "D"},
{"name": "Kevin Michel", "position": "Piano/Pad", "team": "Band", "status": "C"},
{"name": "Luis Decamps", "position": "Drums", "team": "Band", "status": "D"},
{"name": "Anthony Hoyt", "position": "MC", "team": "Communication", "status": "C"},
{"name": "Tim Hatch", "position": "Speaker", "team": "Communication", "status": "C"},
{"name": "Chris Meireles", "position": "Audio - FOH", "team": "Sunday Production", "status": "C"},
]}
def slide(label="", text="", notes="", enabled=True):
return {"label": label, "text": text, "notes": notes, "enabled": enabled}
def pres(name, slides, timeline=False, cues=None):
return {"name": name, "path": "/Volumes/MEDIA/ProPresenter/Libraries/%s.pro" % name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
"cues": len(slides) if cues is None else cues, "slides": slides}
OLD_NOTES = "- Operation Christmas child\n- 5k\n- Movie night\n- Flourish"
def prop():
song = lambda name, bg="Heavenly_Lights_1_4k.mp4": pres(name, [slide(bg, "Verse"), slide("", "Chorus")], timeline=True)
return {"playlist": "Where is Your Faith? - Part 8 - October 18, 2026", "items": [
{"name": "Doors Open", "type": "header", "pco": True, "pres": None},
{"name": "Preservice", "type": "presentation", "pco": True, "pres": pres("Preservice", [slide("CLICK THIS TO START")])},
{"name": "MC Announcements", "type": "presentation", "pco": True,
"pres": pres("MC Announcements", [slide("ANNOUNCEMENTS", notes=OLD_NOTES)] + [slide(notes=OLD_NOTES)] * 4)},
{"name": "Worship", "type": "header", "pco": True, "pres": None},
{"name": "Rejoice Again - [ Full ]", "type": "presentation", "pco": True, "pres": song("Rejoice Again")},
{"name": "Can't Help But Praise LM - [ Full ]", "type": "presentation", "pco": True, "pres": song("Can't Help But Praise LM")},
{"name": "Holy Forever - [ Full ]", "type": "presentation", "pco": True, "pres": song("Holy Forever", bg="")},
{"name": "Baptisms", "type": "presentation", "pco": True,
"pres": pres("Baptisms", [slide("Zoey 1130"), slide("LIVE BAPTISM"), slide("Bobby 1130")])},
{"name": "Message", "type": "header", "pco": True, "pres": None},
{"name": "Message Bumper", "type": "presentation", "pco": True, "pres": pres("Message Bumper", [slide("Bumper.mp4")])},
{"name": "Covered By The Blood TAG - [ TAG ]", "type": "presentation", "pco": True,
"pres": pres("Covered By The Blood TAG", [slide("Grainyents_Dark_2_4k.mp4")], timeline=False, cues=0)},
{"name": "Postservice", "type": "presentation", "pco": True, "pres": pres("Postservice", [slide("24_Prayer-1.png")])},
]}
def track(id_, name, kind="AudioTrack", group="-1", vol=1.0, ext=True, clips=((0, 100, None),)):
return {"id": id_, "kind": kind, "name": name, "group": group, "vol": vol, "ext": ext,
"out": "Ext. Out 5/6 Keys" if ext else "Master", "clips": [list(c) for c in clips]}
def ableton(lighting=((0, 50, 12), (100, 150, 8))):
tracks = [
track("1", "LIGHTING CUES", "MidiTrack", clips=lighting),
track("10", "REJOICE AGAIN", "GroupTrack", clips=()),
track("11", "21-Piano", group="10", vol=0.25, clips=((0, 90, None),)),
track("12", "22-Keys", group="10", clips=((0, 90, None),)),
track("20", "CAN'T HELP BUT PRAISE", "GroupTrack", clips=()),
track("21", "44-Piano", group="20", clips=((100, 190, None),)),
track("22", "45-Piano 2", group="20", clips=((100, 190, None),)),
track("23", "Sub group", "GroupTrack", group="20", clips=()),
track("24", "46-EG", group="23", clips=((100, 190, None),), ext=False),
track("30", "HOLY FOREVER", "GroupTrack", clips=()),
track("31", "72-Piano", group="30", vol=0.5, clips=((200, 290, None),)),
track("40", "COVERED BY THE BLOOD", "GroupTrack", clips=()),
track("41", "80-Bass", group="40", vol=0.7, clips=((300, 390, None),)),
]
path = "/Projects/NA/261018 NA Project/261018 NA Project.als"
# The real tempo map and locators of 261018 NA Project (Rejoice 151 BPM, Can't Help 143, Holy Forever 72, Covered 76).
tempo = {"manual": 68.3, "events": [[-63072000, 143], [0, 143], [0, 151], [1016, 151], [1016, 143], [1992, 143],
[1992, 72], [2760, 72], [2760, 76], [5599.5, 76], [5599.5, 71]]}
locators = [{"name": "1- CALL TO WORSHIP ", "time": 0}, {"name": "LOOP", "time": 76},
{"name": "2 - REJOICE AGAIN - Bb", "time": 196}, {"name": "3- CAN'T HELP BUT PRAISE - Eb", "time": 1016},
{"name": "4- HOLY FOREVER - B", "time": 1992}, {"name": "5- COVERED BY THE BLOOD- B", "time": 2760}]
ltc = {"start": 0.0, "name": "LTC_00000000_60mins_30fps_48000x24", "warped": False, "offset": 0.0}
return {"set": path, "live_set": path, "mtime": WEDNESDAY,
"summary": {"tracks": tracks, "locators": locators, "tempo": tempo, "ltc": ltc},
"bundles": {"AB": {"folder": "/Projects/AB/261018 AB Project", "als": True, "copying": False},
"SC": {"folder": "/Projects/SC/261018 SC Project", "als": False, "copying": False}}}
def lineup(names, plan_id="92013595"):
return {"state": {"plan": {"id": plan_id, "dates": "October 18, 2026"}, "lastError": "",
"slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}}
def composition(deck="Service", empty=(), ignored=(), columns=12):
"""Resolume's "Crosses": a clip in every column of layer 1 but the `empty` ones; layer 2 all empty."""
clip = lambda n, full, ign: {"name": {"value": "Clip %d" % n if full else ""},
"connected": {"value": "Disconnected" if full else "Empty"},
"ignorecolumntrigger": {"value": "On" if ign else "Layer Determined"}}
return {"name": {"value": "Crosses"},
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
"columns": [{"name": {"value": "Theme 10 White" if n == 11 else "Column %d" % n}} for n in range(1, columns + 1)],
"layers": [{"clips": [clip(n, n not in empty, n in ignored) for n in range(1, columns + 1)]},
{"clips": [clip(n, False, False) for n in range(1, columns + 1)]}]}
# Companion as the chain really is: page 17 "White" presses 6/1/4, page 6's 1/4 connects column 11;
# page 17 "Salvation" presses 6/1/5 (column 12) and 17/2/4 (White again); 17/0/1 presses itself.
COMPANION = {
17: {"controls": {"2": {"4": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/4"}}}]}}}}},
"4": {"3": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/5"}}},
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/2/4"}}}]}}}}},
"0": {"1": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/0/1"}}}]}}}}}}},
6: {"controls": {"1": {"4": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/11/connect"}}}]}}}},
"5": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/12/connect"}}}]}}}}}}},
}
MACROS = {"U-WHITE": {"name": "White", "cc": [["Stream Deck", "CC x", "17/2/4"]], "calls": []},
"U-SALV": {"name": "Salvation Moment", "cc": [["Stream Deck", "CC x", "17/4/3"]], "calls": []},
"U-LOOP": {"name": "Loop", "cc": [["Stream Deck", "CC x", "17/0/1"]], "calls": ["U-WHITE"]}}
def macros_found(paths):
"""Holy Forever and Rejoice Again fire White; Baptisms fires the macro that runs White too."""
use = {"Holy Forever": ["U-WHITE"], "Rejoice Again": ["U-WHITE"], "Baptisms": ["U-LOOP"]}
return {"macros": MACROS, "used": {p: use.get(p.rsplit("/", 1)[-1][:-4], []) for p in paths}}
def msgs(line):
return [m for _, m, _ in line.faults]
class TestNames(unittest.TestCase):
def test_titles_match_across_pco_propresenter_and_ableton(self):
self.assertTrue(rd.same("Can't Help But Praise (Live)", "Can't Help But Praise LM - [ Full ]"))
self.assertTrue(rd.same("Can't Help But Praise (Live)", "3- CAN’T HELP BUT PRAISE - Eb"))
self.assertTrue(rd.same("Covered By The Blood TAG", "COVERED BY THE BLOOD"))
self.assertTrue(rd.same("Message", "Message Bumper"))
self.assertFalse(rd.same("Holy Forever", "Holy Spirit"))
self.assertFalse(rd.same("", "Anything"))
def test_lists_in_descriptions(self):
self.assertEqual(rd.bullets("- Celebrate recovery\n- WK Team night\nnot a bullet"), ["Celebrate recovery", "WK Team night"])
self.assertEqual(rd.listed_names("730 930 None\n1130 Samantha Lopez, Bob Ray & Ann Lee"),
["Samantha Lopez", "Bob Ray", "Ann Lee"])
def test_the_plan_is_todays_or_the_next(self):
p = lambda pid, iso: {"id": pid, "attributes": {"sort_date": iso}}
plans = [p("old", "2026-10-11T13:30:00Z"), p("next", "2026-10-18T13:30:00Z"), p("later", "2026-10-25T13:30:00Z")]
self.assertEqual(rd.pick_plan(plans, WEDNESDAY)[0]["id"], "next")
self.assertEqual(rd.pick_plan(plans, datetime(2026, 10, 11, 12, 0).timestamp())[0]["id"], "old") # Sunday itself
self.assertEqual(rd.pick_plan([], WEDNESDAY), (None, None))
def test_week_starts_monday_midnight(self):
self.assertEqual(rd.week_start(WEDNESDAY), datetime(2026, 10, 12).timestamp())
self.assertEqual(rd.week_start(datetime(2026, 10, 12, 0, 0).timestamp()), datetime(2026, 10, 12).timestamp())
class TestPropresenter(unittest.TestCase):
def test_pco_against_the_playlist(self):
out = rd.eval_prop(RC, plan(), prop())
self.assertEqual(msgs(out["prop"]), ["MC SLIDES OLD?", "BAPTISM: NO SAMANTHA"])
self.assertEqual(msgs(out["timecode"]), ["NO TC COVERED BY THE."])
self.assertEqual(msgs(out["backgrounds"]), ["NO BG HOLY FOREVER"])
def test_too_few_announcement_slides_and_a_missing_item(self):
p = plan()
p["items"][2]["description"] += "\n- Fifth thing\n- Sixth thing"
p["items"].insert(4, item("Welcome Video", "media"))
pp = prop()
pp["items"][2]["pres"]["slides"] = [slide(notes=p["items"][2]["description"])] * 3
out = rd.eval_prop(RC, p, pp)
self.assertIn("MC 3/6 SLIDES", msgs(out["prop"]))
self.assertIn("MISSING WELCOME VIDEO", msgs(out["prop"]))
self.assertNotIn("MC SLIDES OLD?", msgs(out["prop"])) # the notes are this week's
def test_no_playlist_for_the_weekend(self):
out = rd.eval_prop(RC, plan(), {"playlist": None, "items": []})
self.assertTrue(all(msgs(line)[0].startswith("NO PLAYLIST") for line in out.values()))
class TestAbleton(unittest.TestCase):
def test_mixed_routed_and_lit(self):
out = rd.eval_ableton(RC, plan(), ableton())
self.assertEqual(msgs(out["tracks"]), [])
# every Can't Help track is at 0 dB, and one of them goes to Master; Holy Forever has no lighting cue
self.assertEqual(msgs(out["mix_foh"]), ["NOT MIXED CANT HELP BUT P.", "TO MASTER CANT HELP BUT P."])
self.assertEqual(out["mix_foh"].faults[0][0], rd.WARN)
self.assertEqual(msgs(out["lights"]), ["NO LIGHTS HOLY FOREVER", "NO LIGHTS COVERED BY THE."])
def test_a_song_with_no_tracks_and_an_old_set_open(self):
al = ableton()
al["summary"]["tracks"] = [t for t in al["summary"]["tracks"] if t["id"] not in ("30", "31")]
al["live_set"] = "/Projects/NA/261011 NA Project/261011 NA Project.als"
out = rd.eval_ableton(RC, plan(), al)
self.assertEqual(msgs(out["tracks"]), ["NO TRACKS HOLY FOREVER", "LIVE HAS OLD SET"])
def test_no_set_for_the_weekend(self):
out = rd.eval_ableton(RC, plan(), {"set": None, "bundles": {}})
self.assertEqual(msgs(out["tracks"]), ["NO SET 261018"])
def test_tracks_sent_to_the_locations(self):
self.assertEqual(msgs(rd.eval_track_bundle(RC, ableton())), ["NOT SENT SC"])
def test_the_remote_scripts_are_valid_python(self):
params = json.dumps(rd.ableton_params(RC, plan()))
for script in (rd.ALS_SCRIPT % params, rd.COPY_SCRIPT % params, rd.BUNDLE_SCRIPT % json.dumps({"folder": "x", "pattern": "*"})):
compile(script, "remote", "exec")
self.assertEqual(json.loads(params)["date"], "261018")
class TestTimecode(unittest.TestCase):
def test_seconds_through_the_tempo_map(self):
tempo = ableton()["summary"]["tempo"]
self.assertAlmostEqual(rd.seconds_at(tempo, 1016), 403.7086, places=3) # Live's own warp marker
self.assertAlmostEqual(rd.seconds_at(tempo, 2760), 1453.2191, places=3)
self.assertAlmostEqual(rd.seconds_at({"manual": 120, "events": []}, 8), 4.0)
self.assertAlmostEqual(rd.seconds_at({"manual": 0, "events": [[0, 60], [4, 120]]}, 4), 60 * 4 / 60 * __import__("math").log(2))
self.assertEqual(rd.ltc_clip_info("LTC_01000000_60mins_25fps"), (3600.0, 25.0))
self.assertEqual(rd.timecode(813.2191, 30), "00:13:33:07")
def test_tracks_timecode_is_what_pco_says(self):
out = [(s["title"], s["tracks"]) for s in rd.song_ltc(RC, plan(), ableton())]
self.assertEqual(out, [("Rejoice Again", "00:01:17:26"), ("Can't Help But Praise (Live)", "00:06:43:21"),
("Holy Forever", "00:13:33:07"), ("Covered By The Blood TAG", "00:24:13:07")])
self.assertEqual(msgs(rd.eval_ltc(RC, plan(), ableton(), rd.Line())), [])
def test_wrong_or_missing_ltc_in_pco(self):
p = plan()
p["items"][4]["notes"] = {} # Rejoice: no LTC note
p["items"][6]["notes"]["LTC"] = "00:13:30:00" # Holy Forever: moved in the tracks
self.assertEqual(msgs(rd.eval_ltc(RC, p, ableton(), rd.Line())), ["NO LTC REJOICE AGAIN", "LTC WRONG HOLY FOREVER"])
def test_hold_writes_the_tracks_timecode_into_pco(self):
p = plan()
p["items"][4]["notes"], p["items"][4]["note_ids"] = {}, {}
p["items"][6]["notes"]["LTC"] = "00:13:30:00"
songs = rd.song_ltc(RC, p, ableton())
calls, real_get, real_login, real_http = [], rd.pco_get, rd.pco_login, rd.http_json
rd.pco_login = lambda rc: ("Basic x", "275974")
rd.pco_get = lambda auth, path, pages=10: ([{"id": "77", "attributes": {"name": "LTC"}}], [])
rd.http_json = lambda url, headers=None, method="GET", data=None, timeout=10: calls.append(
(method, url.split("/plans/")[1], json.loads(data)["data"]["attributes"])) or {}
try:
self.assertTrue(rd.write_ltc(RC, songs, "275974", "92013595"))
finally:
rd.pco_get, rd.pco_login, rd.http_json = real_get, real_login, real_http
self.assertEqual(calls, [
("POST", "92013595/items/i-Rejoice Again/item_notes", {"content": "00:01:17:26", "item_note_category_id": "77"}),
("PATCH", "92013595/items/i-Holy Forever/item_notes/n-Holy Forever", {"content": "00:13:33:07"})])
self.assertTrue(rd.write_ltc(RC, rd.song_ltc(RC, plan(), ableton()), "275974", "92013595")) # all right: no writes
class TestLineupCrossBundle(unittest.TestCase):
def test_lineup_against_pco(self):
line = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "DJ McLean", "Luis Decamps",
"Anthony Hoyt", "Tim Hatch", None]))
self.assertEqual(msgs(line), ["DJ MCLEAN NOT ON PLAN", "LUIS DECAMPS DECLINED", "KEVIN MICHEL MISSING"])
good = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]))
self.assertEqual(msgs(good), [])
old = rd.eval_lineup(RC, plan(), lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"], "91"))
self.assertEqual(msgs(old), ["OLD PLAN"])
def test_short_names_on_the_board(self):
p = plan()
p["team"] += [{"name": "Alex Gaitan", "position": "Drums", "team": "Band", "status": "C"},
{"name": "Alexander Zinni", "position": "Guitar [Bass]", "team": "Band", "status": "C"}]
board = ["Jonah Perdue", "KRISTA", "Kevin M", "AlexG", "Alex Zinni", "Anthony Hoyt", "Tim Hatch", "JONAH", "Luis"]
self.assertEqual(msgs(rd.eval_lineup(RC, p, lineup(board))), ["LUIS DECLINED"])
self.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
def test_columns_follow_macro_companion_resolume(self):
get = lambda n: COMPANION.get(n, {})
self.assertEqual(rd.button_columns(get, "17/2/4"), {11})
self.assertEqual(rd.button_columns(get, "17/4/3"), {11, 12}) # through two buttons
self.assertEqual(rd.button_columns(get, "17/0/1"), set()) # presses itself: no loop
self.assertEqual(rd.macro_columns(MACROS, "U-LOOP", get), {11}) # a macro that runs another
found = macros_found([it["pres"]["path"] for it in prop()["items"] if it["pres"]])
line = rd.eval_columns(RC, prop(), found, get, composition())
self.assertEqual((msgs(line), line.info), ([], "1 COLUMNS OK"))
line = rd.eval_columns(RC, prop(), found, get, composition(empty=(11,)))
self.assertEqual(msgs(line), ["COL 11 EMPTY"])
self.assertIn("Theme 10 White", line.faults[0][2])
self.assertIn("Holy Forever", line.faults[0][2])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(ignored=(11,)))), ["COL 11 EMPTY"])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(columns=10))), ["COL 11 MISSING"])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition("Test"))), ["WRONG DECK"])
self.assertEqual(rd.eval_columns(RC, prop(), {"macros": {}, "used": {}}, get, composition()).info, "NO COLUMNS USED")
def test_the_macro_reader_runs(self):
compile(rd.MACRO_SCRIPT % json.dumps({"paths": ["/x.pro"]}), "remote", "exec")
def test_prop_bundle_in_this_weekends_folder(self):
rc = dict(RC, prop_bundle_folder="~/Shared drives/Location Assets/{date}")
plan = {"time": datetime(2026, 10, 25, 9, 0).timestamp()}
folder = rd.prop_bundle_folder(rc, plan)
self.assertEqual(folder, "~/Shared drives/Location Assets/261025")
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["WORSHIP BUNDLE.probundle", WEDNESDAY - 86400 * 10]], WEDNESDAY, folder)), [])
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [], WEDNESDAY, folder)), ["NOT IN 261025"])
def test_prop_bundle_this_week(self):
since = rd.week_start(WEDNESDAY)
rc = dict(RC, prop_bundle_folder="~/Shared drives/Location Assets/ProPresenter") # one folder for every week
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["Message Templates.probundle", WEDNESDAY - 3600]], since)), [])
self.assertEqual(msgs(rd.eval_prop_bundle(rc, [["Old.probundle", since - 86400]], since)), ["NOT SENT (LAST 10/11)"])
def test_mics_from_pco(self):
self.assertEqual(rd.mics_needed(RC, plan()), "2 VOX MC TIM")
class TestLook(unittest.TestCase):
def sources(self, **over):
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
resolume=composition, prop_bundle=lambda plan: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {}))
src.update(over)
return src
def test_everything_at_once(self):
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources())
self.assertEqual(r["plan"]["dates"], "October 18, 2026")
self.assertEqual(set(r["items"]), set(sp.READY_ITEMS))
self.assertEqual(r["items"]["lineup"]["faults"], [])
self.assertEqual(r["items"]["mics"]["info"], "2 VOX MC TIM")
self.assertEqual(r["items"]["track_bundle"]["faults"], [["err", "NOT SENT SC"]])
def test_one_source_down_does_not_hide_the_others(self):
def down(*a):
raise rd.ReadyError("PROP UNREACHABLE")
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(prop=down))
self.assertEqual(r["items"]["prop"]["faults"], [["warn", "PROP UNREACHABLE"]])
self.assertEqual(r["items"]["lineup"]["faults"], [])
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(pco=lambda: (_ for _ in ()).throw(rd.ReadyError("NO PCO LOGIN"))))
self.assertEqual(r["items"]["tracks"]["faults"], [["warn", "NO PCO LOGIN"]])
self.assertEqual(r["items"]["led_cross"]["faults"], [["warn", "NO PCO LOGIN"]]) # no plan, no columns to look at
def test_a_campus_that_only_receives(self):
cfg = json.loads(json.dumps(CONFIG))
for k in ("locations", "prop_bundle_host", "resolume_url", "lineup_url"):
cfg["ready"].pop(k)
cfg["ready"]["prop_url"] = ""
r = rd.look(cfg, now=WEDNESDAY, sources=self.sources())
self.assertEqual(sorted(r["items"]), ["lights", "mics", "mix_foh", "timecode", "tracks"]) # LTC: tracks vs PCO
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
snap.ready = r
tiles = engine.step(snap, WEDNESDAY)
self.assertEqual(tiles["rd_trk_out"]["x"], "TRACKS OUT\nNOT SET UP")
self.assertEqual(tiles["rd_prop"]["x"], "PROP\nNOT SET UP")
class TestWatchdog(unittest.TestCase):
def setUp(self):
self.engine = sp.Engine(CONFIG, None)
self.snap = sp.Snapshot()
self.snap.conns_time = 0
def step(self, now):
self.engine.step(self.snap, now)
return self.engine.step(self.snap, now + 5)
def test_amber_until_due_then_red(self):
self.snap.ready = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources())
tiles = self.step(WEDNESDAY)
self.assertEqual(tiles["rd_trk_out"]["x"], "TRACKS OUT\nNOT SENT SC")
self.assertEqual(tiles["rd_trk_out"]["b"], sp.COLORS["warn"][0][0])
self.assertEqual(tiles["rd_boh"]["x"], "BOH MIX\nTAP WHEN DONE")
self.assertEqual(tiles["rd_cross"]["b"], sp.COLORS["ok"][0][0])
self.assertTrue(tiles["ready"]["x"].startswith("READY\n"))
self.assertIn("TO DO", tiles["ready"]["x"])
# the checklist never reaches ALL: a weekday gap is not an alarm
self.assertNotIn("rd_trk_out", self.engine.detail_ids)
self.assertFalse(any(k.startswith("rd_") for k in self.engine.keys_for("all")))
tiles = self.step(SATURDAY)
self.assertEqual(tiles["rd_trk_out"]["b"], sp.COLORS["err"][0][0]) # due: red, blinking
self.assertEqual(tiles["rd_boh"]["b"], sp.COLORS["err"][0][0])
foh = {res.msg: lv for lv, res, _ in self.engine.items["rd_foh"]}
self.assertEqual(foh, {"NOT MIXED CANT HELP BUT P.": "warn", "TO MASTER CANT HELP BUT P.": "err"}) # NOT MIXED stays a warning
def test_tap_done_lasts_the_week(self):
self.snap.ready = {"time": WEDNESDAY, "items": {}}
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY)
tiles = self.step(WEDNESDAY + 10)
self.assertEqual(tiles["rd_boh"]["x"], "BOH MIX\nDONE WED 15:00")
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY + 20) # tapped again: taken back
self.assertNotIn("mix_boh", self.engine.ready_done(WEDNESDAY + 30))
sp.handle_commands("done rd_boh", self.engine, None, self.snap, WEDNESDAY + 40)
next_monday = datetime(2026, 10, 19, 9, 0).timestamp()
self.assertIn("mix_boh", self.engine.ready_done(SATURDAY))
self.assertNotIn("mix_boh", self.engine.ready_done(next_monday)) # a new week starts clean
self.assertIsNone(self.engine.toggle_done("rd_tracks", WEDNESDAY)) # not a tap key
def test_one_hold_fixes_everything_that_can_be_fixed(self):
look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources(
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test")))
self.snap.ready = look
self.step(WEDNESDAY)
plan = self.engine.fix_plan("ready", self.snap)
fixes = sorted(what for _, kind, what, _ in plan if kind == "fix")
self.assertEqual(fixes, ["ready_copy_tracks", "ready_led_cross", "ready_lineup", "ready_ltc", "ready_recheck"])
ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
self.engine.run_fixes("ready", self.snap, None, WEDNESDAY + 10)
for t in self.engine.fix_threads:
t.join(2)
self.assertEqual(sorted(ran), fixes)
self.assertEqual(self.engine.fix_plan("rd_all", self.snap), plan) # the key on the READY page too
def test_checking_before_the_first_look(self):
tiles = self.step(WEDNESDAY)
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nCHECKING")
self.assertEqual(tiles["ready"]["x"].split("\n")[0], "READY")
class TestPages(unittest.TestCase):
def actions(self, export, row, col):
step = export["page"]["controls"][str(row)][str(col)]["steps"]["0"]["action_sets"]
return {k: [(a["definitionId"], {o: v["value"] for o, v in a["options"].items()}) for a in v] for k, v in step.items()}
def test_status_page_ready_key_opens_the_page(self):
acts = self.actions(make_page.build(CONFIG), 2, 7)
self.assertEqual(acts["up"], [("set_page", {"surfaceId": "self", "page": 21})])
self.assertEqual(acts["2000"][0][1]["value"], "$(custom:sp_cmd);restart ready $(internal:time_unix)")
def test_ready_page(self):
export = make_page.build_ready(CONFIG)
self.assertEqual(export["page"]["name"], "21\\nReady")
self.assertEqual(self.actions(export, 0, 3)["up"][0][1]["value"], "$(custom:sp_cmd);done rd_boh")
self.assertNotIn("2000", self.actions(export, 0, 3)) # a tap key has no hold
self.assertEqual(self.actions(export, 2, 1)["up"][0][1]["value"], "$(custom:sp_cmd);ack rd_lineup")
self.assertIn("2000", self.actions(export, 2, 1))
self.assertEqual(self.actions(export, 3, 0)["up"], [("set_page", {"surfaceId": "self", "page": 19})])
notes = [c["options"]["notes"] for row in export["page"]["controls"].values() for c in row.values() if c and "options" in c]
self.assertTrue(all(any(m in n for m in make_page.MARKERS) for n in notes)) # push_page may overwrite it
def test_settings_page_applies_page_24(self):
from settings import apply as ap
self.assertIn("ready", ap.build_hashes(CONFIG))
moved = json.loads(json.dumps(CONFIG))
moved["ready_page"]["keys"][0]["pos"] = [3, 5]
self.assertEqual(ap.what_apply_does(CONFIG, moved, ap.build_hashes(CONFIG))["pages"], ["ready"])
def test_rules(self):
bad = json.loads(json.dumps(CONFIG))
bad["ready_page"]["keys"][1]["pos"] = bad["ready_page"]["keys"][0]["pos"]
bad["ready_page"]["keys"][0]["checks"][0]["item"] = "nonsense"
bad["ready_page"]["page"] = 19
bad["fixes"]["ready_lineup"]["action"] = "explode"
import schema
errors = "\n".join(schema.validate(bad)[0])
for expect in ("share row 0 column 1", "unknown checklist line nonsense", "cannot be the Status or Restart page",
"unknown READY action explode"):
self.assertIn(expect, errors)
class TestFixes(unittest.TestCase):
def test_lineup_sync_waits_for_the_new_pull(self):
calls, states = [], iter([{"lastFetched": 1}, {"lastFetched": 1}, {"lastFetched": 2, "lastError": ""}])
real = rd.http_json
def fake(url, headers=None, method="GET", data=None, timeout=10):
calls.append((method, url.rsplit("/", 1)[-1]))
return {"ok": True} if method == "POST" else {"state": next(states)}
rd.http_json = fake
try:
self.assertTrue(rd.run_action(CONFIG, "lineup_sync", sleep=lambda s: None))
finally:
rd.http_json = real
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
def test_cross_hold_puts_resolume_back_on_its_deck(self):
calls = []
real = rd.http_json
def fake(url, headers=None, method="GET", data=None, timeout=10):
calls.append((method, url.split("/api/v1/composition")[-1]))
return composition("Test")
rd.http_json = fake
try:
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
finally:
rd.http_json = real
self.assertIn(("POST", "/decks/1/select"), calls)
self.assertFalse(any("/clips/" in path for _, path in calls)) # never plays a clip by itself
class TestSpotifyAndMute(unittest.TestCase):
def test_alarms_muted_stops_spotify_too(self):
cfg = json.loads(json.dumps(CONFIG))
cfg["auto_fix"]["log_dir"] = __import__("tempfile").mkdtemp(prefix="sp-autolog-")
engine, snap = sp.Engine(cfg, None), sp.Snapshot()
wed = datetime(2026, 10, 14, 20, 20).timestamp() # inside Wednesday's auto-fix window
engine.service.set(False, wed) # ALARMS muted
snap.spotify = {"running": True, "state": "paused"}
snap.remote = {"tracks\0pgrep -xq Live": False} # a gentle fix that must still wait
ran = []
engine.executor = lambda c, fix_id, chk: ran.append(fix_id) or True
for t in range(0, 40, 2):
engine.step(snap, wed + t)
engine.auto_fix(snap, None, wed + 40)
for th in engine.fix_threads:
th.join(2)
self.assertEqual(ran, []) # muted: nothing, not even Spotify
engine2 = sp.Engine(cfg, None)
engine2.service.set(True, wed) # alarms on: it plays
engine2.executor = engine.executor
snap.remote = {}
for t in range(0, 40, 2):
engine2.step(snap, wed + t)
engine2.auto_fix(snap, None, wed + 40)
for th in engine2.fix_threads:
th.join(2)
self.assertEqual(ran, ["spotify_play"])
if __name__ == "__main__":
unittest.main()
class TestOtherCampuses(unittest.TestCase):
"""The other locations play the same songs: their plans get North Attleboro's timecode, in its order."""
CAMPUS = {"name": "Apollo Beach", "short": "AB", "service_type": "1113969"}
def campus_plan(self, titles, notes=None):
p = plan()
p["id"], p["service_type"] = "ab-1", "1113969"
p["items"] = [item(t, "song", notes=(notes or {}).get(t)) for t in titles]
return p
def test_songs_match_in_home_order_and_out_of_order_ones_are_skipped(self):
na = rd.song_ltc(RC, plan(), ableton())
titles = [s["title"] for s in na]
self.assertGreaterEqual(len(titles), 3)
# the location plays song 2 first, then 1, then 3: song 1 breaks the order and is skipped; a song NA does not play is skipped too
cp = self.campus_plan([titles[1], titles[0], titles[2], "Something Else"])
res = rd.campus_ltc(RC, plan(), na, self.CAMPUS, cp)
self.assertEqual([s["title"] for s in res["songs"]], [titles[1], titles[2]])
self.assertEqual(res["skipped"], [titles[0], "Something Else"])
self.assertEqual(res["songs"][0]["tracks"], na[1]["tracks"])
line = rd.eval_campus_ltc(RC, res, rd.Line())
self.assertEqual([m for _, m, _ in line.faults], ["AB NO LTC " + rd.short(titles[1], 12), "AB NO LTC " + rd.short(titles[2], 12)])
self.assertTrue(any("left alone" in d for d in line.as_dict()["details"]))
right = self.campus_plan(titles, notes={t: {"LTC": s["tracks"]} for t, s in zip(titles, na)})
self.assertEqual(rd.eval_campus_ltc(RC, rd.campus_ltc(RC, plan(), na, self.CAMPUS, right), rd.Line()).faults, [])
def test_a_song_without_home_timecode_is_matched_but_not_faulted_or_written(self):
"""The set is not on Tracks yet: the location's songs are still in order, nothing is wrong at the location, nothing is written."""
p = plan()
cp = self.campus_plan([it["title"] for it in rd.plan_songs(RC, p)])
res = rd.campus_ltc(RC, p, [], self.CAMPUS, cp)
self.assertEqual(res["skipped"], [])
self.assertTrue(res["songs"] and all(s["tracks"] is None for s in res["songs"]))
self.assertEqual(rd.eval_campus_ltc(RC, res, rd.Line()).faults, [])
self.assertIs(rd.write_ltc(RC, res["songs"], "1113969", "ab-1"), True)
def test_the_fix_writes_every_campus(self):
na = rd.song_ltc(RC, plan(), ableton())
cp = self.campus_plan([s["title"] for s in na])
res = rd.campus_ltc(RC, plan(), na, self.CAMPUS, cp)
rd.LAST["ltc"], rd.LAST["service_type"], rd.LAST["plan_id"] = na, "275974", "92013595"
rd.LAST["ltc_other"] = [res]
calls = []
real_http, real_get, real_login = rd.http_json, rd.pco_get, rd.pco_login
rd.pco_login = lambda rc: ("Basic x", "275974")
rd.pco_get = lambda auth, path, pages=10: ([{"id": "77", "attributes": {"name": "LTC"}}], [])
rd.http_json = lambda url, headers=None, method="GET", data=None, timeout=10: calls.append((method, url)) or {}
try:
cfg = dict(CONFIG, ready=dict(RC, ltc_campuses=[self.CAMPUS]))
self.assertTrue(rd.run_action(cfg, "ltc_to_pco", sleep=lambda s: None))
finally:
rd.http_json, rd.pco_get, rd.pco_login = real_http, real_get, real_login
self.assertEqual(len([c for c in calls if "/1113969/" in c[1] and c[0] == "POST"]), len(res["songs"])) # one note per location song
class TestRunHere(unittest.TestCase):
def test_a_script_for_this_mac_runs_here_without_ssh(self):
cfg = {"hosts": {"ingest": {"self": True}}}
self.assertEqual(rd.run_remote(cfg, "ingest", "import json; print(json.dumps({'ok': 1}))"), {"ok": 1})
+25 -13
View File
@@ -16,26 +16,28 @@ import make_page # noqa: E402
import schema # noqa: E402
import status_panel as sp # noqa: E402
WALKED = ["status_panel.py", "make_page.py", "push_page.py", "nav_keys.py", "netgear.py"]
WALKED = ["status_panel.py", "make_page.py", "push_page.py", "nav_keys.py", "netgear.py", "readiness.py"]
# What a variable holds, by name, when nothing in the function says otherwise (parameters mostly).
NAMES = {"cfg": "top", "config": "top", "chk": "check", "c": "check", "fix": "fix", "f": "fix", "t": "tile",
"tile": "tile", "k": "restart_key", "key": "restart_key", "rp": "restart_page", "w": "window",
"names": "variables", "host": "host", "h": "host", "windows": "windows", "site": "site"}
"names": "variables", "host": "host", "h": "host", "windows": "windows", "site": "site", "rc": "ready"}
ATTRS = {"self.cfg": "top", "engine.cfg": "top", "self.net": "network", "self.auto": "auto", "self.site": "site",
"self.atem": "atem",
"self.atem": "atem", "self.ready_cfg": "ready", "self.failover_cfg": "failover",
"self.tiles": "tiles", "engine.tiles": "tiles", "self.restart_keys": "restart_keys",
"engine.restart_keys": "restart_keys"}
# A key of a top-level dict that holds a record of another kind.
TOP = {"companion": "companion", "network": "network", "variables": "variables", "auto_fix": "auto", "site": "site",
"atem": "atem", "spotify": "spotify", "timing": "timing", "flap": "flap", "hosts": "hosts", "fixes": "fixes",
"tiles": "tiles", "restart_page": "restart_page", "gear": "gears", "service_windows": "windows"}
"tiles": "tiles", "restart_page": "restart_page", "gear": "gears", "service_windows": "windows",
"ready": "ready", "ready_page": "ready_page", "failover": "failover"}
SUB = {("auto", "windows"): "windows", ("site", "live_services"): "windows", ("tile", "checks"): "checks",
("restart_page", "keys"): "restart_keys", ("tile", "members"): "names"}
("restart_page", "keys"): "restart_keys", ("tile", "members"): "names", ("ready", "due"): "windows",
("ready_page", "keys"): "tiles", ("failover", "windows"): "windows"}
ELEMENT = {"tiles": "tile", "checks": "check", "restart_keys": "restart_key", "windows": "window", "fixes": "fix",
"hosts": "host", "gears": "gear"}
# Keys the code reads that are the watchdog's own working data, not settings.
NOT_SETTINGS = {("check", "ip")}
NOT_SETTINGS = {("check", "ip"), ("check", "repoint")} # set by the watchdog on a fix's copy of a check
def dotted(node):
@@ -67,6 +69,10 @@ def collect(path):
return step(base, key)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("items", "values"):
return kind_of(node.func.value, local)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "effective_config":
return "top" # the config with switched-off keys and checks taken out
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("all_tiles", "ready_keys"):
return "tiles" # the Status page's tiles and the READY page's keys
if isinstance(node, ast.Call) and dotted(node.func) == "json.load":
return "jsonfile" # a config file when it lands in cfg / config
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in ("enumerate", "dict", "sorted", "list") and node.args:
@@ -141,7 +147,8 @@ def path_of(kind, key):
"restart_key": "restart_page.keys[].", "restart_page": "restart_page.", "auto": "auto_fix.",
"network": "network.", "variables": "variables.", "site": "site.", "atem": "atem.", "spotify": "spotify.",
"companion": "companion.", "timing": "timing.", "flap": "flap.", "gear": "gear.*.",
"window": "auto_fix.windows[]."}.get(kind)
"window": "auto_fix.windows[].", "ready": "ready.", "ready_page": "ready_page.",
"failover": "failover."}.get(kind)
return None if prefix is None else prefix + key
@@ -170,20 +177,22 @@ class TestSchemaMatchesCode(unittest.TestCase):
def test_every_declared_key_is_read_somewhere(self):
"""Dead settings are caught; keys only the settings page uses are listed here."""
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.contacts", "site.editing_code",
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.admin_ips", "site.admin_keys", "site.contacts", "site.editing_code",
"site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role",
"hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done",
"gear", "gear.*.location", "gear.*.notes", "atem.source_folder", "fixes.*.template", "fixes.*.params",
"gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "ready.ltc_campuses[].name", "ready.ltc_campuses[].short", "ready.ltc_campuses[].service_type", "atem.source_folder", "fixes.*.template", "fixes.*.params",
"tiles[].checks[].template", "tiles[].checks[].params", "spotify.name", "site", "timing",
"fixes", "hosts", "tiles", "flap", "auto_fix", "network", "variables", "companion", "atem", "spotify",
"restart_page", "service_windows", "restart_page.keys", "tiles[].checks", "auto_fix.windows",
"site.live_services", "tiles[].checks[].type", "fixes.*.playlist", "tiles[].checks[].playlist"}
"site.live_services", "tiles[].checks[].type", "fixes.*.playlist", "tiles[].checks[].playlist",
"ready_page", "ready_page.keys", "ready", "discover", "discover.auto"} # discover: settings/campus.py
read = {path_of(kind, key) for _, kind, key, _, _ in all_reads()}
read |= {"service_windows[].days", "service_windows[].start", "service_windows[].end",
"site.live_services[].days", "site.live_services[].start", "site.live_services[].end"}
for w in ("days", "start", "end"):
if "auto_fix.windows[]." + w in read:
read |= {"service_windows[].%s" % w, "site.live_services[].%s" % w}
read |= {"service_windows[].%s" % w, "site.live_services[].%s" % w, "ready.due[].%s" % w,
"failover.windows[].%s" % w}
dead = sorted(p for p in schema.F if p not in read and p not in ui_only)
self.assertEqual(dead, [], "declared settings nothing reads")
@@ -230,7 +239,8 @@ class TestSchemaMatchesCode(unittest.TestCase):
if ctl and "options" in ctl:
ctl["options"].pop("notes", None)
return export
for build, name in ((make_page.build, "status-page.companionconfig"), (make_page.build_restart, "restart-page.companionconfig")):
for build, name in ((make_page.build, "status-page.companionconfig"), (make_page.build_restart, "restart-page.companionconfig"),
(make_page.build_ready, "ready-page.companionconfig")):
on_disk = json.loads(gzip.open(os.path.join(ROOT, name)).read())
self.assertEqual(strip(build(cfg)), strip(on_disk), name)
self.assertNotIn("restart_page", {k for k in cfg if k == "nothing"})
@@ -285,7 +295,9 @@ class TestSchemaMatchesCode(unittest.TestCase):
def test_phrases(self):
cfg = sp.load_config(os.path.join(ROOT, "config.json"))
p = schema.phrases(cfg)
self.assertEqual(p["auto_fix"], "may run by itself on Sundays 06:30-13:30")
self.assertEqual(p["auto_fix"], "may run by itself " + sp.windows_phrase(cfg["auto_fix"]["windows"])) # the location's own hours
self.assertEqual(schema.phrases(dict(cfg, auto_fix={"windows": [{"days": ["sun"], "start": "06:30", "end": "13:30"}]}))["auto_fix"],
"may run by itself on Sundays 06:30-13:30")
self.assertEqual(p["live"], "changes wait while a HyperDeck is recording")
self.assertEqual(p["hold"], "HOLD 2s")
self.assertEqual(sp.windows_phrase([{"days": ["sat", "sun"], "start": "18:00", "end": "00:30"}]),
+49 -1
View File
@@ -227,7 +227,7 @@ class TestServer(unittest.TestCase):
self.assertIn("config.json", z.namelist())
bundle_cfg = json.loads(z.read("config.json"))
self.assertEqual(bundle_cfg["site"]["editing_code"], self.CODE) # the bundle is the real file
# a new campus from that bundle: this campus's own site block stays, computers are flagged
# a new location from that bundle: this location's own site block stays, computers are flagged
other = json.loads(json.dumps(bundle_cfg)); other["site"]["name"] = "Woonsocket"; other["site"]["editing_code"] = "theirs"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z2:
@@ -291,6 +291,27 @@ class TestServer(unittest.TestCase):
self.assertEqual(len(v["changes"]), 1); self.assertTrue(v["changes"][0]["text"].startswith("computer Lighting PC › notes"))
self.put("/api/draft", {}, method="DELETE")
def test_a_page_that_loaded_before_a_change_never_undoes_it(self):
"""2026-10-15: a tab loaded the settings, a program update then added ready.prop_host, the tab
changed one window and saved its whole (older) copy - Apply took prop_host out again. Now the
page says what it started from and only its own change goes on top of the live settings."""
import hashlib
before = self.site.load(); base_sha = self.site.cfg_sha
newer = json.loads(json.dumps(before)); newer["debounce_seconds"] = (before.get("debounce_seconds") or 3) + 2
sp.write_json(self.site.config_path, newer) # changed under the page
self.site.load()
page = json.loads(json.dumps(before)); page["flap"] = dict(before.get("flap") or {}, count=7) # the page's own edit
st, r = self.put("/api/draft", {"config": page, "base": base_sha, "base_config": before}, who="christian")
self.assertTrue(r["rebased"])
d = self.site.draft.read()["config"]
self.assertEqual(d["flap"]["count"], 7) # the page's change
self.assertEqual(d["debounce_seconds"], newer["debounce_seconds"]) # the newer value kept
st, r = self.put("/api/draft", {"config": d, "base": self.site.cfg_sha, "base_config": d}, who="christian")
self.assertFalse(r["rebased"]) # up to date: nothing to do
self.put("/api/draft", {}, method="DELETE")
sp.write_json(self.site.config_path, before)
self.site.load()
def test_probe_is_refused_while_a_fix_runs(self):
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
try:
@@ -355,3 +376,30 @@ class TestBindingAndProbes(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestNewestCopy(unittest.TestCase):
"""An update installed beside the old copy: the newest version opens, whatever the file names."""
@unittest.skipUnless(sys.platform == "darwin", "reads app versions with macOS's defaults")
def test_the_newest_version_wins_not_the_last_name(self):
import subprocess
from settings import builders as b
for names in ((("Show 20.0.1.app", "20.0.1"), ("Show.app", "20.1.0")), (("Show 20.0.1.app", "20.0.1"), ("Show.app", "19.4"))):
root = tempfile.mkdtemp(prefix="sp-apps-")
for name, ver in names:
os.makedirs(os.path.join(root, name, "Contents"))
subprocess.run(["/usr/bin/defaults", "write", os.path.join(root, name, "Contents", "Info"),
"CFBundleShortVersionString", ver], check=True)
out = subprocess.run(["/bin/sh", "-c", b.newest_app(root + "/Show*.app") + 'echo "$APP"'],
capture_output=True, text=True).stdout.strip()
want = max(names, key=lambda n: tuple(int(x) for x in n[1].split(".")))[0]
self.assertEqual(os.path.basename(out), want)
def test_any_copy_counts_as_running_but_never_a_helper(self):
import re
from settings import builders as b
m = b.app_match("/Applications/ProPresenter*.app", "ProPresenter")
self.assertTrue(re.search(m, "/Applications/ProPresenter 20.0.1.app/Contents/MacOS/ProPresenter"))
self.assertTrue(re.search(m, "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter"))
self.assertFalse(re.search(m, "/Applications/ProPresenter.app/Contents/MacOS/ProPresenter Helper (Network)"))
+4 -4
View File
@@ -1,5 +1,5 @@
"""The generated set-up scripts and the campus template: every value comes from the record, the
templates hold no site-shaped value, and the empty campus validates and builds its pages."""
"""The generated set-up scripts and the location template: every value comes from the record, the
templates hold no site-shaped value, and the empty location validates and builds its pages."""
import json
import os
import sys
@@ -29,7 +29,7 @@ class TestScripts(unittest.TestCase):
text = scripts.windows_setup(CFG, "lighting")
self.assertIn("$Key = '%s'" % KEY[0], text)
self.assertIn("$KeyMark = 'status-panel@NA-Ingest'", text)
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222')", text)
self.assertIn("$IngestIPs = @('10.1.1.1', '10.6.3.222', '10.6.7.38')", text) # NA Ingest, then the admin laptop
for exe in ("Vista.exe", "Arena.exe", "rtpMIDI.exe"):
self.assertIn("(Find-Exe '%s')" % exe, text)
self.assertIn("foreach ($svc in @('rtpMIDIService'))", text)
@@ -85,7 +85,7 @@ class TestSites(unittest.TestCase):
errors, _ = schema.validate(cfg)
self.assertEqual(errors, [])
make_page.build(cfg)
self.assertEqual(sorted(t["kind"] for t in cfg["tiles"]), ["master", "network", "service"])
self.assertEqual(sorted(t["kind"] for t in cfg["tiles"]), ["autofix", "master", "network", "service"])
self.assertEqual([a for a, h in cfg["hosts"].items() if h.get("self")], ["ingest"])
self.assertEqual(cfg["hosts"]["ingest"]["networks"], [])
self.assertNotIn("Attleboro", json.dumps(cfg))
+818 -9
View File
@@ -27,9 +27,14 @@ CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
# The tests pin their own schedule so they do not depend on the site's choice.
CONFIG["service_windows"] = [{"days": ["sun"], "start": "07:00", "end": "12:30"}]
CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t in CONFIG["tiles"]]
# NA has an AUTO-FIX key since 2026-10-21; most tests here cover ALARMS doing both (a page without one),
# TestSwitches puts it back.
CONFIG["tiles"] = [t for t in CONFIG["tiles"] if t.get("kind") != "autofix"]
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop
# ...and their own auto-fix hours, so the location can change its own on the settings page.
CONFIG["auto_fix"]["windows"] = [{"days": ["wed"], "start": "16:00", "end": "21:00"}, {"days": ["sun"], "start": "06:30", "end": "13:30"}]
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
@@ -68,9 +73,10 @@ def healthy_snapshot(now):
snap.ping = {"10.1.3.227": 0, "10.6.7.13": 0, "10.1.0.1": 0}
snap.link = True
snap.launchd = {"org.waterschurch.resi-propresenter": 884, "org.waterschurch.lobby-captions": 900,
"org.waterschurch.lobby-captions-display": 901}
"org.waterschurch.lobby-captions-display": 901, "org.waterschurch.digico-relay": 902}
snap.launchd_time = now
snap.procs = {"/Applications/ProdDash.app/Contents/MacOS/ProdDash": True}
snap.remote = {"studiob\0pgrep -xq 'Pro Tools'": True} # the failover's check has a verdict
snap.http = {"https://central.resi.io/": True, "http://10.6.7.13:8080/": True, "http://10.1.14.225:1025/": True}
snap.disk = {"/": 97}
return snap
@@ -415,6 +421,7 @@ class TestChecks(unittest.TestCase):
engine = sp.Engine(CONFIG)
self.assertEqual(engine.step(self.snap, self.now)["midi"]["x"], "MIDI\nOK") # not read yet: no verdict
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
{"name": "NA Ingest", "address": "", "state": "running"},
{"name": "SuperRack-LiveBox", "address": "", "state": "running"},
{"name": "Studio B Mac Studio", "address": "", "state": "running"},
{"name": "Lighting PC", "address": "", "state": "running"}]}}
@@ -455,6 +462,39 @@ class TestChecks(unittest.TestCase):
self.assertEqual(sp.check_midi_peer({"type": "midi_peer", "host": "tracks", "session": "NA Tracks",
"peer": "Lighting PC"}, self.snap, self.ctx)[0][1].level, sp.OK)
def test_a_midi_peer_on_wi_fi_is_named(self):
"""2026-10-20: ProdDash joined Tracks' session over both Macs' Wi-Fi."""
chk = {"type": "midi_wired", "nets": ["10.1.0.0/16"]}
self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK])
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
{"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"},
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}, # a name: no verdict
{"name": "NA Ingest", "address": "[fe80::1]:5004", "state": "running"}]}}
self.snap.remote_midi_time["tracks"] = self.now
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
out = sp.check_midi_wired(chk, self.snap, self.ctx)
self.assertEqual([r.msg for _, r in out], ["PRODDASH LIGH. NOT WIRED", "STUDIO B MAC. BY NAME"]) # once each, not per session
self.snap.remote_midi_time["tracks"] = self.now - 30 # a stale stream: no verdict
self.snap.midi["NA Ingest"]["peers"].pop()
self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK])
cfg = json.loads(json.dumps(CONFIG))
midi = next(t for t in cfg["tiles"] if t["id"] == "midi")
midi["checks"] = [c for c in midi["checks"] if c["type"] != "midi_wired"] + [dict(chk)] # one, wherever it came from
import schema
self.assertEqual([e for e in schema.validate(cfg)[0] if "wired" in e], [])
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["wifi"]
self.assertTrue(any("not a network" in e for e in schema.validate(cfg)[0]))
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
peer = next(c for c in midi["checks"] if c.get("peer") == "Studio B Mac Studio")
for bad in ("\tStudio B Mac Studio\tlocal.", "10.6.9.193:5004"): # a name, Wi-Fi: refused
peer["address"] = bad
self.assertTrue(any("Studio B Mac Studio" in e and "not on the wired network" in e for e in schema.validate(cfg)[0]), bad)
peer["address"] = "10.1.13.175:5004"
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
tiles, _ = run_steps(sp.Engine(cfg), self.snap, self.now, 4)
self.assertEqual(tiles["midi"]["x"], "MIDI\nPRODDASH LIGH. NOT WIRED")
def test_midi_peer_joining_is_not_an_error(self):
for p in self.snap.midi["NA Ingest"]["peers"]:
p["state"] = "connected" # joined, clock sync not finished yet
@@ -607,7 +647,7 @@ class TestBehaviour(unittest.TestCase):
self.assertEqual(level(tiles["x32"]), "quiet")
self.assertEqual(tiles["all"]["x"], "1 DOWN\nX32 OFFLINE")
self.assertEqual(level(tiles["all"]), "quiet")
self.assertEqual(tiles["service"]["x"], "SERVICE\nOFF")
self.assertEqual(tiles["service"]["x"], "SERVICE OFF\nAUTO-FIX OFF")
for t in tiles.values():
self.assertEqual((t["b"], t["f"]), (t["b2"], t["f2"]), t["x"]) # no blinking anywhere
@@ -972,6 +1012,64 @@ class TestFixes(unittest.TestCase):
self.assertEqual(self.engine.auto_fix(self.snap, comp, weekday + 30), [])
self.assertEqual(ran, [])
def test_a_ready_fix_marked_anytime_runs_on_a_weekday(self):
"""The LTC write, ticked for auto-fix and marked anytime, fills an amber TIMECODE gap on a
Thursday morning; the window's fixes (Ableton closed) still wait for the window."""
cfg = json.loads(json.dumps(CONFIG))
cfg["auto_fix"]["fixes"] = cfg["auto_fix"]["fixes"] + ["ready_ltc"]
cfg["fixes"]["ready_ltc"]["anytime"] = True
thursday = SUNDAY - 3 * 86400
self.engine = sp.Engine(cfg)
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
self.engine.service.set(True, thursday) # ALARMS on all week, as at NA
ran, comp = self.auto_setup()
self.snap.ready = {"items": {"timecode": {"faults": [[sp.ERR, "NO LTC JEHOVAH"]], "info": ""}}}
self.snap.remote["tracks\0pgrep -xq Live"] = False
run_steps(self.engine, self.snap, thursday, 10)
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 10), []) # not 20 s yet
done = self.engine.auto_fix(self.snap, comp, thursday + 30)
time.sleep(0.2)
self.assertEqual((done, ran), (["rd_timecode"], ["ready_ltc"])) # never "ableton"
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 60), []) # 90 s between tries
self.engine.service.set(False, thursday + 61) # ALARMS muted: nothing at all
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 200), [])
self.engine.service.set(True, thursday + 201) # back on: it catches up
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 210), ["rd_timecode"])
time.sleep(0.2)
self.assertEqual(ran, ["ready_ltc", "ready_ltc"]) # and still never "ableton"
del cfg["fixes"]["ready_ltc"]["anytime"] # not marked: the window only
self.engine = sp.Engine(cfg)
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
self.engine.service.set(True, thursday)
ran, comp = self.auto_setup()
run_steps(self.engine, self.snap, thursday, 25)
self.assertEqual(self.engine.auto_fix(self.snap, comp, thursday + 30), [])
run_steps(self.engine, self.snap, SUNDAY, 25)
done = self.engine.auto_fix(self.snap, comp, SUNDAY + 30)
time.sleep(0.2)
self.assertIn("rd_timecode", done) # in the window it acts
self.assertIn("ready_ltc", ran)
import schema
bad = json.loads(json.dumps(CONFIG))
bad["fixes"]["ableton"]["anytime"] = True
self.assertTrue(any("anytime" in e for e in schema.validate(bad)[0])) # only a READY fix may
def test_alarms_key_says_alarms_and_auto_fix_and_when_they_come_back(self):
cfg = json.loads(json.dumps(CONFIG))
cfg["service_windows"] = [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}] # NA: armed all day
engine = sp.Engine(cfg)
monday = datetime(2026, 10, 19, 14, 30).timestamp()
tiles = engine.step(self.snap, monday)
self.assertEqual(tiles["service"]["x"], "SERVICE ON\nAUTO-FIX ON")
engine.service.set(False, monday) # pressed
tiles = engine.step(self.snap, monday + 1)
self.assertEqual(tiles["service"]["x"], "SERVICE OFF\nAUTO-FIX OFF\nTIL 12AM")
self.assertTrue(engine.service.on(datetime(2026, 10, 20, 0, 0, 30).timestamp())) # back by itself
note = [ctl for row in make_page.build(cfg)["page"]["controls"].values() for ctl in row.values()
if ctl and "'service'" in (ctl.get("options") or {}).get("notes", "")][0]["options"]["notes"]
self.assertIn("stop every auto-fix", note)
self.assertIn("at midnight", note)
def test_restart_keys_flash_when_their_thing_is_down(self):
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
devices.remove("NA-Tracks") # DVS on the Tracks Mac gone
@@ -1081,7 +1179,8 @@ class TestFixes(unittest.TestCase):
def test_midi_reconnect_where_the_peer_was_last_seen(self):
others = [{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}]
{"name": "NA Ingest", "address": "10.1.1.1:5009", "state": "running"},
{"name": "Studio B Mac Studio", "address": "10.1.13.175:5004", "state": "running"}]
tracks = {"NA Tracks": {"port": 5004, "peers": others + [
{"name": "Lighting PC", "address": "10.1.10.77:5004", "state": "running"}]}}
sp.remember_peers(self.snap, "tracks", tracks) # seen at a new address earlier
@@ -1105,7 +1204,7 @@ class TestFixes(unittest.TestCase):
self.assertTrue(sp.execute_fix(CONFIG, "midi_reconnect", chk))
argv, kw = run.call_args.args[0], run.call_args.kwargs
self.assertIn("waterschurchmedia@10.1.3.227", argv)
self.assertIn("ns['connect_peer']('NA Tracks', 'Lighting PC', '10.1.10.206:5004')", kw["input"])
self.assertIn("ns['connect_peer']('NA Tracks', 'Lighting PC', '10.1.10.206:5004', repoint=False)", kw["input"])
failed = mock.Mock(returncode=255, stdout="", stderr="ssh: timed out")
with mock.patch.object(sp.subprocess, "run", return_value=failed):
self.assertFalse(sp.execute_fix(CONFIG, "midi_reconnect", chk))
@@ -1142,6 +1241,60 @@ class TestFixes(unittest.TestCase):
self.assertEqual([[p["name"] for p in w["peers"]] for w in writes],
[["Lighting PC"], ["Lighting PC", "SuperRack-LiveBox"]]) # dropped, then added back
def test_a_peer_listed_elsewhere_is_moved_to_its_wired_address(self):
import coremidi
from unittest import mock
sess = {"dns-sd-name": "NA Tracks", "port": 5004, "peers": [
{"name": "Lighting PC", "address": "10.1.10.206:5004", "state": "running"},
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}]}
writes = []
with mock.patch.object(coremidi, "_session_entities", return_value=[(1, sess)]), \
mock.patch.object(coremidi, "_string_prop", return_value=None), \
mock.patch.object(coremidi, "_set_dict_prop", side_effect=lambda e, k, v: writes.append(v) or 0):
self.assertEqual(coremidi.connect_peer("NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004"), "already")
self.assertEqual(writes, []) # a plain reconnect never moves a live link
self.assertEqual(coremidi.connect_peer("NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004",
settle=lambda s: None, repoint=True), "moved")
self.assertEqual(coremidi.connect_peer("NA Tracks", "Lighting PC", "10.1.10.206:5004", repoint=True), "already")
self.assertEqual([[p["address"] for p in w["peers"]] for w in writes],
[["10.1.10.206:5004"], ["10.1.10.206:5004", "10.1.13.175:5004"]]) # dropped, then at the wired address
def test_midi_reconnects_stay_on_the_wired_network(self):
nets = [sp.ipaddress.ip_network("10.1.0.0/16")]
self.assertEqual(sp.pick_midi_address(nets, "10.1.10.77:5004", "10.1.10.206:5004"), "10.1.10.77:5004") # moved, still wired
self.assertEqual(sp.pick_midi_address(nets, "10.6.3.222:5040", "10.1.1.1:5040"), "10.1.1.1:5040") # last seen on Wi-Fi
self.assertEqual(sp.pick_midi_address(nets, "\tNA Ingest\tlocal.", "10.1.1.1:5009"), "10.1.1.1:5009") # last seen by name
self.assertEqual(sp.pick_midi_address(nets, "\tNA Ingest\tlocal.", None), "\tNA Ingest\tlocal.") # nothing better known
self.assertEqual(sp.pick_midi_address([], "10.6.3.222:5040", "10.1.1.1:5040"), "10.6.3.222:5040") # no rule: as before
def test_a_link_joined_by_name_is_flagged_and_a_hold_moves_it(self):
cfg = json.loads(json.dumps(CONFIG))
midi = next(t for t in cfg["tiles"] if t["id"] == "midi")
midi["checks"] = [c for c in midi["checks"] if c["type"] != "midi_wired"] + [
{"type": "midi_wired", "nets": ["10.1.0.0/16"], "fix": "midi_reconnect"}]
for c in midi["checks"]:
if c.get("peer") == "Studio B Mac Studio":
c["address"] = "10.1.13.175:5004"
engine = sp.Engine(cfg)
engine.executor = engine.poe_executor = lambda *a: True
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
{"name": "Lighting PC", "address": "10.1.10.206:5004", "state": "running"},
{"name": "NA Ingest", "address": "10.1.1.1:5009", "state": "running"},
{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"},
{"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"}]}}
now = SUNDAY
for t in range(9): # the stream stays fresh
self.snap.remote_midi_time["tracks"] = now + t
tiles = engine.step(self.snap, now + t)
self.assertIn("STUDIO B MAC. BY NAME", tiles["midi"]["x"])
plan = [(kind, what, c) for _, kind, what, c in engine.fix_plan("midi", self.snap)]
moves = [c for kind, what, c in plan if c.get("repoint")]
self.assertEqual([(c["host"], c["session"], c["peer"], c["address"]) for c in moves],
[("tracks", "NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004")]) # ProdDash's own: left to it
auto = engine.fix_plan("midi", self.snap, SUNDAY + 30, 0, True)
self.assertFalse(any(c.get("repoint") for _, _, _, c in auto)) # never by itself: a hold
def test_an_off_pc_is_woken(self):
from unittest import mock
woke = []
@@ -1446,7 +1599,8 @@ class TestFixes(unittest.TestCase):
for t in self.engine.fix_threads:
t.join(2)
self.assertEqual(self.engine.running, {})
self.assertEqual(sp.exit_timeout(CONFIG), 90) # the longest fix + its verify + 10
# the longest fix + its verify + 10: the Tascam outlet power-cycle (timeout 300)
self.assertEqual(sp.exit_timeout(CONFIG), 310)
def test_a_hyperdeck_switched_off_is_always_switched_back_on(self):
calls = []
@@ -1521,6 +1675,380 @@ class TestFixes(unittest.TestCase):
self.assertIn(f.get("kind", "ssh"), sp.FIX_KINDS)
def run_failover(engine, snap, start, seconds, step=0.5):
"""Like run_steps, but the failover looks after every step, as the run loop does."""
tiles, t = None, start
while t <= start + seconds:
snap.conns_time = t
tiles = engine.step(snap, t)
engine.failover(snap, None, t)
t += step
return tiles, t
STB_PING, STB_PT = "10.1.13.175", "studiob\0pgrep -xq 'Pro Tools'"
class TestSwitchedOff(unittest.TestCase):
"""Anything can be switched off in the settings and stay there, like a connection in Companion:
a key goes grey OFF and checks nothing, a check is skipped, a fix never runs."""
def cfg(self, **changes):
cfg = json.loads(json.dumps(CONFIG))
for path, value in changes.items():
node = cfg
*parts, last = path.split("/")
for p in parts:
node = node[int(p)] if isinstance(node, list) else node[p]
node[last] = value
return cfg
def test_a_key_switched_off_is_grey_and_checks_nothing(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
engine = sp.Engine(self.cfg(**{"tiles/%d/disabled" % i: True}))
snap = healthy_snapshot(SUNDAY)
snap.conns.pop("OBS-Youtube", None) # OBS gone: would be red
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertEqual(tiles["obs"]["x"], "OBS\nOFF")
self.assertEqual(level(tiles["obs"]), "off")
self.assertEqual(tiles["all"]["x"], "ALL OK") # not part of ALL any more
self.assertEqual(tiles["stream"]["x"], "STREAM\nOK") # nor of its group
def test_a_check_switched_off_is_skipped(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "stb_audio")
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/disabled" % i: True}))
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5 # the ping check would fail
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nOK")
self.assertEqual(len(engine.tiles["stb_audio"]["checks"]), 2)
self.assertEqual(len(CONFIG["tiles"][i]["checks"]), 3) # the settings keep all three
def test_a_fix_switched_off_never_runs(self):
cfg = self.cfg(**{"fixes/obs/disabled": True, "fixes/stream_foh_backup/disabled": True})
self.assertEqual(sp.execute_fix(cfg, "obs", {}), "TURNED\nOFF")
engine = sp.Engine(cfg)
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
engine.notify = lambda url, text: None
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5 # Studio B dies on a Sunday
snap.conns.pop("OBS-Youtube", None)
run_failover(engine, snap, SUNDAY, 40)
self.assertEqual(engine.failed, {}) # no auto switch
self.assertEqual(engine.auto_fix(snap, mock_comp(), SUNDAY + 41), []) # no auto-fix
def test_a_restart_key_switched_off(self):
rp = self.cfg()["restart_page"]
j = next(n for n, k in enumerate(rp["keys"]) if k["id"] == "rs_tascam")
engine = sp.Engine(self.cfg(**{"restart_page/keys/%d/disabled" % j: True}))
tiles = engine.step(healthy_snapshot(SUNDAY), SUNDAY)
self.assertEqual(tiles["rs_tascam"]["x"], "TASCAM\nOFF")
self.assertIsNone(engine.run_force("rs_tascam", healthy_snapshot(SUNDAY), None, SUNDAY))
self.assertEqual(engine.notes["rs_tascam"]["text"], "TURNED\nOFF")
def test_a_computer_switched_off(self):
cfg = self.cfg(**{"hosts/studiob/disabled": True})
engine = sp.Engine(cfg)
stb = engine.tiles["stb_audio"]["checks"]
self.assertEqual([c["type"] for c in stb], ["dante"]) # its ping and Pro Tools checks are gone
self.assertTrue(engine.cfg["fixes"]["solo_afl"]["disabled"]) # and a fix that logs in to it
self.assertFalse(any(c.get("host") == "studiob" for t in sp.all_tiles(engine.cfg) for c in t.get("checks", [])))
self.assertNotIn("studiob", sp.remote_midi_hosts(engine.cfg))
engine = sp.Engine(self.cfg(**{"hosts/bibletv/disabled": True})) # its web page is not fetched either
self.assertNotIn("http://10.6.7.13:8080/", [u for u, _ in sp.http_checks(engine.cfg)])
self.assertTrue(engine.cfg["fixes"]["bibleapp"]["disabled"])
def test_a_dante_device_switched_off(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "dante")
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/devices_off" % i: ["STUDIO-B-Tascam"]}))
snap = healthy_snapshot(SUNDAY)
devices = json.loads(snap.vars[("audinate-dantecontroller", "devices")])
snap.vars[("audinate-dantecontroller", "devices")] = json.dumps([d for d in devices if d != "STUDIO-B-Tascam"])
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertNotIn("GONE", tiles["dante"]["x"]) # gone, but switched off: no alarm
self.assertIn("STUDIO-B-Tascam", CONFIG["tiles"][i]["checks"][0]["devices"]) # still in the list
def test_whole_features_switched_off(self):
cfg = self.cfg(**{"auto_fix/disabled": True, "failover/disabled": True, "ready/disabled": True})
engine = sp.Engine(cfg)
self.assertEqual(engine.cfg["auto_fix"]["windows"], [])
self.assertEqual(engine.failover_windows, [])
self.assertTrue(all(k.get("disabled") for k in sp.ready_keys(engine.cfg)))
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
engine.notify = lambda url, text: None
tiles, _ = run_failover(engine, snap, SUNDAY, 20)
self.assertEqual(engine.failed, {})
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nOFF")
def test_the_rules_accept_switched_off_things(self):
import schema
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
cfg = self.cfg(**{"tiles/%d/disabled" % i: True, "tiles/%d/checks/0/disabled" % i: True, "fixes/obs/disabled": True})
errors, warnings = schema.validate(cfg)
self.assertEqual(errors, [])
self.assertFalse(any("disabled" in w for w in warnings), warnings)
class TestFailover(unittest.TestCase):
"""Studio B dies on a Sunday: the FOH Backup button is pressed by itself, once, and the keys say so.
Nothing here presses a real button, posts a real alert or writes a real log."""
def setUp(self):
self.snap = healthy_snapshot(SUNDAY)
self.engine = sp.Engine(CONFIG)
self.ran, self.sent = [], []
self.engine.executor = lambda cfg, fix_id, chk: self.ran.append(fix_id) or True
self.engine.poe_executor = lambda *a: True
self.engine.notify = lambda url, text: self.sent.append(text)
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
def fired_at(self):
self.engine.fix_threads and [t.join(2) for t in self.engine.fix_threads]
rec = self.engine.failed.get("stream_foh_backup")
return rec and rec["at"]
def test_config_wires_studio_b_to_foh_backup(self):
tile = next(t for t in CONFIG["tiles"] if t["id"] == "stb_audio")
self.assertNotIn("pos", tile) # hidden: AUDIO shows it
self.assertIn("stb_audio", next(t for t in CONFIG["tiles"] if t["id"] == "audio")["members"])
for c in tile["checks"]:
fixes = set(c["fix"].values()) if isinstance(c["fix"], dict) else {c["fix"]}
self.assertEqual(fixes, {"stream_foh_backup"}, c["type"])
fix = CONFIG["fixes"]["stream_foh_backup"]
self.assertEqual((fix["kind"], fix["page"], fix["row"], fix["col"]), ("companion_press", 4, 0, 5))
self.assertTrue(fix["failover"])
self.assertEqual(fix["undo"], "stream_studio_b")
self.assertFalse(CONFIG["fixes"]["stream_studio_b"].get("failover"))
self.assertNotIn("stream_foh_backup", CONFIG["auto_fix"]["fixes"])
self.assertEqual(CONFIG["failover"]["after_seconds"], 2)
self.assertEqual(self.engine.failover_windows, [{"days": ["sun"], "start": "06:30", "end": "13:30"}])
def test_studio_b_dies_foh_backup_is_pressed_once(self):
t0 = SUNDAY
run_failover(self.engine, self.snap, t0 - 5, 4) # all fine for a while
self.snap.ping[STB_PING] = 2 # two 1-second pings missed
tiles, t = run_failover(self.engine, self.snap, t0, 6)
at = self.fired_at()
self.assertEqual(self.ran, ["stream_foh_backup"])
key = next(k for k, (tid, chk, sub) in self.engine.meta.items() if chk.get("host") == STB_PING)
since = self.engine.trackers[key].since
self.assertEqual(since, t0 + 1) # 1 s debounce
self.assertEqual(at, since + 2) # + 2 s after_seconds
self.assertLessEqual(at - t0, 5)
tiles, t = run_failover(self.engine, self.snap, t, 60) # the fault lasts a minute
self.assertEqual(self.ran, ["stream_foh_backup"]) # still once
self.assertNotIn("stream_studio_b", self.ran) # never switched back by itself
# the keys say where the stream is
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING\nON FOH BACKUP")
self.assertEqual(level(tiles["audio"]), "err")
self.engine.notes.pop("stb_audio", None) # under the fix's own feedback
tiles = self.engine.step(self.snap, t)
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nSTUDIO B NO PING\nON FOH BACKUP")
self.assertEqual(tiles["all"]["x"], "1 DOWN\nSTUDIO B NO PING")
# the log, the alert, and a hold right after
body = [l for l in open(self.engine.autolog.path(t0)).read().splitlines() if l[:2].isdigit()]
self.assertTrue(any("STB AUDIO FAILOVER: STUDIO B NO PING - press the FOH Backup button" in l for l in body), body)
self.assertTrue(any("-> done, checking it comes back" in l for l in body), body)
self.assertEqual(len(self.sent), 1)
self.assertTrue(self.sent[0].startswith("NA Ingest: STB AUDIO STUDIO B NO PING - switched over by itself: press the FOH Backup"))
self.assertIn("Switch back by hand (press the Studio B button in Companion", self.sent[0])
self.engine.run_fixes("audio", self.snap, None, at + 10)
self.assertEqual(self.engine.notes["audio"]["text"], "JUST TRIED\nWAIT 1 MIN")
out = sp.snapshot_summary(self.snap, self.engine, t)
json.dumps(out)
self.assertEqual(out["failover"]["stream_foh_backup"]["note"], "ON FOH BACKUP")
self.assertEqual(out["failover"]["stream_foh_backup"]["faults"], ["STUDIO B NO PING"])
def test_pro_tools_crash_is_as_fast(self):
t0 = SUNDAY
self.snap.remote[STB_PT] = False
run_failover(self.engine, self.snap, t0, 6)
self.assertEqual(self.ran, ["stream_foh_backup"])
self.assertLessEqual(self.fired_at() - t0, 5)
tiles = self.engine.step(self.snap, t0 + 7)
self.assertEqual(tiles["audio"]["x"], "AUDIO\nPRO TOOLS NOT RUNNING\nON FOH BACKUP")
def test_fires_with_alarms_muted(self):
self.engine.service.set(False, SUNDAY) # maintenance: auto-fix stops
self.snap.ping[STB_PING] = 2
run_failover(self.engine, self.snap, SUNDAY, 6)
self.assertEqual(self.ran, ["stream_foh_backup"]) # the failover does not
def test_only_inside_the_window(self):
for when in (WEDNESDAY, datetime(2026, 10, 11, 15, 0).timestamp()): # a weekday; Sunday after the window
snap = healthy_snapshot(when)
snap.ping[STB_PING] = 2
run_failover(self.engine, snap, when, 10)
self.assertEqual(self.ran, [], datetime.fromtimestamp(when))
self.assertEqual(self.engine.failed, {})
def test_rearms_when_the_fault_clears_and_the_note_fades(self):
t0 = SUNDAY
self.snap.ping[STB_PING] = 2
tiles, t = run_failover(self.engine, self.snap, t0, 6)
self.snap.ping[STB_PING] = 0 # Studio B answers again
tiles, t = run_failover(self.engine, self.snap, t, 5)
self.assertEqual(tiles["audio"]["x"], "AUDIO\nOK\nON FOH BACKUP") # still on the backup
self.assertEqual(level(tiles["audio"]), "ok")
self.assertIsNotNone(self.engine.failed["stream_foh_backup"]["cleared"])
self.snap.ping[STB_PING] = 2 # ...and dies again 5 s later
tiles, t = run_failover(self.engine, self.snap, t, 4)
self.assertEqual(self.ran, ["stream_foh_backup"]) # was clear under 10 s: not armed yet
tiles, t = run_failover(self.engine, self.snap, t, 3) # 10 s since it cleared: armed, fires
self.assertEqual(self.ran, ["stream_foh_backup", "stream_foh_backup"])
self.snap.ping[STB_PING] = 0
tiles, t = run_failover(self.engine, self.snap, t, 2)
tiles, t = run_failover(self.engine, self.snap, t + 1000, 1) # note_seconds later: forgotten
self.assertEqual(tiles["audio"]["x"], "AUDIO\nOK")
self.assertEqual(self.engine.failed, {})
def test_a_check_that_cannot_ask_is_not_a_clear(self):
t0 = SUNDAY
self.snap.remote[STB_PT] = False # Pro Tools gone
tiles, t = run_failover(self.engine, self.snap, t0, 6)
self.assertEqual(self.ran, ["stream_foh_backup"])
self.snap.remote[STB_PT] = None # ssh could not ask for a while
tiles, t = run_failover(self.engine, self.snap, t, 30)
self.assertIsNone(self.engine.failed["stream_foh_backup"]["cleared"]) # not re-armed on a hiccup
self.snap.remote[STB_PT] = False # the answer is back: still gone
tiles, t = run_failover(self.engine, self.snap, t, 10)
self.assertEqual(self.ran, ["stream_foh_backup"]) # no second press
def test_studio_b_rednet_is_never_power_cycled_by_itself(self):
"""Only STUDIO-B-RedNet-TNX vanishes: the failover presses FOH Backup; Sunday auto-fix on the DANTE
key must not power-cycle the RedNet's PoE port (that stays a hold), nor touch anything else."""
comp = mock_comp()
self.snap.dante_map = {"STUDIO-B-RedNet-TNX": {"mac": "00:1d:c1:aa:bb:cc", "poe": True, "switch": "10.1.60.2", "port": "0/14"}}
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps([d for d in devices if d != "STUDIO-B-RedNet-TNX"])
poe = []
self.engine.poe_executor = lambda cfg, name, entry: poe.append(name) or True
tiles, t = run_failover(self.engine, self.snap, SUNDAY, 30)
self.assertEqual(self.ran, ["stream_foh_backup"])
self.assertEqual(self.engine.auto_fix(self.snap, comp, t), [])
time.sleep(0.2)
self.assertEqual((poe, comp.restarted, self.ran), ([], [], ["stream_foh_backup"]))
self.assertEqual([(k, w) for _, k, w, _ in self.engine.fix_plan("dante", self.snap)], [("poe", "STUDIO-B-RedNet-TNX")]) # a hold would
def test_switching_back_by_hand_clears_the_note(self):
t0 = SUNDAY
gate = threading.Event() # the Studio B press takes a moment
def press(cfg, fix_id, chk):
if fix_id == "stream_studio_b":
gate.wait(2)
self.ran.append(fix_id)
return True
self.engine.executor = press
self.snap.ping[STB_PING] = 2
tiles, t = run_failover(self.engine, self.snap, t0, 6)
self.assertIn("ON FOH BACKUP", tiles["audio"]["x"])
# the undo, run through the watchdog (a Restart key or the settings page's Run the fix)
self.assertEqual(self.engine.run_force("stb_audio", self.snap, None, t, fix_id="stream_studio_b", chk={}), "stream_studio_b")
self.assertEqual(self.engine.notes["stb_audio"]["text"], "PRESSING")
gate.set()
time.sleep(0.2)
self.assertEqual(self.engine.notes["stb_audio"]["text"], "DONE")
self.assertEqual(self.engine.failed, {})
tiles = self.engine.step(self.snap, t + 0.5)
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING")
tiles, t = run_failover(self.engine, self.snap, t + 1, 4) # still dead: it fires again
self.assertEqual(self.ran, ["stream_foh_backup", "stream_studio_b", "stream_foh_backup"])
self.assertIn("ON FOH BACKUP", tiles["audio"]["x"])
def test_held_back_when_several_dante_devices_vanish(self):
t0 = SUNDAY
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
[d for d in devices if d not in ("STUDIO-B-RedNet-TNX", "NA-Tracks", "BOH-WING")])
tiles, t = run_failover(self.engine, self.snap, t0, 12)
self.assertEqual(self.ran, []) # Ingest's view of Dante: hands off
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nSTB TNX GONE") # seen, no note
body = open(self.engine.autolog.path(t0)).read()
self.assertIn("failover held back: 3 Dante devices gone at once", body)
self.assertEqual(body.count("held back"), 1) # said once, not every half second
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
[d for d in devices if d != "STUDIO-B-RedNet-TNX"]) # only Studio B's own box
run_failover(self.engine, self.snap, t, 3)
self.assertEqual(self.ran, ["stream_foh_backup"])
def test_state_survives_a_restart(self):
state = os.path.join(tempfile.mkdtemp(prefix="sp-state-"), "state.json")
engine = sp.Engine(CONFIG, state)
engine.executor = lambda cfg, fix_id, chk: self.ran.append(fix_id) or True
engine.notify = lambda url, text: None
engine.autolog = sp.AutoLog(tempfile.mkdtemp())
self.snap.ping[STB_PING] = 2
tiles, t = run_failover(engine, self.snap, SUNDAY, 6)
self.assertEqual(self.ran, ["stream_foh_backup"])
self.assertEqual(json.load(open(state))["failover"]["stream_foh_backup"]["tile"], "stb_audio")
again = sp.Engine(CONFIG, state) # the watchdog restarts mid-fault
again.executor = lambda cfg, fix_id, chk: self.ran.append(fix_id) or True
again.notify = lambda url, text: None
again.autolog = sp.AutoLog(tempfile.mkdtemp())
tiles, t = run_failover(again, self.snap, t, 10)
self.assertEqual(self.ran, ["stream_foh_backup"]) # not pressed a second time
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING\nON FOH BACKUP")
def test_nothing_new_starts_while_stopping(self):
self.snap.ping[STB_PING] = 2
self.engine.stopping = True
run_failover(self.engine, self.snap, SUNDAY, 6)
self.assertEqual(self.ran, [])
def test_load_config_checks_the_button_and_the_undo(self):
def load(change):
cfg = json.loads(json.dumps(CONFIG))
change(cfg)
path = os.path.join(tempfile.mkdtemp(prefix="sp-cfg-"), "config.json")
with open(path, "w") as f:
json.dump(cfg, f)
return sp.load_config(path)
load(lambda c: None)
for bad in (lambda c: c["fixes"]["stream_foh_backup"].update(page=0),
lambda c: c["fixes"]["stream_foh_backup"].update(row=True),
lambda c: c["fixes"]["stream_foh_backup"].update(col="5"),
lambda c: c["fixes"]["stream_foh_backup"].pop("col"),
lambda c: c["fixes"]["stream_foh_backup"].update(never_auto=True),
lambda c: c["fixes"]["stream_foh_backup"].update(undo="nope"),
lambda c: c["failover"]["windows"].append({"days": ["sun"], "start": "6:30", "end": "13:30"})):
with self.assertRaises(ValueError):
load(bad)
def test_studio_b_is_pinged_every_second(self):
self.assertEqual(sp.ping_hosts(CONFIG, fast=True), [STB_PING])
slow = sp.ping_hosts(CONFIG)
self.assertNotIn(STB_PING, slow)
self.assertIn("10.1.0.1", slow) # the gateway stays in the slow list
self.assertIn("10.1.3.227", slow)
self.assertEqual(sp.PING_INTERVAL, 3)
def test_remote_groups_run_when_due_each_on_its_own(self):
from unittest import mock
groups = sp.remote_groups(CONFIG)
self.assertEqual(groups[("studiob", 2)], ["pgrep -xq 'Pro Tools'"])
self.assertTrue(all(k[1] == 10 for k in groups if k != ("studiob", 2)))
calls = []
fake = lambda argv, **kw: calls.append(argv) or mock.Mock(returncode=0, stdout="SP0=0\nSP1=0\n", stderr="")
t = SUNDAY + 100
with mock.patch.object(sp.subprocess, "run", fake):
last = {k: t - 3 for k in groups} # every group ran 3 s ago
for th in sp.poll_remote(self.snap, CONFIG, now=t, last=last):
th.join(2)
self.assertEqual(len(calls), 1) # only the 2-second group is due
self.assertIn("studiob@10.1.13.175", calls[0])
self.assertIn("pgrep -xq 'Pro Tools'", calls[0][-1])
self.assertTrue(self.snap.remote[STB_PT])
self.assertEqual(last[("studiob", 2)], t)
self.assertEqual(sp.poll_remote(self.snap, CONFIG, now=t + 1, last=last), []) # nothing due yet
calls.clear()
sp.poll_remote(self.snap, CONFIG) # run_once: everything, and waits
self.assertEqual(len(calls), len(groups))
self.assertEqual(sp.REMOTE_INTERVAL, 10)
class TestNetgearParsing(unittest.TestCase):
BIG = """ VLAN ID MAC Address Interface IfIndex Status
66 00:1D:C1:51:34:C8 0/14 14 Learned
@@ -1603,6 +2131,13 @@ class FakeCompanion(BaseHTTPRequestHandler):
if m:
s["restarts"].append(m.group(1))
return self._send(200, "{}")
m = re.match(r"^/api/location/(\d+)/(\d+)/(\d+)/press$", u.path)
if m:
where = tuple(int(x) for x in m.groups())
s["presses"].append(where)
if where in s["buttons"]:
return self._send(200, "ok")
return self._send(204, "No control") # like Companion 5.0.7 for an empty cell
self._send(404, "")
@@ -1615,6 +2150,8 @@ class TestAgainstFakeCompanion(unittest.TestCase):
"vars": dict(snap.vars),
"custom": {"sp_state": "", "sp_hb": "", "sp_cmd": "", "flash_tick": "0"},
"restarts": [],
"presses": [],
"buttons": {(4, 0, 5), (4, 0, 6)}, # the Audio page's FOH Backup and Studio B
}
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeCompanion)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
@@ -1637,6 +2174,20 @@ class TestAgainstFakeCompanion(unittest.TestCase):
self.assertEqual(comp.set_custom("missing", "x"), (False, 404))
print("\n sp_state is %d bytes, %d in the URL" % (len(js), len(urllib.parse.quote(js, safe=""))))
def test_press_a_button(self):
comp = sp.Companion("127.0.0.1", self.port)
store = FakeCompanion.store
self.assertEqual(comp.press(4, 0, 5), 200)
self.assertEqual(comp.press(4, 1, 1), 204) # an empty cell
self.assertEqual(store["presses"][-2:], [(4, 0, 5), (4, 1, 1)])
cfg = json.loads(json.dumps(CONFIG))
cfg["companion"]["port"] = self.port
self.assertTrue(sp.execute_fix(cfg, "stream_foh_backup", {}))
self.assertEqual(store["presses"][-1], (4, 0, 5))
cfg["fixes"]["stream_foh_backup"]["col"] = 7
self.assertEqual(sp.execute_fix(cfg, "stream_foh_backup", {}), "NO BUTTON\nTHERE")
self.assertEqual(store["presses"][-1], (4, 0, 7))
def test_commands(self):
comp = sp.Companion("127.0.0.1", self.port)
store = FakeCompanion.store
@@ -1679,7 +2230,7 @@ class TestAgainstFakeCompanion(unittest.TestCase):
sp.poll_mdns = lambda snap, seconds=3: None
sp.poll_system = lambda snap, cfg: None
sp.poll_midi = lambda snap, cfg: None # CoreMIDI has its own test
sp.poll_remote = lambda snap, cfg: None # never SSH anywhere from a test
sp.poll_remote = lambda snap, cfg, **k: [] # never SSH anywhere from a test
sp.start_remote_midi_streams = lambda snap, cfg, stop: []
stop = threading.Event()
loop = threading.Thread(target=sp.run, args=(cfg, data_dir), kwargs={"stop": stop}, daemon=True)
@@ -1723,10 +2274,11 @@ class TestPage(unittest.TestCase):
self.assertTrue(key["options"]["notes"].startswith("STATUS nav key (%s)" % kind))
step = key["steps"]["0"]
self.assertEqual(step["action_sets"]["down"], [])
tap, hold = step["action_sets"]["up"][0], step["action_sets"]["1000"][0]
tap, hold = step["action_sets"]["up"][0], step["action_sets"]["2000"][0] # NA: 2 s ...
self.assertEqual((tap["definitionId"], tap["options"]["page"]["value"]), (action, 1)) # page 1 outright, not "startup"
self.assertEqual((hold["definitionId"], hold["options"]["page"]["value"]), ("set_page", 19))
self.assertEqual(step["options"]["runWhileHeld"], [1000])
self.assertEqual(step["action_sets"]["3000"][0]["options"]["page"]["value"], 98) # ... then 3 s: LOCATIONS
self.assertEqual(step["options"]["runWhileHeld"], [2000, 3000])
box = [l for l in key["style"]["layers"] if l["type"] == "box"][0]
self.assertTrue(box["color"]["isExpression"])
self.assertIn("'$.all.b'", box["color"]["value"]) # follows the ALL button
@@ -1746,7 +2298,7 @@ class TestPage(unittest.TestCase):
for r in c.values():
for ctl in r.values():
sets = ctl.get("steps", {}).get("0", {}).get("action_sets", {})
if "2000" in sets:
if "2000" in sets and sets["2000"][0]["definitionId"] != "set_page": # not the page-number key's hold
holds[sets["2000"][0]["options"]["value"]["value"]] = sets["up"]
self.assertEqual(len(holds), len(CONFIG["restart_page"]["keys"]))
self.assertIn("$(custom:sp_cmd);force rs_tascam $(internal:time_unix)", holds)
@@ -1854,3 +2406,260 @@ class TestCoreMIDI(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestDanteNewcomers(unittest.TestCase):
"""A Dante device the module never listed before gets the Dante Controller connection restarted once."""
def setUp(self):
self.snap = healthy_snapshot(time.time())
self.engine = sp.Engine(CONFIG, None)
self.restarts = []
me = self
class Comp:
def restart_connection(self, cid):
me.restarts.append(cid); return True
self.comp = Comp()
self.label = sp.dante_label(CONFIG)
self.snap.conns[self.label] = {"id": "dante-1", "enabled": True, "category": "good"}
def devices(self, names):
self.snap.vars[(self.label, "devices")] = json.dumps(names)
def test_first_list_is_remembered_then_a_newcomer_restarts_once(self):
now = time.time()
self.devices(["A", "B"])
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now))
self.assertEqual(self.engine.state.get("dante_known"), ["A", "B"])
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 60)) # nothing new
self.devices(["A", "B", "BOH-AD4Q-A"])
self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 120))
self.assertEqual(self.restarts, ["dante-1"])
self.assertEqual(self.engine.state.get("dante_known"), ["A", "B", "BOH-AD4Q-A"])
self.devices(["A", "B", "BOH-AD4Q-A", "X"])
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 180)) # ten-minute cooldown
self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 800))
self.assertEqual(len(self.restarts), 2)
self.devices(["A", "B"]) # devices going away never restart
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 2000))
self.devices(["A", "B", "BOH-AD4Q-A"]) # nor coming back
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 2600))
def test_waits_for_the_service_to_end(self):
now = time.time()
self.devices(["A"])
sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now)
self.devices(["A", "NEW"])
self.engine.any_recording = True
self.assertFalse(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 100))
self.engine.any_recording = False
self.assertTrue(sp.watch_dante_newcomers(self.snap, CONFIG, self.comp, self.engine, now + 200))
class TestSwitches(unittest.TestCase):
"""ALARMS and AUTO-FIX as two keys (2026-10-21), and a key that hands a device to an operator
(Southcoast's LIGHTS: a lighting op runs the desk, so nothing may send it MIDI)."""
def setUp(self):
self.snap = healthy_snapshot(SUNDAY)
self.cfg = json.loads(json.dumps(CONFIG))
for t in self.cfg["tiles"]:
if t.get("pos") == [3, 7]:
t.pop("pos") # NA's spot for it (ARENA moved off)
self.cfg["tiles"].append({"id": "autofix", "label": "AUTO-FIX", "kind": "autofix", "pos": [3, 7]})
self.midi = next(c for t in self.cfg["tiles"] for c in t.get("checks", []) if c["type"] == "midi_peer")
self.cfg["tiles"].append({"id": "lights", "label": "LIGHTS", "kind": "bypass",
"bypass": {"peers": [self.midi["peer"]], "hosts": [""], "conns": ["x32"]}})
self.state = os.path.join(tempfile.mkdtemp(), "state.json")
self.engine = self.new_engine()
def new_engine(self):
e = sp.Engine(self.cfg, self.state)
e.executor = e.poe_executor = lambda *a: True
e.autolog = sp.AutoLog(tempfile.mkdtemp())
return e
def test_alarms_silence_only_and_auto_fix_has_its_own_key(self):
import schema
self.assertEqual(schema.validate(self.cfg)[0], [])
ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
self.snap.remote["tracks\0pgrep -xq Live"] = False # Ableton closed
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 4)
self.assertEqual(tiles["service"]["x"], "SERVICE\nON")
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nON\nTIL 1:30PM") # Sunday's hours end then
self.engine.service.set(False, SUNDAY + 5) # ALARMS: silence only
run_steps(self.engine, self.snap, SUNDAY + 5, 25)
self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 30)
time.sleep(0.2)
self.assertEqual(ran, ["ableton"]) # auto-fix carried on
self.engine.autofix.pause(SUNDAY + 40) # AUTO-FIX pressed in the hours: paused
tiles = self.engine.step(self.snap, SUNDAY + 41)
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nPAUSED\nTIL 1:30PM") # through Sunday's hours
self.assertEqual(level(tiles["autofix"]), "off")
self.assertEqual(self.engine.auto_fix(self.snap, mock_comp(), SUNDAY + 200), [])
self.assertTrue(self.new_engine().autofix.paused) # survives a restart
self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # Wednesday's hours: back by itself
self.assertFalse(self.engine.autofix.paused)
# Pressed outside the hours (Thursday): the key said OFF / ON SUN 6:30AM; the pause holds through Sunday's hours.
thursday = SUNDAY - 3 * 86400
tiles = self.engine.step(self.snap, thursday)
self.assertEqual(tiles["autofix"]["x"], "AUTO-FIX\nOFF\nON SUN 6:30AM")
self.engine.autofix.pause(thursday)
self.assertEqual(self.engine.step(self.snap, thursday + 1)["autofix"]["x"], "AUTO-FIX\nPAUSED\nTIL SUN 1:30PM")
self.assertFalse(self.engine.autofix.on(SUNDAY)) # Sunday 8:00, inside the hours: still paused
self.assertTrue(self.engine.autofix.on(datetime(2026, 10, 14, 16, 30).timestamp())) # and over after them
self.engine.autofix.pause(thursday)
self.engine.autofix.resume(thursday + 60) # pressed again: back to the hours
self.assertTrue(self.engine.autofix.on(SUNDAY))
page = make_page.build(self.cfg)["page"]["controls"]
notes = {(ctl.get("options") or {}).get("notes", "") for row in page.values() for ctl in row.values() if ctl}
self.assertTrue(any("Auto-fix is the AUTO-FIX key" in n for n in notes))
self.assertTrue(any("pause auto-fix" in n for n in notes))
def test_a_hold_from_north_attleboro_flips_a_switch(self):
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY)
self.assertTrue(self.engine.autofix.paused)
self.engine.run_fixes("autofix", self.snap, mock_comp(), SUNDAY + 2)
self.assertFalse(self.engine.autofix.paused)
self.assertTrue(self.engine.autofix.on(SUNDAY + 3))
def test_the_operator_key_takes_the_midi_out_and_puts_it_back(self):
calls = []
here = {self.midi["session"]: {"port": 5004, "peers": [{"name": self.midi["peer"], "address": self.midi["address"]},
{"name": "Other", "address": "10.9.9.9:5004"}]}}
real = (sp.midi_sessions, sp.midi_peer)
sp.midi_sessions = lambda cfg, host: here
sp.midi_peer = lambda cfg, host, action, session, peer, address=None, repoint=False: calls.append((action, host, session, peer)) or ("asked", None)
class Comp:
def __init__(self):
self.on = True
self.log = []
def connections(self):
return [{"label": "x32", "id": "id-x32", "enabled": self.on}]
def disable_connection(self, cid):
self.on = False; self.log.append("off"); return True
def enable_connection(self, cid):
self.on = True; self.log.append("on"); return True
comp = Comp()
try:
if self.midi.get("host"):
self.snap.remote_midi[self.midi["host"]] = {} # its Mac reports: no peers
self.snap.remote_midi_time[self.midi["host"]] = SUNDAY + 100
else:
self.snap.midi, self.snap.midi_time = {self.midi["session"]: {"port": 5004, "peers": []}}, SUNDAY + 100
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 100, 3)
self.assertEqual(level(tiles["lights"]), "ok")
self.engine.run_fixes("lights", self.snap, comp, SUNDAY + 104) # hold: an operator has it
for _ in range(50):
if not self.engine.bypass_busy:
break
time.sleep(0.05)
self.assertEqual(calls, [("disconnect", "", self.midi["session"], self.midi["peer"])]) # never "Other"
self.assertEqual(comp.log, ["off"])
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 105, 3)
midi_tile = next(t["id"] for t in self.cfg["tiles"] if self.midi in t.get("checks", []))
tag = self.midi.get("tag") or self.midi["peer"]
self.assertNotIn(tag + " MIDI", tiles[midi_tile]["x"]) # taken out on purpose: no alarm
self.assertEqual(tiles["lights"]["x"], "LIGHTS\nTAKING\nMIDI OUT")
tiles = self.engine.step(self.snap, SUNDAY + 120)
self.assertEqual(tiles["lights"]["x"], "LIGHTS\nOPERATOR\nMIDI OFF")
tr = next(t for k, t in self.engine.trackers.items() if self.engine.meta.get(k, (None, {}))[1] is self.midi
or (self.engine.meta.get(k, (None, {}))[1].get("peer") == self.midi["peer"]
and self.engine.meta[k][1].get("session") == self.midi["session"]))
tr.starts.extend([SUNDAY + 1, SUNDAY + 2, SUNDAY + 3]) # it had dropped three times before
self.engine.step(self.snap, SUNDAY + 121)
self.assertEqual(len(tr.starts), 0) # not called flapping once taken out
self.assertIn("lights", self.new_engine().bypass) # survives a restart
calls.clear()
self.engine.run_fixes("lights", self.snap, comp, SUNDAY + 130) # hold again: put back
for _ in range(50):
if not self.engine.bypass_busy:
break
time.sleep(0.05)
self.assertEqual(calls[0], ("connect", "", self.midi["session"], self.midi["peer"])) # what was taken out
self.assertEqual({c[0] for c in calls}, {"connect"}) # and every link the checks watch
self.assertEqual(comp.log, ["off", "on"])
self.assertNotIn("lights", self.engine.bypass)
finally:
sp.midi_sessions, sp.midi_peer = real
def test_a_peer_without_an_address_is_never_called(self):
res, why = sp.midi_peer(self.cfg, "", "connect", "SC Tracks NEW", "Lighting", ":5004")
self.assertIsNone(res)
self.assertIn("calls in", why)
def test_a_bypass_key_needs_its_peers(self):
import schema
bad = json.loads(json.dumps(self.cfg))
next(t for t in bad["tiles"] if t["id"] == "lights")["bypass"] = {"hosts": ["nowhere"]}
errors = schema.validate(bad)[0]
self.assertTrue(any("bypass.peers" in e for e in errors))
self.assertTrue(any("nowhere" in e for e in errors))
class TestCompanionKeeper(unittest.TestCase):
"""After a restart the panel opens the Companion that ran last, and only then (Southcoast, 2026-10-21)."""
def setUp(self):
self.engine = sp.Engine(CONFIG, os.path.join(tempfile.mkdtemp(), "state.json"))
self.data = tempfile.mkdtemp()
self.app = tempfile.mkdtemp(suffix=".app") # stands in for /Applications/Companion 5.0.7.app
self.opened = []
self.now = 1000.0
self.running = self.app
def keeper(self):
return sp.CompanionKeeper(self.engine, self.data, started=self.now, running=lambda: self.running,
opener=self.opened.append)
def test_it_remembers_the_running_copy_and_opens_it_after_a_restart(self):
self.keeper().check(self.now) # Companion running: remembered
self.assertEqual(self.engine.state.get("companion_app"), self.app)
self.running = None # the Mac restarted without Companion
k = self.keeper()
self.assertEqual(k.check(self.now + 30), self.app)
self.assertEqual(k.check(self.now + 60), None) # not again straight away
self.assertEqual(self.opened, [self.app])
def test_it_never_acts_later_on_or_during_an_upgrade_or_without_knowing_the_copy(self):
k = self.keeper()
self.running = None
self.assertIsNone(k.check(self.now + 30)) # never seen one: no guessing
self.engine.state.set("companion_app", self.app)
self.assertIsNone(k.check(self.now + 700)) # someone quit it later on: left alone
open(os.path.join(self.data, "companion-upgrading"), "w").write("x")
self.assertIsNone(self.keeper().check(self.now + 30)) # an upgrade is going on
self.assertEqual(self.opened, [])
class TestMidiNoState(unittest.TestCase):
"""macOS 26 gives each live link a state, macOS 14/15 none: a link with no state is judged by which (2026-10-21)."""
def snap(self, peers, now):
s = healthy_snapshot(now)
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": peers}}}
s.remote_midi_time = {"tracks": now}
return s
def check(self, s, now):
chk = {"type": "midi_peer", "host": "tracks", "session": "T", "peer": "Lighting", "address": "10.5.1.168:5004"}
s.remote_midi_time = {"tracks": now} # a fresh reading every 2 s
return sp.check_midi_peer(chk, s, {"now": now})[0][1]
def test_on_a_mac_that_reports_states_no_state_means_no_answer(self):
s = self.snap([{"name": "FOH", "address": "x", "state": "running"}, {"name": "Lighting", "address": "y", "state": ""}], 100.0)
self.assertEqual(self.check(s, 100.0).level, sp.ERR)
self.assertEqual(self.check(s, 100.0).msg, "MIDI NO ANSWER") # there at the first reading: long-standing
def test_on_an_older_mac_a_link_put_back_counts_only_once_it_stays(self):
s = self.snap([{"name": "Lighting", "address": "y"}], 100.0)
self.assertEqual(self.check(s, 100.0).level, sp.OK) # there from the start: configured, fine
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": []}}}
self.assertEqual(self.check(s, 101.0).msg, "MIDI LOST") # dropped
s.remote_midi = {"tracks": {"T": {"port": 5004, "peers": [{"name": "Lighting", "address": "y"}]}}}
self.assertEqual(self.check(s, 110.0).msg, "MIDI CONNECTING") # put back: not trusted yet
self.assertEqual(self.check(s, 125.0).msg, "MIDI CONNECTING")
self.assertEqual(self.check(s, 131.0).level, sp.OK) # stayed 20 s: it answered
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""SSH from the admin laptop to every machine on every location.
python3 tools/fleet.py ard the command to paste into Apple Remote Desktop (Send UNIX Command, as root)
python3 tools/fleet.py config write ~/.ssh/config.d/waters from the machines that reported back
The ARD command, on each Mac: turns on Remote Login, lets the logged-in user's account take this
laptop's key (~/.ssh/id_ed25519_waters), and reports name, user and addresses to North Attleboro's
settings page (/api/fleet/hello). `config` turns that list into shortcuts: ssh na-tracks-mac.
"""
import json
import os
import re
import subprocess
import sys
import urllib.request
HUB = os.environ.get("FLEET_HUB", "http://10.1.1.1:8020")
KEY = os.path.expanduser("~/.ssh/id_ed25519_waters")
OUT = os.path.expanduser("~/.ssh/config.d/waters")
def pubkey():
return open(KEY + ".pub").read().strip()
def ard():
key = pubkey()
return r"""U=$(stat -f%Su /dev/console); if [ "$U" = root ] || [ -z "$U" ]; then echo "SKIPPED $(scutil --get ComputerName): nobody is logged in"; exit 0; fi
H=$(dscl . -read "/Users/$U" NFSHomeDirectory | awk '{print $2}'); KEY='""" + key + r"""'
mkdir -p "$H/.ssh" && touch "$H/.ssh/authorized_keys" && (grep -qF "$KEY" "$H/.ssh/authorized_keys" || echo "$KEY" >> "$H/.ssh/authorized_keys") && chown -R "$U:staff" "$H/.ssh" && chmod 700 "$H/.ssh" && chmod 600 "$H/.ssh/authorized_keys"
nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1 || systemsetup -setremotelogin on >/dev/null 2>&1 || launchctl load -w /System/Library/LaunchDaemons/ssh.plist >/dev/null 2>&1
dseditgroup -o read com.apple.access_ssh >/dev/null 2>&1 && dseditgroup -o edit -a "$U" -t user com.apple.access_ssh >/dev/null 2>&1
IPS=$(ifconfig | awk '/inet /{print $2}' | grep -v '^127\.' | sed 's/.*/"&"/' | paste -sd, -); N=$(scutil --get ComputerName | tr -d '"\\')
curl -s -m 5 -X POST -H 'Content-Type: application/json' -d "{\"user\":\"$U\",\"name\":\"$N\",\"hostname\":\"$(scutil --get LocalHostName).local\",\"os\":\"mac\",\"ips\":[$IPS]}" """ + HUB + r"""/api/fleet/hello >/dev/null
echo "OK $N user=$U ssh=$(nc -z -G 2 127.0.0.1 22 >/dev/null 2>&1 && echo on || echo OFF)"
"""
def slug(s):
s = re.sub(r"\s*\(\d+\)$", "", s) # macOS's "Name (2)" after a rename clash
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "mac"
def config():
with urllib.request.urlopen(HUB + "/api/fleet", timeout=10) as r:
machines = json.load(r)["machines"]
# The locations' wired networks first (10.1 NA, 10.2 WS, 10.3 AB, 10.4 NW, 10.5 SC); NA Ingest also has a
# 10.66 address the laptop cannot reach (2026-10-21), and 10.6 is staff Wi-Fi.
site = lambda ip: re.match(r"10\.[1-5]\.", ip) is not None
wired = lambda ip: not ip.startswith(("10.6.", "169.254.", "127."))
# A record registered from this laptop carries the laptop's own address too (the hub adds the sender's).
own = set(re.findall(r"inet (\d+\.\d+\.\d+\.\d+)", subprocess.run(["/sbin/ifconfig"], capture_output=True, text=True).stdout))
lines, used = ["# Written by tools/fleet.py from %s/api/fleet - every machine that took this laptop's key." % HUB], set()
for m in sorted(machines, key=lambda m: m["name"].lower()):
ips = [i for i in m["ips"] if i not in own]
ip = next((i for i in ips if site(i)), next((i for i in ips if wired(i)), ips[0] if ips else None))
if not ip:
continue
alias = slug(m["name"])
while alias in used:
alias += "-2"
used.add(alias)
lines += ["", "Host %s" % alias, " HostName %s" % ip, " User %s" % m["user"],
" IdentityFile %s" % KEY, " IdentitiesOnly yes", " ConnectTimeout 8"]
os.makedirs(os.path.dirname(OUT), exist_ok=True)
open(OUT, "w").write("\n".join(lines) + "\n")
cfg = os.path.expanduser("~/.ssh/config")
text = open(cfg).read() if os.path.exists(cfg) else ""
if "Include ~/.ssh/config.d/*" not in text:
open(cfg, "w").write("Include ~/.ssh/config.d/*\n\n" + text)
print("%d machines -> %s: %s" % (len(used), OUT, ", ".join(sorted(used))))
if __name__ == "__main__":
what = (sys.argv[1:] or ["ard"])[0]
print(ard()) if what == "ard" else config()
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""A status panel a location installs and runs on its own: no North Attleboro.
python3 tools/standalone.py ab "Apollo Beach" -> dist/Apollo Beach Status Panel.zip
Apollo Beach, 2026-10-21 (the user): "they want to take care of it on their own, so make a sorta
standalone version that i can send them. i dont need to monitor AB from NA like i do for SC and WS".
The zip holds the program (the same files a location downloads from NA, never NA's settings, plug keys
or login keys), an installer and a read-me. Installed, the location sets itself up from what it finds,
exactly as Southcoast and Woonsocket did, but it reports to nobody (campuses.json "standalone"), lets
no other Mac in, and backs itself up every night into ~/Documents/Status Panel Backups.
"""
import os
import re
import sys
import zipfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
from settings.campus import KIT_FILES, KIT_DIRS # noqa: E402
INSTALL = r"""#!/bin/zsh
# Status panel for %(name)s: installs on the Mac that runs Companion and sets itself up from what it finds.
# Safe to run again: it then updates the program and looks for new gear.
set -e
HERE=${0:A:h}
if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
echo "First the Command Line Tools: a window opens - click Install, wait for it, then run this again."
/usr/bin/xcode-select --install 2>/dev/null || true
exit 1
fi
DIR="$HOME/status-panel-install"
rm -rf "$DIR" && mkdir -p "$DIR"
/usr/bin/ditto "$HERE/program" "$DIR"
/usr/bin/xattr -dr com.apple.quarantine "$DIR" 2>/dev/null || true
[[ -f "$HOME/.ssh/id_status_panel" ]] || { mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" && /usr/bin/ssh-keygen -q -t ed25519 -N "" -C "status-panel@$(scutil --get LocalHostName)" -f "$HOME/.ssh/id_status_panel"; }
cd "$DIR"
zsh install.sh --site %(cid)s
cd "$HOME/Library/Application Support/status-panel"
/usr/bin/python3 -m settings.location setup --id %(cid)s --name %(qname)s --standalone
# This Mac's wired address (the default route's port).
IF=$(/sbin/route -n get default 2>/dev/null | awk '/interface:/{print $2}')
ME=$(/usr/sbin/ipconfig getifaddr "$IF" 2>/dev/null || hostname)
echo
echo "Settings page: http://$ME:8020 (the editing code is printed above)"
echo
echo "Next, on each other computer here (ProPresenter, Tracks, Lighting, FOH...):"
echo " Mac: open Terminal and paste curl -fsSL http://$ME:8020/join | sh"
echo " (or send it to all of them at once in Apple Remote Desktop: Send UNIX Command, as root)"
echo " PC: open PowerShell as administrator and paste irm http://$ME:8020/join.ps1 | iex"
echo "Then on the settings page: Auto-setup > Look now."
"""
README = """%(name)s - Status Panel
%(rule)s
What it is
A status page on the Stream Deck (Bitfocus Companion) that watches your gear - ProPresenter, the
ATEM, Resi, consoles, Tracks, Lighting, MIDI links, the network - and fixes what it can. It runs on
the Mac that runs Companion. It is yours: nothing is sent to any other location.
Before you start
- Companion 5.0.7 on that Mac, with its HTTP API on (Companion > Settings > Protocols).
If yours is older, the installer says so and how to upgrade (download 5.0.7 for Mac from
https://bitfocus.io/companion; your buttons come along).
- Remote Login on for the other Macs (System Settings > General > Sharing), so the panel can check
and restart programs on them.
Install (about 5 minutes)
1. Copy this folder to the Companion Mac (Downloads is fine).
2. Open Terminal, type zsh and a space, drag "install.command" from this folder into the
window, press Return.
3. It finds what you have, makes the Stream Deck pages (19 status, 20 restart, or the first free
pages), and prints the settings page address and its editing code (PIN). Keep the PIN.
4. On each other computer, paste the line it prints (Mac: Terminal; PC: PowerShell as
administrator). Then on the settings page: Auto-setup > Look now. New gear gets keys by itself.
Using it (the Stream Deck)
- Green means fine. Flashing red names the problem, for example "PROP OFFLINE".
- Press a red key to acknowledge it: it stops flashing and stays dark red.
- Hold any key for 2 seconds to fix it: it reconnects, restarts or reopens whatever is failing.
- Purple means the panel itself is not running: restart the Companion Mac, or run the installer again.
- ALARMS (bottom-left) silences alarms and stops automatic fixes until midnight. Use it during
maintenance.
- The page-number key on every page turns red when something is wrong. Hold it for 1 second to
jump to the status page.
- RESTART PAGE opens the restart page. Hold a key there for 2 seconds to restart that program even
if it looks fine.
- Automatic fixes run Tuesday 2-10 pm, Wednesday 9 am-9 pm and Sunday 5 am-2 pm. Change the hours
on the settings page (Auto-fix) to match your services.
- Nothing is ever restarted while the HyperDeck is recording.
The settings page (http://<Companion Mac>:8020)
Everything is changed there: keys, checks, fixes, auto-fix hours, the Restart page. Every Apply is
kept, and History puts back any earlier one. Changing gear? Auto-setup > Look now adds what is new.
Backups
Every night at 4:30 am the settings are copied to ~/Documents/Status Panel Backups/%(cid)s on the
Companion Mac (the newest 30 are kept). Copy that folder somewhere safe now and then, or point it at
a Google Drive folder (ask whoever installed it).
Run it again
Running install.command again updates the program and looks for new gear; your settings stay.
"""
def build(cid, name, out_dir):
cid = re.match(r"[a-z0-9]{1,8}", cid.lower()).group(0)
top = "%s Status Panel" % name
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, top + ".zip")
def add_file(z, src, arc, mode=0o644):
info = zipfile.ZipInfo.from_file(src, arc)
info.external_attr = (0o100000 | mode) << 16
info.compress_type = zipfile.ZIP_DEFLATED
with open(src, "rb") as f:
z.writestr(info, f.read())
def add_text(z, text, arc, mode=0o644):
info = zipfile.ZipInfo(arc, date_time=(2026, 10, 21, 0, 0, 0))
info.external_attr = (0o100000 | mode) << 16
info.compress_type = zipfile.ZIP_DEFLATED
z.writestr(info, text)
with zipfile.ZipFile(path, "w") as z:
prog = top + "/program/"
for f in KIT_FILES + ("install.sh", "README.md"):
p = os.path.join(ROOT, f)
if os.path.exists(p):
add_file(z, p, prog + f, 0o755 if f.endswith(".sh") else 0o644)
for d in KIT_DIRS + ("sites/_template",):
for base, dirs, files in os.walk(os.path.join(ROOT, d)):
dirs[:] = [x for x in dirs if x != "__pycache__"]
for f in files:
if f.endswith(".pyc") or f.startswith("._") or f == ".DS_Store":
continue
p = os.path.join(base, f)
add_file(z, p, prog + os.path.relpath(p, ROOT), 0o755 if f.endswith(".sh") else 0o644)
q = "'" + name.replace("'", "'\"'\"'") + "'"
add_text(z, INSTALL % {"name": name, "cid": cid, "qname": q}, top + "/install.command", 0o755)
add_text(z, README % {"name": name, "cid": cid, "rule": "=" * (len(name) + 15)}, top + "/READ ME FIRST.txt")
return path
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit(__doc__)
print(build(sys.argv[1], sys.argv[2], os.path.join(ROOT, "dist")))