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 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 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 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 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. 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.*`, 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 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 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", "template": "helper_fresh",
"type": "remote" "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", "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 " 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\"}, ...], " "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") "\"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\": " 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\"], " "\"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 " "\"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[].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[].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[].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[].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[].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[].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"], "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?", "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."}, "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, "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."}, "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, "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", "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", "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", "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": [ "check the device or the cable": [
"NO PING", "NO ANSWER", "UNREACHABLE", "LINK DOWN", "NO GATEWAY", "NO DISK", "FULL", "LOW", "BATT", "INTERF", "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"). # "like a wiring diagram ... hide different connections ... how many and what scripts are running on each machine").
WIRE_TYPES = [ WIRE_TYPES = [
{"id": "companion", "label": "Companion control", "color": "#4f8cff", "hint": "Companion's connections: what the deck's buttons drive"}, {"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": "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": "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": "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 return
key = "|".join([kind] + sorted([a, b]) + [label]) key = "|".join([kind] + sorted([a, b]) + [label])
edges[key] = {"id": key, "type": kind, "from": a, "to": b, "label": label, "light": light} 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 {} gear = cfg.get("gear") or {}
kinds = {"hyperdeck": "recorder", "tascam": "recorder", "shure": "wireless", "psm": "in-ears", "atem": "switcher"} 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")} 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") node = target(address, label, conn_kind.get(label, "device")) if address else target(None, label, "service")
if node.startswith("d:"): if node.startswith("d:"):
nodes[node]["light"] = light if nodes[node]["light"] in ("unknown", "ok") else nodes[node]["light"] 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 = {me: snap.get("midi") or {}} if me else {}
sessions.update(snap.get("remote_midi") or {}) sessions.update(snap.get("remote_midi") or {})
for alias, sess in sessions.items(): for alias, sess in sessions.items():
+256 -155
View File
@@ -633,230 +633,331 @@
// ---- Wiring ------------------------------------------------------------------ // ---- Wiring ------------------------------------------------------------------
// Every computer and device the panel knows and what connects them, like a wiring diagram (2026-10-27). Drag a // 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 // 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. // connections; pick a layout. Positions (per layout) and what is hidden are kept in this browser only.
const W_W = 2000, W_H = 1300; // Everything here is named wd... (a wireLayout already belongs to the Keys screen).
const wire = { data: null, pos: null, hidden: null, types: null, sel: null, focus: false, problems: false, drag: null, view: null }; const WD_W = 2100, WD_H = 1300, WD_DANTE_X = 1660;
const wireRead = (k, d) => { try { return JSON.parse(store.get(k) || "null") || d; } catch (e) { return d; } }; const WD_LAYOUTS = [["hub", "Hub", "This Mac in the middle, the computers around it, each device beside its machine"],
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])); }; ["columns", "Columns", "Left to right: what Companion drives, Companion's Mac, the computers, what they talk to"],
const wireType = (id) => (wire.data.types.find((t) => t.id === id) || { label: id, color: "#888" }); ["groups", "Groups", "Machines grouped by kind"], ["free", "Free", "Pulled together by their connections"]];
const wireNode = (id) => wire.data.nodes.find((n) => n.id === id); const wd = { data: null, pos: null, hidden: null, types: null, layout: "hub", zones: [], sel: null, focus: false, problems: false, drag: null, view: null };
const wireName = (id) => (wireNode(id) || { label: id }).label; 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) { async function wiring(rest, quiet) {
if (quiet && (wire.drag || document.getElementById("dialog").hidden === false)) return; if (quiet && (wd.drag || document.getElementById("dialog").hidden === false)) return;
wire.data = await api("/api/wiring"); wd.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 (!wd.pos) {
if (wire.sel && !(wire.sel.edge ? wire.data.edges.some((e) => e.id === wire.sel.id) : wireNode(wire.sel.id))) wire.sel = null; wd.pos = wdRead("sp-wd-pos", {}); wd.hidden = new Set(wdRead("sp-wd-hidden", [])); wd.types = new Set(wdRead("sp-wd-types", ["watch"]));
wireLayout(); wd.layout = WD_LAYOUTS.some((l) => l[0] === store.get("sp-wd-layout")) ? store.get("sp-wd-layout") : "hub";
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(); 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) { // ---- layouts: each returns {pos: {id: {x, y}}, zones: [{x, y, w, h, label}]} for every node --------------
const d = wire.data, cx = W_W / 2, cy = W_H / 2; function wdGraph() {
const fresh = d.nodes.filter((n) => all || !wire.pos[n.id]).map((n) => n.id); const d = wd.data, nb = {}, kinds = {};
if (!fresh.length) return; for (const e of d.edges) {
const nb = {}; if (e.type !== "watch") { (nb[e.from] = nb[e.from] || []).push(e.to); (nb[e.to] = nb[e.to] || []).push(e.from); }
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);
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);
} }
// 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)) { 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.sort((a, b) => String(a.kind).localeCompare(String(b.kind)) || String(a.label).localeCompare(String(b.label)));
list.forEach((n, i) => { list.forEach((n, i) => {
if (!move.has(n.id)) return; 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) }; }
if (hub === d.me || angle[hub] === undefined) { // the outer ring, kinds together 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) }; }
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. 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 = {}; const ids = d.nodes.map((n) => n.id), size = {};
for (const n of d.nodes) size[n.id] = nodeSize(n); for (const n of d.nodes) size[n.id] = wdSize(n);
for (let it = 0; it < 260; it++) { 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++) { for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
const a = ids[i], b = ids[j]; const a = ids[i], b = ids[j];
if (!move.has(a) && !move.has(b)) continue; if (!move.has(a) && !move.has(b)) continue;
const pa = wire.pos[a], pb = wire.pos[b]; const pa = P[a], pb = P[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 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; if (ox <= 0 || oy <= 0) continue;
moved = true;
const ma = move.has(a) ? (move.has(b) ? 0.5 : 1) : 0, mb = 1 - ma; 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; } 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; } else { const sy = (pa.y < pb.y ? -1 : 1) * oy; pa.y += sy * ma; pb.y -= sy * mb; }
} }
for (const id of move) { if (!moved) break;
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(); wdSave();
} }
function wireFit() { function wdShown() {
const { nodes } = wireShown(); const d = wd.data, sel = wd.sel && !wd.sel.edge ? wd.sel.id : null;
if (!nodes.length) { wire.view = { x: 0, y: 0, w: W_W, h: W_H }; return; } const edges = d.edges.filter((e) => !wd.types.has(e.type) && !wd.hidden.has(e.id) && (!wd.problems || e.light === "problem")
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; && (!wd.focus || !sel || e.from === sel || e.to === sel));
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 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 }; 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() { function wdDraw() {
const svg = document.getElementById("wiresvg"); const svg = document.getElementById("wdsvg");
if (!svg) return; if (!svg) return;
if (!wire.view) wireFit(); if (!wd.view) wdFit();
svg.setAttribute("viewBox", `${wire.view.x} ${wire.view.y} ${wire.view.w} ${wire.view.h}`); wdViewBox(svg);
const { edges, nodes } = wireShown(), sel = wire.sel; const P = wdP(), { edges, nodes } = wdShown(), sel = wd.sel, near = new Set();
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) { 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 = {}; const pairs = {};
for (const e of edges) { const k = [e.from, e.to].sort().join("|"); (pairs[k] = pairs[k] || []).push(e); } 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) => { for (const list of Object.values(pairs)) list.forEach((e, i) => {
const a = wire.pos[e.from], b = wire.pos[e.to]; const a = P[e.from], b = P[e.to];
if (!a || !b) return; 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 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 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; 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(wireName(e.from))} ↔ ${esc(wireName(e.to))} · ${esc(e.label)} · ${esc(lightWord[e.light] || e.light)}</title></path>`; 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) { 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 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; 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> <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>`; ${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; svg.innerHTML = out;
} }
function wireTools() { function wdTools() {
const el = document.getElementById("wiretools"); const el = document.getElementById("wdtools");
if (!el) return; 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; for (const e of d.edges) count[e.type] = (count[e.type] || 0) + 1;
const bad = d.edges.filter((e) => e.light === "problem").length; const present = d.types.filter((t) => count[t.id]), 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("") 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>`
+ `<button class="small${wire.problems ? " on" : ""}" id="wprob" data-hint="Only the connections with a problem">Problems only <span class="muted">${bad}</span></button>` + 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="wall" data-hint="Show every connection again">Show all</button>` + `<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" id="wre" data-hint="Forget where machines were dragged and lay them out again">Re-arrange</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="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>`; + `<button class="small" id="wdre" data-hint="Forget where machines were dragged in this layout and lay them out again">Re-arrange</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(); }; + `<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>`;
el.querySelector("#wprob").onclick = () => { wire.problems = !wire.problems; wireTools(); wireDraw(); wireSide(); }; const redraw = () => { wdSave(); wdTools(); wdDraw(); wdSide(); };
el.querySelector("#wall").onclick = () => { wire.types.clear(); wire.hidden.clear(); wire.focus = false; wire.problems = false; wireSave(); wireTools(); wireDraw(); wireSide(); }; for (const b of el.querySelectorAll("[data-wdlayout]")) b.onclick = () => { wd.layout = b.dataset.wdlayout; wdLayout(); wdFit(); redraw(); };
el.querySelector("#wre").onclick = () => { wireLayout(true); wireFit(); wireDraw(); }; 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("#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); }; 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() { function wdSide() {
const el = document.getElementById("wireside"); const el = document.getElementById("wdside");
if (!el) return; if (!el) return;
const d = wire.data, sel = wire.sel; const d = wd.data, sel = wd.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 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; let html;
if (sel && sel.edge) { if (sel && sel.edge) {
const e = d.edges.find((x) => x.id === sel.id), t = wireType(e.type); 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(wireName(e.from))} ↔ ${esc(wireName(e.to))}</p><p class="small">${esc(e.label)} · ${lightDot(e.light)} ${esc(lightWord[e.light] || e.light)}</p> 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-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>`; <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) { } else if (sel) {
const n = wireNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id); const n = wdNode(sel.id), mine = d.edges.filter((e) => e.from === n.id || e.to === n.id), byType = {};
const byType = {};
for (const e of mine) (byType[e.type] = byType[e.type] || []).push(e); for (const e of mine) (byType[e.type] = byType[e.type] || []).push(e);
const scripts = n.kind !== "computer" ? "" : n.scripts == null 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</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>` : ""}`; : `<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 ? " · this Mac (the panel)" : "") : n.kind)}${n.address ? " · " + esc(n.address) : ""}${n.role ? " · " + esc(n.role) : ""}</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>` : ""} ${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} <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:${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>`} <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>` : ""}`; ${(n.local || []).length ? `<p class="small muted">On this machine itself: ${esc(n.local.map((x) => x.label).join(", "))}</p>` : ""}`;
} else { } else {
const comps = d.nodes.filter((n) => n.kind === "computer"); const comps = d.nodes.filter((n) => n.kind === "computer"), bad = d.edges.filter((e) => e.light === "problem");
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> 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> <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-wedge="${esc(e.id)}">${esc(wireName(e.from))} ↔ ${esc(wireName(e.to))}</a> <span class="muted small">${esc(e.label)}</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>` : ""}
${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>` : ""}`; ${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; 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(); }; const again = () => { wdSave(); wdDraw(); wdSide(); };
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 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 a of el.querySelectorAll("[data-wsel]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wsel }; wireDraw(); wireSide(); }; 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-wedge]")) a.onclick = (ev) => { ev.preventDefault(); wire.sel = { id: a.dataset.wedge, edge: true }; wireDraw(); wireSide(); }; for (const a of el.querySelectorAll("[data-wdsel]")) a.onclick = (ev) => { ev.preventDefault(); wd.sel = { id: a.dataset.wdsel }; wdDraw(); wdSide(); };
const f = el.querySelector("#wfocus"); if (f) f.onclick = () => { wire.focus = !wire.focus; wireDraw(); wireSide(); }; 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() { function wdEvents() {
const svg = document.getElementById("wiresvg"); 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()); }; 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("wdzin").onclick = () => wdZoom(0.8);
document.getElementById("wzout").onclick = () => wireZoom(1.25); document.getElementById("wdzout").onclick = () => wdZoom(1.25);
document.getElementById("wfit").onclick = () => { wireFit(); wireDraw(); }; document.getElementById("wdfit").onclick = () => { wdFit(); wdDraw(); };
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 }); // Trackpads send many small wheel steps (pinch: with ctrlKey): each zooms a little, in proportion; a mouse
svg.addEventListener("pointerdown", (ev) => { // wheel's big steps are capped. Sideways two-finger scroll moves the view.
const g = ev.target.closest("[data-node]"), path = ev.target.closest("[data-edge]"); 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); 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 }; } wdZoom(Math.exp(Math.max(-0.2, Math.min(0.2, dy * (ev.ctrlKey ? 0.008 : 0.0018)))), p.x, p.y);
else if (path) { wire.sel = { id: path.dataset.edge, edge: true }; wireDraw(); wireSide(); return; } }, { passive: false });
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.addEventListener("pointerdown", (ev) => {
svg.setPointerCapture(ev.pointerId); ev.preventDefault(); 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) => { 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; if (!dr.moved && Math.hypot(ev.clientX - dr.x0, ev.clientY - dr.y0) < 4) return;
dr.moved = true; 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); const p = at(ev);
wire.pos[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy }; wdP()[dr.id] = { x: p.x + dr.dx, y: p.y + dr.dy };
wireDraw(); wdDraw();
}); });
const end = () => { const end = () => {
const dr = wire.drag; wire.drag = null; if (!dr) return; const dr = wd.drag; wd.drag = null; if (!dr) return;
if (dr.pan) { if (!dr.moved) { wire.sel = null; wire.focus = false; wireDraw(); wireSide(); } return; } if (dr.pan) { if (!dr.moved) { wd.sel = null; wd.focus = false; wdDraw(); wdSide(); } 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; } 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; }
wireDraw(); wireSide(); wdDraw(); wdSide();
}; };
svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end); svg.addEventListener("pointerup", end); svg.addEventListener("pointercancel", end);
} }
@@ -1148,7 +1249,7 @@
else if (which === "outlets") body = await outletsHtml(); else if (which === "outlets") body = await outletsHtml();
else if (which === "companion") body = (await namesHtml()) + (await companionHtml()); else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante"); 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 === "site") body = siteHtml(c);
else if (which === "program") body = await programHtml(); else if (which === "program") body = await programHtml();
else if (which === "advanced") body = advancedHtml(); 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>"; 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. // 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); const d = await api("/api/discover").catch(() => null);
if (!d) return "<p class='muted'>Auto-setup is not answering.</p>"; if (!d) return "<p class='muted'>Auto-setup is not answering.</p>";
const last = d.last || {}, inv = last.inventory || {}; 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; } .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; } .wrow { display: flex; gap: 6px; align-items: center; cursor: pointer; } .wtyp { margin: 8px 0 2px; }
main:has(.wire) { max-width: none; } 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", "url": "http://127.0.0.1:8095/timecode",
"msg": "TIMECODE LOST", "msg": "TIMECODE LOST",
"interval_seconds": 2 "interval_seconds": 2
},
{
"type": "rosstalk",
"address": "local",
"process": "ProPresenter",
"name": "PROP",
"debounce_seconds": 20
} }
], ],
"pos": [ "pos": [
+7
View File
@@ -500,6 +500,13 @@
"match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$", "match": "/Applications/ProPresenter[^/]*\\.app/Contents/MacOS/ProPresenter$",
"name": "PROP", "name": "PROP",
"type": "process" "type": "process"
},
{
"type": "rosstalk",
"address": "local",
"process": "ProPresenter",
"name": "PROP",
"debounce_seconds": 20
} }
], ],
"id": "prop", "id": "prop",
+95 -5
View File
@@ -170,6 +170,8 @@ class Snapshot:
self.remote_midi_time = {} # host -> when it last reported self.remote_midi_time = {} # host -> when it last reported
self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at
self.conn_hosts = {} # Companion connection label -> its "host" setting 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.prefs = {} # (preferences domain, key) -> value as "defaults read" prints it
self.atem = None # atem_watch.js's latest report self.atem = None # atem_watch.js's latest report
self.spotify = None # {"running", "state", "track", "name", "artist"} on NA Ingest 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))] 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): def check_process(chk, snap, ctx):
running = snap.procs.get(chk["match"]) running = snap.procs.get(chk["match"])
if running is False: 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, "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, "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, "ready": check_ready, "ready_manual": check_ready_manual, "tune_rx": check_tune_rx, "heartbeat": check_heartbeat,
"rosstalk": check_rosstalk,
} }
@@ -2911,12 +2934,76 @@ def companion_conn_hosts(path):
return hosts return hosts
def poll_conn_hosts(snap, cfg): def companion_conn_info(path):
if any(c["type"] == "ping" and c.get("conn") for t in all_tiles(cfg) for c in t.get("checks", [])): """{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:
rows = db.execute("SELECT value FROM instances").fetchall()
finally:
db.close()
out = {}
for (value,) in rows:
try: try:
snap.conn_hosts = companion_conn_hosts(companion_db_path(cfg)) or snap.conn_hosts inst = json.loads(value)
except Exception as e: # Companion's schema is its own business: fall back to "host" except (TypeError, ValueError):
log.warning("could not read connection addresses from Companion: %s", e) 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) ' SPOTIFY_STATE = ('tell application "Spotify" to return (player state as text) & "|" & (id 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("variables", 1.0, lambda: poll_variables(c2, snap, fast), stop),
Periodic("slow-variables", 3.0, lambda: poll_variables(c3, snap, slow), 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("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("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("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), 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()}, for label, c in snap.conns.items()},
"conns_fresh": snap.conns_fresh(now), "conns_fresh": snap.conns_fresh(now),
"conn_hosts": dict(snap.conn_hosts), "conn_hosts": dict(snap.conn_hosts),
"conn_info": dict(snap.conn_info),
"rosstalk": snap.rosstalk,
"ping": dict(snap.ping), "ping": dict(snap.ping),
"link": snap.link, "link": snap.link,
"midi": snap.midi if snap.midi_time else None, "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")) 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 = json.load(open(os.path.join(self.data, "snapshot.json")))
snap.update({"conns": {"ATEM": {"category": "good", "enabled": True}, "x32": {"category": "error", "enabled": True}, 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}}, "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"}]}}}, "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()}, "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()}, "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(("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("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("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) self.assertEqual({t["id"] for t in w["types"]} >= {"companion", "midi", "watch", "dante"}, True)
def test_gear_and_keys_and_pages(self): 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"}) 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)]) 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): def test_probe_commands_never_carry_names_from_the_settings(self):
cfg = json.loads(json.dumps(CONFIG)) cfg = json.loads(json.dumps(CONFIG))
cfg["hosts"]["tracks"]["runs"][1]["process"] = "x; rm -rf ~" # a hostile name in the settings 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)) 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): class TestTextSize(unittest.TestCase):
def test_no_word_is_split(self): def test_no_word_is_split(self):
for text in ("TASCAM\nOK", "SERVICE\nON\nmanual", "OBS\nOFFLINE", "INGEST\n97GB free", for text in ("TASCAM\nOK", "SERVICE\nON\nmanual", "OBS\nOFFLINE", "INGEST\n97GB free",