#!/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 (the master acks everything) hold 2 seconds restart (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": [], } 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 build(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}) 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()