diff --git a/config.json b/config.json index a662d0a..7105ee9 100644 --- a/config.json +++ b/config.json @@ -1799,6 +1799,12 @@ "name": "TRACKS IN", "source": "Network NA Ingest Listen", "type": "midi_rx" + }, + { + "nets": [ + "10.1.0.0/16" + ], + "type": "midi_wired" } ], "id": "midi", diff --git a/schema.py b/schema.py index f9e387e..05a765e 100644 --- a/schema.py +++ b/schema.py @@ -9,6 +9,7 @@ must be declared here, every default in the code must equal the one here). Paths: "companion.host", "tiles[].checks[].conn" (a list element), "fixes.*.run" (a map entry). """ import copy +import ipaddress import re import status_panel as sp @@ -298,6 +299,7 @@ field("tiles[].checks[].warn_gb", "int", 20, "Amber below this many GB free.", " field("tiles[].checks[].err_gb", "int", 5, "Red below this many GB free.", "Keys", advanced=True) field("tiles[].checks[].session", "str", None, "The network MIDI session.", "Keys") field("tiles[].checks[].peer", "str", None, "The peer that must be in it.", "Keys") +field("tiles[].checks[].nets", "list", None, "The wired network(s) every MIDI peer must come over, e.g. 10.1.0.0/16.", "Keys") field("tiles[].checks[].address", "str", None, "Where to reconnect the peer if it was never seen.", "Keys", advanced=True) field("tiles[].checks[].domain", "str", None, "The macOS preference domain.", "Keys") field("tiles[].checks[].key", "str", None, "The preference key.", "Keys") @@ -423,6 +425,8 @@ CHECKS = { "question": "Is a setting right?", "words": "Free space on a disk."}, "midi_peer": {"fields": ["session", "peer", "host", "address"], "subs": ["peer"], "identity": ["host", "session", "peer"], "module": None, "question": "Is a MIDI link up?", "words": "A peer is in a network MIDI session (here or on another Mac)."}, + "midi_wired": {"fields": ["nets"], "subs": ["wired", ""], "identity": [], "module": None, + "question": "Is a MIDI link up?", "words": "Every network MIDI peer comes over the wired network (not Wi-Fi or Dante)."}, "pref": {"fields": ["domain", "key", "want", "msg", "template", "params"], "subs": ["pref"], "identity": ["domain", "key"], "module": None, "question": "Is a setting right?", "words": "A macOS preference has the value it should."}, "spotify": {"fields": ["playlist", "fix"], "subs": ["spotify"], "identity": [], "module": None, @@ -611,6 +615,16 @@ def validate(cfg, live=None): errors.append("%s: a ping needs an address or a connection" % cw) if c["type"] == "midi_peer" and not (c.get("session") and c.get("peer")): errors.append("%s: a MIDI link needs a session and a peer" % cw) + if c["type"] == "midi_wired": + nets = c.get("nets") + if not (isinstance(nets, list) and nets): + errors.append("%s: a wired-MIDI check needs the wired network(s), e.g. 10.1.0.0/16" % cw) + else: + for n in nets: + try: + ipaddress.ip_network(str(n), strict=False) + except ValueError: + errors.append("%s: %r is not a network (like 10.1.0.0/16)" % (cw, n)) if c["type"] == "remote" and not (c.get("host") and c.get("cmd") and c.get("msg")): errors.append("%s: a remote check needs a computer, a command and the words to show" % cw) fx = c.get("fix") diff --git a/settings/keyguide.py b/settings/keyguide.py index 33e6981..aeefe61 100644 --- a/settings/keyguide.py +++ b/settings/keyguide.py @@ -69,6 +69,8 @@ def check_sentence(chk, cfg): if t == "midi_peer": where = ("on %s" % who(chk["host"])) if chk.get("host") else "on this Mac" return "the network MIDI session %s %s has %s in it" % (e(chk["session"]), where, e(chk["peer"])) + if t == "midi_wired": + return "every peer in the network MIDI sessions (here and on the other Macs) comes over the wired network %s, not Wi-Fi or Dante" % e(", ".join(chk.get("nets") or [])) if t == "pref": return "the setting %s in %s is %s" % (e(chk["key"]), e(chk["domain"]), e(chk["want"])) if t == "spotify": diff --git a/settings/ui/app.js b/settings/ui/app.js index 29709d4..83875c7 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -501,7 +501,7 @@ const checks = (t.checks || []).map((chk, j) => { const spec = state.schema.checks[chk.type] || { fields: [], question: "" }; const sentence = (kt.checks[j] || {}).sentence || chk.type; - const what = chk.type === "midi_peer" ? `${chk.peer || "?"} in ${esc(chk.session || "?")}` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40)); + const what = chk.type === "midi_peer" ? `${chk.peer || "?"} in ${esc(chk.session || "?")}` : chk.type === "midi_wired" ? `every MIDI peer wired ${esc((chk.nets || []).join(", "))}` : esc(chk.conn || chk.name || (chk.params || {}).process || chk.msg || chk.host || chk.label || chk.url || chk.session || short(sentence, 40)); const q = spec.question || chk.type; const header = q !== lastQ ? `
${esc(q)}
` : ""; lastQ = q; const summary = `${onOff(`${list}.${i}.checks.${j}`, chk, "this check")} ${what}${hint(sentence)}`; diff --git a/status_panel.py b/status_panel.py index 168a85d..2ad0366 100644 --- a/status_panel.py +++ b/status_panel.py @@ -18,6 +18,7 @@ import argparse import calendar import hashlib import http.client +import ipaddress import json import logging import math @@ -507,6 +508,39 @@ def check_midi_peer(chk, snap, ctx): return [("peer", Result(ERR, "MIDI LOST"))] +def peer_ip(address): + """The IPv4 address of a MIDI peer ("10.1.1.1:5040"), or None for a Bonjour name or IPv6.""" + host = str(address or "").strip().rsplit(":", 1)[0] + try: + ip = ipaddress.ip_address(host) + except ValueError: + return None + return ip if ip.version == 4 else None + + +def check_midi_wired(chk, snap, ctx): + """Every peer in the network MIDI sessions (here and on the other Macs) comes over the wired + network. The Macs are also on Wi-Fi and the Dante networks, and Bonjour lists each session once + per network: a peer joined through the wrong copy works until Wi-Fi hiccups mid-service + (2026-10-20: ProdDash joined Tracks over both Macs' Wi-Fi). Names the peer and the Mac.""" + nets = [ipaddress.ip_network(n, strict=False) for n in chk["nets"]] + groups = [("", snap.midi if snap.midi_time else None)] + groups += [(h, s) for h, s in snap.remote_midi.items() if ctx["now"] - snap.remote_midi_time.get(h, 0) <= 12] + out, seen = [], set() + for host, sessions in groups: + for sname, sess in (sessions or {}).items(): + for p in sess.get("peers", []): + 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: + continue + 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"]), + Result(ERR, "%s NOT WIRED" % name, named=True))) + return out or [("wired", Result(OK))] + + def check_mdns(chk, snap, ctx): if snap.mdns_time and chk["instance"] not in snap.mdns: return [("mdns", Result(ERR, chk.get("name", chk["instance"]) + " GONE", named=True))] @@ -597,7 +631,7 @@ READY_ACTIONS = ("recheck", "lineup_sync", "led_cross", "copy_tracks", "ltc_to_p CHECKS = { "conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure, "psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd, - "process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "pref": check_pref, "spotify": check_spotify, + "process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "midi_wired": check_midi_wired, "pref": check_pref, "spotify": check_spotify, "mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet, "ready": check_ready, "ready_manual": check_ready_manual, } diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index a48556a..ae64f12 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -458,6 +458,32 @@ class TestChecks(unittest.TestCase): self.assertEqual(sp.check_midi_peer({"type": "midi_peer", "host": "tracks", "session": "NA Tracks", "peer": "Lighting PC"}, self.snap, self.ctx)[0][1].level, sp.OK) + def test_a_midi_peer_on_wi_fi_is_named(self): + """2026-10-20: ProdDash joined Tracks' session over both Macs' Wi-Fi.""" + chk = {"type": "midi_wired", "nets": ["10.1.0.0/16"]} + self.assertEqual([r.level for _, r in sp.check_midi_wired(chk, self.snap, self.ctx)], [sp.OK]) + self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": [ + {"name": "ProdDash Lighting", "address": "10.6.3.222:5040", "state": "running"}, + {"name": "Studio B Mac Studio", "address": "\tStudio B Mac Studio\tlocal.", "state": "running"}, # a name: no verdict + {"name": "NA Ingest", "address": "[fe80::1]:5004", "state": "running"}]}} + 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"}) + 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.snap.remote_midi_time["tracks"] = self.now - 30 # a stale stream: no verdict + 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]) + cfg = json.loads(json.dumps(CONFIG)) + next(t for t in cfg["tiles"] if t["id"] == "midi")["checks"].append(dict(chk)) + import schema + 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"] + self.assertTrue(any("not a network" in e for e in schema.validate(cfg)[0])) + 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"] + tiles, _ = run_steps(sp.Engine(cfg), self.snap, self.now, 4) + self.assertEqual(tiles["midi"]["x"], "MIDI\nPRODDASH LIGH. NOT WIRED") + def test_midi_peer_joining_is_not_an_error(self): for p in self.snap.midi["NA Ingest"]["peers"]: p["state"] = "connected" # joined, clock sync not finished yet