Files
status-indicators/make_page.py
T

307 lines
14 KiB
Python
Raw Normal View History

#!/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)
python3 make_page.py -> status-page.companionconfig and restart-page.companionconfig
"""
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 # noqa: E402
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
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):
state, hb = names["state"], names["heartbeat"]
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (hb, STALE_SECONDS)
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:flash_tick) == 1 ? %s : %s)" % (stale, PURPLE, b_on, b_off)
fg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, 0xFFFFFF, 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):
"""A plain key (no watchdog state): tap goes to `page`."""
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": 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 2 s = "force <id>"."""
names = cfg["variables"]
stale_text = "'%s\\n?'" % key["label"]
return {
"type": "button-layered",
"style": {"layers": layers(key["id"], key, names, stale_text)},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False,
"canModifyStyleInApis": True,
"notes": "RESTART key '%s' - hold 2 s: restart it even when the status page says it is fine "
"(status_panel.py on NA Ingest runs fix '%s')." % (key["id"], 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 = cfg["variables"]
kind = tile.get("kind")
label = tile["label"]
if 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") else None
if tile.get("hold_always"):
note = "Press: acknowledge. Hold 2 s: %s." % cfg["fixes"][tile["hold_always"]]["label"]
else:
note = "Press: acknowledge." + (" Hold 2 s: fix what this button is failing on (nothing if it is green)."
if hold else "")
action_sets = {"down": [], "up": [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)},
"options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False,
"canModifyStyleInApis": True,
"notes": "STATUS tile '%s' - drawn by status_panel.py on NA Ingest. %s" % (tid, 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_key(kind, names, status_page=None, hold_ms=1000, tap_page=1):
"""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".
"""
templates = json.load(open(os.path.join(HERE, "nav_templates.json")))
key = json.loads(json.dumps(templates[kind]))
stale = "$(internal:time_unix) - $(custom:%s) > %d" % (names["heartbeat"], STALE_SECONDS)
pick = lambda field: "jsonpath($(custom:%s), '$.all.%s')" % (names["state"], field)
bg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, PURPLE, pick("b"), pick("b2"))
fg = "%s ? %d : ($(custom:flash_tick) == 1 ? %s : %s)" % (stale, 0xFFFFFF, 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))
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):
return {
"version": EXPORT_VERSION,
"type": "page",
"companionBuild": COMPANION_BUILD,
"page": {"id": stable_id("page", name), "name": name, "controls": controls, "gridSize": 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"]))
return page_export(cfg["page_name"], cfg["page"], controls)
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"]))
for key in rp["keys"]:
place(controls, key["pos"], restart_key(key, cfg))
return page_export(rp["page_name"], rp["page"], controls)
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():
cfg = load_config(os.path.join(HERE, "config.json"))
out = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "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 len(sys.argv) <= 1:
out = os.path.join(HERE, "restart-page.companionconfig")
write(out, build_restart(cfg))
print("wrote %s (%d restart keys)" % (out, len(cfg["restart_page"]["keys"])))
if __name__ == "__main__":
main()