Compare commits

..
2 Commits
Author SHA1 Message Date
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
15 changed files with 2360 additions and 68 deletions
+71 -4
View File
@@ -13,7 +13,7 @@ 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:
@@ -21,7 +21,10 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
- REC: HD1–6 and the Tascam.
- AUDIO: X32 and Dante.
- 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.
@@ -93,6 +96,69 @@ Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **e
Add or move keys in `"restart_page"` in `config.json`, then `python3 make_page.py` and `python3 push_page.py --which restart`. `push_page.py` refuses any page that holds real buttons unless it already is that page.
## READY page (page 24): the weekly checklist
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. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again |
| BKGDS | every song has at least one slide with a background | looks again |
| LED CROSS | Resolume is on the **Service** deck and a clip is playing | selects the Service deck and plays the cross background again (the one in `"led_clip"`, else the last one seen playing) |
| 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 campus:** 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 campus 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 24, which was empty (pages 21 to 23 are in use).
## 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`).
@@ -151,7 +217,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:
@@ -224,7 +290,8 @@ To go back, pick 1.3.0 on the connection again.
| `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 |
+340
View File
@@ -751,6 +751,37 @@
"template": "hyperdeck",
"params": {}
},
"ready_recheck": {
"kind": "ready",
"action": "recheck",
"short": "CHECK",
"label": "look at this weekend's checklist again now"
},
"ready_lineup": {
"kind": "ready",
"action": "lineup_sync",
"short": "LINEUP",
"label": "re-sync the Band Lineup board from Planning Center"
},
"ready_led_cross": {
"kind": "ready",
"action": "led_cross",
"short": "CROSS",
"label": "put Resolume back on the Service deck and play the cross background again"
},
"ready_copy_tracks": {
"kind": "ready",
"action": "copy_tracks",
"short": "COPY",
"never_auto": true,
"label": "copy this weekend's Ableton project to the locations that do not have it yet"
},
"ready_ltc": {
"kind": "ready",
"action": "ltc_to_pco",
"short": "LTC",
"label": "write each song's timecode from the tracks into its LTC note in Planning Center"
},
"tascam_power": {
"kind": "local",
"template": "outlet_cycle",
@@ -815,6 +846,9 @@
"atem_camera",
"atem_midi",
"spotify_play"
],
"while_muted": [
"spotify_play"
]
},
"restart_page": {
@@ -961,6 +995,312 @@
}
]
},
"ready_page": {
"page": 24,
"page_name": "24\\nReady",
"back_pos": [
3,
0
],
"keys": [
{
"id": "rd_tracks",
"label": "TRACKS",
"pos": [
0,
1
],
"checks": [
{
"type": "ready",
"item": "tracks",
"fix": "ready_recheck"
}
]
},
{
"id": "rd_foh",
"label": "FOH MIX",
"pos": [
0,
2
],
"checks": [
{
"type": "ready",
"item": "mix_foh",
"fix": "ready_recheck"
}
]
},
{
"id": "rd_boh",
"label": "BOH MIX",
"pos": [
0,
3
],
"checks": [
{
"type": "ready_manual",
"item": "mix_boh"
}
]
},
{
"id": "rd_lights",
"label": "LIGHTS",
"pos": [
0,
4
],
"checks": [
{
"type": "ready",
"item": "lights",
"fix": "ready_recheck"
}
]
},
{
"id": "rd_midi",
"label": "MIDI",
"pos": [
0,
5
],
"checks": [
{
"type": "midi_peer",
"host": "tracks",
"session": "NA Tracks",
"peer": "Lighting PC",
"tag": "FX LIGHTS",
"fix": "midi_reconnect",
"address": "10.1.10.206:5004"
},
{
"type": "midi_peer",
"host": "tracks",
"session": "NA Tracks",
"peer": "SuperRack-LiveBox",
"tag": "TRK LIVEBOX",
"fix": "midi_reconnect",
"address": "10.1.13.140:5004"
},
{
"type": "midi_peer",
"host": "tracks",
"session": "NA Tracks",
"peer": "Studio B Mac Studio",
"tag": "TRK STUDIO B",
"fix": "midi_reconnect",
"address": "\tStudio B Mac Studio\tlocal."
},
{
"type": "remote",
"host": "tracks",
"cmd": "pgrep -xq Live",
"msg": "ABLETON NOT RUNNING",
"fix": "ableton",
"template": "mac_program",
"params": {
"host": "tracks",
"process": "Live"
}
}
]
},
{
"id": "rd_prop",
"label": "PROP",
"pos": [
1,
1
],
"checks": [
{
"type": "ready",
"item": "prop",
"fix": "ready_recheck"
}
]
},
{
"id": "rd_timecode",
"label": "TIMECODE",
"pos": [
1,
2
],
"checks": [
{
"type": "ready",
"item": "timecode",
"fix": "ready_ltc"
}
]
},
{
"id": "rd_bg",
"label": "BKGDS",
"pos": [
1,
3
],
"checks": [
{
"type": "ready",
"item": "backgrounds",
"fix": "ready_recheck"
}
]
},
{
"id": "rd_cross",
"label": "LED CROSS",
"pos": [
1,
4
],
"checks": [
{
"type": "ready",
"item": "led_cross",
"fix": "ready_led_cross"
}
]
},
{
"id": "rd_lineup",
"label": "LINEUP TV",
"pos": [
2,
1
],
"checks": [
{
"type": "ready",
"item": "lineup",
"fix": "ready_lineup"
}
]
},
{
"id": "rd_trk_out",
"label": "TRACKS OUT",
"pos": [
3,
1
],
"checks": [
{
"type": "ready",
"item": "track_bundle",
"fix": "ready_copy_tracks"
}
]
},
{
"id": "rd_prop_out",
"label": "PROP OUT",
"pos": [
3,
2
],
"checks": [
{
"type": "ready",
"item": "prop_bundle",
"fix": "ready_recheck"
}
]
},
{
"id": "rd_all",
"label": "READY",
"pos": [
3,
7
],
"kind": "ready"
}
]
},
"ready": {
"interval_seconds": 120,
"due": [
{
"days": [
"sat"
],
"start": "12:00",
"end": "23:59"
},
{
"days": [
"sun"
],
"start": "00:00",
"end": "13:30"
}
],
"reset_day": "mon",
"pco_credentials": "~/Library/Application Support/ProdDash/modules.json",
"pco_module": "pco-plan",
"pco_service_type": "275974",
"skip_songs": [],
"ltc_note": "LTC",
"prop_url": "http://10.1.14.225:1025",
"prop_lists": [
{
"match": "announcement",
"kind": "count",
"short": "MC"
},
{
"match": "baptism",
"kind": "names",
"short": "BAPTISM"
},
{
"match": "dedication",
"kind": "names",
"short": "DEDICATION"
}
],
"tracks_host": "tracks",
"projects": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Waters Creative/Ableton/Projects",
"site_folder": "NA",
"folder_format": "{date} {loc} Project",
"ltc_track": "LTC",
"lighting_track": "LIGHTING CUES",
"locations": [
"AB",
"SC"
],
"remote_timeout": 90,
"lineup_url": "http://127.0.0.1:24500/api/modules/band-lineup",
"lineup_teams": [
"Band",
"Communication"
],
"lineup_skip_positions": [],
"resolume_url": "http://10.1.10.206:8080",
"resolume_deck": "Service",
"led_clip": "",
"prop_bundle_host": "prop",
"prop_bundle_folder": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Location Assets/ProPresenter",
"prop_bundle_pattern": "*.probundle",
"vocal_positions": [
"Vocalist",
"[Leader]"
],
"mic_positions": {
"MC": "MC",
"Speaker": "TIM"
}
},
"tiles": [
{
"id": "all",
+1 -1
View File
@@ -18,7 +18,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
+40 -5
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 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
@@ -193,7 +212,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.
@@ -322,6 +341,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 +374,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
+1030
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+128 -3
View File
@@ -111,6 +111,8 @@ field("auto_fix.windows", "list", [], "When auto-fix may act.", "Schedules")
field("auto_fix.windows[].days", "days", None, "", "Schedules", required=True)
field("auto_fix.windows[].start", "hhmm", None, "", "Schedules", required=True)
field("auto_fix.windows[].end", "hhmm", None, "", "Schedules", required=True)
field("auto_fix.while_muted", "list", [], "Auto-fixes that keep running inside the window even while ALARMS is "
"muted (harmless ones, like playing the Spotify playlist).", "Schedules")
field("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,
@@ -184,6 +186,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")
@@ -214,6 +218,7 @@ 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.*.action", "enum", None, "What a READY page fix does.", "Fixes", options=list(sp.READY_ACTIONS))
# -- tiles (keys) --
field("tiles", "list", [], "The keys on the Status page.", "Keys", required=True)
@@ -222,8 +227,9 @@ 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("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARMS), network (NET), ready (READY: "
"opens the READY page and sums up its checklist).", "Keys", advanced=True,
options=["master", "service", "network", "ready"], apply="pages")
field("tiles[].members", "list", None, "A group: the keys it sums up.", "Keys")
field("tiles[].checks", "list", [], "What the key watches.", "Keys")
field("tiles[].hold_always", "str", None, "A fix a hold runs even when the key is green.", "Keys", apply="pages")
@@ -275,6 +281,7 @@ 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 --
@@ -293,6 +300,60 @@ field("restart_page.keys[].fix", "str", None, "What holding it runs.", "Keys", r
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 campus 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.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_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 campus'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 campuses that get this campus'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.led_clip", "str", "", "The cross background a hold plays; blank = the last one seen playing.", "Ready")
field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready")
field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes.", "Ready")
field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True)
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.
@@ -336,6 +397,10 @@ CHECKS = {
"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"]
FIX_KINDS = sp.FIX_KINDS
@@ -343,7 +408,7 @@ FIX_KIND_FIELDS = {
"ssh": ["host", "run", "timeout", "reconnect", "conn"], "local": ["run", "timeout", "reconnect", "conn"],
"atem": ["part"], "reconnect": ["conn", "verify_seconds", "hung_note"], "midi_connect": [],
"netgear_poe": ["switch", "port", "mac"], "hyperdeck": ["verify_seconds", "off_seconds", "hung_note"],
"spotify": ["playlist"],
"spotify": ["playlist"], "ready": ["action"],
}
EXPECTED_MODULE = {t: spec["module"] for t, spec in CHECKS.items() if spec["module"]}
@@ -361,6 +426,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", "NO CLIP KNOWN", "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",
@@ -399,6 +472,10 @@ 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("network."):
@@ -571,6 +648,9 @@ def validate(cfg, live=None):
errors.append("auto-fix: %s is marked never to run by itself" % f)
elif re.search(r"pkill|taskkill|killall", fixes[f].get("run", "")) and not fixes[f].get("restart_ok"):
errors.append("auto-fix: %s closes a running program; tick 'may close a running program' or take it out" % f)
for f in a.get("while_muted", []):
if f not in a.get("fixes", []):
errors.append("auto-fix: %s runs while ALARMS is muted but is not an auto-fix" % f)
_windows(cfg.get("service_windows"), "alarms window", errors)
_windows(a.get("windows"), "auto-fix window", errors)
_windows((cfg.get("site") or {}).get("live_services"), "live services", errors)
@@ -600,6 +680,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", ""))):
+7 -2
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)
@@ -558,7 +563,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 +598,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"
+3 -2
View File
@@ -306,7 +306,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
@@ -1130,7 +1131,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."
+55 -12
View File
@@ -262,6 +262,7 @@
function canRunNow(fixId) {
const f = cfg().fixes[fixId]; if (!f) return false;
if (["reconnect", "hyperdeck", "midi_connect", "spotify"].includes(f.kind)) return true;
if (f.kind === "ready") return !f.never_auto; // the READY page's fixes (not the tracks copy)
if ((f.kind || "ssh") === "ssh" || f.kind === "local") return !closes(f) && !f.never_auto;
return false;
}
@@ -274,21 +275,24 @@
function deckHtml(deck, c, which, selected, draggable) {
const cells = {};
const rp = c.restart_page;
const rp = c.restart_page, dp = c.ready_page;
if (which === "restart" && rp) { for (const k of rp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[rp.back_pos] = { nav: "STATUS\nPAGE" }; }
else if (which === "ready" && dp) { for (const k of dp.keys) cells[k.pos] = { id: k.id, label: k.label }; cells[dp.back_pos] = { nav: "STATUS\nPAGE", back: "dback:" }; }
else { for (const t of c.tiles) if (t.pos) cells[t.pos] = { id: t.id, label: t.label }; if (rp) cells[rp.jump_pos] = { nav: "RESTART\nPAGE" }; }
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : c.page), "2,0": "DOWN" };
const nav = { "0,0": "UP", "1,0": String(which === "restart" && rp ? rp.page : which === "ready" && dp ? dp.page : c.page), "2,0": "DOWN" };
const prefix = { restart: "rkey:", ready: "dkey:" }[which] || "tile:";
const route_ = { restart: "restart/", ready: "ready/" }[which] || "";
const drag = (what) => editing() && draggable ? ` draggable="true" data-drag="${esc(what)}"` : "";
let out = `<div class="deck">`;
for (let r = 0; r < 4; r++) for (let col = 0; col < 8; col++) {
const k = `${r},${col}`, cell = cells[k];
if (nav[k]) out += `<div class="key nav locked">${esc(nav[k])}</div>`;
else if (cell && cell.nav) out += `<div class="key nav"${drag(cell.nav.startsWith("RESTART") ? "jump:" : "back:")}>${esc(cell.nav)}</div>`;
else if (cell && cell.nav) out += `<div class="key nav"${drag(cell.back || (cell.nav.startsWith("RESTART") ? "jump:" : "back:"))}>${esc(cell.nav)}</div>`;
else if (cell) {
const s = deck && deck[cell.id];
const style = s ? `--b1:${hex(s.b)};--f1:${hex(s.f)};--b2:${hex(s.b2)};--f2:${hex(s.f2)};background:${hex(s.b)};color:${hex(s.f)}` : "background:#444;color:#ddd";
const blink = s && s.b !== s.b2 ? " blink" : "";
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}"${drag((which === "restart" ? "rkey:" : "tile:") + cell.id)} onclick="location.hash='#keys/${which === "restart" ? "restart/" : ""}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
out += `<div class="key${blink}${selected === cell.id ? " sel" : ""}" style="${style}"${drag(prefix + cell.id)} onclick="location.hash='#keys/${route_}${esc(cell.id)}'">${esc(s ? s.x : cell.label)}</div>`;
} else out += `<div class="key empty"${editing() && draggable ? ` data-drop="${r},${col}"` : ""}></div>`;
}
return out + `</div>`;
@@ -300,29 +304,32 @@
if (rest[0] === "fixes") return fixes(rest.slice(1));
const o = await api("/api/overview").catch(() => ({ deck: null }));
const c = cfg();
const ready = rest[0] === "ready";
const restart = rest[0] === "restart";
const sel = restart ? rest[1] : rest[0];
const sel = restart || ready ? rest[1] : rest[0];
const hold = ((c.page_hold_ms || 2000) / 1000) + " s";
const tabs = `<div class="tabs"><button class="${restart ? "" : "on"}" onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page</button>` : ""}<button onclick="location.hash='#keys/fixes'">Fixes</button>
const tabs = `<div class="tabs"><button class="${restart || ready ? "" : "on"}" onclick="location.hash='#keys'">Status page</button>${c.restart_page ? `<button class="${restart ? "on" : ""}" onclick="location.hash='#keys/restart'">Restart page</button>` : ""}${c.ready_page ? `<button class="${ready ? "on" : ""}" onclick="location.hash='#keys/ready'" data-hint="The weekly worship checklist, checked against Planning Center">READY page</button>` : ""}<button onclick="location.hash='#keys/fixes'">Fixes</button>
${editing() ? `<span class="small muted" style="margin-left:auto">page <input class="pagenum" data-which="status" type="number" min="1" value="${c.page}" data-hint="The Status page's number in Companion">${c.restart_page ? ` · restart page <input class="pagenum" data-which="restart" type="number" min="1" value="${c.restart_page.page}">` : ""} <label class="inline"><input type="checkbox" id="hasrestart" ${c.restart_page ? "checked" : ""}> Restart page${hint("A second page where each key restarts one thing, even when green")}</label></span>` : ""}</div>`;
let panel = `<p class="muted small">Click a key.${editing() ? " Drag to move; drop on the tray to hide." : ""}</p>`;
if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
if (ready) { const i = sel && c.ready_page ? c.ready_page.keys.findIndex((x) => x.id === sel) : -1; panel = await readyKeyPanel(i, hold); }
else if (sel && !restart) { const i = c.tiles.findIndex((x) => x.id === sel); if (i >= 0) panel = await keyPanel(i, hold); }
else if (sel && restart && c.restart_page) { const i = c.restart_page.keys.findIndex((x) => x.id === sel); if (i >= 0) panel = restartKeyPanel(i, hold); }
const trayKeys = c.tiles.filter((t) => !t.pos && t.kind !== "master");
const tray = trayKeys.map((t) => `<button class="small"${editing() ? ` draggable="true" data-drag="tile:${esc(t.id)}"` : ""} onclick="location.hash='#keys/${esc(t.id)}'">${esc(t.label)}</button>`).join(" ");
let note = "";
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `<div class="notice">Layout changed: Apply re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.</div>`; } catch (e) {} }
const adders = editing() ? (restart ? `<button id="newrkey" class="small">+ Restart key</button>` : `<button id="newkey" class="small">+ Key</button>`) : "";
if (state.draft) { try { const v = await post("/api/validate", {}); if (v.plan && v.plan.pages.length) note = `<div class="notice">Layout changed: Apply re-imports the ${v.plan.pages.filter((p) => p === "status" || p === "restart" || p === "ready").map((p) => p[0].toUpperCase() + p.slice(1)).join(" and ")} page.</div>`; } catch (e) {} }
const adders = editing() && !ready ? (restart ? `<button id="newrkey" class="small">+ Restart key</button>` : `<button id="newkey" class="small">+ Key</button>`) : "";
main.innerHTML = `<h1>Keys${hint("The deck as people hold it. Click a key to see what it watches and what holding it does.")}</h1>${tabs}${note}
<div class="cols keycols"><div>${restart && !c.restart_page ? "<p class='muted'>No Restart page.</p>" : deckHtml(o.deck, c, restart ? "restart" : "status", sel, true)}
${!restart ? `<p class="small muted tray" id="tray" style="margin-top:8px">In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "<span class='muted'>none</span>"}</p>` : ""}
<div class="cols keycols"><div>${restart && !c.restart_page ? "<p class='muted'>No Restart page.</p>" : ready && !c.ready_page ? "<p class='muted'>No READY page.</p>" : deckHtml(o.deck, c, restart ? "restart" : ready ? "ready" : "status", sel, true)}
${!restart && !ready ? `<p class="small muted tray" id="tray" style="margin-top:8px">In a group only${hint("Keys with no spot of their own: they show inside their group key. Drop a key here to hide it.")}: ${tray || "<span class='muted'>none</span>"}</p>` : ""}
<div class="row" style="margin-top:8px">${adders}</div></div><div id="panel">${panel}</div></div>`;
wireKeyPanel(sel, restart);
wireKeyPanel(sel, restart || ready);
wireLayout(restart);
}
function takenCells(c, which) {
const taken = new Set(["0,0", "1,0", "2,0"]);
if (which === "restart") { for (const k of c.restart_page.keys) taken.add(String(k.pos)); taken.add(String(c.restart_page.back_pos)); }
else if (which === "ready") { for (const k of c.ready_page.keys) taken.add(String(k.pos)); taken.add(String(c.ready_page.back_pos)); }
else { for (const t of c.tiles) if (t.pos) taken.add(String(t.pos)); if (c.restart_page) taken.add(String(c.restart_page.jump_pos)); }
return taken;
}
@@ -347,6 +354,8 @@
else if (kind === "rkey") { const k = c.restart_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
else if (kind === "jump" && pos) c.restart_page.jump_pos = pos;
else if (kind === "back" && pos) c.restart_page.back_pos = pos;
else if (kind === "dkey") { const k = c.ready_page.keys.find((x) => x.id === id); if (k && pos) k.pos = pos; }
else if (kind === "dback" && pos) c.ready_page.back_pos = pos;
saveDraft(); route();
};
let dragging = null;
@@ -479,6 +488,40 @@
for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); };
for (const s of main.querySelectorAll("select.watchsel")) s.onchange = () => setPath(s.dataset.path, [...s.selectedOptions].map((o) => o.value));
}
// ---- The READY page: what each checklist key looks at, and why it is amber or red right now ----
const READY_WORDS = {
tracks: "This weekend's Ableton set exists, has a song group for every Planning Center song, and is the set Live last opened.",
mix_foh: "Every song is mixed (a song whose tracks are all still at 0 dB is not) and its stems go out to the console, not to Master.",
lights: "The lighting cue track has a clip under every song.",
prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
backgrounds: "Every song has at least one slide with a background.",
led_cross: "Resolume is on the Service deck and a clip is playing on the cross.",
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
};
async function readyKeyPanel(i, hold) {
const c = cfg();
const snap = await api("/api/snapshot").catch(() => ({}));
const r = snap.ready || {};
const plan = r.plan ? `<p class="small muted">Plan: ${esc(r.plan.title || "")} ${esc(r.plan.dates || "")}${r.time ? ` · looked ${esc(new Date(r.time * 1000).toLocaleTimeString())}` : ""}</p>` : `<p class="small muted">Not looked yet.</p>`;
if (i < 0) return `<div class="card"><h2 style="margin-top:4px">The weekly checklist</h2>${plan}<p class="small">Amber: still to do. Blinking red: still missing once it is due (${esc(((c.ready || {}).due || []).map((w) => w.days.join("/") + " " + w.start + "-" + w.end).join(", "))}). BOH MIX is tapped done on the deck and reset every ${esc((c.ready || {}).reset_day || "mon")}. Hold any key ${esc(hold)} to fix it or look again; hold READY to fix everything.</p><p class="small muted">Click a key to see what it checks and why it is amber or red.</p></div>`;
const k = c.ready_page.keys[i], chk = (k.checks || [])[0] || {};
const item = r.items && chk.item ? r.items[chk.item] : null;
const fix = chk.fix && c.fixes[chk.fix];
let what;
if (k.kind === "ready") what = `Sums up the page: how many keys still have something to do, and which to look at first. <b>Hold ${esc(hold)}</b>: fix everything on the page that can be fixed.`;
else if (chk.type === "ready_manual") what = `Only a person can check this. <b>Tap</b> on the deck when it is done; tap again to take it back. It resets every ${esc((c.ready || {}).reset_day || "mon")}.`;
else if (chk.type === "ready") what = `${esc(READY_WORDS[chk.item] || chk.item)} <b>Hold ${esc(hold)}</b>: ${esc(fix && fix.action !== "recheck" ? fix.label : "look again now")}.`;
else what = `Watches ${esc((k.checks || []).map((x) => x.tag || x.msg || x.type).join(", "))}. <b>Hold ${esc(hold)}</b>: fix the one that dropped.`;
const faults = item && item.faults && item.faults.length
? `<ul class="small">${item.details.map((d) => `<li>${esc(d)}</li>`).join("")}</ul>`
: item ? `<p class="small">Nothing to do${item.info ? ` (${esc(item.info)})` : ""}.</p>` : "";
const edit = editing() ? inputHtml(`ready_page.keys.${i}.label`, F("tiles[].label"), k.label) : "";
return `<div class="card"><h2 style="margin-top:4px">${esc(k.label)} <span class="muted small">row ${k.pos[0]}, col ${k.pos[1]}</span></h2>${plan}<p class="small">${what}</p>${faults}${edit}
${chk.fix && canRunNow(chk.fix) && editing() ? `<button class="small" data-run="${esc(k.id)}|${esc(chk.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(k.id)}</dd>${chk.item ? `<dt>checklist line</dt><dd class="mono">${esc(chk.item)}</dd>` : ""}</dl></details></div>`;
}
function restartKeyPanel(i, hold) {
const c = cfg(), r = c.restart_page.keys[i];
const f = c.fixes[r.fix] || {};
Binary file not shown.
+157 -27
View File
@@ -42,7 +42,7 @@ log = logging.getLogger("status-panel")
VERSION = "2026.10.13" # logged at start ("status panel running (code ...)") and written to started.json
# Every kind of fix the watchdog can run (validated by load_config).
FIX_KINDS = ("ssh", "local", "atem", "reconnect", "midi_connect", "netgear_poe", "hyperdeck", "spotify")
FIX_KINDS = ("ssh", "local", "atem", "reconnect", "midi_connect", "netgear_poe", "hyperdeck", "spotify", "ready")
OK, WARN, ERR, OFF = "ok", "warn", "err", "off"
@@ -179,6 +179,7 @@ class Snapshot:
self.dante_map_time = 0.0 # last successful survey of the Dante switches
self.artnet_last = {} # source IP -> time its last Art-Net packet arrived
self.artnet_since = None # when the Art-Net listener started (None: not listening)
self.ready = None # readiness.py's latest look at this weekend's checklist
def var(self, label, name):
return self.vars.get((label, name))
@@ -537,13 +538,54 @@ def check_remote(chk, snap, ctx):
return [("remote", Result(OK))]
def check_ready(chk, snap, ctx):
"""One line of the weekly checklist on the READY page; readiness.py does the looking. Until the
checklist is due (Saturday noon on) every gap is amber; from then on a real gap is red."""
if snap.ready is None:
return [("ready", Result(OFF, info="CHECKING"))]
item = snap.ready.get("items", {}).get(chk["item"])
if item is None:
return [("ready", Result(OFF, info="NOT SET UP"))]
if not item.get("faults"):
return [("ready", Result(OK, info=item.get("info", "")))]
return [(msg, Result(ERR if level == ERR and ctx.get("ready_due") else WARN, msg))
for level, msg in item["faults"]]
def check_ready_manual(chk, snap, ctx):
"""A checklist line only a person can judge (BOH mix): a tap marks it done for the week."""
done = ctx.get("ready_done", {}).get(chk["item"])
if done:
return [("done", Result(OK, info="DONE " + datetime.fromtimestamp(done).strftime("%a %H:%M").upper()))]
info = ((snap.ready or {}).get("items", {}).get(chk["item"]) or {}).get("info") # e.g. the mics PCO needs
msg = chk.get("msg", "TAP WHEN DONE")
return [("done", Result(ERR if ctx.get("ready_due") else WARN, (info + "\n" + msg) if info else msg))]
# What readiness.py looks at (check type "ready") and what a "ready" fix can do.
READY_ITEMS = ("tracks", "mix_foh", "lights", "prop", "timecode", "backgrounds", "led_cross", "lineup", "mics",
"track_bundle", "prop_bundle")
READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_pco")
CHECKS = {
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "pref": check_pref, "spotify": check_spotify,
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
"ready": check_ready, "ready_manual": check_ready_manual,
}
def ready_keys(cfg):
"""The READY page's keys (tile-shaped: label, pos, checks)."""
return (cfg.get("ready_page") or {}).get("keys", [])
def all_tiles(cfg):
"""Every key the watchdog judges: the Status page's tiles and the READY page's keys. The READY
page's keys are not in ALL (nor in auto-fix): a weekday gap in the checklist is not an alarm."""
return cfg["tiles"] + ready_keys(cfg)
# Checks whose connection a long press may restart (Dante is shared, so not that one).
RESTARTABLE = ("conn", "hyperdeck", "shure", "psm", "tascam")
# A check's "fix" may name this instead of an entry in "fixes": restart its own Companion
@@ -558,7 +600,7 @@ DANTE_AUTO = "dante-auto"
def needed_variables(cfg):
"""(fast, slow) lists of (connection label, variable name) to poll."""
fast, slow = [], []
for tile in cfg["tiles"]:
for tile in all_tiles(cfg):
for chk in tile.get("checks", []):
t, label = chk["type"], chk.get("conn")
if t == "hyperdeck":
@@ -733,7 +775,7 @@ def check_identity(chk):
if t == "pref":
return "%s/%s" % (chk["domain"], chk["key"])
single = {"http": "url", "mdns": "instance", "launchd": "label", "process": "match", "artnet": "source",
"atem": "part", "disk": "path", "midi_rx": "source"}
"atem": "part", "disk": "path", "midi_rx": "source", "ready": "item", "ready_manual": "item"}
if t in single:
return str(chk.get(single[t], ""))
return chk.get("conn") or t
@@ -814,7 +856,10 @@ class ServiceMode:
class Engine:
def __init__(self, cfg, state_path=None, data_dir=None):
self.cfg = cfg
self.tiles = {t["id"]: t for t in cfg["tiles"]}
self.tiles = {t["id"]: t for t in all_tiles(cfg)}
# The READY page's checklist keys (a "ready" kind key sums them up, on either page).
self.ready_ids = [r["id"] for r in ready_keys(cfg) if r.get("kind") != "ready"]
self.ready_cfg = cfg.get("ready") or {}
self.debounce = cfg.get("debounce_seconds", 3)
self.flap_count = cfg.get("flap", {}).get("count", 3)
self.flap_window = cfg.get("flap", {}).get("window_seconds", 600)
@@ -864,6 +909,7 @@ class Engine:
self.auto_watch = {} # "BUTTON FAULT" auto-fix went after -> when (to log when it clears)
self.auto_paused = False
self.auto_on = False # inside the window (for the "auto-fix on" line)
self.auto_muted = False # ...with ALARMS muted, so only the "while_muted" fixes run
self.auto_last = 0.0
self.deck_ips = {} # HyperDeck connection -> last address Companion reported
self.now = 0.0
@@ -945,19 +991,20 @@ class Engine:
# raw results for every check key
def evaluate(self, snap, now, service_on):
ctx = {"now": now, "service": service_on, "any_recording": False, "dante_all": {}}
for t in self.cfg["tiles"]:
ctx = {"now": now, "service": service_on, "any_recording": False, "dante_all": {},
"ready_due": in_windows(self.ready_cfg.get("due", []), now), "ready_done": self.ready_done(now)}
for t in all_tiles(self.cfg):
for chk in t.get("checks", []):
if chk["type"] == "dante":
ctx["dante_all"].setdefault(chk["conn"], set()).update(chk["devices"])
for t in self.cfg["tiles"]:
for t in all_tiles(self.cfg):
for chk in t.get("checks", []):
if chk["type"] == "hyperdeck" and chk.get("rec_sync", True):
if conn_result(snap, chk["conn"], now).level == OK and hyperdeck_recording(snap, chk["conn"]):
ctx["any_recording"] = True
self.any_recording = ctx["any_recording"]
raw = {}
for t in self.cfg["tiles"]:
for t in all_tiles(self.cfg):
for i, chk in enumerate(t.get("checks", [])):
try:
results = CHECKS[chk["type"]](chk, snap, ctx)
@@ -1087,6 +1134,8 @@ class Engine:
tile = self.tiles.get(tile_id, {})
if tile.get("kind") == "master":
ids = self.detail_ids
elif tile.get("kind") == "ready":
ids = self.ready_ids
elif "members" in tile:
ids = tile["members"]
else:
@@ -1117,7 +1166,7 @@ class Engine:
Nothing is done for a healthy button, and a recording HyperDeck is never touched.
"""
tile = self.tiles.get(tile_id, {})
ids = tile.get("members") or [tile_id]
ids = self.ready_ids if tile.get("kind") == "ready" else (tile.get("members") or [tile_id])
plan, seen = [], set()
for key in sorted(self.meta):
tid, chk, sub = self.meta[key]
@@ -1212,6 +1261,31 @@ class Engine:
return True
return False
def ready_done(self, now):
"""{checklist item: when it was tapped done} for this week (the week starts on "reset_day")."""
import readiness
since = readiness.week_start(now, self.ready_cfg.get("reset_day", "mon"))
return {k: v for k, v in (self.state.get("ready_done") or {}).items() if v >= since}
def toggle_done(self, key_id, now):
"""A tap on a READY page key that only a person can check: done for the week, or (tapped
again) not done after all."""
chk = next((c for c in self.tiles.get(key_id, {}).get("checks", []) if c["type"] == "ready_manual"), None)
if chk is None:
log.warning("DONE %s: not a tap-to-confirm key", key_id)
return None
done = self.ready_done(now)
if chk["item"] in done:
del done[chk["item"]]
log.info("DONE %s: taken back", key_id)
self.note(key_id, "NOT DONE", "idle", now, 2)
else:
done[chk["item"]] = now
log.info("DONE %s: %s", key_id, self.tiles[key_id]["label"])
self.note(key_id, "DONE", "ok", now, 2)
self.state.set("ready_done", done)
return chk["item"] in done
def note(self, tile_id, text, level, now, seconds):
"""Show `text` on the button for a while (hold feedback), instead of its usual text."""
self.notes[tile_id] = {"text": text, "level": level, "until": now + seconds}
@@ -1345,7 +1419,11 @@ class Engine:
if not a or now - self.auto_last < 5 or self.stopping:
return []
self.auto_last = now
if not in_windows(a.get("windows", []), now) or not self.service.on(now):
# ALARMS muted (maintenance) stops auto-fix, except the harmless fixes in "while_muted"
# (playing the Spotify playlist): those keep running inside the window.
muted = not self.service.on(now)
keep = set(a.get("while_muted", [])) if muted else None
if not in_windows(a.get("windows", []), now) or (muted and not keep):
if self.auto_on and self.autolog:
self.autolog.write(now, "PANEL", "auto-fix off (%s)" % ("ALARMS muted" if in_windows(
a.get("windows", []), now) else "end of the window"))
@@ -1353,10 +1431,11 @@ class Engine:
self.auto_tries.clear()
self.auto_seen.clear()
return []
if not self.auto_on and self.autolog: # opens today's file, so it can be watched from the start
if (not self.auto_on or self.auto_muted != muted) and self.autolog: # opens today's file, so it can be watched from the start
ends = [w["end"] for w in a.get("windows", []) if in_windows([w], now)]
self.autolog.write(now, "PANEL", "auto-fix on%s" % (" until " + ends[0] if ends else ""))
self.auto_on = True
self.autolog.write(now, "PANEL", "auto-fix on%s%s" % (" until " + ends[0] if ends else "",
" (ALARMS muted: only %s)" % ", ".join(sorted(keep)) if muted else ""))
self.auto_on, self.auto_muted = True, muted
net_down = any(self.trackers.get("net:" + k) and self.trackers["net:" + k].confirmed
and self.trackers["net:" + k].result.level == ERR for k in ("link", "gateway"))
if net_down or (self.burst and (self.burst["cleared"] is None
@@ -1390,6 +1469,8 @@ class Engine:
for ident, kind, what, chk in self.fix_plan(t["id"], snap, now, a.get("after_seconds", 20), True):
if not ((kind == "fix" and what in gentle) or (kind == "restart" and a.get("restart_connections"))):
continue # not gentle: stays a hold
if keep is not None and not (kind == "fix" and what in keep):
continue # ALARMS muted: only the "while_muted" fixes
self.auto_seen[ident] = now
tries, last = self.auto_tries.get(ident, (0, 0.0))
if tries >= a.get("max_tries", 2):
@@ -1607,7 +1688,7 @@ def render(engine, service_on):
out[kid] = colored(key["label"] + "\nHOLD TO FIX", "err")
else:
out[kid] = colored(key["label"] + "\n" + engine.hold_text, "idle")
for tile in engine.cfg["tiles"]:
for tile in all_tiles(engine.cfg):
tid, kind = tile["id"], tile.get("kind")
note = engine.notes.get(tid)
if note and engine.now < note["until"]: # a hold is being acted on: say so
@@ -1631,6 +1712,21 @@ def render(engine, service_on):
text = ("%d DOWN" % n_bad if n_bad else "%d WARN" % len(faults)) + "\n" + first
out[tid] = colored(text, level)
continue
if kind == "ready":
# The checklist: amber while things are still to do, red once it is due and they are not.
items = [(lv, res, since, engine.tiles[m]) for m in engine.ready_ids
for lv, res, since in engine.items.get(m, [])]
level, faults = worst([(lv, res, since) for lv, res, since, _ in items])
if faults:
first = next(t for lv, r, s, t in items if (lv, r, s) == faults[0])
todo = len({t["id"] for lv, r, s, t in items if lv not in ("ok", "off")}) # keys, not faults
text = "%s\n%d TO DO\n%s" % (tile["label"], todo, first["label"]) # the key to look at first
elif level == "ok":
text = tile["label"] + "\nALL DONE"
else:
text = tile["label"] + "\nCHECKING"
out[tid] = colored(text, level)
continue
if "members" in tile:
items = [(lv, res, since, engine.tiles[m]) for m in tile["members"]
for lv, res, since in engine.items.get(m, [])]
@@ -1706,7 +1802,7 @@ def poll_pings(snap, hosts, interface):
def poll_system(snap, cfg):
checks = [c for t in cfg["tiles"] for c in t.get("checks", [])]
checks = [c for t in all_tiles(cfg) for c in t.get("checks", [])]
if any(c["type"] == "launchd" for c in checks):
out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout
agents = {}
@@ -1841,7 +1937,7 @@ def companion_conn_hosts(path):
def poll_conn_hosts(snap, cfg):
if any(c["type"] == "ping" and c.get("conn") for t in cfg["tiles"] for c in t.get("checks", [])):
if any(c["type"] == "ping" and c.get("conn") for t in all_tiles(cfg) for c in t.get("checks", [])):
try:
snap.conn_hosts = companion_conn_hosts(companion_db_path(cfg)) or snap.conn_hosts
except Exception as e: # Companion's schema is its own business: fall back to "host"
@@ -1880,7 +1976,7 @@ def spotify_playlist(cfg, chk=None):
def poll_spotify(snap, cfg):
checks = [c for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "spotify"]
checks = [c for t in all_tiles(cfg) for c in t.get("checks", []) if c["type"] == "spotify"]
if not checks:
return
def refresh():
@@ -1912,7 +2008,7 @@ def poll_midi(snap, cfg):
import coremidi
snap.midi, snap.midi_time = coremidi.sessions(), time.time()
remember_peers(snap, "", snap.midi)
for t in cfg["tiles"]:
for t in all_tiles(cfg):
for c in t.get("checks", []):
if c["type"] == "midi_rx":
lis = snap.listeners.get(c["source"])
@@ -1924,7 +2020,7 @@ def poll_midi(snap, cfg):
# --- Other machines: SSH checks and fixes ------------------------------------
def remote_midi_hosts(cfg):
return sorted({c["host"] for t in cfg["tiles"] for c in t.get("checks", [])
return sorted({c["host"] for t in all_tiles(cfg) for c in t.get("checks", [])
if c["type"] == "midi_peer" and c.get("host")})
@@ -2055,7 +2151,7 @@ def parse_remote(out, n):
def poll_remote(snap, cfg):
by_host = {}
for t in cfg["tiles"]:
for t in all_tiles(cfg):
for c in t.get("checks", []):
if c["type"] == "remote":
by_host.setdefault(c["host"], [])
@@ -2098,7 +2194,7 @@ def arp_table():
def dante_label(cfg):
for t in cfg["tiles"]:
for t in all_tiles(cfg):
for c in t.get("checks", []):
if c["type"] == "dante":
return c["conn"]
@@ -2221,6 +2317,10 @@ def execute_fix(cfg, fix_id, chk):
netgear.poe_cycle(fix["switch"], fix["port"], fix.get("mac"))
log.info("FIX %s -> switch %s port %s power-cycled", label, fix["switch"], fix["port"])
return True
if kind == "ready":
# The READY page: re-sync the lineup, put the cross back up, copy tracks out, or look again.
import readiness
return readiness.run_action(cfg, fix["action"], chk)
if kind == "hyperdeck":
# Turn Companion's connection to the deck off and on: after a deck restarts, that is what
# brings it back green (a plain reconnect does not, 2026-10-11). The deck is never rebooted.
@@ -2551,18 +2651,20 @@ def load_config(path):
check_host(cfg, fix.get("host"), "fix " + fid)
if kind in ("ssh", "local") and not fix.get("run"):
raise ValueError("fix %s: nothing to run" % fid)
if kind == "ready" and fix.get("action") not in READY_ACTIONS:
raise ValueError("fix %s: unknown READY action %s" % (fid, fix.get("action")))
check_windows(cfg.get("service_windows"), "service_windows")
check_windows((cfg.get("auto_fix") or {}).get("windows"), "auto_fix")
check_windows((cfg.get("site") or {}).get("live_services"), "site.live_services")
ids = set()
for t in cfg["tiles"]:
for t in all_tiles(cfg):
if t["id"] in ids:
raise ValueError("duplicate tile id %s" % t["id"])
ids.add(t["id"])
for c in t.get("checks", []):
if c["type"] not in CHECKS:
raise ValueError("tile %s: unknown check type %s" % (t["id"], c["type"]))
for t in cfg["tiles"]:
for t in all_tiles(cfg):
for m in t.get("members", []):
if m not in ids:
raise ValueError("tile %s: unknown member %s" % (t["id"], m))
@@ -2588,17 +2690,34 @@ def load_config(path):
for k in cfg.get("restart_page", {}).get("keys", []):
if k["id"] in ids:
raise ValueError("restart key %s has a tile's id" % k["id"])
if k["id"] in {r["id"] for r in ready_keys(cfg)}:
raise ValueError("restart key %s has a READY key's id" % k["id"])
if k["fix"] not in cfg.get("fixes", {}):
raise ValueError("restart key %s: unknown fix %s" % (k["id"], k["fix"]))
for w in k.get("watch", []):
if not w.startswith("conn:") and w not in cfg.get("fixes", {}):
raise ValueError("restart key %s: unknown fix %s in watch" % (k["id"], w))
rp = cfg.get("ready_page")
if rp:
check_windows((cfg.get("ready") or {}).get("due"), "ready.due")
if rp.get("page") in (cfg.get("page"), (cfg.get("restart_page") or {}).get("page")):
raise ValueError("ready_page: page %s is already the Status or Restart page" % rp.get("page"))
for rk in ready_keys(cfg):
if "pos" not in rk:
raise ValueError("READY key %s has no place on the READY page" % rk["id"])
for c in rk.get("checks", []):
if c["type"] in ("ready", "ready_manual") and not c.get("item"):
raise ValueError("READY key %s: a %s check needs an item" % (rk["id"], c["type"]))
if c["type"] == "ready" and c["item"] not in READY_ITEMS:
raise ValueError("READY key %s: unknown item %s" % (rk["id"], c["item"]))
if sum(1 for t in cfg["tiles"] if t.get("kind") == "ready") > 1:
raise ValueError("at most one READY key on the Status page")
return cfg
def ping_hosts(cfg, snap=None):
hosts = [ping_target(c, snap) if snap else c.get("host")
for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "ping"]
for t in all_tiles(cfg) for c in t.get("checks", []) if c["type"] == "ping"]
hosts = [h for h in hosts if h]
gw = cfg.get("network", {}).get("gateway")
return list(dict.fromkeys(hosts + ([gw] if gw else [])))
@@ -2607,7 +2726,7 @@ def ping_hosts(cfg, snap=None):
def http_checks(cfg):
"""[(url, interval)]: the Resi cloud every 30 s, a LAN app as often as its check asks."""
found = {}
for t in cfg["tiles"]:
for t in all_tiles(cfg):
for c in t.get("checks", []):
if c["type"] == "http":
found[c["url"]] = min(found.get(c["url"], 999), c.get("interval_seconds", 30))
@@ -2639,7 +2758,7 @@ def start_pollers(cfg, snap, stop, data_dir=None):
for t in threads:
t.start()
threads += start_remote_midi_streams(snap, cfg, stop)
if cfg.get("atem") and any(c["type"] == "atem" for t in cfg["tiles"] for c in t.get("checks", [])):
if cfg.get("atem") and any(c["type"] == "atem" for t in all_tiles(cfg) for c in t.get("checks", [])):
global ATEM_WATCHER
try:
ATEM_WATCHER = AtemWatcher(cfg, snap, data_dir, stop)
@@ -2648,7 +2767,7 @@ def start_pollers(cfg, snap, stop, data_dir=None):
threads.append(t)
except (OSError, ValueError) as e:
log.warning("ATEM checks off: %s", e)
if any(c["type"] == "artnet" for t in cfg["tiles"] for c in t.get("checks", [])):
if any(c["type"] == "artnet" for t in all_tiles(cfg) for c in t.get("checks", [])):
start_artnet_listener(snap, stop)
return threads
@@ -2690,6 +2809,7 @@ def snapshot_summary(snap, engine, now):
"burst": engine.burst and {"start": engine.burst["start"], "conns": sorted(engine.burst["conns"]),
"cleared": engine.burst["cleared"]},
"live": list(engine.live_now(now)),
"ready": snap.ready,
"faults": {},
}
for key, tr in engine.trackers.items():
@@ -2748,6 +2868,8 @@ def handle_commands(text, engine, comp, snap, now):
log.info("TEST %s: no time on it, or too long ago - ignored", arg)
else:
engine.run_test(arg, words[2], words[3], snap, comp, now)
elif verb == "done": # a tap on a READY page key a person checks
engine.toggle_done(arg, now)
elif verb == "resurvey": # the settings page: look at the Dante switches now
snap.dante_map_time = 0.0
log.info("RESURVEY: Dante map survey requested")
@@ -2775,6 +2897,11 @@ def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
except ValueError: # not the main thread (tests)
pass
threads = start_pollers(cfg, snap, stop, data_dir)
if cfg.get("ready_page"):
import readiness
poller = readiness.Poller(cfg, snap, stop, data_dir, is_live=lambda: engine.live_now(time.time())[0])
poller.start()
threads.append(poller)
if not dry_run:
try:
# A hold pressed while the watchdog was down must not fire on a config it was not meant for.
@@ -2880,6 +3007,9 @@ def run_once(cfg):
poll_midi(snap, cfg)
except Exception as e:
print("midi: %s" % e)
if cfg.get("ready_page"):
import readiness
snap.ready = readiness.look(cfg)
engine = Engine(cfg, None)
tiles = engine.step(snap, time.time(), debounce=0)
rows = {}
+506
View File
@@ -0,0 +1,506 @@
"""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"))
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, "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", playing=True):
return {"name": {"value": "Crosses"},
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
"layers": [{"clips": [{"name": {"value": "SS_StainedGlass005"}, "connected": {"value": "Disconnected"}},
{"name": {"value": "Stained Glass 11"}, "connected": {"value": "Connected" if playing else "Disconnected"}}]}]}
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_led_cross(self):
memory = {}
self.assertEqual(msgs(rd.eval_resolume(RC, composition(), memory)), [])
self.assertEqual(memory["led_clip"], "Stained Glass 11")
self.assertEqual(msgs(rd.eval_resolume(RC, composition("Test", False), {})), ["WRONG DECK", "NOTHING ON"])
def test_prop_bundle_this_week(self):
since = rd.week_start(WEDNESDAY)
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: [["Message Templates.probundle", WEDNESDAY]])
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"], []) # Resolume needs no plan
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", False)))
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": 24})])
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"], "24\\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_comes_back_with_the_clip_last_seen(self):
calls = []
real = rd.http_json
comp = composition("Test", False)
def fake(url, headers=None, method="GET", data=None, timeout=10):
calls.append((method, url.split("/api/v1/composition")[-1]))
return comp
rd.http_json = fake
rd.MEMORY["led_clip"] = "Stained Glass 11"
try:
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
finally:
rd.http_json = real
rd.MEMORY.clear()
self.assertIn(("POST", "/decks/1/select"), calls)
self.assertIn(("POST", "/layers/1/clips/2/connect"), calls)
class TestSpotifyWhileMuted(unittest.TestCase):
def test_spotify_still_plays_with_alarms_muted(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, ["spotify_play"])
cfg["auto_fix"]["while_muted"] = [] # without it: nothing while muted
engine2 = sp.Engine(cfg, None)
engine2.service.set(False, wed)
engine2.executor = engine.executor
for t in range(0, 40, 2):
engine2.step(snap, wed + t)
self.assertEqual(engine2.auto_fix(snap, None, wed + 40), [])
if __name__ == "__main__":
unittest.main()
+16 -10
View File
@@ -16,22 +16,24 @@ 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.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"}
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"}
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.
@@ -67,6 +69,8 @@ def collect(path):
return step(base, key)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in ("items", "values"):
return kind_of(node.func.value, local)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id 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 +145,7 @@ 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."}.get(kind)
return None if prefix is None else prefix + key
@@ -173,17 +177,18 @@ class TestSchemaMatchesCode(unittest.TestCase):
ui_only = {"site.id", "site.name", "site.ingest_hostname", "site.ingest_ips", "site.contacts", "site.editing_code",
"site.settings_port", "site.dvs_helper", "site.solo_dir", "hosts.*.name", "hosts.*.role",
"hosts.*.notes", "hosts.*.pingable", "hosts.*.runs", "hosts.*.networks", "hosts.*.manual_done",
"gear", "gear.*.location", "gear.*.notes", "atem.source_folder", "fixes.*.template", "fixes.*.params",
"gear", "gear.*.location", "gear.*.notes", "gear.*.kind", "gear.*.address", "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"}
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}
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 +235,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"})