diff --git a/README.md b/README.md index 852cf42..5d52e1a 100644 --- a/README.md +++ b/README.md @@ -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,70 @@ 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, stage) | LINEUP TV | | MICS (tap) | STAGE (tap) | | +| 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. + +**Tap keys** (BOH MIX, MICS, STAGE) are things only a person can see. Tap once when it is done; the key +shows `DONE THU 14:02`. Tap again to take it back. Everything resets on Monday at midnight. MICS also says which mics +this weekend needs, from Planning Center (`4 VOX MC TIM`). + +**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 +218,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 +291,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 | diff --git a/config.json b/config.json index 79b1c7f..1658f7e 100644 --- a/config.json +++ b/config.json @@ -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,342 @@ } ] }, + "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_mics", + "label": "MICS", + "pos": [ + 2, + 3 + ], + "checks": [ + { + "type": "ready_manual", + "item": "mics", + "msg": "TAP WHEN SET" + } + ] + }, + { + "id": "rd_stage", + "label": "STAGE", + "pos": [ + 2, + 4 + ], + "checks": [ + { + "type": "ready_manual", + "item": "stage", + "msg": "HELIX, RISERS: TAP" + } + ] + }, + { + "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", diff --git a/install.sh b/install.sh index 6a0a406..5c70d24 100644 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/make_page.py b/make_page.py index 7d72b13..032134b 100644 --- a/make_page.py +++ b/make_page.py @@ -15,8 +15,10 @@ Buttons write commands into sp_cmd, which status_panel.py reads: press ack (the master acks everything) hold 2 seconds restart (fixes what that tile is failing on) Restart page force (hold 2 s: restart that thing even when it looks fine) + READY page done (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__": diff --git a/push_page.py b/push_page.py index fa98b74..69ac6b5 100644 --- a/push_page.py +++ b/push_page.py @@ -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 diff --git a/readiness.py b/readiness.py new file mode 100644 index 0000000..d2b254c --- /dev/null +++ b/readiness.py @@ -0,0 +1,1030 @@ +#!/usr/bin/env python3 +"""The READY page: the weekly worship checklist, checked against Planning Center. + +Every couple of minutes this looks at this weekend's plan in Planning Center (PCO) and at +what is actually in place for it, and writes one verdict per checklist line into +snap.ready, which the READY page keys draw (check type "ready" in status_panel.py): + + tracks the weekend's Ableton set exists and has a song group for every PCO song + mix_foh every song is mixed (a song whose tracks are all at 0 dB is not) and its + stems go out to the console (Ext. Out), not to Master + lights the lighting cue track has a clip under every song + prop the ProPresenter playlist has every PCO item; lists in PCO item descriptions + match the slides (MC announcements: one slide each; baptisms: every name) + timecode every song's presentation has a timeline, and PCO's LTC note for it is the timecode + the tracks play at that song's start (worked out from the set's LTC clip and tempo map) + backgrounds every song's slides have a background (ProPresenter labels a slide with + its media's file name) + led_cross Resolume is on the Service deck and something is playing on the cross + lineup the Band Lineup board (ProdDash) shows this plan and exactly the people on it + mics (a tap-to-confirm key) which mics this weekend needs, from PCO + track_bundle every location has this weekend's Ableton project folder on the shared drive + prop_bundle a ProPresenter bundle was saved to the locations' folder this week + +What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync +the lineup from PCO, put the cross background back up, copy the tracks to a location that +has none, or simply look again now. + +Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read +from disk on the Tracks Mac (nice'd, and only when it changed), and the whole look is +skipped while a HyperDeck is recording. + +Stdlib only; runs on the macOS /usr/bin/python3 (3.9). +""" +import base64 +import calendar +import json +import logging +import os +import re +import subprocess +import threading +import time +import urllib.parse +import urllib.request +from datetime import datetime, timedelta + +log = logging.getLogger("status-panel") + +OK, WARN, ERR = "ok", "warn", "err" +DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +MEDIA = re.compile(r"\.(mp4|mov|m4v|mxf|mpg|mpeg|avi|webm|jpg|jpeg|png|gif|heic|tif|tiff)$", re.I) + +WAKE = threading.Event() # set by a hold: look again now instead of waiting for the interval +LAST = {} # the latest look (run_action needs the plan's date) +MEMORY = {} # what is worth remembering across looks: the cross's clip ("led_clip") +MEMORY_FILE = "ready-memory.json" + + +class ReadyError(Exception): + """A source could not be read; the message is what the key shows.""" + + +# --- Names ---------------------------------------------------------------------- + +def norm(text): + """A title as words to compare: "Can't Help But Praise LM - [ Full ]" -> "CANT HELP BUT PRAISE LM", + "3- CAN'T HELP BUT PRAISE - Eb" -> "CANT HELP BUT PRAISE", "Rejoice Again (Live)" -> "REJOICE AGAIN".""" + s = (text or "").upper().replace("’", "'") + s = re.sub(r"\[.*?\]|\(.*?\)", " ", s) + s = re.sub(r"^\s*\d+\s*-\s*", "", s) # a locator's running number + s = re.sub(r"\s*-\s*[A-G](#|B)?M?\s*$", "", s) # a locator's key: " - Bb", "BLOOD- B" + s = re.sub(r"[^A-Z0-9 ]", "", s) + return " ".join(s.split()) + + +def same(a, b): + """The same song or item: equal, or one is the other plus more words ("COVERED BY THE BLOOD TAG").""" + a, b = norm(a), norm(b) + if not a or not b: + return False + return a == b or a.startswith(b + " ") or b.startswith(a + " ") + + +def short(title, n=16): + t = norm(title) or (title or "").upper() + return t if len(t) <= n else t[:n - 1].rstrip() + "." + + +def bullets(text): + """The "- item" lines of a description.""" + return [m.strip() for m in re.findall(r"^\s*[-•*]\s*(.+?)\s*$", text or "", re.M) if m.strip()] + + +def listed_names(text): + """People named in a description like "730 930 None / 1130 Samantha Lopez, Bob Ray": the names, + without service times or "None".""" + names = [] + for line in (text or "").splitlines(): + line = re.sub(r"\b\d{3,4}\b", " ", line) # service times: 730, 1130 + for part in re.split(r",|&|/|\band\b", line): + words = [w for w in part.split() if w.lower() not in ("none", "n/a", "na", "tbd", "-")] + if words and words[0][:1].isalpha(): + names.append(" ".join(words)) + return names + + +# --- Time ------------------------------------------------------------------------- + +def parse_iso(text): + """PCO's "2026-10-18T13:30:00Z" as unix time (None if it is not one).""" + try: + return datetime.fromisoformat((text or "").replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def week_start(now, day="mon"): + """Local midnight at the start of the checklist's week (taps from before it no longer count).""" + d = datetime.fromtimestamp(now) + back = (d.weekday() - DAYS.index(day)) % 7 + start = datetime(d.year, d.month, d.day) - timedelta(days=back) + return time.mktime(start.timetuple()) + + +# --- HTTP and SSH ------------------------------------------------------------------ + +def http_json(url, headers=None, method="GET", data=None, timeout=10): + req = urllib.request.Request(url, headers=headers or {}, method=method, data=data) + with urllib.request.urlopen(req, timeout=timeout) as r: + body = r.read() + return json.loads(body) if body.strip() else None + + +def ssh_argv(cfg, host): + """The same login the watchdog's checks and fixes use (status_panel.ssh_cmd).""" + import status_panel + return status_panel.ssh_cmd(cfg, host) + + +def run_remote(cfg, host, script, timeout=60): + """Run a Python script on another Mac (on stdin, at low priority); its last line is JSON.""" + r = subprocess.run(ssh_argv(cfg, host) + ["nice -n 19 /usr/bin/python3 -"], input=script, + capture_output=True, text=True, timeout=timeout) + if r.returncode == 255: + raise ReadyError("%s UNREACHABLE" % host.upper()) + lines = [ln for ln in r.stdout.strip().splitlines() if ln.strip()] + if r.returncode != 0 or not lines: + raise ReadyError("%s SCRIPT FAILED" % host.upper()) if not lines else ReadyError(lines[-1][:40]) + return json.loads(lines[-1]) + + +# --- Planning Center ----------------------------------------------------------------- + +PCO = "https://api.planningcenteronline.com/services/v2" + + +def pco_login(rc): + """(Authorization header, service type) from ProdDash's own PCO settings on this Mac, so the + login lives in one place.""" + path = os.path.expanduser(rc.get("pco_credentials", "~/Library/Application Support/ProdDash/modules.json")) + try: + mods = json.load(open(path)) + except (OSError, ValueError): + raise ReadyError("NO PCO LOGIN") + c = (mods.get(rc.get("pco_module", "pco-plan")) or {}).get("config") or {} + if c.get("accessToken"): + auth = "Bearer " + c["accessToken"] + elif c.get("appId") and c.get("secret"): + auth = "Basic " + base64.b64encode(("%s:%s" % (c["appId"], c["secret"])).encode()).decode() + else: + raise ReadyError("NO PCO LOGIN") + st = rc.get("pco_service_type") or (c.get("serviceTypes") or [None])[0] or c.get("serviceTypeId") + if not st: + raise ReadyError("NO SERVICE TYPE") + return auth, str(st) + + +def pco_get(auth, path, pages=10): + """One PCO list (data, included), following "next" for at most `pages` pages (the plan lists + want one page: following them would walk the church's whole history).""" + url, data, included = PCO + path, [], [] + while url and pages > 0: + d = http_json(url, {"Authorization": auth}, timeout=15) + data += d.get("data") if isinstance(d.get("data"), list) else [d.get("data")] + included += d.get("included") or [] + url, pages = (d.get("links") or {}).get("next"), pages - 1 + return data, included + + +def pick_plan(plans, now): + """Today's plan, else the next one (from 12 hours ago on) - the plan ProdDash's lineup picks too.""" + dated = [] + for p in plans: + t = parse_iso((p.get("attributes") or {}).get("sort_date")) + if t is not None: + dated.append((t, p)) + today = datetime.fromtimestamp(now).date() + for t, p in dated: + if datetime.fromtimestamp(t).date() == today: + return p, t + upcoming = sorted(((t, p) for t, p in dated if t >= now - 12 * 3600), key=lambda tp: tp[0]) + return (upcoming[0][1], upcoming[0][0]) if upcoming else (None, None) + + +def pco_plan(rc, now): + """This weekend's plan: {"id", "title", "dates", "time", "items": [...], "team": [...]}.""" + auth, st = pco_login(rc) + try: + future, _ = pco_get(auth, "/service_types/%s/plans?filter=future&order=sort_date&per_page=4" % st, pages=1) + past, _ = pco_get(auth, "/service_types/%s/plans?filter=past&order=-sort_date&per_page=2" % st, pages=1) + except OSError as e: + raise ReadyError("PCO UNREACHABLE") from e + plan, t = pick_plan(future + past, now) + if plan is None: + raise ReadyError("NO PLAN IN PCO") + pid = plan["id"] + items, inc = pco_get(auth, "/service_types/%s/plans/%s/items?include=item_notes&per_page=100" % (st, pid)) + notes = {i["id"]: i.get("attributes") or {} for i in inc if i.get("type") == "ItemNote"} + out_items = [] + for it in items: + a = it.get("attributes") or {} + ns, note_ids = {}, {} + for ref in ((it.get("relationships") or {}).get("item_notes") or {}).get("data") or []: + n = notes.get(ref["id"]) + if n: + ns[n.get("category_name") or ""] = n.get("content") or "" + note_ids[n.get("category_name") or ""] = ref["id"] + out_items.append({"id": it.get("id"), "note_ids": note_ids, "title": a.get("title") or "", "type": a.get("item_type") or "", + "position": a.get("service_position") or "", "description": a.get("description") or "", + "notes": ns}) + members, inc = pco_get(auth, "/service_types/%s/plans/%s/team_members?include=team&per_page=100" % (st, pid)) + teams = {i["id"]: (i.get("attributes") or {}).get("name") for i in inc if i.get("type") == "Team"} + team = [] + for m in members: + a = m.get("attributes") or {} + tref = ((m.get("relationships") or {}).get("team") or {}).get("data") or {} + team.append({"name": a.get("name") or "", "position": a.get("team_position_name") or "", + "team": teams.get(tref.get("id"), ""), "status": a.get("status") or ""}) + a = plan.get("attributes") or {} + return {"id": pid, "service_type": st, "title": a.get("title") or "", "dates": a.get("dates") or "", "time": t, + "items": out_items, "team": team} + + +def plan_songs(rc, plan): + """The songs the band plays: PCO songs during the service, minus any the site skips.""" + skip = {norm(s) for s in rc.get("skip_songs", [])} + return [it for it in plan["items"] if it["type"] == "song" and it["position"] in ("during", "") + and norm(it["title"]) not in skip] + + +def declined(member): + return member["status"] in ("D", "Declined") + + +# --- ProPresenter ---------------------------------------------------------------------- + +def flatten_playlists(nodes, prefix=()): + """[(api path "2/3", name)] for every playlist in the /v1/playlists tree.""" + out = [] + for i, node in enumerate(nodes or []): + idx = (node.get("id") or {}).get("index", i) + path = prefix + (str(idx),) + if node.get("field_type") == "group": + out += flatten_playlists(node.get("children"), path) + else: + out.append(("/".join(path), (node.get("id") or {}).get("name") or "")) + return out + + +def prop_look(rc, plan): + """{"playlist": name, "items": [{"name", "type", "pres": {...} or None}]} for the plan's playlist.""" + base = rc.get("prop_url", "").rstrip("/") + if not base: + raise ReadyError("NO PROP ADDRESS") + try: + tree = http_json(base + "/v1/playlists", timeout=6) + except (OSError, ValueError) as e: + raise ReadyError("PROP UNREACHABLE") from e + dates = (plan.get("dates") or "").lower() + found = [(p, n) for p, n in flatten_playlists(tree) if dates and dates in n.lower()] + if not found: + return {"playlist": None, "items": []} + path, name = found[-1] + data = http_json(base + "/v1/playlist/" + "/".join(urllib.parse.quote(x) for x in path.split("/")), timeout=6) + items, cache = [], {} + for it in (data or {}).get("items") or []: + uuid = (it.get("presentation_info") or {}).get("presentation_uuid") + pres = None + if uuid: + if uuid not in cache: + try: + p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {} + slides = [s for g in p.get("groups") or [] for s in g.get("slides") or []] + cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), + "timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0, + "slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "", + "text": s.get("text") or "", "notes": s.get("notes") or ""} for s in slides]} + except (OSError, ValueError): + cache[uuid] = None + pres = cache[uuid] + items.append({"name": (it.get("id") or {}).get("name") or "", "type": it.get("type") or "", + "pco": bool(it.get("is_pco")), "pres": pres}) + return {"playlist": name, "items": items} + + +def match_prop(pco_items, prop_items): + """{index of a PCO item: its ProPresenter item}. By name; a PCO item the name does not find takes + the playlist item in the same place when the playlist came from PCO (ProPresenter names linked + items after the presentation: PCO "Message" is ProPresenter "Message Bumper").""" + want = [it for it in pco_items if it["type"] != "header"] + have = [it for it in prop_items if it["type"] != "header"] + found, used = {}, set() + for i, it in enumerate(want): + for j, p in enumerate(have): + if j not in used and same(it["title"], p["name"]): + found[i] = p + used.add(j) + break + if len(want) == len(have): + for i in range(len(want)): + if i not in found and i not in used and have[i]["pco"]: + found[i] = have[i] + used.add(i) + return want, found + + +def eval_prop(rc, plan, prop): + """The prop, timecode and backgrounds lines.""" + out = {"prop": Line(), "timecode": Line(), "backgrounds": Line()} + if prop["playlist"] is None: + for line in out.values(): + line.fault(ERR, "NO PLAYLIST %s" % short(plan.get("dates"), 14), "ProPresenter has no playlist named for %s" % plan.get("dates")) + return out + want, found = match_prop(plan["items"], prop["items"]) + songs = {id(s) for s in plan_songs(rc, plan)} + lists = rc.get("prop_lists", []) + for i, it in enumerate(want): + p = found.get(i) + if p is None: + out["prop"].fault(ERR, "MISSING " + short(it["title"]), "%s is in PCO but not in the ProPresenter playlist" % it["title"]) + continue + pres = p["pres"] + if pres is None: + continue + slides = [s for s in pres["slides"] if s["enabled"]] + for rule in lists: + if rule.get("match", "").lower() not in it["title"].lower(): + continue + tag = rule.get("short") or short(it["title"], 10) + if rule.get("kind") == "count": + want_n = bullets(it["description"]) + if want_n and len(slides) < len(want_n): + out["prop"].fault(ERR, "%s %d/%d SLIDES" % (tag, len(slides), len(want_n)), + "PCO lists %d for %s, ProPresenter has %d slides" % (len(want_n), it["title"], len(slides))) + noted = {norm(b) for s in slides for b in bullets(s["notes"])} + if want_n and noted and not {norm(b) for b in want_n} <= noted: + out["prop"].fault(WARN, "%s SLIDES OLD?" % tag, + "%s: the slide notes list %s; PCO lists %s" % ( + it["title"], ", ".join(sorted(noted)), ", ".join(want_n))) + elif rule.get("kind") == "names": + seen = " ".join((s["label"] + " " + s["text"]).lower() for s in slides) + for name in listed_names(it["description"]): + first = name.split()[0].lower() + if not re.search(r"\b%s\b" % re.escape(first), seen): + out["prop"].fault(ERR, "%s: NO %s" % (tag, short(name.split()[0], 10)), + "%s: PCO lists %s, no slide in %s names them" % (it["title"], name, pres["name"])) + if id(it) in songs: + if not pres["timeline"] or not pres["cues"]: + out["timecode"].fault(ERR, "NO TC " + short(it["title"]), "%s has no timeline in ProPresenter" % pres["name"]) + # A background plays on until the next one, so one is enough; none at all is the gap. + if not any(MEDIA.search(s["label"].strip()) for s in slides): + out["backgrounds"].fault(ERR, "NO BG " + short(it["title"]), "no slide of %s has a background" % ( + pres["name"] or p["name"])) + n_songs = len(songs) + out["prop"].info = "%d ITEMS" % len(want) + out["timecode"].info = "%d SONGS" % n_songs + out["backgrounds"].info = "%d SONGS" % n_songs + return out + + +# --- Ableton (read on the Tracks Mac) ------------------------------------------------------ + +ALS_SCRIPT = r''' +import glob, gzip, json, os, re, sys +import xml.etree.ElementTree as ET +P = json.loads(%r) +def live_set(): + logs = sorted(glob.glob(os.path.expanduser("~/Library/Preferences/Ableton/Live*/Log.txt")), key=os.path.getmtime) + path = None + if logs: + with open(logs[-1], "rb") as f: + text = f.read().decode("utf-8", "replace") + for m in re.finditer(r'info: Loading document "(.*)"', text): + if "/Templates/" not in m.group(1): + path = m.group(1) + return path +def folder(loc): + return os.path.join(os.path.expanduser(P["projects"]), loc, P["folder"].format(date=P["date"], loc=loc)) +def newest_als(d): + found = [os.path.join(d, n) for n in os.listdir(d) if n.endswith(".als") and not n.startswith(".")] if os.path.isdir(d) else [] + return max(found, key=os.path.getmtime) if found else None +def summary(path): + root = ET.parse(gzip.open(path)).getroot() + ls = root.find("LiveSet") + tracks = [] + for t in list(ls.find("Tracks")): + dc = t.find("DeviceChain") + g = t.find("TrackGroupId") + vol = dc.find("Mixer/Volume/Manual") if dc is not None else None + tgt = dc.find("AudioOutputRouting/Target") if dc is not None else None + up = dc.find("AudioOutputRouting/UpperDisplayString") if dc is not None else None + lo = dc.find("AudioOutputRouting/LowerDisplayString") if dc is not None else None + clips = [] + for c in t.findall(".//ArrangerAutomation/Events/AudioClip") + t.findall(".//ArrangerAutomation/Events/MidiClip"): + dis = c.find("Disabled") + if dis is not None and dis.get("Value") == "true": + continue + s, e = c.find("CurrentStart"), c.find("CurrentEnd") + notes = len(c.findall(".//MidiNoteEvent")) if c.tag == "MidiClip" else None + clips.append([float(s.get("Value")), float(e.get("Value")), notes]) + tracks.append({"id": t.get("Id"), "kind": t.tag, "name": t.find("Name/EffectiveName").get("Value"), + "group": g.get("Value") if g is not None else "-1", + "vol": float(vol.get("Value")) if vol is not None else None, + "ext": bool(tgt is not None and tgt.get("Value", "").startswith("AudioOut/External")), + "out": " ".join(x.get("Value") for x in (up, lo) if x is not None and x.get("Value")), + "clips": clips}) + locs = [{"name": l.find("Name").get("Value"), "time": float(l.find("Time").get("Value"))} + for l in ls.findall("Locators/Locators/Locator")] + mt = ls.find("MasterTrack") + tempo = mt.find("DeviceChain/Mixer/Tempo") + target = tempo.find("AutomationTarget").get("Id") + events = [] + for env in mt.findall("AutomationEnvelopes/Envelopes/AutomationEnvelope"): + if env.find("EnvelopeTarget/PointeeId").get("Value") == target: + events = [[float(e.get("Time")), float(e.get("Value"))] for e in env.findall("Automation/Events/FloatEvent")] + ltc = None + for t in list(ls.find("Tracks")): + if t.find("Name/EffectiveName").get("Value").strip().upper() == P["ltc_track"].strip().upper(): + for c in t.findall(".//ArrangerAutomation/Events/AudioClip"): + g = lambda p: c.find(p).get("Value") if c.find(p) is not None else None + ltc = {"start": float(g("CurrentStart")), "name": g("Name"), "warped": g("IsWarped") == "true", + "offset": float(g("Loop/LoopStart") or 0)} + break + return {"tracks": tracks, "locators": locs, "tempo": {"manual": float(tempo.find("Manual").get("Value")), "events": events}, + "ltc": ltc} +out = {"live_set": live_set(), "bundles": {}} +path = newest_als(folder(P["site"])) +out["set"] = path +if path: + st = os.stat(path) + key = [path, st.st_mtime, st.st_size, 2] # 2: the summary's layout + cache = os.path.expanduser("~/Library/Caches/status-panel-ready-als.json") + data = None + try: + c = json.load(open(cache)) + if c.get("key") == key: + data = c["data"] + except Exception: + pass + if data is None: + data = summary(path) + try: + with open(cache + ".tmp", "w") as f: + json.dump({"key": key, "data": data}, f) + os.replace(cache + ".tmp", cache) + except OSError: + pass + out["mtime"] = st.st_mtime + out["summary"] = data +for loc in P["locations"]: + d = folder(loc) + out["bundles"][loc] = {"folder": d, "als": bool(newest_als(d)), "copying": os.path.isdir(d + ".copying")} +print(json.dumps(out)) +''' + + +def ableton_params(rc, plan): + return {"projects": rc.get("projects", ""), "site": rc.get("site_folder", ""), "folder": rc.get("folder_format", "{date} {loc} Project"), + "date": datetime.fromtimestamp(plan["time"]).strftime("%y%m%d"), "locations": rc.get("locations", []), + "ltc_track": rc.get("ltc_track", "LTC")} + + +def ableton_look(cfg, rc, plan): + host = rc.get("tracks_host") + if not host or not rc.get("projects") or not rc.get("site_folder"): + raise ReadyError("NO TRACKS SETUP") + return run_remote(cfg, host, ALS_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=rc.get("remote_timeout", 90)) + + +def song_groups(summary): + """{top-level group track name: [its audio tracks, at any depth]}.""" + tracks = summary["tracks"] + by_id = {t["id"]: t for t in tracks} + + def top(t): + seen = set() + while t["group"] not in ("-1", None) and t["group"] in by_id and t["id"] not in seen: + seen.add(t["id"]) + t = by_id[t["group"]] + return t + groups = {t["name"]: [] for t in tracks if t["kind"] == "GroupTrack" and t["group"] in ("-1", None)} + for t in tracks: + if t["kind"] == "AudioTrack" and t["group"] not in ("-1", None): + g = top(t) + if g is not t and g["name"] in groups: + groups[g["name"]].append(t) + return groups + + +def eval_ableton(rc, plan, look): + out = {"tracks": Line(), "mix_foh": Line(), "lights": Line()} + date = ableton_params(rc, plan)["date"] + if not look.get("set"): + for name, line in out.items(): + line.fault(ERR, "NO SET %s" % date, "no %s in %s/%s" % ( + rc.get("folder_format", "{date} {loc} Project").format(date=date, loc=rc.get("site_folder")), + rc.get("projects"), rc.get("site_folder"))) + return out + summary = look["summary"] + groups = song_groups(summary) + lighting = next((t for t in summary["tracks"] if norm(t["name"]) == norm(rc.get("lighting_track", "LIGHTING CUES"))), None) + cues = [c for c in (lighting or {}).get("clips", []) if c[2]] if lighting else [] + songs = plan_songs(rc, plan) + unmixed = 0 + for it in songs: + gname = next((g for g in groups if same(it["title"], g)), None) + if gname is None: + out["tracks"].fault(ERR, "NO TRACKS " + short(it["title"]), "%s has no song group in %s" % ( + it["title"], os.path.basename(look["set"]))) + continue + audio = [t for t in groups[gname] if t["clips"]] + if not audio: + out["tracks"].fault(ERR, "EMPTY " + short(it["title"]), "the %s group has no audio clips" % gname) + continue + vols = [t["vol"] for t in audio if t["vol"] is not None] + if vols and all(abs(v - 1.0) < 1e-6 for v in vols): + unmixed += 1 + out["mix_foh"].fault(WARN, "NOT MIXED " + short(it["title"]), "every one of the %d tracks of %s is at 0 dB" % (len(vols), gname)) + stray = [t for t in audio if not t["ext"]] + if stray: + out["mix_foh"].fault(ERR, "TO MASTER " + short(it["title"]), "%s: %s go to %s, not out to the console" % ( + gname, ", ".join(t["name"] for t in stray[:4]), stray[0]["out"] or "Master")) + start = min(c[0] for t in audio for c in t["clips"]) + end = max(c[1] for t in audio for c in t["clips"]) + if lighting is None: + pass + elif not any(c[0] < end and c[1] > start for c in cues): + out["lights"].fault(ERR, "NO LIGHTS " + short(it["title"]), "no clip on %s between beats %g and %g (%s)" % ( + lighting["name"], start, end, gname)) + if lighting is None: + out["lights"].fault(ERR, "NO CUE TRACK", "no track called %s in the set" % rc.get("lighting_track", "LIGHTING CUES")) + live = look.get("live_set") + if live and os.path.basename(live) != os.path.basename(look["set"]): + out["tracks"].fault(WARN, "LIVE HAS OLD SET", "Ableton last opened %s, not %s" % (os.path.basename(live), os.path.basename(look["set"]))) + saved = datetime.fromtimestamp(look.get("mtime") or 0).strftime("%a %H:%M").upper() + out["tracks"].info = "%d SONGS" % len(songs) + out["mix_foh"].info = "SAVED " + saved + out["lights"].info = "%d CUES" % len(cues) + return out + + +def eval_track_bundle(rc, look): + line = Line() + for loc, b in sorted((look.get("bundles") or {}).items()): + if b.get("copying"): + line.fault(WARN, "COPYING TO " + loc, "the copy to %s is still running" % loc) + elif not b.get("als"): + line.fault(ERR, "NOT SENT " + loc, "no set in %s" % b.get("folder")) + line.info = " ".join(sorted(look.get("bundles") or {})) or "NO LOCATIONS" + return line + + +def seconds_at(tempo, beat): + """Seconds from the arrangement's start to `beat`, through the tempo automation (a step where two + points share a time, a ramp between two points).""" + pts = sorted(((t, v) for t, v in tempo.get("events") or [] if t >= 0), key=lambda p: p[0]) + if not pts: + return beat * 60.0 / tempo["manual"] + first = [v for t, v in tempo.get("events") or [] if t < 0] + pts = [(0.0, first[-1] if first else pts[0][1])] + pts + secs = 0.0 + for (t1, v1), (t2, v2) in zip(pts, pts[1:]): + if beat <= t1: + break + end = min(beat, t2) + if end > t1: + if abs(v2 - v1) < 1e-9: + secs += (end - t1) * 60.0 / v1 + else: + v_end = v1 + (v2 - v1) * (end - t1) / (t2 - t1) # a ramp: integrate 60/bpm + secs += 60.0 * (end - t1) / (v_end - v1) * __import__("math").log(v_end / v1) + last_t, last_v = pts[-1] + if beat > last_t: + secs += (beat - last_t) * 60.0 / last_v + return secs + + +def ltc_clip_info(name): + """(start seconds, fps) from an LTC file's name: "LTC_00000000_60mins_30fps_48000x24" -> (0, 30).""" + m = re.search(r"(\d{2})(\d{2})(\d{2})(\d{2})", name or "") + f = re.search(r"(\d+(?:\.\d+)?)\s*fps", name or "", re.I) + fps = float(f.group(1)) if f else 30.0 + start = (int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) + int(m.group(4)) / fps) if m else 0.0 + return start, fps + + +def timecode(seconds, fps): + frames = int(round(seconds * fps)) + whole = int(round(fps)) + return "%02d:%02d:%02d:%02d" % (frames // (whole * 3600), frames // (whole * 60) % 60, frames // whole % 60, frames % whole) + + +def song_ltc(rc, plan, look): + """[{item, title, start beat, tracks: "HH:MM:SS:FF", pco: what PCO's LTC note says}] for every PCO song the set has. + A song starts at its locator ("2 - REJOICE AGAIN - Bb"), else at its group's first clip.""" + summary = (look or {}).get("summary") or {} + clip = summary.get("ltc") + if not clip or not summary.get("tempo"): + return [] + file_start, fps = ltc_clip_info(clip["name"]) + clip_secs = seconds_at(summary["tempo"], clip["start"]) + groups = song_groups(summary) + note = rc.get("ltc_note", "LTC") + out = [] + for it in plan_songs(rc, plan): + loc = next((l for l in summary.get("locators") or [] if same(it["title"], l["name"])), None) + if loc is not None: + beat = loc["time"] + else: + gname = next((g for g in groups if same(it["title"], g)), None) + clips = [c for t in groups.get(gname, []) for c in t["clips"]] + if not clips: + continue + beat = min(c[0] for c in clips) + secs = seconds_at(summary["tempo"], beat) - clip_secs + clip.get("offset", 0) + file_start + out.append({"item": it, "title": it["title"], "beat": beat, "tracks": timecode(secs, fps), + "pco": (it["notes"].get(note) or "").strip()}) + return out + + +def eval_ltc(rc, plan, look, line): + """PCO's LTC note for every song must be the timecode the tracks play at that song's start.""" + for s in song_ltc(rc, plan, look): + if not s["pco"]: + line.fault(ERR, "NO LTC " + short(s["title"]), "%s: PCO has no %s note; the tracks start it at %s" % ( + s["title"], rc.get("ltc_note", "LTC"), s["tracks"])) + elif s["pco"] != s["tracks"]: + line.fault(ERR, "LTC WRONG " + short(s["title"]), "%s: PCO says %s, the tracks start it at %s" % ( + s["title"], s["pco"], s["tracks"])) + return line + + +COPY_SCRIPT = r''' +import json, os, shlex, subprocess +P = json.loads(%r) +def folder(loc): + return os.path.join(os.path.expanduser(P["projects"]), loc, P["folder"].format(date=P["date"], loc=loc)) +src = folder(P["site"]) +done = [] +for loc in P["locations"]: + dst = folder(loc) + if os.path.exists(dst) or os.path.isdir(dst + ".copying") or not os.path.isdir(src): + continue + old = os.path.basename(src) + ".als" + new = os.path.basename(dst) + ".als" + # Copied under a temporary name and renamed at the end, so a half-copied project never looks sent. + cmd = ("mkdir -p %%s && rsync -a --exclude Backup/ %%s/ %%s/ && if [ -f %%s ]; then mv %%s %%s; fi && " + "[ ! -e %%s ] && mv %%s %%s") %% tuple(map(shlex.quote, (dst + ".copying", src, dst + ".copying", + dst + ".copying/" + old, dst + ".copying/" + old, dst + ".copying/" + new, dst, dst + ".copying", dst))) + subprocess.Popen(["/usr/bin/nohup", "/bin/sh", "-c", cmd], stdout=open("/tmp/status-panel-copy-%%s.log" %% loc, "w"), + stderr=subprocess.STDOUT, start_new_session=True) + done.append(loc) +print(json.dumps({"started": done, "source": os.path.isdir(src)})) +''' + + +# --- The lineup TV (ProdDash's Band Lineup) ------------------------------------------------- + +def name_score(shown, full): + """How surely a name typed on the board is this PCO person: 3 the same, 2 "Alex Zinni" / "AlexG" / "DIEGO" + (first name, nickname, last initial), 0 not them.""" + b = " ".join(re.sub(r"[^a-z ]", "", (shown or "").lower()).split()) + f = " ".join(re.sub(r"[^a-z ]", "", (full or "").lower()).split()) + if not b or not f: + return 0 + if b == f: + return 3 + first, last = f.split()[0], f.split()[-1] + words = b.split() + alike = lambda w: w == first or (len(w) >= 3 and (first.startswith(w) or w.startswith(first))) + if len(words) == 1: + w = words[0] + if w == first + last or w == first + last[0]: + return 2 + return 1 if alike(w) else 0 + return 2 if alike(words[0]) and words[-1] in (last, last[0]) else 0 + + +def best_person(shown, people): + """The PCO person a board name means, or None (a bare first name shared by two people means nobody).""" + scored = sorted(((name_score(shown, m["name"]), m) for m in people), key=lambda sm: -sm[0]) + if not scored or scored[0][0] == 0: + return None + if len(scored) > 1 and scored[1][0] == scored[0][0] and scored[0][1]["name"] != scored[1][1]["name"]: + return None + return scored[0][1] + + +def eval_lineup(rc, plan, state): + line = Line() + st = (state or {}).get("state") or {} + shown = [s["person"]["name"] for s in st.get("slots") or [] if s.get("person") and s["person"].get("name")] + if str((st.get("plan") or {}).get("id")) != str(plan["id"]): + line.fault(ERR, "OLD PLAN", "the board shows %s, PCO's plan is %s" % ((st.get("plan") or {}).get("dates") or "nothing", plan["dates"])) + if st.get("lastError"): + line.fault(WARN, "SYNC ERROR", st["lastError"]) + on_plan = [m for m in plan["team"] if not declined(m)] + gone = [m for m in plan["team"] if declined(m)] + teams = {t.lower() for t in rc.get("lineup_teams", ["Band", "Communication"])} + skip = {p.lower() for p in rc.get("lineup_skip_positions", [])} + seen = set() + for name in shown: + m = best_person(name, on_plan) + if m is not None: + seen.add(m["name"]) + elif best_person(name, gone) is not None: + line.fault(ERR, "%s DECLINED" % short(name, 14), "%s is on the board but declined in PCO" % name) + else: + line.fault(ERR, "%s NOT ON PLAN" % short(name, 14), "%s is on the board but not on this plan" % name) + for m in on_plan: + if m["team"].lower() in teams and m["position"].lower() not in skip and m["name"] not in seen: + line.fault(ERR, "%s MISSING" % short(m["name"], 14), "%s (%s, %s) is on the plan but not on the board" % ( + m["name"], m["team"], m["position"])) + line.info = "%d NAMES" % len(shown) + return line + + +# --- Resolume (the LED cross) ------------------------------------------------------------------ + +def connected_clips(comp): + """[(layer number, clip number, name)] of every clip playing in the composition (1-based, as the API wants).""" + out = [] + for li, layer in enumerate(comp.get("layers") or []): + for ci, clip in enumerate(layer.get("clips") or []): + state = ((clip.get("connected") or {}).get("value") or "") + if state.startswith("Connected"): + out.append((li + 1, ci + 1, ((clip.get("name") or {}).get("value") or ""))) + return out + + +def eval_resolume(rc, comp, memory): + line = Line() + deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None) + want = rc.get("resolume_deck", "") + if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want): + line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s" % (((deck or {}).get("name") or {}).get("value"), want)) + playing = connected_clips(comp) + if not playing: + line.fault(ERR, "NOTHING ON", "no clip is playing in %s" % ((comp.get("name") or {}).get("value") or "the composition")) + else: + memory["led_clip"] = playing[0][2] + line.info = short(playing[0][2], 30) + return line + + +# --- Bundles, mics --------------------------------------------------------------------------- + +BUNDLE_SCRIPT = r''' +import glob, json, os +P = json.loads(%r) +files = glob.glob(os.path.join(os.path.expanduser(P["folder"]), P["pattern"])) +print(json.dumps(sorted([[os.path.basename(f), os.path.getmtime(f)] for f in files], key=lambda x: -x[1])[:5])) +''' + + +def eval_prop_bundle(rc, files, since): + line = Line() + fresh = [f for f in files if f[1] >= since] + if not fresh: + last = datetime.fromtimestamp(files[0][1]).strftime("%-m/%-d") if files else "NEVER" + line.fault(ERR, "NOT SENT (LAST %s)" % last, "no %s in %s saved since Monday" % (rc.get("prop_bundle_pattern"), rc.get("prop_bundle_folder"))) + else: + line.info = "SENT " + datetime.fromtimestamp(fresh[0][1]).strftime("%a %H:%M").upper() + return line + + +def mics_needed(rc, plan): + """What the mic table needs this weekend, from PCO: "4 VOX MC TIM".""" + vocal = {p.lower() for p in rc.get("vocal_positions", ["Vocalist", "[Leader]"])} + singers = {m["name"] for m in plan["team"] if not declined(m) and m["position"].lower() in vocal} + words = ["%d VOX" % len(singers)] if singers else [] + for position, mic in sorted((rc.get("mic_positions") or {}).items()): + if any(m["position"].lower() == position.lower() and not declined(m) for m in plan["team"]): + words.append(mic) + return " ".join(words) + + +# --- The whole look ------------------------------------------------------------------------------ + +class Line: + """One checklist line: its faults [(level, msg, detail)] and, when it is fine, what to show.""" + + def __init__(self): + self.faults, self.info = [], "" + + def fault(self, level, msg, detail=""): + if all(m != msg for _, m, _ in self.faults): + self.faults.append((level, msg, detail)) + + def as_dict(self): + return {"faults": [[lv, m] for lv, m, _ in self.faults], "details": [d for _, _, d in self.faults if d], + "info": self.info} + + +def load_memory(data_dir): + try: + return json.load(open(os.path.join(data_dir, MEMORY_FILE))) if data_dir else {} + except (OSError, ValueError): + return {} + + +def save_memory(data_dir, memory): + if not data_dir: + return + try: + tmp = os.path.join(data_dir, MEMORY_FILE + ".tmp") + with open(tmp, "w") as f: + json.dump(memory, f) + os.replace(tmp, os.path.join(data_dir, MEMORY_FILE)) + except OSError as e: + log.warning("%s: %s", MEMORY_FILE, e) + + +def look(cfg, now=None, data_dir=None, sources=None): + """Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}. + `sources` replaces the readers (tests).""" + rc = cfg.get("ready") or {} + now = now or time.time() + src = dict(pco=lambda: pco_plan(rc, now), prop=lambda plan: prop_look(rc, plan), + ableton=lambda plan: ableton_look(cfg, rc, plan), + lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6), + resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6), + prop_bundle=lambda: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps( + {"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30)) + src.update(sources or {}) + lines = {} + if not MEMORY: + MEMORY.update(load_memory(data_dir)) + + def failed(names, err): + msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED" + if not isinstance(err, ReadyError): + log.warning("READY %s: %s", "/".join(names), err) + for n in names: + lines.setdefault(n, Line()).fault(WARN, msg, "%s: %s" % (msg, err)) + + try: + plan = src["pco"]() + except Exception as e: # noqa: BLE001 - one source down must not hide the others + plan = None + failed([n for n, on in (("tracks", rc.get("tracks_host")), ("mix_foh", rc.get("tracks_host")), + ("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")), + ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), + ("lineup", rc.get("lineup_url")), ("mics", True), + ("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host"))) if on], e) + if plan is not None: + if rc.get("prop_url"): + try: + lines.update(eval_prop(rc, plan, src["prop"](plan))) + except Exception as e: # noqa: BLE001 + failed(["prop", "timecode", "backgrounds"], e) + if rc.get("tracks_host"): + try: + al = src["ableton"](plan) + lines.update(eval_ableton(rc, plan, al)) + if rc.get("ltc_note", "LTC"): + eval_ltc(rc, plan, al, lines.setdefault("timecode", Line())) + LAST["ltc"] = [dict(s, item={"id": s["item"]["id"], "note_ids": s["item"]["note_ids"]}) + for s in song_ltc(rc, plan, al)] + LAST["service_type"], LAST["plan_id"] = plan.get("service_type"), plan["id"] + if rc.get("locations"): + lines["track_bundle"] = eval_track_bundle(rc, al) + except Exception as e: # noqa: BLE001 + failed(["tracks", "mix_foh", "lights"] + (["track_bundle"] if rc.get("locations") else []), e) + if rc.get("lineup_url"): + try: + lines["lineup"] = eval_lineup(rc, plan, src["lineup"]()) + except Exception as e: # noqa: BLE001 + failed(["lineup"], e if isinstance(e, ReadyError) else ReadyError("LINEUP UNREACHABLE")) + mics = Line() + mics.info = mics_needed(rc, plan) + lines["mics"] = mics + if rc.get("prop_bundle_host"): + try: + lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon"))) + except Exception as e: # noqa: BLE001 + failed(["prop_bundle"], e) + if rc.get("resolume_url"): + try: + before = dict(MEMORY) + lines["led_cross"] = eval_resolume(rc, src["resolume"](), MEMORY) + if MEMORY != before: + save_memory(data_dir, MEMORY) + except Exception as e: # noqa: BLE001 + failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE")) + out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}} + if plan is not None: + out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]} + return out + + +class Poller(threading.Thread): + """Looks every "interval_seconds" (or at once after a hold), never while `is_live()` - a + recording HyperDeck means a service, and the Tracks and ProPresenter Macs are busy.""" + + def __init__(self, cfg, snap, stop, data_dir=None, is_live=lambda: False): + super().__init__(name="ready", daemon=True) + self.cfg, self.snap, self.stop, self.data_dir, self.is_live = cfg, snap, stop, data_dir, is_live + self.interval = (cfg.get("ready") or {}).get("interval_seconds", 120) + + def run(self): + forced = True + while not self.stop.is_set(): + if forced or not self.is_live(): + try: + result = look(self.cfg, data_dir=self.data_dir) + self.snap.ready = result + for k in ("time", "items", "plan"): + LAST.pop(k, None) + LAST.update(result) + except Exception: # noqa: BLE001 - the panel carries on without the READY page + log.exception("READY look failed") + WAKE.clear() + forced = False + deadline = time.time() + self.interval + while not self.stop.is_set() and time.time() < deadline: + if WAKE.wait(1.0): + forced = True + break + + +# --- Fixes (a hold on a READY key: fix kind "ready") ------------------------------------------------ + +def write_ltc(rc, songs, service_type, plan_id): + """Write the tracks' timecode into PCO's LTC note of every song where it is missing or different.""" + todo = [s for s in songs if s["pco"] != s["tracks"]] + if not todo: + return True + if not (service_type and plan_id): + return "NO PLAN YET" + auth, _ = pco_login(rc) + note = rc.get("ltc_note", "LTC") + base = "%s/service_types/%s/plans/%s/items" % (PCO, service_type, plan_id) + headers = {"Authorization": auth, "Content-Type": "application/json"} + category = None + for s in todo: + note_id = s["item"]["note_ids"].get(note) + if note_id: + body = {"data": {"type": "ItemNote", "id": note_id, "attributes": {"content": s["tracks"]}}} + http_json("%s/%s/item_notes/%s" % (base, s["item"]["id"], note_id), headers, "PATCH", + json.dumps(body).encode(), timeout=15) + else: + if category is None: + cats, _ = pco_get(auth, "/service_types/%s/item_note_categories" % service_type, pages=3) + category = next((c["id"] for c in cats if ((c.get("attributes") or {}).get("name") or "").lower() == note.lower()), "") + if not category: + return "NO %s IN PCO" % note.upper() + body = {"data": {"type": "ItemNote", "attributes": {"content": s["tracks"], "item_note_category_id": category}}} + http_json("%s/%s/item_notes" % (base, s["item"]["id"]), headers, "POST", json.dumps(body).encode(), timeout=15) + log.info("FIX PCO %s note for %s: %s -> %s", note, s["title"], s["pco"] or "(none)", s["tracks"]) + return True + + +def run_action(cfg, action, chk=None, sleep=time.sleep): + """One "ready" fix. True when it worked; a string says what to do instead.""" + rc = cfg.get("ready") or {} + try: + if action == "recheck": + return True + if action == "lineup_sync": + base = rc.get("lineup_url", "").rstrip("/") + before = ((http_json(base + "/state", timeout=6) or {}).get("state") or {}).get("lastFetched") + http_json(base + "/sync", method="POST", data=b"{}", headers={"Content-Type": "application/json"}, timeout=10) + for _ in range(30): + sleep(1) + st = (http_json(base + "/state", timeout=6) or {}).get("state") or {} + if st.get("lastFetched") != before: + log.info("FIX lineup synced from PCO%s", (": " + st["lastError"]) if st.get("lastError") else "") + return "SYNC ERROR" if st.get("lastError") else True + return "SYNC SLOW" + if action == "led_cross": + base = rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition" + comp = http_json(base, timeout=6) or {} + want = rc.get("resolume_deck", "") + for i, d in enumerate(comp.get("decks") or []): + if want and ((d.get("name") or {}).get("value") == want) and not (d.get("selected") or {}).get("value"): + http_json("%s/decks/%d/select" % (base, i + 1), method="POST", timeout=6) + log.info("FIX Resolume deck %s selected", want) + sleep(1) + comp = http_json(base, timeout=6) or {} + if connected_clips(comp): + return True + name = rc.get("led_clip") or MEMORY.get("led_clip") # set in config, else the last one seen playing + for li, layer in enumerate(comp.get("layers") or []): + for ci, clip in enumerate(layer.get("clips") or []): + if name and ((clip.get("name") or {}).get("value") or "") == name: + http_json("%s/layers/%d/clips/%d/connect" % (base, li + 1, ci + 1), method="POST", timeout=6) + log.info("FIX LED cross: %s playing again (layer %d clip %d)", name, li + 1, ci + 1) + return True + return "NO CLIP KNOWN" + if action == "ltc_to_pco": + return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id")) + if action == "copy_tracks": + plan = LAST.get("plan") + if not plan: + return "NO PLAN YET" + r = run_remote(cfg, rc["tracks_host"], COPY_SCRIPT % json.dumps(ableton_params(rc, plan)), timeout=60) + if not r.get("source"): + return "NO SET TO COPY" + log.info("FIX tracks copy started to %s", ", ".join(r["started"]) or "nowhere (all there)") + return True + log.warning("FIX ready: unknown action %r", action) + return False + except ReadyError as e: + return str(e) + except (OSError, ValueError) as e: + log.warning("FIX ready %s failed: %s", action, e) + return "PCO WRITE FAILED" if action == "ltc_to_pco" else False + finally: + WAKE.set() # look again now, so the key shows the result diff --git a/ready-page.companionconfig b/ready-page.companionconfig new file mode 100644 index 0000000..2decef7 Binary files /dev/null and b/ready-page.companionconfig differ diff --git a/schema.py b/schema.py index 5e819da..9e9c11c 100644 --- a/schema.py +++ b/schema.py @@ -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: