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
+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