diff --git a/README.md b/README.md index 1a4f65b..080380d 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,7 @@ everything on the page at once. | PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again | | TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again | | BKGDS | every song has at least one slide with a background | looks again | -| LED CROSS | Resolume is on the **Service** deck and a clip is playing | selects the Service deck and plays the cross background again (the one in `"led_clip"`, else the last one seen playing) | +| LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) | | LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center | | TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. | | PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again | diff --git a/config.json b/config.json index 6615fbc..4d5743c 100644 --- a/config.json +++ b/config.json @@ -767,7 +767,7 @@ "kind": "ready", "action": "led_cross", "short": "CROSS", - "label": "put Resolume back on the Service deck and play the cross background again" + "label": "put Resolume back on the Service deck, then look at the columns again" }, "ready_copy_tracks": { "kind": "ready", @@ -1345,7 +1345,7 @@ "lineup_skip_positions": [], "resolume_url": "http://10.1.10.206:8080", "resolume_deck": "Service", - "led_clip": "", + "prop_host": "prop", "prop_bundle_host": "prop", "prop_bundle_folder": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Location Assets/ProPresenter", "prop_bundle_pattern": "*.probundle", diff --git a/readiness.py b/readiness.py index d2b254c..54a5cd4 100644 --- a/readiness.py +++ b/readiness.py @@ -15,14 +15,15 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p the tracks play at that song's start (worked out from the set's LTC clip and tempo map) backgrounds every song's slides have a background (ProPresenter labels a slide with its media's file name) - led_cross Resolume is on the Service deck and something is playing on the cross + led_cross every Resolume column Sunday's slides call up holds content, on the Service deck + (slide -> macro -> RossTalk "CC page/row/col" to Companion -> Resolume column) lineup the Band Lineup board (ProdDash) shows this plan and exactly the people on it mics (a tap-to-confirm key) which mics this weekend needs, from PCO track_bundle every location has this weekend's Ableton project folder on the shared drive prop_bundle a ProPresenter bundle was saved to the locations' folder this week What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync -the lineup from PCO, put the cross background back up, copy the tracks to a location that +the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that has none, or simply look again now. Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read @@ -52,8 +53,6 @@ MEDIA = re.compile(r"\.(mp4|mov|m4v|mxf|mpg|mpeg|avi|webm|jpg|jpeg|png|gif|heic| WAKE = threading.Event() # set by a hold: look again now instead of waiting for the interval LAST = {} # the latest look (run_action needs the plan's date) -MEMORY = {} # what is worth remembering across looks: the cross's clip ("led_clip") -MEMORY_FILE = "ready-memory.json" class ReadyError(Exception): @@ -291,7 +290,7 @@ def prop_look(rc, plan): try: p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {} slides = [s for g in p.get("groups") or [] for s in g.get("slides") or []] - cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), + cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), "path": p.get("presentation_path"), "timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0, "slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "", "text": s.get("text") or "", "notes": s.get("notes") or ""} for s in slides]} @@ -735,31 +734,181 @@ def eval_lineup(rc, plan, state): return line -# --- Resolume (the LED cross) ------------------------------------------------------------------ +# --- Resolume: the columns Sunday's slides call up ------------------------------------------- +# A ProPresenter slide fires a macro (the .pro holds the macro's UUID); the macro sends RossTalk +# "CC page/row/column" to Companion; that Companion button (and the buttons it presses in turn) +# sends Resolume "/composition/columns/N/connect". Every column found that way must hold content. -def connected_clips(comp): - """[(layer number, clip number, name)] of every clip playing in the composition (1-based, as the API wants).""" - out = [] - for li, layer in enumerate(comp.get("layers") or []): - for ci, clip in enumerate(layer.get("clips") or []): - state = ((clip.get("connected") or {}).get("value") or "") - if state.startswith("Connected"): - out.append((li + 1, ci + 1, ((clip.get("name") or {}).get("value") or ""))) +MACRO_SCRIPT = r""" +import json, os, subprocess +P = json.loads(%r) +def show_dir(): + try: + d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"], + capture_output=True, text=True, timeout=5).stdout.strip() + if d: + return os.path.expanduser(d) + except Exception: + pass + return os.path.expanduser("~/Documents/ProPresenter") +def varint(b, i): + s = r = 0 + while True: + x = b[i]; i += 1; r |= (x & 0x7f) << s + if not x & 0x80: + return r, i + s += 7 +def fields(b): + out, i = [], 0 + while i < len(b): + k, i = varint(b, i); num, wt = k >> 3, k & 7 + if wt == 0: v, i = varint(b, i) + elif wt == 1: v, i = b[i:i + 8], i + 8 + elif wt == 5: v, i = b[i:i + 4], i + 4 + elif wt == 2: + n, i = varint(b, i); v = b[i:i + n]; i += n + else: raise ValueError(wt) + out.append((num, wt, v)) return out +def get(b, *path): + for p in path: + if not isinstance(b, bytes): + return None + f = [v for n, w, v in fields(b) if n == p] + if not f: + return None + b = f[0] + return b if isinstance(b, bytes) else None +macros, raw = {}, {} +for num, wt, v in fields(open(os.path.join(show_dir(), "Configuration", "Macros"), "rb").read()): + if num != 2 or wt != 2: + continue + try: + uuid = get(v, 1, 1).decode(); name = (get(v, 2) or b"").decode() + except Exception: + continue + cc = [] + for n, w, act in fields(v): + comm = get(act, 32) if n == 4 and w == 2 else None + if comm is None: + continue + dev, fmt = (get(comm, 1, 2) or b"").decode(), (get(comm, 2) or b"").decode() + for n2, w2, cmd in fields(comm): + if n2 == 4 and w2 == 2: + cc.append([dev, fmt, (get(cmd, 2) or b"").decode()]) + macros[uuid] = {"name": name, "cc": cc} + raw[uuid] = v +for u, v in raw.items(): # a macro that runs other macros + macros[u]["calls"] = [o for o in raw if o != u and o.encode() in v] +used = {} +for p in P["paths"]: + try: + b = open(p, "rb").read() + except OSError: + used[p] = None + continue + used[p] = [u for u in macros if u.encode() in b] +print(json.dumps({"macros": macros, "used": used})) +""" + +COLUMN = re.compile(r"/composition/columns/(\d+)/connect") +LOCATION = re.compile(r"^\s*(\d+)/(\d+)/(\d+)\s*$") +PRESSES = ("button_pressrelease", "button_press", "button_release", "button_pressrelease_condition") -def eval_resolume(rc, comp, memory): +def companion_page(cfg, n): + """One Companion page as Companion exports it (read-only).""" + comp = cfg.get("companion") or {} + url = "http://%s:%d/int/export/page/%d?includeSecrets=false" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), n) + with urllib.request.urlopen(url, timeout=6) as r: + raw = r.read() + try: + raw = __import__("gzip").decompress(raw) + except OSError: + pass + return json.loads(raw).get("page") or {} + + +def button_columns(get_page, location, seen=None): + """The Resolume columns a Companion button connects, following the buttons it presses.""" + seen = set() if seen is None else seen + m = LOCATION.match(location or "") + if not m or location in seen or len(seen) > 40: + return set() + seen.add(location) + page, row, col = (int(x) for x in m.groups()) + ctl = (((get_page(page) or {}).get("controls") or {}).get(str(row)) or {}).get(str(col)) or {} + cols = set() + + def walk(actions): + for a in actions or []: + opts = {k: (v.get("value") if isinstance(v, dict) else v) for k, v in (a.get("options") or {}).items()} + for v in opts.values(): + for hit in COLUMN.findall(str(v)): + cols.add(int(hit)) + if "olumn" in str(a.get("definitionId")) and str(opts.get("column", "")).isdigit(): + cols.add(int(opts["column"])) + if a.get("connectionId") == "internal" and a.get("definitionId") in PRESSES: + cols.update(button_columns(get_page, str(opts.get("location", "")), seen)) + for kids in (a.get("children") or {}).values(): + walk(kids) + for step in (ctl.get("steps") or {}).values(): + for actions in (step.get("action_sets") or {}).values(): + walk(actions) + return cols + + +def macro_columns(macros, uuid, get_page, seen=None): + """The columns one ProPresenter macro calls up (through the Companion buttons it presses).""" + seen = set() if seen is None else seen + if uuid in seen or uuid not in macros: + return set() + seen.add(uuid) + cols = set() + for dev, fmt, value in macros[uuid].get("cc") or []: + if fmt.strip().upper().startswith("CC"): + cols |= button_columns(get_page, value.replace(":", "/")) + for other in macros[uuid].get("calls") or []: + cols |= macro_columns(macros, other, get_page, seen) + return cols + + +def column_has_content(comp, n): + """Column n (1-based) holds a clip on some layer that a column trigger plays.""" + for layer in comp.get("layers") or []: + clips = layer.get("clips") or [] + if n - 1 < len(clips): + c = clips[n - 1] + ignore = (c.get("ignorecolumntrigger") or {}).get("value") # "Layer Determined" / "On" / "Off" + if ((c.get("connected") or {}).get("value") or "Empty") != "Empty" and ignore not in ("On", True): + return True + return False + + +def eval_columns(rc, prop, found, get_page, comp): + """Every Resolume column the plan's ProPresenter items call up holds content, on the right deck.""" line = Line() deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None) want = rc.get("resolume_deck", "") if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want): - line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s" % (((deck or {}).get("name") or {}).get("value"), want)) - playing = connected_clips(comp) - if not playing: - line.fault(ERR, "NOTHING ON", "no clip is playing in %s" % ((comp.get("name") or {}).get("value") or "the composition")) - else: - memory["led_clip"] = playing[0][2] - line.info = short(playing[0][2], 30) + line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % ( + ((deck or {}).get("name") or {}).get("value"), want)) + macros, used = found.get("macros") or {}, found.get("used") or {} + wanted = {} # column -> [items that call it up] + for it in prop.get("items") or []: + path = (it.get("pres") or {}).get("path") + for uuid in used.get(path) or []: + for col in macro_columns(macros, uuid, get_page): + wanted.setdefault(col, []).append(it["name"]) + columns = comp.get("columns") or [] + for col in sorted(wanted): + name = ((columns[col - 1].get("name") or {}).get("value") if col <= len(columns) else "") or "" + who = ", ".join(dict.fromkeys(wanted[col])) + if col > len(columns): + line.fault(ERR, "COL %d MISSING" % col, "%s call up column %d; Resolume has %d columns" % (who, col, len(columns))) + elif not column_has_content(comp, col): + line.fault(ERR, "COL %d EMPTY" % col, "column %d (%s) is empty in Resolume; %s call it up" % (col, name, who)) + line.info = "%d COLUMNS OK" % len(wanted) if wanted else "NO COLUMNS USED" return line @@ -812,25 +961,6 @@ class Line: "info": self.info} -def load_memory(data_dir): - try: - return json.load(open(os.path.join(data_dir, MEMORY_FILE))) if data_dir else {} - except (OSError, ValueError): - return {} - - -def save_memory(data_dir, memory): - if not data_dir: - return - try: - tmp = os.path.join(data_dir, MEMORY_FILE + ".tmp") - with open(tmp, "w") as f: - json.dump(memory, f) - os.replace(tmp, os.path.join(data_dir, MEMORY_FILE)) - except OSError as e: - log.warning("%s: %s", MEMORY_FILE, e) - - def look(cfg, now=None, data_dir=None, sources=None): """Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}. `sources` replaces the readers (tests).""" @@ -840,12 +970,12 @@ def look(cfg, now=None, data_dir=None, sources=None): ableton=lambda plan: ableton_look(cfg, rc, plan), lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6), resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6), + macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60), + companion=lambda n: companion_page(cfg, n), prop_bundle=lambda: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps( {"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30)) src.update(sources or {}) lines = {} - if not MEMORY: - MEMORY.update(load_memory(data_dir)) def failed(names, err): msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED" @@ -862,13 +992,31 @@ def look(cfg, now=None, data_dir=None, sources=None): ("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), ("lineup", rc.get("lineup_url")), ("mics", True), - ("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host"))) if on], e) + ("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")), + ("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e) if plan is not None: + prop = None if rc.get("prop_url"): try: - lines.update(eval_prop(rc, plan, src["prop"](plan))) + prop = src["prop"](plan) + lines.update(eval_prop(rc, plan, prop)) except Exception as e: # noqa: BLE001 - failed(["prop", "timecode", "backgrounds"], e) + failed(["prop", "timecode", "backgrounds"] + (["led_cross"] if rc.get("resolume_url") and rc.get("prop_host") else []), e) + if rc.get("resolume_url") and rc.get("prop_host") and prop is not None: + try: + pages = {} + + def get_page(n): + if n not in pages: + try: + pages[n] = src["companion"](n) + except (OSError, ValueError): + pages[n] = {} + return pages[n] + paths = sorted({(it.get("pres") or {}).get("path") for it in prop["items"]} - {None}) + lines["led_cross"] = eval_columns(rc, prop, src["macros"](paths), get_page, src["resolume"]()) + except Exception as e: # noqa: BLE001 + failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE")) if rc.get("tracks_host"): try: al = src["ableton"](plan) @@ -895,14 +1043,6 @@ def look(cfg, now=None, data_dir=None, sources=None): lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon"))) except Exception as e: # noqa: BLE001 failed(["prop_bundle"], e) - if rc.get("resolume_url"): - try: - before = dict(MEMORY) - lines["led_cross"] = eval_resolume(rc, src["resolume"](), MEMORY) - if MEMORY != before: - save_memory(data_dir, MEMORY) - except Exception as e: # noqa: BLE001 - failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE")) out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}} if plan is not None: out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]} @@ -998,16 +1138,7 @@ def run_action(cfg, action, chk=None, sleep=time.sleep): log.info("FIX Resolume deck %s selected", want) sleep(1) comp = http_json(base, timeout=6) or {} - if connected_clips(comp): - return True - name = rc.get("led_clip") or MEMORY.get("led_clip") # set in config, else the last one seen playing - for li, layer in enumerate(comp.get("layers") or []): - for ci, clip in enumerate(layer.get("clips") or []): - if name and ((clip.get("name") or {}).get("value") or "") == name: - http_json("%s/layers/%d/clips/%d/connect" % (base, li + 1, ci + 1), method="POST", timeout=6) - log.info("FIX LED cross: %s playing again (layer %d clip %d)", name, li + 1, ci + 1) - return True - return "NO CLIP KNOWN" + return True # an empty column is filled by hand in Resolume if action == "ltc_to_pco": return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id")) if action == "copy_tracks": diff --git a/schema.py b/schema.py index 8cb99a5..113f4c3 100644 --- a/schema.py +++ b/schema.py @@ -382,7 +382,8 @@ field("ready.lineup_teams", "list", ["Band", "Communication"], "PCO teams whose field("ready.lineup_skip_positions", "list", [], "PCO positions the lineup TV does not show.", "Ready") field("ready.resolume_url", "url", "", "Resolume Arena's web server.", "Ready") field("ready.resolume_deck", "str", "", "The deck Resolume must be on.", "Ready") -field("ready.led_clip", "str", "", "The cross background a hold plays; blank = the last one seen playing.", "Ready") +field("ready.prop_host", "str", None, "The ProPresenter computer: its macros and presentation files are read to find " + "the Resolume columns Sunday's slides call up.", "Ready") field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready") field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes.", "Ready") field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True) @@ -467,7 +468,7 @@ GLOSSARY = { "MISSING", "SLIDES OLD?", "SLIDES", "NO TC", "NO LTC", "NO BG", "NO PLAYLIST", "OLD PLAN", "SYNC ERROR", "NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN", "PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE", - "UNREACHABLE", "CHECK FAILED", "NO CLIP KNOWN", "TURNED OFF", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX", + "UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX", ], "ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"], "what a key says while you hold it": [ diff --git a/settings/ui/app.js b/settings/ui/app.js index c4c3825..c871f5c 100644 --- a/settings/ui/app.js +++ b/settings/ui/app.js @@ -569,7 +569,7 @@ prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.", timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.", backgrounds: "Every song has at least one slide with a background.", - led_cross: "Resolume is on the Service deck and a clip is playing on the cross.", + led_cross: "Every Resolume column Sunday's slides call up (slide → macro → Companion → column) holds content, and Resolume is on the Service deck.", lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.", track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.", prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.", diff --git a/tests/test_readiness.py b/tests/test_readiness.py index 705cd01..96dc50b 100644 --- a/tests/test_readiness.py +++ b/tests/test_readiness.py @@ -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):