From 0a4352bb9288c7dac2bdc6f11c3f21239a5a9139 Mon Sep 17 00:00:00 2001 From: christian Date: Sun, 11 Oct 2026 09:26:26 -0400 Subject: [PATCH] Watch Pro Tools Solo Mode (AFL + Latch) on the Studio B Mac Studio A LaunchAgent helper on that Mac (Accessibility) reads Options > Solo Mode every second into a status file and ticks AFL/Latch on request. APPS alarms PT SOLO NOT AFL (hold fixes it; in the Sunday auto-fix list) and PT SOLO CHECK OFF when the helper is not running or not allowed. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- config.json | 9 ++- remote-access/install-solo-mode.sh | 27 +++++++++ remote-access/solo-mode-agent.swift | 87 +++++++++++++++++++++++++++++ tests/test_status_panel.py | 14 +++++ 5 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 remote-access/install-solo-mode.sh create mode 100644 remote-access/solo-mode-agent.swift diff --git a/README.md b/README.md index 6d059b4..e8f2530 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ The **page number key** on every page follows the ALL button. It's green when ev | LED | VX1000 and UHD Jr: Companion connection **and** each one answers ping (the NovaStar module keeps saying "Connected" after a processor is switched off). "UHDJR NO PING" = processor off or unreachable; "UHDJR OFFLINE" = Companion's connection failed; "UHDJR DISABLED" (amber) = connection turned off in Companion. | | DANTE | All 19 Dante devices ("19/19"), named when one drops: "WING GONE", "TRACKS GONE", "PROP MAC GONE". | | MIDI | All six network MIDI links on the FOH / Studio B checklist, each named when it drops: **LIGHT PC** (Ingest → Lighting), **SUPERRACK** (Ingest → Livebox), **FX LIGHTS** (Tracks → Lighting, the FX lighting cues), **TRK LIVEBOX** (Tracks → Livebox), **TRK STUDIO B** (Tracks → Studio B), **PROP LIGHTS** (ProPresenter → Lighting). NA Ingest's own session is read locally; NA Tracks' and NA ProP's over SSH every 5 s. Also: the Lighting PC and NA Tracks MIDI sessions still announced on the network. A clean disconnect shows within about 10 s; a crash or pulled cable takes about a minute (macOS's own timeout, measured). Hold 2 s reconnects whichever links dropped. | -| APPS | ProPresenter API (offline or error only: its harmless yellow warning, every 15–20 min, is ignored with `"ignore_warning": true`); Ableton OSC; tracks Mac pings; Bible TV app answering on :8080 (every 5 s); Bible TV OSC. Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections. | +| APPS | ProPresenter API (offline or error only: its harmless yellow warning, every 15–20 min, is ignored with `"ignore_warning": true`); Ableton OSC; tracks Mac pings; Bible TV app answering on :8080 (every 5 s); Bible TV OSC. Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections.; **Pro Tools on the Studio B Mac Studio** set to Solo Mode **AFL + Latch** ("PT SOLO NOT AFL"; hold, or Sunday auto-fix, ticks them back). A helper on that Mac (`remote-access/solo-mode-agent.swift`, installed with `install-solo-mode.sh`) reads the menu; it needs Accessibility allowed once, then a restart of the helper. "PT SOLO CHECK OFF" = that helper is not running or not allowed. | | VISTA | Vista on the Lighting PC: its Art-Net poll (sent every 0.5 s) must be heard within 5 s, otherwise **SILENT** (Vista closed or crashed, or the Lighting PC is off the network). Hold 2 s starts Vista if it is not running; Vista then opens **Prop Takeover** from its show folder (`E:\media\Documents\Vista 3\Shows`) by itself, through its own autoload preference (`` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. | | RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. | | NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. | diff --git a/config.json b/config.json index 17bef2f..9932158 100644 --- a/config.json +++ b/config.json @@ -34,6 +34,7 @@ "translation": { "ssh": "studio@10.1.7.39" }, "bibletv": { "ssh": "media@10.6.7.13" }, "lighting": { "ssh": "media@10.1.10.206" }, + "studiob": { "ssh": "studiob@10.1.13.175" }, "livestream": { "ssh": "media@10.1.9.126", "mac": "6c:24:08:d7:fb:57", "wake_broadcast": "10.1.255.255" } }, @@ -64,6 +65,8 @@ "run": "(sc query rtpMIDIService | find \"RUNNING\" >nul || net start rtpMIDIService) && (tasklist /NH /FI \"IMAGENAME eq rtpMIDI.exe\" | find /I \"rtpMIDI.exe\" >nul || schtasks /Run /TN \"\\StatusPanel\\Start rtpMIDI\")" }, "midi_reconnect": { "kind": "midi_connect", "short": "RECONNECT", "label": "reconnect the peer to the network MIDI session it dropped out of (like Connect in Audio MIDI Setup); the address is where it was last seen, else the check's \"address\"" }, "caption_screen": { "kind": "local", "short": "CAPTION SCREEN", "label": "restart the lobby caption screen (display app only)", "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist" }, + "solo_afl": { "host": "studiob", "short": "PT SOLO", "label": "set Pro Tools on the Studio B Mac Studio back to Solo Mode AFL + Latch (clicks Options > Solo Mode)", + "run": "touch \"$HOME/Library/Application Support/status-panel/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"" }, "proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)", "run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" }, "prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)", @@ -83,7 +86,7 @@ "after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60, "restart_connections": true, "fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs", - "ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation"] + "ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl"] }, "restart_page": { @@ -162,7 +165,9 @@ { "type": "ping", "host": "10.1.3.227", "name": "TRACKS MAC" }, { "type": "http", "url": "http://10.1.14.225:1025/", "msg": "PROP TV DOWN", "interval_seconds": 5, "fix": "propresenter" }, { "type": "http", "url": "http://10.6.7.13:8080/", "msg": "BIBLE APP DOWN", "interval_seconds": 5, "fix": "bibleapp" }, - { "type": "conn", "conn": "BibleTV", "tag": "BIBLE OSC" } + { "type": "conn", "conn": "BibleTV", "tag": "BIBLE OSC" }, + { "type": "remote", "host": "studiob", "cmd": "! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"", "msg": "PT SOLO NOT AFL", "fix": "solo_afl" }, + { "type": "remote", "host": "studiob", "cmd": "f=\"$HOME/Library/Application Support/status-panel/solo-mode.json\"; [ $(( $(date +%s) - $(stat -f %m \"$f\") )) -lt 20 ] && grep -q '\"trusted\":true' \"$f\"", "msg": "PT SOLO CHECK OFF" } ] }, { "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" }, diff --git a/remote-access/install-solo-mode.sh b/remote-access/install-solo-mode.sh new file mode 100644 index 0000000..1757986 --- /dev/null +++ b/remote-access/install-solo-mode.sh @@ -0,0 +1,27 @@ +#!/bin/zsh +# Install (or update) the Pro Tools solo-mode helper on the Studio B Mac Studio, as its logged-in user. +# Run from NA Ingest: +# scp remote-access/solo-mode-agent.swift remote-access/install-solo-mode.sh studiob@10.1.13.175:/tmp/ +# ssh studiob@10.1.13.175 'zsh /tmp/install-solo-mode.sh' +# Then allow "solo-mode-agent" once in System Settings > Privacy & Security > Accessibility on that Mac. +set -e +DIR="$HOME/Library/Application Support/status-panel" +LABEL=org.waterschurch.status-panel.solo-mode +PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" +mkdir -p "$DIR" "$HOME/Library/LaunchAgents" +/usr/bin/swiftc -O "${0:A:h}/solo-mode-agent.swift" -o "$DIR/solo-mode-agent" +cat > "$PLIST" < + + + Label$LABEL + ProgramArguments$DIR/solo-mode-agent + RunAtLoad + KeepAlive + ThrottleInterval10 + +EOF +launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true +for i in {1..50}; do launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done +launchctl bootstrap "gui/$(id -u)" "$PLIST" +echo "installed $LABEL" diff --git a/remote-access/solo-mode-agent.swift b/remote-access/solo-mode-agent.swift new file mode 100644 index 0000000..56803d6 --- /dev/null +++ b/remote-access/solo-mode-agent.swift @@ -0,0 +1,87 @@ +// Status panel helper for the Studio B Mac Studio: watches Pro Tools' Options > Solo Mode menu. +// +// Every second it writes ~/Library/Application Support/status-panel/solo-mode.json: +// {"time": ..., "running": true, "trusted": true, "afl": true, "latch": true, "bad": false, "error": ""} +// "bad" is true only when Pro Tools is running and AFL or Latch is not ticked. +// If ~/Library/Application Support/status-panel/solo-mode.fix appears, it ticks what is missing +// (presses the menu items, as a person would) and removes the file. +// +// It reads and presses menus through Accessibility, so macOS asks once: +// System Settings > Privacy & Security > Accessibility > allow "solo-mode-agent". +// Installed as the LaunchAgent org.waterschurch.status-panel.solo-mode by remote-access/install-solo-mode.sh. +import Cocoa +import ApplicationServices + +let dir = NSString(string: "~/Library/Application Support/status-panel").expandingTildeInPath +let stateFile = dir + "/solo-mode.json" +let fixFile = dir + "/solo-mode.fix" + +func attr(_ e: AXUIElement, _ a: String) -> AnyObject? { + var v: AnyObject? + return AXUIElementCopyAttributeValue(e, a as CFString, &v) == .success ? v : nil +} +func children(_ e: AXUIElement) -> [AXUIElement] { (attr(e, kAXChildrenAttribute) as? [AXUIElement]) ?? [] } +func title(_ e: AXUIElement) -> String { (attr(e, kAXTitleAttribute) as? String) ?? "" } +func items(of e: AXUIElement) -> [AXUIElement] { children(e).flatMap { children($0) } } // item -> menu -> items +func ticked(_ e: AXUIElement) -> Bool { !((attr(e, kAXMenuItemMarkCharAttribute) as? String) ?? "").isEmpty } + +func soloItems() -> (afl: AXUIElement?, latch: AXUIElement?, error: String) { + guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.avid.ProTools").first else { + return (nil, nil, "Pro Tools not running") + } + let ax = AXUIElementCreateApplication(app.processIdentifier) + guard let bar = attr(ax, kAXMenuBarAttribute) else { return (nil, nil, "no menu bar") } + guard let options = children(bar as! AXUIElement).first(where: { title($0) == "Options" }) else { + return (nil, nil, "no Options menu") + } + guard let solo = items(of: options).first(where: { title($0) == "Solo Mode" }) else { + return (nil, nil, "no Solo Mode menu") + } + let modes = items(of: solo) + return (modes.first { title($0).hasPrefix("AFL") }, modes.first { title($0) == "Latch" }, "") +} + +func write(_ state: [String: Any]) { + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + if let data = try? JSONSerialization.data(withJSONObject: state, options: [.sortedKeys]) { + let tmp = stateFile + ".tmp" + FileManager.default.createFile(atPath: tmp, contents: data) + _ = try? FileManager.default.replaceItemAt(URL(fileURLWithPath: stateFile), withItemAt: URL(fileURLWithPath: tmp)) + } +} + +// Ask once for Accessibility (shows macOS's own prompt on this Mac's screen). +let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String +_ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary) + +while true { + autoreleasepool { + var state: [String: Any] = ["time": Date().timeIntervalSince1970, "trusted": AXIsProcessTrusted()] + let running = !NSRunningApplication.runningApplications(withBundleIdentifier: "com.avid.ProTools").isEmpty + state["running"] = running + var bad = false + if running && AXIsProcessTrusted() { + var found = soloItems() + if FileManager.default.fileExists(atPath: fixFile) { + try? FileManager.default.removeItem(atPath: fixFile) + if let a = found.afl, !ticked(a) { AXUIElementPerformAction(a, kAXPressAction as CFString) } + Thread.sleep(forTimeInterval: 0.3) + found = soloItems() + if let l = found.latch, !ticked(l) { AXUIElementPerformAction(l, kAXPressAction as CFString) } + Thread.sleep(forTimeInterval: 0.3) + found = soloItems() + } + if let a = found.afl, let l = found.latch { + state["afl"] = ticked(a) + state["latch"] = ticked(l) + bad = !(ticked(a) && ticked(l)) + } + state["error"] = found.error + } else { + state["error"] = running ? "Accessibility not allowed yet" : "Pro Tools not running" + } + state["bad"] = bad + write(state) + } + Thread.sleep(forTimeInterval: 1.0) +} diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index cdcd877..e455083 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -264,6 +264,17 @@ class TestChecks(unittest.TestCase): self.assertEqual(tiles["apps"]["x"], "APPS\nPROP TV DOWN") self.assertEqual(dict(sp.http_checks(CONFIG))["http://10.1.14.225:1025/"], 5) + def test_pro_tools_solo_mode_on_studio_b(self): + chk = [c for c in CONFIG["tiles"][[t["id"] for t in CONFIG["tiles"]].index("apps")]["checks"] + if c.get("msg") == "PT SOLO NOT AFL"][0] + self.snap.remote["studiob\0" + chk["cmd"]] = False + engine = sp.Engine(CONFIG) + tiles, _ = run_steps(engine, self.snap, self.now, 4) + self.assertEqual(tiles["apps"]["x"], "APPS\nPT SOLO NOT AFL") + self.assertEqual(engine.fix_plan("apps", self.snap)[0][1:3], ("fix", "solo_afl")) + self.assertIn("solo_afl", CONFIG["auto_fix"]["fixes"]) + self.assertIn('"bad":true', chk["cmd"]) + def test_propresenter_yellow_warning_is_ignored(self): engine = sp.Engine(CONFIG) self.snap.conns["Propresenter-API"].update(category="warning", message="Socket closed") @@ -1175,8 +1186,10 @@ class TestFixes(unittest.TestCase): self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nFIXING\nDVS", "fixing")) gate.set() time.sleep(0.2) + self.snap.conns_time = time.time() # fresh data at the real clock tiles = self.engine.step(self.snap, time.time()) self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nCHECKING\nDVS", "fixing")) + self.snap.conns_time = time.time() + 25 # fresh data at the real clock tiles = self.engine.step(self.snap, time.time() + 25) # still gone: back to the alarm self.assertEqual(tiles["dante"]["x"], "DANTE\nPROP MAC GONE") # held again within a minute @@ -1186,6 +1199,7 @@ class TestFixes(unittest.TestCase): outcome["ok"] = False self.engine.run_fixes("dante", self.snap, None, t + 100) time.sleep(0.2) + self.snap.conns_time = time.time() # fresh data at the real clock tiles = self.engine.step(self.snap, time.time()) self.assertEqual((tiles["dante"]["x"], level(tiles["dante"])), ("DANTE\nFIX FAILED\nDVS", "failed"))