Wiring view: every machine and what connects it (Companion, MIDI, Dante, watchdog, web, Art-Net), hide types or single links, scripts per computer

RESI LIVE NOT REC follows the Resi auto-record service (custom autorec_status, not during its REC hold; stale heartbeat = no verdict);
INGEST watches AUTO REC (running, heartbeat, Resi reachable), hold restarts it. Probes list each computer's scripts by file name only.
HyperDeck addresses read from bonjourHost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:23:02 -04:00
co-authored by Claude Opus 5.5
parent 6c78ed3221
commit 26804e4ce2
11 changed files with 655 additions and 43 deletions
+17 -2
View File
@@ -278,7 +278,7 @@ The **page number key** on every page follows the ALL button. It's green when ev
| Button | Checks |
|---|---|
| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't; **RESI LIVE NOT REC** when Resi has been playing for a minute (`rec_watch`: resi-studio-cues `playback_state` PLAYING) and no deck records. Holding HD1–6 or REC then **starts recording** on every deck with a disk that is not recording (over the deck's own protocol, port 9993; a recording deck is sent nothing), named like the Director page's REC key: `CAM3-YYYYMMDD_HHMMSS-` (`rec_name` per deck) |
| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't; **RESI LIVE NOT REC** when Resi is live and no deck records (`rec_watch`: the Resi auto-record service's own verdict, Companion's `custom:autorec_status` saying LIVE, NOT RECORDING / RECORDING / STARTING for 20 s; not while it holds REC until 10 minutes before a service that Resi started early, and nothing while its heartbeat `autorec_hb` is stale). Holding HD1–6 or REC then **starts recording** on every deck with a disk that is not recording (over the deck's own protocol, port 9993; a recording deck is sent nothing), named like the Director page's REC key: `CAM3-YYYYMMDD_HHMMSS-` (`rec_name` per deck) |
| | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. |
| TASCAM | connection; the recorder's own Error / Caution. Use the **1.3.1-quiet** Tascam module (see below) so Companion's log isn't flooded.; pinged at the address on its Companion connection. |
| X32, ARENA, OBS | Companion connection |
@@ -292,7 +292,7 @@ The **page number key** on every page follows the ALL button. It's green when ev
| VISTA | Vista on the Lighting PC: its Art-Net poll (sent every 0.5 s) must be heard within 5 s, otherwise **SILENT** (Vista closed or crashed, or the Lighting PC is off the network). Hold 2 s starts Vista if it is not running; Vista then opens **Prop Takeover** from its show folder (`E:\media\Documents\Vista 3\Shows`) by itself, through its own autoload preference (`<autoloadpath>` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. |
| RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. |
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. |
| INGEST | the bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk |
| INGEST | **AUTO REC** (`resi_autorec.py`, LaunchAgent `org.waterschurch.resi-autorec`: presses the Director page's REC key 3/2/3 when Resi's encoder goes live and STOP a minute after it goes off air; its own key is 3/3/3): **AUTO REC STOPPED / NOT LOADED**, **AUTO REC STUCK** (heartbeat `custom:autorec_hb` over 90 s old), amber **AUTO REC NO RESI** (it cannot see Resi); a hold restarts it. The bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk |
| SPOTIFY | Spotify on NA Ingest must be playing the **Christian EDM 2026** playlist (`spotify:playlist:2uTpJVYQLFnKF1WnSOuco8`): **SPOTIFY CLOSED**, **NOT PLAYING**, or **WRONG PLAYLIST** (it reads all of the playlist's songs from Spotify every hour, and again whenever a song plays that isn't on the list, since the playlist keeps getting new songs). Hold 2 s (and Sunday auto-fix) plays the playlist, opening Spotify first if needed. |
| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. |
@@ -310,6 +310,21 @@ Fixes and the remote checks are built from fields (`settings/builders.py`): a bu
The page reads what the watchdog writes next to its config (`snapshot.json` every 5 s, `started.json`, `live-now.json`, `fixes-running.json`) instead of asking the gear again. Its own look at the computers (`settings/probes.py`) runs fixed read-only commands over the same login key, fetches whole lists and matches them here, never while a fix runs.
## Wiring (settings page)
**Wiring** on the settings page draws every computer and device the panel knows and what connects them, from what
the watchdog already sees (`/api/wiring`, refreshed every 5 s): **Companion control** (each connection, to the computer
or device at its address), **Network MIDI** (each session's peers), **Watchdog** (its SSH logins and pings: hidden at
first), **Web checks**, **Dante** (each device to its switch port, from the Dante map; a computer's Dante name joins
its box) and **Art-Net**. Click a type to hide or show it, untick single connections in the side panel, or click a
machine and **Only this one's connections**. Red dashes = that connection has a problem, grey dashes = can't see it
from here. Drag machines to arrange them; the wheel or +/− zooms, dragging the background pans, Fit shows everything.
Positions and what is hidden stay in that browser.
Each computer shows how many **scripts** run on it and which (side panel): our LaunchAgents (`org.waterschurch.*`,
linked to their script), any running `.py/.js/.sh/...` script, and on PCs the `\StatusPanel\` tasks (running while
their program runs). Only file names are kept, never the rest of a command line. **Check scripts now** looks
(read-only; the same probe as Computers › Check now).
## Other locations: auto-setup
A location sets itself up. All a person does is let the panel in: one line on the location's Mac that runs Companion,
+39 -2
View File
@@ -553,6 +553,18 @@
"run": "tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul || (schtasks /Run /TN \"\\StatusPanel\\Start Vista\" >nul && ping -n 9 127.0.0.1 >nul && tasklist /NH /FI \"IMAGENAME eq Vista.exe\" | find /I \"Vista.exe\" >nul)",
"short": "VISTA",
"template": "win_start"
},
"autorec": {
"kind": "local",
"label": "restart the Resi auto-record",
"params": {
"labels": [
"org.waterschurch.resi-autorec"
]
},
"run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.resi-autorec || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.resi-autorec.plist",
"short": "AUTO REC",
"template": "helpers"
}
},
"flap": {
@@ -2618,6 +2630,24 @@
"path": "/",
"type": "disk",
"warn_gb": 25
},
{
"fix": "autorec",
"label": "org.waterschurch.resi-autorec",
"name": "AUTO REC",
"type": "launchd"
},
{
"type": "heartbeat",
"conn": "custom",
"var": "autorec_hb",
"max_age_seconds": 90,
"msg": "AUTO REC STUCK",
"status_var": "autorec_status",
"warn_if": "RESI ERROR",
"warn_msg": "AUTO REC NO RESI",
"debounce_seconds": 180,
"fix": "autorec"
}
],
"id": "ingest",
@@ -2819,7 +2849,14 @@
"state": "sp_state"
},
"rec_watch": {
"conn": "resi-studio-cues",
"after_seconds": 60
"conn": "custom",
"var": "autorec_status",
"heartbeat": "autorec_hb",
"live": [
"LIVE, NOT",
"RECORDING",
"STARTING"
],
"after_seconds": 20
}
}
+14 -5
View File
@@ -104,9 +104,10 @@ field("service_windows[].end", "hhmm", None, "", "Schedules", required=True)
field("resi_events", "dict", None, "Resi's scheduled events (studio.resi.io), so the checks marked quiet \"resi_events\" keep quiet "
"while an event starts or stops: {\"weekly\": [{\"days\": [\"sun\"], \"start\": \"07:30\", \"end\": \"09:15\"}, ...], "
"\"margin_minutes\": 10, \"pco_specials\": true (special services' times from Planning Center)}.", "Site")
field("rec_watch", "dict", None, "Every deck with a disk must record while Resi plays: {\"conn\": \"resi-studio-cues\", "
"\"after_seconds\": 60} (its playback_state PLAYING that long). A deck that does not shows RESI LIVE NOT REC; "
"a hold starts recording on the idle decks only.", "Site")
field("rec_watch", "dict", None, "Every deck with a disk must record while Resi is live: {\"conn\": \"custom\", \"var\": "
"\"autorec_status\", \"heartbeat\": \"autorec_hb\", \"live\": [\"LIVE, NOT\", \"RECORDING\", \"STARTING\"], "
"\"after_seconds\": 20} (the Resi auto-record service's own verdict; without a heartbeat a module's playback_state "
"PLAYING). A deck that does not record shows RESI LIVE NOT REC; a hold starts recording on the idle decks only.", "Site")
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)
@@ -325,6 +326,11 @@ 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[].var", "str", None, "The Companion variable with the service's heartbeat (unix time).", "Keys")
field("tiles[].checks[].max_age_seconds", "int", 90, "A heartbeat older than this: the service is stuck.", "Keys", advanced=True, lo=10, hi=3600)
field("tiles[].checks[].status_var", "str", None, "The service's status text (Companion variable).", "Keys", advanced=True)
field("tiles[].checks[].warn_if", "str", None, "Amber while the status text contains this.", "Keys", advanced=True)
field("tiles[].checks[].warn_msg", "str", None, "What the key says then.", "Keys", advanced=True)
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, "Look this often, in seconds: a web address every 30, a command "
@@ -515,6 +521,9 @@ CHECKS = {
"question": "Is a recorder OK?", "words": "The Tascam recorder reports no error or caution."},
"ping": {"fields": ["host", "conn", "name", "msg", "failures", "interval_seconds", "service_only"], "subs": ["ping"], "identity": ["conn", "host"],
"module": None, "question": "Is a device answering?", "words": "An address answers ping."},
"heartbeat": {"fields": ["conn", "var", "max_age_seconds", "msg", "status_var", "warn_if", "warn_msg"], "subs": ["hb"],
"identity": ["var"], "module": None, "question": "Is a service still writing its heartbeat?",
"words": "A service writes the time into a Companion variable (custom autorec_hb: the Resi auto-record); too old means it is stuck."},
"launchd": {"fields": ["label", "name"], "subs": ["svc"], "identity": ["label"], "module": None,
"question": "Is a program running?", "words": "A helper (LaunchAgent) on this Mac is loaded and running."},
"process": {"fields": ["match", "name", "template", "params"], "subs": ["proc"], "identity": ["match"], "module": None,
@@ -565,12 +574,12 @@ GLOSSARY = {
"DISABLED", "OFFLINE", "NO SESSION", "NOT RUNNING", "DOWN", "NOT REC", "SILENT", "WARNING", "STARTING",
"ATEM AUDIO WRONG", "ATEM CAMERA WRONG", "ATEM SW MIDI ON", "PT SOLO NOT AFL", "PROPRES NOT RUNNING",
"ABLETON NOT RUNNING", "ABLESET NOT RUNNING", "TRANSLATE NOT RUNNING", "PROP TV DOWN", "BIBLE APP DOWN", "PRODCOM DOWN", "PRODDASH DOWN",
"ENCODER WEB DOWN", "PT SOLO CHECK OFF", "UNREACH", "DROPPED", "RESI LIVE NOT REC",
"ENCODER WEB DOWN", "PT SOLO CHECK OFF", "UNREACH", "DROPPED", "RESI LIVE NOT REC", "AUTO REC STOPPED", "AUTO REC STUCK",
],
"check the device or the cable": [
"NO PING", "NO ANSWER", "UNREACHABLE", "LINK DOWN", "NO GATEWAY", "NO DISK", "FULL", "LOW", "BATT", "INTERF",
"RF MUTE", "HUNG - POWER CYCLE IT", "NO NETWORK CHECK POWER", "STILL DOWN", "REFUSED", "LOGIN", "BLIP",
"NOT REACHABLE", "DISK", "RATE LIMITED",
"NOT REACHABLE", "DISK", "RATE LIMITED", "AUTO REC NO RESI",
],
"the READY page: what is not ready yet (hold the key to fix it or look again)": [
"TO DO", "ALL DONE", "TAP WHEN DONE", "TAP WHEN SET", "NOT DONE", "DONE", "NOT SET UP", "CHECKING",
+54 -8
View File
@@ -27,7 +27,7 @@ MAC_PROBE = (
'echo SP_AGENTS; launchctl list 2>/dev/null; echo SP_PROCS; ps -axo comm= 2>/dev/null; '
'echo SP_APPS; ls /Applications 2>/dev/null; echo SP_HELPER; ls -l {helper} 2>&1; sudo -n -l {helper} 2>&1 | tail -1; '
'echo SP_SOLO; cat {solo}/solo-mode.json 2>/dev/null; echo; stat -f %m {solo}/solo-mode.json 2>/dev/null; '
'echo SP_NOW; date +%s; echo SP_END'
'echo SP_NOW; date +%s; echo SP_SCRIPTS; ps -axo pid=,args= 2>/dev/null | grep -E \'[.](py|js|mjs|sh|zsh|command|swift|rb|pl|scpt)( |$)\' | grep -v SP_SCRIPTS; echo SP_END'
)
WIN_PROBE = (
'echo SP_NAME=%COMPUTERNAME% & echo SP_TASKS & schtasks /Query /FO CSV /NH 2>nul & echo SP_PROCS & tasklist /FO CSV /NH 2>nul & '
@@ -35,8 +35,15 @@ WIN_PROBE = (
'"(Get-NetConnectionProfile | Select -First 1 -ExpandProperty NetworkCategory); '
'(Get-NetAdapterPowerManagement | Select -First 1 -ExpandProperty WakeOnMagicPacket); '
'((Get-NetFirewallRule -Name OpenSSH-Server-In-TCP -ErrorAction SilentlyContinue | Get-NetFirewallAddressFilter | '
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_END'
'Select -ExpandProperty RemoteAddress) -join \',\')" 2>nul & echo SP_SCRIPTS & powershell -NoProfile -Command '
'"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match \'[.](ps1|py|js|bat|cmd|vbs)\' } | '
'ForEach-Object { [string]$_.ProcessId + \' \' + $_.CommandLine }" 2>nul & echo SP_END'
)
# A script: a file run by an interpreter (its name only is kept, never the rest of the command line).
SCRIPT_RE = r"[^ /\\]+[.](py|js|mjs|sh|zsh|command|swift|rb|pl|scpt|ps1|bat|cmd|vbs)( |\"|$)"
# Not the user's scripts: programs' own insides (Companion's modules, app bundles, the system).
NOT_SCRIPTS = (".app/Contents/", "/companion/modules/", "/System/", "/usr/libexec/", "/Library/Apple/", "\\Windows\\",
"/node_modules/", "Program Files")
def shell_path(path):
@@ -113,6 +120,38 @@ def parse_sc(lines):
return services
def parse_scripts(lines, agents=None, tasks=None):
"""The scripts a computer runs: [{"name", "pid", "by", "running"}]. From `ps` (pid, command line) lines,
our own launchd jobs (org.waterschurch.*, linked to their script by pid) and the \\StatusPanel\\ tasks
({path: state}). Only a script's file name is kept, never the rest of its command line."""
agents, out, seen_pids = agents or {}, [], set()
by_pid = {pid: label for label, pid in agents.items() if pid}
for line in lines:
line = line.strip()
m = re.match(r"^(\d+)\s+(.*)$", line)
if not m or "SP_SCRIPTS" in line:
continue
found = re.search(SCRIPT_RE, m.group(2))
if not found:
continue
# The script's own path (from the last " /", ' "' or " C:\" before its name): the interpreter may sit
# inside an app (Apple's python3 is in Python.app) without the script being a program's insides.
head = m.group(2)[:found.end()]
cut = max([head.rfind(" /"), head.rfind(' "')] + [x.start() for x in re.finditer(r" [A-Za-z]:\\", head)])
if any(x in head[cut + 1:] for x in NOT_SCRIPTS):
continue
name = re.split(r"[\\/]", found.group(0).strip().strip('"'))[-1]
pid = int(m.group(1))
out.append({"name": name, "pid": pid, "by": by_pid.get(pid, ""), "running": True})
seen_pids.add(pid)
for label, pid in sorted(agents.items()):
if label.startswith("org.waterschurch.") and pid not in seen_pids:
out.append({"name": label[len("org.waterschurch."):], "pid": pid, "by": label, "running": pid is not None})
for path, st in sorted((tasks or {}).items()):
out.append({"name": path.split("\\")[-1], "pid": None, "by": path, "running": st.lower() == "running", "state": st})
return sorted(out, key=lambda x: (not x["running"], x["name"].lower()))
def light(ok, reason=""):
return {"light": "ok" if ok else "problem", "reason": reason if not ok else ""}
@@ -192,7 +231,7 @@ def mac_items(host, parsed, cfg):
item.update({"light": "unknown", "reason": ""})
items.append(item)
return items, {"agents": sorted(a for a in agents if not a.startswith(("com.apple.", "application."))),
"processes": sorted(proc_names)[:200]}
"processes": sorted(proc_names)[:200], "scripts": parse_scripts(parsed.get("SP_SCRIPTS", []), agents)}
def windows_items(host, parsed):
@@ -228,8 +267,13 @@ def windows_items(host, parsed):
item.update({"light": "unknown", "reason": ""})
items.append(item)
setup = {"profile": profile, "wake_on_lan": wol, "firewall_allow": allow}
return items, {"tasks": sorted(t for t in tasks if "StatusPanel" in t), "services": sorted(k for k, v in services.items()
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60]}, setup
# A task that starts a program is "Ready" once it did: it counts as running while that program runs.
up = {"\\StatusPanel\\" + run["name"] for run, item in zip(host.get("runs", []), items)
if run.get("kind") == "task" and run.get("exe") and item.get("light") == "ok"}
ours = {t: ("Running" if t in up else row[2] if len(row) > 2 else "") for t, row in tasks.items() if "StatusPanel" in t}
return items, {"tasks": sorted(ours), "services": sorted(k for k, v in services.items()
if v == "RUNNING" and not k.lower().startswith(("w", "s", "d", "e", "n", "p", "t", "u", "c")))[:60],
"scripts": parse_scripts(parsed.get("SP_SCRIPTS", []), tasks=ours)}, setup
def probe_host(cfg, alias, host):
@@ -245,7 +289,7 @@ def probe_host(cfg, alias, host):
rec["error"] = err.strip() or "no answer"
return rec
rec["reachable"] = True
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_END"))
parsed = sections(out, ("SP_TASKS", "SP_PROCS", "SP_SVC", "SP_PS", "SP_SCRIPTS", "SP_END"))
rec["computer_name"] = values(out).get("NAME", "").strip()
rec["items"], rec["found"], rec["setup"] = windows_items(host, parsed)
rec["setup"]["key_ok"] = True
@@ -257,7 +301,7 @@ def probe_host(cfg, alias, host):
return rec
rec["reachable"] = True
v = values(out)
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_END"))
parsed = sections(out, ("SP_AGENTS", "SP_PROCS", "SP_APPS", "SP_HELPER", "SP_SOLO", "SP_NOW", "SP_SCRIPTS", "SP_END"))
rec["computer_name"], rec["os_version"], rec["python"] = v.get("NAME", ""), v.get("OSV", ""), v.get("PY", "")
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
rec["setup"] = {"key_ok": (v.get("KEY") or "0").strip() not in ("", "0"), "python_ok": "3." in v.get("PY", "")}
@@ -301,6 +345,7 @@ def probe_self(cfg, alias, host):
try:
agents_out = subprocess.run(["/bin/launchctl", "list"], capture_output=True, text=True, timeout=5).stdout
procs_out = subprocess.run(["/bin/ps", "-axo", "comm="], capture_output=True, text=True, timeout=5).stdout
args_out = subprocess.run(["/bin/ps", "-axo", "pid=,args="], capture_output=True, text=True, timeout=5).stdout
except (OSError, subprocess.TimeoutExpired) as e:
rec["error"] = str(e)
return rec
@@ -308,7 +353,8 @@ def probe_self(cfg, alias, host):
helper = subprocess.run(["/bin/zsh", "-c", "ls -l %s 2>&1; sudo -n -l %s 2>&1 | tail -1" % (paths["helper"], paths["helper"])],
capture_output=True, text=True, timeout=6).stdout
parsed = {"SP_AGENTS": agents_out.splitlines(), "SP_PROCS": procs_out.splitlines(), "SP_APPS": [],
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))]}
"SP_HELPER": helper.splitlines(), "SP_SOLO": [], "SP_NOW": [str(int(time.time()))],
"SP_SCRIPTS": [l for l in args_out.splitlines() if re.search(SCRIPT_RE, l)]}
rec["items"], rec["found"] = mac_items(host, parsed, cfg)
for item, run in zip(rec["items"], host.get("runs", [])):
if run.get("kind") == "folder":
+130
View File
@@ -522,6 +522,134 @@ class Site:
"the DVS helper and its sudo rule", "the Dante and switch-management ports are up", "Google Drive is signed in (saved switcher files)"],
}
# The Wiring screen: every computer and device the panel knows, and what connects them (2026-10-27:
# "like a wiring diagram ... hide different connections ... how many and what scripts are running on each machine").
WIRE_TYPES = [
{"id": "companion", "label": "Companion control", "color": "#4f8cff", "hint": "Companion's connections: what the deck's buttons drive"},
{"id": "midi", "label": "Network MIDI", "color": "#b36bff", "hint": "rtpMIDI / Audio MIDI Setup sessions and their peers"},
{"id": "watch", "label": "Watchdog", "color": "#8a94a6", "hint": "What the status panel logs in to or pings"},
{"id": "web", "label": "Web checks", "color": "#2bb3a3", "hint": "Web pages the panel asks (apps' own pages, Resi's cloud)"},
{"id": "dante", "label": "Dante", "color": "#f0a020", "hint": "Dante devices and the switch port each is plugged into"},
{"id": "artnet", "label": "Art-Net", "color": "#e8618c", "hint": "Lighting data the panel hears"},
]
def wiring_view(self):
cfg = self.load()
snap = self.read_json("snapshot.json", {}) or {}
live = self.read_json("machines-live.json", {}) or {}
hosts = cfg.get("hosts", {})
me = next((a for a, h in hosts.items() if h.get("self")), None)
lines = {l["alias"]: l for l in self.computer_lines(snap)}
word_light = {"OK": "ok", "Problem": "problem", "Off": "not_needed"}
nodes, edges, owner = {}, {}, {"127.0.0.1": me, "localhost": me}
def norm(x):
return re.sub(r"[^a-z0-9]", "", str(x).lower())
for alias, h in hosts.items():
probe = live.get(alias) or {}
ip = (h.get("ssh") or "").split("@")[-1]
for a in [ip] + [n.get("ip") for n in h.get("networks", [])] + [i.get("ip") for i in (probe.get("found") or {}).get("interfaces", [])]:
if a:
owner[a] = alias
line = lines.get(alias, {})
found = probe.get("found") or {}
nodes["h:" + alias] = {"id": "h:" + alias, "label": h.get("name", alias), "kind": "computer", "os": h.get("os", "mac"),
"self": bool(h.get("self")), "address": ip or ("this Mac" if h.get("self") else ""),
"role": h.get("role", ""), "light": word_light.get(line.get("word"), "unknown"),
"reason": line.get("reason", ""), "scripts": found.get("scripts"), "looked": probe.get("time")}
for m in (self.read_json("fleet.json", {}) or {}).values():
alias = next((a for a, h in hosts.items() if norm(h.get("name")) == norm(m.get("name"))), None)
for a in m.get("ips") or [] if alias else []:
owner.setdefault(a, alias)
def target(address, label, kind="device"):
"""The node at an address: a computer when it is one of them, else a device (one per address)."""
host = (address or "").split("//")[-1].split("/")[0].split(":")[0] if "://" in (address or "") else (address or "").split(":")[0]
if host in owner and owner[host]:
return "h:" + owner[host]
nid = "d:" + (host or norm(label))
node = nodes.setdefault(nid, {"id": nid, "label": label, "kind": kind, "address": host, "light": "unknown", "names": []})
if label not in node["names"]:
node["names"].append(label)
return nid
def edge(kind, a, b, label, light):
if not a or not b or a == b:
if a and a == b and a in nodes:
nodes[a].setdefault("local", []).append({"type": kind, "label": label, "light": light})
return
key = "|".join([kind] + sorted([a, b]) + [label])
edges[key] = {"id": key, "type": kind, "from": a, "to": b, "label": label, "light": light}
conns, conn_hosts = snap.get("conns") or {}, snap.get("conn_hosts") or {}
gear = cfg.get("gear") or {}
kinds = {"hyperdeck": "recorder", "tascam": "recorder", "shure": "wireless", "psm": "in-ears", "atem": "switcher"}
conn_kind = {c["conn"]: kinds.get(c["type"], "device") for t in cfg["tiles"] for c in t.get("checks", []) if c.get("conn")}
me_id = "h:" + me if me else None
for label, st in sorted(conns.items()):
address = conn_hosts.get(label) or (gear.get(label) or {}).get("address")
light = "ok" if st.get("category") == "good" else ("not_needed" if not st.get("enabled") else "problem")
if label == "audinate-dantecontroller":
continue # the Dante type draws the Dante network itself
node = target(address, label, conn_kind.get(label, "device")) if address else target(None, label, "service")
if node.startswith("d:"):
nodes[node]["light"] = light if nodes[node]["light"] in ("unknown", "ok") else nodes[node]["light"]
edge("companion", me_id, node, label, light)
sessions = {me: snap.get("midi") or {}} if me else {}
sessions.update(snap.get("remote_midi") or {})
for alias, sess in sessions.items():
for name, s in (sess or {}).items():
for peer in s.get("peers", []):
other = target(peer.get("address"), peer.get("name") or peer.get("address"), "MIDI")
edge("midi", "h:" + alias, other, "%s <-> %s" % (name, peer.get("name")),
"ok" if peer.get("state") in ("running", "connected") else "problem")
pings = snap.get("ping") or {}
for alias, h in hosts.items():
if h.get("self") or h.get("disabled"):
continue
probe = live.get(alias) or {}
ip = (h.get("ssh") or "").split("@")[-1]
ok = probe.get("reachable") if probe else (pings.get(ip) == 0 if ip in pings else None)
edge("watch", me_id, "h:" + alias, "logs in (SSH)", "unknown" if ok is None else ("ok" if ok else "problem"))
http = snap.get("http") or {}
for t in cfg["tiles"]:
for c in t.get("checks", []):
if c["type"] == "ping" and c.get("host"):
node = target(c["host"], c.get("name") or c["host"])
fails = pings.get(c["host"])
edge("watch", me_id, node, "ping", "unknown" if fails is None else ("ok" if fails == 0 else "problem"))
elif c["type"] == "http" and c.get("url"):
ok = http.get(c["url"])
node = target(c["url"], c.get("name") or c.get("msg") or c["url"].split("//")[-1].split("/")[0],
"device" if re.search(r"//(10|192\.168|172|127)\.", c["url"]) else "cloud")
edge("web", me_id, node, c.get("name") or c.get("msg") or "web page", "unknown" if ok is None else ("ok" if ok else "problem"))
for dev, d in sorted((snap.get("dante_map") or {}).items()):
sw = "sw:" + str(d.get("switch"))
nodes.setdefault(sw, {"id": sw, "label": "Dante switch %s" % d.get("switch"), "kind": "switch", "address": d.get("switch"),
"light": "ok", "names": []})
nd = norm(dev)
# A computer's Dante name ends with its own (NA-Tracks: tracks; NA-ProPresenter-Mac: ProPresenter Mac);
# STUDIO-B-MON is a monitor, not the Studio B Mac. A device already drawn joins up by name (SuperRack-LiveBox).
alias = next((a for a, h in hosts.items() if nd.endswith(norm(a)) or nd.endswith(norm(h.get("name", a)))), None)
same = next((n["id"] for n in nodes.values() if n["kind"] != "computer" and n["id"] != sw
and any(len(norm(x)) >= 6 and nd.endswith(norm(x)) for x in n.get("names", []))), None)
node = "h:" + alias if alias else same or target(None, dev, "Dante")
if node.startswith("d:") and dev not in nodes[node]["names"]:
nodes[node]["names"].append(dev)
fresh = time.time() - (d.get("seen") or 0) < 3600
edge("dante", node, sw, "port %s%s" % (d.get("port"), " (PoE)" if d.get("poe") else ""), "ok" if fresh else "unknown")
for src, age in (snap.get("artnet") or {}).items():
edge("artnet", target(src, "Art-Net from %s" % src), me_id, "Art-Net", "ok" if age < 10 else "problem")
rank = {"problem": 3, "ok": 1, "not_needed": 0, "unknown": -1}
for e in edges.values(): # a device is as good as what reaches it
for end in (e["from"], e["to"]):
n = nodes.get(end)
if n and n["kind"] != "computer" and rank.get(e["light"], -1) > rank.get(n["light"], -1):
n["light"] = e["light"]
used = {e["from"] for e in edges.values()} | {e["to"] for e in edges.values()}
out = [n for n in nodes.values() if n["kind"] == "computer" or n["id"] in used]
return {"nodes": out, "edges": list(edges.values()), "types": self.WIRE_TYPES, "me": me_id, "time": snap.get("time"),
"probing": bool(self.probing and self.probing.is_alive())}
def gear_view(self):
cfg = self.load()
snap = self.read_json("snapshot.json", {}) or {}
@@ -769,6 +897,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, site.companion_page(int(n)))
except (OSError, ValueError) as e:
return self.send(200, {"page": int(n), "buttons": [], "error": "Companion did not answer: %s" % e})
if path == "/api/wiring":
return self.send(200, site.wiring_view())
if path == "/api/machines":
return self.send(200, site.machines_view())
if path == "/api/gear":
+233 -2
View File
@@ -233,7 +233,7 @@
search.onblur = () => setTimeout(() => { results.hidden = true; }, 200);
// ---- routing ---------------------------------------------------------------
const screens = { overview, keys, computers, settings, logs, history, help, review };
const screens = { overview, keys, computers, wiring, settings, logs, history, help, review };
let timer = null;
async function route() {
let [name, ...rest] = (location.hash || "#overview").slice(1).split("/");
@@ -247,7 +247,7 @@
await fn(rest);
wireInputs(main);
wireOnOff(main);
const refresh = name === "overview" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
const refresh = name === "overview" || name === "wiring" || (name === "computers" && !["gear", "setup"].includes(rest[0]));
if (refresh) timer = setInterval(() => fn(rest, true).then(() => wireInputs(main)).catch(() => {}), 5000);
} catch (e) { main.innerHTML = `<p class="notice">Could not load this screen: ${esc(e.message)}</p>`; }
}
@@ -630,6 +630,237 @@
${canRunNow(r.fix) && editing() ? `<button class="small" data-run="${esc(r.id)}|${esc(r.fix)}|0">Run the fix</button>` : ""}<details><summary>more</summary><dl class="kv"><dt>id</dt><dd class="mono">${esc(r.id)}</dd>${r.watch && r.watch.length ? `<dt>flashes while down</dt><dd>${esc(r.watch.join(", "))}</dd>` : ""}</dl></details>${editing() ? `<p style="margin-top:12px"><button class="small danger" id="delrkey">Delete key</button></p>` : ""}</div>`;
}
// ---- Wiring ------------------------------------------------------------------
// Every computer and device the panel knows and what connects them, like a wiring diagram (2026-10-27). Drag a
// machine to arrange it; click one to see its scripts and connections; hide connection types or single
// connections. Positions and what is hidden are kept in this browser only.
const W_W = 2000, W_H = 1300;
const wire = { data: null, pos: null, hidden: null, types: null, sel: null, focus: false, problems: false, drag: null, view: null };
const wireRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } };
const wireSave = () => { store.set("sp-wire-pos", JSON.stringify(wire.pos)); store.set("sp-wire-hidden", JSON.stringify([...wire.hidden])); store.set("sp-wire-types", JSON.stringify([...wire.types])); };
const wireType = (id) => (wire.data.types.find((t) => t.id === id) || { label: id, color: "#888" });
const wireNode = (id) => wire.data.nodes.find((n) => n.id === id);
const wireName = (id) => (wireNode(id) || { label: id }).label;
async function wiring(rest, quiet) {
if (quiet && (wire.drag || document.getElementById("dialog").hidden === false)) return;
wire.data = await api("/api/wiring");
if (!wire.pos) { wire.pos = wireRead("sp-wire-pos", {}); wire.hidden = new Set(wireRead("sp-wire-hidden", [])); wire.types = new Set(wireRead("sp-wire-types", ["watch"])); } // the watchdog's own lines: hidden at first
if (wire.sel && !(wire.sel.edge ? wire.data.edges.some((e) => e.id === wire.sel.id) : wireNode(wire.sel.id))) wire.sel = null;
wireLayout();
if (!(quiet && document.getElementById("wiresvg"))) {
main.innerHTML = `<h1>Wiring${hint("What is connected to what. Drag a machine to move it, click it for its scripts and connections. Colours: the kind of connection; red = that connection has a problem; dashed = can't see it from here.")}</h1>
<div class="tabs" id="wiretools"></div><div class="wire"><div class="wirecanvas"><div class="wzoom"><button class="small" id="wzin" aria-label="zoom in">+</button><button class="small" id="wzout" aria-label="zoom out">−</button><button class="small" id="wfit" data-hint="Show everything that is shown">Fit</button></div><svg id="wiresvg" class="canvas" viewBox="0 0 ${W_W} ${W_H}" role="img" aria-label="wiring diagram"></svg></div><aside class="card wireside" id="wireside"></aside></div>`;
wireEvents();
}
wireTools(); wireDraw(); wireSide();
}
function wireLayout(all) {
const d = wire.data, cx = W_W / 2, cy = W_H / 2;
const fresh = d.nodes.filter((n) => all || !wire.pos[n.id]).map((n) => n.id);
if (!fresh.length) return;
const nb = {};
for (const e of d.edges) if (e.type !== "watch") { (nb[e.from] = nb[e.from] || []).push(e.to); (nb[e.to] = nb[e.to] || []).push(e.from); }
const move = new Set(fresh), hubs = d.nodes.filter((n) => (n.kind === "computer" || n.kind === "switch") && n.id !== d.me);
hubs.sort((a, b) => (a.kind === "switch") - (b.kind === "switch") || a.label.localeCompare(b.label));
if (d.me && move.has(d.me)) wire.pos[d.me] = { x: cx, y: cy };
const angle = {};
hubs.forEach((n, i) => { angle[n.id] = 2 * Math.PI * i / hubs.length - Math.PI / 2; if (move.has(n.id)) wire.pos[n.id] = { x: cx + W_W * 0.24 * Math.cos(angle[n.id]), y: cy + W_H * 0.26 * Math.sin(angle[n.id]) }; });
// Each device beside the machine it belongs to: its switch or computer with the most links, else Companion's Mac.
const anchor = {}, groups = {};
for (const n of d.nodes) {
if (n.kind === "computer" || n.kind === "switch") continue;
const count = {};
for (const o of nb[n.id] || []) if (angle[o] !== undefined) count[o] = (count[o] || 0) + 1;
const best = Object.keys(count).sort((a, b) => count[b] - count[a])[0] || d.me;
anchor[n.id] = best; (groups[best] = groups[best] || []).push(n);
}
for (const [hub, list] of Object.entries(groups)) {
list.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
list.forEach((n, i) => {
if (!move.has(n.id)) return;
if (hub === d.me || angle[hub] === undefined) { // the outer ring, kinds together
const a = 2 * Math.PI * i / list.length - Math.PI / 2 + 0.2;
wire.pos[n.id] = { x: cx + W_W * 0.43 * Math.cos(a), y: cy + W_H * 0.44 * Math.sin(a) };
} else { // a fan outward from its machine
const a = angle[hub] + (i - (list.length - 1) / 2) * 0.12, r = 1.0 + 0.16 * (1 + (i % 2));
wire.pos[n.id] = { x: cx + W_W * 0.24 * r * Math.cos(a), y: cy + W_H * 0.26 * r * Math.sin(a) };
}
});
}
// Push apart whatever overlaps (boxes, with a margin), each device held near its machine.
const ids = d.nodes.map((n) => n.id), size = {};
for (const n of d.nodes) size[n.id] = nodeSize(n);
for (let it = 0; it < 260; it++) {
for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
const a = ids[i], b = ids[j];
if (!move.has(a) && !move.has(b)) continue;
const pa = wire.pos[a], pb = wire.pos[b];
const ox = (size[a].w + size[b].w) / 2 + 14 - Math.abs(pa.x - pb.x), oy = (size[a].h + size[b].h) / 2 + 12 - Math.abs(pa.y - pb.y);
if (ox <= 0 || oy <= 0) continue;
const ma = move.has(a) ? (move.has(b) ? 0.5 : 1) : 0, mb = 1 - ma;
if (ox < oy) { const sx = (pa.x < pb.x ? -1 : 1) * ox; pa.x += sx * ma; pb.x -= sx * mb; }
else { const sy = (pa.y < pb.y ? -1 : 1) * oy; pa.y += sy * ma; pb.y -= sy * mb; }
}
for (const id of move) {
const h = anchor[id], p = wire.pos[id];
if (h && h !== d.me && wire.pos[h]) { const q = wire.pos[h], dx = q.x - p.x, dy = q.y - p.y, dd = Math.hypot(dx, dy) || 1; if (dd > 230) { p.x += dx * 0.02; p.y += dy * 0.02; } }
p.x = Math.max(size[id].w / 2 + 6, Math.min(W_W - size[id].w / 2 - 6, p.x)); p.y = Math.max(20, Math.min(W_H - 20, p.y));
}
}
wireSave();
}
function wireFit() {
const { nodes } = wireShown();
if (!nodes.length) { wire.view = { x: 0, y: 0, w: W_W, h: W_H }; return; }
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) { const p = wire.pos[n.id], { w, h } = nodeSize(n); x0 = Math.min(x0, p.x - w / 2); x1 = Math.max(x1, p.x + w / 2); y0 = Math.min(y0, p.y - h / 2); y1 = Math.max(y1, p.y + h / 2); }
const svg = document.getElementById("wiresvg"), box = svg && svg.getBoundingClientRect();
const pad = 30, w = x1 - x0 + 2 * pad, h = y1 - y0 + 2 * pad, ratio = box && box.height ? box.width / box.height : W_W / W_H;
const vw = Math.max(w, h * ratio), vh = vw / ratio;
wire.view = { x: (x0 + x1) / 2 - vw / 2, y: (y0 + y1) / 2 - vh / 2, w: vw, h: vh };
}
function wireZoom(f, px, py) {
const v = wire.view, nw = Math.max(250, Math.min(W_W * 2.5, v.w * f)), k = nw / v.w;
px = px == null ? v.x + v.w / 2 : px; py = py == null ? v.y + v.h / 2 : py;
wire.view = { x: px - (px - v.x) * k, y: py - (py - v.y) * k, w: nw, h: v.h * k };
const svg = document.getElementById("wiresvg"); if (svg) svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
}
function wireShown() {
const d = wire.data, sel = wire.sel && !wire.sel.edge ? wire.sel.id : null;
const edges = d.edges.filter((e) => !wire.types.has(e.type) && !wire.hidden.has(e.id) && (!wire.problems || e.light === "problem")
&& (!wire.focus || !sel || e.from === sel || e.to === sel));
const ends = new Set(); for (const e of edges) { ends.add(e.from); ends.add(e.to); }
const nodes = d.nodes.filter((n) => ends.has(n.id) || (n.kind === "computer" && !wire.focus && !wire.problems) || n.id === sel);
return { edges, nodes };
}
const nodeSize = (n) => n.kind === "computer" ? { w: 168, h: 46 } : { w: Math.min(190, 26 + 6.6 * String(n.label).length), h: 26 };
function wireDraw() {
const svg = document.getElementById("wiresvg");
if (!svg) return;
if (!wire.view) wireFit();
svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`);
const { edges, nodes } = wireShown(), sel = wire.sel;
const near = new Set();
if (sel && !sel.edge) { near.add(sel.id); for (const e of edges) if (e.from === sel.id || e.to === sel.id) { near.add(e.from); near.add(e.to); } }
if (sel && sel.edge) { const e = wire.data.edges.find((x) => x.id === sel.id); if (e) { near.add(e.from); near.add(e.to); } }
const pairs = {};
for (const e of edges) { const k = [e.from, e.to].sort().join("|"); (pairs[k] = pairs[k] || []).push(e); }
let out = "";
for (const list of Object.values(pairs)) list.forEach((e, i) => {
const a = wire.pos[e.from], b = wire.pos[e.to];
if (!a || !b) return;
const off = (i - (list.length - 1) / 2) * 16, dx = b.x - a.x, dy = b.y - a.y, len = Math.hypot(dx, dy) || 1;
const mx = (a.x + b.x) / 2 - dy / len * off, my = (a.y + b.y) / 2 + dx / len * off;
const t = wireType(e.type), on = sel ? (sel.edge ? sel.id === e.id : e.from === sel.id || e.to === sel.id) : true;
out += `<path class="wedge ${esc(e.light)}${on ? "" : " dim"}${sel && sel.edge && sel.id === e.id ? " sel" : ""}" data-edge="${esc(e.id)}" d="M${a.x},${a.y} Q${mx},${my} ${b.x},${b.y}" style="stroke:${e.light === "problem" ? "var(--problem)" : t.color}"><title>${esc(t.label)}: ${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} · ${esc(e.label)} · ${esc(lightWord[e.light] || e.light)}</title></path>`;
});
for (const n of nodes) {
const p = wire.pos[n.id], { w, h } = nodeSize(n), dim = sel && !near.has(n.id);
const scripts = n.scripts ? n.scripts.filter((x) => x.running).length : null;
const shape = n.kind === "computer" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="9"/>`
: n.kind === "switch" ? `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="2"/>`
: `<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>`;
const name = String(n.label).length > 26 ? String(n.label).slice(0, 25) + "…" : n.label;
out += `<g class="wnode ${esc(n.kind)} ${esc(n.light)}${dim ? " dim" : ""}${sel && !sel.edge && sel.id === n.id ? " sel" : ""}" data-node="${esc(n.id)}" transform="translate(${p.x},${p.y})"><title>${esc(n.label)}${n.address ? " · " + esc(n.address) : ""}${n.reason ? " · " + esc(n.reason) : ""}</title>${shape}
<circle class="wdot" cx="${-w / 2 + 12}" cy="0" r="5"/><text x="${-w / 2 + 22}" y="${n.kind === "computer" ? -3 : 4}">${esc(name)}</text>
${n.kind === "computer" ? `<text class="wsub" x="${-w / 2 + 22}" y="13">${scripts == null ? "scripts: not looked yet" : scripts + " script" + (scripts === 1 ? "" : "s") + " running"}${n.self ? " · this Mac" : ""}</text>` : ""}</g>`;
}
svg.innerHTML = out;
}
function wireTools() {
const el = document.getElementById("wiretools");
if (!el) return;
const d = wire.data, count = {};
for (const e of d.edges) count[e.type] = (count[e.type] || 0) + 1;
const bad = d.edges.filter((e) => e.light === "problem").length;
el.innerHTML = d.types.filter((t) => count[t.id]).map((t) => `<button class="small wtype${wire.types.has(t.id) ? " off" : " on"}" data-wtype="${esc(t.id)}" data-hint="${esc(t.hint)} · click to ${wire.types.has(t.id) ? "show" : "hide"}"><i style="background:${t.color}"></i>${esc(t.label)} <span class="muted">${count[t.id]}</span></button>`).join("")
+ `<button class="small${wire.problems ? " on" : ""}" id="wprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>`
+ `<button class="small" id="wall" data-hint="Show every connection again">Show all</button>`
+ `<button class="small" id="wre" data-hint="Forget where machines were dragged and lay them out again">Re-arrange</button>`
+ `<button class="small" id="wprobe" style="margin-left:auto" ${d.probing ? "disabled" : ""} data-hint="Log in to every computer now and list its scripts (read-only)">${d.probing ? "Looking…" : "Check scripts now"}</button>`;
for (const b of el.querySelectorAll("[data-wtype]")) b.onclick = () => { const t = b.dataset.wtype; wire.types.has(t) ? wire.types.delete(t) : wire.types.add(t); wireSave(); wireTools(); wireDraw(); wireSide(); };
el.querySelector("#wprob").onclick = () => { wire.problems = !wire.problems; wireTools(); wireDraw(); wireSide(); };
el.querySelector("#wall").onclick = () => { wire.types.clear(); wire.hidden.clear(); wire.focus = false; wire.problems = false; wireSave(); wireTools(); wireDraw(); wireSide(); };
el.querySelector("#wre").onclick = () => { wireLayout(true); wireFit(); wireDraw(); };
el.querySelector("#wprobe").onclick = async (ev) => { ev.target.disabled = true; ev.target.textContent = "Looking…"; try { await post("/api/machines/probe"); } catch (e) { alertBox(e.message); } setTimeout(() => wiring([], true), 6000); };
}
function wireSide() {
const el = document.getElementById("wireside");
if (!el) return;
const d = wire.data, sel = wire.sel;
const row = (e, other) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}" ${wire.hidden.has(e.id) ? "" : "checked"}>${lightDot(e.light, lightWord[e.light])}<span><b>${esc(wireName(other))}</b> <span class="muted small">${esc(e.label)}</span></span></label></li>`;
let html;
if (sel && sel.edge) {
const e = d.edges.find((x) => x.id === sel.id), t = wireType(e.type);
html = `<h2><i class="wsw" style="background:${t.color}"></i>${esc(t.label)}</h2><p>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p>
<p><button class="small" data-whide-one="${esc(e.id)}">${wire.hidden.has(e.id) ? "Show" : "Hide"} this connection</button> <button class="small" data-wsel="${esc(e.from)}">${esc(wireName(e.from))}</button> <button class="small" data-wsel="${esc(e.to)}">${esc(wireName(e.to))}</button></p>`;
} else if (sel) {
const n = wireNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id);
const byType = {};
for (const e of mine) (byType[e.type] = byType[e.type] || []).push(e);
const scripts = n.kind !== "computer" ? "" : n.scripts == null
? `<h3>Scripts</h3><p class="small muted">Not looked yet: press Check scripts now.</p>`
: `<h3>Scripts <span class="muted small">${n.scripts.filter((x) => x.running).length} running${n.scripts.some((x) => !x.running) ? `, ${n.scripts.filter((x) => !x.running).length} stopped` : ""}</span></h3>${n.scripts.length ? `<ul class="wlist">${n.scripts.map((x) => `<li>${lightDot(x.running ? "ok" : "not_needed", x.running ? "running" : (x.state || "not running"))}<span class="mono small">${esc(x.name)}</span>${x.by && x.by !== x.name ? ` <span class="muted small">${esc(x.by.replace(/^org\.waterschurch\./, ""))}</span>` : ""}</li>`).join("")}</ul>` : `<p class="small muted">None.</p>`}${n.looked ? `<p class="small muted">looked ${esc(ago(Date.now() / 1000 - n.looked))}</p>` : ""}`;
html = `<h2>${lightDot(n.light, lightWord[n.light])} ${esc(n.label)}</h2><p class="small muted">${esc(n.kind === "computer" ? (n.os === "windows" ? "Windows PC" : "Mac") + (n.self ? " · this Mac (the panel)" : "") : n.kind)}${n.address ? " · " + esc(n.address) : ""}${n.role ? " · " + esc(n.role) : ""}</p>
${n.reason ? `<p class="small">${esc(n.reason)}</p>` : ""}${n.names && n.names.length > 1 ? `<p class="small muted">also called ${esc(n.names.filter((x) => x !== n.label).join(", "))}</p>` : ""}
<p><button class="small${wire.focus ? " on" : ""}" id="wfocus">${wire.focus ? "Show everything" : "Only this one's connections"}</button></p>${scripts}
<h3>Connections <span class="muted small">${mine.length}</span></h3>${Object.entries(byType).map(([t, list]) => `<p class="small wtyp"><i class="wsw" style="background:${wireType(t).color}"></i>${esc(wireType(t).label)}</p><ul class="wlist">${list.map((e) => row(e, e.from === n.id ? e.to : e.from)).join("")}</ul>`).join("") || `<p class="small muted">None.</p>`}
${(n.local || []).length ? `<p class="small muted">On this machine itself: ${esc(n.local.map((x) => x.label).join(", "))}</p>` : ""}`;
} else {
const comps = d.nodes.filter((n) => n.kind === "computer");
const bad = d.edges.filter((e) => e.light === "problem");
html = `<h2>${comps.length} computers, ${d.nodes.length - comps.length} devices</h2><p class="small">${d.edges.length} connections. Click a machine for its scripts and connections; untick a connection to hide it.</p>
<h3>Scripts per computer</h3><ul class="wlist">${comps.map((n) => `<li><a href="#" data-wsel="${esc(n.id)}">${esc(n.label)}</a> <span class="muted small">${n.scripts == null ? "not looked yet" : n.scripts.filter((x) => x.running).length + " running"}</span></li>`).join("")}</ul>
${bad.length ? `<h3>Problems <span class="muted small">${bad.length}</span></h3><ul class="wlist">${bad.map((e) => `<li><a href="#" data-wedge="${esc(e.id)}">${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</a> <span class="muted small">${esc(e.label)}</span></li>`).join("")}</ul>` : ""}
${wire.hidden.size ? `<h3>Hidden <span class="muted small">${wire.hidden.size}</span></h3><ul class="wlist">${[...wire.hidden].map((id) => d.edges.find((e) => e.id === id)).filter(Boolean).map((e) => `<li><label class="wrow"><input type="checkbox" data-whide="${esc(e.id)}"><span>${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))} <span class="muted small">${esc(e.label)}</span></span></label></li>`).join("")}</ul>` : ""}`;
}
el.innerHTML = html;
for (const c of el.querySelectorAll("[data-whide]")) c.onchange = () => { c.checked ? wire.hidden.delete(c.dataset.whide) : wire.hidden.add(c.dataset.whide); wireSave(); wireDraw(); wireSide(); };
for (const b of el.querySelectorAll("[data-whide-one]")) b.onclick = () => { const id = b.dataset.whideOne; wire.hidden.has(id) ? wire.hidden.delete(id) : wire.hidden.add(id); wireSave(); wireDraw(); wireSide(); };
for (const a of el.querySelectorAll("[data-wsel]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wsel }; wireDraw(); wireSide(); };
for (const a of el.querySelectorAll("[data-wedge]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wedge, edge: true }; wireDraw(); wireSide(); };
const f = el.querySelector("#wfocus"); if (f) f.onclick = () => { wire.focus = !wire.focus; wireDraw(); wireSide(); };
}
function wireEvents() {
const svg = document.getElementById("wiresvg");
const at = (ev) => { const pt = svg.createSVGPoint(); pt.x = ev.clientX; pt.y = ev.clientY; return pt.matrixTransform(svg.getScreenCTM().inverse()); };
document.getElementById("wzin").onclick = () => wireZoom(0.8);
document.getElementById("wzout").onclick = () => wireZoom(1.25);
document.getElementById("wfit").onclick = () => { wireFit(); wireDraw(); };
svg.addEventListener("wheel", (ev) => { ev.preventDefault(); const p = at(ev); wireZoom(ev.deltaY > 0 ? 1.12 : 1 / 1.12, p.x, p.y); }, { passive: false });
svg.addEventListener("pointerdown", (ev) => {
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]");
const p = at(ev);
if (g) { const id = g.dataset.node; wire.drag = { id, dx: wire.pos[id].x - p.x, dy: wire.pos[id].y - p.y, x0: ev.clientX, y0: ev.clientY, moved: false }; }
else if (path) { wire.sel = { id: path.dataset.edge, edge: true }; wireDraw(); wireSide(); return; }
else wire.drag = { pan: true, x0: ev.clientX, y0: ev.clientY, vx: wire.view.x, vy: wire.view.y, k: wire.view.w / svg.getBoundingClientRect().width, moved: false };
svg.setPointerCapture(ev.pointerId); ev.preventDefault();
});
svg.addEventListener("pointermove", (ev) => {
const dr = wire.drag; if (!dr) return;
if (!dr.moved && Math.hypot(ev.clientX - dr.x0, ev.clientY - dr.y0) < 4) return;
dr.moved = true;
if (dr.pan) { wire.view.x = dr.vx - (ev.clientX - dr.x0) * dr.k; wire.view.y = dr.vy - (ev.clientY - dr.y0) * dr.k; svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`); return; }
const p = at(ev);
wire.pos[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
wireDraw();
});
const end = () => {
const dr = wire.drag; wire.drag = null; if (!dr) return;
if (dr.pan) { if (!dr.moved) { wire.sel = null; wire.focus = false; wireDraw(); wireSide(); } return; }
if (dr.moved) wireSave(); else { wire.sel = wire.sel && !wire.sel.edge && wire.sel.id === dr.id ? null : { id: dr.id }; if (!wire.sel) wire.focus = false; }
wireDraw(); wireSide();
};
svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end);
}
// ---- Computers ---------------------------------------------------------------
let compTab = "map";
async function computers(rest, quiet) {
+1
View File
@@ -13,6 +13,7 @@
<a href="#overview" data-hint="Is the room OK"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="1.5"/><rect x="13" y="3" width="8" height="8" rx="1.5"/><rect x="3" y="13" width="8" height="8" rx="1.5"/><rect x="13" y="13" width="8" height="8" rx="1.5"/></svg><span>Overview</span></a>
<a href="#keys" data-hint="The deck: what each key watches and does"><svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h2M10 10h2M14 10h2M18 10h0M6 14h12"/></svg><span>Keys</span></a>
<a href="#computers" data-hint="What runs where"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg><span>Computers</span></a>
<a href="#wiring" data-hint="What is connected to what, and the scripts on each computer"><svg viewBox="0 0 24 24"><rect x="2" y="3" width="7" height="5" rx="1"/><rect x="15" y="3" width="7" height="5" rx="1"/><rect x="8.5" y="16" width="7" height="5" rx="1"/><path d="M5.5 8v3.5h13V8M12 11.5V16"/></svg><span>Wiring</span></a>
<a href="#settings" data-hint="Everything you can change"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg><span>Settings</span></a>
<a href="#logs" data-hint="What the watchdog did and saw"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span>Logs</span></a>
<a href="#history" data-hint="Every version ever applied"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>History</span></a>
+32
View File
@@ -153,3 +153,35 @@ details.script textarea { width: 100%; font-size: .8rem; }
.onoff.on span { background: #1f8f4e; color: #fff; }
.onoff input:disabled + span { opacity: .75; cursor: default; }
tr.off td, .trig.off, .key.off { opacity: .55; }
/* Wiring: the diagram and the panel beside it (one column on a phone) */
.wire { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; align-items: start; }
@media (max-width: 900px) { .wire { grid-template-columns: minmax(0, 1fr); } }
.wirecanvas { position: relative; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; touch-action: none; }
.wzoom { position: absolute; top: 8px; left: 8px; display: flex; gap: 4px; z-index: 1; } .wzoom button { min-width: 30px; justify-content: center; }
.wirecanvas svg.canvas { cursor: move; }
.wirecanvas svg.canvas { width: 100%; height: calc(100vh - 175px); min-height: 420px; display: block; fill: none; stroke: none; stroke-width: 1; user-select: none; -webkit-user-select: none; }
.wedge { fill: none; stroke-width: 2.2; opacity: .85; cursor: pointer; }
.wedge:hover, .wedge.sel { stroke-width: 4.5; opacity: 1; }
.wedge.unknown { stroke-dasharray: 5 5; } .wedge.not_needed { opacity: .3; }
.wedge.problem { stroke-width: 3.4; stroke-dasharray: 9 5; animation: wflow 1s linear infinite; }
@keyframes wflow { to { stroke-dashoffset: -14; } }
.wedge.dim, .wnode.dim { opacity: .12; }
.wnode { cursor: grab; } .wnode:active { cursor: grabbing; }
.wnode rect { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
.wnode.computer rect { stroke: var(--fg); stroke-width: 1.8; }
.wnode.switch rect { fill: var(--bg); }
.wnode.problem rect { stroke: var(--problem); stroke-width: 2.5; }
.wnode.sel rect { stroke: var(--accent); stroke-width: 3.5; }
.wnode text { fill: var(--fg); font: 600 13px var(--font); stroke: none; }
.wnode:not(.computer) text { font-weight: 500; font-size: 12px; }
.wnode text.wsub { fill: var(--muted); font: 11px var(--font); }
.wnode .wdot { fill: var(--unknown); stroke: none; }
.wnode.ok .wdot { fill: var(--ok); } .wnode.problem .wdot { fill: var(--problem); } .wnode.not_needed .wdot { fill: var(--none); }
.wtype i, .wsw { display: inline-block; width: 12px; height: 4px; border-radius: 2px; margin-right: 2px; vertical-align: middle; }
.wtype.off { opacity: .45; text-decoration: line-through; } .wtype.on { border-color: var(--line); }
.wireside { position: sticky; top: 70px; max-height: calc(100vh - 90px); overflow: auto; margin: 0; }
.wireside h2 { margin: 4px 0 6px; font-size: 1.1rem; } .wireside h3 { margin: 14px 0 4px; font-size: .95rem; }
.wlist { list-style: none; margin: 0; padding: 0; } .wlist li { padding: 3px 0; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.wrow { display: flex; gap: 6px; align-items: center; cursor: pointer; } .wtyp { margin: 8px 0 2px; }
main:has(.wire) { max-width: none; }
+46 -6
View File
@@ -484,6 +484,25 @@ def check_launchd(chk, snap, ctx):
return [("svc", Result(OK))]
def check_heartbeat(chk, snap, ctx):
"""A service that writes the time into a Companion variable (the Resi auto-record: custom autorec_hb):
older than max_age_seconds is stuck. With status_var, a status containing warn_if is amber."""
conn = chk.get("conn", "custom")
raw = snap.var(conn, chk["var"])
if raw in (None, ""):
return [("hb", Result(OK))] # not read yet
try:
age = ctx["now"] - float(raw)
except ValueError:
age = None
if age is None or age > chk.get("max_age_seconds", 90):
return [("hb", Result(ERR, chk.get("msg", "STUCK"), named=True))]
status = (snap.var(conn, chk["status_var"]) or "") if chk.get("status_var") else ""
if chk.get("warn_if") and chk["warn_if"].upper() in status.upper():
return [("hb", Result(WARN, chk.get("warn_msg", chk["warn_if"]), named=True))]
return [("hb", Result(OK))]
def check_process(chk, snap, ctx):
running = snap.procs.get(chk["match"])
if running is False:
@@ -956,7 +975,7 @@ CHECKS = {
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "midi_wired": check_midi_wired, "pref": check_pref, "spotify": check_spotify,
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
"ready": check_ready, "ready_manual": check_ready_manual, "tune_rx": check_tune_rx,
"ready": check_ready, "ready_manual": check_ready_manual, "tune_rx": check_tune_rx, "heartbeat": check_heartbeat,
}
@@ -1040,6 +1059,14 @@ def needed_variables(cfg):
rw = cfg.get("rec_watch") or {}
if rw.get("conn"):
fast.append((rw["conn"], rw.get("var", "playback_state")))
if rw.get("heartbeat"):
fast.append((rw["conn"], rw["heartbeat"]))
for tile in all_tiles(cfg):
for chk in tile.get("checks", []):
if chk["type"] == "heartbeat":
fast.append((chk.get("conn", "custom"), chk["var"]))
if chk.get("status_var"):
fast.append((chk.get("conn", "custom"), chk["status_var"]))
dedupe = lambda xs: list(dict.fromkeys(xs))
return dedupe(fast), dedupe(slow)
@@ -1957,13 +1984,22 @@ class Engine:
self.toggle_bypass(tid, comp, now)
def resi_playing(self, snap, now):
"""config "rec_watch": Resi's playback (resi-studio-cues "playback_state") has read PLAYING for
after_seconds, with the module itself connected. The decks must record then."""
"""config "rec_watch": Resi is live and the decks should record, for after_seconds. NA reads the Resi
auto-record service's own verdict (custom variable autorec_status: RECORDING / STARTING / LIVE, NOT
RECORDING; not while it holds REC until 10 minutes before a service), and nothing while its heartbeat
is stale. Without a heartbeat the connection must be up (a module's playback_state PLAYING)."""
rw = self.cfg.get("rec_watch") or {}
if not rw.get("conn"):
return False
value = (snap.var(rw["conn"], rw.get("var", "playback_state")) or "").strip().upper()
if value != rw.get("playing", "PLAYING").upper() or conn_result(snap, rw["conn"], now).level != OK:
value = (snap.var(rw["conn"], rw.get("var", "playback_state")) or "").upper()
if rw.get("heartbeat"):
try:
fresh = now - float(snap.var(rw["conn"], rw["heartbeat"]) or 0) < rw.get("stale_seconds", 90)
except ValueError:
fresh = False
else:
fresh = conn_result(snap, rw["conn"], now).level == OK
if not fresh or not any(w.upper() in value for w in rw.get("live", ["PLAYING"])):
self.resi_since = None
return False
self.resi_since = self.resi_since or now
@@ -2865,7 +2901,11 @@ def companion_conn_hosts(path):
inst = json.loads(value)
except (TypeError, ValueError):
continue
host = (inst.get("config") or {}).get("host") if isinstance(inst, dict) else None
conf = (inst.get("config") or {}) if isinstance(inst, dict) else {}
host = conf.get("host")
if not (isinstance(host, str) and host.strip()) and isinstance(conf.get("bonjourHost"), str):
host = conf["bonjourHost"].rsplit(":", 1)[0] if re.match(r"^[\d.]+:\d+$", conf["bonjourHost"].strip()) \
else conf["bonjourHost"] # HyperDecks: "10.1.10.11:9993"
if inst.get("label") and isinstance(host, str) and host.strip():
hosts[inst["label"]] = host.strip()
return hosts
+53
View File
@@ -102,6 +102,40 @@ class TestServer(unittest.TestCase):
self.assertEqual(web["light"], "ok") # from snapshot.json, not a probe
self.assertIn("mac", o["setup_steps"])
def test_wiring_shows_machines_their_connections_and_scripts(self):
# 2026-10-27: "like a wiring diagram ... how many and what scripts are running on each machine"
live = {"tracks": {"alias": "tracks", "reachable": True, "time": time.time(), "items": [],
"found": {"scripts": [{"name": "statuspanel.py", "pid": 5, "by": "", "running": True}]}}}
json.dump(live, open(os.path.join(self.data, "machines-live.json"), "w"))
snap = json.load(open(os.path.join(self.data, "snapshot.json")))
snap.update({"conns": {"ATEM": {"category": "good", "enabled": True}, "x32": {"category": "error", "enabled": True},
"HyperDeck_1": {"category": "good", "enabled": True}, "tcp-HyperDeck-1": {"category": "good", "enabled": True}},
"conn_hosts": {"ATEM": "10.1.10.2", "x32": "10.1.1.112", "HyperDeck_1": "10.1.10.11", "tcp-HyperDeck-1": "10.1.10.11"},
"remote_midi": {"tracks": {"NA Tracks": {"peers": [{"address": "10.1.13.140:5004", "name": "SuperRack-LiveBox", "state": "running"}]}}},
"dante_map": {"NA-Tracks": {"switch": "10.66.1.100", "port": "0/6", "seen": time.time()},
"FOH-SuperRack-LiveBox": {"switch": "10.66.1.102", "port": "0/9", "seen": time.time()},
"STUDIO-B-MON": {"switch": "10.66.1.100", "port": "0/2", "seen": time.time()}}})
path = os.path.join(self.data, "snapshot.json")
old = open(path).read()
try:
json.dump(snap, open(path, "w"))
w = json.loads(get(self.port, "/api/wiring")[1])
finally:
open(path, "w").write(old)
os.remove(os.path.join(self.data, "machines-live.json"))
nodes = {n["id"]: n for n in w["nodes"]}
edges = [(e["type"], e["from"], e["to"], e["light"]) for e in w["edges"]]
self.assertEqual(w["me"], "h:ingest")
self.assertEqual(nodes["h:tracks"]["scripts"][0]["name"], "statuspanel.py")
self.assertIsNone(nodes["h:prop"]["scripts"]) # not looked yet
self.assertIn(("companion", "h:ingest", "d:10.1.1.112", "problem"), edges)
self.assertEqual(sorted(nodes["d:10.1.10.11"]["names"]), ["HyperDeck_1", "tcp-HyperDeck-1"]) # one deck, one box
self.assertIn(("midi", "h:tracks", "d:10.1.13.140", "ok"), edges)
self.assertIn(("dante", "h:tracks", "sw:10.66.1.100", "ok"), edges) # NA-Tracks is the Tracks Mac
self.assertIn("FOH-SuperRack-LiveBox", nodes["d:10.1.13.140"]["names"]) # same box as its MIDI peer
self.assertIn("d:studiobmon", nodes) # a monitor, not the Studio B Mac
self.assertEqual({t["id"] for t in w["types"]} >= {"companion", "midi", "watch", "dante"}, True)
def test_gear_and_keys_and_pages(self):
gear = json.loads(get(self.port, "/api/gear")[1])
self.assertTrue(any(r["name"] == "HyperDeck_2" and "hd2" in r["watched_by"] for r in gear))
@@ -362,6 +396,25 @@ class TestBindingAndProbes(unittest.TestCase):
self.assertEqual(by["sshd"]["light"], "ok")
self.assertEqual((setup["profile"], setup["wake_on_lan"], setup["firewall_allow"]), ("Public", "Enabled", "10.1.1.1,10.6.3.222"))
self.assertIn("\\StatusPanel\\Start Arena", found["tasks"])
scripts = {x["name"]: x["running"] for x in found["scripts"]}
self.assertEqual((scripts["Start Vista"], scripts["Start rtpMIDI"]), (True, False)) # Vista.exe runs; rtpMIDI.exe does not
def test_scripts_are_listed_by_file_name_only(self):
agents = {"org.waterschurch.prop-guard": 412, "org.waterschurch.status-panel": None, "com.apple.x": 9}
lines = ["412 /usr/bin/python3 /Users/m/Library/Application Support/status-panel/prop_guard.py /Users/m/cfg --token=SECRET",
"77 /bin/bash /Users/m/backup/backup-campuses.sh", "55 /Applications/Companion.app/Contents/Resources/node main.js",
"60 node /Users/m/Library/Application Support/companion/modules/x/main.js", "9 zsh -c echo SP_SCRIPTS",
"88 /Applications/Xcode.app/Contents/Developer/Library/Frameworks/Python3.framework/Versions/3.9/Resources/"
"Python.app/Contents/MacOS/Python /Users/m/Library/Application Support/resi-autorec/resi_autorec.py"]
got = probes.parse_scripts(lines, agents)
self.assertEqual([(x["name"], x["by"], x["running"]) for x in got],
[("backup-campuses.sh", "", True), ("prop_guard.py", "org.waterschurch.prop-guard", True),
("resi_autorec.py", "", True),
("status-panel", "org.waterschurch.status-panel", False)])
self.assertNotIn("SECRET", json.dumps(got)) # never the rest of the line
win = probes.parse_scripts(['812 powershell.exe -File "C:\\ProgramData\\StatusPanel\\start-vista.ps1"'],
tasks={"\\StatusPanel\\Start Vista": "Running", "\\StatusPanel\\Start rtpMIDI": "Ready"})
self.assertEqual([(x["name"], x["running"]) for x in win], [("Start Vista", True), ("start-vista.ps1", True), ("Start rtpMIDI", False)])
def test_probe_commands_never_carry_names_from_the_settings(self):
cfg = json.loads(json.dumps(CONFIG))
+36 -18
View File
@@ -76,7 +76,8 @@ def healthy_snapshot(now):
snap.ping = {"10.1.3.227": 0, "10.6.7.13": 0, "10.1.0.1": 0}
snap.link = True
snap.launchd = {"org.waterschurch.resi-propresenter": 884, "org.waterschurch.lobby-captions": 900,
"org.waterschurch.lobby-captions-display": 901, "org.waterschurch.digico-relay": 902}
"org.waterschurch.lobby-captions-display": 901, "org.waterschurch.digico-relay": 902,
"org.waterschurch.resi-autorec": 903}
snap.launchd_time = now
snap.procs = {"/Applications/ProdDash.app/Contents/MacOS/ProdDash": True}
snap.remote = {"studiob\0pgrep -xq 'Pro Tools'": True} # the failover's check has a verdict
@@ -488,12 +489,14 @@ class TestChecks(unittest.TestCase):
db.execute("CREATE TABLE instances (id STRING, value STRING)")
rows = [("a", {"label": "Axient_1", "config": {"host": "10.1.1.101", "port": 2202}}),
("b", {"label": "OBS-Youtube", "config": {"host": " 10.1.9.126 "}}),
("c", {"label": "no-host", "config": {}}), ("d", "not json")]
("c", {"label": "no-host", "config": {}}), ("d", "not json"),
("e", {"label": "HyperDeck_1", "config": {"bonjourHost": "10.1.10.11:9993"}})]
db.executemany("INSERT INTO instances VALUES (?, ?)",
[(i, v if isinstance(v, str) else json.dumps(v)) for i, v in rows])
db.commit()
db.close()
self.assertEqual(sp.companion_conn_hosts(path), {"Axient_1": "10.1.1.101", "OBS-Youtube": "10.1.9.126"})
self.assertEqual(sp.companion_conn_hosts(path), {"Axient_1": "10.1.1.101", "OBS-Youtube": "10.1.9.126",
"HyperDeck_1": "10.1.10.11"})
self.assertEqual(sp.companion_conn_hosts(os.path.join(d, "missing.sqlite")), {})
def test_fx_lights_midi_on_the_tracks_mac(self):
@@ -1517,43 +1520,58 @@ class TestFixes(unittest.TestCase):
self.assertEqual(self.plan("rec"), [("enable", "HyperDeck_1")])
self.assertEqual(self.plan("x32"), [("enable", "x32")])
def autorec(self, status, hb=None):
"""What the Resi auto-record service publishes (custom variables)."""
self.snap.vars[("custom", "autorec_status")] = status
self.snap.vars[("custom", "autorec_hb")] = str(int(SUNDAY + 100000 if hb is None else hb))
def test_resi_live_and_no_deck_recording_is_red_and_a_hold_starts_them(self):
# 2026-10-27: "notify me if we are not recording on the hyperdecks, but resi is playing"
self.snap.vars[("resi-studio-cues", "playback_state")] = "PLAYING"
self.autorec("AUTO REC\nLIVE, NOT\nRECORDING")
self.snap.vars[("HyperDeck_2", "slotId")] = "-" # optional deck, no disk: not in use
self.snap.vars[("HyperDeck_2", "recordingTime")] = "--:--:--"
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 30)
self.assertEqual(level(tiles["hd1"]), "ok") # not yet: Resi just started
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 30, 40)
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 10)
self.assertEqual(level(tiles["hd1"]), "ok") # not yet: give it a moment
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 10, 30)
self.assertEqual(tiles["hd1"]["x"], "HD1\nRESI LIVE NOT REC")
self.assertEqual(level(tiles["rec"]), "err")
self.assertEqual(level(tiles["hd2"]), "off")
self.snap.vars[("HyperDeck_3", "status")] = "Record" # once one records, the others are NOT REC
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 70, 4)
self.autorec("AUTO REC\nRECORDING\n9:30 AM")
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 40, 4)
self.assertEqual(tiles["hd1"]["x"], "HD1\nNOT REC")
started = []
self.engine.record_executor = lambda ip, name: started.append((ip, name)) or True
self.engine.run_fixes("rec", self.snap, None, SUNDAY + 80)
self.engine.run_fixes("rec", self.snap, None, SUNDAY + 50)
time.sleep(0.2)
self.assertEqual([ip for ip, _ in started], ["10.1.10.11", "10.1.10.14", "10.1.10.15", "10.1.10.16"])
self.assertTrue(started[0][1].startswith("CAM3-") and started[0][1].endswith("-"))
self.assertTrue(started[3][1].startswith("PGM4-"))
self.assertEqual(self.engine.notes["rec"]["text"], "STARTING\nREC")
def test_resi_paused_or_its_module_down_is_not_live(self):
self.snap.vars[("resi-studio-cues", "playback_state")] = "PAUSED"
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 70)
def test_not_live_while_auto_rec_waits_or_is_stuck(self):
self.autorec("AUTO REC\nREC AT\n9:20 AM") # live early: REC held until 10 min before
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 40)
self.assertEqual(level(tiles["hd1"]), "ok")
self.snap.vars[("resi-studio-cues", "playback_state")] = "PLAYING"
self.snap.conns["resi-studio-cues"]["category"] = "error"
run_steps(self.engine, self.snap, SUNDAY + 70, 70)
self.autorec("AUTO REC\nLIVE, NOT\nRECORDING", hb=SUNDAY - 600) # a stale verdict counts for nothing...
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 40, 200)
self.assertFalse(self.engine.resi_live)
self.assertEqual(tiles["ingest"]["x"], "INGEST\nAUTO REC STUCK") # ...and says the service is stuck
ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(chk.get("conn")) or True
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
self.engine.record_executor = lambda ip, name: self.fail("must not record when Resi is not live")
self.engine.run_fixes("hd1", self.snap, None, SUNDAY + 150) # the usual hold: reconnect
self.engine.run_fixes("ingest", self.snap, None, SUNDAY + 250) # the hold restarts the service
time.sleep(0.2)
self.assertEqual(ran, ["HyperDeck_1"])
self.assertEqual(ran, ["autorec"])
def test_auto_rec_not_running_or_without_resi(self):
del self.snap.launchd["org.waterschurch.resi-autorec"]
tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 6)
self.assertEqual(tiles["ingest"]["x"], "INGEST\nAUTO REC NOT LOADED")
self.snap.launchd["org.waterschurch.resi-autorec"] = 903
self.autorec("AUTO REC\nRESI ERROR")
tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 6, 200) # amber only once it lasts 3 minutes
self.assertEqual((tiles["ingest"]["x"], level(tiles["ingest"])), ("INGEST\nAUTO REC NO RESI", "warn"))
def test_not_recording_is_not_fixed_by_a_reboot(self):
for i in (1, 3):