From 3649fb269f436df9ac2742a2a0d617ac223afd10 Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 11 Oct 2026 07:55:39 -0400 Subject: [PATCH] MIDI drops show in about 1-2 s - NA Tracks and the ProPresenter Mac: one long-lived ssh each streams the MIDI sessions (checked twice a second, sent on change) instead of an ssh login every 5 s; a silent stream gives no verdict after 12 s. - NA Ingest's own session is read twice a second (was every 2 s). - MIDI links show a drop after 1 s (was 3 s); everything redraws twice a second (was once). Co-Authored-By: Claude Opus 5.5 --- status_panel.py | 64 +++++++++++++++++++++++++++++++++++--- tests/test_status_panel.py | 27 ++++++++++++++-- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/status_panel.py b/status_panel.py index c541ff7..57fe06b 100644 --- a/status_panel.py +++ b/status_panel.py @@ -146,6 +146,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.remote_midi_time = {} # host -> when it last reported self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at self.conn_hosts = {} # Companion connection label -> its "host" setting self.midi_time = 0.0 @@ -430,6 +431,8 @@ def check_midi_peer(chk, snap, ctx): # "host": a session on another Mac (e.g. NA Tracks -> Lighting PC, the FX lighting cues), # read over SSH. Unreachable or not read yet = no verdict (that Mac's own ping says if it is down). sessions = snap.remote_midi.get(chk["host"]) if chk.get("host") else (snap.midi if snap.midi_time else None) + if chk.get("host") and ctx["now"] - snap.remote_midi_time.get(chk["host"], ctx["now"]) > 12: + sessions = None # that Mac stopped reporting: no verdict if sessions is None: return [("peer", Result(OK))] sess = sessions.get(chk["session"]) @@ -837,7 +840,9 @@ class Engine: deb = self.debounce if debounce is None else debounce for key, (tile_id, res, chk) in raw.items(): tr = self.trackers.setdefault(key, Tracker()) - event = tr.update(res, now, 0 if chk["type"] == "network" else chk.get("debounce_seconds", deb), + # A MIDI peer leaving a session is a clean event: show it after 1 s, not the general 3 s. + event = tr.update(res, now, 0 if chk["type"] == "network" else + chk.get("debounce_seconds", 1 if chk["type"] == "midi_peer" else deb), self.flap_window) if event == "fault": self.events.append(("FAULT", "%s %s" % (self.tiles[tile_id]["label"], res.msg))) @@ -1580,6 +1585,57 @@ def parse_remote_midi(out): return None +def remote_midi_stream_script(interval=0.5): + """Like remote_midi_script, but keeps running: prints the sessions whenever they change (and every + 5 s anyway), checking twice a second. It ends by itself when the ssh connection goes.""" + src = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "coremidi.py"), encoding="utf-8").read() + return ("import json, time\nns = {'__name__': 'spcm'}\nexec(compile(%r, 'coremidi.py', 'exec'), ns)\n" + "last, sent = None, 0.0\n" + "while True:\n" + " s = json.dumps(ns['sessions']())\n" + " if s != last or time.time() - sent > 5:\n" + " print('SPMIDI' + s, flush=True)\n" + " last, sent = s, time.time()\n" + " time.sleep(%s)\n" % (src, interval)) + + +def stream_remote_midi(snap, cfg, host, stop): + """One long-lived ssh per Mac (NA Tracks, ProPresenter) reporting its MIDI sessions as they + change, so a dropped link shows in about a second instead of up to 5 s + an ssh login.""" + script = remote_midi_stream_script() + while not stop.is_set(): + proc = None + try: + proc = subprocess.Popen(ssh_cmd(cfg, host)[:1] + ["-o", "ServerAliveInterval=2", "-o", "ServerAliveCountMax=3"] + + ssh_cmd(cfg, host)[1:] + ["/usr/bin/python3", "-u", "-"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + proc.stdin.write(script) + proc.stdin.close() + for line in proc.stdout: + data = parse_remote_midi(line) + if data is not None: + snap.remote_midi[host], snap.remote_midi_time[host] = data, time.time() + remember_peers(snap, host, data) + if stop.is_set(): + break + except (OSError, ValueError) as e: + log.warning("MIDI stream from %s: %s", host, e) + finally: + if proc and proc.poll() is None: + proc.terminate() + snap.remote_midi[host] = None # no verdict until it reports again + stop.wait(3) + + +def start_remote_midi_streams(snap, cfg, stop): + threads = [] + for host in remote_midi_hosts(cfg): + t = threading.Thread(target=stream_remote_midi, args=(snap, cfg, host, stop), name="midi-" + host, daemon=True) + t.start() + threads.append(t) + return threads + + def poll_remote_midi(snap, cfg): script = remote_midi_script() for host in remote_midi_hosts(cfg): @@ -2004,16 +2060,16 @@ def start_pollers(cfg, snap, stop, data_dir=None): Periodic("slow-variables", 3.0, lambda: poll_variables(c3, snap, slow), stop), Periodic("conn-hosts", 60.0, lambda: poll_conn_hosts(snap, cfg), stop), Periodic("pings", 3.0, lambda: poll_pings(snap, ping_hosts(cfg, snap), iface), stop), - Periodic("midi", 2.0, lambda: poll_midi(snap, cfg), stop), + Periodic("midi", 0.5, lambda: poll_midi(snap, cfg), stop), Periodic("system", 10.0, lambda: poll_system(snap, cfg), stop), Periodic("bonjour", 15.0, lambda: poll_mdns(snap), stop), Periodic("http", 5.0, lambda: poll_http(snap, http_checks(cfg)), stop), Periodic("remote", 10.0, lambda: poll_remote(snap, cfg), stop), - Periodic("remote-midi", 5.0, lambda: poll_remote_midi(snap, cfg), stop), Periodic("dante-map", 30.0, lambda: poll_dante_map(snap, cfg, c4, dante_map_path(data_dir)), stop), ] for t in threads: t.start() + threads += start_remote_midi_streams(snap, cfg, stop) if any(c["type"] == "artnet" for t in cfg["tiles"] for c in t.get("checks", [])): start_artnet_listener(snap, stop) return threads @@ -2085,7 +2141,7 @@ def run(cfg, data_dir, dry_run=False, stop=None): comp.set_custom(names["command"], "") handle_commands(cmd, engine, comp, snap, now) last_eval = 0.0 # redraw now, so a press shows at once - if now - last_eval >= 1.0: + if now - last_eval >= 0.5: # redraw twice a second last_eval = now tiles = engine.step(snap, now) if not dry_run: diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index 66c1f8d..cdcd877 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -329,6 +329,27 @@ class TestChecks(unittest.TestCase): self.assertIsNone(sp.parse_remote_midi("ssh: connect to host timed out")) self.assertEqual(sp.remote_midi_hosts(CONFIG), ["prop", "tracks"]) + def test_a_midi_drop_shows_within_a_second_or_two(self): + engine = sp.Engine(CONFIG) + run_steps(engine, self.snap, self.now, 2) + self.snap.midi["NA Ingest"]["peers"] = [p for p in self.snap.midi["NA Ingest"]["peers"] if p["name"] != "Lighting PC"] + tiles, _ = run_steps(engine, self.snap, self.now + 3, 1.0, step=0.5) + self.assertEqual(tiles["midi"]["x"], "MIDI\nLIGHT PC MIDI LOST") # 1 s, not 3 + + def test_remote_midi_stream_script_runs_and_streams(self): + p = subprocess.Popen(["/usr/bin/python3", "-u", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) + p.stdin.write(sp.remote_midi_stream_script(0.1)) + p.stdin.close() + first = p.stdout.readline() + p.kill() + self.assertIsInstance(sp.parse_remote_midi(first), dict) + + def test_a_silent_remote_stream_gives_no_verdict(self): + self.snap.remote_midi["tracks"] = {"NA Tracks": {"port": 5004, "peers": []}} + self.snap.remote_midi_time["tracks"] = self.now - 30 + 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_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 @@ -1480,13 +1501,13 @@ class TestAgainstFakeCompanion(unittest.TestCase): store["custom"]["sp_cmd"] = ";ack atem" data_dir = tempfile.mkdtemp() originals = (sp.poll_mdns, sp.poll_system, sp.poll_midi, sp.poll_remote, sp.poll_dante_map, - sp.poll_remote_midi) + sp.start_remote_midi_streams) sp.poll_dante_map = lambda *a, **k: None # never log in to a switch from a test sp.poll_mdns = lambda snap, seconds=3: None sp.poll_system = lambda snap, cfg: None sp.poll_midi = lambda snap, cfg: None # CoreMIDI has its own test sp.poll_remote = lambda snap, cfg: None # never SSH anywhere from a test - sp.poll_remote_midi = lambda snap, cfg: None + sp.start_remote_midi_streams = lambda snap, cfg, stop: [] stop = threading.Event() loop = threading.Thread(target=sp.run, args=(cfg, data_dir), kwargs={"stop": stop}, daemon=True) try: @@ -1503,7 +1524,7 @@ class TestAgainstFakeCompanion(unittest.TestCase): stop.set() loop.join(timeout=15) (sp.poll_mdns, sp.poll_system, sp.poll_midi, sp.poll_remote, sp.poll_dante_map, - sp.poll_remote_midi) = originals + sp.start_remote_midi_streams) = originals store["conns"]["ATEM"]["category"] = "good" self.assertFalse(loop.is_alive()) # it shuts down cleanly self.assertTrue(store["custom"]["sp_hb"].isdigit())