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:
+49
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user