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
+66
View File
@@ -7,6 +7,9 @@ Two things the status panel needs that Companion cannot see:
"apple.midirtp.session" entity property.
* Listener - timestamps the last message that arrived on a named MIDI
source, so a tile can say "last MIDI 3s ago".
* connect_peer() - re-add a peer to a named session, the way Audio MIDI Setup's
"Connect" does (macOS's MIDINetworkSession API only reaches the
default session, not named ones like "NA Tracks").
"""
import ctypes
import ctypes.util
@@ -33,6 +36,7 @@ _cm.MIDIGetSource.argtypes = [ctypes.c_ulong]
_cm.MIDIObjectGetStringProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
_cm.MIDIObjectGetDictionaryProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
_cm.MIDIObjectGetIntegerProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32)]
_cm.MIDIObjectSetDictionaryProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.c_void_p]
_cf.CFStringCreateWithCString.restype = ctypes.c_void_p
_cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, _U32]
@@ -44,6 +48,11 @@ _cf.CFDataGetLength.restype = ctypes.c_long
_cf.CFDataGetLength.argtypes = [ctypes.c_void_p]
_cf.CFDataGetBytePtr.restype = ctypes.c_void_p
_cf.CFDataGetBytePtr.argtypes = [ctypes.c_void_p]
_cf.CFDataCreate.restype = ctypes.c_void_p
_cf.CFDataCreate.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long]
_cf.CFPropertyListCreateWithData.restype = ctypes.c_void_p
_cf.CFPropertyListCreateWithData.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_ulong,
ctypes.c_void_p, ctypes.c_void_p]
# One lock around every CoreMIDI call: concurrent first calls from two threads
# (session reads vs. MIDIClientCreate) intermittently fail with -2.
@@ -81,6 +90,63 @@ def _dict_prop(obj, key):
return plistlib.loads(raw)
def _set_dict_prop(obj, key, value):
raw = plistlib.dumps(value)
data = _cf.CFDataCreate(None, raw, len(raw))
plist = _cf.CFPropertyListCreateWithData(None, data, 0, None, None)
_cf.CFRelease(data)
if not plist:
raise OSError("could not build the property list")
k = _cfstr(key)
err = _cm.MIDIObjectSetDictionaryProperty(obj, k, plist)
_cf.CFRelease(k)
_cf.CFRelease(plist)
return err
def _session_entities():
"""[(entity, its "apple.midirtp.session" dict)] for every network MIDI session on this Mac."""
out = []
for i in range(_cm.MIDIGetNumberOfDevices()):
dev = _cm.MIDIGetDevice(i)
if _string_prop(dev, "driver") != "com.apple.AppleMIDINetworkDriver":
continue
for j in range(_cm.MIDIDeviceGetNumberOfEntities(dev)):
ent = _cm.MIDIDeviceGetEntity(dev, j)
sess = _dict_prop(ent, "apple.midirtp.session")
if sess is not None:
out.append((ent, sess))
return out
def with_peer(sess, peer_name, address):
"""The session settings with this peer added, or None if it is already in the session.
Peers already there are passed back exactly as read, so they are left alone."""
peers = list(sess.get("peers", []))
if any(p.get("name") == peer_name or p.get("address") == address for p in peers):
return None
new = dict(sess)
new["peers"] = peers + [{"address": address, "name": peer_name}]
return new
def connect_peer(session_name, peer_name, address):
"""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) or "asked"."""
with _lock:
for ent, sess in _session_entities():
if (sess.get("dns-sd-name") or _string_prop(ent, "name")) != session_name:
continue
new = with_peer(sess, peer_name, address)
if new is None:
return "already"
err = _set_dict_prop(ent, "apple.midirtp.session", new)
if err:
raise OSError("MIDIObjectSetDictionaryProperty failed: %d" % err)
return "asked"
raise LookupError("no network MIDI session named %r on this Mac" % session_name)
def sessions():
"""{session name: {"port": int, "peers": [{"name", "address", "state"}]}}"""
found = {}