Woonsocket's startup list covered: M32, SoundGrid link, MIDI on Macs without python
From the WS Production Startup checklist (check.watersmedia.org):
- M32 (the "X32" there is a Midas M32 at 10.2.1.146): Woonsocket's Companion got the X32/M32
module (imported from NA's copy through Companion's own API) and an m32 connection; auto-setup
names a single M32 connection's key M32.
- SuperRack's link to the Waves SoundGrid server: a soundgrid check builder (SuperRack's control
connection on port 19000) inside every SUPERRACK key.
- MIDI ("Open Audio MIDI"): Macs without the Command Line Tools have their sessions read with
JavaScript for Automation (coremidi.JXA_SESSIONS) - by the watchdog every 2 s and by discovery. A
peer with no state (older macOS) counts as configured. A peer joined by name or IPv6 gets the
wired address of the Mac whose session it is (matched by session name), at that session's port.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+22
@@ -207,6 +207,28 @@ def disconnect_peer(session_name, peer_name):
|
|||||||
return "not connected" if result == "unchanged" else result
|
return "not connected" if result == "unchanged" else result
|
||||||
|
|
||||||
|
|
||||||
|
# The same read as sessions(), for a Mac without python (no Command Line Tools): macOS's own
|
||||||
|
# JavaScript for Automation calls CoreMIDI directly, nothing to install. Prints the sessions as JSON.
|
||||||
|
# Older macOS keeps no "state" for a peer; a peer without one is listed as configured, no verdict.
|
||||||
|
JXA_SESSIONS = r"""
|
||||||
|
ObjC.import('CoreMIDI'); ObjC.import('Foundation');
|
||||||
|
function str(o, k) { var r = Ref(); if ($.MIDIObjectGetStringProperty(o, k, r) !== 0) return null; return ObjC.unwrap(ObjC.castRefToObject(r[0])); }
|
||||||
|
function dict(o, k) { var r = Ref(); if ($.MIDIObjectGetDictionaryProperty(o, k, r) !== 0) return null; return ObjC.deepUnwrap(ObjC.castRefToObject(r[0])); }
|
||||||
|
var out = {}, n = $.MIDIGetNumberOfDevices();
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var dev = $.MIDIGetDevice(i), drv = str(dev, $('driver')) || '';
|
||||||
|
if (drv.indexOf('Network') < 0 && drv.toLowerCase().indexOf('rtp') < 0) continue;
|
||||||
|
for (var j = 0; j < $.MIDIDeviceGetNumberOfEntities(dev); j++) {
|
||||||
|
var ent = $.MIDIDeviceGetEntity(dev, j), s = dict(ent, $('apple.midirtp.session'));
|
||||||
|
if (!s) continue;
|
||||||
|
out[s['dns-sd-name'] || str(ent, $('name')) || '?'] = {port: s.port, peers: (s.peers || []).map(function (p) {
|
||||||
|
var q = {name: p.name, address: p.address}; if (p.state !== undefined) q.state = p.state; return q; })};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'SPMIDI' + JSON.stringify(out);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def sessions():
|
def sessions():
|
||||||
"""{session name: {"port": int, "peers": [{"name", "address", "state"}]}}"""
|
"""{session name: {"port": int, "peers": [{"name", "address", "state"}]}}"""
|
||||||
found = {}
|
found = {}
|
||||||
|
|||||||
@@ -212,6 +212,8 @@ def items(inv, nets):
|
|||||||
item["group"] = "VIDEO"
|
item["group"] = "VIDEO"
|
||||||
if a.get("group"):
|
if a.get("group"):
|
||||||
item["group"] = a["group"]
|
item["group"] = a["group"]
|
||||||
|
if a["app"] == "superrack": # and its link to the Waves SoundGrid server
|
||||||
|
item["checks"].append({"type": "remote", "template": "soundgrid", "params": {"host": alias}, "msg": "SOUNDGRID LOST"})
|
||||||
out.append(item)
|
out.append(item)
|
||||||
apps_at[(a["app"], c.get("ip"))] = item
|
apps_at[(a["app"], c.get("ip"))] = item
|
||||||
for ip in c.get("ips") or []:
|
for ip in c.get("ips") or []:
|
||||||
@@ -229,6 +231,8 @@ def items(inv, nets):
|
|||||||
ident = "conn:" + label
|
ident = "conn:" + label
|
||||||
many = counts.get(conn["module"], 0) > 1
|
many = counts.get(conn["module"], 0) > 1
|
||||||
text = spec["label"] if not many else key_text(label)
|
text = spec["label"] if not many else key_text(label)
|
||||||
|
if conn.get("module") == "behringer-x32" and "m32" in label.lower() and not many:
|
||||||
|
text = "M32" # a Midas M32 on the same module (Woonsocket)
|
||||||
if spec["kind"] == "hyperdeck":
|
if spec["kind"] == "hyperdeck":
|
||||||
hd_n += 1
|
hd_n += 1
|
||||||
text = "HD%d" % hd_n if many else "HD"
|
text = "HD%d" % hd_n if many else "HD"
|
||||||
@@ -265,16 +269,19 @@ def items(inv, nets):
|
|||||||
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": {}, "restart": [], "also": []})
|
out.append({"ident": ident, "group": spec["group"], "label": text, "checks": checks, "fixes": {}, "restart": [], "also": []})
|
||||||
# Network MIDI: every peer in the sessions here and on the other Macs, at its wired address.
|
# Network MIDI: every peer in the sessions here and on the other Macs, at its wired address.
|
||||||
midi_checks = []
|
midi_checks = []
|
||||||
|
# Whose session a peer is, by its name: a peer joined by name or by an IPv6 address gets that Mac's wired address.
|
||||||
|
owner = {sname: alias for alias, sessions in (inv.get("midi") or {}).items() if alias for sname in (sessions or {})}
|
||||||
for host, sessions in (inv.get("midi") or {}).items():
|
for host, sessions in (inv.get("midi") or {}).items():
|
||||||
for sname, sess in (sessions or {}).items():
|
for sname, sess in (sessions or {}).items():
|
||||||
for p in sess.get("peers") or []:
|
for p in sess.get("peers") or []:
|
||||||
addr = p.get("address") or ""
|
addr = p.get("address") or ""
|
||||||
if not in_nets(nets, addr):
|
if not in_nets(nets, addr):
|
||||||
alias = computer_named(inv, p.get("name"))
|
alias = owner.get(p.get("name")) or computer_named(inv, p.get("name"))
|
||||||
ip = alias and next((i for i in comps[alias].get("ips") or [] if in_nets(nets, i)), None)
|
ip = alias and next((i for i in comps[alias].get("ips") or [] if in_nets(nets, i)), None)
|
||||||
if not ip:
|
if not ip:
|
||||||
continue # no wired address known: the wired check will name it
|
continue # no wired address known: the wired check will name it
|
||||||
addr = "%s:5004" % ip
|
port = (((inv.get("midi") or {}).get(alias) or {}).get(p.get("name")) or {}).get("port") or 5004
|
||||||
|
addr = "%s:%d" % (ip, port)
|
||||||
chk = {"type": "midi_peer", "session": sname, "peer": p["name"], "address": addr, "fix": "midi_reconnect",
|
chk = {"type": "midi_peer", "session": sname, "peer": p["name"], "address": addr, "fix": "midi_reconnect",
|
||||||
"tag": tag_for(p["name"])}
|
"tag": tag_for(p["name"])}
|
||||||
if host:
|
if host:
|
||||||
|
|||||||
@@ -327,6 +327,12 @@ CHECK_TEMPLATES = {
|
|||||||
"params": [P_HOST, ("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
|
"params": [P_HOST, ("exe", "Program file", "As Task Manager shows it (Vista.exe).", "str", None)],
|
||||||
"render": lambda p, cfg: win_running(win(p["exe"], "program file")),
|
"render": lambda p, cfg: win_running(win(p["exe"], "program file")),
|
||||||
},
|
},
|
||||||
|
"soundgrid": {
|
||||||
|
"words": "SuperRack is connected to its SoundGrid server", "type": "remote",
|
||||||
|
"params": [P_HOST],
|
||||||
|
# SuperRack keeps a control link to the Waves server (port 19000, over the SoundGrid network).
|
||||||
|
"render": lambda p, cfg: "lsof -nP -iTCP -sTCP:ESTABLISHED -a -c SuperRack 2>/dev/null | grep -q ':19000 (ESTABLISHED)'",
|
||||||
|
},
|
||||||
"helper_flag": {
|
"helper_flag": {
|
||||||
"words": "A helper file does not say something is wrong", "type": "remote",
|
"words": "A helper file does not say something is wrong", "type": "remote",
|
||||||
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
|
"params": [P_HOST, ("file", "File", "In the helper's folder (solo-mode.json).", "str", "solo-mode.json"),
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ def look_at(cfg, alias, host):
|
|||||||
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
|
m = subprocess.run(sp.ssh_cmd(cfg, alias) + sp.remote_python("-"), input=sp.remote_midi_script(), capture_output=True, text=True, timeout=15)
|
||||||
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
|
out["midi"] = sp.parse_remote_midi(m.stdout) or {}
|
||||||
out["python"] = m.returncode != 97
|
out["python"] = m.returncode != 97
|
||||||
|
if not out["python"]:
|
||||||
|
out["midi"] = sp.jxa_midi(cfg, alias) or {}
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
out["midi"] = {}
|
out["midi"] = {}
|
||||||
else:
|
else:
|
||||||
|
|||||||
+28
-2
@@ -502,7 +502,7 @@ def check_midi_peer(chk, snap, ctx):
|
|||||||
# Measured 2026-10-07: a joining peer reads "connected" for ~6 s until
|
# Measured 2026-10-07: a joining peer reads "connected" for ~6 s until
|
||||||
# clock sync, then "running". A peer that dies is removed from the list:
|
# clock sync, then "running". A peer that dies is removed from the list:
|
||||||
# at once if it says goodbye, after ~60 s if it crashes or its cable is pulled.
|
# at once if it says goodbye, after ~60 s if it crashes or its cable is pulled.
|
||||||
if p["state"] in ("running", "connected"):
|
if p.get("state", "running") in ("running", "connected"): # no state: older macOS, configured
|
||||||
return [("peer", Result(OK))]
|
return [("peer", Result(OK))]
|
||||||
return [("peer", Result(ERR, "MIDI " + (p["state"] or "?").upper()[:8]))]
|
return [("peer", Result(ERR, "MIDI " + (p["state"] or "?").upper()[:8]))]
|
||||||
return [("peer", Result(ERR, "MIDI LOST"))]
|
return [("peer", Result(ERR, "MIDI LOST"))]
|
||||||
@@ -2450,7 +2450,9 @@ def stream_remote_midi(snap, cfg, host, stop):
|
|||||||
proc.stdin.write(script)
|
proc.stdin.write(script)
|
||||||
proc.stdin.close()
|
proc.stdin.close()
|
||||||
empty_since = None
|
empty_since = None
|
||||||
|
lines = 0
|
||||||
for line in proc.stdout:
|
for line in proc.stdout:
|
||||||
|
lines += 1
|
||||||
data = parse_remote_midi(line)
|
data = parse_remote_midi(line)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
snap.remote_midi[host], snap.remote_midi_time[host] = data, time.time()
|
snap.remote_midi[host], snap.remote_midi_time[host] = data, time.time()
|
||||||
@@ -2466,7 +2468,10 @@ def stream_remote_midi(snap, cfg, host, stop):
|
|||||||
empty_since = None
|
empty_since = None
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
break
|
break
|
||||||
except (OSError, ValueError) as e:
|
if not lines and proc.wait(timeout=10) == 97:
|
||||||
|
log.info("MIDI on %s: no python there (no Command Line Tools): reading it with osascript every 2 s", host)
|
||||||
|
return poll_jxa_midi(snap, cfg, host, stop)
|
||||||
|
except (OSError, ValueError, subprocess.TimeoutExpired) as e:
|
||||||
log.warning("MIDI stream from %s: %s", host, e)
|
log.warning("MIDI stream from %s: %s", host, e)
|
||||||
finally:
|
finally:
|
||||||
if proc and proc.poll() is None:
|
if proc and proc.poll() is None:
|
||||||
@@ -2475,6 +2480,27 @@ def stream_remote_midi(snap, cfg, host, stop):
|
|||||||
stop.wait(3)
|
stop.wait(3)
|
||||||
|
|
||||||
|
|
||||||
|
def jxa_midi(cfg, host, timeout=15):
|
||||||
|
"""A Mac's MIDI sessions read with JavaScript for Automation (no python there), or None."""
|
||||||
|
import coremidi
|
||||||
|
try:
|
||||||
|
r = subprocess.run(ssh_cmd(cfg, host) + ["/usr/bin/osascript -l JavaScript -"], input=coremidi.JXA_SESSIONS,
|
||||||
|
capture_output=True, text=True, timeout=timeout)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return None
|
||||||
|
return parse_remote_midi(r.stdout) if r.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def poll_jxa_midi(snap, cfg, host, stop):
|
||||||
|
"""Every two seconds over the shared ssh connection, for a Mac without python."""
|
||||||
|
while not stop.is_set():
|
||||||
|
data = jxa_midi(cfg, host)
|
||||||
|
if data is not None:
|
||||||
|
snap.remote_midi[host], snap.remote_midi_time[host] = data, time.time()
|
||||||
|
remember_peers(snap, host, data)
|
||||||
|
stop.wait(2)
|
||||||
|
|
||||||
|
|
||||||
def start_remote_midi_streams(snap, cfg, stop):
|
def start_remote_midi_streams(snap, cfg, stop):
|
||||||
threads = []
|
threads = []
|
||||||
for host in remote_midi_hosts(cfg):
|
for host in remote_midi_hosts(cfg):
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ class TestBuild(unittest.TestCase):
|
|||||||
self.assertEqual(sr["checks"][0]["cmd"], "pgrep -xq 'SuperRack SoundGrid'")
|
self.assertEqual(sr["checks"][0]["cmd"], "pgrep -xq 'SuperRack SoundGrid'")
|
||||||
self.assertIn("/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app", cfg["fixes"][sr["checks"][0]["fix"]]["run"])
|
self.assertIn("/Applications/Waves/SuperRack SoundGrid/SuperRack SoundGrid.app", cfg["fixes"][sr["checks"][0]["fix"]]["run"])
|
||||||
self.assertIn("X32 EDIT", [t["label"] for t in cfg["tiles"]])
|
self.assertIn("X32 EDIT", [t["label"] for t in cfg["tiles"]])
|
||||||
|
self.assertEqual([c.get("msg") for c in sr["checks"]], ["SUPERRACK NOT RUNNING", "SOUNDGRID LOST"])
|
||||||
|
self.assertIn(":19000 (ESTABLISHED)", sr["checks"][1]["cmd"])
|
||||||
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "wslighting", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True,
|
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "wslighting", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True,
|
||||||
"procs": ["Vista", "VistaPlaybackNode"], "apps": ["/Applications/Vista.app"], "paths": {"Vista": "/Applications/Vista.app"}}
|
"procs": ["Vista", "VistaPlaybackNode"], "apps": ["/Applications/Vista.app"], "paths": {"Vista": "/Applications/Vista.app"}}
|
||||||
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||||
@@ -118,6 +120,35 @@ class TestBuild(unittest.TestCase):
|
|||||||
self.assertEqual(vista[0]["checks"][0]["cmd"], "pgrep -xq Vista") # Vista on a Mac, too
|
self.assertEqual(vista[0]["checks"][0]["cmd"], "pgrep -xq Vista") # Vista on a Mac, too
|
||||||
self.assertIn("SUPERRACK", [k["label"] for k in cfg["restart_page"]["keys"]])
|
self.assertIn("SUPERRACK", [k["label"] for k in cfg["restart_page"]["keys"]])
|
||||||
|
|
||||||
|
def test_links_by_name_or_ipv6_get_the_wired_address_of_the_mac_whose_session_it_is(self):
|
||||||
|
"""Woonsocket: Tracks lists "Lighting" by name and the FOH Mac by an IPv6 address."""
|
||||||
|
inv = small()
|
||||||
|
inv["computers"]["tracks"] = {"name": "WS Tracks", "os": "mac", "user": "t", "ip": "10.4.1.219", "ips": ["169.254.45.114", "10.4.1.219"], "reachable": True, "procs": []}
|
||||||
|
inv["computers"]["lights"] = {"name": "WS Lights", "os": "mac", "user": "l", "ip": "10.4.2.2", "ips": ["10.4.2.2"], "reachable": True, "procs": []}
|
||||||
|
inv["computers"]["foh"] = {"name": "WS FOH", "os": "mac", "user": "f", "ip": "10.4.1.12", "ips": ["10.4.1.12"], "reachable": True, "procs": []}
|
||||||
|
inv["midi"] = {"": {}, "tracks": {"WS Tracks": {"port": 5004, "peers": [{"name": "Lighting", "address": "\tLighting\tlocal."},
|
||||||
|
{"name": "WS FOH Mac mini", "address": "[fe80::4c3:64d1:835f:f61f]:5004"}]}},
|
||||||
|
"lights": {"Lighting": {"port": 5006, "peers": [{"name": "WS Tracks", "address": "[fe80::188d]:5004", "state": "running"}]}},
|
||||||
|
"foh": {"WS FOH Mac mini": {"port": 5004, "peers": [{"name": "WS Tracks", "address": "\tWS Tracks\tlocal."}]}}}
|
||||||
|
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||||
|
midi = next(t for t in cfg["tiles"] if t["label"] == "MIDI")
|
||||||
|
got = sorted((c.get("host"), c["peer"], c["address"]) for c in midi["checks"] if c["type"] == "midi_peer")
|
||||||
|
self.assertEqual(got, [("foh", "WS Tracks", "10.4.1.219:5004"), ("lights", "WS Tracks", "10.4.1.219:5004"),
|
||||||
|
("tracks", "Lighting", "10.4.2.2:5006"), ("tracks", "WS FOH Mac mini", "10.4.1.12:5004")])
|
||||||
|
self.assertEqual(schema.validate(cfg)[0], [])
|
||||||
|
import status_panel as sp
|
||||||
|
snap = sp.Snapshot()
|
||||||
|
snap.remote_midi["tracks"], snap.remote_midi_time["tracks"] = inv["midi"]["tracks"], time.time()
|
||||||
|
chk = {"type": "midi_peer", "host": "tracks", "session": "WS Tracks", "peer": "Lighting"}
|
||||||
|
self.assertEqual(sp.check_midi_peer(chk, snap, {"now": time.time()})[0][1].level, sp.OK) # no state: older macOS, configured
|
||||||
|
|
||||||
|
def test_an_m32_is_called_m32(self):
|
||||||
|
inv = small()
|
||||||
|
inv["connections"] = [c for c in inv["connections"] if c["label"] != "x32"] + [
|
||||||
|
{"label": "m32", "module": "behringer-x32", "host": "10.4.1.146", "enabled": True}]
|
||||||
|
cfg, _ = ac.build(BASE, inv, {"id": "ws", "name": "Woonsocket"}, "1234")
|
||||||
|
self.assertIn("M32", [t["label"] for t in cfg["tiles"]])
|
||||||
|
|
||||||
def test_a_big_campus_gets_group_keys_and_the_receivers_fold_first(self):
|
def test_a_big_campus_gets_group_keys_and_the_receivers_fold_first(self):
|
||||||
cfg, _ = ac.build(BASE, big(), {"id": "ab", "name": "Apollo Beach"}, "1111")
|
cfg, _ = ac.build(BASE, big(), {"id": "ab", "name": "Apollo Beach"}, "1111")
|
||||||
self.assertEqual(schema.validate(cfg)[0], [])
|
self.assertEqual(schema.validate(cfg)[0], [])
|
||||||
|
|||||||
Reference in New Issue
Block a user