settings/discover.py reads Companion's connections (and their addresses and channel counts), the Dante devices, the network MIDI sessions here and on joined Macs, this Mac's wired network, and what runs on each joined computer. settings/autoconf.py turns that into keys, checks, start and restart fixes and a page layout the way North Attleboro's were made (group keys only when not everything fits; receivers fold first), all through the existing builders and rules. settings/campus.py prepares Companion through its internal API (the panel's custom variables and the blink trigger), picks free pages, applies the first config, serves the program kit, the campus install line and the Mac/PC join lines, and looks every ten minutes to add new gear (discover.auto; never during a service or a recording, never removes, never re-adds what someone took off). Settings > Auto-setup shows what was found and the lines to paste. install.sh keeps the kit and makes the panel's login key. Checked on North Attleboro read-only: discovery finds all 43 connections, 19 Dante devices, four Macs' MIDI sessions and seven computers, and a from-scratch build reproduces NA's layout. The Companion calls were tried with a throwaway variable and a disabled trigger, then removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
27 KiB
Python
472 lines
27 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")),
|
|
},
|
|
"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
|