Wiring: layouts (Hub, Columns, Groups, Free), All/None, Dante in its own box, gentle trackpad zoom; fix the Keys pages (a second wireLayout) and Computers > Setup (a second setupHtml)

RossTalk and OSC: the watchdog reads Companion's RossTalk port (ROSSTALK OFF, PROP ROSSTALK LOST at NA/WS/SC),
Bible TV and Ableton must be listening for Companion's OSC (BIBLE OSC OFF, ABLETON OSC OFF), and both are their own
connection types on the Wiring screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:45:09 -04:00
co-authored by Claude Opus 5.5
parent 26804e4ce2
commit 5d0263a9b9
11 changed files with 490 additions and 165 deletions
+14 -2
View File
@@ -316,10 +316,22 @@ The page reads what the watchdog writes next to its config (`snapshot.json` ever
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
its box; Dante sits in its own box on the right: its own network), **RossTalk** (ProPresenter's cues into Companion's
listener on 7788, Companion to the Resi decoder), **OSC** (Companion to Bible TV, Ableton, the DiGiCo relay) and **Art-Net**.
Layouts: **Hub** (Companion's Mac in the middle, each device beside its machine), **Columns** (what Companion drives,
Companion's Mac, the computers, what they talk to), **Groups** (by kind), **Free** (pulled together by their links);
each keeps its own arrangement. **All** / **None** show or hide every type at once (None, then click the one you want). 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.
Two-finger scroll and pinch zoom gently (a mouse wheel notch is capped), sideways scroll pans. Positions and what is
hidden stay in that browser.
**RossTalk and OSC on the keys (2026-10-27).** The watchdog reads Companion's RossTalk port every 5 s (`rosstalk_port`,
7788): **ROSSTALK OFF** when Companion's RossTalk listener is not open, **PROP ROSSTALK LOST** when ProPresenter is not
connected to it (check type `rosstalk`: `address` = ProPresenter's address, or `local` + `process` where ProPresenter runs
on the Companion Mac): NA's APPS key, WS/SC's PROP key. The Resi decoder's RossTalk is its RESI connection. OSC goes one
way (UDP), so the app that should receive it must be listening: NA's APPS key shows **BIBLE OSC OFF** (the Bible app runs
but nothing listens on UDP 3333; hold restarts the app) and **ABLETON OSC OFF** (Live runs but AbletonOSC is not on 11000).
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
+21
View File
@@ -2457,6 +2457,27 @@
},
"template": "helper_fresh",
"type": "remote"
},
{
"type": "rosstalk",
"address": "10.1.14.225",
"name": "PROP",
"debounce_seconds": 20
},
{
"type": "remote",
"host": "bibletv",
"cmd": "powershell -NoProfile -Command \"if (-not (Get-Process -Name 'Bible App' -ErrorAction SilentlyContinue)) { exit 0 }; if (Get-NetUDPEndpoint -LocalPort 3333 -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }\"",
"msg": "BIBLE OSC OFF",
"fix": "bibleapp",
"debounce_seconds": 20
},
{
"type": "remote",
"host": "tracks",
"cmd": "! pgrep -xq Live || lsof -nP -iUDP:11000 2>/dev/null | grep -q '^Live'",
"msg": "ABLETON OSC OFF",
"debounce_seconds": 20
}
],
"id": "apps",
+8
View File
@@ -104,6 +104,7 @@ 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("rosstalk_port", "int", 7788, "Companion's RossTalk port (Settings > Protocols in Companion).", "Site", advanced=True, lo=1, hi=65535)
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 "
@@ -326,6 +327,9 @@ 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[].address", "str", None, "RossTalk: the address of the machine that must be connected (\"local\": a program on this Mac).", "Keys")
field("tiles[].checks[].process", "str", None, "RossTalk from this Mac: the program's name (ProPresenter).", "Keys", advanced=True)
field("tiles[].checks[].off_msg", "str", None, "What the key says when Companion's RossTalk listener is off.", "Keys", advanced=True)
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)
@@ -524,6 +528,9 @@ CHECKS = {
"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."},
"rosstalk": {"fields": ["address", "process", "name", "msg", "off_msg"], "subs": ["rosstalk"], "identity": ["address"], "module": None,
"question": "Is a machine's RossTalk link into Companion up?",
"words": "Companion's RossTalk listener (port 7788) is open and the machine that sends cues by RossTalk (ProPresenter) is connected to it."},
"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,
@@ -575,6 +582,7 @@ GLOSSARY = {
"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", "AUTO REC STOPPED", "AUTO REC STUCK",
"ROSSTALK LOST", "ROSSTALK OFF", "OSC OFF",
],
"check the device or the cable": [
"NO PING", "NO ANSWER", "UNREACHABLE", "LINK DOWN", "NO GATEWAY", "NO DISK", "FULL", "LOW", "BATT", "INTERF",
+28 -2
View File
@@ -526,6 +526,8 @@ class Site:
# "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": "rosstalk", "label": "RossTalk", "color": "#8bc34a", "hint": "RossTalk (port 7788): ProPresenter's cues into Companion, Companion to the Resi decoder"},
{"id": "osc", "label": "OSC", "color": "#f5d142", "hint": "OSC from Companion (Bible TV, Ableton, the DiGiCo relay)"},
{"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)"},
@@ -580,7 +582,17 @@ class Site:
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 {}
conns, conn_hosts, info = snap.get("conns") or {}, snap.get("conn_hosts") or {}, snap.get("conn_info") or {}
rt = snap.get("rosstalk") or {}
def protocol(label):
"""What a Companion connection speaks, when it is one of the two the user follows (2026-10-27)."""
module = (info.get(label) or {}).get("module", "").lower()
if "osc" in module:
return "osc"
if module == "resi-decoders": # its decoders take RossTalk on 7788
return "rosstalk"
return "companion"
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")}
@@ -593,7 +605,21 @@ class Site:
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)
kind = protocol(label)
port = (info.get(label) or {}).get("port")
edge(kind, me_id, node, label + (" (OSC to port %s)" % port if kind == "osc" and port else " (RossTalk)" if kind == "rosstalk" else ""), light)
# RossTalk into Companion: who is connected to its listener now, and who must be (a "rosstalk" check).
want = {c.get("address"): c for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "rosstalk" and c.get("address")}
for c in rt.get("clients", []):
local = c["ip"].startswith("127.") or owner.get(c["ip"]) == me
src = me_id if local else target(c["ip"], c["ip"])
edge("rosstalk", src, me_id, "RossTalk into Companion%s" % (" from " + c["cmd"] if c.get("cmd") else ""), "ok")
for addr, c in want.items():
here = any((x["cmd"] or "").lower().startswith((c.get("process") or "").lower()[:9]) for x in rt.get("clients", [])) if addr == "local" \
else any(x["ip"] == addr for x in rt.get("clients", []))
if rt and not here:
src = me_id if addr == "local" else target(addr, addr)
edge("rosstalk", src, me_id, "RossTalk into Companion: NOT CONNECTED", "problem")
sessions = {me: snap.get("midi") or {}} if me else {}
sessions.update(snap.get("remote_midi") or {})
for alias, sess in sessions.items():
+256 -155
View File
@@ -633,230 +633,331 @@
// ---- 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;
// connections; pick a layout. Positions (per layout) and what is hidden are kept in this browser only.
// Everything here is named wd... (a wireLayout already belongs to the Keys screen).
const WD_W = 2100, WD_H = 1300, WD_DANTE_X = 1660;
const WD_LAYOUTS = [["hub", "Hub", "This Mac in the middle, the computers around it, each device beside its machine"],
["columns", "Columns", "Left to right: what Companion drives, Companion's Mac, the computers, what they talk to"],
["groups", "Groups", "Machines grouped by kind"], ["free", "Free", "Pulled together by their connections"]];
const wd = { data: null, pos: null, hidden: null, types: null, layout: "hub", zones: [], sel: null, focus: false, problems: false, drag: null, view: null };
const wdRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } };
const wdSave = () => { store.set("sp-wd-pos", JSON.stringify(wd.pos)); store.set("sp-wd-hidden", JSON.stringify([...wd.hidden])); store.set("sp-wd-types", JSON.stringify([...wd.types])); store.set("sp-wd-layout", wd.layout); };
const wdType = (id) => (wd.data.types.find((t) => t.id === id) || { label: id, color: "#888" });
const wdNode = (id) => wd.data.nodes.find((n) => n.id === id);
const wdName = (id) => (wdNode(id) || { label: id }).label;
const wdP = () => (wd.pos[wd.layout] = wd.pos[wd.layout] || {});
const wdSize = (n) => n.kind === "computer" ? { w: 168, h: 46 } : { w: Math.min(190, 26 + 6.6 * String(n.label).length), h: 26 };
const wdViewBox = (svg) => svg && svg.setAttribute("viewBox", `${wd.view.x} ${wd.view.y} ${wd.view.w} ${wd.view.h}`);
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();
if (quiet && (wd.drag || document.getElementById("dialog").hidden === false)) return;
wd.data = await api("/api/wiring");
if (!wd.pos) {
wd.pos = wdRead("sp-wd-pos", {}); wd.hidden = new Set(wdRead("sp-wd-hidden", [])); wd.types = new Set(wdRead("sp-wd-types", ["watch"]));
wd.layout = WD_LAYOUTS.some((l) => l[0] === store.get("sp-wd-layout")) ? store.get("sp-wd-layout") : "hub";
}
wireTools(); wireDraw(); wireSide();
if (wd.sel && !(wd.sel.edge ? wd.data.edges.some((e) => e.id === wd.sel.id) : wdNode(wd.sel.id))) wd.sel = null;
wdLayout();
if (!(quiet && document.getElementById("wdsvg"))) {
wd.view = null; // a fresh visit starts with everything in view
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. Two-finger scroll or pinch zooms, drag the background to move around.")}</h1>
<div class="tabs" id="wdtools"></div><div class="wire"><div class="wirecanvas"><div class="wzoom"><button class="small" id="wdzin" aria-label="zoom in">+</button><button class="small" id="wdzout" aria-label="zoom out">−</button><button class="small" id="wdfit" data-hint="Show everything that is shown">Fit</button></div><svg id="wdsvg" class="canvas" viewBox="0 0 ${WD_W} ${WD_H}" role="img" aria-label="wiring diagram"></svg></div><aside class="card wireside" id="wdside"></aside></div>`;
wdEvents();
}
wdTools(); wdDraw(); wdSide();
}
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);
// ---- layouts: each returns {pos: {id: {x, y}}, zones: [{x, y, w, h, label}]} for every node --------------
function wdGraph() {
const d = wd.data, nb = {}, kinds = {};
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); }
for (const end of [e.from, e.to]) (kinds[end] = kinds[end] || new Set()).add(e.type);
}
// Dante is its own network: its switches and the devices only on it sit apart, on the right.
const dante = new Set(d.nodes.filter((n) => n.kind === "switch" || (n.kind !== "computer" && kinds[n.id] && [...kinds[n.id]].every((t) => t === "dante"))).map((n) => n.id));
return { nb, dante };
}
function wdDanteZone(g, pos) {
const d = wd.data, switches = d.nodes.filter((n) => n.kind === "switch").sort((a, b) => a.label.localeCompare(b.label));
let y = 70;
for (const s of switches) {
pos[s.id] = { x: WD_DANTE_X + 215, y };
const devs = (g.nb[s.id] || []).filter((id) => g.dante.has(id) && id !== s.id).sort((a, b) => wdName(a).localeCompare(wdName(b)));
devs.forEach((id, i) => { pos[id] = { x: WD_DANTE_X + 110 + (i % 2) * 210, y: y + 44 + Math.floor(i / 2) * 34 }; });
y += 44 + Math.ceil(devs.length / 2) * 34 + 40;
}
for (const id of g.dante) if (!pos[id]) { pos[id] = { x: WD_DANTE_X + 215, y }; y += 34; }
return switches.length ? [{ x: WD_DANTE_X - 10, y: 20, w: WD_W - WD_DANTE_X, h: Math.max(200, y - 10), label: "Dante network" }] : [];
}
function wdAnchor(g, n, hubs) {
const count = {};
for (const o of g.nb[n.id] || []) if (hubs.has(o)) count[o] = (count[o] || 0) + 1;
return Object.keys(count).sort((a, b) => count[b] - count[a])[0] || wd.data.me;
}
function wdHub(g) {
const d = wd.data, pos = {}, cx = WD_DANTE_X / 2 - 20, cy = WD_H / 2, rx = WD_DANTE_X * 0.27, ry = WD_H * 0.27;
const hubs = d.nodes.filter((n) => n.kind === "computer" && n.id !== d.me).sort((a, b) => a.label.localeCompare(b.label));
if (d.me) 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; pos[n.id] = { x: cx + rx * Math.cos(angle[n.id]), y: cy + ry * Math.sin(angle[n.id]) }; });
const groups = {}, hubIds = new Set(hubs.map((n) => n.id));
for (const n of d.nodes) if (n.kind !== "computer" && !g.dante.has(n.id)) { const a = wdAnchor(g, n, hubIds); (groups[a] = groups[a] || []).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) };
}
if (angle[hub] === undefined) { const a = 2 * Math.PI * i / list.length - Math.PI / 2 + 0.2; pos[n.id] = { x: cx + rx * 1.75 * Math.cos(a), y: cy + ry * 1.7 * Math.sin(a) }; }
else { const a = angle[hub] + (i - (list.length - 1) / 2) * 0.12, r = 1.16 + 0.16 * (i % 2); pos[n.id] = { x: cx + rx * r * Math.cos(a), y: cy + ry * r * Math.sin(a) }; }
});
}
// Push apart whatever overlaps (boxes, with a margin), each device held near its machine.
return { pos, zones: wdDanteZone(g, pos) };
}
function wdColumns(g) {
const d = wd.data, pos = {};
const comps = d.nodes.filter((n) => n.kind === "computer" && n.id !== d.me).sort((a, b) => a.label.localeCompare(b.label));
const hubIds = new Set(comps.map((n) => n.id)), mine = [], theirs = {};
for (const n of d.nodes) {
if (n.kind === "computer" || g.dante.has(n.id)) continue;
const a = wdAnchor(g, n, hubIds);
if (a === d.me || !hubIds.has(a)) mine.push(n); else (theirs[a] = theirs[a] || []).push(n);
}
mine.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
const per = Math.ceil(mine.length / Math.max(1, Math.ceil(mine.length / 34)));
mine.forEach((n, i) => { pos[n.id] = { x: 130 + Math.floor(i / per) * 215, y: 60 + (i % per) * 35 }; });
const colX = 130 + Math.ceil(mine.length / per) * 215 + 140;
if (d.me) pos[d.me] = { x: colX, y: WD_H / 2 };
let y = 60;
for (const c of comps) {
pos[c.id] = { x: colX + 330, y };
const list = (theirs[c.id] || []).sort((a, b) => String(a.label).localeCompare(String(b.label)));
list.forEach((n, i) => { pos[n.id] = { x: colX + 600 + (i % 2) * 210, y: y + Math.floor(i / 2) * 34 }; });
y += Math.max(80, Math.ceil(list.length / 2) * 34 + 30);
}
return { pos, zones: [{ x: 20, y: 20, w: colX - 150, h: WD_H - 40, label: "Companion drives" }].concat(wdDanteZone(g, pos)) };
}
function wdGroups(g) {
const d = wd.data, pos = {}, zones = [], word = { computer: "Computers", service: "Services and cloud", cloud: "Services and cloud", MIDI: "MIDI devices", device: "Devices" };
const by = {};
for (const n of d.nodes) if (!g.dante.has(n.id)) { const k = word[n.kind] || (n.kind.charAt(0).toUpperCase() + n.kind.slice(1) + "s"); (by[k] = by[k] || []).push(n); }
const order = Object.keys(by).sort((a, b) => (a === "Computers" ? -1 : b === "Computers" ? 1 : by[b].length - by[a].length));
let x = 30, y = 30, rowH = 0;
for (const k of order) {
const list = by[k].sort((a, b) => String(a.label).localeCompare(String(b.label)));
const isComp = k === "Computers", cols = isComp ? 2 : Math.min(3, Math.ceil(list.length / 8)), cw = isComp ? 190 : 205, rh = isComp ? 62 : 34;
const w = cols * cw + 30, h = Math.ceil(list.length / cols) * rh + 50;
if (x + w > WD_DANTE_X - 20) { x = 30; y += rowH + 30; rowH = 0; }
list.forEach((n, i) => { pos[n.id] = { x: x + 15 + cw / 2 + (i % cols) * cw, y: y + 45 + Math.floor(i / cols) * rh }; });
zones.push({ x, y, w, h, label: k });
x += w + 30; rowH = Math.max(rowH, h);
}
return { pos, zones: zones.concat(wdDanteZone(g, pos)) };
}
function wdFree(g) {
const d = wd.data, base = wdHub(g).pos, pos = {};
for (const id in base) pos[id] = { x: base[id].x + (Math.random() - 0.5) * 40, y: base[id].y + (Math.random() - 0.5) * 40 };
const ids = d.nodes.filter((n) => !g.dante.has(n.id)).map((n) => n.id);
for (let it = 0; it < 240; it++) {
const f = {};
for (const a of ids) f[a] = { x: 0, y: 0 };
for (const a of ids) for (const b of ids) {
if (a === b) continue;
const dx = pos[a].x - pos[b].x, dy = pos[a].y - pos[b].y, dd = Math.hypot(dx, dy) || 1;
if (dd < 220) { const k = (220 - dd) / dd * 0.06; f[a].x += dx * k; f[a].y += dy * k; }
}
for (const e of d.edges) if (pos[e.from] && pos[e.to] && f[e.from] && f[e.to]) {
const dx = pos[e.to].x - pos[e.from].x, dy = pos[e.to].y - pos[e.from].y, dd = Math.hypot(dx, dy) || 1, k = (dd - 170) / dd * 0.02;
f[e.from].x += dx * k; f[e.from].y += dy * k; f[e.to].x -= dx * k; f[e.to].y -= dy * k;
}
for (const a of ids) { pos[a].x = Math.max(110, Math.min(WD_DANTE_X - 110, pos[a].x + Math.max(-20, Math.min(20, f[a].x)))); pos[a].y = Math.max(30, Math.min(WD_H - 30, pos[a].y + Math.max(-20, Math.min(20, f[a].y)))); }
}
return { pos, zones: wdDanteZone(g, pos) };
}
// Places the nodes that have no position in this layout yet (all of them with all=true), then pushes apart what overlaps.
function wdLayout(all) {
const d = wd.data, P = wdP(), g = wdGraph();
const lay = ({ hub: wdHub, columns: wdColumns, groups: wdGroups, free: wdFree }[wd.layout] || wdHub)(g);
wd.zones = lay.zones;
const move = new Set(d.nodes.filter((n) => all || !P[n.id]).map((n) => n.id));
if (!move.size) return;
for (const id of move) P[id] = Object.assign({}, lay.pos[id] || { x: WD_DANTE_X / 2, y: WD_H / 2 });
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 (const n of d.nodes) size[n.id] = wdSize(n);
for (let it = 0; it < (wd.layout === "hub" || wd.layout === "free" ? 200 : 40); it++) {
let moved = false;
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);
const pa = P[a], pb = P[b];
const ox = (size[a].w + size[b].w) / 2 + 12 - Math.abs(pa.x - pb.x), oy = (size[a].h + size[b].h) / 2 + 8 - Math.abs(pa.y - pb.y);
if (ox <= 0 || oy <= 0) continue;
moved = true;
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));
if (!moved) break;
}
}
wireSave();
wdSave();
}
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));
function wdShown() {
const d = wd.data, sel = wd.sel && !wd.sel.edge ? wd.sel.id : null;
const edges = d.edges.filter((e) => !wd.types.has(e.type) && !wd.hidden.has(e.id) && (!wd.problems || e.light === "problem")
&& (!wd.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);
const nodes = d.nodes.filter((n) => ends.has(n.id) || (n.kind === "computer" && !wd.focus && !wd.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 wdFit() {
const { nodes } = wdShown(), P = wdP();
if (!nodes.length) { wd.view = { x: 0, y: 0, w: WD_W, h: WD_H }; return; }
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const n of nodes) { const p = P[n.id], { w, h } = wdSize(n); if (!p) continue; 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("wdsvg"), box = svg && svg.getBoundingClientRect();
const pad = 40, w = x1 - x0 + 2 * pad, h = y1 - y0 + 2 * pad, ratio = box && box.height ? box.width / box.height : WD_W / WD_H;
const vw = Math.max(w, h * ratio), vh = vw / ratio;
wd.view = { x: (x0 + x1) / 2 - vw / 2, y: (y0 + y1) / 2 - vh / 2, w: vw, h: vh };
}
function wdZoom(f, px, py) {
const v = wd.view, nw = Math.max(250, Math.min(WD_W * 3, 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;
wd.view = { x: px - (px - v.x) * k, y: py - (py - v.y) * k, w: nw, h: v.h * k };
wdViewBox(document.getElementById("wdsvg"));
}
function wireDraw() {
const svg = document.getElementById("wiresvg");
function wdDraw() {
const svg = document.getElementById("wdsvg");
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 (!wd.view) wdFit();
wdViewBox(svg);
const P = wdP(), { edges, nodes } = wdShown(), sel = wd.sel, 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); } }
if (sel && sel.edge) { const e = wd.data.edges.find((x) => x.id === sel.id); if (e) { near.add(e.from); near.add(e.to); } }
const shownIds = new Set(nodes.map((n) => n.id));
let out = wd.zones.filter((z) => z.label !== "Dante network" || nodes.some((n) => n.kind === "switch"))
.map((z) => `<g class="wzone"><rect x="${z.x}" y="${z.y}" width="${z.w}" height="${z.h}" rx="14"/><text x="${z.x + 14}" y="${z.y + 22}">${esc(z.label)}</text></g>`).join("");
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 a = P[e.from], b = P[e.to];
if (!a || !b || !shownIds.has(e.from) || !shownIds.has(e.to)) 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>`;
const t = wdType(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(wdName(e.from))} ↔ ${esc(wdName(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 p = P[n.id]; if (!p) continue;
const { w, h } = wdSize(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}
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>
<rect x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${n.kind === "computer" ? 9 : n.kind === "switch" ? 2 : h / 2}"/>
<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");
function wdTools() {
const el = document.getElementById("wdtools");
if (!el) return;
const d = wire.data, count = {};
const d = wd.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); };
const present = d.types.filter((t) => count[t.id]), bad = d.edges.filter((e) => e.light === "problem").length;
el.innerHTML = `<span class="seg">${WD_LAYOUTS.map(([id, label, h]) => `<button class="small${wd.layout === id ? " on" : ""}" data-wdlayout="${id}" data-hint="${esc(h)}">${label}</button>`).join("")}</span>`
+ present.map((t) => `<button class="small wtype${wd.types.has(t.id) ? " off" : " on"}" data-wdtype="${esc(t.id)}" data-hint="${esc(t.hint)} · click to ${wd.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" id="wdall" data-hint="Show every kind of connection (and any you hid one by one)">All</button><button class="small" id="wdnone" data-hint="Hide every kind: then click the one you want">None</button>`
+ `<button class="small${wd.problems ? " on" : ""}" id="wdprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>`
+ `<button class="small" id="wdre" data-hint="Forget where machines were dragged in this layout and lay them out again">Re-arrange</button>`
+ `<button class="small" id="wdprobe" 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>`;
const redraw = () => { wdSave(); wdTools(); wdDraw(); wdSide(); };
for (const b of el.querySelectorAll("[data-wdlayout]")) b.onclick = () => { wd.layout = b.dataset.wdlayout; wdLayout(); wdFit(); redraw(); };
for (const b of el.querySelectorAll("[data-wdtype]")) b.onclick = () => { const t = b.dataset.wdtype; wd.types.has(t) ? wd.types.delete(t) : wd.types.add(t); redraw(); };
el.querySelector("#wdall").onclick = () => { wd.types.clear(); wd.hidden.clear(); wd.focus = false; wd.problems = false; redraw(); };
el.querySelector("#wdnone").onclick = () => { wd.types = new Set(present.map((t) => t.id)); wd.focus = false; redraw(); };
el.querySelector("#wdprob").onclick = () => { wd.problems = !wd.problems; redraw(); };
el.querySelector("#wdre").onclick = () => { wdLayout(true); wdFit(); redraw(); };
el.querySelector("#wdprobe").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");
function wdSide() {
const el = document.getElementById("wdside");
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>`;
const d = wd.data, sel = wd.sel;
const row = (e, other) => `<li><label class="wrow"><input type="checkbox" data-wdhide="${esc(e.id)}" ${wd.hidden.has(e.id) ? "" : "checked"}>${lightDot(e.light, lightWord[e.light])}<span><b>${esc(wdName(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>`;
const e = d.edges.find((x) => x.id === sel.id), t = wdType(e.type);
html = `<h2><i class="wsw" style="background:${t.color}"></i>${esc(t.label)}</h2><p>${esc(wdName(e.from))} ↔ ${esc(wdName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p>${e.detail ? `<p class="small muted">${esc(e.detail)}</p>` : ""}
<p><button class="small" data-wdhide-one="${esc(e.id)}">${wd.hidden.has(e.id) ? "Show" : "Hide"} this connection</button> <button class="small" data-wdsel="${esc(e.from)}">${esc(wdName(e.from))}</button> <button class="small" data-wdsel="${esc(e.to)}">${esc(wdName(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 = {};
const n = wdNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id), 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>
: `<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.replace(/^org\.waterschurch\./, "") !== 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 ? " · runs 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>`}
<p><button class="small${wd.focus ? " on" : ""}" id="wdfocus">${wd.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:${wdType(t).color}"></i>${esc(wdType(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");
const comps = d.nodes.filter((n) => n.kind === "computer"), 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>` : ""}`;
<h3>Scripts per computer</h3><ul class="wlist">${comps.map((n) => `<li><a href="#" data-wdsel="${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-wdedge="${esc(e.id)}">${esc(wdName(e.from))} ↔ ${esc(wdName(e.to))}</a> <span class="muted small">${esc(e.label)}</span></li>`).join("")}</ul>` : ""}
${wd.hidden.size ? `<h3>Hidden <span class="muted small">${wd.hidden.size}</span></h3><ul class="wlist">${[...wd.hidden].map((id) => d.edges.find((e) => e.id === id)).filter(Boolean).map((e) => `<li><label class="wrow"><input type="checkbox" data-wdhide="${esc(e.id)}"><span>${esc(wdName(e.from))} ↔ ${esc(wdName(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(); };
const again = () => { wdSave(); wdDraw(); wdSide(); };
for (const c of el.querySelectorAll("[data-wdhide]")) c.onchange = () => { c.checked ? wd.hidden.delete(c.dataset.wdhide) : wd.hidden.add(c.dataset.wdhide); again(); };
for (const b of el.querySelectorAll("[data-wdhide-one]")) b.onclick = () => { const id = b.dataset.wdhideOne; wd.hidden.has(id) ? wd.hidden.delete(id) : wd.hidden.add(id); again(); };
for (const a of el.querySelectorAll("[data-wdsel]")) a.onclick = (ev) => { ev.preventDefault(); wd.sel = { id: a.dataset.wdsel }; wdDraw(); wdSide(); };
for (const a of el.querySelectorAll("[data-wdedge]")) a.onclick = (ev) => { ev.preventDefault(); wd.sel = { id: a.dataset.wdedge, edge: true }; wdDraw(); wdSide(); };
const f = el.querySelector("#wdfocus"); if (f) f.onclick = () => { wd.focus = !wd.focus; wdDraw(); wdSide(); };
}
function wireEvents() {
const svg = document.getElementById("wiresvg");
function wdEvents() {
const svg = document.getElementById("wdsvg");
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]");
document.getElementById("wdzin").onclick = () => wdZoom(0.8);
document.getElementById("wdzout").onclick = () => wdZoom(1.25);
document.getElementById("wdfit").onclick = () => { wdFit(); wdDraw(); };
// Trackpads send many small wheel steps (pinch: with ctrlKey): each zooms a little, in proportion; a mouse
// wheel's big steps are capped. Sideways two-finger scroll moves the view.
svg.addEventListener("wheel", (ev) => {
ev.preventDefault();
const unit = ev.deltaMode === 1 ? 16 : ev.deltaMode === 2 ? 400 : 1, dx = ev.deltaX * unit, dy = ev.deltaY * unit;
if (!ev.ctrlKey && Math.abs(dx) > Math.abs(dy)) {
const k = wd.view.w / svg.getBoundingClientRect().width;
wd.view.x += dx * k; wdViewBox(svg); return;
}
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();
wdZoom(Math.exp(Math.max(-0.2, Math.min(0.2, dy * (ev.ctrlKey ? 0.008 : 0.0018)))), p.x, p.y);
}, { passive: false });
svg.addEventListener("pointerdown", (ev) => {
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]"), p = at(ev), P = wdP();
if (g) { const id = g.dataset.node; wd.drag = { id, dx: P[id].x - p.x, dy: P[id].y - p.y, x0: ev.clientX, y0: ev.clientY, moved: false }; }
else if (path) { wd.sel = { id: path.dataset.edge, edge: true }; wdDraw(); wdSide(); return; }
else wd.drag = { pan: true, x0: ev.clientX, y0: ev.clientY, vx: wd.view.x, vy: wd.view.y, k: wd.view.w / svg.getBoundingClientRect().width, moved: false };
try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* a synthetic pointer */ }
ev.preventDefault();
});
svg.addEventListener("pointermove", (ev) => {
const dr = wire.drag; if (!dr) return;
const dr = wd.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; }
if (dr.pan) { wd.view.x = dr.vx - (ev.clientX - dr.x0) * dr.k; wd.view.y = dr.vy - (ev.clientY - dr.y0) * dr.k; wdViewBox(svg); return; }
const p = at(ev);
wire.pos[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
wireDraw();
wdP()[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
wdDraw();
});
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();
const dr = wd.drag; wd.drag = null; if (!dr) return;
if (dr.pan) { if (!dr.moved) { wd.sel = null; wd.focus = false; wdDraw(); wdSide(); } return; }
if (dr.moved) wdSave(); else { wd.sel = wd.sel && !wd.sel.edge && wd.sel.id === dr.id ? null : { id: dr.id }; if (!wd.sel) wd.focus = false; }
wdDraw(); wdSide();
};
svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end);
}
@@ -1148,7 +1249,7 @@
else if (which === "outlets") body = await outletsHtml();
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "setup") body = await setupHtml(c);
else if (which === "setup") body = await siteSetupHtml(c);
else if (which === "site") body = siteHtml(c);
else if (which === "program") body = await programHtml();
else if (which === "advanced") body = advancedHtml();
@@ -1176,7 +1277,7 @@
return screens.map((s) => { const h = fieldsHtml(s, true); return h ? `<h3>${esc(s)}</h3>${h}` : ""; }).join("") || "<p class='muted'>Nothing.</p>";
}
// Auto-setup: what was found and added, the join lines, and the install line for another location.
async function setupHtml(c) {
async function siteSetupHtml(c) {
const d = await api("/api/discover").catch(() => null);
if (!d) return "<p class='muted'>Auto-setup is not answering.</p>";
const last = d.last || {}, inv = last.inventory || {};
+3
View File
@@ -185,3 +185,6 @@ tr.off td, .trig.off, .key.off { opacity: .55; }
.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; }
.wzone rect { fill: var(--bg); stroke: var(--line); stroke-dasharray: 6 5; } .wzone text { fill: var(--muted); font: 600 15px var(--font); stroke: none; letter-spacing: .04em; }
.seg { display: inline-flex; gap: 0; margin-right: 6px; } .seg button { border-radius: 0; margin-left: -1px; } .seg button:first-child { border-radius: 8px 0 0 8px; } .seg button:last-child { border-radius: 0 8px 8px 0; }
.seg button.on { background: var(--accent-soft); border-color: var(--accent); position: relative; }
+7
View File
@@ -171,6 +171,13 @@
"url": "http://127.0.0.1:8095/timecode",
"msg": "TIMECODE LOST",
"interval_seconds": 2
},
{
"type": "rosstalk",
"address": "local",
"process": "ProPresenter",
"name": "PROP",
"debounce_seconds": 20
}
],
"pos": [
+7
View File
@@ -500,6 +500,13 @@
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
"name": "PROP",
"type": "process"
},
{
"type": "rosstalk",
"address": "local",
"process": "ProPresenter",
"name": "PROP",
"debounce_seconds": 20
}
],
"id": "prop",
+93 -3
View File
@@ -170,6 +170,8 @@ class Snapshot:
self.remote_midi_time = {} # host -> when it last reported
self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at
self.conn_hosts = {} # Companion connection label -> its "host" setting
self.conn_info = {} # Companion connection label -> {"module", "port"} (the Wiring screen's protocols)
self.rosstalk = None # Companion's RossTalk port: {"listen", "clients": [{"ip", "cmd"}], "targets": [ip], "time"}
self.prefs = {} # (preferences domain, key) -> value as "defaults read" prints it
self.atem = None # atem_watch.js's latest report
self.spotify = None # {"running", "state", "track", "name", "artist"} on NA Ingest
@@ -503,6 +505,26 @@ def check_heartbeat(chk, snap, ctx):
return [("hb", Result(OK))]
def check_rosstalk(chk, snap, ctx):
"""RossTalk into Companion (2026-10-27: "ross talk for companion, resi, prop"): Companion's listener is open and,
with "address", that machine is connected to it ("local": a program on this Mac whose name has "process")."""
rt = snap.rosstalk
if not rt or ctx["now"] - rt.get("time", 0) > 60:
return [("rosstalk", Result(OK))] # not looked yet
if not rt["listen"]:
return [("rosstalk", Result(ERR, chk.get("off_msg", "ROSSTALK OFF")))]
addr = chk.get("address")
if addr:
if addr == "local": # lsof cuts program names to 9 letters ("ProPresen")
want = (chk.get("process") or "").lower()[:9]
ok = any(c["cmd"] and c["cmd"].lower().startswith(want) for c in rt["clients"])
else:
ok = any(c["ip"] == addr for c in rt["clients"])
if not ok:
return [("rosstalk", Result(ERR, chk.get("msg") or "%s ROSSTALK LOST" % chk.get("name", "").upper(), named=True))]
return [("rosstalk", Result(OK))]
def check_process(chk, snap, ctx):
running = snap.procs.get(chk["match"])
if running is False:
@@ -976,6 +998,7 @@ CHECKS = {
"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, "heartbeat": check_heartbeat,
"rosstalk": check_rosstalk,
}
@@ -2911,14 +2934,78 @@ def companion_conn_hosts(path):
return hosts
def poll_conn_hosts(snap, cfg):
if any(c["type"] == "ping" and c.get("conn") for t in all_tiles(cfg) for c in t.get("checks", [])):
def companion_conn_info(path):
"""{connection label: {"module", "port"}} from Companion's settings database (read-only): which protocol each
connection speaks (generic-osc / ableton-osc: OSC; resi-decoders: RossTalk) and the port it sends to."""
import sqlite3
if not path or not os.path.exists(path):
return {}
db = sqlite3.connect("file:%s?mode=ro" % urllib.parse.quote(path), uri=True, timeout=2)
try:
snap.conn_hosts = companion_conn_hosts(companion_db_path(cfg)) or snap.conn_hosts
rows = db.execute("SELECT value FROM instances").fetchall()
finally:
db.close()
out = {}
for (value,) in rows:
try:
inst = json.loads(value)
except (TypeError, ValueError):
continue
if isinstance(inst, dict) and inst.get("label"):
conf = inst.get("config") or {}
out[inst["label"]] = {"module": inst.get("moduleId") or inst.get("instance_type") or "",
"port": conf.get("targetPort") or conf.get("port")}
return out
def poll_conn_hosts(snap, cfg):
try:
path = companion_db_path(cfg)
snap.conn_hosts = companion_conn_hosts(path) or snap.conn_hosts
snap.conn_info = companion_conn_info(path) or snap.conn_info
except Exception as e: # Companion's schema is its own business: fall back to "host"
log.warning("could not read connection addresses from Companion: %s", e)
ROSSTALK_PORT = 7788
def parse_rosstalk(out, port=ROSSTALK_PORT):
"""`lsof -nP -iTCP:<port>` on the Companion Mac -> {"listen", "clients", "targets"}: whether Companion's RossTalk
listener is open, who is connected to it (ProPresenter sends its slide cues this way; a client on this Mac
itself is named by its program), and the devices Companion talks RossTalk to (the Resi decoder)."""
listen, ends = False, []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) < 9:
continue
name, state = parts[8], " ".join(parts[9:])
if "(LISTEN)" in state and name.endswith(":%d" % port):
listen = True
elif "->" in name and "ESTABLISHED" in state:
local, remote = name.split("->", 1)
ends.append((parts[0], local, remote))
by_local = {local: cmd for cmd, local, _ in ends}
clients, targets = [], []
for cmd, local, remote in ends:
rip, rport = remote.rsplit(":", 1)
if local.endswith(":%d" % port): # someone connected to Companion's listener
loop = rip.startswith("127.") or rip == local.rsplit(":", 1)[0]
clients.append({"ip": rip, "cmd": by_local.get(remote, "") if loop else ""})
elif rport == str(port) and not rip.startswith("127."): # Companion talking RossTalk to a device
targets.append(rip)
return {"listen": listen, "clients": clients, "targets": sorted(set(targets))}
def poll_rosstalk(snap, cfg):
port = int(cfg.get("rosstalk_port", ROSSTALK_PORT))
try:
out = subprocess.run(["/usr/sbin/lsof", "-w", "-nP", "-iTCP:%d" % port], capture_output=True, text=True, timeout=8).stdout
except (OSError, subprocess.SubprocessError):
return
snap.rosstalk = dict(parse_rosstalk(out, port), time=time.time())
SPOTIFY_STATE = ('tell application "Spotify" to return (player state as text) & "|" & (id of current track) '
'& "|" & (name of current track) & "|" & (artist of current track)')
@@ -4021,6 +4108,7 @@ def start_pollers(cfg, snap, stop, data_dir=None):
Periodic("variables", 1.0, lambda: poll_variables(c2, snap, fast), stop),
Periodic("slow-variables", 3.0, lambda: poll_variables(c3, snap, slow), stop),
Periodic("conn-hosts", 60.0, lambda: poll_conn_hosts(snap, cfg), stop),
Periodic("rosstalk", 5.0, lambda: poll_rosstalk(snap, cfg), stop),
Periodic("spotify", 3.0, lambda: poll_spotify(snap, cfg), stop),
Periodic("pings", float(PING_INTERVAL), lambda: poll_pings(snap, ping_hosts(cfg, snap), iface), stop),
Periodic("fast-pings", 1.0, lambda: poll_pings(snap, ping_hosts(cfg, snap, fast=True), None), stop),
@@ -4060,6 +4148,8 @@ def snapshot_summary(snap, engine, now):
for label, c in snap.conns.items()},
"conns_fresh": snap.conns_fresh(now),
"conn_hosts": dict(snap.conn_hosts),
"conn_info": dict(snap.conn_info),
"rosstalk": snap.rosstalk,
"ping": dict(snap.ping),
"link": snap.link,
"midi": snap.midi if snap.midi_time else None,
+19 -1
View File
@@ -109,8 +109,12 @@ class TestServer(unittest.TestCase):
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},
"BibleTV": {"category": "good", "enabled": True}, "RESI_Decoder": {"category": "good", "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"},
"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",
"BibleTV": "10.6.7.13", "RESI_Decoder": "10.1.10.203"},
"conn_info": {"BibleTV": {"module": "generic-osc", "port": 3333}, "RESI_Decoder": {"module": "resi-decoders", "port": None}},
"rosstalk": {"listen": True, "clients": [{"ip": "10.1.14.225", "cmd": ""}], "targets": ["10.1.10.203"], "time": time.time()},
"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()},
@@ -134,6 +138,9 @@ class TestServer(unittest.TestCase):
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.assertIn(("osc", "h:ingest", "h:bibletv", "ok"), edges) # OSC to the Bible TV PC
self.assertIn(("rosstalk", "h:ingest", "d:10.1.10.203", "ok"), edges) # RossTalk to the Resi decoder
self.assertIn(("rosstalk", "h:prop", "h:ingest", "ok"), edges) # ProPresenter's cues into Companion
self.assertEqual({t["id"] for t in w["types"]} >= {"companion", "midi", "watch", "dante"}, True)
def test_gear_and_keys_and_pages(self):
@@ -416,6 +423,17 @@ class TestBindingAndProbes(unittest.TestCase):
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_the_page_script_has_no_two_functions_with_one_name(self):
# 2026-10-27: a second wireLayout (the Wiring screen's) replaced the Keys screen's and broke it ("null errors"),
# and a second setupHtml had broken Computers > Setup the same way.
import collections
import re
js = open(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "settings", "ui", "app.js")).read()
# the page's own names: two spaces in, inside its one (function () { ... })
names = collections.Counter(re.findall(r"^ (?:async )?function\s+([A-Za-z0-9_$]+)\s*\(", js, re.M))
names.update(re.findall(r"^ (?:const|let)\s+([A-Za-z0-9_$]+)\s*=", js, re.M))
self.assertEqual([n for n, c in names.items() if c > 1], [])
def test_probe_commands_never_carry_names_from_the_settings(self):
cfg = json.loads(json.dumps(CONFIG))
cfg["hosts"]["tracks"]["runs"][1]["process"] = "x; rm -rf ~" # a hostile name in the settings
+32
View File
@@ -677,6 +677,38 @@ class TestHyperDeckRecord(unittest.TestCase):
self.assertFalse(sp.hyperdeck_record("127.0.0.1", "x", port=1, timeout=1))
class TestRossTalk(unittest.TestCase):
"""Companion's RossTalk port (2026-10-27): ProPresenter's cues come in on 7788, Companion talks to the Resi decoder."""
NA = ("COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
"node 925 Studio 37u IPv6 0x1 0t0 TCP *:7788 (LISTEN)\n"
"node 925 Studio 155u IPv6 0x2 0t0 TCP 10.1.1.1:7788->10.1.14.225:61156 (ESTABLISHED)\n"
"node 1058 Studio 22u IPv4 0x3 0t0 TCP 10.1.1.1:49697->10.1.10.203:7788 (ESTABLISHED)\n")
WS = ("COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n"
"node 973 wsprop 38u IPv6 0x1 0t0 TCP *:7788 (LISTEN)\n"
"node 973 wsprop 43u IPv6 0x2 0t0 TCP 127.0.0.1:7788->127.0.0.1:53750 (ESTABLISHED)\n"
"node 17116 wsprop 22u IPv4 0x3 0t0 TCP 10.2.2.210:57966->10.2.1.166:7788 (ESTABLISHED)\n"
"ProPresen 53124 wsprop 77u IPv4 0x4 0t0 TCP 127.0.0.1:53750->127.0.0.1:7788 (ESTABLISHED)\n")
def check(self, chk, rt, now=1000.0):
snap = sp.Snapshot()
snap.rosstalk = dict(rt, time=now) if rt is not None else None
return dict(sp.check_rosstalk(chk, snap, {"now": now}))["rosstalk"]
def test_parsing(self):
self.assertEqual(sp.parse_rosstalk(self.NA), {"listen": True, "clients": [{"ip": "10.1.14.225", "cmd": ""}], "targets": ["10.1.10.203"]})
self.assertEqual(sp.parse_rosstalk(self.WS), {"listen": True, "clients": [{"ip": "127.0.0.1", "cmd": "ProPresen"}], "targets": ["10.2.1.166"]})
def test_prop_link(self):
na, ws = sp.parse_rosstalk(self.NA), sp.parse_rosstalk(self.WS)
self.assertEqual(self.check({"address": "10.1.14.225", "name": "PROP"}, na).level, sp.OK)
self.assertEqual(self.check({"address": "local", "process": "ProPresenter", "name": "PROP"}, ws).level, sp.OK)
gone = sp.parse_rosstalk("\n".join(l for l in self.NA.splitlines() if "61156" not in l))
self.assertEqual(self.check({"address": "10.1.14.225", "name": "PROP"}, gone).msg, "PROP ROSSTALK LOST")
off = sp.parse_rosstalk("COMMAND\n")
self.assertEqual(self.check({"address": "10.1.14.225", "name": "PROP"}, off).msg, "ROSSTALK OFF")
self.assertEqual(self.check({"address": "10.1.14.225"}, None).level, sp.OK) # not looked yet
class TestTextSize(unittest.TestCase):
def test_no_word_is_split(self):
for text in ("TASCAM\nOK", "SERVICE\nON\nmanual", "OBS\nOFFLINE", "INGEST\n97GB free",