- readiness.py looks every 2 minutes (never while a HyperDeck records) at this weekend's PCO plan and what is in place for it: the Ableton set (songs, mix, routing, lighting cues), ProPresenter (every PCO item, MC announcements, baptism names, timelines, backgrounds), the LTC note of every song against the timecode the tracks play, the Band Lineup TV, Resolume's LED cross, and the tracks and ProP bundles for the locations. - READY key on the Status page (row 2, col 7; SPOTIFY now shows inside SHOW) opens page 24. Amber until done, blinking red from Saturday noon; not part of ALL. Tap keys for BOH mix, mics and stage reset every Monday. - Holds fix what can be fixed: re-sync the lineup, put the cross back up, write LTC into PCO, copy tracks to locations, look again. Hold READY fixes everything. - Settings page: a READY page tab (live keys, drag to move, why each key is amber), and Apply re-imports page 24. - Auto-fix: "while_muted" fixes keep running with ALARMS muted (Spotify playlist). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
19 KiB
Python
385 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the Companion 5 page export for the STATUS page from config.json.
|
|
|
|
Every positioned tile becomes a layered button whose background colour, text
|
|
colour and text are expressions over three custom variables:
|
|
|
|
sp_state JSON written by status_panel.py: {tile: {x, b, f, b2, f2}}
|
|
sp_hb unix time of status_panel.py's last write
|
|
flash_tick the existing 1 Hz blink clock (trigger "flash_tick_500")
|
|
|
|
If sp_hb is more than 15 s old every tile turns purple, so a stopped
|
|
watchdog can never leave a frozen green page behind.
|
|
|
|
Buttons write commands into sp_cmd, which status_panel.py reads:
|
|
press ack <tile> (the master acks everything)
|
|
hold 2 seconds restart <tile> (fixes what that tile is failing on)
|
|
Restart page force <key> (hold 2 s: restart that thing even when it looks fine)
|
|
READY page done <key> (tap a key only a person can check: done for the week)
|
|
|
|
python3 make_page.py -> status-page.companionconfig, restart-page.companionconfig
|
|
and ready-page.companionconfig
|
|
python3 make_page.py --out DIR -> the same files in DIR (the data dir on the Ingest Mac)
|
|
"""
|
|
import gzip
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
from status_panel import RESTARTABLE, load_config, ready_keys # noqa: E402
|
|
|
|
# Defaults; "companion", "page_grid", "page_stale_seconds" and "page_hold_ms" in config.json override them.
|
|
EXPORT_VERSION = 12 # Companion 5.0.7's export format
|
|
COMPANION_BUILD = "5.0.7+9763-stable-cec2f88e6f"
|
|
GRID = {"minColumn": 0, "maxColumn": 8, "minRow": 0, "maxRow": 5}
|
|
STALE_SECONDS = 15
|
|
PURPLE = 0x6A1B9A
|
|
HOLD_MS = 2000
|
|
# Every key this tool makes carries one of these in its notes; push_page.py overwrites a page only
|
|
# when every key on it (besides Companion's own page keys) does.
|
|
MARKERS = ("STATUS tile", "RESTART key", "STATUS jump key", "RESTART back key", "STATUS nav key", "READY back key")
|
|
|
|
|
|
def settings(cfg):
|
|
"""The page-building numbers, from config.json where set."""
|
|
comp = cfg.get("companion", {})
|
|
return {"export_version": comp.get("export_version", EXPORT_VERSION), "build": comp.get("build", COMPANION_BUILD),
|
|
"grid": cfg.get("page_grid", GRID), "stale": cfg.get("page_stale_seconds", STALE_SECONDS),
|
|
"hold_ms": cfg.get("page_hold_ms", HOLD_MS), "flash": cfg.get("variables", {}).get("flash", "flash_tick"),
|
|
"ingest": cfg.get("site", {}).get("ingest_name", "NA Ingest")}
|
|
|
|
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"
|
|
|
|
|
|
def stable_id(*parts):
|
|
"""Companion-style 21 character id, the same on every build."""
|
|
digest = hashlib.sha256("/".join(parts).encode()).digest()
|
|
return "".join(ALPHABET[b % 64] for b in digest[:21])
|
|
|
|
|
|
def prop(value, expression=False):
|
|
return {"value": value, "isExpression": expression}
|
|
|
|
|
|
def layers(tid, tile, names, stale_text, opts=None):
|
|
opts = opts or settings({"variables": names})
|
|
state, hb, flash = names["state"], names["heartbeat"], opts["flash"]
|
|
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (hb, opts["stale"])
|
|
|
|
def pick(on_key, off_key):
|
|
return "jsonpath($(custom:%s), '$.%s.%s')" % (state, tid, on_key), \
|
|
"jsonpath($(custom:%s), '$.%s.%s')" % (state, tid, off_key)
|
|
|
|
b_on, b_off = pick("b", "b2")
|
|
f_on, f_off = pick("f", "f2")
|
|
bg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, PURPLE, flash, b_on, b_off)
|
|
fg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, 0xFFFFFF, flash, f_on, f_off)
|
|
text = "%s ? %s : jsonpath($(custom:%s), '$.%s.x')" % (stale, stale_text, state, tid)
|
|
# The watchdog picks a size per key so words never break mid-word.
|
|
size = "%s ? 20 : jsonpath($(custom:%s), '$.%s.s')" % (stale, state, tid)
|
|
geometry = {"enabled": prop(True), "opacity": prop(100), "x": prop(0), "y": prop(0),
|
|
"width": prop(100), "height": prop(100), "rotation": prop(0)}
|
|
return [
|
|
{"id": "canvas", "name": "Canvas", "usage": "auto", "type": "canvas",
|
|
# "border": no top bar (more room for text), still shows a press.
|
|
"decoration": prop("border"), "showStatusIcons": prop("default")},
|
|
dict({"id": "box0", "name": "Background", "usage": "auto", "type": "box"}, **geometry,
|
|
color=prop(bg, True), borderWidth=prop(0), borderColor=prop(0), borderPosition=prop("inside")),
|
|
dict({"id": "image0", "name": "Image", "usage": "auto", "type": "image"}, **geometry,
|
|
base64Image=prop(None), halign=prop("center"), valign=prop("center"), fillMode=prop("fit")),
|
|
dict({"id": "text0", "name": "Text", "usage": "auto", "type": "text"}, **geometry,
|
|
text=prop(text, True), color=prop(fg, True), halign=prop("center"), valign=prop("center"),
|
|
fontsize=prop(size, True), fontsizeAllowShrink=prop(True), font=prop("companion-sans"),
|
|
outlineColor=prop(4278190080)),
|
|
]
|
|
|
|
|
|
def command_action(tid, role, cmd_var, command):
|
|
return {
|
|
"type": "action",
|
|
"id": stable_id(tid, role),
|
|
"definitionId": "custom_variable_set_value",
|
|
"connectionId": "internal",
|
|
"options": {
|
|
"name": prop(cmd_var),
|
|
# Plain text, not an expression: Companion fills in $(custom:...) as text,
|
|
# so this appends (two quick presses are both seen). In a Companion 5
|
|
# expression '+' is numeric and 'a' + 'b' gives NaN.
|
|
"value": prop("$(custom:%s);%s" % (cmd_var, command)),
|
|
},
|
|
"children": {},
|
|
}
|
|
|
|
|
|
def set_page_action(tid, role, page):
|
|
return {"type": "action", "id": stable_id(tid, role), "definitionId": "set_page", "connectionId": "internal",
|
|
"options": {"surfaceId": prop("self"), "page": prop(page)}, "children": {}}
|
|
|
|
|
|
def static_button(tid, text, bg, fg, page, note, marker):
|
|
"""A plain key (no watchdog state): tap goes to `page`. `marker` names it as ours in its notes."""
|
|
geometry = {"enabled": prop(True), "opacity": prop(100), "x": prop(0), "y": prop(0),
|
|
"width": prop(100), "height": prop(100), "rotation": prop(0)}
|
|
layers = [
|
|
{"id": "canvas", "name": "Canvas", "usage": "auto", "type": "canvas",
|
|
"decoration": prop("border"), "showStatusIcons": prop("default")},
|
|
dict({"id": "box0", "name": "Background", "usage": "auto", "type": "box"}, **geometry,
|
|
color=prop(bg), borderWidth=prop(0), borderColor=prop(0), borderPosition=prop("inside")),
|
|
dict({"id": "text0", "name": "Text", "usage": "auto", "type": "text"}, **geometry,
|
|
text=prop(text), color=prop(fg), halign=prop("center"), valign=prop("center"),
|
|
fontsize=prop("auto"), fontsizeAllowShrink=prop(True), font=prop("companion-sans"),
|
|
outlineColor=prop(4278190080)),
|
|
]
|
|
return {
|
|
"type": "button-layered",
|
|
"style": {"layers": layers},
|
|
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False,
|
|
"canModifyStyleInApis": False, "notes": "%s - %s" % (marker, note)},
|
|
"feedbacks": [],
|
|
"steps": {"0": {"action_sets": {"down": [], "up": [set_page_action(tid, "go", page)]},
|
|
"options": {"runWhileHeld": []}}},
|
|
"localVariables": [],
|
|
}
|
|
|
|
|
|
def restart_key(key, cfg):
|
|
"""A Restart page key: drawn by the watchdog like a tile; hold = "force <id>"."""
|
|
names, opts = cfg["variables"], settings(cfg)
|
|
hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0)
|
|
stale_text = "'%s\\n?'" % key["label"]
|
|
return {
|
|
"type": "button-layered",
|
|
"style": {"layers": layers(key["id"], key, names, stale_text, opts)},
|
|
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False,
|
|
"canModifyStyleInApis": True,
|
|
"notes": "RESTART key '%s' - hold %s s: restart it even when the status page says it is fine "
|
|
"(status_panel.py on %s runs fix '%s')." % (key["id"], secs, opts["ingest"], key["fix"])},
|
|
"feedbacks": [],
|
|
"steps": {"0": {"action_sets": {"down": [], "up": [],
|
|
str(hold_ms): [command_action(key["id"], "hold", names["command"],
|
|
"force %s $(internal:time_unix)" % key["id"])]},
|
|
"options": {"runWhileHeld": [hold_ms]}}},
|
|
"localVariables": [],
|
|
}
|
|
|
|
|
|
def button(tid, tile, cfg):
|
|
names, opts = cfg["variables"], settings(cfg)
|
|
hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0)
|
|
kind = tile.get("kind")
|
|
label = tile["label"]
|
|
rp = cfg.get("ready_page") or {}
|
|
on_ready_page = any(k["id"] == tid for k in rp.get("keys", []))
|
|
go = None
|
|
if kind == "ready" and not on_ready_page:
|
|
stale_text = "'%s\\n?'" % label
|
|
press, hold, go = None, "restart " + tid, rp.get("page")
|
|
note = ("Tap: open the READY page (page %s). Hold %s s: fix everything on it that can be fixed "
|
|
"(re-sync the lineup, the cross background, look again)." % (rp.get("page"), secs))
|
|
elif kind == "ready":
|
|
stale_text = "'%s\\n?'" % label
|
|
press, hold = "ack " + tid, "restart " + tid
|
|
note = "Press: stop the blinking. Hold %s s: fix everything on this page that can be fixed." % secs
|
|
elif any(c["type"] == "ready_manual" for c in tile.get("checks", [])):
|
|
stale_text = "'%s\\n?'" % label
|
|
press, hold = "done " + tid, None
|
|
note = "Tap: done for this week (tap again to take it back). Resets every %s." % (
|
|
(cfg.get("ready") or {}).get("reset_day", "mon").capitalize())
|
|
elif kind == "master":
|
|
# Template string: '+' would add numbers, not join text (see above).
|
|
stale_text = "`${'WATCHDOG\\nDOWN\\n'}${$(internal:instance_errors)} ERR`"
|
|
press, hold = "ackall", None
|
|
note = "Press: acknowledge every alarm."
|
|
elif kind == "service":
|
|
stale_text = "'%s\\n?'" % label
|
|
press, hold = "service toggle", None
|
|
note = "Press: turn service mode on/off until the next scheduled window starts or ends."
|
|
else:
|
|
stale_text = "'%s\\n?'" % label
|
|
press = "ack " + tid
|
|
fixable = "members" in tile or any((c["type"] in RESTARTABLE and c.get("conn")) or c.get("fix")
|
|
for c in tile.get("checks", []))
|
|
hold = ("restart " + tid) if fixable or tile.get("hold_always") or kind == "network" else None
|
|
if kind == "network":
|
|
minutes = cfg.get("network", {}).get("remember_seconds", 300) // 60
|
|
note = ("Press: stop the flashing. Hold %s s: clear a network blip (it also clears itself after %d minutes)."
|
|
% (secs, minutes))
|
|
elif tile.get("hold_always"):
|
|
note = "Press: acknowledge. Hold %s s: %s." % (secs, cfg["fixes"][tile["hold_always"]]["label"])
|
|
else:
|
|
note = "Press: acknowledge." + (" Hold %s s: fix what this button is failing on (nothing if it is green)."
|
|
% secs if hold else "")
|
|
action_sets = {"down": [], "up": [set_page_action(tid, "go", go) if go else command_action(tid, "press", names["command"], press)]}
|
|
run_while_held = []
|
|
if hold:
|
|
# Stamped with Companion's clock, so a hold made while the watchdog was down is dropped later.
|
|
action_sets[str(hold_ms)] = [command_action(tid, "hold", names["command"], hold + " $(internal:time_unix)")]
|
|
run_while_held = [hold_ms]
|
|
return {
|
|
"type": "button-layered",
|
|
"style": {"layers": layers(tid, tile, names, stale_text, opts)},
|
|
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False,
|
|
"canModifyStyleInApis": True,
|
|
"notes": "STATUS tile '%s' - drawn by status_panel.py on %s. %s" % (tid, opts["ingest"], note)},
|
|
"feedbacks": [],
|
|
"steps": {"0": {"action_sets": action_sets, "options": {"runWhileHeld": run_while_held}}},
|
|
"localVariables": [],
|
|
}
|
|
|
|
|
|
NAV_LOCATIONS = {"pageup": ("0", "0"), "pagenum": ("1", "0"), "pagedown": ("2", "0")}
|
|
NAV_MARKER = "STATUS nav key"
|
|
|
|
|
|
def nav_templates(cfg):
|
|
"""Companion's own nav key designs for the version in use: companion-objects/nav_templates/<major.minor>.json
|
|
when that exists, else this folder's nav_templates.json (captured from 5.0.7)."""
|
|
version = ".".join(str(settings(cfg)["build"]).split("+")[0].split(".")[:2])
|
|
for path in (os.path.join(HERE, "companion-objects", "nav_templates", version + ".json"),
|
|
os.path.join(HERE, "nav_templates.json")):
|
|
if os.path.exists(path):
|
|
return json.load(open(path))
|
|
raise FileNotFoundError("no nav key templates for Companion %s" % version)
|
|
|
|
|
|
def nav_key(kind, names, status_page=None, hold_ms=1000, tap_page=1, cfg=None):
|
|
"""Companion's own UP / page number / DOWN key, coloured like the ALL button.
|
|
|
|
For the page number key, status_page adds: tap = go to tap_page (on release,
|
|
so a hold does not flash through it first), hold for hold_ms = go to the
|
|
status page. The tap names the page outright: the converted key's "startup"
|
|
means the current page on surfaces set to "use last page".
|
|
"""
|
|
cfg = cfg or {"variables": names}
|
|
opts = settings(cfg)
|
|
key = json.loads(json.dumps(nav_templates(cfg)[kind]))
|
|
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], opts["stale"])
|
|
pick = lambda field: "jsonpath($(custom:%s), '$.all.%s')" % (names["state"], field)
|
|
bg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, PURPLE, opts["flash"], pick("b"), pick("b2"))
|
|
fg = "%s ? %d : ($(custom:%s) == 1 ? %s : %s)" % (stale, 0xFFFFFF, opts["flash"], pick("f"), pick("f2"))
|
|
|
|
def paint(layers):
|
|
for layer in layers:
|
|
if layer["type"] == "box":
|
|
layer["color"] = prop(bg, True)
|
|
elif layer["type"] == "text":
|
|
layer["color"] = prop(fg, True)
|
|
elif layer["type"] == "line":
|
|
layer["borderColor"] = prop(fg, True)
|
|
paint(layer.get("children", []))
|
|
paint(key["style"]["layers"])
|
|
if kind == "pagenum" and status_page:
|
|
step = key["steps"]["0"]
|
|
tap = step["action_sets"]["down"]
|
|
tap[0]["options"]["page"] = prop(tap_page)
|
|
hold = [{"type": "action", "id": stable_id("nav", "hold"), "definitionId": "set_page",
|
|
"connectionId": "internal",
|
|
"options": {"surfaceId": prop("self"), "page": prop(status_page)}, "children": {}}]
|
|
step["action_sets"] = {"down": [], "up": tap, str(hold_ms): hold}
|
|
step["options"]["runWhileHeld"] = [hold_ms]
|
|
key["options"]["notes"] = ("%s (%s): colour follows the ALL button on the status page. "
|
|
"Undo on every page with: python3 nav_keys.py --revert" % (NAV_MARKER, kind))
|
|
return key
|
|
|
|
|
|
def nav_controls(cfg):
|
|
controls = {}
|
|
coloured = cfg.get("nav_keys", list(NAV_LOCATIONS)) # which nav keys follow the ALL button
|
|
for kind, (r, c) in NAV_LOCATIONS.items():
|
|
controls.setdefault(r, {})[c] = (nav_key(kind, cfg["variables"], cfg["page"], cfg.get("nav_hold_ms", 1000),
|
|
cfg.get("nav_tap_page", 1), cfg)
|
|
if kind in coloured else {"type": kind})
|
|
return controls
|
|
|
|
|
|
def place(controls, pos, control):
|
|
r, c = (str(n) for n in pos)
|
|
if controls.get(r, {}).get(c):
|
|
raise ValueError("two buttons at row %s column %s" % (r, c))
|
|
controls.setdefault(r, {})[c] = control
|
|
|
|
|
|
def page_export(name, number, controls, cfg=None):
|
|
opts = settings(cfg or {})
|
|
return {
|
|
"version": opts["export_version"],
|
|
"type": "page",
|
|
"companionBuild": opts["build"],
|
|
"page": {"id": stable_id("page", name), "name": name, "controls": controls, "gridSize": opts["grid"]},
|
|
"instances": {},
|
|
"connectionCollections": [],
|
|
"oldPageNumber": number,
|
|
"imageLibrary": [],
|
|
"imageLibraryCollections": [],
|
|
}
|
|
|
|
|
|
def build(cfg):
|
|
controls = nav_controls(cfg)
|
|
for tile in cfg["tiles"]:
|
|
if "pos" in tile:
|
|
place(controls, tile["pos"], button(tile["id"], tile, cfg))
|
|
rp = cfg.get("restart_page")
|
|
if rp:
|
|
place(controls, rp["jump_pos"], static_button("restart_jump", "RESTART\\nPAGE", 0x37474F, 0xFFFFFF,
|
|
rp["page"], "Tap: open the Restart page (page %d)." % rp["page"],
|
|
"STATUS jump key"))
|
|
return page_export(cfg["page_name"], cfg["page"], controls, cfg)
|
|
|
|
|
|
def build_restart(cfg):
|
|
rp = cfg["restart_page"]
|
|
controls = nav_controls(cfg)
|
|
place(controls, rp["back_pos"], static_button("restart_back", "STATUS\\nPAGE", 0x37474F, 0xFFFFFF, cfg["page"],
|
|
"Tap: back to the status page (page %d)." % cfg["page"],
|
|
"RESTART back key"))
|
|
for key in rp["keys"]:
|
|
place(controls, key["pos"], restart_key(key, cfg))
|
|
return page_export(rp["page_name"], rp["page"], controls, cfg)
|
|
|
|
|
|
def build_ready(cfg):
|
|
"""The READY page: the weekly checklist, one key per line, drawn by the watchdog like the Status page."""
|
|
rp = cfg["ready_page"]
|
|
controls = nav_controls(cfg)
|
|
place(controls, rp["back_pos"], static_button("ready_back", "STATUS\\nPAGE", 0x37474F, 0xFFFFFF, cfg["page"],
|
|
"Tap: back to the status page (page %d)." % cfg["page"],
|
|
"READY back key"))
|
|
for key in ready_keys(cfg):
|
|
place(controls, key["pos"], button(key["id"], key, cfg))
|
|
return page_export(rp["page_name"], rp["page"], controls, cfg)
|
|
|
|
|
|
def write(path, export):
|
|
with gzip.open(path, "wb") as f: # Companion's own exports are gzipped JSON
|
|
f.write(json.dumps(export, indent=1).encode())
|
|
|
|
|
|
def main(argv=None):
|
|
import argparse
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--config", default=os.path.join(HERE, "config.json"))
|
|
ap.add_argument("--out", help="folder for the page files (default: this folder)")
|
|
ap.add_argument("status_file", nargs="?", help="write only the status page, to this file")
|
|
args = ap.parse_args(argv)
|
|
cfg = load_config(args.config)
|
|
folder = args.out or HERE
|
|
out = args.status_file or os.path.join(folder, "status-page.companionconfig")
|
|
write(out, build(cfg))
|
|
print("wrote %s (%d buttons)" % (out, sum(1 for t in cfg["tiles"] if "pos" in t)))
|
|
if cfg.get("restart_page") and not args.status_file:
|
|
out = os.path.join(folder, "restart-page.companionconfig")
|
|
write(out, build_restart(cfg))
|
|
print("wrote %s (%d restart keys)" % (out, len(cfg["restart_page"]["keys"])))
|
|
if cfg.get("ready_page") and not args.status_file:
|
|
out = os.path.join(folder, "ready-page.companionconfig")
|
|
write(out, build_ready(cfg))
|
|
print("wrote %s (%d READY keys)" % (out, len(ready_keys(cfg))))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|