Hold MIDI reconnects a dropped peer from the Mac side

macOS's MIDINetworkSession API only reaches the default session (blank and
disabled on NA Ingest and NA Tracks), so coremidi.connect_peer writes the
named session's "apple.midirtp.session" settings with the peer added, as
Audio MIDI Setup's Connect does. Existing peers are passed back unchanged.
The watchdog remembers each peer's last address (config "address" is the
fallback) and runs it locally for NA Ingest's session or over SSH on the
Tracks Mac for NA Tracks' (FX lights). Checked live: both Macs answer
"already" while the Lighting PC is connected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 15:54:53 -04:00
co-authored by Claude Opus 5.5
parent 74fbe73a4b
commit 80fd89d03b
5 changed files with 161 additions and 14 deletions
+45 -5
View File
@@ -134,6 +134,7 @@ class Snapshot:
self.vars = {} # (label, name) -> str or None
self.midi = {} # session name -> {"peers": [...]}
self.remote_midi = {} # host -> the same, read on that Mac over SSH (None: unreachable)
self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at
self.midi_time = 0.0
self.mdns = set() # Bonjour _apple-midi._udp instance names
self.mdns_time = 0.0
@@ -801,14 +802,25 @@ class Engine:
continue
# One run per fix, except a HyperDeck fix, which is per deck. The deck's
# address comes from its Companion connection (remembered if it goes away).
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck"
fix_kind = self.cfg["fixes"][fix_id].get("kind")
per_deck = fix_kind == "hyperdeck"
target = ""
if per_deck:
ip = chk.get("ip") or snap.var(chk["conn"], "ip") or self.deck_ips.get(chk["conn"])
if not ip:
continue
self.deck_ips[chk["conn"]] = ip
chk = dict(chk, ip=ip)
ident = ("fix", fix_id, chk["conn"] if per_deck else "")
target = chk["conn"]
if fix_kind == "midi_connect":
# Reconnect the peer where it was last seen (or the config's address).
key = (chk.get("host", ""), chk["session"], chk["peer"])
address = snap.peer_addr.get(key) or chk.get("address")
if not address:
continue
chk = dict(chk, address=address)
target = "|".join(key)
ident = ("fix", fix_id, target)
if ident not in seen:
seen.add(ident)
plan.append((ident, "fix", fix_id, chk))
@@ -1125,9 +1137,18 @@ def poll_http(snap, checks):
snap.http = result
def remember_peers(snap, host, sessions):
"""Keep each peer's last address, so a hold can reconnect it after it has gone."""
for name, sess in (sessions or {}).items():
for p in sess.get("peers", []):
if p.get("address"):
snap.peer_addr[(host, name, p["name"])] = p["address"]
def poll_midi(snap, cfg):
import coremidi
snap.midi, snap.midi_time = coremidi.sessions(), time.time()
remember_peers(snap, "", snap.midi)
for t in cfg["tiles"]:
for c in t.get("checks", []):
if c["type"] == "midi_rx":
@@ -1144,12 +1165,12 @@ def remote_midi_hosts(cfg):
if c["type"] == "midi_peer" and c.get("host")})
def remote_midi_script():
"""Python for /usr/bin/python3 on another Mac: this folder's coremidi.py, then print its sessions.
def remote_midi_script(call="ns['sessions']()"):
"""Python for /usr/bin/python3 on another Mac: this folder's coremidi.py, then print `call`'s result.
Nothing is installed there; the code goes over stdin each time."""
src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "coremidi.py"), encoding="utf-8").read()
return ("import json\nns = {'__name__': 'spcm'}\nexec(compile(%r, 'coremidi.py', 'exec'), ns)\n"
"print('SPMIDI' + json.dumps(ns['sessions']()))\n" % src)
"print('SPMIDI' + json.dumps(%s))\n" % (src, call))
def parse_remote_midi(out):
@@ -1169,6 +1190,7 @@ def poll_remote_midi(snap, cfg):
r = subprocess.run(ssh_cmd(cfg, host) + ["/usr/bin/python3", "-"], input=script,
capture_output=True, text=True, timeout=20)
snap.remote_midi[host] = parse_remote_midi(r.stdout) if r.returncode == 0 else None
remember_peers(snap, host, snap.remote_midi[host])
except (OSError, subprocess.SubprocessError):
snap.remote_midi[host] = None
@@ -1371,6 +1393,24 @@ def execute_fix(cfg, fix_id, chk):
ok = chk["conn"] in ids and comp.restart_connection(ids[chk["conn"]])
log.info("FIX %s -> Companion connection %s restarted %s", label, chk["conn"], "ok" if ok else "FAILED")
return r.returncode == 0
if kind == "midi_connect":
# Like Connect in Audio MIDI Setup: add the peer back to the session it dropped out of.
args = (chk["session"], chk["peer"], chk["address"])
if chk.get("host"):
r = subprocess.run(ssh_cmd(cfg, chk["host"]) + ["/usr/bin/python3", "-"],
input=remote_midi_script("ns['connect_peer'](%r, %r, %r)" % args),
capture_output=True, text=True, timeout=30)
result = parse_remote_midi(r.stdout) if r.returncode == 0 else None
if result is None:
log.info("FIX %s -> %s on %s failed: %s", label, chk["peer"], chk["host"],
(r.stdout + r.stderr).strip().replace("\n", " ")[-200:])
return False
else:
import coremidi
result = coremidi.connect_peer(*args)
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)
return result in ("asked", "already")
if kind == "netgear_poe":
# Netgear AV switch on the Dante network: power-cycle one PoE port (netgear.py).
import netgear