Muted, nothing fixes itself at all: no Spotify exception (while_muted is gone) and no any-hour READY fix; holds still fix and the Sunday stream-audio failover still switches. The key reads ALARMS OFF / AUTO-FIX OFF / TIL 12AM, and its note says both. The settings page's switch says the same. The mute's end is worked out at the press and kept: a watchdog that was not running at 23:59 no longer leaves the alarms muted until the next schedule change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
389 lines
20 KiB
Python
389 lines
20 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 DAYS, 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
|
|
sw = cfg.get("service_windows") or []
|
|
always = len(sw) == 1 and set(sw[0].get("days", [])) == set(DAYS) and sw[0].get("start") == "00:00" and sw[0].get("end") in ("23:59", "00:00")
|
|
note = ("Press: silence the alarms AND stop every auto-fix; press again to turn both back on. They come back by "
|
|
"themselves %s. Holds still fix. The stream-audio failover still switches by itself on Sundays."
|
|
% ("at midnight" if always else "when the alarm schedule next 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()
|