Files
status-indicators/make_page.py
T
christianandClaude Opus 5.5 246500f965 Add the Companion status page and its watchdog for NA Ingest.
status_panel.py runs on NA Ingest as a LaunchAgent. Every second it reads
Companion's connection statuses and module variables, adds what Companion
cannot see (network MIDI peers, Bonjour sessions, pings, launchd services,
disk, Resi cloud, the Bible TV app), and writes one JSON custom variable that
page 19 renders. Buttons acknowledge alarms, mute them, and restart a stuck
Companion connection on a two second hold. If the watchdog stops, the page
turns purple instead of freezing on stale green.

make_page.py builds the page export from config.json; tests/ runs offline
against a fake Companion and a CoreMIDI loopback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:00:56 -04:00

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()