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:
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 || {};
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user