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:
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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. |
|
| 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 |
|
| PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again |
|
||||||
|
|||||||
+2
-2
@@ -767,7 +767,7 @@
|
|||||||
"kind": "ready",
|
"kind": "ready",
|
||||||
"action": "led_cross",
|
"action": "led_cross",
|
||||||
"short": "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": {
|
"ready_copy_tracks": {
|
||||||
"kind": "ready",
|
"kind": "ready",
|
||||||
@@ -1345,7 +1345,7 @@
|
|||||||
"lineup_skip_positions": [],
|
"lineup_skip_positions": [],
|
||||||
"resolume_url": "http://10.1.10.206:8080",
|
"resolume_url": "http://10.1.10.206:8080",
|
||||||
"resolume_deck": "Service",
|
"resolume_deck": "Service",
|
||||||
"led_clip": "",
|
"prop_host": "prop",
|
||||||
"prop_bundle_host": "prop",
|
"prop_bundle_host": "prop",
|
||||||
"prop_bundle_folder": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Location Assets/ProPresenter",
|
"prop_bundle_folder": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Location Assets/ProPresenter",
|
||||||
"prop_bundle_pattern": "*.probundle",
|
"prop_bundle_pattern": "*.probundle",
|
||||||
|
|||||||
+195
-64
@@ -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)
|
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
|
backgrounds every song's slides have a background (ProPresenter labels a slide with
|
||||||
its media's file name)
|
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
|
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
|
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
|
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
|
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
|
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.
|
has none, or simply look again now.
|
||||||
|
|
||||||
Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read
|
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
|
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)
|
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):
|
class ReadyError(Exception):
|
||||||
@@ -291,7 +290,7 @@ def prop_look(rc, plan):
|
|||||||
try:
|
try:
|
||||||
p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {}
|
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 []]
|
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,
|
"timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0,
|
||||||
"slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "",
|
"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]}
|
"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
|
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):
|
MACRO_SCRIPT = r"""
|
||||||
"""[(layer number, clip number, name)] of every clip playing in the composition (1-based, as the API wants)."""
|
import json, os, subprocess
|
||||||
out = []
|
P = json.loads(%r)
|
||||||
for li, layer in enumerate(comp.get("layers") or []):
|
def show_dir():
|
||||||
for ci, clip in enumerate(layer.get("clips") or []):
|
try:
|
||||||
state = ((clip.get("connected") or {}).get("value") or "")
|
d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"],
|
||||||
if state.startswith("Connected"):
|
capture_output=True, text=True, timeout=5).stdout.strip()
|
||||||
out.append((li + 1, ci + 1, ((clip.get("name") or {}).get("value") or "")))
|
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
|
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()
|
line = Line()
|
||||||
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
|
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
|
||||||
want = rc.get("resolume_deck", "")
|
want = rc.get("resolume_deck", "")
|
||||||
if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want):
|
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))
|
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % (
|
||||||
playing = connected_clips(comp)
|
((deck or {}).get("name") or {}).get("value"), want))
|
||||||
if not playing:
|
macros, used = found.get("macros") or {}, found.get("used") or {}
|
||||||
line.fault(ERR, "NOTHING ON", "no clip is playing in %s" % ((comp.get("name") or {}).get("value") or "the composition"))
|
wanted = {} # column -> [items that call it up]
|
||||||
else:
|
for it in prop.get("items") or []:
|
||||||
memory["led_clip"] = playing[0][2]
|
path = (it.get("pres") or {}).get("path")
|
||||||
line.info = short(playing[0][2], 30)
|
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
|
return line
|
||||||
|
|
||||||
|
|
||||||
@@ -812,25 +961,6 @@ class Line:
|
|||||||
"info": self.info}
|
"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):
|
def look(cfg, now=None, data_dir=None, sources=None):
|
||||||
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
|
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
|
||||||
`sources` replaces the readers (tests)."""
|
`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),
|
ableton=lambda plan: ableton_look(cfg, rc, plan),
|
||||||
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6),
|
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),
|
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(
|
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))
|
{"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
|
||||||
src.update(sources or {})
|
src.update(sources or {})
|
||||||
lines = {}
|
lines = {}
|
||||||
if not MEMORY:
|
|
||||||
MEMORY.update(load_memory(data_dir))
|
|
||||||
|
|
||||||
def failed(names, err):
|
def failed(names, err):
|
||||||
msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED"
|
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")),
|
("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
|
||||||
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
|
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
|
||||||
("lineup", rc.get("lineup_url")), ("mics", True),
|
("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:
|
if plan is not None:
|
||||||
|
prop = None
|
||||||
if rc.get("prop_url"):
|
if rc.get("prop_url"):
|
||||||
try:
|
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
|
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"):
|
if rc.get("tracks_host"):
|
||||||
try:
|
try:
|
||||||
al = src["ableton"](plan)
|
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")))
|
lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon")))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
failed(["prop_bundle"], e)
|
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()}}
|
out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}}
|
||||||
if plan is not None:
|
if plan is not None:
|
||||||
out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]}
|
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)
|
log.info("FIX Resolume deck %s selected", want)
|
||||||
sleep(1)
|
sleep(1)
|
||||||
comp = http_json(base, timeout=6) or {}
|
comp = http_json(base, timeout=6) or {}
|
||||||
if connected_clips(comp):
|
return True # an empty column is filled by hand in Resolume
|
||||||
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"
|
|
||||||
if action == "ltc_to_pco":
|
if action == "ltc_to_pco":
|
||||||
return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
|
return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
|
||||||
if action == "copy_tracks":
|
if action == "copy_tracks":
|
||||||
|
|||||||
@@ -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.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_url", "url", "", "Resolume Arena's web server.", "Ready")
|
||||||
field("ready.resolume_deck", "str", "", "The deck Resolume must be on.", "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_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_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)
|
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",
|
"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",
|
"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",
|
"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"],
|
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
|
||||||
"what a key says while you hold it": [
|
"what a key says while you hold it": [
|
||||||
|
|||||||
+1
-1
@@ -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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
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.",
|
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
|
||||||
|
|||||||
+62
-18
@@ -63,7 +63,7 @@ def slide(label="", text="", notes="", enabled=True):
|
|||||||
|
|
||||||
|
|
||||||
def pres(name, slides, timeline=False, cues=None):
|
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}
|
"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)]}}
|
"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"},
|
return {"name": {"value": "Crosses"},
|
||||||
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
|
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
|
||||||
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
|
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
|
||||||
"layers": [{"clips": [{"name": {"value": "SS_StainedGlass005"}, "connected": {"value": "Disconnected"}},
|
"columns": [{"name": {"value": "Theme 10 White" if n == 11 else "Column %d" % n}} for n in range(1, columns + 1)],
|
||||||
{"name": {"value": "Stained Glass 11"}, "connected": {"value": "Connected" if playing else "Disconnected"}}]}]}
|
"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):
|
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.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure
|
||||||
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
|
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
|
||||||
|
|
||||||
def test_led_cross(self):
|
def test_columns_follow_macro_companion_resolume(self):
|
||||||
memory = {}
|
get = lambda n: COMPANION.get(n, {})
|
||||||
self.assertEqual(msgs(rd.eval_resolume(RC, composition(), memory)), [])
|
self.assertEqual(rd.button_columns(get, "17/2/4"), {11})
|
||||||
self.assertEqual(memory["led_clip"], "Stained Glass 11")
|
self.assertEqual(rd.button_columns(get, "17/4/3"), {11, 12}) # through two buttons
|
||||||
self.assertEqual(msgs(rd.eval_resolume(RC, composition("Test", False), {})), ["WRONG DECK", "NOTHING ON"])
|
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):
|
def test_prop_bundle_this_week(self):
|
||||||
since = rd.week_start(WEDNESDAY)
|
since = rd.week_start(WEDNESDAY)
|
||||||
@@ -302,7 +348,8 @@ class TestLook(unittest.TestCase):
|
|||||||
def sources(self, **over):
|
def sources(self, **over):
|
||||||
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
|
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"]),
|
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)
|
src.update(over)
|
||||||
return src
|
return src
|
||||||
|
|
||||||
@@ -322,7 +369,7 @@ class TestLook(unittest.TestCase):
|
|||||||
self.assertEqual(r["items"]["lineup"]["faults"], [])
|
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"))))
|
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"]["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):
|
def test_a_campus_that_only_receives(self):
|
||||||
cfg = json.loads(json.dumps(CONFIG))
|
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):
|
def test_one_hold_fixes_everything_that_can_be_fixed(self):
|
||||||
look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources(
|
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.snap.ready = look
|
||||||
self.step(WEDNESDAY)
|
self.step(WEDNESDAY)
|
||||||
plan = self.engine.fix_plan("ready", self.snap)
|
plan = self.engine.fix_plan("ready", self.snap)
|
||||||
@@ -457,23 +504,20 @@ class TestFixes(unittest.TestCase):
|
|||||||
rd.http_json = real
|
rd.http_json = real
|
||||||
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
|
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 = []
|
calls = []
|
||||||
real = rd.http_json
|
real = rd.http_json
|
||||||
comp = composition("Test", False)
|
|
||||||
|
|
||||||
def fake(url, headers=None, method="GET", data=None, timeout=10):
|
def fake(url, headers=None, method="GET", data=None, timeout=10):
|
||||||
calls.append((method, url.split("/api/v1/composition")[-1]))
|
calls.append((method, url.split("/api/v1/composition")[-1]))
|
||||||
return comp
|
return composition("Test")
|
||||||
rd.http_json = fake
|
rd.http_json = fake
|
||||||
rd.MEMORY["led_clip"] = "Stained Glass 11"
|
|
||||||
try:
|
try:
|
||||||
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
|
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
|
||||||
finally:
|
finally:
|
||||||
rd.http_json = real
|
rd.http_json = real
|
||||||
rd.MEMORY.clear()
|
|
||||||
self.assertIn(("POST", "/decks/1/select"), calls)
|
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):
|
class TestSpotifyWhileMuted(unittest.TestCase):
|
||||||
|
|||||||
Reference in New Issue
Block a user