READY page: LED CROSS checks the Resolume columns Sunday's slides call up

- For every item in the plan's ProPresenter playlist: the macros its slides fire (read on the
  ProP Mac from Configuration/Macros and the .pro files), the RossTalk "CC page/row/col" each
  macro sends to Companion, the Companion buttons that one presses in turn, down to Resolume's
  /composition/columns/N/connect. Each such column must hold a clip on some layer (one whose
  ignore-column-trigger is not On), and Resolume must be on the Service deck.
- COL N EMPTY / COL N MISSING name the column and the items that use it. The hold only puts
  Resolume back on its deck; it never plays a clip. "Something is playing" is no longer checked.
- ready.prop_host replaces ready.led_clip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:43:49 -04:00
co-authored by Claude Opus 5.5
parent 5adab6c518
commit c9ffbc6828
6 changed files with 264 additions and 88 deletions
+62 -18
View File
@@ -63,7 +63,7 @@ def slide(label="", text="", notes="", enabled=True):
def pres(name, slides, timeline=False, cues=None):
return {"name": name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
return {"name": name, "path": "/Volumes/MEDIA/ProPresenter/Libraries/%s.pro" % name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
"cues": len(slides) if cues is None else cues, "slides": slides}
@@ -131,12 +131,43 @@ def lineup(names, plan_id="92013595"):
"slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}}
def composition(deck="Service", playing=True):
def composition(deck="Service", empty=(), ignored=(), columns=12):
"""Resolume's "Crosses": a clip in every column of layer 1 but the `empty` ones; layer 2 all empty."""
clip = lambda n, full, ign: {"name": {"value": "Clip %d" % n if full else ""},
"connected": {"value": "Disconnected" if full else "Empty"},
"ignorecolumntrigger": {"value": "On" if ign else "Layer Determined"}}
return {"name": {"value": "Crosses"},
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
"layers": [{"clips": [{"name": {"value": "SS_StainedGlass005"}, "connected": {"value": "Disconnected"}},
{"name": {"value": "Stained Glass 11"}, "connected": {"value": "Connected" if playing else "Disconnected"}}]}]}
"columns": [{"name": {"value": "Theme 10 White" if n == 11 else "Column %d" % n}} for n in range(1, columns + 1)],
"layers": [{"clips": [clip(n, n not in empty, n in ignored) for n in range(1, columns + 1)]},
{"clips": [clip(n, False, False) for n in range(1, columns + 1)]}]}
# Companion as the chain really is: page 17 "White" presses 6/1/4, page 6's 1/4 connects column 11;
# page 17 "Salvation" presses 6/1/5 (column 12) and 17/2/4 (White again); 17/0/1 presses itself.
COMPANION = {
17: {"controls": {"2": {"4": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/4"}}}]}}}}},
"4": {"3": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/5"}}},
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/2/4"}}}]}}}}},
"0": {"1": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/0/1"}}}]}}}}}}},
6: {"controls": {"1": {"4": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/11/connect"}}}]}}}},
"5": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/12/connect"}}}]}}}}}}},
}
MACROS = {"U-WHITE": {"name": "White", "cc": [["Stream Deck", "CC x", "17/2/4"]], "calls": []},
"U-SALV": {"name": "Salvation Moment", "cc": [["Stream Deck", "CC x", "17/4/3"]], "calls": []},
"U-LOOP": {"name": "Loop", "cc": [["Stream Deck", "CC x", "17/0/1"]], "calls": ["U-WHITE"]}}
def macros_found(paths):
"""Holy Forever and Rejoice Again fire White; Baptisms fires the macro that runs White too."""
use = {"Holy Forever": ["U-WHITE"], "Rejoice Again": ["U-WHITE"], "Baptisms": ["U-LOOP"]}
return {"macros": MACROS, "used": {p: use.get(p.rsplit("/", 1)[-1][:-4], []) for p in paths}}
def msgs(line):
@@ -283,11 +314,26 @@ class TestLineupCrossBundle(unittest.TestCase):
self.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
def test_led_cross(self):
memory = {}
self.assertEqual(msgs(rd.eval_resolume(RC, composition(), memory)), [])
self.assertEqual(memory["led_clip"], "Stained Glass 11")
self.assertEqual(msgs(rd.eval_resolume(RC, composition("Test", False), {})), ["WRONG DECK", "NOTHING ON"])
def test_columns_follow_macro_companion_resolume(self):
get = lambda n: COMPANION.get(n, {})
self.assertEqual(rd.button_columns(get, "17/2/4"), {11})
self.assertEqual(rd.button_columns(get, "17/4/3"), {11, 12}) # through two buttons
self.assertEqual(rd.button_columns(get, "17/0/1"), set()) # presses itself: no loop
self.assertEqual(rd.macro_columns(MACROS, "U-LOOP", get), {11}) # a macro that runs another
found = macros_found([it["pres"]["path"] for it in prop()["items"] if it["pres"]])
line = rd.eval_columns(RC, prop(), found, get, composition())
self.assertEqual((msgs(line), line.info), ([], "1 COLUMNS OK"))
line = rd.eval_columns(RC, prop(), found, get, composition(empty=(11,)))
self.assertEqual(msgs(line), ["COL 11 EMPTY"])
self.assertIn("Theme 10 White", line.faults[0][2])
self.assertIn("Holy Forever", line.faults[0][2])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(ignored=(11,)))), ["COL 11 EMPTY"])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(columns=10))), ["COL 11 MISSING"])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition("Test"))), ["WRONG DECK"])
self.assertEqual(rd.eval_columns(RC, prop(), {"macros": {}, "used": {}}, get, composition()).info, "NO COLUMNS USED")
def test_the_macro_reader_runs(self):
compile(rd.MACRO_SCRIPT % json.dumps({"paths": ["/x.pro"]}), "remote", "exec")
def test_prop_bundle_this_week(self):
since = rd.week_start(WEDNESDAY)
@@ -302,7 +348,8 @@ class TestLook(unittest.TestCase):
def sources(self, **over):
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
resolume=composition, prop_bundle=lambda: [["Message Templates.probundle", WEDNESDAY]])
resolume=composition, prop_bundle=lambda: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {}))
src.update(over)
return src
@@ -322,7 +369,7 @@ class TestLook(unittest.TestCase):
self.assertEqual(r["items"]["lineup"]["faults"], [])
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(pco=lambda: (_ for _ in ()).throw(rd.ReadyError("NO PCO LOGIN"))))
self.assertEqual(r["items"]["tracks"]["faults"], [["warn", "NO PCO LOGIN"]])
self.assertEqual(r["items"]["led_cross"]["faults"], []) # Resolume needs no plan
self.assertEqual(r["items"]["led_cross"]["faults"], [["warn", "NO PCO LOGIN"]]) # no plan, no columns to look at
def test_a_campus_that_only_receives(self):
cfg = json.loads(json.dumps(CONFIG))
@@ -381,7 +428,7 @@ class TestWatchdog(unittest.TestCase):
def test_one_hold_fixes_everything_that_can_be_fixed(self):
look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources(
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test", False)))
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test")))
self.snap.ready = look
self.step(WEDNESDAY)
plan = self.engine.fix_plan("ready", self.snap)
@@ -457,23 +504,20 @@ class TestFixes(unittest.TestCase):
rd.http_json = real
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
def test_cross_comes_back_with_the_clip_last_seen(self):
def test_cross_hold_puts_resolume_back_on_its_deck(self):
calls = []
real = rd.http_json
comp = composition("Test", False)
def fake(url, headers=None, method="GET", data=None, timeout=10):
calls.append((method, url.split("/api/v1/composition")[-1]))
return comp
return composition("Test")
rd.http_json = fake
rd.MEMORY["led_clip"] = "Stained Glass 11"
try:
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
finally:
rd.http_json = real
rd.MEMORY.clear()
self.assertIn(("POST", "/decks/1/select"), calls)
self.assertIn(("POST", "/layers/1/clips/2/connect"), calls)
self.assertFalse(any("/clips/" in path for _, path in calls)) # never plays a clip by itself
class TestSpotifyWhileMuted(unittest.TestCase):