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 c16e5cd..9a3311d 100644 Binary files a/restart-page.companionconfig and b/restart-page.companionconfig differ diff --git a/schema.py b/schema.py new file mode 100644 index 0000000..b0e2826 --- /dev/null +++ b/schema.py @@ -0,0 +1,658 @@ +"""The registry of every setting the status panel has. + +One record per config key (path, type, default, plain-words help, which screen of the settings +page owns it, whether it sits under Advanced), the check types with their fields, the fix kinds, +the words a key can show, and the rules a config must pass. The settings page draws its forms +from this file; tests/test_schema.py keeps it in step with the code (every key the watchdog reads +must be declared here, every default in the code must equal the one here). + +Paths: "companion.host", "tiles[].checks[].conn" (a list element), "fixes.*.run" (a map entry). +""" +import copy +import re + +import status_panel as sp + +# --- Field records ----------------------------------------------------------- +# type: str | int | float | bool | list | dict | enum | path | ip | mac | url | hhmm | days | id +# apply: what changes when the value does: "restart" (the watchdog restarts), "pages" (the Companion +# pages are rebuilt and re-imported when the built page differs), "none" (page-only data), "settings". + +F = {} + + +def field(path, type, default=None, help="", screen="", advanced=False, secret=False, apply="restart", + required=False, options=None, lo=None, hi=None, regex=None, example=None, shows=None): + F[path] = {"path": path, "type": type, "default": default, "help": help, "screen": screen, + "advanced": advanced, "secret": secret, "apply": apply, "required": required, + "options": options, "lo": lo, "hi": hi, "regex": regex, "example": example, "shows": shows} + + +# -- site -- +field("site", "dict", {}, "This campus: its name, this Mac, and when changes must wait.", "Site") +field("site.id", "id", "na", "Short name used for folders and files.", "Site", advanced=True, regex=r"[a-z0-9_-]+") +field("site.name", "str", "", "The campus, as people say it.", "Site", example="North Attleboro", apply="none") +field("site.ingest_name", "str", "NA Ingest", "What this Mac is called in logs, notes and alerts.", "Site", apply="pages") +field("site.ingest_hostname", "str", "", "This Mac's network name, used in the page address and by the page tools.", + "Site", example="na-ingest.local", apply="none") +field("site.ingest_ips", "list", [], "This Mac's addresses, for the PCs' firewall allow-lists.", "Site", apply="none") +field("site.contacts", "str", "", "Printed on the cheat sheet: who to call.", "Site", apply="none") +field("site.alert_prefix", "str", "", "The first words of an alert message; blank = this Mac's name.", "Site", advanced=True) +field("site.live_services", "list", [], "Clock windows during which changes to the panel wait (a recording " + "HyperDeck always counts as live, whatever the clock says).", "Schedules") +field("site.live_services[].days", "days", None, "Days of the week.", "Schedules", required=True) +field("site.live_services[].start", "hhmm", None, "Start time.", "Schedules", required=True) +field("site.live_services[].end", "hhmm", None, "End time; earlier than the start means it runs past midnight.", + "Schedules", required=True) +field("site.editing_code", "str", "", "The code that switches Editing on. Kept here, in the repo, so everyone " + "with the repo has it.", "Site", advanced=True, secret=True, apply="none") +field("site.settings_port", "int", 8020, "Where the settings page listens.", "Site", advanced=True, lo=1024, hi=65535, + apply="settings") +field("site.dvs_helper", "path", "/usr/local/sbin/status-panel-dvs-start", "The DVS helper on each Mac that runs " + "Dante Virtual Soundcard.", "Site", advanced=True) +field("site.solo_dir", "path", "~/Library/Application Support/status-panel", "The Pro Tools helper's folder on " + "the Studio B Mac.", "Site", advanced=True) + +# -- companion -- +field("companion", "dict", {}, "Where Companion answers.", "Companion", required=True) +field("companion.host", "str", "127.0.0.1", "Companion's address from this Mac.", "Companion", advanced=True) +field("companion.port", "int", 8000, "Companion's HTTP port.", "Companion", advanced=True, lo=1, hi=65535) +field("companion.db", "path", None, "Companion's settings database; blank = the newest one found.", "Companion", + advanced=True) +field("companion.export_version", "int", sp.__dict__.get("EXPORT_VERSION", 12), "Companion's page export format " + "number (changes with a Companion upgrade).", "Companion", advanced=True, apply="pages") +field("companion.build", "str", "5.0.7+9763-stable-cec2f88e6f", "The Companion build the pages are written for.", + "Companion", advanced=True, apply="pages") +field("page", "int", 19, "The Status page's number in Companion.", "Keys", lo=1, apply="pages") +field("page_name", "str", "19\\nStatus", "The Status page's name in Companion.", "Keys", advanced=True, apply="pages") +field("nav_keys", "list", ["pagenum"], "Which of Companion's page keys (page up, page number, page down) are " + "coloured like the ALL key on every page.", "Companion", advanced=True, options=["pageup", "pagenum", "pagedown"], + apply="pages") +field("nav_hold_ms", "int", 1000, "Holding the page-number key this long jumps to the Status page.", "Companion", + advanced=True, lo=200, hi=5000, apply="pages") +field("nav_tap_page", "int", 1, "Tapping the page-number key goes to this page.", "Companion", advanced=True, lo=1, + apply="pages") +field("variables", "dict", {}, "The custom variables the panel uses inside Companion.", "Companion", required=True) +field("variables.state", "str", "sp_state", "The variable the keys are drawn from.", "Companion", advanced=True, + regex=r"[A-Za-z0-9_]+", apply="pages") +field("variables.heartbeat", "str", "sp_hb", "The variable that says the watchdog is alive.", "Companion", + advanced=True, regex=r"[A-Za-z0-9_]+", apply="pages") +field("variables.command", "str", "sp_cmd", "The variable key presses are written to.", "Companion", advanced=True, + regex=r"[A-Za-z0-9_]+", apply="pages") +field("variables.flash", "str", "flash_tick", "The blink clock. Renaming it means renaming it inside the trigger " + "in Companion too.", "Companion", advanced=True, regex=r"[A-Za-z0-9_]+", apply="pages") +field("page_grid", "dict", {"minColumn": 0, "maxColumn": 8, "minRow": 0, "maxRow": 5}, "The export grid " + "(the deck is the 8x4 inside it).", "Companion", advanced=True, apply="pages") +field("page_stale_seconds", "int", 15, "Keys turn purple when the watchdog has been silent this long.", + "Companion", advanced=True, lo=5, hi=120, apply="pages") +field("page_hold_ms", "int", 2000, "How long a key is held to run its fix.", "Schedules", advanced=True, lo=500, + hi=5000, apply="pages", shows="HOLD 2s") + +# -- schedules and timing -- +field("service_windows", "list", [{"days": list(sp.DAYS), "start": "00:00", "end": "23:59"}], + "When alarms are armed: always (the ALARMS key silences them until midnight).", "Schedules", advanced=True) +field("service_windows[].days", "days", None, "", "Schedules", required=True) +field("service_windows[].start", "hhmm", None, "", "Schedules", required=True) +field("service_windows[].end", "hhmm", None, "", "Schedules", required=True) +field("debounce_seconds", "int", 3, "A fault must last this long before it shows (one bad reading is not an " + "alarm).", "Schedules", advanced=True, lo=0, hi=60) +field("flap", "dict", {}, "Repeated faults show amber 'xN'.", "Schedules", advanced=True) +field("flap.count", "int", 3, "This many faults...", "Schedules", advanced=True, lo=1, hi=20) +field("flap.window_seconds", "int", 600, "...within this many seconds.", "Schedules", advanced=True, lo=60, hi=86400) +field("timing", "dict", {}, "Timing numbers.", "Schedules", advanced=True) +field("timing.fix_cooldown_seconds", "int", 60, "The same fix never re-runs within this.", "Schedules", advanced=True, + lo=10, hi=600, shows="WAIT 1 MIN") +field("timing.alert_after_seconds", "int", 30, "Send the alert message when Companion has not answered for this " + "long.", "Schedules", advanced=True, lo=5, hi=600) +field("alert_webhook", "url", "", "Where alert messages go (a Slack-style web hook); blank = off.", "Site", + secret=True) +field("auto_fix", "dict", {}, "When the panel may fix things by itself.", "Schedules") +field("auto_fix.windows", "list", [], "When auto-fix may act.", "Schedules") +field("auto_fix.windows[].days", "days", None, "", "Schedules", required=True) +field("auto_fix.windows[].start", "hhmm", None, "", "Schedules", required=True) +field("auto_fix.windows[].end", "hhmm", None, "", "Schedules", required=True) +field("auto_fix.after_seconds", "int", 20, "A fault must last this long before auto-fix acts.", "Schedules", + advanced=True, lo=5, hi=600) +field("auto_fix.max_tries", "int", 2, "Tries per fault, then it is yours to hold.", "Schedules", advanced=True, + lo=1, hi=10) +field("auto_fix.retry_seconds", "int", 90, "Between tries.", "Schedules", advanced=True, lo=30, hi=3600) +field("auto_fix.pause_after_network_seconds", "int", 60, "Wait this long after a network blip.", "Schedules", + advanced=True, lo=0, hi=3600) +field("auto_fix.forget_seconds", "int", 600, "Forget a fault's tries once it has been gone this long.", + "Schedules", advanced=True, lo=60, hi=86400) +field("auto_fix.restart_connections", "bool", False, "Also reconnect Companion connections by itself.", "Schedules") +field("auto_fix.fixes", "list", [], "The fixes auto-fix may run (gentle ones only).", "Schedules") +field("auto_fix.log_dir", "path", "~/Desktop/Auto-Fix Logs", "One file per day of everything auto-fix does.", + "Schedules", advanced=True) +field("auto_fix.keep_months", "int", 1, "Daily log files are deleted after this many months.", "Schedules", + advanced=True, lo=1, hi=12) + +# -- network and Dante -- +field("network", "dict", {}, "The NET key.", "Network & Dante") +field("network.interface", "str", "en0", "The network port to watch for link.", "Network & Dante") +field("network.gateway", "ip", "", "The router to ping; blank = off.", "Network & Dante") +field("network.burst_connections", "int", 4, "A blip is this many connections...", "Network & Dante", advanced=True, + lo=1, hi=50) +field("network.burst_groups", "int", 3, "...from this many groups...", "Network & Dante", advanced=True, lo=1, hi=20) +field("network.burst_window_seconds", "int", 10, "...dropping within this many seconds.", "Network & Dante", + advanced=True, lo=1, hi=300) +field("network.remember_seconds", "int", 300, "NET shows BLIP this long after.", "Network & Dante", advanced=True, + lo=10, hi=3600, apply="pages") +field("ssh_key", "path", "~/.ssh/id_status_panel", "The login key this Mac uses for the other computers.", "Site", + advanced=True) +field("dante_switches", "list", [], "The Netgear switches on the Dante network.", "Network & Dante") +field("dante_map_seconds", "int", 600, "Survey the switches this often.", "Network & Dante", advanced=True, lo=60, + hi=86400) +field("dante_max_shared", "int", 2, "Never power-cycle a port carrying more than this many devices (an uplink).", + "Network & Dante", advanced=True, lo=1, hi=10) + +# -- Spotify and ATEM -- +field("spotify", "dict", {}, "The service playlist.", "Spotify") +field("spotify.playlist", "str", "", "The playlist Spotify must be playing.", "Spotify", regex=r"spotify:playlist:[A-Za-z0-9]+", + required=True) +field("spotify.name", "str", "", "The playlist's name, as the keys and the key guide say it.", "Spotify") +field("spotify.open_wait", "int", 8, "Seconds to wait after opening Spotify.", "Spotify", advanced=True, lo=1, hi=60) +field("spotify.play_wait", "int", 3, "Seconds to wait before checking it plays.", "Spotify", advanced=True, lo=1, hi=60) +field("atem", "dict", {}, "The switcher and its saved file.", "ATEM") +field("atem.host", "ip", "", "The switcher's address.", "ATEM", required=True) +field("atem.node", "path", "/Applications/Companion [0-9]*.app/Contents/Resources/node-runtimes/node22/bin/node", + "Companion's node (pattern; the newest match is used).", "ATEM", advanced=True) +field("atem.library", "path", "~/Library/Application Support/companion/modules/bmd-atem-*/node_modules/atem-connection", + "Companion's ATEM library (pattern).", "ATEM", advanced=True) +field("atem.reference", "path", "atem-reference.xml", "The saved switcher file to keep in line with.", "ATEM") +field("atem.compare", "dict", None, "What to keep in line: audio fields and camera settings; blank = everything.", + "ATEM", advanced=True) +field("atem.source_folder", "path", "", "Where ATEM Software Control saves its files (for 'pick the newest').", + "ATEM", apply="none") + +# -- hosts -- +field("hosts", "dict", {}, "The computers this Mac logs in to.", "Computers") +field("hosts.*.ssh", "str", None, "How this Mac logs in: user@address.", "Computers", regex=r"[^@\s]+@[^@\s]+") +field("hosts.*.mac", "mac", None, "The PC's hardware address, to wake it when it is off.", "Computers") +field("hosts.*.wake_broadcast", "ip", None, "The broadcast address the wake-up goes to.", "Computers", advanced=True) +field("hosts.*.name", "str", None, "The computer, as people say it.", "Computers", apply="none") +field("hosts.*.os", "enum", "mac", "Mac or Windows (a PC's checks are written for cmd.exe).", "Computers", + options=["mac", "windows", "other"]) +field("hosts.*.role", "str", None, "What it is for.", "Computers", apply="none") +field("hosts.*.notes", "str", None, "Notes for operators.", "Computers", apply="none") +field("hosts.*.pingable", "bool", True, "Off for a PC whose firewall blocks ping.", "Computers", apply="none") +field("hosts.*.self", "bool", False, "This Mac itself.", "Computers", apply="none") +field("hosts.*.runs", "list", [], "What runs on it (programs, tasks, services, helpers).", "Computers", apply="none") +field("hosts.*.networks", "list", [], "Its network ports and what each is for (LAN, Dante, switch management).", + "Computers", advanced=True, apply="none") +field("hosts.*.manual_done", "dict", {}, "Set-up steps ticked by hand (who, when).", "Computers", apply="none") +field("gear", "dict", {}, "Devices that are not computers: where each one is.", "Computers", apply="none") +field("gear.*.location", "str", None, "Where it is in the building.", "Computers", apply="none") +field("gear.*.notes", "str", None, "Notes for operators.", "Computers", apply="none") + +# -- fixes -- +field("fixes", "dict", {}, "Everything a hold can run.", "Fixes") +field("fixes.*.kind", "enum", "ssh", "How it is built.", "Fixes", options=list(sp.FIX_KINDS)) +field("fixes.*.host", "str", None, "The computer it runs on (ssh fixes).", "Fixes") +field("fixes.*.run", "str", None, "The command (zsh on Macs, cmd.exe on PCs).", "Fixes", advanced=True) +field("fixes.*.timeout", "int", 60, "Give up after this many seconds.", "Fixes", advanced=True, lo=5, hi=600) +field("fixes.*.short", "str", None, "The word the key shows while it runs (FIXING ...).", "Fixes", apply="pages") +field("fixes.*.label", "str", None, "Plain words: what it does.", "Fixes", required=True, apply="pages") +field("fixes.*.reconnect", "bool", False, "Reconnect the Companion connection afterwards.", "Fixes") +field("fixes.*.conn", "str", None, "The Companion connection it reconnects.", "Fixes") +field("fixes.*.restart_ok", "bool", False, "May close a running program (and still run by itself).", "Fixes") +field("fixes.*.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.*.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.", + "Fixes", advanced=True) +field("fixes.*.switch", "ip", None, "The PoE switch.", "Fixes") +field("fixes.*.port", "str", None, "The PoE port (e.g. 0/14).", "Fixes", regex=r"\d+/\d+") +field("fixes.*.mac", "mac", None, "The device expected on that port.", "Fixes") +field("fixes.*.playlist", "str", None, "A playlist other than the site's (rare).", "Fixes", advanced=True) +field("fixes.*.template", "str", None, "The builder this fix was made with.", "Fixes", advanced=True, apply="none") +field("fixes.*.params", "dict", None, "The builder's fields.", "Fixes", advanced=True, apply="none") + +# -- tiles (keys) -- +field("tiles", "list", [], "The keys on the Status page.", "Keys", required=True) +field("tiles[].id", "id", None, "Used inside Companion.", "Keys", advanced=True, required=True, regex=r"[A-Za-z0-9_]+", + apply="pages") +field("tiles[].label", "str", None, "The first line on the key.", "Keys", required=True, apply="pages") +field("tiles[].pos", "list", None, "[row, column]; absent = no key of its own (shown inside a group).", "Keys", + apply="pages") +field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARMS), network (NET).", "Keys", + advanced=True, options=["master", "service", "network"], apply="pages") +field("tiles[].members", "list", None, "A group: the keys it sums up.", "Keys") +field("tiles[].checks", "list", [], "What the key watches.", "Keys") +field("tiles[].hold_always", "str", None, "A fix a hold runs even when the key is green.", "Keys", apply="pages") +field("tiles[].checks[].type", "enum", None, "What kind of check.", "Keys", required=True) +field("tiles[].checks[].conn", "str", None, "The Companion connection.", "Keys") +field("tiles[].checks[].fix", "str", None, "What holding the key runs for this check (a fix, or a table per " + "sub-check).", "Keys") +field("tiles[].checks[].tag", "str", None, "Names the device when one key covers several.", "Keys") +field("tiles[].checks[].service_only", "bool", False, "Legacy: only during a service (has no effect while alarms " + "are armed all day).", "Keys", advanced=True) +field("tiles[].checks[].debounce_seconds", "int", None, "Wait before this check's fault shows.", "Keys", advanced=True, + lo=0, hi=60) +field("tiles[].checks[].ignore_warning", "bool", False, "Ignore Companion's yellow warning state.", "Keys") +field("tiles[].checks[].optional", "bool", False, "A HyperDeck that may have no disk (grey, no alarm).", "Keys") +field("tiles[].checks[].disk_service_only", "bool", True, "NO DISK only during a service.", "Keys", advanced=True) +field("tiles[].checks[].full_minutes", "int", 5, "FULL below this many minutes left.", "Keys", advanced=True) +field("tiles[].checks[].low_minutes", "int", 30, "LOW below this many minutes left.", "Keys", advanced=True) +field("tiles[].checks[].rec_sync", "bool", True, "Must record whenever another deck records.", "Keys") +field("tiles[].checks[].ip", "ip", None, "The deck's address, instead of what Companion reports.", "Keys", advanced=True) +field("tiles[].checks[].devices", "dict", None, "Dante devices: name -> 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: