From 187dd464c0aa3e5fca872ca26989b9a2bc90eb32 Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 14 Oct 2026 14:17:31 -0400 Subject: [PATCH] Fix and check builders: every fix is built from fields; today's 27 fixes carry their templates settings/builders.py renders a fix's command from a builder (template) and its fields; validate refuses a config whose stored command no longer equals what its fields build; the Fixes screen and the remote checks in the key editor rebuild through /api/build and /api/build-check. config.json adopts templates for all 27 fixes and 5 remote checks (ProPresenter's restart loop and Ableton's quoting normalised, same behaviour); tests/test_builders.py (174 tests pass). Deployed to NA Ingest. Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +- config.json | 1554 +++++++++++++++++++++++++++++++----- schema.py | 9 + settings/builders.py | 430 ++++++++++ settings/server.py | 12 + settings/ui/app.js | 94 ++- tests/test_builders.py | 121 +++ tests/test_status_panel.py | 2 +- 8 files changed, 2014 insertions(+), 214 deletions(-) create mode 100644 settings/builders.py create mode 100644 tests/test_builders.py diff --git a/README.md b/README.md index bc72371..f0b5713 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,11 @@ A problem has to last 3 seconds before it shows, so one bad reading doesn't trig ## The settings page -`http://na-ingest.local:8020/` (the Ingest Mac, LaunchAgent `org.waterschurch.status-settings`, log `~/Library/Logs/status-settings.log`). Anyone on the church network can look: the live deck, every key and what holding it does, every computer and what runs on it (with a light each after "Check computers now"), the gear, the logs, the history, the help pages (What each key does, the cheat sheet, Where things are). Nothing changes without the editing code, which is `site.editing_code` in `config.json`. Step 1 is read-only; editing and Apply arrive with step 2 (see `docs/settings-plan.md`). +`http://na-ingest.local:8020/` (the Ingest Mac, LaunchAgent `org.waterschurch.status-settings`, log `~/Library/Logs/status-settings.log`). Anyone on the church network can look: the live deck, every key and what holding it does, every computer and what runs on it (with a light each after "Check computers now"), the gear, the logs, the history, the help pages (What each key does, the cheat sheet, Where things are). Nothing changes without the editing code, which is `site.editing_code` in `config.json` (top right: Editing, the code and your first name). + +With Editing on every screen grows inputs. Changes go into one shared draft on the Ingest Mac (two people editing see each other's version; a stale tab is told); the badge at the top counts them and opens **Review & Apply**, which reads every change back in plain words, says what will happen (the deck goes purple for about 5 s only when something the watchdog reads changed; pages re-import only when the layout changed) and runs the pipeline: check the config, keep the old version in `history/`, write, restart the watchdog and wait for its start stamp, re-import the pages (refused if any unmarked key sits on them), recolour the page-number keys. If the watchdog does not come back the previous version is put back by itself. Apply is refused while a HyperDeck is recording (the Emergency button needs a reason and the word LIVE, and is logged). History keeps every version with who/when/note; **Go back** and **Undo** make the chosen version the draft. + +Fixes and the remote checks are built from fields (`settings/builders.py`): a builder (Start a Windows program, Restart a Mac program, Restart a helper, Reopen Ableton with its last set, Restart DVS...) and its fields make the command; the stored command must equal what the fields build or the config is refused. Every Mac value is shell-quoted, every Windows value is regex-checked. A Custom command (Advanced) is the one place a command is typed by hand; Review shows commands in red. The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs. diff --git a/config.json b/config.json index ef91c2d..0078fd0 100644 --- a/config.json +++ b/config.json @@ -1,25 +1,52 @@ { "site": { - "id": "na", "name": "North Attleboro", "ingest_name": "NA Ingest", "ingest_hostname": "na-ingest.local", - "ingest_ips": ["10.1.1.1", "10.6.3.222"], + "id": "na", + "name": "North Attleboro", + "ingest_name": "NA Ingest", + "ingest_hostname": "na-ingest.local", + "ingest_ips": [ + "10.1.1.1", + "10.6.3.222" + ], "live_services": [], "editing_code": "stingo serried warp" }, - "companion": { "host": "127.0.0.1", "port": 8000 }, + "companion": { + "host": "127.0.0.1", + "port": 8000 + }, "page": 19, "page_name": "19\\nStatus", - "nav_keys": ["pagenum"], + "nav_keys": [ + "pagenum" + ], "nav_hold_ms": 1000, "nav_tap_page": 1, - "variables": { "state": "sp_state", "heartbeat": "sp_hb", "command": "sp_cmd" }, - + "variables": { + "state": "sp_state", + "heartbeat": "sp_hb", + "command": "sp_cmd" + }, "service_windows": [ - { "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "start": "00:00", "end": "23:59" } + { + "days": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ], + "start": "00:00", + "end": "23:59" + } ], - "debounce_seconds": 3, - "flap": { "count": 3, "window_seconds": 600 }, - + "flap": { + "count": 3, + "window_seconds": 600 + }, "network": { "interface": "en0", "gateway": "10.1.0.1", @@ -28,16 +55,23 @@ "burst_window_seconds": 10, "remember_seconds": 300 }, - "alert_webhook": "", - "timing": { "fix_cooldown_seconds": 60, "alert_after_seconds": 30 }, + "timing": { + "fix_cooldown_seconds": 60, + "alert_after_seconds": 30 + }, "page_hold_ms": 2000, - - "spotify": { "playlist": "spotify:playlist:2uTpJVYQLFnKF1WnSOuco8", "name": "Christian EDM 2026", "open_wait": 8, "play_wait": 3 }, - + "spotify": { + "playlist": "spotify:playlist:2uTpJVYQLFnKF1WnSOuco8", + "name": "Christian EDM 2026", + "open_wait": 8, + "play_wait": 3 + }, "ssh_key": "~/.ssh/id_status_panel", - - "dante_switches": ["10.66.1.100", "10.66.1.102"], + "dante_switches": [ + "10.66.1.100", + "10.66.1.102" + ], "hosts": { "ingest": { "self": true, @@ -389,204 +423,1326 @@ } } }, - "gear": {}, - "fixes": { - "propresenter": { "host": "prop", "short": "PROPRES", "label": "restart ProPresenter (newest installed)", - "run": "APP=$(ls -d /Applications/ProPresenter*.app | sort -V | tail -1); if pgrep -xq ProPresenter; then pkill -TERM -x ProPresenter; for i in 1 2 3 4 5 6 7 8 9 10; do pgrep -xq ProPresenter || break; sleep 1; done; pkill -KILL -x ProPresenter; sleep 1; fi; open \"$APP\"" }, - "ableton": { "host": "tracks", "short": "ABLETON", "label": "start Ableton if it is not running, with the set it last had open", - "run": "pgrep -xq Live || { L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi; }" }, - "translate": { "host": "translation", "short": "TRANSLATE", "label": "start Church Translate if it is not running", - "run": "pgrep -xq 'Church Translate' || open -a 'Church Translate'" }, - "dvs_prop": { "host": "prop", "short": "DVS", "label": "restart Dante Virtual Soundcard on the ProPresenter Mac (its engine restarts; audio to and from that Mac drops for 5-10 s)", "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard'" }, - "dvs_tracks": { "host": "tracks", "short": "DVS", "label": "restart Dante Virtual Soundcard on the Tracks Mac (its engine restarts; audio to and from that Mac drops for 5-10 s)", "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard'" }, - "dvs_translation": { "host": "translation", "short": "DVS", "label": "restart Dante Virtual Soundcard on NA Translation (its engine restarts; audio to and from that Mac drops for 5-10 s)", "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard'" }, - "dvs_ingest": { "kind": "local", "short": "DVS", "label": "restart Dante Virtual Soundcard on NA Ingest, then ProdCom (it keeps a dead DVS input otherwise)", "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard' || exit 1; sleep 5; pkill -TERM -x ProdCom; i=0; while pgrep -xq ProdCom && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProdCom && pkill -KILL -x ProdCom; sleep 1; open -g /Applications/ProdCom.app" }, - "bridge": { "kind": "local", "short": "BRIDGE", "label": "restart the Resi bridge", - "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.resi-propresenter || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.resi-propresenter.plist" }, - "captions": { "kind": "local", "short": "CAPTIONS", "label": "restart lobby captions (server and display)", - "run": "s=0; { launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions.plist; } || s=1; { launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist; } || s=1; exit $s" }, - "bibleapp": { "host": "bibletv", "short": "BIBLE APP", "label": "restart the Bible app on the Sanctuary TV PC (Windows: cmd.exe)", - "run": "taskkill /F /T /IM \"Bible App.exe\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\Start BibleApp\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Bible App.exe\" | find /I \"Bible App.exe\" >nul" }, - "obs": { "host": "livestream", "short": "OBS", "label": "start OBS on the LiveStream PC if it is not running (wakes the PC if it is off), then reconnect Companion", "reconnect": true, - "run": "tasklist /NH /FI \"IMAGENAME eq obs64.exe\" | find /I \"obs64.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start OBS\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq obs64.exe\" | find /I \"obs64.exe\" >nul)" }, - "arena": { "host": "lighting", "short": "ARENA", "label": "start Resolume Arena on the Lighting PC if it is not running, then reconnect Companion", "reconnect": true, - "run": "tasklist /NH /FI \"IMAGENAME eq Arena.exe\" | find /I \"Arena.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Arena\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Arena.exe\" | find /I \"Arena.exe\" >nul)" }, - "vista": { "host": "lighting", "short": "VISTA", "label": "start Vista on the Lighting PC if it is not running (a running Vista is never closed)", - "run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)" }, - "rtpmidi_start": { "host": "lighting", "short": "RTPMIDI", "label": "start rtpMIDI on the Lighting PC if it is not running (service, then tray app). A running rtpMIDI is never restarted: that drops every session.", - "run": "(sc query rtpMIDIService | find \"RUNNING\" >nul || net start rtpMIDIService) && (tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul || schtasks /Run /TN \"\\StatusPanel\\Start rtpMIDI\")" }, - "midi_reconnect": { "kind": "midi_connect", "short": "RECONNECT", "label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup); the address is where it was last seen, else the check's \"address\"" }, - "caption_screen": { "kind": "local", "short": "CAPTION SCREEN", "label": "restart the lobby caption screen (display app only)", "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist" }, - "solo_afl": { "host": "studiob", "short": "PT SOLO", "label": "set Pro Tools on the Studio B Mac Studio back to Solo Mode AFL + Latch (clicks Options > Solo Mode)", - "run": "touch \"$HOME/Library/Application Support/status-panel/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"" }, - "atem_audio": { "kind": "atem", "part": "audio", "short": "ATEM AUDIO", "label": "set the switcher's audio inputs back to the restore file (Resi on input 16 on, the rest as saved)" }, - "atem_camera": { "kind": "atem", "part": "camera", "short": "ATEM CAMERA", "label": "set the cameras' settings back to the restore file (only what differs)" }, - "atem_midi": { "kind": "local", "short": "ATEM MIDI", "restart_ok": true, - "label": "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)", - "run": "was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; [ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; [ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]" }, - "spotify_play": { "kind": "spotify", "short": "SPOTIFY", "label": "play the Christian EDM 2026 playlist in Spotify on NA Ingest (opens Spotify if it is closed)" }, - "proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)", - "run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" }, - "prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)", - "run": "pkill -TERM -x ProdCom; i=0; while pgrep -xq ProdCom && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProdCom && pkill -KILL -x ProdCom; sleep 1; open -g /Applications/ProdCom.app" }, - "ableton_restart": { "host": "tracks", "short": "ABLETON", "label": "restart Ableton on the Tracks Mac and reopen the set it had open (unsaved changes are lost; Live may ask to recover on the Tracks Mac)", "conn": "ableton", "reconnect": true, "timeout": 60, - "run": "L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); pkill -TERM -x 'Live'; i=0; while pgrep -xq 'Live' && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq 'Live' && pkill -KILL -x 'Live'; sleep 1; if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi" }, - "translate_restart": { "host": "translation", "short": "TRANSLATE", "label": "restart Church Translate on NA Translation (and its translation server)", "timeout": 60, - "run": "pg=$(ps -o pgid= -p \"$(pgrep -x 'Church Translate' | head -1)\" 2>/dev/null | tr -d ' '); pkill -TERM -x 'Church Translate'; i=0; while pgrep -xq 'Church Translate' && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq 'Church Translate' && pkill -KILL -x 'Church Translate'; [ -n \"$pg\" ] && [ \"$pg\" != \"$(ps -o pgid= -p $$ | tr -d ' ')\" ] && kill -KILL -- -\"$pg\" 2>/dev/null; sleep 1; open -a 'Church Translate'" }, - "tascam_reconnect": { "kind": "reconnect", "conn": "tascam", "short": "TASCAM", "verify_seconds": 20, "hung_note": "HUNG - POWER\nCYCLE IT", - "label": "reconnect Companion to the Tascam; if it still does not answer but pings, its control port has hung: power-cycle it at the rack" }, - "hyperdeck": { "kind": "hyperdeck", "short": "HYPERDECK", "verify_seconds": 15, "hung_note": "STILL OFFLINE\nHOLD AGAIN", - "label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck" } + "propresenter": { + "host": "prop", + "short": "PROPRES", + "label": "restart ProPresenter (newest installed)", + "run": "APP=$(ls -d /Applications/ProPresenter*.app | sort -V | tail -1); pkill -TERM -x ProPresenter; i=0; while pgrep -xq ProPresenter && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProPresenter && pkill -KILL -x ProPresenter; sleep 1; open \"$APP\"", + "template": "mac_restart", + "params": { + "host": "prop", + "process": "ProPresenter", + "app": "$APP", + "newest_of": "/Applications/ProPresenter*.app", + "wait_seconds": 10, + "force": true + }, + "kind": "ssh" + }, + "ableton": { + "host": "tracks", + "short": "ABLETON", + "label": "start Ableton if it is not running, with the set it last had open", + "run": "pgrep -xq Live || { L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi; }", + "template": "ableton", + "params": { + "host": "tracks", + "app": "Ableton Live 11 Suite", + "restart": false + }, + "kind": "ssh" + }, + "translate": { + "host": "translation", + "short": "TRANSLATE", + "label": "start Church Translate if it is not running", + "run": "pgrep -xq 'Church Translate' || open -a 'Church Translate'", + "template": "mac_start", + "params": { + "host": "translation", + "process": "Church Translate", + "app": "Church Translate" + }, + "kind": "ssh" + }, + "dvs_prop": { + "host": "prop", + "short": "DVS", + "label": "restart Dante Virtual Soundcard on the ProPresenter Mac (its engine restarts; audio to and from that Mac drops for 5-10 s)", + "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard'", + "template": "dvs", + "params": { + "host": "prop" + }, + "kind": "ssh" + }, + "dvs_tracks": { + "host": "tracks", + "short": "DVS", + "label": "restart Dante Virtual Soundcard on the Tracks Mac (its engine restarts; audio to and from that Mac drops for 5-10 s)", + "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard'", + "template": "dvs", + "params": { + "host": "tracks" + }, + "kind": "ssh" + }, + "dvs_translation": { + "host": "translation", + "short": "DVS", + "label": "restart Dante Virtual Soundcard on NA Translation (its engine restarts; audio to and from that Mac drops for 5-10 s)", + "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard'", + "template": "dvs", + "params": { + "host": "translation" + }, + "kind": "ssh" + }, + "dvs_ingest": { + "kind": "local", + "short": "DVS", + "label": "restart Dante Virtual Soundcard on NA Ingest, then ProdCom (it keeps a dead DVS input otherwise)", + "run": "sudo -n /usr/local/sbin/status-panel-dvs-start && open -g -a 'Dante Virtual Soundcard' || exit 1; sleep 5; pkill -TERM -x ProdCom; i=0; while pgrep -xq ProdCom && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProdCom && pkill -KILL -x ProdCom; sleep 1; open -g /Applications/ProdCom.app", + "template": "dvs", + "params": { + "host": "ingest", + "then_process": "ProdCom", + "then_app": "/Applications/ProdCom.app" + } + }, + "bridge": { + "kind": "local", + "short": "BRIDGE", + "label": "restart the Resi bridge", + "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.resi-propresenter || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.resi-propresenter.plist", + "template": "helpers", + "params": { + "labels": [ + "org.waterschurch.resi-propresenter" + ] + } + }, + "captions": { + "kind": "local", + "short": "CAPTIONS", + "label": "restart lobby captions (server and display)", + "run": "s=0; { launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions.plist; } || s=1; { launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist; } || s=1; exit $s", + "template": "helpers", + "params": { + "labels": [ + "org.waterschurch.lobby-captions", + "org.waterschurch.lobby-captions-display" + ] + } + }, + "bibleapp": { + "host": "bibletv", + "short": "BIBLE APP", + "label": "restart the Bible app on the Sanctuary TV PC (Windows: cmd.exe)", + "run": "taskkill /F /T /IM \"Bible App.exe\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\Start BibleApp\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Bible App.exe\" | find /I \"Bible App.exe\" >nul", + "template": "win_restart", + "params": { + "host": "bibletv", + "task": "Start BibleApp", + "exe": "Bible App.exe" + }, + "kind": "ssh" + }, + "obs": { + "host": "livestream", + "short": "OBS", + "label": "start OBS on the LiveStream PC if it is not running (wakes the PC if it is off), then reconnect Companion", + "reconnect": true, + "run": "tasklist /NH /FI \"IMAGENAME eq obs64.exe\" | find /I \"obs64.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start OBS\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq obs64.exe\" | find /I \"obs64.exe\" >nul)", + "template": "win_start", + "params": { + "host": "livestream", + "task": "Start OBS", + "exe": "obs64.exe" + }, + "kind": "ssh" + }, + "arena": { + "host": "lighting", + "short": "ARENA", + "label": "start Resolume Arena on the Lighting PC if it is not running, then reconnect Companion", + "reconnect": true, + "run": "tasklist /NH /FI \"IMAGENAME eq Arena.exe\" | find /I \"Arena.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Arena\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Arena.exe\" | find /I \"Arena.exe\" >nul)", + "template": "win_start", + "params": { + "host": "lighting", + "task": "Start Arena", + "exe": "Arena.exe" + }, + "kind": "ssh" + }, + "vista": { + "host": "lighting", + "short": "VISTA", + "label": "start Vista on the Lighting PC if it is not running (a running Vista is never closed)", + "run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)", + "template": "win_start", + "params": { + "host": "lighting", + "task": "Start Vista", + "exe": "Vista.exe" + }, + "kind": "ssh" + }, + "rtpmidi_start": { + "host": "lighting", + "short": "RTPMIDI", + "label": "start rtpMIDI on the Lighting PC if it is not running (service, then tray app). A running rtpMIDI is never restarted: that drops every session.", + "run": "(sc query rtpMIDIService | find \"RUNNING\" >nul || net start rtpMIDIService) && (tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul || schtasks /Run /TN \"\\StatusPanel\\Start rtpMIDI\")", + "template": "win_service", + "params": { + "host": "lighting", + "service": "rtpMIDIService", + "task": "Start rtpMIDI", + "exe": "rtpMIDI.exe" + }, + "kind": "ssh" + }, + "midi_reconnect": { + "kind": "midi_connect", + "short": "RECONNECT", + "label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup); the address is where it was last seen, else the check's \"address\"", + "template": "midi_connect", + "params": {} + }, + "caption_screen": { + "kind": "local", + "short": "CAPTION SCREEN", + "label": "restart the lobby caption screen (display app only)", + "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist", + "template": "helpers", + "params": { + "labels": [ + "org.waterschurch.lobby-captions-display" + ] + } + }, + "solo_afl": { + "host": "studiob", + "short": "PT SOLO", + "label": "set Pro Tools on the Studio B Mac Studio back to Solo Mode AFL + Latch (clicks Options > Solo Mode)", + "run": "touch \"$HOME/Library/Application Support/status-panel/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"", + "template": "solo_afl", + "params": { + "host": "studiob" + }, + "kind": "ssh" + }, + "atem_audio": { + "kind": "atem", + "part": "audio", + "short": "ATEM AUDIO", + "label": "set the switcher's audio inputs back to the restore file (Resi on input 16 on, the rest as saved)", + "template": "atem", + "params": {} + }, + "atem_camera": { + "kind": "atem", + "part": "camera", + "short": "ATEM CAMERA", + "label": "set the cameras' settings back to the restore file (only what differs)", + "template": "atem", + "params": {} + }, + "atem_midi": { + "kind": "local", + "short": "ATEM MIDI", + "restart_ok": true, + "label": "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)", + "run": "was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; [ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; [ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]", + "template": "atem_midi_off", + "params": {} + }, + "spotify_play": { + "kind": "spotify", + "short": "SPOTIFY", + "label": "play the Christian EDM 2026 playlist in Spotify on NA Ingest (opens Spotify if it is closed)", + "template": "spotify", + "params": {} + }, + "proddash": { + "kind": "local", + "short": "PRODDASH", + "label": "restart ProdDash (its launcher stops its server cleanly)", + "run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app", + "template": "mac_restart", + "params": { + "host": "ingest", + "process": "ProdDash", + "app": "/Applications/ProdDash.app", + "wait_seconds": 10, + "force": false, + "background": true + } + }, + "prodcom": { + "kind": "local", + "short": "PRODCOM", + "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)", + "run": "pkill -TERM -x ProdCom; i=0; while pgrep -xq ProdCom && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq ProdCom && pkill -KILL -x ProdCom; sleep 1; open -g /Applications/ProdCom.app", + "template": "mac_restart", + "params": { + "host": "ingest", + "process": "ProdCom", + "app": "/Applications/ProdCom.app", + "wait_seconds": 10, + "force": true, + "background": true + } + }, + "ableton_restart": { + "host": "tracks", + "short": "ABLETON", + "label": "restart Ableton on the Tracks Mac and reopen the set it had open (unsaved changes are lost; Live may ask to recover on the Tracks Mac)", + "conn": "ableton", + "reconnect": true, + "timeout": 60, + "run": "L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" | grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); pkill -TERM -x Live; i=0; while pgrep -xq Live && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq Live && pkill -KILL -x Live; sleep 1; if [ -n \"$S\" ] && [ -f \"$S\" ]; then open -a 'Ableton Live 11 Suite' \"$S\"; else open -a 'Ableton Live 11 Suite'; fi", + "template": "ableton", + "params": { + "host": "tracks", + "app": "Ableton Live 11 Suite", + "restart": true, + "wait_seconds": 15 + }, + "kind": "ssh" + }, + "translate_restart": { + "host": "translation", + "short": "TRANSLATE", + "label": "restart Church Translate on NA Translation (and its translation server)", + "timeout": 60, + "run": "pg=$(ps -o pgid= -p \"$(pgrep -x 'Church Translate' | head -1)\" 2>/dev/null | tr -d ' '); pkill -TERM -x 'Church Translate'; i=0; while pgrep -xq 'Church Translate' && [ $i -lt 30 ]; do sleep 0.5; i=$((i+1)); done; pgrep -xq 'Church Translate' && pkill -KILL -x 'Church Translate'; [ -n \"$pg\" ] && [ \"$pg\" != \"$(ps -o pgid= -p $$ | tr -d ' ')\" ] && kill -KILL -- -\"$pg\" 2>/dev/null; sleep 1; open -a 'Church Translate'", + "template": "mac_restart", + "params": { + "host": "translation", + "process": "Church Translate", + "app": "Church Translate", + "wait_seconds": 15, + "force": true, + "stop_children": true + }, + "kind": "ssh" + }, + "tascam_reconnect": { + "kind": "reconnect", + "conn": "tascam", + "short": "TASCAM", + "verify_seconds": 20, + "hung_note": "HUNG - POWER\nCYCLE IT", + "label": "reconnect Companion to the Tascam; if it still does not answer but pings, its control port has hung: power-cycle it at the rack", + "template": "reconnect", + "params": {} + }, + "hyperdeck": { + "kind": "hyperdeck", + "short": "HYPERDECK", + "verify_seconds": 15, + "hung_note": "STILL OFFLINE\nHOLD AGAIN", + "label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck", + "template": "hyperdeck", + "params": {} + } }, - "atem": { "host": "10.1.10.2", "node": "/Applications/Companion [0-9]*.app/Contents/Resources/node-runtimes/node22/bin/node", "library": "~/Library/Application Support/companion/modules/bmd-atem-*/node_modules/atem-connection", "reference": "atem-reference.xml" }, - "auto_fix": { - "windows": [{ "days": ["sun"], "start": "06:30", "end": "13:30" }], - "after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60, + "windows": [ + { + "days": [ + "sun" + ], + "start": "06:30", + "end": "13:30" + } + ], + "after_seconds": 20, + "max_tries": 2, + "retry_seconds": 90, + "pause_after_network_seconds": 60, "restart_connections": true, - "fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs", - "ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl", - "atem_audio", "atem_camera", "atem_midi", "spotify_play"] + "fixes": [ + "hyperdeck", + "tascam_reconnect", + "midi_reconnect", + "rtpmidi_start", + "vista", + "arena", + "obs", + "ableton", + "translate", + "dvs_prop", + "dvs_tracks", + "dvs_translation", + "solo_afl", + "atem_audio", + "atem_camera", + "atem_midi", + "spotify_play" + ] }, - "restart_page": { - "page": 20, "page_name": "20\\nRestart", "jump_pos": [2, 6], "back_pos": [2, 6], + "page": 20, + "page_name": "20\\nRestart", + "jump_pos": [ + 2, + 6 + ], + "back_pos": [ + 2, + 6 + ], "keys": [ - { "id": "rs_dvs_prop", "label": "DVS PROP", "pos": [0, 1], "fix": "dvs_prop" }, - { "id": "rs_dvs_tracks", "label": "DVS TRACKS", "pos": [0, 2], "fix": "dvs_tracks" }, - { "id": "rs_dvs_transl", "label": "DVS TRANSL", "pos": [0, 3], "fix": "dvs_translation" }, - { "id": "rs_dvs_ingest", "label": "DVS INGEST", "pos": [0, 4], "fix": "dvs_ingest" }, - { "id": "rs_propres", "label": "PROPRESENTER", "pos": [1, 1], "fix": "propresenter" }, - { "id": "rs_ableton", "label": "ABLETON", "pos": [1, 2], "fix": "ableton_restart", "watch": ["ableton", "conn:ableton"] }, - { "id": "rs_translate", "label": "TRANSLATE", "pos": [1, 3], "fix": "translate_restart", "watch": ["translate"] }, - { "id": "rs_spotify", "label": "SPOTIFY", "pos": [1, 4], "fix": "spotify_play" }, - { "id": "rs_prodcom", "label": "PRODCOM", "pos": [2, 1], "fix": "prodcom" }, - { "id": "rs_proddash", "label": "PRODDASH", "pos": [2, 2], "fix": "proddash" }, - { "id": "rs_captions", "label": "CAPTIONS", "pos": [2, 3], "fix": "captions", "watch": ["caption_screen"] }, - { "id": "rs_bridge", "label": "BRIDGE", "pos": [2, 4], "fix": "bridge" }, - { "id": "rs_tascam", "label": "TASCAM", "pos": [3, 1], "fix": "tascam_reconnect", "watch": ["conn:tascam"] } + { + "id": "rs_dvs_prop", + "label": "DVS PROP", + "pos": [ + 0, + 1 + ], + "fix": "dvs_prop" + }, + { + "id": "rs_dvs_tracks", + "label": "DVS TRACKS", + "pos": [ + 0, + 2 + ], + "fix": "dvs_tracks" + }, + { + "id": "rs_dvs_transl", + "label": "DVS TRANSL", + "pos": [ + 0, + 3 + ], + "fix": "dvs_translation" + }, + { + "id": "rs_dvs_ingest", + "label": "DVS INGEST", + "pos": [ + 0, + 4 + ], + "fix": "dvs_ingest" + }, + { + "id": "rs_propres", + "label": "PROPRESENTER", + "pos": [ + 1, + 1 + ], + "fix": "propresenter" + }, + { + "id": "rs_ableton", + "label": "ABLETON", + "pos": [ + 1, + 2 + ], + "fix": "ableton_restart", + "watch": [ + "ableton", + "conn:ableton" + ] + }, + { + "id": "rs_translate", + "label": "TRANSLATE", + "pos": [ + 1, + 3 + ], + "fix": "translate_restart", + "watch": [ + "translate" + ] + }, + { + "id": "rs_spotify", + "label": "SPOTIFY", + "pos": [ + 1, + 4 + ], + "fix": "spotify_play" + }, + { + "id": "rs_prodcom", + "label": "PRODCOM", + "pos": [ + 2, + 1 + ], + "fix": "prodcom" + }, + { + "id": "rs_proddash", + "label": "PRODDASH", + "pos": [ + 2, + 2 + ], + "fix": "proddash" + }, + { + "id": "rs_captions", + "label": "CAPTIONS", + "pos": [ + 2, + 3 + ], + "fix": "captions", + "watch": [ + "caption_screen" + ] + }, + { + "id": "rs_bridge", + "label": "BRIDGE", + "pos": [ + 2, + 4 + ], + "fix": "bridge" + }, + { + "id": "rs_tascam", + "label": "TASCAM", + "pos": [ + 3, + 1 + ], + "fix": "tascam_reconnect", + "watch": [ + "conn:tascam" + ] + } ] }, - "tiles": [ - { "id": "all", "label": "ALL", "pos": [0, 1], "kind": "master" }, - { "id": "rec", "label": "REC", "pos": [0, 2], "members": ["hd1", "hd2", "hd3", "hd4", "hd5", "hd6", "tascam"] }, - { "id": "audio", "label": "AUDIO", "pos": [0, 3], "members": ["x32", "dante"] }, - { "id": "rf", "label": "RF", "pos": [0, 4], "members": ["axient1", "axient2", "ulxd", "psm1", "psm2", "psm3"] }, - { "id": "show", "label": "SHOW", "pos": [0, 5], "members": ["midi", "apps", "vista", "spotify"] }, - { "id": "video", "label": "VIDEO", "pos": [0, 6], "members": ["atem", "led", "arena"] }, - { "id": "stream", "label": "STREAM", "pos": [0, 7], "members": ["obs", "resi"] }, - - { "id": "hd1", "label": "HD1", "pos": [1, 1], "hold_always": "hyperdeck", "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_1", "fix": { "conn": "hyperdeck" } }] }, - { "id": "hd2", "label": "HD2", "pos": [1, 2], "hold_always": "hyperdeck", "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_2", "optional": true, "fix": { "conn": "hyperdeck" } }] }, - { "id": "hd3", "label": "HD3", "pos": [1, 3], "hold_always": "hyperdeck", "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_3", "fix": { "conn": "hyperdeck" } }] }, - { "id": "hd4", "label": "HD4", "pos": [1, 4], "hold_always": "hyperdeck", "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_4", "optional": true, "fix": { "conn": "hyperdeck" } }] }, - { "id": "hd5", "label": "HD5", "pos": [1, 5], "hold_always": "hyperdeck", "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_5", "fix": { "conn": "hyperdeck" } }] }, - { "id": "hd6", "label": "HD6", "pos": [1, 6], "hold_always": "hyperdeck", "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_6", "fix": { "conn": "hyperdeck" } }] }, - { "id": "tascam", "label": "TASCAM", "pos": [1, 7], "hold_always": "tascam_reconnect", "checks": [ - { "type": "tascam", "conn": "tascam", "fix": "tascam_reconnect" }, - { "type": "ping", "conn": "tascam", "host": "10.1.11.74", "name": "TASCAM" } - ] }, - - { "id": "x32", "label": "X32", "pos": [2, 1], "checks": [{ "type": "conn", "conn": "x32" }] }, - { "id": "dante", "label": "DANTE", "pos": [2, 2], "checks": [ - { "type": "dante", "conn": "audinate-dantecontroller", - "fix": { "NA-ProPresenter-Mac": "dvs_prop", "NA-Tracks": "dvs_tracks", "NA-Translation": "dvs_translation", - "STUDIO-A-Ingest": "dvs_ingest", - "*": "dante-auto" }, - "devices": { - "FOH-DiGiCo-DMI-1": "DMI-1", "FOH-DiGiCo-DMI-2": "DMI-2", "BOH-WING": "WING", "BOH-DQ-Rack": "DQ RACK", - "BOH-AD4Q-A": "AD4Q-A", "BOH-AD4Q-B": "AD4Q-B", "STUDIO-A-X32": "STA X32", - "STUDIO-A-Resi-AJA-SDI": "RESI AJA", "STUDIO-A-Ingest": "INGEST", "NA-Translation": "TRANSL", - "STUDIO-B-RedNet-TNX": "STB TNX", "STUDIO-B-TB": "STB TB", "STUDIO-B-TB-MON": "STB TB MON", - "STUDIO-B-MON": "STB MON", "STUDIO-B-SOLO-MON": "STB SOLO", "STUDIO-B-Tascam": "STB TASCAM", - "NA-Tracks": "TRACKS", "FOH-SuperRack-LiveBox": "SUPERRACK", "NA-ProPresenter-Mac": "PROP MAC" } } - ] }, - { "id": "midi", "label": "MIDI", "pos": [2, 3], "checks": [ - { "type": "midi_peer", "session": "NA Ingest", "peer": "Lighting PC", "tag": "LIGHT PC", "fix": "midi_reconnect", "address": "10.1.10.206:5004" }, - { "type": "midi_peer", "session": "NA Ingest", "peer": "SuperRack-LiveBox", "tag": "SUPERRACK", "fix": "midi_reconnect", "address": "10.1.13.140:5004" }, - { "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": "midi_peer", "host": "prop", "session": "NA ProP", "peer": "Lighting PC", "tag": "PROP LIGHTS", "fix": "midi_reconnect", "address": "10.1.10.206:5004" }, - { "type": "conn", "conn": "lighting-midi", "tag": "COMPANION MIDI" }, - { "type": "mdns", "instance": "Lighting PC", "name": "LIGHT PC MIDI", "fix": "rtpmidi_start" }, - { "type": "mdns", "instance": "NA Tracks", "name": "TRACKS MIDI" }, - { "type": "midi_rx", "source": "Network NA Ingest Listen", "name": "TRACKS IN" } - ] }, - { "id": "spotify", "label": "SPOTIFY", "pos": [2, 7], "hold_always": "spotify_play", "checks": [ - { "type": "spotify", "fix": "spotify_play" } - ] }, - { "id": "vista", "label": "VISTA", "pos": [2, 5], "checks": [ - { "type": "artnet", "source": "10.1.10.206", "max_silence_seconds": 5, "fix": "vista" } - ] }, - { "id": "apps", "label": "APPS", "pos": [2, 4], "checks": [ - { "type": "conn", "conn": "Propresenter-API", "tag": "PROPRES", "fix": "propresenter", "ignore_warning": true }, - { "type": "remote", "host": "prop", "cmd": "pgrep -xq ProPresenter", "msg": "PROPRES NOT RUNNING", "fix": "propresenter" }, - { "type": "conn", "conn": "ableton", "tag": "ABLETON" }, - { "type": "remote", "host": "tracks", "cmd": "pgrep -xq Live", "msg": "ABLETON NOT RUNNING", "fix": "ableton" }, - { "type": "remote", "host": "translation", "cmd": "pgrep -xq 'Church Translate'", "msg": "TRANSLATE NOT RUNNING", "fix": "translate" }, - { "type": "ping", "host": "10.1.3.227", "name": "TRACKS MAC" }, - { "type": "http", "url": "http://10.1.14.225:1025/", "msg": "PROP TV DOWN", "interval_seconds": 5, "fix": "propresenter" }, - { "type": "http", "url": "http://10.6.7.13:8080/", "msg": "BIBLE APP DOWN", "interval_seconds": 5, "fix": "bibleapp" }, - { "type": "conn", "conn": "BibleTV", "tag": "BIBLE OSC" }, - { "type": "remote", "host": "studiob", "cmd": "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"", "msg": "PT SOLO NOT AFL", "fix": "solo_afl" }, - { "type": "remote", "host": "studiob", "cmd": "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\"", "msg": "PT SOLO CHECK OFF" } - ] }, - - { "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" }, - { "id": "atem", "label": "ATEM", "pos": [3, 1], "checks": [ - { "type": "conn", "conn": "ATEM" }, - { "type": "atem", "part": "audio", "msg": "ATEM AUDIO WRONG", "fix": "atem_audio" }, - { "type": "atem", "part": "camera", "msg": "ATEM CAMERA WRONG", "fix": "atem_camera" }, - { "type": "pref", "domain": "com.blackmagic-design.ATEM Software Control", "key": "midiEnabled", "want": "0", "msg": "ATEM SW MIDI ON", "fix": "atem_midi" } - ] }, - { "id": "obs", "label": "OBS", "pos": [3, 2], "checks": [{ "type": "conn", "conn": "OBS-Youtube", "service_only": true, "fix": "obs" }] }, - { "id": "resi", "label": "RESI", "pos": [3, 3], "checks": [ - { "type": "conn", "conn": "RESI_Decoder", "tag": "DECODER" }, - { "type": "ping", "host": "10.1.10.203", "name": "DECODER" }, - { "type": "ping", "host": "10.1.10.202", "name": "ENCODER" }, - { "type": "http", "url": "http://10.1.10.202/", "msg": "ENCODER WEB DOWN", "interval_seconds": 10 }, - { "type": "conn", "conn": "resi-studio-cues" }, - { "type": "http", "url": "https://central.resi.io/", "name": "CLOUD" } - ] }, - { "id": "net", "label": "NET", "pos": [3, 4], "kind": "network" }, - { "id": "ingest", "label": "INGEST", "pos": [3, 5], "checks": [ - { "type": "launchd", "label": "org.waterschurch.resi-propresenter", "name": "BRIDGE", "fix": "bridge" }, - { "type": "launchd", "label": "org.waterschurch.lobby-captions", "name": "CAPTIONS", "fix": "captions" }, - { "type": "launchd", "label": "org.waterschurch.lobby-captions-display", "name": "CAPTION SCREEN", "fix": "caption_screen" }, - { "type": "http", "url": "http://127.0.0.1:24480/api/v1/ping", "msg": "PRODCOM DOWN", "interval_seconds": 5, "fix": "prodcom" }, - { "type": "process", "match": "/Applications/ProdDash.app/Contents/MacOS/ProdDash", "name": "PRODDASH", "fix": "proddash" }, - { "type": "http", "url": "http://127.0.0.1:24500/", "msg": "PRODDASH DOWN", "interval_seconds": 10, "fix": "proddash" }, - { "type": "disk", "path": "/", "warn_gb": 25, "err_gb": 8 } - ] }, - { "id": "led", "label": "LED", "pos": [3, 6], "checks": [ - { "type": "conn", "conn": "VX1000", "tag": "VX1000" }, - { "type": "conn", "conn": "UHDJr", "tag": "UHDJR" }, - { "type": "ping", "host": "10.1.14.116", "name": "VX1000" }, - { "type": "ping", "host": "10.1.14.115", "name": "UHDJR" } - ] }, - { "id": "arena", "label": "ARENA", "pos": [3, 7], "checks": [{ "type": "conn", "conn": "arena", "fix": "arena" }] }, - - { "id": "axient1", "label": "AXIENT 1", "checks": [{ "type": "shure", "conn": "Axient_1", "channels": 4 }, - { "type": "ping", "conn": "Axient_1", "host": "10.1.1.101", "name": "AXIENT 1" }] }, - { "id": "axient2", "label": "AXIENT 2", "checks": [{ "type": "shure", "conn": "Axient_2", "channels": 4 }, - { "type": "ping", "conn": "Axient_2", "host": "10.1.1.102", "name": "AXIENT 2" }] }, - { "id": "ulxd", "label": "ULXD", "checks": [{ "type": "shure", "conn": "ULXD-4", "channels": 1 }, - { "type": "ping", "conn": "ULXD-4", "host": "10.1.1.106", "name": "ULXD" }] }, - { "id": "psm1", "label": "PSM 1", "checks": [{ "type": "psm", "conn": "PSM_1", "channels": 2 }, - { "type": "ping", "conn": "PSM_1", "host": "10.1.1.103", "name": "PSM 1" }] }, - { "id": "psm2", "label": "PSM 2", "checks": [{ "type": "psm", "conn": "PSM_2", "channels": 2 }, - { "type": "ping", "conn": "PSM_2", "host": "10.1.1.104", "name": "PSM 2" }] }, - { "id": "psm3", "label": "PSM 3", "checks": [{ "type": "psm", "conn": "PSM_3", "channels": 2 }, - { "type": "ping", "conn": "PSM_3", "host": "10.1.1.105", "name": "PSM 3" }] } + { + "id": "all", + "label": "ALL", + "pos": [ + 0, + 1 + ], + "kind": "master" + }, + { + "id": "rec", + "label": "REC", + "pos": [ + 0, + 2 + ], + "members": [ + "hd1", + "hd2", + "hd3", + "hd4", + "hd5", + "hd6", + "tascam" + ] + }, + { + "id": "audio", + "label": "AUDIO", + "pos": [ + 0, + 3 + ], + "members": [ + "x32", + "dante" + ] + }, + { + "id": "rf", + "label": "RF", + "pos": [ + 0, + 4 + ], + "members": [ + "axient1", + "axient2", + "ulxd", + "psm1", + "psm2", + "psm3" + ] + }, + { + "id": "show", + "label": "SHOW", + "pos": [ + 0, + 5 + ], + "members": [ + "midi", + "apps", + "vista", + "spotify" + ] + }, + { + "id": "video", + "label": "VIDEO", + "pos": [ + 0, + 6 + ], + "members": [ + "atem", + "led", + "arena" + ] + }, + { + "id": "stream", + "label": "STREAM", + "pos": [ + 0, + 7 + ], + "members": [ + "obs", + "resi" + ] + }, + { + "id": "hd1", + "label": "HD1", + "pos": [ + 1, + 1 + ], + "hold_always": "hyperdeck", + "checks": [ + { + "type": "hyperdeck", + "conn": "HyperDeck_1", + "fix": { + "conn": "hyperdeck" + } + } + ] + }, + { + "id": "hd2", + "label": "HD2", + "pos": [ + 1, + 2 + ], + "hold_always": "hyperdeck", + "checks": [ + { + "type": "hyperdeck", + "conn": "HyperDeck_2", + "optional": true, + "fix": { + "conn": "hyperdeck" + } + } + ] + }, + { + "id": "hd3", + "label": "HD3", + "pos": [ + 1, + 3 + ], + "hold_always": "hyperdeck", + "checks": [ + { + "type": "hyperdeck", + "conn": "HyperDeck_3", + "fix": { + "conn": "hyperdeck" + } + } + ] + }, + { + "id": "hd4", + "label": "HD4", + "pos": [ + 1, + 4 + ], + "hold_always": "hyperdeck", + "checks": [ + { + "type": "hyperdeck", + "conn": "HyperDeck_4", + "optional": true, + "fix": { + "conn": "hyperdeck" + } + } + ] + }, + { + "id": "hd5", + "label": "HD5", + "pos": [ + 1, + 5 + ], + "hold_always": "hyperdeck", + "checks": [ + { + "type": "hyperdeck", + "conn": "HyperDeck_5", + "fix": { + "conn": "hyperdeck" + } + } + ] + }, + { + "id": "hd6", + "label": "HD6", + "pos": [ + 1, + 6 + ], + "hold_always": "hyperdeck", + "checks": [ + { + "type": "hyperdeck", + "conn": "HyperDeck_6", + "fix": { + "conn": "hyperdeck" + } + } + ] + }, + { + "id": "tascam", + "label": "TASCAM", + "pos": [ + 1, + 7 + ], + "hold_always": "tascam_reconnect", + "checks": [ + { + "type": "tascam", + "conn": "tascam", + "fix": "tascam_reconnect" + }, + { + "type": "ping", + "conn": "tascam", + "host": "10.1.11.74", + "name": "TASCAM" + } + ] + }, + { + "id": "x32", + "label": "X32", + "pos": [ + 2, + 1 + ], + "checks": [ + { + "type": "conn", + "conn": "x32" + } + ] + }, + { + "id": "dante", + "label": "DANTE", + "pos": [ + 2, + 2 + ], + "checks": [ + { + "type": "dante", + "conn": "audinate-dantecontroller", + "fix": { + "NA-ProPresenter-Mac": "dvs_prop", + "NA-Tracks": "dvs_tracks", + "NA-Translation": "dvs_translation", + "STUDIO-A-Ingest": "dvs_ingest", + "*": "dante-auto" + }, + "devices": { + "FOH-DiGiCo-DMI-1": "DMI-1", + "FOH-DiGiCo-DMI-2": "DMI-2", + "BOH-WING": "WING", + "BOH-DQ-Rack": "DQ RACK", + "BOH-AD4Q-A": "AD4Q-A", + "BOH-AD4Q-B": "AD4Q-B", + "STUDIO-A-X32": "STA X32", + "STUDIO-A-Resi-AJA-SDI": "RESI AJA", + "STUDIO-A-Ingest": "INGEST", + "NA-Translation": "TRANSL", + "STUDIO-B-RedNet-TNX": "STB TNX", + "STUDIO-B-TB": "STB TB", + "STUDIO-B-TB-MON": "STB TB MON", + "STUDIO-B-MON": "STB MON", + "STUDIO-B-SOLO-MON": "STB SOLO", + "STUDIO-B-Tascam": "STB TASCAM", + "NA-Tracks": "TRACKS", + "FOH-SuperRack-LiveBox": "SUPERRACK", + "NA-ProPresenter-Mac": "PROP MAC" + } + } + ] + }, + { + "id": "midi", + "label": "MIDI", + "pos": [ + 2, + 3 + ], + "checks": [ + { + "type": "midi_peer", + "session": "NA Ingest", + "peer": "Lighting PC", + "tag": "LIGHT PC", + "fix": "midi_reconnect", + "address": "10.1.10.206:5004" + }, + { + "type": "midi_peer", + "session": "NA Ingest", + "peer": "SuperRack-LiveBox", + "tag": "SUPERRACK", + "fix": "midi_reconnect", + "address": "10.1.13.140:5004" + }, + { + "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": "midi_peer", + "host": "prop", + "session": "NA ProP", + "peer": "Lighting PC", + "tag": "PROP LIGHTS", + "fix": "midi_reconnect", + "address": "10.1.10.206:5004" + }, + { + "type": "conn", + "conn": "lighting-midi", + "tag": "COMPANION MIDI" + }, + { + "type": "mdns", + "instance": "Lighting PC", + "name": "LIGHT PC MIDI", + "fix": "rtpmidi_start" + }, + { + "type": "mdns", + "instance": "NA Tracks", + "name": "TRACKS MIDI" + }, + { + "type": "midi_rx", + "source": "Network NA Ingest Listen", + "name": "TRACKS IN" + } + ] + }, + { + "id": "spotify", + "label": "SPOTIFY", + "pos": [ + 2, + 7 + ], + "hold_always": "spotify_play", + "checks": [ + { + "type": "spotify", + "fix": "spotify_play" + } + ] + }, + { + "id": "vista", + "label": "VISTA", + "pos": [ + 2, + 5 + ], + "checks": [ + { + "type": "artnet", + "source": "10.1.10.206", + "max_silence_seconds": 5, + "fix": "vista" + } + ] + }, + { + "id": "apps", + "label": "APPS", + "pos": [ + 2, + 4 + ], + "checks": [ + { + "type": "conn", + "conn": "Propresenter-API", + "tag": "PROPRES", + "fix": "propresenter", + "ignore_warning": true + }, + { + "type": "remote", + "host": "prop", + "cmd": "pgrep -xq ProPresenter", + "msg": "PROPRES NOT RUNNING", + "fix": "propresenter", + "template": "mac_program", + "params": { + "host": "prop", + "process": "ProPresenter" + } + }, + { + "type": "conn", + "conn": "ableton", + "tag": "ABLETON" + }, + { + "type": "remote", + "host": "tracks", + "cmd": "pgrep -xq Live", + "msg": "ABLETON NOT RUNNING", + "fix": "ableton", + "template": "mac_program", + "params": { + "host": "tracks", + "process": "Live" + } + }, + { + "type": "remote", + "host": "translation", + "cmd": "pgrep -xq 'Church Translate'", + "msg": "TRANSLATE NOT RUNNING", + "fix": "translate", + "template": "mac_program", + "params": { + "host": "translation", + "process": "Church Translate" + } + }, + { + "type": "ping", + "host": "10.1.3.227", + "name": "TRACKS MAC" + }, + { + "type": "http", + "url": "http://10.1.14.225:1025/", + "msg": "PROP TV DOWN", + "interval_seconds": 5, + "fix": "propresenter" + }, + { + "type": "http", + "url": "http://10.6.7.13:8080/", + "msg": "BIBLE APP DOWN", + "interval_seconds": 5, + "fix": "bibleapp" + }, + { + "type": "conn", + "conn": "BibleTV", + "tag": "BIBLE OSC" + }, + { + "type": "remote", + "host": "studiob", + "cmd": "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"", + "msg": "PT SOLO NOT AFL", + "fix": "solo_afl", + "template": "helper_flag", + "params": { + "host": "studiob" + } + }, + { + "type": "remote", + "host": "studiob", + "cmd": "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\"", + "msg": "PT SOLO CHECK OFF", + "template": "helper_fresh", + "params": { + "host": "studiob" + } + } + ] + }, + { + "id": "service", + "label": "ALARMS", + "pos": [ + 3, + 0 + ], + "kind": "service" + }, + { + "id": "atem", + "label": "ATEM", + "pos": [ + 3, + 1 + ], + "checks": [ + { + "type": "conn", + "conn": "ATEM" + }, + { + "type": "atem", + "part": "audio", + "msg": "ATEM AUDIO WRONG", + "fix": "atem_audio" + }, + { + "type": "atem", + "part": "camera", + "msg": "ATEM CAMERA WRONG", + "fix": "atem_camera" + }, + { + "type": "pref", + "domain": "com.blackmagic-design.ATEM Software Control", + "key": "midiEnabled", + "want": "0", + "msg": "ATEM SW MIDI ON", + "fix": "atem_midi" + } + ] + }, + { + "id": "obs", + "label": "OBS", + "pos": [ + 3, + 2 + ], + "checks": [ + { + "type": "conn", + "conn": "OBS-Youtube", + "service_only": true, + "fix": "obs" + } + ] + }, + { + "id": "resi", + "label": "RESI", + "pos": [ + 3, + 3 + ], + "checks": [ + { + "type": "conn", + "conn": "RESI_Decoder", + "tag": "DECODER" + }, + { + "type": "ping", + "host": "10.1.10.203", + "name": "DECODER" + }, + { + "type": "ping", + "host": "10.1.10.202", + "name": "ENCODER" + }, + { + "type": "http", + "url": "http://10.1.10.202/", + "msg": "ENCODER WEB DOWN", + "interval_seconds": 10 + }, + { + "type": "conn", + "conn": "resi-studio-cues" + }, + { + "type": "http", + "url": "https://central.resi.io/", + "name": "CLOUD" + } + ] + }, + { + "id": "net", + "label": "NET", + "pos": [ + 3, + 4 + ], + "kind": "network" + }, + { + "id": "ingest", + "label": "INGEST", + "pos": [ + 3, + 5 + ], + "checks": [ + { + "type": "launchd", + "label": "org.waterschurch.resi-propresenter", + "name": "BRIDGE", + "fix": "bridge" + }, + { + "type": "launchd", + "label": "org.waterschurch.lobby-captions", + "name": "CAPTIONS", + "fix": "captions" + }, + { + "type": "launchd", + "label": "org.waterschurch.lobby-captions-display", + "name": "CAPTION SCREEN", + "fix": "caption_screen" + }, + { + "type": "http", + "url": "http://127.0.0.1:24480/api/v1/ping", + "msg": "PRODCOM DOWN", + "interval_seconds": 5, + "fix": "prodcom" + }, + { + "type": "process", + "match": "/Applications/ProdDash.app/Contents/MacOS/ProdDash", + "name": "PRODDASH", + "fix": "proddash" + }, + { + "type": "http", + "url": "http://127.0.0.1:24500/", + "msg": "PRODDASH DOWN", + "interval_seconds": 10, + "fix": "proddash" + }, + { + "type": "disk", + "path": "/", + "warn_gb": 25, + "err_gb": 8 + } + ] + }, + { + "id": "led", + "label": "LED", + "pos": [ + 3, + 6 + ], + "checks": [ + { + "type": "conn", + "conn": "VX1000", + "tag": "VX1000" + }, + { + "type": "conn", + "conn": "UHDJr", + "tag": "UHDJR" + }, + { + "type": "ping", + "host": "10.1.14.116", + "name": "VX1000" + }, + { + "type": "ping", + "host": "10.1.14.115", + "name": "UHDJR" + } + ] + }, + { + "id": "arena", + "label": "ARENA", + "pos": [ + 3, + 7 + ], + "checks": [ + { + "type": "conn", + "conn": "arena", + "fix": "arena" + } + ] + }, + { + "id": "axient1", + "label": "AXIENT 1", + "checks": [ + { + "type": "shure", + "conn": "Axient_1", + "channels": 4 + }, + { + "type": "ping", + "conn": "Axient_1", + "host": "10.1.1.101", + "name": "AXIENT 1" + } + ] + }, + { + "id": "axient2", + "label": "AXIENT 2", + "checks": [ + { + "type": "shure", + "conn": "Axient_2", + "channels": 4 + }, + { + "type": "ping", + "conn": "Axient_2", + "host": "10.1.1.102", + "name": "AXIENT 2" + } + ] + }, + { + "id": "ulxd", + "label": "ULXD", + "checks": [ + { + "type": "shure", + "conn": "ULXD-4", + "channels": 1 + }, + { + "type": "ping", + "conn": "ULXD-4", + "host": "10.1.1.106", + "name": "ULXD" + } + ] + }, + { + "id": "psm1", + "label": "PSM 1", + "checks": [ + { + "type": "psm", + "conn": "PSM_1", + "channels": 2 + }, + { + "type": "ping", + "conn": "PSM_1", + "host": "10.1.1.103", + "name": "PSM 1" + } + ] + }, + { + "id": "psm2", + "label": "PSM 2", + "checks": [ + { + "type": "psm", + "conn": "PSM_2", + "channels": 2 + }, + { + "type": "ping", + "conn": "PSM_2", + "host": "10.1.1.104", + "name": "PSM 2" + } + ] + }, + { + "id": "psm3", + "label": "PSM 3", + "checks": [ + { + "type": "psm", + "conn": "PSM_3", + "channels": 2 + }, + { + "type": "ping", + "conn": "PSM_3", + "host": "10.1.1.105", + "name": "PSM 3" + } + ] + } ] } diff --git a/schema.py b/schema.py index aacb266..5e819da 100644 --- a/schema.py +++ b/schema.py @@ -200,6 +200,10 @@ field("fixes.*.never_auto", "bool", False, "Never runs by itself.", "Fixes") field("fixes.*.part", "enum", None, "Which part of the switcher.", "Fixes", options=["audio", "camera"]) field("fixes.*.verify_seconds", "int", 20, "Wait this long before judging a reconnect.", "Fixes", advanced=True, lo=5, hi=120) +field("fixes.*.template", "str", None, "How it is built (the builder); custom = typed by hand under Advanced.", "Fixes", apply="none") +field("fixes.*.params", "dict", None, "The builder's fields; the command is made from them.", "Fixes", apply="none") +field("tiles[].checks[].template", "str", None, "How the check's command is built.", "Keys", apply="none") +field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", apply="none") field("fixes.*.off_seconds", "int", 2, "How long a HyperDeck's connection stays off.", "Fixes", advanced=True, lo=1, hi=10) field("fixes.*.hung_note", "str", None, "What the key says when the device answers ping but not Companion.", @@ -615,6 +619,11 @@ def validate(cfg, live=None): errors.append("%s: %r is not in the right form" % (p, v)) if f["type"] == "enum" and f["options"] and v not in f["options"]: errors.append("%s: %r is not one of %s" % (p, v, ", ".join(f["options"]))) + try: + from settings import builders + errors += builders.mismatches(cfg) + except ImportError: + pass return errors, warnings diff --git a/settings/builders.py b/settings/builders.py new file mode 100644 index 0000000..62edf9b --- /dev/null +++ b/settings/builders.py @@ -0,0 +1,430 @@ +"""Fix and check builders: a fix is built from fields, never typed as a command. + +A fix with a `template` and `params` carries its generated `run` (and kind/host) beside them; the +watchdog runs only `run`. Every Mac value is shlex-quoted, every Windows value regex-checked. +`build(cfg, fix)` regenerates; `mismatches(cfg)` lists fixes and checks whose stored command no +longer equals what their fields build (validate refuses those). `adopt(cfg)` gives today's fixes +their templates (a one-time migration; it prints what it normalised).""" +import re +import shlex + +WIN_SAFE = re.compile(r"^[A-Za-z0-9 ._()\\:*-]+$") +LABEL_SAFE = re.compile(r"^[A-Za-z0-9._-]+$") + + +def q(value): + return shlex.quote(str(value)) + + +def win(value, what): + value = str(value) + if not WIN_SAFE.match(value): + raise ValueError("%s: on a PC only letters, digits, space and . _ ( ) \\ : * - are allowed, not %r" % (what, value)) + return value + + +def label(value, what): + value = str(value) + if not LABEL_SAFE.match(value): + raise ValueError("%s: a launchd label holds letters, digits, dots, - and _ only, not %r" % (what, value)) + return value + + +def home_dir(path): + """~/x -> $HOME/x inside double quotes; refuses quotes and $ in the rest.""" + path = str(path) + if re.search(r'["$`\\]', path.replace("$HOME", "", 1)): + raise ValueError("folder %r may not hold quotes, $ or backslashes" % path) + if path.startswith("~/"): + path = "$HOME/" + path[2:] + return path.rstrip("/") + + +def mac_quit(process, wait_seconds, force, stop_children=False): + """Quit a Mac program gently, give it wait_seconds, then (force) kill it.""" + p = q(process) + ticks = max(1, int(round(float(wait_seconds) * 2))) + s = "" + if stop_children: + s += "pg=$(ps -o pgid= -p \"$(pgrep -x %s | head -1)\" 2>/dev/null | tr -d ' '); " % p + s += "pkill -TERM -x %s; i=0; while pgrep -xq %s && [ $i -lt %d ]; do sleep 0.5; i=$((i+1)); done; " % (p, p, ticks) + if force: + s += "pgrep -xq %s && pkill -KILL -x %s; " % (p, p) + if stop_children: + s += "[ -n \"$pg\" ] && [ \"$pg\" != \"$(ps -o pgid= -p $$ | tr -d ' ')\" ] && kill -KILL -- -\"$pg\" 2>/dev/null; " + if force or stop_children: + s += "sleep 1; " + return s + + +def mac_open(app, background=False, arg=None): + """open -a Name / open /path/App.app (+ -g to stay in the background).""" + app = str(app) + cmd = "open -g" if background else "open" + if app == "$APP": + cmd += " \"$APP\"" + elif app.startswith("/"): + cmd += " " + q(app) + else: + cmd += " -a " + q(app) + if arg: + cmd += " " + arg + return cmd + + +ABLETON_SET = ("L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" " + "| grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); ") + + +def ableton_open(app): + return "if [ -n \"$S\" ] && [ -f \"$S\" ]; then %s; else %s; fi" % (mac_open(app, arg="\"$S\""), mac_open(app)) + + +def helper_restart(labels): + one = "launchctl kickstart -k gui/$(id -u)/%s || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/%s.plist" + labels = [label(x, "helper") for x in labels] + if not labels: + raise ValueError("at least one helper (launchd label) is needed") + if len(labels) == 1: + return one % (labels[0], labels[0]) + return "s=0; " + " ".join("{ %s; } || s=1;" % (one % (x, x)) for x in labels) + " exit $s" + + +def win_running(exe): + return "tasklist /NH /FI \"IMAGENAME eq %s\" | find /I \"%s\" >nul" % (exe, exe) + + +# --- the templates --------------------------------------------------------------- +# params: (name, words, help, kind, default) - kind: str, host, bool, int, list, app +P_HOST = ("host", "Computer", "Which computer it runs on.", "host", None) +TEMPLATES = { + "mac_start": { + "words": "Start a Mac program if it is not running", + "os": "mac", "start_only": True, + "params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None), + ("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)], + "render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])), + "suggest": lambda p, cfg: "start %s if it is not running" % p["app"], + }, + "mac_restart": { + "words": "Restart a Mac program (quit it, then open it again)", + "os": "mac", "closes": True, + "params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None), + ("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None), + ("wait_seconds", "Wait for it to quit", "Seconds before it is killed.", "int", 10), + ("force", "Kill it if it will not quit", "", "bool", True), + ("background", "Open it in the background", "No window comes to the front.", "bool", False), + ("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False), + ("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")], + "render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "") + + mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False)) + + mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)), + "suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]), + }, + "ableton": { + "words": "Open Ableton Live with the set it last had open", + "os": "mac", + "params": [P_HOST, ("app", "Ableton app name", "As in /Applications.", "str", "Ableton Live 11 Suite"), + ("restart", "Quit it first if it is running", "Otherwise it only starts when it is not running.", "bool", False), + ("wait_seconds", "Wait for it to quit", "Seconds before it is killed (restart only).", "int", 15)], + "render": lambda p, cfg: (ABLETON_SET + mac_quit("Live", p.get("wait_seconds", 15), True) + ableton_open(p["app"])) if p.get("restart") + else "pgrep -xq Live || { " + ABLETON_SET + ableton_open(p["app"]) + "; }", + "suggest": lambda p, cfg: ("restart Ableton and reopen the set it had open (unsaved changes are lost)" if p.get("restart") + else "start Ableton if it is not running, with the set it last had open"), + }, + "dvs": { + "words": "Restart Dante Virtual Soundcard (through its helper)", + "os": "mac", "closes": True, + "params": [P_HOST, ("then_process", "Then restart this program", "Process name of a program that keeps a dead DVS input (blank: none).", "str", ""), + ("then_app", "...and open it as", "Its name or /Applications/... path.", "str", "")], + "render": lambda p, cfg: "sudo -n %s && open -g -a 'Dante Virtual Soundcard'" % q(dvs_helper(cfg)) + + ((" || exit 1; sleep 5; " + mac_quit(p["then_process"], 10, True) + mac_open(p["then_app"] or p["then_process"], True)) if p.get("then_process") else ""), + "suggest": lambda p, cfg: "restart Dante Virtual Soundcard (its engine restarts; audio to and from that Mac drops for 5-10 s)" + + (", then %s" % p["then_process"] if p.get("then_process") else ""), + }, + "helpers": { + "words": "Restart a helper on this Mac (a LaunchAgent)", + "os": "self", "closes": True, + "params": [("labels", "Helpers", "launchd labels, one or more (org.waterschurch.lobby-captions, say).", "list", [])], + "render": lambda p, cfg: helper_restart(p.get("labels") or []), + "suggest": lambda p, cfg: "restart %s" % ", ".join(p.get("labels") or []), + }, + "win_start": { + "words": "Start a Windows program if it is not running (a StatusPanel task)", + "os": "windows", "start_only": True, + "params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\ (Start Vista, say).", "str", None), + ("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)], + "render": lambda p, cfg: "%s || (schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s)" + % (win_running(win(p["exe"], "program file")), win(p["task"], "task name"), win_running(p["exe"])), + "suggest": lambda p, cfg: "start %s if it is not running" % str(p["exe"]).replace(".exe", ""), + }, + "win_restart": { + "words": "Restart a Windows program (close it, then its StatusPanel task)", + "os": "windows", "closes": True, + "params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\.", "str", None), + ("exe", "Program file", "As Task Manager shows it.", "str", None)], + "render": lambda p, cfg: "taskkill /F /T /IM \"%s\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s" + % (win(p["exe"], "program file"), win(p["task"], "task name"), win_running(p["exe"])), + "suggest": lambda p, cfg: "restart %s" % str(p["exe"]).replace(".exe", ""), + }, + "win_service": { + "words": "Start a Windows service (and its tray program) if they are not running", + "os": "windows", "start_only": True, + "params": [P_HOST, ("service", "Service name", "As services.msc lists it (rtpMIDIService).", "str", None), + ("task", "Tray program's task", "The StatusPanel task that starts the tray program (blank: none).", "str", ""), + ("exe", "Tray program file", "rtpMIDI.exe, say.", "str", "")], + "render": lambda p, cfg: "(sc query %s | find \"RUNNING\" >nul || net start %s)" % (win(p["service"], "service"), win(p["service"], "service")) + + ((" && (%s || schtasks /Run /TN \"\\StatusPanel\\%s\")" % (win_running(win(p["exe"], "program file")), win(p["task"], "task name"))) if p.get("task") else ""), + "suggest": lambda p, cfg: "start %s if it is not running" % p["service"], + }, + "solo_afl": { + "words": "Put Pro Tools back to Solo Mode AFL + Latch (through the solo helper)", + "os": "mac", + "params": [P_HOST], + "render": lambda p, cfg: "touch \"%s/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"%s/solo-mode.json\"" % (solo_dir(cfg), solo_dir(cfg)), + "suggest": lambda p, cfg: "set Pro Tools back to Solo Mode AFL + Latch (clicks Options > Solo Mode)", + }, + "atem_midi_off": { + "words": "Turn off MIDI control in ATEM Software Control (quits and reopens it)", + "os": "self", "closes": True, "restart_ok": True, + "params": [], + "render": lambda p, cfg: ("was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; " + "i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; " + "defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; " + "[ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; " + "[ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]"), + "suggest": lambda p, cfg: "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)", + }, + # kinds without a command: the template is the kind, the params are its fields + "reconnect": {"words": "Reconnect a Companion connection", "kind": "reconnect", "start_only": True, "params": [], "render": None, + "suggest": lambda p, cfg: "reconnect Companion to it"}, + "hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None, + "suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"}, + "midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None, + "suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"}, + "atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None, + "suggest": lambda p, cfg: "set the switcher back to the saved file"}, + "netgear_poe": {"words": "Power-cycle a PoE port on a Dante switch", "kind": "netgear_poe", "params": [], "render": None, + "suggest": lambda p, cfg: "switch the device's PoE port off and on"}, + "spotify": {"words": "Play the service playlist in Spotify", "kind": "spotify", "start_only": True, "params": [], "render": None, + "suggest": lambda p, cfg: "play the %s playlist in Spotify on %s (opens Spotify if it is closed)" + % ((cfg.get("spotify") or {}).get("name") or "service", (cfg.get("site") or {}).get("ingest_name") or "this Mac")}, + "custom": {"words": "Custom command (Advanced: typed by hand)", "params": [], "render": None, "suggest": lambda p, cfg: ""}, +} + + +def dvs_helper(cfg): + return (cfg.get("site") or {}).get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start" + + +def solo_dir(cfg): + return home_dir((cfg.get("site") or {}).get("solo_dir") or "~/Library/Application Support/status-panel") + + +def template_list(cfg=None): + """For the page: name, words, params with defaults, what it needs.""" + out = [] + for name, t in TEMPLATES.items(): + out.append({"name": name, "words": t["words"], "os": t.get("os"), "kind": t.get("kind"), "start_only": bool(t.get("start_only")), + "closes": bool(t.get("closes")), + "params": [{"name": n, "words": w, "help": h, "type": k, "default": d} for n, w, h, k, d in t["params"]]}) + return out + + +def build(cfg, fix): + """A fix with template + params -> the same fix with kind/host/run regenerated. Raises ValueError with plain words.""" + name = fix.get("template") + if not name or name == "custom": + return dict(fix) + t = TEMPLATES.get(name) + if t is None: + raise ValueError("unknown builder %s" % name) + params = dict(fix.get("params") or {}) + for n, words, _, kind, default in t["params"]: + if params.get(n) in (None, "") and default is not None: + params[n] = default + if params.get(n) in (None, "") and default is None: + raise ValueError("%s: %s is needed" % (t["words"], words.lower())) + out = {k: v for k, v in fix.items() if k not in ("run", "host", "kind")} + out["template"], out["params"] = name, params + if t.get("kind"): + out["kind"] = t["kind"] + return out + hosts = cfg.get("hosts") or {} + host = params.get("host") + if t.get("os") == "self": + out["kind"] = "local" + else: + h = hosts.get(host) + if h is None: + raise ValueError("%s: unknown computer %s" % (t["words"], host)) + want = t.get("os") + have = "mac" if h.get("self") else (h.get("os") or "mac") + if want and have != want: + raise ValueError("%s: %s is a %s computer, this builder is for %s" % (t["words"], h.get("name", host), have, want)) + if h.get("self"): + out["kind"] = "local" + else: + out["kind"], out["host"] = "ssh", host + out["run"] = t["render"](params, cfg) + if t.get("restart_ok"): + out["restart_ok"] = True + return out + + +def suggest_label(cfg, fix): + t = TEMPLATES.get(fix.get("template") or "") + if not t: + return "" + try: + return t["suggest"](dict(fix.get("params") or {}), cfg) + except (KeyError, TypeError): + return "" + + +# --- check builders (the three remote checks that carry a command) ---------------- +CHECK_TEMPLATES = { + "mac_program": { + "words": "A program is running on a Mac", "type": "remote", + "params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None)], + "render": lambda p, cfg: "pgrep -xq %s" % q(p["process"]), + }, + "helper_flag": { + "words": "A helper file does not say something is wrong", "type": "remote", + "params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"), + ("bad", "The bad text", "The check fails when the file holds this.", "str", "\"bad\":true")], + "render": lambda p, cfg: "! grep -q %s \"%s/%s\"" % (q(p["bad"]), solo_dir(cfg), p["file"]), + }, + "helper_fresh": { + "words": "A helper file is fresh and says it is trusted", "type": "remote", + "params": [P_HOST, ("file", "File", "In the helper's folder.", "str", "solo-mode.json"), + ("max_age", "Fresh means younger than", "Seconds.", "int", 20), + ("flag", "The good text", "The check fails unless the file holds this.", "str", "\"trusted\":true")], + "render": lambda p, cfg: "f=\"%s/%s\"; [ $(( $(date +%%s) - $(stat -f %%m \"$f\") )) -lt %d ] && grep -q %s \"$f\"" + % (solo_dir(cfg), p["file"], int(p["max_age"]), q(p["flag"])), + }, +} + + +def check_template_list(): + return [{"name": n, "words": t["words"], "type": t["type"], + "params": [{"name": a, "words": w, "help": h, "type": k, "default": d} for a, w, h, k, d in t["params"]]} for n, t in CHECK_TEMPLATES.items()] + + +def build_check(cfg, chk): + name = chk.get("template") + if not name: + return dict(chk) + t = CHECK_TEMPLATES.get(name) + if t is None: + raise ValueError("unknown check builder %s" % name) + params = dict(chk.get("params") or {}) + for n, words, _, kind, default in t["params"]: + if params.get(n) in (None, "") and default is not None: + params[n] = default + if params.get(n) in (None, "") and default is None: + raise ValueError("%s: %s is needed" % (t["words"], words.lower())) + h = (cfg.get("hosts") or {}).get(params.get("host")) + if h is None: + raise ValueError("%s: unknown computer %s" % (t["words"], params.get("host"))) + if h.get("self"): + raise ValueError("%s: %s is this Mac - use 'A program is running on this Mac'" % (t["words"], h.get("name"))) + out = dict(chk, type=t["type"], template=name, params=params, host=params["host"]) + for k, v in chk.items(): + if k in ("fix", "tag", "service_only", "debounce_seconds", "msg", "name"): + out[k] = v + for k in ("fix", "tag", "service_only", "debounce_seconds"): + if k in chk: + out[k] = chk[k] + out["cmd"] = t["render"](params, cfg) + return out + + +def mismatches(cfg): + """Fixes and checks whose stored command differs from what their fields build (plain words).""" + out = [] + for fid, fix in (cfg.get("fixes") or {}).items(): + if fix.get("template") and fix["template"] != "custom": + try: + b = build(cfg, fix) + except ValueError as e: + out.append("fix %s: %s" % (fid, e)) + continue + for k in ("run", "kind", "host"): + if (b.get(k) or None) != (fix.get(k) or None): + out.append("fix %s: its %s no longer matches its fields - open it on Fixes and press Save" % (fid, "command" if k == "run" else k)) + break + for t in cfg.get("tiles") or []: + for i, c in enumerate(t.get("checks") or []): + if c.get("template"): + try: + b = build_check(cfg, c) + except ValueError as e: + out.append("key %s check %d: %s" % (t.get("label", t["id"]), i + 1, e)) + continue + if b.get("cmd") != c.get("cmd") or b.get("host") != c.get("host"): + out.append("key %s check %d: its command no longer matches its fields" % (t.get("label", t["id"]), i + 1)) + return out + + +# --- today's fixes get their templates (one-time) ------------------------------------ +TODAY = { + "propresenter": ("mac_restart", {"host": "prop", "process": "ProPresenter", "app": "$APP", "newest_of": "/Applications/ProPresenter*.app", "wait_seconds": 10, "force": True}), + "ableton": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": False}), + "ableton_restart": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": True, "wait_seconds": 15}), + "translate": ("mac_start", {"host": "translation", "process": "Church Translate", "app": "Church Translate"}), + "translate_restart": ("mac_restart", {"host": "translation", "process": "Church Translate", "app": "Church Translate", "wait_seconds": 15, "force": True, "stop_children": True}), + "dvs_prop": ("dvs", {"host": "prop"}), "dvs_tracks": ("dvs", {"host": "tracks"}), "dvs_translation": ("dvs", {"host": "translation"}), + "dvs_ingest": ("dvs", {"host": "ingest", "then_process": "ProdCom", "then_app": "/Applications/ProdCom.app"}), + "bridge": ("helpers", {"labels": ["org.waterschurch.resi-propresenter"]}), + "captions": ("helpers", {"labels": ["org.waterschurch.lobby-captions", "org.waterschurch.lobby-captions-display"]}), + "caption_screen": ("helpers", {"labels": ["org.waterschurch.lobby-captions-display"]}), + "bibleapp": ("win_restart", {"host": "bibletv", "task": "Start BibleApp", "exe": "Bible App.exe"}), + "obs": ("win_start", {"host": "livestream", "task": "Start OBS", "exe": "obs64.exe"}), + "arena": ("win_start", {"host": "lighting", "task": "Start Arena", "exe": "Arena.exe"}), + "vista": ("win_start", {"host": "lighting", "task": "Start Vista", "exe": "Vista.exe"}), + "rtpmidi_start": ("win_service", {"host": "lighting", "service": "rtpMIDIService", "task": "Start rtpMIDI", "exe": "rtpMIDI.exe"}), + "solo_afl": ("solo_afl", {"host": "studiob"}), + "atem_midi": ("atem_midi_off", {}), + "proddash": ("mac_restart", {"host": "ingest", "process": "ProdDash", "app": "/Applications/ProdDash.app", "wait_seconds": 10, "force": False, "background": True}), + "prodcom": ("mac_restart", {"host": "ingest", "process": "ProdCom", "app": "/Applications/ProdCom.app", "wait_seconds": 10, "force": True, "background": True}), + "midi_reconnect": ("midi_connect", {}), "atem_audio": ("atem", {}), "atem_camera": ("atem", {}), "spotify_play": ("spotify", {}), + "tascam_reconnect": ("reconnect", {}), "hyperdeck": ("hyperdeck", {}), +} +TODAY_CHECKS = { # (key id, cmd) -> template + params + ("apps", "pgrep -xq ProPresenter"): ("mac_program", {"host": "prop", "process": "ProPresenter"}), + ("apps", "pgrep -xq Live"): ("mac_program", {"host": "tracks", "process": "Live"}), + ("apps", "pgrep -xq 'Church Translate'"): ("mac_program", {"host": "translation", "process": "Church Translate"}), + ("apps", "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\""): ("helper_flag", {"host": "studiob"}), + ("apps", "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\""): + ("helper_fresh", {"host": "studiob"}), +} + + +def adopt(cfg): + """Give today's fixes and checks their templates; returns [(what, before, after)] for every command that changed shape.""" + changed = [] + for fid, fix in cfg.get("fixes", {}).items(): + if fix.get("template") or fid not in TODAY: + continue + name, params = TODAY[fid] + fix["template"], fix["params"] = name, params + built = build(cfg, fix) + if built.get("run") != fix.get("run"): + changed.append(("fix " + fid, fix.get("run"), built.get("run"))) + fix.update({k: v for k, v in built.items() if k in ("run", "kind", "host", "restart_ok")}) + for k in ("host",): + if k not in built and k in fix: + del fix[k] + for t in cfg.get("tiles", []): + for c in t.get("checks", []): + key = (t["id"], c.get("cmd")) + if c.get("template") or key not in TODAY_CHECKS: + continue + name, params = TODAY_CHECKS[key] + c["template"], c["params"] = name, params + built = build_check(cfg, c) + if built["cmd"] != c.get("cmd"): + changed.append(("key %s check" % t["id"], c.get("cmd"), built["cmd"])) + c["cmd"], c["host"] = built["cmd"], built["host"] + return changed diff --git a/settings/server.py b/settings/server.py index 822de1d..3a5c64b 100644 --- a/settings/server.py +++ b/settings/server.py @@ -31,6 +31,7 @@ from settings import PARENT, HERE # noqa: F401 import schema import status_panel as sp from settings import apply as applying +from settings import builders from settings import keyguide, probes log = logging.getLogger("status-settings") @@ -644,6 +645,8 @@ class Handler(http.server.BaseHTTPRequestHandler): if path == "/api/live/midi": snap = site.read_json("snapshot.json", {}) or {} return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")}) + if path == "/api/builders": + return self.send(200, {"fixes": builders.template_list(site.cfg), "checks": builders.check_template_list()}) if path == "/api/facts": return self.send(200, {"facts": COMPANION_FACTS}) if path == "/api/snapshot": @@ -734,6 +737,15 @@ class Handler(http.server.BaseHTTPRequestHandler): site.draft.clear() self.discard_atem() return self.send(200, {"ok": True}) + if path in ("/api/build", "/api/build-check"): + cfg = (site.draft.read() or {}).get("config") or site.load() + try: + if path == "/api/build": + fix = builders.build(cfg, body.get("fix") or {}) + return self.send(200, {"ok": True, "fix": fix, "label": builders.suggest_label(cfg, fix)}) + return self.send(200, {"ok": True, "check": builders.build_check(cfg, body.get("check") or {})}) + except ValueError as e: + return self.send(200, {"ok": False, "error": str(e)}) if path == "/api/validate": cfg = schema.unmask(body.get("config") or (site.draft.read() or {}).get("config") or site.load(), site.load()) return self.send(200, self.check(cfg)) diff --git a/settings/ui/app.js b/settings/ui/app.js index 286c47b..d0d949a 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -69,8 +69,8 @@ // ---- the draft ------------------------------------------------------------ async function loadAll() { - const [c, s, d] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft")]); - state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; + const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]); + state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b; document.getElementById("sitename").textContent = (state.live.site || {}).name || ""; badge(); } @@ -309,7 +309,13 @@ const sentence = (kt.checks[j] || {}).sentence || chk.type; if (!editing()) return `
  • ${esc(sentence)}
    Advanced
    ${Object.entries(chk).map(([n, v]) => `
    ${esc(n)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
  • `; const base = `tiles.${i}.checks.${j}.`; - const fields = (spec.fields || []).filter((f) => f !== "fix" && f !== "type").map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); + let fields = (spec.fields || []).filter((f) => !["fix", "type", "template", "params"].includes(f)).map((f) => inputHtml(base + f, F("tiles[].checks[]." + f), chk[f])).join(""); + if (chk.type === "remote") { + const ctpls = state.builders.checks.concat([{ name: "custom", words: "Custom command (typed by hand)", params: [] }]); + const ctName = chk.template || "custom", ct = ctpls.find((x) => x.name === ctName); + fields = `
    ${paramsHtml(ct, chk.params || {}, "ck" + j)}
    ` + + (ctName === "custom" ? fields : inputHtml(base + "msg", F("tiles[].checks[].msg"), chk.msg) + (chk.cmd ? `
    ${esc(chk.cmd)}
    ` : "")); + } const fixSel = typeof chk.fix === "object" && chk.fix ? `
    A table: sub-check → fix, "*" for everything else.
    ` : `
    `; const run = typeof chk.fix === "string" && chk.fix && canRunNow(chk.fix) ? `` : ""; @@ -337,6 +343,19 @@ for (const cb of main.querySelectorAll("[data-member]")) cb.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const set = new Set(t.members); cb.checked ? set.add(cb.dataset.member) : set.delete(cb.dataset.member); t.members = [...set]; saveDraft(); }; const add = main.querySelector("#addcheck"); if (add) add.onchange = () => { if (!add.value || !ensureDraft()) return; const t = state.draft.config.tiles[i]; t.checks = t.checks || []; t.checks.push({ type: add.value }); saveDraft(); route(); }; const pos = main.querySelector("#pos"); if (pos) pos.onchange = () => { if (!ensureDraft()) return; const t = state.draft.config.tiles[i]; const m = pos.value.match(/^\s*(\d+)\s*,\s*(\d+)\s*$/); if (m) t.pos = [+m[1], +m[2]]; else if (!pos.value.trim()) delete t.pos; else return alertBox("Row, column: two numbers, like 2, 5."); saveDraft(); route(); }; + const rebuildCheck = async (j) => { + if (!ensureDraft()) return; + const t = state.draft.config.tiles[i], chk = t.checks[j]; + const tplName = main.querySelector(`select.ctplsel[data-check="${j}"]`).value; + if (tplName === "custom") { delete chk.template; delete chk.params; saveDraft(); route(); return; } + const ct = state.builders.checks.find((x) => x.name === tplName); + const params = readParams(main.querySelector(`.ctplform[data-check="${j}"]`), ct); + const r = await post("/api/build-check", { check: Object.assign({}, chk, { template: tplName, params }) }); + if (!r.ok) { chk.template = tplName; chk.params = params; saveDraft(); alertBox(r.error); return; } + t.checks[j] = r.check; saveDraft(); route(); + }; + for (const sel3 of main.querySelectorAll("select.ctplsel")) sel3.onchange = () => rebuildCheck(+sel3.dataset.check); + for (const form of main.querySelectorAll(".ctplform")) for (const el of form.querySelectorAll("[data-param]")) el.onchange = () => rebuildCheck(+form.dataset.check); for (const s of main.querySelectorAll("select.fixsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); }; for (const ta of main.querySelectorAll("textarea.fixtable")) ta.onchange = () => { try { setPath(ta.dataset.path, JSON.parse(ta.value)); } catch (e) { alertBox("Not a valid table."); } }; for (const s of main.querySelectorAll("select.holdsel")) s.onchange = () => { setPath(s.dataset.path, s.value || undefined); route(); }; @@ -609,6 +628,33 @@ } } + // ---- builders: a fix or a check is made from fields --------------------------------- + function paramsHtml(tpl, params, prefix) { + const c = cfg(); + return tpl.params.map((p) => { + const v = params[p.name] != null ? params[p.name] : p.default; + const id = `${prefix}-${p.name}`; + let input; + if (p.type === "host") input = ``; + else if (p.type === "bool") input = ``; + else if (p.type === "int") input = ``; + else if (p.type === "list") input = ``; + else input = ``; + return `
    ${input}
    ${esc(p.help)}
    `; + }).join(""); + } + function readParams(root, tpl) { + const out = {}; + for (const p of tpl.params) { + const el = root.querySelector(`[data-param="${p.name}"]`); if (!el) continue; + if (p.type === "bool") out[p.name] = el.checked; + else if (p.type === "int") out[p.name] = el.value === "" ? undefined : parseInt(el.value, 10); + else if (p.type === "list") out[p.name] = el.value.split(",").map((x) => x.trim()).filter(Boolean); + else out[p.name] = el.value.trim(); + } + return out; + } + // ---- Fixes ------------------------------------------------------------------------- async function fixes(rest) { const c = cfg(), auto = new Set(c.auto_fix.fixes || []); @@ -621,20 +667,42 @@ let detail = ""; if (f) { const kind = f.kind || "ssh"; - const fields = ["label", "short"].concat((state.schema.fix_kind_fields || {})[kind] || []).concat(["restart_ok", "never_auto", "timeout"]); - const inputs = editing() ? [...new Set(fields)].map((fl) => { - const Fd = F("fixes.*." + fl); if (!Fd) return ""; - if (fl === "host") return `
    `; - if (fl === "run") return `
    zsh on a Mac, cmd.exe on a PC. ${esc(Fd.help)}
    `; - return inputHtml(`fixes.${id}.${fl}`, Fd, f[fl]); - }).join("") : ""; - const kindSel = editing() ? `
    ` : ""; + const tpls = state.builders.fixes, tplName = f.template || "custom", tpl = tpls.find((t) => t.name === tplName) || tpls.find((t) => t.name === "custom"); + const kindOnly = !!tpl.kind; const keys = [...(used[id] || [])]; - detail = `

    ${esc(f.short || id)}

    ${esc(f.label || "")}

    ${kindSel}${inputs}${canRunNow(id) && editing() ? `

    Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).

    ` : ""}
    Advanced
    ${Object.entries(f).map(([k, v]) => `
    ${esc(k)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
    id
    ${esc(id)}
    ${editing() && !keys.length ? `` : ""}
    `; + let inputs = ""; + if (editing()) { + inputs += `
    `; + inputs += `
    ${paramsHtml(tpl, f.params || {}, "fx")}

    `; + inputs += inputHtml(`fixes.${id}.label`, F("fixes.*.label"), f.label) + inputHtml(`fixes.${id}.short`, F("fixes.*.short"), f.short); + if (kindOnly) inputs += (state.schema.fix_kind_fields[tpl.kind] || []).map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join(""); + if (tplName === "custom") { + inputs += `
    `; + if (kind === "ssh") inputs += `
    `; + inputs += `
    zsh on a Mac, cmd.exe on a PC.
    `; + } else if (f.run) inputs += `
    ${esc(f.run)}
    `; + inputs += `
    Advanced${["reconnect", "conn", "restart_ok", "never_auto", "timeout"].map((fl) => inputHtml(`fixes.${id}.${fl}`, F("fixes.*." + fl), f[fl])).join("")}
    `; + } else inputs = `

    Built as: ${esc(tpl.words)}${f.run ? `

    ${esc(f.run)}
    ` : "

    "}`; + detail = `

    ${esc(f.short || id)}

    ${esc(f.label || "")}

    ${inputs}${canRunNow(id) && editing() ? `

    Run it from the key it belongs to (${esc(keys.join(", ") || "none yet")}).

    ` : ""}
    Everything stored
    ${Object.entries(f).map(([k, v]) => `
    ${esc(k)}
    ${esc(typeof v === "object" ? JSON.stringify(v) : v)}
    `).join("")}
    id
    ${esc(id)}
    ${editing() && !keys.length ? `` : ""}
    `; } const add = editing() ? `
    ` : ""; main.innerHTML = `

    Fixes

    Everything a hold can run. ${Object.keys(c.fixes).length} today.

    ${add}
    ${rows}
    key textplain wordsbuilt asused byby itself
    ${detail}`; - const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value); route(); }; + const ks = main.querySelector("select.kindsel"); if (ks) ks.onchange = () => { setPath(ks.dataset.path, ks.value === "another computer" ? "ssh" : ks.value === "this Mac" ? "local" : ks.value); route(); }; + const rebuild = async () => { + if (!ensureDraft()) return; + const fx = state.draft.config.fixes[id], tplName = main.querySelector("#tplsel").value, tpl = state.builders.fixes.find((t) => t.name === tplName); + const params = readParams(main.querySelector("#tplform"), tpl); + if (tplName === "custom") { fx.template = "custom"; delete fx.params; saveDraft(); route(); return; } + const r = await post("/api/build", { fix: Object.assign({}, fx, { template: tplName, params }) }); + const msg = main.querySelector("#tplmsg"); + if (!r.ok) { msg.textContent = r.error; fx.template = tplName; fx.params = params; saveDraft(); return; } + const keep = { label: fx.label, short: fx.short }; + Object.assign(fx, r.fix); for (const k of ["run", "host"]) if (!(k in r.fix)) delete fx[k]; + if (!keep.label && r.label) fx.label = r.label; + msg.textContent = ""; + saveDraft(); route(); + }; + const ts = main.querySelector("#tplsel"); if (ts) { ts.onchange = rebuild; for (const el of main.querySelectorAll("#tplform [data-param]")) el.onchange = rebuild; } const del = main.querySelector("#delfix"); if (del) del.onclick = () => confirmBox(`Delete the fix ${id}? No key uses it.`, "Delete", () => { if (!ensureDraft()) return; delete state.draft.config.fixes[id]; const af2 = state.draft.config.auto_fix.fixes || []; state.draft.config.auto_fix.fixes = af2.filter((x) => x !== id); saveDraft(); location.hash = "#fixes"; }); const af = main.querySelector("#addfix"); if (af) af.onclick = () => { const nid = main.querySelector("#newfix").value.trim(); if (!/^[A-Za-z0-9_]+$/.test(nid) || c.fixes[nid]) return alertBox("A new id: letters, digits and _ only, not in use."); if (!ensureDraft()) return; state.draft.config.fixes[nid] = { kind: "local", short: nid.toUpperCase().slice(0, 12), label: "", run: "" }; saveDraft(); location.hash = "#fixes/" + nid; }; } diff --git a/tests/test_builders.py b/tests/test_builders.py new file mode 100644 index 0000000..6d3c0d6 --- /dev/null +++ b/tests/test_builders.py @@ -0,0 +1,121 @@ +"""Fix and check builders: every one of today's fixes regenerates from its fields exactly, values are +quoted or refused, a start builder never closes a running program, and this Mac becomes a local fix.""" +import json +import os +import sys +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, ROOT) +import schema # noqa: E402 +from settings import builders as b # noqa: E402 + +CFG = json.load(open(os.path.join(ROOT, "config.json"))) + + +class TestTodaysFixes(unittest.TestCase): + def test_every_fix_has_a_builder_and_regenerates_exactly(self): + for fid, fix in CFG["fixes"].items(): + self.assertTrue(fix.get("template"), fid) + built = b.build(CFG, fix) + for k in ("run", "kind", "host"): + self.assertEqual(built.get(k), fix.get(k), "%s %s" % (fid, k)) + self.assertEqual(b.mismatches(CFG), []) + self.assertEqual(len(CFG["fixes"]), 27) + + def test_todays_remote_checks_regenerate(self): + n = 0 + for t in CFG["tiles"]: + for c in t.get("checks", []): + if c.get("template"): + self.assertEqual(b.build_check(CFG, c)["cmd"], c["cmd"]) + n += 1 + self.assertEqual(n, 5) + + def test_a_changed_command_is_refused_by_validate(self): + cfg = json.loads(json.dumps(CFG)) + cfg["fixes"]["vista"]["run"] = "shutdown /s" + errors, _ = schema.validate(cfg) + self.assertTrue(any("vista" in e and "no longer matches" in e for e in errors), errors) + cfg["fixes"]["vista"]["template"] = "custom" # a hand-typed command is allowed when it says so + self.assertEqual([e for e in schema.validate(cfg)[0] if "vista" in e], []) + + def test_exact_strings_of_the_shapes_that_matter(self): + vista = b.build(CFG, {"template": "win_start", "params": {"host": "lighting", "task": "Start Vista", "exe": "Vista.exe"}}) + self.assertEqual(vista["run"], 'tasklist /NH /FI "IMAGENAME eq Vista.exe" | find /I "Vista.exe" >nul || (schtasks /Run /TN "\\StatusPanel\\Start Vista" >nul ' + '&& ping -n 9 127.0.0.1 >nul && tasklist /NH /FI "IMAGENAME eq Vista.exe" | find /I "Vista.exe" >nul)') + self.assertEqual((vista["kind"], vista["host"]), ("ssh", "lighting")) + self.assertNotIn("%ERRORLEVEL%", vista["run"]) + caps = b.build(CFG, {"template": "helpers", "params": {"labels": ["a.b", "c.d"]}}) + self.assertEqual(caps["run"], "s=0; { launchctl kickstart -k gui/$(id -u)/a.b || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/a.b.plist; } || s=1; " + "{ launchctl kickstart -k gui/$(id -u)/c.d || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/c.d.plist; } || s=1; exit $s") + self.assertEqual(caps["kind"], "local") + + +class TestQuotingAndGuards(unittest.TestCase): + def test_mac_names_are_quoted(self): + f = b.build(CFG, {"template": "mac_start", "params": {"host": "tracks", "process": "Ableton Live 11 Suite", "app": "Rob's App"}}) + self.assertEqual(f["run"], "pgrep -xq 'Ableton Live 11 Suite' || open -a 'Rob'\"'\"'s App'") + f = b.build(CFG, {"template": "mac_restart", "params": {"host": "tracks", "process": "x; rm -rf /", "app": "X"}}) + self.assertIn("pkill -TERM -x 'x; rm -rf /'", f["run"]) + + def test_windows_values_are_checked(self): + for bad in ("Vista & del", "Start!Vista", 'a"b', "x%y", "a^b"): + with self.assertRaises(ValueError): + b.build(CFG, {"template": "win_start", "params": {"host": "lighting", "task": bad, "exe": "Vista.exe"}}) + ok = b.build(CFG, {"template": "win_restart", "params": {"host": "bibletv", "task": "Start BibleApp", "exe": "Bible App.exe"}}) + self.assertEqual(ok["run"], CFG["fixes"]["bibleapp"]["run"]) + + def test_helper_labels_and_folders_are_checked(self): + with self.assertRaises(ValueError): + b.build(CFG, {"template": "helpers", "params": {"labels": ["org.x; rm"]}}) + cfg = json.loads(json.dumps(CFG)) + cfg["site"]["solo_dir"] = '~/Library/"x"' + with self.assertRaises(ValueError): + b.build(cfg, {"template": "solo_afl", "params": {"host": "studiob"}}) + cfg["site"]["solo_dir"] = "~/Library/Application Support/status-panel/" + self.assertEqual(b.build(cfg, {"template": "solo_afl", "params": {"host": "studiob"}})["run"], CFG["fixes"]["solo_afl"]["run"]) + + def test_start_builders_never_close_a_running_program(self): + for name, t in b.TEMPLATES.items(): + if t.get("start_only") and t.get("render"): + params = {p[0]: (p[4] if p[4] is not None else {"host": "lighting" if t.get("os") == "windows" else "tracks"}.get(p[0], "Thing.exe" if t.get("os") == "windows" else "Thing")) + for p in t["params"]} + run = b.build(CFG, {"template": name, "params": params})["run"] + self.assertFalse(any(w in run for w in ("pkill", "taskkill", "killall", "kill ")), (name, run)) + self.assertTrue("||" in run, (name, run)) # only if not already running + + def test_this_mac_becomes_a_local_fix_and_the_wrong_os_is_refused(self): + f = b.build(CFG, {"template": "mac_restart", "params": {"host": "ingest", "process": "ProdDash", "app": "/Applications/ProdDash.app"}}) + self.assertEqual((f["kind"], f.get("host")), ("local", None)) + with self.assertRaises(ValueError): + b.build(CFG, {"template": "win_start", "params": {"host": "tracks", "task": "Start X", "exe": "X.exe"}}) + with self.assertRaises(ValueError): + b.build(CFG, {"template": "mac_start", "params": {"host": "lighting", "process": "x", "app": "x"}}) + with self.assertRaises(ValueError): + b.build(CFG, {"template": "mac_start", "params": {"host": "nowhere", "process": "x", "app": "x"}}) + + def test_missing_fields_and_suggested_words(self): + with self.assertRaises(ValueError) as cm: + b.build(CFG, {"template": "mac_start", "params": {"host": "tracks"}}) + self.assertIn("process name is needed", str(cm.exception)) + self.assertEqual(b.suggest_label(CFG, {"template": "spotify"}), "play the Christian EDM 2026 playlist in Spotify on NA Ingest (opens Spotify if it is closed)") + self.assertEqual(b.suggest_label(CFG, {"template": "win_start", "params": {"task": "Start OBS", "exe": "obs64.exe"}}), "start obs64 if it is not running") + names = {t["name"] for t in b.template_list(CFG)} + self.assertEqual(names, set(b.TEMPLATES)) + + def test_check_builders(self): + c = b.build_check(CFG, {"type": "remote", "template": "mac_program", "params": {"host": "prop", "process": "Pro Tools"}, "msg": "PT NOT RUNNING", "fix": "x"}) + self.assertEqual((c["cmd"], c["host"], c["fix"], c["msg"]), ("pgrep -xq 'Pro Tools'", "prop", "x", "PT NOT RUNNING")) + with self.assertRaises(ValueError): + b.build_check(CFG, {"template": "mac_program", "params": {"host": "ingest", "process": "x"}}) + cfg = json.loads(json.dumps(CFG)) + for t in cfg["tiles"]: + for ch in t.get("checks", []): + if ch.get("template") == "mac_program": + ch["cmd"] = "true" + self.assertTrue(any("no longer matches" in e for e in schema.validate(cfg)[0])) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index b64f682..f83a566 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -807,7 +807,7 @@ class TestFixes(unittest.TestCase): # what each key restarts keys = {k["id"]: k["fix"] for k in CONFIG["restart_page"]["keys"]} self.assertEqual(keys["rs_ableton"], "ableton_restart") - self.assertIn("pkill -TERM -x 'Live'", CONFIG["fixes"]["ableton_restart"]["run"]) + self.assertIn("pkill -TERM -x Live", CONFIG["fixes"]["ableton_restart"]["run"]) self.assertIn("kickstart -k system/com.audinate.dante.DanteVirtualSoundcard", open(os.path.join(os.path.dirname(os.path.abspath(sp.__file__)), "remote-access", "mac-dvs-helper.sh")).read()) self.assertIn("open -g /Applications/ProdCom.app", CONFIG["fixes"]["dvs_ingest"]["run"]) # ProdCom after DVS