Files
christianandClaude Opus 5.5 d5ede0c91e READY page: the weekly worship checklist, checked against Planning Center
- 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>
2026-10-14 20:28:43 -04:00

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()