From 5d66805687696f1e046a43ac2dc83a8b12f467e7 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 13 Oct 2026 16:00:41 -0400 Subject: [PATCH] Step 0 of the settings page: registry, safe restart, remembered state, test verb, page markers - schema.py: every setting with type, default, help, owner screen and the rules a config must pass; tests/test_schema.py keeps it in step with the code (every key the code reads is declared, every default matches, check types and fix kinds agree, the glossary covers every word a key can show, today's config still builds today's pages apart from the key notes) - status_panel.py: VERSION + started.json; SIGTERM lets a running fix finish and starts nothing new (RESTARTING SOON); fixes-running.json and live-now.json for the settings page; state.json keeps the ALARMS override, acknowledgements, auto-fix tries and fix times across restarts, keyed by what a check is about rather than its position; 'test' and 'resurvey' commands; queued commands dropped at start; overnight windows; a HyperDeck switched off is always switched back on; the spotify fix kind (playlist set once, in config 'spotify'); Windows remote checks batched for cmd.exe with conditional echoes; every fix's kind and host validated; never_auto; wording and numbers from config (site block, timing, page_hold_ms, auto-fix keep_months); hyperdeck_probe removed; --check and --exit-timeout - make_page.py: numbers from config, markers on every key, nav templates per Companion version, --out; push_page.py: a page is overwritten only when every key carries a marker (foreign keys named by position), secret-free fetch, host from the site; nav_keys.py: host from the site; netgear.py: switches and the shared-port limit from config - install.sh: validates with the new code first, keeps prev/ for --rollback, --keep-config, derived ExitTimeOut, wider deploy list, waits for the start stamp - config.json: site, timing, spotify sections; Windows hosts marked; deployed to NA Ingest and both pages re-imported once (--force) so every key carries its marker Co-Authored-By: Claude Fable 5.1 --- README.md | 13 +- config.json | 20 +- install.sh | 128 +++++-- make_page.py | 129 ++++--- nav_keys.py | 4 +- netgear.py | 17 +- push_page.py | 59 ++-- restart-page.companionconfig | Bin 3567 -> 3574 bytes schema.py | 658 +++++++++++++++++++++++++++++++++++ status-page.companionconfig | Bin 6206 -> 6214 bytes status_panel.py | 567 ++++++++++++++++++++++++------ tests/test_schema.py | 296 ++++++++++++++++ tests/test_status_panel.py | 238 ++++++++----- 13 files changed, 1822 insertions(+), 307 deletions(-) create mode 100644 schema.py create mode 100644 tests/test_schema.py diff --git a/README.md b/README.md index 9662c17..9244137 100644 --- a/README.md +++ b/README.md @@ -139,15 +139,17 @@ A problem has to last 3 seconds before it shows, so one bad reading doesn't trig This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest: ```bash -scp status_panel.py coremidi.py netgear.py config.json install.sh checklist-ingest:status-panel-install/ +scp status_panel.py coremidi.py netgear.py make_page.py push_page.py nav_keys.py nav_templates.json config.json install.sh atem_watch.js atem_state.js atem-reference.xml checklist-ingest:status-panel-install/ ``` -Then run the installer there. It backs up the old config and restarts the watchdog: +Then run the installer there. It checks the config with the new code first (a bad config never gets as far as the watchdog), keeps the code that was running in `prev/`, backs up the old config, writes the LaunchAgent with the exit timeout this config needs (the longest fix plus its verify time, so a restart never cuts a fix in half) and restarts the watchdog: ```bash ssh checklist-ingest 'zsh ~/status-panel-install/install.sh' ``` +`install.sh --keep-config` updates the code and leaves the live config alone; `install.sh --rollback` puts the previous code back. + If you add or move tiles, rebuild the page: ```bash @@ -172,6 +174,8 @@ zsh install.sh --remove # stop the watchdog The log is `~/Library/Logs/status-panel.log` on NA Ingest. Every fault and clear is written there with a timestamp, which is handy for a post-service review. +What the watchdog keeps in its data folder (`~/Library/Application Support/status-panel/`): `state.json` (the ALARMS override, acknowledged faults, auto-fix tries and when each fix last ran, so a restart forgets nothing), `started.json` (when it started, with which config), `live-now.json` (whether a HyperDeck is recording: changes to the panel wait while it is), `fixes-running.json` (the fixes running right now). A restart (`launchctl kickstart -k`, or the installer) lets a running fix finish first and starts nothing new meanwhile: keys say RESTARTING SOON. Windows in `config.json` may run past midnight (`"end"` earlier than `"start"`), and a remote check on a Windows PC (`"os": "windows"` on the host) is batched for cmd.exe. + ## Optional: see what the tracks send to the lights The TRACKS tile can show when the tracks rig last sent MIDI ("TRK MIDI 3s"). For that, NA Ingest has to *listen* to the "NA Tracks" session. Use a **separate** session so nothing Companion sends can reach Ableton: @@ -204,7 +208,8 @@ To go back, pick 1.3.0 on the connection again. | `status_panel.py` | the watchdog | | `netgear.py` | reads the Netgear Dante switches (MAC tables, PoE) and power-cycles one PoE port | | `coremidi.py` | reads network MIDI sessions and listens to MIDI sources (ctypes, no extra installs) | -| `config.json` | tiles, positions, checks, service windows | +| `config.json` | tiles, positions, checks, fixes, hosts, windows: every setting (the `site` block names the campus; `spotify` holds the playlist; `timing` and `page_hold_ms` the numbers behind HOLD 2s and WAIT 1 MIN) | +| `schema.py` | the registry of every setting: type, default, help, which screen of the settings page owns it, and the rules a config must pass (`tests/test_schema.py` keeps it in step with the code) | | `make_page.py` | builds `status-page.companionconfig` from `config.json` | | `install.sh` | installs or updates the LaunchAgent on NA Ingest | | `push_page.py` | imports the status page into page 19 over Companion's API | @@ -212,4 +217,4 @@ To go back, pick 1.3.0 on the connection again. | `nav_templates.json` | Companion's own nav key designs, coloured by `make_page.py` | | `remote-access/` | one-time setup scripts for SSH to the Macs and Windows PCs | | `tascam-quiet/` | builds the quiet Tascam module | -| `tests/` | offline tests, including a fake Companion and a MIDI loopback | +| `tests/` | offline tests, including a fake Companion and a MIDI loopback; `test_schema.py` checks that every key the code reads is declared in `schema.py` with the same default | diff --git a/config.json b/config.json index 3193ccb..ba529ff 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,9 @@ { + "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"], + "live_services": [] + }, "companion": { "host": "127.0.0.1", "port": 8000 }, "page": 19, "page_name": "19\\nStatus", @@ -24,6 +29,10 @@ }, "alert_webhook": "", + "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 }, "ssh_key": "~/.ssh/id_status_panel", @@ -32,10 +41,10 @@ "prop": { "ssh": "napropresenter@10.1.14.225" }, "tracks": { "ssh": "waterschurchmedia@10.1.3.227" }, "translation": { "ssh": "studio@10.1.7.39" }, - "bibletv": { "ssh": "media@10.6.7.13" }, - "lighting": { "ssh": "media@10.1.10.206" }, + "bibletv": { "ssh": "media@10.6.7.13", "os": "windows" }, + "lighting": { "ssh": "media@10.1.10.206", "os": "windows" }, "studiob": { "ssh": "studiob@10.1.13.175" }, - "livestream": { "ssh": "media@10.1.9.126", "mac": "6c:24:08:d7:fb:57", "wake_broadcast": "10.1.255.255" } + "livestream": { "ssh": "media@10.1.9.126", "os": "windows", "mac": "6c:24:08:d7:fb:57", "wake_broadcast": "10.1.255.255" } }, "fixes": { @@ -72,8 +81,7 @@ "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": "local", "short": "SPOTIFY", "label": "play the Christian EDM 2026 playlist in Spotify on NA Ingest (opens Spotify if it is closed)", - "run": "pgrep -xq Spotify || { open -g -a Spotify; sleep 8; }; osascript -e 'tell application \"Spotify\" to play track \"spotify:playlist:2uTpJVYQLFnKF1WnSOuco8\"'; sleep 3; [ \"$(osascript -e 'tell application \"Spotify\" to player state as text')\" = playing ]" }, + "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)", @@ -170,7 +178,7 @@ { "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", "playlist": "spotify:playlist:2uTpJVYQLFnKF1WnSOuco8", "name": "Christian EDM 2026", "fix": "spotify_play" } + { "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" } diff --git a/install.sh b/install.sh index c7729c3..8386d98 100644 --- a/install.sh +++ b/install.sh @@ -1,9 +1,14 @@ #!/bin/zsh -# Install or update the status panel watchdog on this Mac (NA Ingest) as a LaunchAgent. -# Re-run after changing status_panel.py, coremidi.py or config.json in this folder. +# Install or update the status panel watchdog on this Mac (the Ingest Mac) as a LaunchAgent. +# Re-run after changing any file in this folder. # -# zsh install.sh install / update and (re)start -# zsh install.sh --remove stop it and remove the LaunchAgent (files stay) +# zsh install.sh install / update and (re)start +# zsh install.sh --keep-config update the code but leave the live config.json as it is +# zsh install.sh --rollback put the previous code back (kept in prev/) and restart +# zsh install.sh --remove stop it and remove the LaunchAgent (files stay) +# +# The config is validated with the NEW code before anything is touched, so a stricter check can +# never leave the watchdog crash-looping. The previous code is kept in prev/ for --rollback. set -e SRC=${0:A:h} LABEL=org.waterschurch.status-panel @@ -11,23 +16,29 @@ LABEL=org.waterschurch.status-panel DEST="$HOME/Library/Application Support/status-panel" PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" DOMAIN="gui/$(id -u)" +CODE=(status_panel.py coremidi.py netgear.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json) +KEEP_CONFIG=0 +for arg in "$@"; do + case "$arg" in + --keep-config) KEEP_CONFIG=1 ;; + --remove|--rollback) ;; + *) echo "unknown option $arg" >&2; exit 2 ;; + esac +done -if [[ "$1" == "--remove" ]]; then +restart_agent() { launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true - rm -f "$PLIST" - echo "removed $LABEL (files left in $DEST)" - exit 0 -fi + # bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error". + for i in {1..50}; do launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done + for attempt in 1 2 3; do + launchctl bootstrap "$DOMAIN" "$PLIST" && break + [[ $attempt == 3 ]] && { echo "launchctl bootstrap failed" >&2; exit 1; } + sleep 1 + done +} -/usr/bin/python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$SRC/config.json" -mkdir -p "$DEST" -if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC/config.json" "$DEST/config.json"; then - cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)" -fi -cp "$SRC/status_panel.py" "$SRC/coremidi.py" "$SRC/netgear.py" "$SRC/config.json" "$SRC/atem_watch.js" "$DEST/" -[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/" - -cat > "$PLIST" < "$PLIST" < @@ -43,18 +54,81 @@ cat > "$PLIST" <RunAtLoad KeepAlive ThrottleInterval10 + ExitTimeOut$1 StandardErrorPath$HOME/Library/Logs/status-panel.err.log EOF +} -launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true -# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error". -for i in {1..50}; do launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done -for attempt in 1 2 3; do - launchctl bootstrap "$DOMAIN" "$PLIST" && break - [[ $attempt == 3 ]] && { echo "launchctl bootstrap failed" >&2; exit 1; } - sleep 1 -done -echo "installed $LABEL -> $DEST" +wait_for_start() { # $1 = install time (unix): wait for a start stamp newer than that + for i in {1..60}; do + if [[ -f "$DEST/started.json" ]] && /usr/bin/python3 - "$DEST/started.json" "$1" <<'PY' 2>/dev/null +import json, sys +d = json.load(open(sys.argv[1])) +sys.exit(0 if d.get("started_at", 0) >= float(sys.argv[2]) else 1) +PY + then + echo "running (code $(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version"))' "$DEST/started.json"))" + return 0 + fi + sleep 0.5 + done + echo "WARNING: no start stamp after 30 s - check ~/Library/Logs/status-panel.log and status-panel.err.log" >&2 + return 1 +} + +if [[ "$1" == "--remove" ]]; then + launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true + rm -f "$PLIST" + echo "removed $LABEL (files left in $DEST)" + exit 0 +fi + +if [[ "$1" == "--rollback" ]]; then + [[ -d "$DEST/prev" ]] || { echo "nothing to roll back to ($DEST/prev is missing)" >&2; exit 1; } + for f in "$DEST"/prev/*(N); do cp "$f" "$DEST/"; done + /usr/bin/python3 "$DEST/status_panel.py" --check --config "$DEST/config.json" + T=$(date +%s) + write_plist "$(/usr/bin/python3 "$DEST/status_panel.py" --exit-timeout --config "$DEST/config.json")" + restart_agent + echo "rolled back to the previous code (version $(/usr/bin/python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import status_panel; print(status_panel.VERSION)' "$DEST"))" + wait_for_start "$T" || true + exit 0 +fi + +# 1. The config that will run, checked with the code that will run it. +mkdir -p "$DEST" +if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then + CFG="$DEST/config.json" +else + CFG="$SRC/config.json" +fi +/usr/bin/python3 "$SRC/status_panel.py" --check --config "$CFG" +EXIT_TIMEOUT=$(/usr/bin/python3 "$SRC/status_panel.py" --exit-timeout --config "$CFG") + +# 2. Keep the code that runs now, for --rollback. +mkdir -p "$DEST/prev" +for f in $CODE; do [[ -f "$DEST/$f" ]] && cp "$DEST/$f" "$DEST/prev/"; done + +# 3. Files. The live config is backed up when it changes; --keep-config leaves it alone. +if [[ $KEEP_CONFIG == 0 ]]; then + if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC/config.json" "$DEST/config.json"; then + cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)" + echo "config.json replaced (the old one is kept as config.json.bak-...; use --keep-config to leave it)" + fi + cp "$SRC/config.json" "$DEST/" +fi +for f in $CODE; do [[ -f "$SRC/$f" ]] && cp "$SRC/$f" "$DEST/"; done +[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/" +if [[ -d "$SRC/companion-objects" ]]; then + rm -rf "$DEST/companion-objects" && cp -R "$SRC/companion-objects" "$DEST/" +fi + +# 4. The LaunchAgent, with the exit timeout this config needs (a running fix finishes before a restart). +T=$(date +%s) +write_plist "$EXIT_TIMEOUT" +restart_agent +echo "installed $LABEL -> $DEST (exit timeout ${EXIT_TIMEOUT}s)" echo "log: ~/Library/Logs/status-panel.log" +wait_for_start "$T" || true diff --git a/make_page.py b/make_page.py index 4f3691f..7d72b13 100644 --- a/make_page.py +++ b/make_page.py @@ -17,6 +17,7 @@ Buttons write commands into sp_cmd, which status_panel.py reads: Restart page force (hold 2 s: restart that thing even when it looks fine) python3 make_page.py -> status-page.companionconfig and restart-page.companionconfig + python3 make_page.py --out DIR -> the same files in DIR (the data dir on the Ingest Mac) """ import gzip import hashlib @@ -28,12 +29,25 @@ HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) from status_panel import RESTARTABLE, load_config # noqa: E402 +# Defaults; "companion", "page_grid", "page_stale_seconds" and "page_hold_ms" in config.json override them. EXPORT_VERSION = 12 # Companion 5.0.7's export format COMPANION_BUILD = "5.0.7+9763-stable-cec2f88e6f" GRID = {"minColumn": 0, "maxColumn": 8, "minRow": 0, "maxRow": 5} STALE_SECONDS = 15 PURPLE = 0x6A1B9A HOLD_MS = 2000 +# Every key this tool makes carries one of these in its notes; push_page.py overwrites a page only +# when every key on it (besides Companion's own page keys) does. +MARKERS = ("STATUS tile", "RESTART key", "STATUS jump key", "RESTART back key", "STATUS nav key") + + +def settings(cfg): + """The page-building numbers, from config.json where set.""" + comp = cfg.get("companion", {}) + return {"export_version": comp.get("export_version", EXPORT_VERSION), "build": comp.get("build", COMPANION_BUILD), + "grid": cfg.get("page_grid", GRID), "stale": cfg.get("page_stale_seconds", STALE_SECONDS), + "hold_ms": cfg.get("page_hold_ms", HOLD_MS), "flash": cfg.get("variables", {}).get("flash", "flash_tick"), + "ingest": cfg.get("site", {}).get("ingest_name", "NA Ingest")} ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" @@ -48,9 +62,10 @@ def prop(value, expression=False): return {"value": value, "isExpression": expression} -def layers(tid, tile, names, stale_text): - state, hb = names["state"], names["heartbeat"] - stale = "$(internal:time_unix) - $(custom:%s) > %d" % (hb, STALE_SECONDS) +def layers(tid, tile, names, stale_text, opts=None): + opts = opts or settings({"variables": names}) + state, hb, flash = names["state"], names["heartbeat"], opts["flash"] + stale = "$(internal:time_unix) - $(custom:%s) > %d" % (hb, opts["stale"]) def pick(on_key, off_key): return "jsonpath($(custom:%s), '$.%s.%s')" % (state, tid, on_key), \ @@ -58,8 +73,8 @@ def layers(tid, tile, names, stale_text): b_on, b_off = pick("b", "b2") f_on, f_off = pick("f", "f2") - bg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, PURPLE, b_on, b_off) - fg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, 0xFFFFFF, f_on, f_off) + bg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, PURPLE, flash, b_on, b_off) + fg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, 0xFFFFFF, flash, f_on, f_off) text = "%s ? %s : jsonpath($(custom:%s), '$.%s.x')" % (stale, stale_text, state, tid) # The watchdog picks a size per key so words never break mid-word. size = "%s ? 20 : jsonpath($(custom:%s), '$.%s.s')" % (stale, state, tid) @@ -102,8 +117,8 @@ def set_page_action(tid, role, page): "options": {"surfaceId": prop("self"), "page": prop(page)}, "children": {}} -def static_button(tid, text, bg, fg, page, note): - """A plain key (no watchdog state): tap goes to `page`.""" +def static_button(tid, text, bg, fg, page, note, marker): + """A plain key (no watchdog state): tap goes to `page`. `marker` names it as ours in its notes.""" geometry = {"enabled": prop(True), "opacity": prop(100), "x": prop(0), "y": prop(0), "width": prop(100), "height": prop(100), "rotation": prop(0)} layers = [ @@ -120,7 +135,7 @@ def static_button(tid, text, bg, fg, page, note): "type": "button-layered", "style": {"layers": layers}, "options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, - "canModifyStyleInApis": False, "notes": note}, + "canModifyStyleInApis": False, "notes": "%s - %s" % (marker, note)}, "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [set_page_action(tid, "go", page)]}, "options": {"runWhileHeld": []}}}, @@ -129,27 +144,29 @@ def static_button(tid, text, bg, fg, page, note): def restart_key(key, cfg): - """A Restart page key: drawn by the watchdog like a tile; hold 2 s = "force ".""" - names = cfg["variables"] + """A Restart page key: drawn by the watchdog like a tile; hold = "force ".""" + names, opts = cfg["variables"], settings(cfg) + hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0) stale_text = "'%s\\n?'" % key["label"] return { "type": "button-layered", - "style": {"layers": layers(key["id"], key, names, stale_text)}, + "style": {"layers": layers(key["id"], key, names, stale_text, opts)}, "options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, - "notes": "RESTART key '%s' - hold 2 s: restart it even when the status page says it is fine " - "(status_panel.py on NA Ingest runs fix '%s')." % (key["id"], key["fix"])}, + "notes": "RESTART key '%s' - hold %s s: restart it even when the status page says it is fine " + "(status_panel.py on %s runs fix '%s')." % (key["id"], secs, opts["ingest"], key["fix"])}, "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [], - str(HOLD_MS): [command_action(key["id"], "hold", names["command"], + str(hold_ms): [command_action(key["id"], "hold", names["command"], "force %s $(internal:time_unix)" % key["id"])]}, - "options": {"runWhileHeld": [HOLD_MS]}}}, + "options": {"runWhileHeld": [hold_ms]}}}, "localVariables": [], } def button(tid, tile, cfg): - names = cfg["variables"] + names, opts = cfg["variables"], settings(cfg) + hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0) kind = tile.get("kind") label = tile["label"] if kind == "master": @@ -168,24 +185,26 @@ def button(tid, tile, cfg): for c in tile.get("checks", [])) hold = ("restart " + tid) if fixable or tile.get("hold_always") or kind == "network" else None if kind == "network": - note = "Press: stop the flashing. Hold 2 s: clear a network blip (it also clears itself after 5 minutes)." + minutes = cfg.get("network", {}).get("remember_seconds", 300) // 60 + note = ("Press: stop the flashing. Hold %s s: clear a network blip (it also clears itself after %d minutes)." + % (secs, minutes)) elif tile.get("hold_always"): - note = "Press: acknowledge. Hold 2 s: %s." % cfg["fixes"][tile["hold_always"]]["label"] + note = "Press: acknowledge. Hold %s s: %s." % (secs, cfg["fixes"][tile["hold_always"]]["label"]) else: - note = "Press: acknowledge." + (" Hold 2 s: fix what this button is failing on (nothing if it is green)." - if hold else "") + note = "Press: acknowledge." + (" Hold %s s: fix what this button is failing on (nothing if it is green)." + % secs if hold else "") action_sets = {"down": [], "up": [command_action(tid, "press", names["command"], press)]} run_while_held = [] if hold: # Stamped with Companion's clock, so a hold made while the watchdog was down is dropped later. - action_sets[str(HOLD_MS)] = [command_action(tid, "hold", names["command"], hold + " $(internal:time_unix)")] - run_while_held = [HOLD_MS] + action_sets[str(hold_ms)] = [command_action(tid, "hold", names["command"], hold + " $(internal:time_unix)")] + run_while_held = [hold_ms] return { "type": "button-layered", - "style": {"layers": layers(tid, tile, names, stale_text)}, + "style": {"layers": layers(tid, tile, names, stale_text, opts)}, "options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, - "notes": "STATUS tile '%s' - drawn by status_panel.py on NA Ingest. %s" % (tid, note)}, + "notes": "STATUS tile '%s' - drawn by status_panel.py on %s. %s" % (tid, opts["ingest"], note)}, "feedbacks": [], "steps": {"0": {"action_sets": action_sets, "options": {"runWhileHeld": run_while_held}}}, "localVariables": [], @@ -196,7 +215,18 @@ NAV_LOCATIONS = {"pageup": ("0", "0"), "pagenum": ("1", "0"), "pagedown": ("2", NAV_MARKER = "STATUS nav key" -def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1): +def nav_templates(cfg): + """Companion's own nav key designs for the version in use: companion-objects/nav_templates/.json + when that exists, else this folder's nav_templates.json (captured from 5.0.7).""" + version = ".".join(str(settings(cfg)["build"]).split("+")[0].split(".")[:2]) + for path in (os.path.join(HERE, "companion-objects", "nav_templates", version + ".json"), + os.path.join(HERE, "nav_templates.json")): + if os.path.exists(path): + return json.load(open(path)) + raise FileNotFoundError("no nav key templates for Companion %s" % version) + + +def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None): """Companion's own UP / page number / DOWN key, coloured like the ALL button. For the page number key, status_page adds: tap = go to tap_page (on release, @@ -204,12 +234,13 @@ def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1): status page. The tap names the page outright: the converted key's "startup" means the current page on surfaces set to "use last page". """ - templates = json.load(open(os.path.join(HERE, "nav_templates.json"))) - key = json.loads(json.dumps(templates[kind])) - stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], STALE_SECONDS) + cfg = cfg or {"variables": names} + opts = settings(cfg) + key = json.loads(json.dumps(nav_templates(cfg)[kind])) + stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], opts["stale"]) pick = lambda field: "jsonpath($(custom:%s), '$.all.%s')" % (names["state"], field) - bg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, PURPLE, pick("b"), pick("b2")) - fg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, 0xFFFFFF, pick("f"), pick("f2")) + bg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, PURPLE, opts["flash"], pick("b"), pick("b2")) + fg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, 0xFFFFFF, opts["flash"], pick("f"), pick("f2")) def paint(layers): for layer in layers: @@ -240,7 +271,7 @@ def nav_controls(cfg): coloured = cfg.get("nav_keys", list(NAV_LOCATIONS)) # which nav keys follow the ALL button for kind, (r, c) in NAV_LOCATIONS.items(): controls.setdefault(r, {})[c] = (nav_key(kind, cfg["variables"], cfg["page"], cfg.get("nav_hold_ms", 1000), - cfg.get("nav_tap_page", 1)) + cfg.get("nav_tap_page", 1), cfg) if kind in coloured else {"type": kind}) return controls @@ -252,12 +283,13 @@ def place(controls, pos, control): controls.setdefault(r, {})[c] = control -def page_export(name, number, controls): +def page_export(name, number, controls, cfg=None): + opts = settings(cfg or {}) return { - "version": EXPORT_VERSION, + "version": opts["export_version"], "type": "page", - "companionBuild": COMPANION_BUILD, - "page": {"id": stable_id("page", name), "name": name, "controls": controls, "gridSize": GRID}, + "companionBuild": opts["build"], + "page": {"id": stable_id("page", name), "name": name, "controls": controls, "gridSize": opts["grid"]}, "instances": {}, "connectionCollections": [], "oldPageNumber": number, @@ -274,18 +306,20 @@ def build(cfg): rp = cfg.get("restart_page") if rp: place(controls, rp["jump_pos"], static_button("restart_jump", "RESTART\\nPAGE", 0x37474F, 0xFFFFFF, - rp["page"], "Tap: open the Restart page (page %d)." % rp["page"])) - return page_export(cfg["page_name"], cfg["page"], controls) + rp["page"], "Tap: open the Restart page (page %d)." % rp["page"], + "STATUS jump key")) + return page_export(cfg["page_name"], cfg["page"], controls, cfg) def build_restart(cfg): rp = cfg["restart_page"] controls = nav_controls(cfg) place(controls, rp["back_pos"], static_button("restart_back", "STATUS\\nPAGE", 0x37474F, 0xFFFFFF, cfg["page"], - "Tap: back to the status page (page %d)." % cfg["page"])) + "Tap: back to the status page (page %d)." % cfg["page"], + "RESTART back key")) for key in rp["keys"]: place(controls, key["pos"], restart_key(key, cfg)) - return page_export(rp["page_name"], rp["page"], controls) + return page_export(rp["page_name"], rp["page"], controls, cfg) def write(path, export): @@ -293,13 +327,20 @@ def write(path, export): f.write(json.dumps(export, indent=1).encode()) -def main(): - cfg = load_config(os.path.join(HERE, "config.json")) - out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "status-page.companionconfig") +def main(argv=None): + import argparse + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--config", default=os.path.join(HERE, "config.json")) + ap.add_argument("--out", help="folder for the page files (default: this folder)") + ap.add_argument("status_file", nargs="?", help="write only the status page, to this file") + args = ap.parse_args(argv) + cfg = load_config(args.config) + folder = args.out or HERE + out = args.status_file or os.path.join(folder, "status-page.companionconfig") write(out, build(cfg)) print("wrote %s (%d buttons)" % (out, sum(1 for t in cfg["tiles"] if "pos" in t))) - if cfg.get("restart_page") and len(sys.argv) <= 1: - out = os.path.join(HERE, "restart-page.companionconfig") + if cfg.get("restart_page") and not args.status_file: + out = os.path.join(folder, "restart-page.companionconfig") write(out, build_restart(cfg)) print("wrote %s (%d restart keys)" % (out, len(cfg["restart_page"]["keys"]))) diff --git a/nav_keys.py b/nav_keys.py index a885f72..24c6057 100644 --- a/nav_keys.py +++ b/nav_keys.py @@ -78,8 +78,8 @@ def find_nav_keys(host, port, status_page): def main(): cfg = json.load(open(os.path.join(HERE, "config.json"))) ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - ap.add_argument("--host", default="na-ingest.local") - ap.add_argument("--port", type=int, default=8000) + ap.add_argument("--host", default=cfg.get("site", {}).get("ingest_hostname") or cfg["companion"].get("host", "127.0.0.1")) + ap.add_argument("--port", type=int, default=cfg["companion"].get("port", 8000)) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--revert", action="store_true") args = ap.parse_args() diff --git a/netgear.py b/netgear.py index d253faa..9a65950 100644 --- a/netgear.py +++ b/netgear.py @@ -18,10 +18,23 @@ import subprocess import sys import telnetlib # stdlib in the macOS python3 (3.9) -SWITCHES = ["10.66.1.100", "10.66.1.102"] +SWITCHES = ["10.66.1.100", "10.66.1.102"] # the CLI's default; the watchdog passes "dante_switches" from config KEYCHAIN_SERVICE = "netgear-status-panel" +def config_switches(): + """"dante_switches" from this folder's config.json, for the command line (the watchdog passes its own).""" + import json, os + try: + with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")) as f: + cfg = json.load(f) + global MAX_SHARED + MAX_SHARED = cfg.get("dante_max_shared", MAX_SHARED) + return cfg.get("dante_switches") or SWITCHES + except (OSError, ValueError): + return SWITCHES + + def credentials(service=KEYCHAIN_SERVICE): attrs = subprocess.run(["/usr/bin/security", "find-generic-password", "-s", service], capture_output=True, text=True).stdout @@ -71,7 +84,7 @@ class Switch: def lookup(macs): user, password = credentials() wanted = {m.lower(): m for m in macs} - for host in SWITCHES: + for host in config_switches(): sw = Switch(host, user, password) try: model = [l.strip() for l in sw.run("show version").splitlines() diff --git a/push_page.py b/push_page.py index e8e256b..d5d8463 100644 --- a/push_page.py +++ b/push_page.py @@ -5,9 +5,10 @@ Does exactly what Import/Export -> Import -> "import into page N" does, over the same internal API the Companion web UI uses (tRPC over a WebSocket at /trpc): upload the file in chunks, check its SHA-1, then import it as a single page. -Safety: it looks at what is on the target page, not just its name. It refuses unless the -page holds nothing but page up / page number / page down keys, or is already the page this -file builds (same name). A page named "PAGE" can still hold real buttons (pages 22 and 97 do). +Safety: it looks at what is on the target page, not just its name. It refuses unless every +key on the page (besides Companion's own page up / number / down keys) carries one of the +markers make_page.py writes into its notes, i.e. the page holds nothing a person made by hand. +A key someone added is named by its position, so it can be moved first. python3 push_page.py # the status page, from config.json python3 push_page.py --which restart # the Restart page @@ -29,6 +30,9 @@ import urllib.parse import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +from make_page import MARKERS # noqa: E402 + CHUNK = 256 * 1024 @@ -124,8 +128,10 @@ NAV_MARKER = "STATUS nav key" # our coloured page number key (make_page def fetch_page(host, port, page): - """The page as Companion exports it ({"page": {"name", "controls": {row: {col: control}}}}).""" - with urllib.request.urlopen("http://%s:%d/int/export/page/%d" % (host, port, page), timeout=10) as r: + """The page as Companion exports it ({"page": {"name", "controls": {row: {col: control}}}}), + without any connection secrets.""" + url = "http://%s:%d/int/export/page/%d?includeSecrets=false" % (host, port, page) + with urllib.request.urlopen(url, timeout=10) as r: raw = r.read() try: raw = gzip.decompress(raw) @@ -134,29 +140,36 @@ def fetch_page(host, port, page): return json.loads(raw) -def real_buttons(export): - """How many buttons a page holds besides its page up / number / down keys.""" +def foreign_buttons(export): + """[(row, col)] of every key on the page that neither Companion nor this tool made.""" page = export.get("page") or {} - n = 0 - for row in (page.get("controls") or {}).values(): - for ctl in (row or {}).values(): + found = [] + for r, row in sorted((page.get("controls") or {}).items(), key=lambda kv: int(kv[0])): + for c, ctl in sorted((row or {}).items(), key=lambda kv: int(kv[0])): if not ctl or ctl.get("type") in NAV_TYPES: continue - if NAV_MARKER in ((ctl.get("options") or {}).get("notes") or ""): + notes = (ctl.get("options") or {}).get("notes") or "" + if any(m in notes for m in MARKERS): continue - n += 1 - return n + found.append((int(r), int(c))) + return found -def safe_target(export, expected_name): - """(ok, reason). OK only for a page with nothing but nav keys, or the page this file builds.""" +def real_buttons(export): + """How many keys a page holds that this tool did not make.""" + return len(foreign_buttons(export)) + + +def safe_target(export, expected_name=None): + """(ok, reason). OK only when every key on the page is Companion's own page key or one of ours, + whatever the page is called: nothing a person made by hand is ever overwritten.""" name = ((export.get("page") or {}).get("name") or "") - if name == expected_name: - return True, "it is already %r" % name - n = real_buttons(export) - if n == 0: - return True, "it holds only page keys" - return False, "it is called %r and holds %d buttons" % (name, n) + foreign = foreign_buttons(export) + if not foreign: + return True, "it holds only page keys and keys made by this tool" + where = ", ".join("row %d col %d" % rc for rc in foreign[:6]) + (" ..." if len(foreign) > 6 else "") + return False, "it is called %r and holds %d key%s not made by this tool (%s) - move or delete them first" % ( + name, len(foreign), "" if len(foreign) == 1 else "s", where) def page_name(host, port, page): @@ -169,8 +182,8 @@ def main(): cfg = json.load(open(os.path.join(HERE, "config.json"))) ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--which", choices=("status", "restart"), default="status") - ap.add_argument("--host", default="na-ingest.local") - ap.add_argument("--port", type=int, default=8000) + ap.add_argument("--host", default=cfg.get("site", {}).get("ingest_hostname") or cfg["companion"].get("host", "127.0.0.1")) + ap.add_argument("--port", type=int, default=cfg["companion"].get("port", 8000)) ap.add_argument("--page", type=int) ap.add_argument("--file") ap.add_argument("--force", action="store_true", help="overwrite the page whatever is on it") diff --git a/restart-page.companionconfig b/restart-page.companionconfig index c16e5cd2796e8280c8d9a09ef6015a118420ed76..9a3311dc0d52ef75ae2a88ec6070063774fa294c 100644 GIT binary patch delta 3556 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 delta 3549 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 key text.", "Keys") +field("tiles[].checks[].count_all", "bool", False, "Count every watched Dante device on this key.", "Keys") +field("tiles[].checks[].channels", "int", 1, "Channels on the receiver.", "Keys", lo=1, hi=8) +field("tiles[].checks[].battery_err_bars", "int", 1, "Red at this many bars or fewer.", "Keys", advanced=True, lo=0, hi=5) +field("tiles[].checks[].battery_warn_bars", "int", 2, "Amber at this many bars or fewer.", "Keys", advanced=True, lo=0, hi=5) +field("tiles[].checks[].muted_is_error", "bool", False, "RF mute is red, not amber.", "Keys") +field("tiles[].checks[].host", "str", None, "The computer (an address for ping, a computer name for the rest).", "Keys") +field("tiles[].checks[].name", "str", None, "The name the key shows.", "Keys") +field("tiles[].checks[].msg", "str", None, "The words the key shows when it fails.", "Keys") +field("tiles[].checks[].failures", "int", 2, "Pings missed before NO PING.", "Keys", advanced=True, lo=1, hi=10) +field("tiles[].checks[].label", "str", None, "The launchd helper.", "Keys") +field("tiles[].checks[].match", "str", None, "The program (pgrep -f pattern).", "Keys") +field("tiles[].checks[].url", "url", None, "The web address to fetch.", "Keys") +field("tiles[].checks[].interval_seconds", "int", 30, "Fetch it this often.", "Keys", advanced=True, lo=5, hi=3600) +field("tiles[].checks[].path", "path", None, "The disk to watch.", "Keys") +field("tiles[].checks[].warn_gb", "int", 20, "Amber below this many GB free.", "Keys", advanced=True) +field("tiles[].checks[].err_gb", "int", 5, "Red below this many GB free.", "Keys", advanced=True) +field("tiles[].checks[].session", "str", None, "The network MIDI session.", "Keys") +field("tiles[].checks[].peer", "str", None, "The peer that must be in it.", "Keys") +field("tiles[].checks[].address", "str", None, "Where to reconnect the peer if it was never seen.", "Keys", advanced=True) +field("tiles[].checks[].domain", "str", None, "The macOS preference domain.", "Keys") +field("tiles[].checks[].key", "str", None, "The preference key.", "Keys") +field("tiles[].checks[].want", "str", None, "The value it must have.", "Keys") +field("tiles[].checks[].playlist", "str", None, "A playlist other than the site's (rare).", "Keys", advanced=True) +field("tiles[].checks[].instance", "str", None, "The Bonjour MIDI name.", "Keys") +field("tiles[].checks[].source", "str", None, "The MIDI source, or the Art-Net sender's address.", "Keys") +field("tiles[].checks[].max_silence_seconds", "int", None, "Amber/red after this much silence.", "Keys", lo=1, hi=600) +field("tiles[].checks[].cmd", "str", None, "The command run on that computer.", "Keys", advanced=True) +field("tiles[].checks[].shell", "enum", None, "sh (Macs) or cmd (PCs); blank = from the computer's kind.", "Keys", + advanced=True, options=["sh", "cmd"]) +field("tiles[].checks[].part", "enum", None, "Which part of the switcher.", "Keys", options=["audio", "camera"]) +field("tiles[].checks[].template", "str", None, "The builder this check was made with.", "Keys", advanced=True, apply="none") +field("tiles[].checks[].params", "dict", None, "The builder's fields.", "Keys", advanced=True, apply="none") + +# -- restart page -- +field("restart_page", "dict", None, "The Restart page; absent = this campus has none.", "Keys", apply="pages") +field("restart_page.page", "int", 20, "Its number in Companion.", "Keys", lo=1, apply="pages") +field("restart_page.page_name", "str", "20\\nRestart", "Its name in Companion.", "Keys", advanced=True, apply="pages") +field("restart_page.jump_pos", "list", None, "Where the 'go to Restart page' key sits on the Status page.", "Keys", + apply="pages") +field("restart_page.back_pos", "list", None, "Where the 'back to Status' key sits on the Restart page.", "Keys", + apply="pages") +field("restart_page.keys", "list", [], "The restart keys.", "Keys", apply="pages") +field("restart_page.keys[].id", "id", None, "", "Keys", advanced=True, required=True, regex=r"[A-Za-z0-9_]+", apply="pages") +field("restart_page.keys[].label", "str", None, "", "Keys", required=True, apply="pages") +field("restart_page.keys[].pos", "list", None, "[row, column].", "Keys", required=True, apply="pages") +field("restart_page.keys[].fix", "str", None, "What holding it runs.", "Keys", required=True) +field("restart_page.keys[].watch", "list", [], "Keep flashing HOLD TO FIX while these fixes' checks or " + "'conn: