diff --git a/README.md b/README.md index f4eba74..060f12d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/install.sh b/install.sh index 8e696cd..7f67508 100644 --- a/install.sh +++ b/install.sh @@ -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" < + + + + Label$GUARD_LABEL + ProgramArguments/usr/bin/python3$DEST/prop_guard.py$DEST/prop-guard.json.cfg + RunAtLoadKeepAlive + StandardErrorPath$HOME/Library/Logs/prop-guard.err.log + + +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" diff --git a/prop_guard.py b/prop_guard.py index 64c3247..ec328d5 100644 --- a/prop_guard.py +++ b/prop_guard.py @@ -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 diff --git a/settings/campus.py b/settings/campus.py index db33348..5f25069 100644 --- a/settings/campus.py +++ b/settings/campus.py @@ -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") diff --git a/tests/test_prop_guard.py b/tests/test_prop_guard.py index 7728340..b0d613b 100644 --- a/tests/test_prop_guard.py +++ b/tests/test_prop_guard.py @@ -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")