Every MIDI link by wired address: flag links joined by name, hold MIDI moves them, refuse non-wired addresses
Tracks had joined NA Ingest, Lighting PC and Studio B by Bonjour name: macOS picks the network each time, so a reconnect could land on Wi-Fi or Dante. The wired check now also names a link joined by name; holding MIDI moves it to the wired address its own MIDI check names (a hold only: auto-fix never moves a working link). Reconnects prefer a wired address over a Wi-Fi or by-name one they last saw. The settings refuse a MIDI link whose address is a name or off the wired network, and the add-a-link screen fills in the wired address. Studio B's link now has its wired address, and Tracks' link to NA Ingest is watched (TRK INGEST). All links on the four Macs were moved and checked: wired IPv4, running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-2
@@ -1055,7 +1055,7 @@
|
|||||||
"type": "midi_peer"
|
"type": "midi_peer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": "\tStudio B Mac Studio\tlocal.",
|
"address": "10.1.13.175:5004",
|
||||||
"fix": "midi_reconnect",
|
"fix": "midi_reconnect",
|
||||||
"host": "tracks",
|
"host": "tracks",
|
||||||
"peer": "Studio B Mac Studio",
|
"peer": "Studio B Mac Studio",
|
||||||
@@ -1762,7 +1762,7 @@
|
|||||||
"type": "midi_peer"
|
"type": "midi_peer"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": "\tStudio B Mac Studio\tlocal.",
|
"address": "10.1.13.175:5004",
|
||||||
"fix": "midi_reconnect",
|
"fix": "midi_reconnect",
|
||||||
"host": "tracks",
|
"host": "tracks",
|
||||||
"peer": "Studio B Mac Studio",
|
"peer": "Studio B Mac Studio",
|
||||||
@@ -1779,6 +1779,15 @@
|
|||||||
"tag": "PROP LIGHTS",
|
"tag": "PROP LIGHTS",
|
||||||
"type": "midi_peer"
|
"type": "midi_peer"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"address": "10.1.1.1:5009",
|
||||||
|
"fix": "midi_reconnect",
|
||||||
|
"host": "tracks",
|
||||||
|
"peer": "NA Ingest",
|
||||||
|
"session": "NA Tracks",
|
||||||
|
"tag": "TRK INGEST",
|
||||||
|
"type": "midi_peer"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"conn": "lighting-midi",
|
"conn": "lighting-midi",
|
||||||
"tag": "COMPANION MIDI",
|
"tag": "COMPANION MIDI",
|
||||||
@@ -1801,6 +1810,7 @@
|
|||||||
"type": "midi_rx"
|
"type": "midi_rx"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"fix": "midi_reconnect",
|
||||||
"nets": [
|
"nets": [
|
||||||
"10.1.0.0/16"
|
"10.1.0.0/16"
|
||||||
],
|
],
|
||||||
|
|||||||
+11
-4
@@ -168,14 +168,21 @@ def stuck_peer(sess, peer_name):
|
|||||||
return any(p.get("name") == peer_name and p.get("state") not in LIVE_STATES for p in sess.get("peers", []))
|
return any(p.get("name") == peer_name and p.get("state") not in LIVE_STATES for p in sess.get("peers", []))
|
||||||
|
|
||||||
|
|
||||||
def connect_peer(session_name, peer_name, address, settle=time.sleep):
|
def listed_elsewhere(sess, peer_name, address):
|
||||||
|
"""True if the peer is in the session at another address (a Bonjour name, a Wi-Fi address)."""
|
||||||
|
return any(p.get("name") == peer_name and p.get("address") != address for p in sess.get("peers", []))
|
||||||
|
|
||||||
|
|
||||||
|
def connect_peer(session_name, peer_name, address, settle=time.sleep, repoint=False):
|
||||||
"""Ask macOS to connect `peer_name` at `address` ("10.1.10.206:5004") to the named session,
|
"""Ask macOS to connect `peer_name` at `address` ("10.1.10.206:5004") to the named session,
|
||||||
like Audio MIDI Setup's Connect. Returns "already" (it is in the session and live), "asked",
|
like Audio MIDI Setup's Connect. Returns "already" (it is in the session and live), "asked",
|
||||||
or "kicked" (it was listed but stuck: dropped, then connected again)."""
|
"kicked" (it was listed but stuck: dropped, then connected again) or, with `repoint`, "moved"
|
||||||
|
(it was listed at another address - a Bonjour name, Wi-Fi - and now joins at this one)."""
|
||||||
with _lock:
|
with _lock:
|
||||||
found = [(e, s) for e, s in _session_entities()
|
found = [(e, s) for e, s in _session_entities()
|
||||||
if (s.get("dns-sd-name") or _string_prop(e, "name")) == session_name]
|
if (s.get("dns-sd-name") or _string_prop(e, "name")) == session_name]
|
||||||
if found and stuck_peer(found[0][1], peer_name):
|
moving = bool(found and repoint and listed_elsewhere(found[0][1], peer_name, address))
|
||||||
|
if found and (moving or stuck_peer(found[0][1], peer_name)):
|
||||||
ent, sess = found[0]
|
ent, sess = found[0]
|
||||||
dropped = without_peer(sess, peer_name)
|
dropped = without_peer(sess, peer_name)
|
||||||
with _lock:
|
with _lock:
|
||||||
@@ -188,7 +195,7 @@ def connect_peer(session_name, peer_name, address, settle=time.sleep):
|
|||||||
err = _set_dict_prop(ent, "apple.midirtp.session", with_peer(dropped, peer_name, address))
|
err = _set_dict_prop(ent, "apple.midirtp.session", with_peer(dropped, peer_name, address))
|
||||||
if err:
|
if err:
|
||||||
raise OSError("MIDIObjectSetDictionaryProperty failed: %d" % err)
|
raise OSError("MIDIObjectSetDictionaryProperty failed: %d" % err)
|
||||||
return "kicked"
|
return "moved" if moving else "kicked"
|
||||||
result = _edit_session(session_name, lambda sess: with_peer(sess, peer_name, address))
|
result = _edit_session(session_name, lambda sess: with_peer(sess, peer_name, address))
|
||||||
return "already" if result == "unchanged" else result
|
return "already" if result == "unchanged" else result
|
||||||
|
|
||||||
|
|||||||
@@ -715,6 +715,29 @@ def validate(cfg, live=None):
|
|||||||
errors.append("key %s check %d: unknown computer %s" % (t.get("id"), i + 1, c["host"]))
|
errors.append("key %s check %d: unknown computer %s" % (t.get("id"), i + 1, c["host"]))
|
||||||
elif h.get("self"):
|
elif h.get("self"):
|
||||||
errors.append("key %s check %d: %s is this Mac" % (t.get("id"), i + 1, c["host"]))
|
errors.append("key %s check %d: %s is this Mac" % (t.get("id"), i + 1, c["host"]))
|
||||||
|
# Network MIDI rides the wired network (2026-10-20): a link set to a Bonjour name or to an
|
||||||
|
# address off it can come back over Wi-Fi or Dante, so it is refused once the MIDI key has the rule.
|
||||||
|
nets = []
|
||||||
|
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
if c.get("type") == "midi_wired":
|
||||||
|
for n in c.get("nets") or []:
|
||||||
|
try:
|
||||||
|
nets.append(ipaddress.ip_network(str(n), strict=False))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if nets:
|
||||||
|
for t in cfg["tiles"] + ((cfg.get("ready_page") or {}).get("keys") or []):
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
if c.get("type") != "midi_peer" or c.get("disabled"):
|
||||||
|
continue
|
||||||
|
where = "key %s, MIDI link %s" % (t.get("id"), c.get("peer"))
|
||||||
|
ip = sp.peer_ip(c.get("address"))
|
||||||
|
if not c.get("address"):
|
||||||
|
warnings.append("%s: no wired address - a reconnect may pick Wi-Fi or Dante" % where)
|
||||||
|
elif ip is None or not any(ip in n for n in nets):
|
||||||
|
errors.append("%s: %s is not on the wired network (%s) - give its wired address, like 10.1.10.206:5004"
|
||||||
|
% (where, str(c["address"]).strip() or "?", ", ".join(str(n) for n in nets)))
|
||||||
a = cfg.get("auto_fix") or {}
|
a = cfg.get("auto_fix") or {}
|
||||||
for f in a.get("fixes", []):
|
for f in a.get("fixes", []):
|
||||||
if f not in fixes:
|
if f not in fixes:
|
||||||
|
|||||||
+10
-2
@@ -1501,25 +1501,33 @@
|
|||||||
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
|
const macs = Object.entries(c.hosts).filter(([, h]) => h.self || (h.os || "mac") === "mac");
|
||||||
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
|
const midiKey = c.tiles.find((t) => (t.checks || []).some((ch) => ch.type === "midi_peer"));
|
||||||
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
|
const fixId = Object.keys(c.fixes).find((id) => c.fixes[id].kind === "midi_connect");
|
||||||
|
// The wired network(s) MIDI must ride (the MIDI key's wired check): suggest and insist on an address on it.
|
||||||
|
const nets = c.tiles.concat((c.ready_page || {}).keys || []).flatMap((t) => (t.checks || []).filter((ch) => ch.type === "midi_wired").flatMap((ch) => ch.nets || []));
|
||||||
|
const ip4 = (a) => { const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)(?::\d+)?$/.exec(String(a || "").trim()); return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null; };
|
||||||
|
const wired = (a) => { const v = ip4(a); return v !== null && nets.some((n) => { const [b, bits] = String(n).split("/"); const k = Number(bits || 32), base = ip4(b); if (base === null) return false; const mask = k ? (2 ** 32 - 2 ** (32 - k)) : 0; return Math.floor(v / 2 ** (32 - k)) === Math.floor(base / 2 ** (32 - k)) && (mask >= 0); }); };
|
||||||
|
const wiredFor = (peer) => { for (const src of [live.local || {}].concat(Object.values(live.remote || {}))) for (const s of Object.values(src)) for (const p of s.peers || []) if (p.name === peer && wired(p.address)) return p.address;
|
||||||
|
const h = Object.values(c.hosts).find((x) => x.name === peer || (x.midi_name || "") === peer); const ip = h && String(h.ssh || "").split("@").pop(); return ip && wired(ip) ? ip + ":5004" : ""; };
|
||||||
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
|
const sessionsOf = (alias) => { const h = c.hosts[alias]; const src = h.self ? (live.local || {}) : ((live.remote || {})[alias] || {}); return Object.entries(src).map(([name, v]) => [name, (v.peers || []).map((p) => p.name)]); };
|
||||||
dialog(`<h3>MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}</h3>
|
dialog(`<h3>MIDI link${hint("A network MIDI session on one of the Macs, and the peer that must stay in it. Pick from what the Macs report now, or type the names if that Mac is off.")}</h3>
|
||||||
<select id="jhost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}${h.self ? " (this Mac)" : ""}</option>`).join("")}</select>
|
<select id="jhost">${macs.map(([a, h]) => `<option value="${esc(a)}">${esc(h.name || a)}${h.self ? " (this Mac)" : ""}</option>`).join("")}</select>
|
||||||
<input id="jsession" list="jsessions" placeholder="session, as Audio MIDI Setup names it"><datalist id="jsessions"></datalist>
|
<input id="jsession" list="jsessions" placeholder="session, as Audio MIDI Setup names it"><datalist id="jsessions"></datalist>
|
||||||
<input id="jpeer" list="jpeers" placeholder="peer (the other end)"><datalist id="jpeers"></datalist>
|
<input id="jpeer" list="jpeers" placeholder="peer (the other end)"><datalist id="jpeers"></datalist>
|
||||||
<input id="jtag" placeholder="key text when lost (LIGHT PC)" maxlength="10">
|
<input id="jtag" placeholder="key text when lost (LIGHT PC)" maxlength="10">
|
||||||
<input id="jaddr" placeholder="address, if it must be reconnected by address (optional)">
|
<input id="jaddr" placeholder="${nets.length ? "wired address, like 10.1.10.206:5004" : "address, if it must be reconnected by address (optional)"}">
|
||||||
<select id="jkey">${midiKey ? `<option value="${esc(midiKey.id)}">on the ${esc(midiKey.label)} key</option>` : ""}<option value="__new">on a new key</option></select>
|
<select id="jkey">${midiKey ? `<option value="${esc(midiKey.id)}">on the ${esc(midiKey.label)} key</option>` : ""}<option value="__new">on a new key</option></select>
|
||||||
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
<div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
|
||||||
b.querySelector("#dcancel").onclick = closeDialog;
|
b.querySelector("#dcancel").onclick = closeDialog;
|
||||||
const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `<option value="${esc(n)}">`).join(""); const cur = ss.find(([n]) => n === b.querySelector("#jsession").value) || ss[0]; if (cur && !b.querySelector("#jsession").value) b.querySelector("#jsession").value = cur[0]; b.querySelector("#jpeers").innerHTML = ((cur || [])[1] || []).map((n) => `<option value="${esc(n)}">`).join(""); };
|
const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `<option value="${esc(n)}">`).join(""); const cur = ss.find(([n]) => n === b.querySelector("#jsession").value) || ss[0]; if (cur && !b.querySelector("#jsession").value) b.querySelector("#jsession").value = cur[0]; b.querySelector("#jpeers").innerHTML = ((cur || [])[1] || []).map((n) => `<option value="${esc(n)}">`).join(""); };
|
||||||
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
|
b.querySelector("#jhost").onchange = () => { b.querySelector("#jsession").value = ""; fill(); };
|
||||||
b.querySelector("#jsession").oninput = fill;
|
b.querySelector("#jsession").oninput = fill;
|
||||||
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; };
|
b.querySelector("#jpeer").oninput = () => { if (!b.querySelector("#jtag").value) b.querySelector("#jtag").placeholder = tagOf(b.querySelector("#jpeer").value) || "LIGHT PC"; const w = wiredFor(b.querySelector("#jpeer").value.trim()); if (w && !b.querySelector("#jaddr").dataset.typed) b.querySelector("#jaddr").value = w; };
|
||||||
|
b.querySelector("#jaddr").oninput = (e) => { e.target.dataset.typed = "1"; };
|
||||||
fill();
|
fill();
|
||||||
b.querySelector("#dok").onclick = () => {
|
b.querySelector("#dok").onclick = () => {
|
||||||
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
|
const host = b.querySelector("#jhost").value, session = b.querySelector("#jsession").value.trim(), peer = b.querySelector("#jpeer").value.trim();
|
||||||
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
|
const tag = b.querySelector("#jtag").value.trim() || tagOf(peer), addr = b.querySelector("#jaddr").value.trim(), keyId = b.querySelector("#jkey").value;
|
||||||
if (!session || !peer) return alertBox("A session and a peer, please.");
|
if (!session || !peer) return alertBox("A session and a peer, please.");
|
||||||
|
if (nets.length && !wired(addr)) return alertBox(`MIDI rides the wired network (${nets.join(", ")}). Give ${peer}'s wired address, like 10.1.10.206:5004: a name or a Wi-Fi address can drop mid-service.`);
|
||||||
if (!ensureDraft()) return;
|
if (!ensureDraft()) return;
|
||||||
const d = state.draft.config;
|
const d = state.draft.config;
|
||||||
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
|
const chk = Object.assign({ type: "midi_peer", session, peer, tag }, d.hosts[host].self ? {} : { host }, fixId ? { fix: fixId } : {}, addr ? { address: addr } : {});
|
||||||
|
|||||||
+64
-9
@@ -530,17 +530,53 @@ def check_midi_wired(chk, snap, ctx):
|
|||||||
for host, sessions in groups:
|
for host, sessions in groups:
|
||||||
for sname, sess in (sessions or {}).items():
|
for sname, sess in (sessions or {}).items():
|
||||||
for p in sess.get("peers", []):
|
for p in sess.get("peers", []):
|
||||||
|
name = p["name"].upper()
|
||||||
|
name = name if len(name) <= 14 else name[:13].rstrip() + "."
|
||||||
|
if by_name(p.get("address")):
|
||||||
|
# Joined from the Bonjour list: on a Mac with Wi-Fi and Dante as well, macOS may pick
|
||||||
|
# any of them, now or at the next reconnect. Hold MIDI joins it at its wired address.
|
||||||
|
out.append(("%s|%s|%s" % (host, sname, p["name"]), Result(ERR, "%s BY NAME" % name, named=True)))
|
||||||
|
continue
|
||||||
ip = peer_ip(p.get("address"))
|
ip = peer_ip(p.get("address"))
|
||||||
if ip is None or ip.is_loopback or any(ip in n for n in nets) or (p["name"], str(ip)) in seen:
|
if ip is None or ip.is_loopback or any(ip in n for n in nets) or (p["name"], str(ip)) in seen:
|
||||||
continue
|
continue
|
||||||
seen.add((p["name"], str(ip)))
|
seen.add((p["name"], str(ip)))
|
||||||
name = p["name"].upper()
|
|
||||||
name = name if len(name) <= 14 else name[:13] + "."
|
|
||||||
out.append(("%s|%s|%s" % (host, sname, p["name"]),
|
out.append(("%s|%s|%s" % (host, sname, p["name"]),
|
||||||
Result(ERR, "%s NOT WIRED" % name, named=True)))
|
Result(ERR, "%s NOT WIRED" % name, named=True)))
|
||||||
return out or [("wired", Result(OK))]
|
return out or [("wired", Result(OK))]
|
||||||
|
|
||||||
|
|
||||||
|
def by_name(address):
|
||||||
|
"""A peer joined by its Bonjour name ("\tNA Ingest\tlocal."): macOS picks the network each time."""
|
||||||
|
return str(address or "").startswith("\t")
|
||||||
|
|
||||||
|
|
||||||
|
def wired_nets(cfg):
|
||||||
|
"""The wired network(s) the MIDI key's wired check names (empty: no rule)."""
|
||||||
|
nets = []
|
||||||
|
for t in all_tiles(cfg):
|
||||||
|
for c in t.get("checks", []):
|
||||||
|
if c.get("type") == "midi_wired":
|
||||||
|
nets += [ipaddress.ip_network(n, strict=False) for n in c.get("nets") or []]
|
||||||
|
return nets
|
||||||
|
|
||||||
|
|
||||||
|
def pick_midi_address(nets, last, configured):
|
||||||
|
"""Where a MIDI reconnect joins a peer: where it was last seen, unless that was off the wired
|
||||||
|
network or a Bonjour name and the settings name a wired address (2026-10-20: a reconnect must
|
||||||
|
never go back to a Wi-Fi copy)."""
|
||||||
|
def wired(a):
|
||||||
|
ip = peer_ip(a)
|
||||||
|
return ip is not None and any(ip in n for n in nets)
|
||||||
|
if not nets:
|
||||||
|
return last or configured
|
||||||
|
if last and wired(last):
|
||||||
|
return last
|
||||||
|
if configured and wired(configured):
|
||||||
|
return configured
|
||||||
|
return last or configured
|
||||||
|
|
||||||
|
|
||||||
def check_mdns(chk, snap, ctx):
|
def check_mdns(chk, snap, ctx):
|
||||||
if snap.mdns_time and chk["instance"] not in snap.mdns:
|
if snap.mdns_time and chk["instance"] not in snap.mdns:
|
||||||
return [("mdns", Result(ERR, chk.get("name", chk["instance"]) + " GONE", named=True))]
|
return [("mdns", Result(ERR, chk.get("name", chk["instance"]) + " GONE", named=True))]
|
||||||
@@ -1374,10 +1410,27 @@ class Engine:
|
|||||||
self.deck_ips[chk["conn"]] = ip
|
self.deck_ips[chk["conn"]] = ip
|
||||||
chk = dict(chk, ip=ip)
|
chk = dict(chk, ip=ip)
|
||||||
target = chk["conn"]
|
target = chk["conn"]
|
||||||
if fix_kind == "midi_connect":
|
if fix_kind == "midi_connect" and chk["type"] == "midi_wired":
|
||||||
# Reconnect the peer where it was last seen (or the config's address).
|
# A peer off the wired network or joined by name: join it again at the wired
|
||||||
|
# address its own MIDI check names. One no check names (ProdDash's own) is left
|
||||||
|
# to its program: the key says which.
|
||||||
|
if errors_only:
|
||||||
|
continue # moving a working link drops it a second: a hold only
|
||||||
|
host, session, peer = (sub.split("|", 2) + ["", ""])[:3]
|
||||||
|
nets = wired_nets(self.cfg)
|
||||||
|
want = next((c.get("address") for t2 in all_tiles(self.cfg) for c in t2.get("checks", [])
|
||||||
|
if c.get("type") == "midi_peer" and c.get("host", "") == host and c.get("session") == session
|
||||||
|
and c.get("peer") == peer and peer_ip(c.get("address")) is not None
|
||||||
|
and any(peer_ip(c["address"]) in n for n in nets)), None)
|
||||||
|
if not want:
|
||||||
|
continue
|
||||||
|
chk = dict({"host": host} if host else {}, type="midi_peer", session=session, peer=peer, address=want, repoint=True)
|
||||||
|
target = "|".join((host, session, peer))
|
||||||
|
elif fix_kind == "midi_connect":
|
||||||
|
# Reconnect the peer where it was last seen, or at the config's address when that
|
||||||
|
# was off the wired network.
|
||||||
key = (chk.get("host", ""), chk["session"], chk["peer"])
|
key = (chk.get("host", ""), chk["session"], chk["peer"])
|
||||||
address = snap.peer_addr.get(key) or chk.get("address")
|
address = pick_midi_address(wired_nets(self.cfg), snap.peer_addr.get(key), chk.get("address"))
|
||||||
if not address:
|
if not address:
|
||||||
continue
|
continue
|
||||||
chk = dict(chk, address=address)
|
chk = dict(chk, address=address)
|
||||||
@@ -1903,7 +1956,8 @@ class Engine:
|
|||||||
return None
|
return None
|
||||||
chk = dict(chk, ip=chk.get("ip") or snap.var(chk["conn"], "ip") or self.deck_ips.get(chk["conn"]))
|
chk = dict(chk, ip=chk.get("ip") or snap.var(chk["conn"], "ip") or self.deck_ips.get(chk["conn"]))
|
||||||
if fix.get("kind") == "midi_connect":
|
if fix.get("kind") == "midi_connect":
|
||||||
address = snap.peer_addr.get((chk.get("host", ""), chk["session"], chk["peer"])) or chk.get("address")
|
address = pick_midi_address(wired_nets(self.cfg), snap.peer_addr.get((chk.get("host", ""), chk["session"], chk["peer"])),
|
||||||
|
chk.get("address"))
|
||||||
if not address:
|
if not address:
|
||||||
log.info("TEST %s: no address known for %s", key_id, chk["peer"])
|
log.info("TEST %s: no address known for %s", key_id, chk["peer"])
|
||||||
return None
|
return None
|
||||||
@@ -2711,9 +2765,10 @@ def execute_fix(cfg, fix_id, chk):
|
|||||||
if kind == "midi_connect":
|
if kind == "midi_connect":
|
||||||
# Like Connect in Audio MIDI Setup: add the peer back to the session it dropped out of.
|
# Like Connect in Audio MIDI Setup: add the peer back to the session it dropped out of.
|
||||||
args = (chk["session"], chk["peer"], chk["address"])
|
args = (chk["session"], chk["peer"], chk["address"])
|
||||||
|
repoint = bool(chk.get("repoint"))
|
||||||
if chk.get("host"):
|
if chk.get("host"):
|
||||||
r = subprocess.run(ssh_cmd(cfg, chk["host"]) + ["/usr/bin/python3", "-"],
|
r = subprocess.run(ssh_cmd(cfg, chk["host"]) + ["/usr/bin/python3", "-"],
|
||||||
input=remote_midi_script("ns['connect_peer'](%r, %r, %r)" % args),
|
input=remote_midi_script("ns['connect_peer'](%r, %r, %r, repoint=%r)" % (args + (repoint,))),
|
||||||
capture_output=True, text=True, timeout=30)
|
capture_output=True, text=True, timeout=30)
|
||||||
result = parse_remote_midi(r.stdout) if r.returncode == 0 else None
|
result = parse_remote_midi(r.stdout) if r.returncode == 0 else None
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -2722,10 +2777,10 @@ def execute_fix(cfg, fix_id, chk):
|
|||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
import coremidi
|
import coremidi
|
||||||
result = coremidi.connect_peer(*args)
|
result = coremidi.connect_peer(*args, repoint=repoint)
|
||||||
log.info("FIX %s -> %s at %s, session %s%s: %s", label, chk["peer"], chk["address"], chk["session"],
|
log.info("FIX %s -> %s at %s, session %s%s: %s", label, chk["peer"], chk["address"], chk["session"],
|
||||||
(" on " + chk["host"]) if chk.get("host") else "", result)
|
(" on " + chk["host"]) if chk.get("host") else "", result)
|
||||||
return result in ("asked", "already", "kicked")
|
return result in ("asked", "already", "kicked", "moved")
|
||||||
if kind == "netgear_poe":
|
if kind == "netgear_poe":
|
||||||
# Netgear AV switch on the Dante network: power-cycle one PoE port (netgear.py).
|
# Netgear AV switch on the Dante network: power-cycle one PoE port (netgear.py).
|
||||||
import netgear
|
import netgear
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ SUB = {("auto", "windows"): "windows", ("site", "live_services"): "windows", ("t
|
|||||||
ELEMENT = {"tiles": "tile", "checks": "check", "restart_keys": "restart_key", "windows": "window", "fixes": "fix",
|
ELEMENT = {"tiles": "tile", "checks": "check", "restart_keys": "restart_key", "windows": "window", "fixes": "fix",
|
||||||
"hosts": "host", "gears": "gear"}
|
"hosts": "host", "gears": "gear"}
|
||||||
# Keys the code reads that are the watchdog's own working data, not settings.
|
# Keys the code reads that are the watchdog's own working data, not settings.
|
||||||
NOT_SETTINGS = {("check", "ip")}
|
NOT_SETTINGS = {("check", "ip"), ("check", "repoint")} # set by the watchdog on a fix's copy of a check
|
||||||
|
|
||||||
|
|
||||||
def dotted(node):
|
def dotted(node):
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ class TestChecks(unittest.TestCase):
|
|||||||
engine = sp.Engine(CONFIG)
|
engine = sp.Engine(CONFIG)
|
||||||
self.assertEqual(engine.step(self.snap, self.now)["midi"]["x"], "MIDI\nOK") # not read yet: no verdict
|
self.assertEqual(engine.step(self.snap, self.now)["midi"]["x"], "MIDI\nOK") # not read yet: no verdict
|
||||||
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
|
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
|
||||||
|
{"name": "NA Ingest", "address": "", "state": "running"},
|
||||||
{"name": "SuperRack-LiveBox", "address": "", "state": "running"},
|
{"name": "SuperRack-LiveBox", "address": "", "state": "running"},
|
||||||
{"name": "Studio B Mac Studio", "address": "", "state": "running"},
|
{"name": "Studio B Mac Studio", "address": "", "state": "running"},
|
||||||
{"name": "Lighting PC", "address": "", "state": "running"}]}}
|
{"name": "Lighting PC", "address": "", "state": "running"}]}}
|
||||||
@@ -469,7 +470,7 @@ class TestChecks(unittest.TestCase):
|
|||||||
self.snap.remote_midi_time["tracks"] = self.now
|
self.snap.remote_midi_time["tracks"] = self.now
|
||||||
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
|
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
|
||||||
out = sp.check_midi_wired(chk, self.snap, self.ctx)
|
out = sp.check_midi_wired(chk, self.snap, self.ctx)
|
||||||
self.assertEqual([r.msg for _, r in out], ["PRODDASH LIGH. NOT WIRED"]) # once, not per session
|
self.assertEqual([r.msg for _, r in out], ["PRODDASH LIGH. NOT WIRED", "STUDIO B MAC. BY NAME"]) # once each, not per session
|
||||||
self.snap.remote_midi_time["tracks"] = self.now - 30 # a stale stream: no verdict
|
self.snap.remote_midi_time["tracks"] = self.now - 30 # a stale stream: no verdict
|
||||||
self.snap.midi["NA Ingest"]["peers"].pop()
|
self.snap.midi["NA Ingest"]["peers"].pop()
|
||||||
self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK])
|
self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK])
|
||||||
@@ -480,6 +481,12 @@ class TestChecks(unittest.TestCase):
|
|||||||
self.assertEqual([e for e in schema.validate(cfg)[0] if "wired" in e], [])
|
self.assertEqual([e for e in schema.validate(cfg)[0] if "wired" in e], [])
|
||||||
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["wifi"]
|
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["wifi"]
|
||||||
self.assertTrue(any("not a network" in e for e in schema.validate(cfg)[0]))
|
self.assertTrue(any("not a network" in e for e in schema.validate(cfg)[0]))
|
||||||
|
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
|
||||||
|
peer = next(c for c in midi["checks"] if c.get("peer") == "Studio B Mac Studio")
|
||||||
|
for bad in ("\tStudio B Mac Studio\tlocal.", "10.6.9.193:5004"): # a name, Wi-Fi: refused
|
||||||
|
peer["address"] = bad
|
||||||
|
self.assertTrue(any("Studio B Mac Studio" in e and "not on the wired network" in e for e in schema.validate(cfg)[0]), bad)
|
||||||
|
peer["address"] = "10.1.13.175:5004"
|
||||||
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
|
self.snap.midi["NA Ingest"]["peers"].append({"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"})
|
||||||
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
|
next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"][-1]["nets"] = ["10.1.0.0/16"]
|
||||||
tiles, _ = run_steps(sp.Engine(cfg), self.snap, self.now, 4)
|
tiles, _ = run_steps(sp.Engine(cfg), self.snap, self.now, 4)
|
||||||
@@ -1169,7 +1176,8 @@ class TestFixes(unittest.TestCase):
|
|||||||
|
|
||||||
def test_midi_reconnect_where_the_peer_was_last_seen(self):
|
def test_midi_reconnect_where_the_peer_was_last_seen(self):
|
||||||
others = [{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
|
others = [{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
|
||||||
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}]
|
{"name": "NA Ingest", "address": "10.1.1.1:5009", "state": "running"},
|
||||||
|
{"name": "Studio B Mac Studio", "address": "10.1.13.175:5004", "state": "running"}]
|
||||||
tracks = {"NA Tracks": {"port": 5004, "peers": others + [
|
tracks = {"NA Tracks": {"port": 5004, "peers": others + [
|
||||||
{"name": "Lighting PC", "address": "10.1.10.77:5004", "state": "running"}]}}
|
{"name": "Lighting PC", "address": "10.1.10.77:5004", "state": "running"}]}}
|
||||||
sp.remember_peers(self.snap, "tracks", tracks) # seen at a new address earlier
|
sp.remember_peers(self.snap, "tracks", tracks) # seen at a new address earlier
|
||||||
@@ -1193,7 +1201,7 @@ class TestFixes(unittest.TestCase):
|
|||||||
self.assertTrue(sp.execute_fix(CONFIG, "midi_reconnect", chk))
|
self.assertTrue(sp.execute_fix(CONFIG, "midi_reconnect", chk))
|
||||||
argv, kw = run.call_args.args[0], run.call_args.kwargs
|
argv, kw = run.call_args.args[0], run.call_args.kwargs
|
||||||
self.assertIn("waterschurchmedia@10.1.3.227", argv)
|
self.assertIn("waterschurchmedia@10.1.3.227", argv)
|
||||||
self.assertIn("ns['connect_peer']('NA Tracks', 'Lighting PC', '10.1.10.206:5004')", kw["input"])
|
self.assertIn("ns['connect_peer']('NA Tracks', 'Lighting PC', '10.1.10.206:5004', repoint=False)", kw["input"])
|
||||||
failed = mock.Mock(returncode=255, stdout="", stderr="ssh: timed out")
|
failed = mock.Mock(returncode=255, stdout="", stderr="ssh: timed out")
|
||||||
with mock.patch.object(sp.subprocess, "run", return_value=failed):
|
with mock.patch.object(sp.subprocess, "run", return_value=failed):
|
||||||
self.assertFalse(sp.execute_fix(CONFIG, "midi_reconnect", chk))
|
self.assertFalse(sp.execute_fix(CONFIG, "midi_reconnect", chk))
|
||||||
@@ -1230,6 +1238,60 @@ class TestFixes(unittest.TestCase):
|
|||||||
self.assertEqual([[p["name"] for p in w["peers"]] for w in writes],
|
self.assertEqual([[p["name"] for p in w["peers"]] for w in writes],
|
||||||
[["Lighting PC"], ["Lighting PC", "SuperRack-LiveBox"]]) # dropped, then added back
|
[["Lighting PC"], ["Lighting PC", "SuperRack-LiveBox"]]) # dropped, then added back
|
||||||
|
|
||||||
|
def test_a_peer_listed_elsewhere_is_moved_to_its_wired_address(self):
|
||||||
|
import coremidi
|
||||||
|
from unittest import mock
|
||||||
|
sess = {"dns-sd-name": "NA Tracks", "port": 5004, "peers": [
|
||||||
|
{"name": "Lighting PC", "address": "10.1.10.206:5004", "state": "running"},
|
||||||
|
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}]}
|
||||||
|
writes = []
|
||||||
|
with mock.patch.object(coremidi, "_session_entities", return_value=[(1, sess)]), \
|
||||||
|
mock.patch.object(coremidi, "_string_prop", return_value=None), \
|
||||||
|
mock.patch.object(coremidi, "_set_dict_prop", side_effect=lambda e, k, v: writes.append(v) or 0):
|
||||||
|
self.assertEqual(coremidi.connect_peer("NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004"), "already")
|
||||||
|
self.assertEqual(writes, []) # a plain reconnect never moves a live link
|
||||||
|
self.assertEqual(coremidi.connect_peer("NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004",
|
||||||
|
settle=lambda s: None, repoint=True), "moved")
|
||||||
|
self.assertEqual(coremidi.connect_peer("NA Tracks", "Lighting PC", "10.1.10.206:5004", repoint=True), "already")
|
||||||
|
self.assertEqual([[p["address"] for p in w["peers"]] for w in writes],
|
||||||
|
[["10.1.10.206:5004"], ["10.1.10.206:5004", "10.1.13.175:5004"]]) # dropped, then at the wired address
|
||||||
|
|
||||||
|
def test_midi_reconnects_stay_on_the_wired_network(self):
|
||||||
|
nets = [sp.ipaddress.ip_network("10.1.0.0/16")]
|
||||||
|
self.assertEqual(sp.pick_midi_address(nets, "10.1.10.77:5004", "10.1.10.206:5004"), "10.1.10.77:5004") # moved, still wired
|
||||||
|
self.assertEqual(sp.pick_midi_address(nets, "10.6.3.222:5040", "10.1.1.1:5040"), "10.1.1.1:5040") # last seen on Wi-Fi
|
||||||
|
self.assertEqual(sp.pick_midi_address(nets, "\tNA Ingest\tlocal.", "10.1.1.1:5009"), "10.1.1.1:5009") # last seen by name
|
||||||
|
self.assertEqual(sp.pick_midi_address(nets, "\tNA Ingest\tlocal.", None), "\tNA Ingest\tlocal.") # nothing better known
|
||||||
|
self.assertEqual(sp.pick_midi_address([], "10.6.3.222:5040", "10.1.1.1:5040"), "10.6.3.222:5040") # no rule: as before
|
||||||
|
|
||||||
|
def test_a_link_joined_by_name_is_flagged_and_a_hold_moves_it(self):
|
||||||
|
cfg = json.loads(json.dumps(CONFIG))
|
||||||
|
midi = next(t for t in cfg["tiles"] if t["id"] == "midi")
|
||||||
|
midi["checks"] = [c for c in midi["checks"] if c["type"] != "midi_wired"] + [
|
||||||
|
{"type": "midi_wired", "nets": ["10.1.0.0/16"], "fix": "midi_reconnect"}]
|
||||||
|
for c in midi["checks"]:
|
||||||
|
if c.get("peer") == "Studio B Mac Studio":
|
||||||
|
c["address"] = "10.1.13.175:5004"
|
||||||
|
engine = sp.Engine(cfg)
|
||||||
|
engine.executor = engine.poe_executor = lambda *a: True
|
||||||
|
self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [
|
||||||
|
{"name": "Lighting PC", "address": "10.1.10.206:5004", "state": "running"},
|
||||||
|
{"name": "NA Ingest", "address": "10.1.1.1:5009", "state": "running"},
|
||||||
|
{"name": "SuperRack-LiveBox", "address": "10.1.13.140:5004", "state": "running"},
|
||||||
|
{"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"},
|
||||||
|
{"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"}]}}
|
||||||
|
now = SUNDAY
|
||||||
|
for t in range(9): # the stream stays fresh
|
||||||
|
self.snap.remote_midi_time["tracks"] = now + t
|
||||||
|
tiles = engine.step(self.snap, now + t)
|
||||||
|
self.assertIn("STUDIO B MAC. BY NAME", tiles["midi"]["x"])
|
||||||
|
plan = [(kind, what, c) for _, kind, what, c in engine.fix_plan("midi", self.snap)]
|
||||||
|
moves = [c for kind, what, c in plan if c.get("repoint")]
|
||||||
|
self.assertEqual([(c["host"], c["session"], c["peer"], c["address"]) for c in moves],
|
||||||
|
[("tracks", "NA Tracks", "Studio B Mac Studio", "10.1.13.175:5004")]) # ProdDash's own: left to it
|
||||||
|
auto = engine.fix_plan("midi", self.snap, SUNDAY + 30, 0, True)
|
||||||
|
self.assertFalse(any(c.get("repoint") for _, _, _, c in auto)) # never by itself: a hold
|
||||||
|
|
||||||
def test_an_off_pc_is_woken(self):
|
def test_an_off_pc_is_woken(self):
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
woke = []
|
woke = []
|
||||||
|
|||||||
Reference in New Issue
Block a user