From 6ae17e5b90d7df3cf4922e6b828d69691bfee3ba Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 20 Oct 2026 10:38:22 -0400 Subject: [PATCH] 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 --- config.json | 14 ++++++-- coremidi.py | 15 +++++--- schema.py | 23 ++++++++++++ settings/ui/app.js | 12 +++++-- status_panel.py | 73 +++++++++++++++++++++++++++++++++----- tests/test_schema.py | 2 +- tests/test_status_panel.py | 68 +++++++++++++++++++++++++++++++++-- 7 files changed, 186 insertions(+), 21 deletions(-) diff --git a/config.json b/config.json index 7105ee9..71baeb8 100644 --- a/config.json +++ b/config.json @@ -1055,7 +1055,7 @@ "type": "midi_peer" }, { - "address": "\tStudio B Mac Studio\tlocal.", + "address": "10.1.13.175:5004", "fix": "midi_reconnect", "host": "tracks", "peer": "Studio B Mac Studio", @@ -1762,7 +1762,7 @@ "type": "midi_peer" }, { - "address": "\tStudio B Mac Studio\tlocal.", + "address": "10.1.13.175:5004", "fix": "midi_reconnect", "host": "tracks", "peer": "Studio B Mac Studio", @@ -1779,6 +1779,15 @@ "tag": "PROP LIGHTS", "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", "tag": "COMPANION MIDI", @@ -1801,6 +1810,7 @@ "type": "midi_rx" }, { + "fix": "midi_reconnect", "nets": [ "10.1.0.0/16" ], diff --git a/coremidi.py b/coremidi.py index b9b96ca..6533996 100644 --- a/coremidi.py +++ b/coremidi.py @@ -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", [])) -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, 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: found = [(e, s) for e, s in _session_entities() 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] dropped = without_peer(sess, peer_name) 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)) if 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)) return "already" if result == "unchanged" else result diff --git a/schema.py b/schema.py index 05a765e..b16d91f 100644 --- a/schema.py +++ b/schema.py @@ -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"])) elif h.get("self"): 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 {} for f in a.get("fixes", []): if f not in fixes: diff --git a/settings/ui/app.js b/settings/ui/app.js index 83875c7..68d2430 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -1501,25 +1501,33 @@ 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 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)]); }; dialog(`

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.")}

- +
`, (b) => { b.querySelector("#dcancel").onclick = closeDialog; const fill = () => { const ss = sessionsOf(b.querySelector("#jhost").value); b.querySelector("#jsessions").innerHTML = ss.map(([n]) => `