ProPresenter guard at every location: install.sh starts it where ProPresenter is, it finds ProPresenter's port, leaves a song already up alone; restart off by default

WS and SC updated to the current build (code only, their settings kept). NA's Resi cues polling slowed to 10 s / 20 s (Companion setting).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:23:14 -04:00
co-authored by Claude Opus 5.5
parent 47a2c12a12
commit 9a818f63ca
5 changed files with 169 additions and 10 deletions
+12
View File
@@ -366,6 +366,18 @@ SC's Lighting PC (SC Lighting PC, 10.5.1.168) keeps several copies of `Tiverton
and starts Vista; a running Vista is left alone. `-WhatIf` shows the pick without changing anything. Running the PC join
line again resets the task to plain `Vista.exe`: point it back at the script afterwards.
## ProPresenter guard (every location with ProPresenter on the Companion Mac)
`prop_guard.py` (LaunchAgent `org.waterschurch.prop-guard`, log `~/Library/Logs/prop-guard.log`) is started by
`install.sh` on any Mac with `/Applications/ProPresenter*.app`: today SC and WS, and AB with its next package. When a
song (a presentation with a timeline) comes up and its first slide's macro left the **Stage Display** screen (else the
first stage screen) on a layout without "Worship" in its name, it is set to **Worship** within 12 s; later picks are left
alone, and so is a song that was already up when the guard started. ProPresenter's network port differs per Mac
(WS 49285, SC 50098) and moves now and then: when the set one does not answer, the guard finds it. Per-location
settings go in `prop-guard.json.cfg` beside it (never overwritten). Restarting a stopped timeline is off unless that
file says `{"resume": true}`. At SC, `LTC Listen.app` hears the tracks' timecode on SoundGrid, and the PROP key shows
**TIMECODE LOST** (`http://127.0.0.1:8095/timecode`) when it stops in the middle of a song.
## A location on its own (Apollo Beach)
Apollo Beach runs its panel itself: not on NA's deck, not in NA's backups, no NA key on its Macs.
+24 -2
View File
@@ -19,7 +19,7 @@ SETTINGS_LABEL=org.waterschurch.status-settings
DEST="$HOME/Library/Application Support/status-panel"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
CODE=(status_panel.py readiness.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json)
CODE=(status_panel.py readiness.py coremidi.py netgear.py schema.py make_page.py push_page.py nav_keys.py atem_watch.js atem_state.js nav_templates.json prop_guard.py)
KEEP_CONFIG=0
SITE=""
SITE_NEXT=0
@@ -125,7 +125,8 @@ PY
if [[ "$1" == "--remove" ]]; then
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
launchctl bootout "$DOMAIN/$SETTINGS_LABEL" 2>/dev/null || true
rm -f "$PLIST" "$SETTINGS_PLIST"
launchctl bootout "$DOMAIN/org.waterschurch.prop-guard" 2>/dev/null || true
rm -f "$PLIST" "$SETTINGS_PLIST" "$HOME/Library/LaunchAgents/org.waterschurch.prop-guard.plist"
echo "removed $LABEL and $SETTINGS_LABEL (files left in $DEST)"
exit 0
fi
@@ -239,6 +240,27 @@ EOF2
launchctl bootout "$DOMAIN/$BACKUP_LABEL" 2>/dev/null || true
launchctl bootstrap "$DOMAIN" "$BACKUP_PLIST" 2>/dev/null || true
fi
# The ProPresenter guard (stage display back on Worship when a song's macro missed it), on a Mac with ProPresenter.
PROP_APPS=(/Applications/ProPresenter*.app(N))
if [[ -f "$DEST/prop_guard.py" && ${#PROP_APPS} -gt 0 ]]; then
GUARD_LABEL=org.waterschurch.prop-guard
GUARD_PLIST="$HOME/Library/LaunchAgents/$GUARD_LABEL.plist"
[[ -f "$DEST/prop-guard.json.cfg" ]] || echo '{}' > "$DEST/prop-guard.json.cfg" # site-owned: never overwritten
cat > "$GUARD_PLIST" <<EOF2
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$GUARD_LABEL</string>
<key>ProgramArguments</key><array><string>/usr/bin/python3</string><string>$DEST/prop_guard.py</string><string>$DEST/prop-guard.json.cfg</string></array>
<key>RunAtLoad</key><true/><key>KeepAlive</key><true/>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/prop-guard.err.log</string>
</dict>
</plist>
EOF2
restart_agent "$GUARD_LABEL" "$GUARD_PLIST"
echo "ProPresenter guard running (log: ~/Library/Logs/prop-guard.log)"
fi
# The panel's own login key for the other computers (made once, never replaced).
if [[ ! -f "$HOME/.ssh/id_status_panel" ]]; then
mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh"
+49 -6
View File
@@ -16,10 +16,15 @@ macro does not always bring the stage display to the worship layout. A song is a
Status (for the status panel): ~/Library/Application Support/status-panel/prop-guard.json; log:
~/Library/Logs/prop-guard.log.
Every location (2026-10-26): install.sh starts it on any Mac with ProPresenter installed. ProPresenter's network
port differs per Mac (WS 49285, SC 50098): when "api" does not answer, the port ProPresenter listens on is found.
The timeline restart stays off unless a location's prop-guard.json.cfg turns it on ({"resume": true}).
"""
import json
import logging
import os
import subprocess
import sys
import time
import urllib.error
@@ -32,7 +37,7 @@ CFG = {
"accept": ["worship"], # a layout whose name contains one of these is fine for a song
"stage_after": 2.5, # seconds after the song starts before the stage is judged
"stage_window": 12.0, # ... and until when it is put right
"resume": True, # start a stopped timeline again (False: only log)
"resume": False, # start a stopped timeline again (False: only log; SC's timeline never runs)
"stopped_for": 1.0, # seconds stopped before it counts (ProPresenter blinks while it seeks)
"poll": 0.4,
"ltc_file": "~/Library/Application Support/status-panel/ltc.json", # LTC Listen.app: the timecode really arriving
@@ -52,6 +57,33 @@ def api(path, timeout=2.0):
return None
def prop_ports():
"""The TCP ports ProPresenter listens on here (its network API is one of them)."""
try:
out = subprocess.run(["/usr/sbin/lsof", "-nP", "-a", "-c", "ProPresen", "-iTCP", "-sTCP:LISTEN"],
capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.SubprocessError):
return []
return sorted({int(l.rsplit(":", 1)[1].split()[0]) for l in out.splitlines()[1:] if ":" in l})
def find_api(ports=prop_ports):
"""CFG["api"] if it answers, else the first local port where ProPresenter's API answers (it is saved in CFG)."""
if (api("/version") or {}).get("api_version"):
return CFG["api"]
for port in ports():
url = "http://127.0.0.1:%d" % port
try:
with urllib.request.urlopen(url + "/version", timeout=2) as r:
if json.loads(r.read()).get("api_version"):
CFG["api"] = url
log.info("ProPresenter's API is on %s", url)
return url
except (urllib.error.URLError, OSError, ValueError):
continue
return None
class Guard:
def __init__(self, now=time.time):
self.now = now
@@ -68,12 +100,16 @@ class Guard:
self.ltc_seen = False # timecode heard during this song
self.ltc_lost_at = None # when it stopped (None: running, or not heard yet this song)
self.lost = False # TIMECODE LOST, for the status panel
self.seen = False # ProPresenter answered once: a song up before that was not just started (WS 10/26)
def stage(self):
for m in api("/v1/stage/layout_map") or []:
"""The band's stage screen and its layout: the one named stage_screen, else the first stage screen."""
maps = api("/v1/stage/layout_map") or []
for m in maps:
if (m.get("screen") or {}).get("name") == CFG["stage_screen"]:
return m["screen"], m.get("layout") or {}
return None, {}
first = min(maps, key=lambda m: (m.get("screen") or {}).get("index", 99), default=None)
return (first["screen"], first.get("layout") or {}) if first and first.get("screen") else (None, {})
def set_layout(self, screen):
layouts = api("/v1/stage/layouts") or []
@@ -117,7 +153,9 @@ class Guard:
def tick(self):
now = self.now()
pres = (api("/v1/presentation/active") or {}).get("presentation")
active = api("/v1/presentation/active")
pres = (active or {}).get("presentation")
first, self.seen = not self.seen and active is not None, self.seen or active is not None
song_name = pres["id"]["name"] if pres and pres.get("has_timeline") else None
if song_name != getattr(self, "ltc_song", None):
self.ltc_seen, self.ltc_lost_at, self.lost, self.ltc_song = False, None, False, song_name
@@ -131,7 +169,7 @@ class Guard:
uuid, name = pres["id"]["uuid"], pres["id"]["name"]
if uuid != self.song:
self.song, self.name, self.started, self.ran, self.last_t, self.stopped_at = uuid, name, now, False, None, None
self.stage_fixed, self.resumed_at, self.hands_off = False, None, False
self.stage_fixed, self.resumed_at, self.hands_off = first, None, False # already up at the start: left alone
self.slides = sum(len(g.get("slides") or []) for g in pres.get("groups") or [])
log.info("SONG %s (%d slides)", name, self.slides)
tl = api("/v1/presentation/active/timeline") or {}
@@ -207,12 +245,17 @@ def main():
logging.basicConfig(filename=os.path.expanduser("~/Library/Logs/prop-guard.log"), level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log.info("prop guard started: %s", json.dumps(CFG))
g, saved = Guard(), 0.0
g, saved, looked = Guard(), 0.0, 0.0
try:
serve_status(g)
except OSError as e:
log.warning("status port %s: %s", CFG["status_port"], e)
while True:
if time.time() - looked > 30 and api("/version") is None: # ProPresenter closed, or on another port
looked = time.time()
if not find_api() and not getattr(g, "said_no_api", False):
log.warning("no ProPresenter API on this Mac (not running, or Network is off in its settings)")
g.said_no_api = True
try:
g.tick()
except Exception: # noqa: BLE001 - keep guarding
+1 -1
View File
@@ -37,7 +37,7 @@ LOOK_EVERY = 600
SEEN, LAST = "discover-seen.json", "discover-last.json"
# What the program kit carries (no settings, keys or history of the location that serves it).
KIT_FILES = ("status_panel.py", "readiness.py", "coremidi.py", "netgear.py", "schema.py", "make_page.py", "push_page.py",
"nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json")
"nav_keys.py", "atem_watch.js", "atem_state.js", "nav_templates.json", "prop_guard.py")
KIT_DIRS = ("settings", "remote-access", "companion-objects", "backup")
+83 -1
View File
@@ -37,18 +37,24 @@ class FakeProP:
class TestGuard(unittest.TestCase):
def setUp(self):
self.fake, self.clock = FakeProP(), [1000.0]
self.orig = pg.api
self.orig, self.cfg = pg.api, dict(pg.CFG)
pg.api = self.fake
pg.CFG["resume"] = True # off by default; these tests turn it on
self.g = pg.Guard(now=lambda: self.clock[0])
def tearDown(self):
pg.api = self.orig
pg.CFG.clear()
pg.CFG.update(self.cfg)
def at(self, t):
self.clock[0] = 1000.0 + t
self.g.tick()
def test_stage_put_on_worship_when_the_macro_missed(self):
song, self.fake.song = self.fake.song, None # the guard watching before the song starts
self.at(-1)
self.fake.song = song
self.at(0)
self.assertEqual(self.fake.layout, "MC") # judged only after a few seconds
self.at(3)
@@ -124,3 +130,79 @@ class TestTimecode(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestEveryLocation(unittest.TestCase):
def setUp(self):
self.orig, self.cfg = pg.api, dict(pg.CFG)
def tearDown(self):
pg.api = self.orig
pg.CFG.clear()
pg.CFG.update(self.cfg)
def test_restart_is_off_unless_a_location_turns_it_on(self):
self.assertFalse(self.cfg["resume"])
def test_the_api_port_is_found_when_the_set_one_does_not_answer(self):
import http.server
import json
import threading
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps({"api_version": "v1"}).encode())
def log_message(self, *a):
pass
srv = http.server.HTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
pg.CFG["api"] = "http://127.0.0.1:1"
port = srv.server_address[1]
self.assertEqual(pg.find_api(ports=lambda: [1, port]), "http://127.0.0.1:%d" % port)
self.assertEqual(pg.CFG["api"], "http://127.0.0.1:%d" % port)
pg.CFG["api"] = "http://127.0.0.1:1"
self.assertIsNone(pg.find_api(ports=lambda: []))
finally:
srv.shutdown()
def test_without_a_screen_called_stage_display_the_first_stage_screen_is_used(self):
pg.api = lambda path, timeout=2.0: [{"screen": {"uuid": "B", "name": "Band", "index": 1}, "layout": {"name": "X"}},
{"screen": {"uuid": "A", "name": "Front", "index": 0}, "layout": {"name": "Y"}}]
screen, layout = pg.Guard().stage()
self.assertEqual((screen["uuid"], layout["name"]), ("A", "Y"))
class TestStart(unittest.TestCase):
def setUp(self):
self.fake, self.clock, self.orig = FakeProP(), [1000.0], pg.api
pg.api = self.fake
self.g = pg.Guard(now=lambda: self.clock[0])
def tearDown(self):
pg.api = self.orig
def at(self, t):
self.clock[0] = 1000.0 + t
self.g.tick()
def test_a_song_already_up_when_the_guard_starts_is_left_alone(self):
self.fake.calls.clear()
self.at(0) # the guard (re)starts mid-song, stage on Message
self.at(3)
self.assertEqual(self.fake.layout, "MC")
self.fake.song = "Goodness" # the next song is judged as usual
self.at(10)
self.at(13)
self.assertEqual(self.fake.layout, "Worship")
def test_prop_not_answering_at_the_start_does_not_count_as_seen(self):
pg.api = lambda path, timeout=2.0: None
self.at(0)
pg.api = self.fake
self.at(1) # first answer: this song was already up
self.at(4)
self.assertEqual(self.fake.layout, "MC")