Files
status-indicators/settings/builders.py
T
christianandClaude Opus 5.5 457335bc96 Woonsocket's startup list covered: M32, SoundGrid link, MIDI on Macs without python
From the WS Production Startup checklist (check.watersmedia.org):
- M32 (the "X32" there is a Midas M32 at 10.2.1.146): Woonsocket's Companion got the X32/M32
  module (imported from NA's copy through Companion's own API) and an m32 connection; auto-setup
  names a single M32 connection's key M32.
- SuperRack's link to the Waves SoundGrid server: a soundgrid check builder (SuperRack's control
  connection on port 19000) inside every SUPERRACK key.
- MIDI ("Open Audio MIDI"): Macs without the Command Line Tools have their sessions read with
  JavaScript for Automation (coremidi.JXA_SESSIONS) - by the watchdog every 2 s and by discovery. A
  peer with no state (older macOS) counts as configured. A peer joined by name or IPv6 gets the
  wired address of the Mac whose session it is (matched by session name), at that session's port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:48:28 -04:00

478 lines
28 KiB
Python

"""Fix and check builders: a fix is built from fields, never typed as a command.
A fix with a `template` and `params` carries its generated `run` (and kind/host) beside them; the
watchdog runs only `run`. Every Mac value is shlex-quoted, every Windows value regex-checked.
`build(cfg, fix)` regenerates; `mismatches(cfg)` lists fixes and checks whose stored command no
longer equals what their fields build (validate refuses those). `adopt(cfg)` gives today's fixes
their templates (a one-time migration; it prints what it normalised)."""
import re
import shlex
WIN_SAFE = re.compile(r"^[A-Za-z0-9 ._()\\:*-]+$")
LABEL_SAFE = re.compile(r"^[A-Za-z0-9._-]+$")
def q(value):
return shlex.quote(str(value))
def win(value, what):
value = str(value)
if not WIN_SAFE.match(value):
raise ValueError("%s: on a PC only letters, digits, space and . _ ( ) \\ : * - are allowed, not %r" % (what, value))
return value
def label(value, what):
value = str(value)
if not LABEL_SAFE.match(value):
raise ValueError("%s: a launchd label holds letters, digits, dots, - and _ only, not %r" % (what, value))
return value
def whole(value, what):
"""A whole number (0 counts; a bool, a blank or 1.5 does not)."""
if isinstance(value, bool) or not re.fullmatch(r"-?\d+", str(value).strip()):
raise ValueError("%s must be a whole number, not %r" % (what, value))
return int(str(value).strip())
def home_dir(path):
"""~/x -> $HOME/x inside double quotes; refuses quotes and $ in the rest."""
path = str(path)
if re.search(r'["$`\\]', path.replace("$HOME", "", 1)):
raise ValueError("folder %r may not hold quotes, $ or backslashes" % path)
if path.startswith("~/"):
path = "$HOME/" + path[2:]
return path.rstrip("/")
def mac_quit(process, wait_seconds, force, stop_children=False):
"""Quit a Mac program gently, give it wait_seconds, then (force) kill it."""
p = q(process)
ticks = max(1, int(round(float(wait_seconds) * 2)))
s = ""
if stop_children:
s += "pg=$(ps -o pgid= -p \"$(pgrep -x %s | head -1)\" 2>/dev/null | tr -d ' '); " % p
s += "pkill -TERM -x %s; i=0; while pgrep -xq %s && [ $i -lt %d ]; do sleep 0.5; i=$((i+1)); done; " % (p, p, ticks)
if force:
s += "pgrep -xq %s && pkill -KILL -x %s; " % (p, p)
if stop_children:
s += "[ -n \"$pg\" ] && [ \"$pg\" != \"$(ps -o pgid= -p $$ | tr -d ' ')\" ] && kill -KILL -- -\"$pg\" 2>/dev/null; "
if force or stop_children:
s += "sleep 1; "
return s
def mac_open(app, background=False, arg=None):
"""open -a Name / open /path/App.app (+ -g to stay in the background)."""
app = str(app)
cmd = "open -g" if background else "open"
if app == "$APP":
cmd += " \"$APP\""
elif app.startswith("/"):
cmd += " " + q(app)
else:
cmd += " -a " + q(app)
if arg:
cmd += " " + arg
return cmd
ABLETON_SET = ("L=$(ls -td ~/Library/Preferences/Ableton/Live*/ | head -1); S=$(grep -a 'info: Loading document \"' \"${L}Log.txt\" "
"| grep -v '/Templates/' | tail -1 | sed -E 's/^.*Loading document \"(.*)\"[^\"]*$/\\1/'); ")
def ableton_open(app):
return "if [ -n \"$S\" ] && [ -f \"$S\" ]; then %s; else %s; fi" % (mac_open(app, arg="\"$S\""), mac_open(app))
def helper_restart(labels):
one = "launchctl kickstart -k gui/$(id -u)/%s || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/%s.plist"
labels = [label(x, "helper") for x in labels]
if not labels:
raise ValueError("at least one helper (launchd label) is needed")
if len(labels) == 1:
return one % (labels[0], labels[0])
return "s=0; " + " ".join("{ %s; } || s=1;" % (one % (x, x)) for x in labels) + " exit $s"
def win_running(exe):
return "tasklist /NH /FI \"IMAGENAME eq %s\" | find /I \"%s\" >nul" % (exe, exe)
# --- the templates ---------------------------------------------------------------
# params: (name, words, help, kind, default) - kind: str, host, bool, int, list, app
P_HOST = ("host", "Computer", "Which computer it runs on.", "host", None)
TEMPLATES = {
"mac_start": {
"words": "Start a Mac program if it is not running",
"os": "mac", "start_only": True,
"params": [P_HOST, ("process", "Process name", "As pgrep sees it (Activity Monitor's process name).", "str", None),
("app", "App to open", "Its name, or the full /Applications/... path.", "str", None)],
"render": lambda p, cfg: "pgrep -xq %s || %s" % (q(p["process"]), mac_open(p["app"])),
"suggest": lambda p, cfg: "start %s if it is not running" % p["app"],
},
"mac_restart": {
"words": "Restart a Mac program (quit it, then open it again)",
"os": "mac", "closes": True,
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None),
("app", "App to open", "Its name, the full /Applications/... path, or $APP with 'newest of' below.", "str", None),
("wait_seconds", "Wait for it to quit", "Seconds before it is killed.", "int", 10),
("force", "Kill it if it will not quit", "", "bool", True),
("background", "Open it in the background", "No window comes to the front.", "bool", False),
("stop_children", "Also stop everything it started", "Its helper processes (a translation server, say).", "bool", False),
("newest_of", "Newest of", "A pattern like /Applications/ProPresenter*.app: the newest installed version opens.", "str", "")],
"render": lambda p, cfg: (("APP=$(ls -d %s | sort -V | tail -1); " % str(p["newest_of"]).strip()) if p.get("newest_of") else "")
+ mac_quit(p["process"], p.get("wait_seconds", 10), p.get("force", True), p.get("stop_children", False))
+ mac_open("$APP" if p.get("newest_of") else p["app"], p.get("background", False)),
"suggest": lambda p, cfg: "restart %s" % (p["app"] if not p.get("newest_of") else "%s (newest installed)" % p["process"]),
},
"ableton": {
"words": "Open Ableton Live with the set it last had open",
"os": "mac",
"params": [P_HOST, ("app", "Ableton app name", "As in /Applications.", "str", "Ableton Live 11 Suite"),
("restart", "Quit it first if it is running", "Otherwise it only starts when it is not running.", "bool", False),
("wait_seconds", "Wait for it to quit", "Seconds before it is killed (restart only).", "int", 15)],
"render": lambda p, cfg: (ABLETON_SET + mac_quit("Live", p.get("wait_seconds", 15), True) + ableton_open(p["app"])) if p.get("restart")
else "pgrep -xq Live || { " + ABLETON_SET + ableton_open(p["app"]) + "; }",
"suggest": lambda p, cfg: ("restart Ableton and reopen the set it had open (unsaved changes are lost)" if p.get("restart")
else "start Ableton if it is not running, with the set it last had open"),
},
"dvs": {
"words": "Restart Dante Virtual Soundcard (through its helper)",
"os": "mac", "closes": True,
"params": [P_HOST, ("then_process", "Then restart this program", "Process name of a program that keeps a dead DVS input (blank: none).", "str", ""),
("then_app", "...and open it as", "Its name or /Applications/... path.", "str", "")],
"render": lambda p, cfg: "sudo -n %s && open -g -a 'Dante Virtual Soundcard'" % q(dvs_helper(cfg))
+ ((" || exit 1; sleep 5; " + mac_quit(p["then_process"], 10, True) + mac_open(p["then_app"] or p["then_process"], True)) if p.get("then_process") else ""),
"suggest": lambda p, cfg: "restart Dante Virtual Soundcard (its engine restarts; audio to and from that Mac drops for 5-10 s)"
+ (", then %s" % p["then_process"] if p.get("then_process") else ""),
},
"helpers": {
"words": "Restart a helper on this Mac (a LaunchAgent)",
"os": "self", "closes": True,
"params": [("labels", "Helpers", "launchd labels, one or more (org.waterschurch.lobby-captions, say).", "list", [])],
"render": lambda p, cfg: helper_restart(p.get("labels") or []),
"suggest": lambda p, cfg: "restart %s" % ", ".join(p.get("labels") or []),
},
"win_start": {
"words": "Start a Windows program if it is not running (a StatusPanel task)",
"os": "windows", "start_only": True,
"params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\ (Start Vista, say).", "str", None),
("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
"render": lambda p, cfg: "%s || (schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s)"
% (win_running(win(p["exe"], "program file")), win(p["task"], "task name"), win_running(p["exe"])),
"suggest": lambda p, cfg: "start %s if it is not running" % str(p["exe"]).replace(".exe", ""),
},
"win_restart": {
"words": "Restart a Windows program (close it, then its StatusPanel task)",
"os": "windows", "closes": True,
"params": [P_HOST, ("task", "Task name", "The scheduled task under \\StatusPanel\\.", "str", None),
("exe", "Program file", "As Task Manager shows it.", "str", None)],
"render": lambda p, cfg: "taskkill /F /T /IM \"%s\" >nul 2>&1 & ping -n 3 127.0.0.1 >nul & schtasks /Run /TN \"\\StatusPanel\\%s\" >nul && ping -n 9 127.0.0.1 >nul && %s"
% (win(p["exe"], "program file"), win(p["task"], "task name"), win_running(p["exe"])),
"suggest": lambda p, cfg: "restart %s" % str(p["exe"]).replace(".exe", ""),
},
"win_service": {
"words": "Start a Windows service (and its tray program) if they are not running",
"os": "windows", "start_only": True,
"params": [P_HOST, ("service", "Service name", "As services.msc lists it (rtpMIDIService).", "str", None),
("task", "Tray program's task", "The StatusPanel task that starts the tray program (blank: none).", "str", ""),
("exe", "Tray program file", "rtpMIDI.exe, say.", "str", "")],
"render": lambda p, cfg: "(sc query %s | find \"RUNNING\" >nul || net start %s)" % (win(p["service"], "service"), win(p["service"], "service"))
+ ((" && (%s || schtasks /Run /TN \"\\StatusPanel\\%s\")" % (win_running(win(p["exe"], "program file")), win(p["task"], "task name"))) if p.get("task") else ""),
"suggest": lambda p, cfg: "start %s if it is not running" % p["service"],
},
"solo_afl": {
"words": "Put Pro Tools back to Solo Mode AFL + Latch (through the solo helper)",
"os": "mac",
"params": [P_HOST],
"render": lambda p, cfg: "touch \"%s/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"%s/solo-mode.json\"" % (solo_dir(cfg), solo_dir(cfg)),
"suggest": lambda p, cfg: "set Pro Tools back to Solo Mode AFL + Latch (clicks Options > Solo Mode)",
},
"atem_midi_off": {
"words": "Turn off MIDI control in ATEM Software Control (quits and reopens it)",
"os": "self", "closes": True, "restart_ok": True,
"params": [],
"render": lambda p, cfg: ("was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; "
"i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; "
"defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; "
"[ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; "
"[ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]"),
"suggest": lambda p, cfg: "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
},
"outlet_cycle": {
"words": "Power-cycle a smart outlet when its device does not answer",
"os": "self", "closes": True,
"params": [("outlet", "Outlet", "As named under Settings > Outlets.", "str", None),
("address", "Address to watch", "The device's address: no power cycle while it answers; afterwards wait for it to answer.", "str", ""),
("dante", "Dante name", "Its Dante device name, if it has a Dante card: the fix waits for it to be back too.", "str", ""),
("wait_seconds", "Off for", "Seconds the outlet stays off.", "int", 8)],
"render": lambda p, cfg: "/usr/bin/python3 \"$HOME/Library/Application Support/status-panel/settings/outlet.py\" cycle %s%s%s --wait %d"
% (q(p["outlet"]), (" --ping " + q(p["address"])) if p.get("address") else "", (" --dante " + q(p["dante"])) if p.get("dante") else "", int(p.get("wait_seconds", 8))),
"suggest": lambda p, cfg: "power-cycle the %s outlet when %s does not answer, then wait for it to come back (never while a HyperDeck is recording)"
% (p["outlet"], p.get("address") or "its device"),
},
# kinds without a command: the template is the kind, the params are its fields
"reconnect": {"words": "Reconnect a Companion connection", "kind": "reconnect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect Companion to it"},
"hyperdeck": {"words": "Switch a HyperDeck's connection off and on", "kind": "hyperdeck", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "turn Companion's connection to that HyperDeck off and on; never reboots the deck"},
"midi_connect": {"words": "Reconnect a network MIDI peer", "kind": "midi_connect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect the peer to the network MIDI session it dropped out of"},
"atem": {"words": "Put the switcher back to the saved file", "kind": "atem", "params": [], "render": None,
"suggest": lambda p, cfg: "set the switcher back to the saved file"},
"netgear_poe": {"words": "Power-cycle a PoE port on a Dante switch", "kind": "netgear_poe", "params": [], "render": None,
"suggest": lambda p, cfg: "switch the device's PoE port off and on"},
"spotify": {"words": "Play the service playlist in Spotify", "kind": "spotify", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "play the %s playlist in Spotify on %s (opens Spotify if it is closed)"
% ((cfg.get("spotify") or {}).get("name") or "service", (cfg.get("site") or {}).get("ingest_name") or "this Mac")},
# a press: "copy" names the params that are also fields of the fix itself (the watchdog reads those)
"companion_press": {
"words": "Press a Companion button (any page, row and column)", "kind": "companion_press", "start_only": True,
"params": [("page", "Page", "The Companion page the button is on (page 4 = Audio).", "int", None),
("row", "Row", "Top row is 0.", "int", None),
("col", "Column", "Left column is 0.", "int", None),
("button", "What the button says", "Only for the words: FOH Backup, say.", "str", "")],
"copy": ["page", "row", "col"], "render": None,
"suggest": lambda p, cfg: "press the %sbutton in Companion (page %s, row %s, column %s)"
% (("%s " % p["button"]) if p.get("button") else "", p["page"], p["row"], p["col"]),
},
"custom": {"words": "Custom command (Advanced: typed by hand)", "params": [], "render": None, "suggest": lambda p, cfg: ""},
}
def dvs_helper(cfg):
return (cfg.get("site") or {}).get("dvs_helper") or "/usr/local/sbin/status-panel-dvs-start"
def solo_dir(cfg):
return home_dir((cfg.get("site") or {}).get("solo_dir") or "~/Library/Application Support/status-panel")
def template_list(cfg=None):
"""For the page: name, words, params with defaults, what it needs."""
out = []
for name, t in TEMPLATES.items():
out.append({"name": name, "words": t["words"], "os": t.get("os"), "kind": t.get("kind"), "start_only": bool(t.get("start_only")),
"closes": bool(t.get("closes")),
"params": [{"name": n, "words": w, "help": h, "type": k, "default": d} for n, w, h, k, d in t["params"]]})
return out
def build(cfg, fix):
"""A fix with template + params -> the same fix with kind/host/run regenerated. Raises ValueError with plain words."""
name = fix.get("template")
if not name or name == "custom":
return dict(fix)
t = TEMPLATES.get(name)
if t is None:
raise ValueError("unknown builder %s" % name)
params = dict(fix.get("params") or {})
for n, words, _, kind, default in t["params"]:
if params.get(n) in (None, "") and default is not None:
params[n] = default
if params.get(n) in (None, "") and default is None:
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
out = {k: v for k, v in fix.items() if k not in ("run", "host", "kind")}
out["template"], out["params"] = name, params
for n in t.get("copy", []):
words = next(w for a, w, _, _, _ in t["params"] if a == n)
out[n] = params[n] = whole(params.get(n), "%s: %s" % (t["words"], words.lower()))
if t.get("kind"):
out["kind"] = t["kind"]
return out
hosts = cfg.get("hosts") or {}
host = params.get("host")
if t.get("os") == "self":
out["kind"] = "local"
else:
h = hosts.get(host)
if h is None:
raise ValueError("%s: unknown computer %s" % (t["words"], host))
want = t.get("os")
have = "mac" if h.get("self") else (h.get("os") or "mac")
if want and have != want:
raise ValueError("%s: %s is a %s computer, this builder is for %s" % (t["words"], h.get("name", host), have, want))
if h.get("self"):
out["kind"] = "local"
else:
out["kind"], out["host"] = "ssh", host
out["run"] = t["render"](params, cfg)
if t.get("restart_ok"):
out["restart_ok"] = True
return out
def suggest_label(cfg, fix):
t = TEMPLATES.get(fix.get("template") or "")
if not t:
return ""
try:
return t["suggest"](dict(fix.get("params") or {}), cfg)
except (KeyError, TypeError):
return ""
# --- check builders (the three remote checks that carry a command) ----------------
CHECK_TEMPLATES = {
"mac_program": {
"words": "A program is running on a Mac", "type": "remote",
"params": [P_HOST, ("process", "Process name", "As pgrep sees it.", "str", None)],
"render": lambda p, cfg: "pgrep -xq %s" % q(p["process"]),
},
"win_program": {
"words": "A program is running on a Windows PC", "type": "remote",
"params": [P_HOST, ("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
"render": lambda p, cfg: win_running(win(p["exe"], "program file")),
},
"soundgrid": {
"words": "SuperRack is connected to its SoundGrid server", "type": "remote",
"params": [P_HOST],
# SuperRack keeps a control link to the Waves server (port 19000, over the SoundGrid network).
"render": lambda p, cfg: "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'",
},
"helper_flag": {
"words": "A helper file does not say something is wrong", "type": "remote",
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
("bad", "The bad text", "The check fails when the file holds this.", "str", "\"bad\":true")],
"render": lambda p, cfg: "! grep -q %s \"%s/%s\"" % (q(p["bad"]), solo_dir(cfg), p["file"]),
},
"helper_fresh": {
"words": "A helper file is fresh and says it is trusted", "type": "remote",
"params": [P_HOST, ("file", "File", "In the helper's folder.", "str", "solo-mode.json"),
("max_age", "Fresh means younger than", "Seconds.", "int", 20),
("flag", "The good text", "The check fails unless the file holds this.", "str", "\"trusted\":true")],
"render": lambda p, cfg: "f=\"%s/%s\"; [ $(( $(date +%%s) - $(stat -f %%m \"$f\") )) -lt %d ] && grep -q %s \"$f\""
% (solo_dir(cfg), p["file"], int(p["max_age"]), q(p["flag"])),
},
}
def check_template_list():
return [{"name": n, "words": t["words"], "type": t["type"],
"params": [{"name": a, "words": w, "help": h, "type": k, "default": d} for a, w, h, k, d in t["params"]]} for n, t in CHECK_TEMPLATES.items()]
def build_check(cfg, chk):
name = chk.get("template")
if not name:
return dict(chk)
t = CHECK_TEMPLATES.get(name)
if t is None:
raise ValueError("unknown check builder %s" % name)
params = dict(chk.get("params") or {})
for n, words, _, kind, default in t["params"]:
if params.get(n) in (None, "") and default is not None:
params[n] = default
if params.get(n) in (None, "") and default is None:
raise ValueError("%s: %s is needed" % (t["words"], words.lower()))
h = (cfg.get("hosts") or {}).get(params.get("host"))
if h is None:
raise ValueError("%s: unknown computer %s" % (t["words"], params.get("host")))
if h.get("self"):
raise ValueError("%s: %s is this Mac - use 'A program is running on this Mac'" % (t["words"], h.get("name")))
out = dict(chk, type=t["type"], template=name, params=params, host=params["host"])
for k, v in chk.items():
if k in ("fix", "tag", "service_only", "debounce_seconds", "msg", "name"):
out[k] = v
for k in ("fix", "tag", "service_only", "debounce_seconds"):
if k in chk:
out[k] = chk[k]
out["cmd"] = t["render"](params, cfg)
return out
def mismatches(cfg):
"""Fixes and checks whose stored command differs from what their fields build (plain words)."""
out = []
for fid, fix in (cfg.get("fixes") or {}).items():
if fix.get("template") and fix["template"] != "custom":
try:
b = build(cfg, fix)
except ValueError as e:
out.append("fix %s: %s" % (fid, e))
continue
for k in ("run", "kind", "host") + tuple(TEMPLATES[fix["template"]].get("copy", [])):
bv, fv = b.get(k), fix.get(k)
if k in ("run", "kind", "host"):
bv, fv = bv or None, fv or None # a blank and an absent command are the same
if bv != fv:
out.append("fix %s: its %s no longer matches its fields - open it on Fixes and press Save" % (fid, "command" if k == "run" else k))
break
for t in cfg.get("tiles") or []:
for i, c in enumerate(t.get("checks") or []):
if c.get("template"):
try:
b = build_check(cfg, c)
except ValueError as e:
out.append("key %s check %d: %s" % (t.get("label", t["id"]), i + 1, e))
continue
if b.get("cmd") != c.get("cmd") or b.get("host") != c.get("host"):
out.append("key %s check %d: its command no longer matches its fields" % (t.get("label", t["id"]), i + 1))
return out
# --- today's fixes get their templates (one-time) ------------------------------------
TODAY = {
"propresenter": ("mac_restart", {"host": "prop", "process": "ProPresenter", "app": "$APP", "newest_of": "/Applications/ProPresenter*.app", "wait_seconds": 10, "force": True}),
"ableton": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": False}),
"ableton_restart": ("ableton", {"host": "tracks", "app": "Ableton Live 11 Suite", "restart": True, "wait_seconds": 15}),
"translate": ("mac_start", {"host": "translation", "process": "Church Translate", "app": "Church Translate"}),
"translate_restart": ("mac_restart", {"host": "translation", "process": "Church Translate", "app": "Church Translate", "wait_seconds": 15, "force": True, "stop_children": True}),
"dvs_prop": ("dvs", {"host": "prop"}), "dvs_tracks": ("dvs", {"host": "tracks"}), "dvs_translation": ("dvs", {"host": "translation"}),
"dvs_ingest": ("dvs", {"host": "ingest", "then_process": "ProdCom", "then_app": "/Applications/ProdCom.app"}),
"bridge": ("helpers", {"labels": ["org.waterschurch.resi-propresenter"]}),
"captions": ("helpers", {"labels": ["org.waterschurch.lobby-captions", "org.waterschurch.lobby-captions-display"]}),
"caption_screen": ("helpers", {"labels": ["org.waterschurch.lobby-captions-display"]}),
"bibleapp": ("win_restart", {"host": "bibletv", "task": "Start BibleApp", "exe": "Bible App.exe"}),
"obs": ("win_start", {"host": "livestream", "task": "Start OBS", "exe": "obs64.exe"}),
"arena": ("win_start", {"host": "lighting", "task": "Start Arena", "exe": "Arena.exe"}),
"vista": ("win_start", {"host": "lighting", "task": "Start Vista", "exe": "Vista.exe"}),
"rtpmidi_start": ("win_service", {"host": "lighting", "service": "rtpMIDIService", "task": "Start rtpMIDI", "exe": "rtpMIDI.exe"}),
"solo_afl": ("solo_afl", {"host": "studiob"}),
"atem_midi": ("atem_midi_off", {}),
"proddash": ("mac_restart", {"host": "ingest", "process": "ProdDash", "app": "/Applications/ProdDash.app", "wait_seconds": 10, "force": False, "background": True}),
"prodcom": ("mac_restart", {"host": "ingest", "process": "ProdCom", "app": "/Applications/ProdCom.app", "wait_seconds": 10, "force": True, "background": True}),
"midi_reconnect": ("midi_connect", {}), "atem_audio": ("atem", {}), "atem_camera": ("atem", {}), "spotify_play": ("spotify", {}),
"tascam_reconnect": ("reconnect", {}), "hyperdeck": ("hyperdeck", {}),
}
TODAY_CHECKS = { # (key id, cmd) -> template + params
("apps", "pgrep -xq ProPresenter"): ("mac_program", {"host": "prop", "process": "ProPresenter"}),
("apps", "pgrep -xq Live"): ("mac_program", {"host": "tracks", "process": "Live"}),
("apps", "pgrep -xq 'Church Translate'"): ("mac_program", {"host": "translation", "process": "Church Translate"}),
("apps", "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\""): ("helper_flag", {"host": "studiob"}),
("apps", "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\""):
("helper_fresh", {"host": "studiob"}),
}
def adopt(cfg):
"""Give today's fixes and checks their templates; returns [(what, before, after)] for every command that changed shape."""
changed = []
for fid, fix in cfg.get("fixes", {}).items():
if fix.get("template") or fid not in TODAY:
continue
name, params = TODAY[fid]
fix["template"], fix["params"] = name, params
built = build(cfg, fix)
if built.get("run") != fix.get("run"):
changed.append(("fix " + fid, fix.get("run"), built.get("run")))
fix.update({k: v for k, v in built.items() if k in ("run", "kind", "host", "restart_ok")})
for k in ("host",):
if k not in built and k in fix:
del fix[k]
for t in cfg.get("tiles", []):
for c in t.get("checks", []):
key = (t["id"], c.get("cmd"))
if c.get("template") or key not in TODAY_CHECKS:
continue
name, params = TODAY_CHECKS[key]
c["template"], c["params"] = name, params
built = build_check(cfg, c)
if built["cmd"] != c.get("cmd"):
changed.append(("key %s check" % t["id"], c.get("cmd"), built["cmd"]))
c["cmd"], c["host"] = built["cmd"], built["host"]
return changed