#!/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 (fixes what that tile is failing on) Restart page force (hold 2 s: restart that thing even when it looks fine) python3 make_page.py -> status-page.companionconfig and restart-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 # 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") 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 ".""" 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"] 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") 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": [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/.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 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 __name__ == "__main__": main()