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>
This commit is contained in:
+168
@@ -0,0 +1,168 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user