169 lines
7.2 KiB
Python
169 lines
7.2 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> (restarts that tile's Companion connections)
|
||
|
|
|
||
|
|
python3 make_page.py -> status-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 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
|
||
|
|
restartable = any(c["type"] in RESTARTABLE and c.get("conn") for c in tile.get("checks", []))
|
||
|
|
hold = ("restart " + tid) if restartable else None
|
||
|
|
note = "Press: acknowledge." + (" Hold 2 s: restart this tile's Companion connection(s)." if hold else "")
|
||
|
|
action_sets = {"down": [], "up": [command_action(tid, "press", names["command"], press)]}
|
||
|
|
run_while_held = []
|
||
|
|
if hold:
|
||
|
|
action_sets[str(HOLD_MS)] = [command_action(tid, "hold", names["command"], hold)]
|
||
|
|
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": [],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def build(cfg):
|
||
|
|
controls = {"0": {"0": {"type": "pageup"}}, "1": {"0": {"type": "pagenum"}}, "2": {"0": {"type": "pagedown"}}}
|
||
|
|
for tile in cfg["tiles"]:
|
||
|
|
if "pos" not in tile:
|
||
|
|
continue
|
||
|
|
r, c = (str(n) for n in tile["pos"])
|
||
|
|
if controls.get(r, {}).get(c):
|
||
|
|
raise ValueError("two buttons at row %s column %s" % (r, c))
|
||
|
|
controls.setdefault(r, {})[c] = button(tile["id"], tile, cfg)
|
||
|
|
return {
|
||
|
|
"version": EXPORT_VERSION,
|
||
|
|
"type": "page",
|
||
|
|
"companionBuild": COMPANION_BUILD,
|
||
|
|
"page": {"id": stable_id("page", cfg["page_name"]), "name": cfg["page_name"],
|
||
|
|
"controls": controls, "gridSize": GRID},
|
||
|
|
"instances": {},
|
||
|
|
"connectionCollections": [],
|
||
|
|
"oldPageNumber": cfg["page"],
|
||
|
|
"imageLibrary": [],
|
||
|
|
"imageLibraryCollections": [],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
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")
|
||
|
|
data = json.dumps(build(cfg), indent=1).encode()
|
||
|
|
with gzip.open(out, "wb") as f: # Companion's own exports are gzipped JSON
|
||
|
|
f.write(data)
|
||
|
|
print("wrote %s (%d buttons)" % (out, sum(1 for t in cfg["tiles"] if "pos" in t)))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|