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:
2026-10-08 09:00:56 -04:00
co-authored by Claude Opus 5.5
commit 246500f965
9 changed files with 2481 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.DS_Store
+122
View File
@@ -0,0 +1,122 @@
# Status Indicators — Companion STATUS page for NA Ingest
One Companion page (page 19, **"19 STATUS"**) that is solid green when everything is fine and **blinks red with the actual problem** ("HD2 NO DISK", "LIGHT PC MIDI LOST") when something needs attention.
There are two parts:
- **`status_panel.py`** — a watchdog that runs on NA Ingest as the LaunchAgent `org.waterschurch.status-panel`. Every second it reads Companion's connection statuses and module variables. It also checks what Companion can't see: network MIDI peers, Bonjour MIDI sessions, pings, launchd services, disk space, and whether the Resi cloud is reachable. It writes the result into one Companion custom variable.
- **`status-page.companionconfig`** — the page. Every button just draws what the watchdog wrote and blinks on your existing `flash_tick` trigger.
## Layout (Stream Deck XL; MK.2 and Mini show the top-left corner)
| | col 1 | col 2 | col 3 | col 4 | col 5 | col 6 | col 7 |
|--------|-------|-------|-------|-------|-------|-------|-------|
| row 0 | **ALL** | REC | AUDIO | RF | SHOW | VIDEO | STREAM |
| row 1 | HD1 | HD2 | HD3 | HD4 | HD5 | HD6 | TASCAM |
| row 2 | X32 | DANTE | TRACKS | LIGHT PC | RACK (SuperRack) | BIBLE TV | PROP (ProPresenter) |
| row 3 | ATEM | OBS | RESI | NET | INGEST | LED | ARENA |
Column 0 holds page up / page number / page down, plus **SERVICE** at row 3.
Row 0 holds the group tiles. Each one shows the first problem in its group, plus "+N" if there are more. RF covers all six Shure units: AD4Q ×2, ULXD4, and PSM1000 ×3.
## Colours
| Tile | Meaning |
|---|---|
| Green | OK. The second line shows something useful, like `28h` of record time or `10/10` Dante devices. |
| **Blinking red** | Show-stopping, during service mode. |
| Solid dark red | Acknowledged. It stays red until the problem clears, then goes green. A new failure blinks again. |
| Amber | Warning: low disk, battery at 2 bars, RF interference, a device that keeps dropping out ("WING GONE x3"), a network blip in the last 3 minutes ("BLIP 10:21"). |
| Dim red, not blinking | A fault outside service mode. |
| Grey | Expected off outside service: OBS, a HyperDeck with no disk, Bible TV. |
| **Purple** | The watchdog has stopped. The master shows `WATCHDOG DOWN` and Companion's own error count. |
## Buttons
- **Press any tile:** acknowledge its alarms (stops the blinking). Press **ALL** to acknowledge everything.
- **Hold a device tile for 2 seconds:** restart that tile's *Companion connection*. This is the fix for a connection stuck after a network blip. It only restarts Companion's side and never reboots the device.
- **SERVICE:** service mode turns on automatically **Sunday 07:00–12:30** (`service_windows` in `config.json`). Press SERVICE to switch it on or off by hand, for example for a Wednesday event. The manual setting lasts until the next scheduled start or end.
## What each tile checks
| Tile | Checks |
|---|---|
| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't |
| | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. |
| TASCAM | connection; the recorder's own Error / Caution |
| X32, ATEM, ARENA, LED, OBS | Companion connection |
| DANTE | 10 key devices present (DiGiCo DMIs, WING, DQ rack, AD4Qs, Studio A X32 / Resi AJA / Ingest, Translation) |
| TRACKS | NA-Tracks Dante device; tracks Mac pings; "NA Tracks" MIDI session still on the network; Ableton OSC connection |
| LIGHT PC | Lighting PC still joined to NA Ingest's network MIDI session; its MIDI session still on the network |
| RACK | SuperRack-LiveBox still joined to the MIDI session; its Dante device |
| BIBLE TV | TV pings; OSC connection |
| PROP | ProPresenter API; NA-ProPresenter-Mac Dante device |
| RESI | decoder (RossTalk); resi-studio-cues; Resi cloud reachable |
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21) |
| INGEST | Resi bridge and lobby captions services running; ProdDash running; free disk |
| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted |
A problem has to last 3 seconds before it shows, so one bad reading doesn't trigger an alarm. Recovery shows immediately.
## One-time Companion setup
1. **Variables → Custom Variables:** add three variables. Leave the start values empty and "persist" off:
- `sp_state`
- `sp_hb`
- `sp_cmd`
2. **Import / Export → Import:** choose `status-page.companionconfig` (it's on NA Ingest's Desktop) and import it into **page 19**. Page 19 is empty today.
3. Make sure the **flash_tick_500** trigger stays enabled. It's the blink clock.
## Changing things
This folder is the source of truth. Edit `config.json` here, then copy the folder to NA Ingest:
```bash
scp status_panel.py coremidi.py config.json install.sh checklist-ingest:status-panel-install/
```
Then run the installer there. It backs up the old config and restarts the watchdog:
```bash
ssh checklist-ingest 'zsh ~/status-panel-install/install.sh'
```
If you add or move tiles, also rebuild the page and import it into page 19 again:
```bash
python3 make_page.py
```
Other commands:
```bash
python3 status_panel.py --once # poll once and print every tile; writes nothing
python3 -m unittest discover -s tests # offline tests, no Companion or gear needed
zsh install.sh --remove # stop the watchdog
```
The log is `~/Library/Logs/status-panel.log` on NA Ingest. Every fault and clear is written there with a timestamp, which is handy for a post-service review.
## Optional: see what the tracks send to the lights
The TRACKS tile can show when the tracks rig last sent MIDI ("TRK MIDI 3s"). For that, NA Ingest has to *listen* to the "NA Tracks" session. Use a **separate** session so nothing Companion sends can reach Ableton:
1. On NA Ingest, open **Audio MIDI Setup → Window → Show MIDI Studio → Network** (the globe icon).
2. Under **My Sessions**, press **+**, rename the new session to `NA Ingest Listen`, and tick it to enable it.
3. With `NA Ingest Listen` selected, choose **NA Tracks** in the **Directory** and press **Connect**.
The watchdog picks it up within about 10 seconds. Until then that line just stays hidden.
The **LIGHT PC** tile can do the same in the other direction. If the lighting software can send a heartbeat note back to NA Ingest every few seconds, set `"max_silence_seconds": 10` on the LIGHT PC `midi_rx` check. The tile then goes amber if the lighting PC stops answering during service.
## Files
| File | Purpose |
|---|---|
| `status_panel.py` | the watchdog |
| `coremidi.py` | reads network MIDI sessions and listens to MIDI sources (ctypes, no extra installs) |
| `config.json` | tiles, positions, checks, service windows |
| `make_page.py` | builds `status-page.companionconfig` from `config.json` |
| `install.sh` | installs or updates the LaunchAgent on NA Ingest |
| `tests/` | offline tests, including a fake Companion and a MIDI loopback |
+99
View File
@@ -0,0 +1,99 @@
{
"companion": { "host": "127.0.0.1", "port": 8000 },
"page": 19,
"page_name": "19\\nStatus",
"variables": { "state": "sp_state", "heartbeat": "sp_hb", "command": "sp_cmd" },
"service_windows": [
{ "days": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], "start": "00:00", "end": "23:59" }
],
"debounce_seconds": 3,
"flap": { "count": 3, "window_seconds": 600 },
"network": {
"interface": "en0",
"gateway": "10.1.0.1",
"burst_connections": 4,
"burst_groups": 3,
"burst_window_seconds": 10,
"remember_seconds": 180
},
"alert_webhook": "",
"tiles": [
{ "id": "all", "label": "ALL", "pos": [0, 1], "kind": "master" },
{ "id": "rec", "label": "REC", "pos": [0, 2], "members": ["hd1", "hd2", "hd3", "hd4", "hd5", "hd6", "tascam"] },
{ "id": "audio", "label": "AUDIO", "pos": [0, 3], "members": ["x32", "dante", "superrack"] },
{ "id": "rf", "label": "RF", "pos": [0, 4], "members": ["axient1", "axient2", "ulxd", "psm1", "psm2", "psm3"] },
{ "id": "show", "label": "SHOW", "pos": [0, 5], "members": ["propres", "lightpc", "tracks", "bibletv"] },
{ "id": "video", "label": "VIDEO", "pos": [0, 6], "members": ["atem", "led", "arena"] },
{ "id": "stream", "label": "STREAM", "pos": [0, 7], "members": ["obs", "resi"] },
{ "id": "hd1", "label": "HD1", "pos": [1, 1], "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_1" }] },
{ "id": "hd2", "label": "HD2", "pos": [1, 2], "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_2", "optional": true }] },
{ "id": "hd3", "label": "HD3", "pos": [1, 3], "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_3" }] },
{ "id": "hd4", "label": "HD4", "pos": [1, 4], "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_4", "optional": true }] },
{ "id": "hd5", "label": "HD5", "pos": [1, 5], "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_5" }] },
{ "id": "hd6", "label": "HD6", "pos": [1, 6], "checks": [{ "type": "hyperdeck", "conn": "HyperDeck_6" }] },
{ "id": "tascam", "label": "TASCAM", "pos": [1, 7], "checks": [{ "type": "tascam", "conn": "tascam" }] },
{ "id": "x32", "label": "X32", "pos": [2, 1], "checks": [{ "type": "conn", "conn": "x32" }] },
{ "id": "dante", "label": "DANTE", "pos": [2, 2], "checks": [
{ "type": "dante", "conn": "audinate-dantecontroller", "devices": {
"FOH-DiGiCo-DMI-1": "DMI-1", "FOH-DiGiCo-DMI-2": "DMI-2", "BOH-WING": "WING", "BOH-DQ-Rack": "DQ RACK",
"BOH-AD4Q-A": "AD4Q-A", "BOH-AD4Q-B": "AD4Q-B", "STUDIO-A-X32": "STA X32",
"STUDIO-A-Resi-AJA-SDI": "RESI AJA", "STUDIO-A-Ingest": "INGEST", "NA-Translation": "TRANSL" } }
] },
{ "id": "tracks", "label": "TRACKS", "pos": [2, 3], "checks": [
{ "type": "dante", "conn": "audinate-dantecontroller", "devices": { "NA-Tracks": "DANTE" } },
{ "type": "ping", "host": "10.1.3.227", "name": "MAC" },
{ "type": "mdns", "instance": "NA Tracks", "name": "MIDI SESS" },
{ "type": "conn", "conn": "ableton" },
{ "type": "midi_rx", "source": "Network NA Ingest Listen", "name": "TRK MIDI" }
] },
{ "id": "lightpc", "label": "LIGHT PC", "pos": [2, 4], "checks": [
{ "type": "midi_peer", "session": "NA Ingest", "peer": "Lighting PC" },
{ "type": "mdns", "instance": "Lighting PC", "name": "MIDI SESS" },
{ "type": "midi_rx", "source": "Network NA Ingest", "name": "MIDI IN" }
] },
{ "id": "superrack", "label": "RACK", "pos": [2, 5], "checks": [
{ "type": "midi_peer", "session": "NA Ingest", "peer": "SuperRack-LiveBox" },
{ "type": "dante", "conn": "audinate-dantecontroller", "devices": { "FOH-SuperRack-LiveBox": "DANTE" } }
] },
{ "id": "bibletv", "label": "BIBLE TV", "pos": [2, 6], "checks": [
{ "type": "http", "url": "http://10.6.7.13:8080/", "msg": "APP DOWN", "interval_seconds": 5 },
{ "type": "conn", "conn": "BibleTV" }
] },
{ "id": "propres", "label": "PROP", "pos": [2, 7], "checks": [
{ "type": "conn", "conn": "Propresenter-API" },
{ "type": "dante", "conn": "audinate-dantecontroller", "devices": { "NA-ProPresenter-Mac": "DANTE" } }
] },
{ "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" },
{ "id": "atem", "label": "ATEM", "pos": [3, 1], "checks": [{ "type": "conn", "conn": "ATEM" }] },
{ "id": "obs", "label": "OBS", "pos": [3, 2], "checks": [{ "type": "conn", "conn": "OBS-Youtube", "service_only": true }] },
{ "id": "resi", "label": "RESI", "pos": [3, 3], "checks": [
{ "type": "conn", "conn": "RESI_Decoder" },
{ "type": "conn", "conn": "resi-studio-cues" },
{ "type": "http", "url": "https://central.resi.io/", "name": "CLOUD" }
] },
{ "id": "net", "label": "NET", "pos": [3, 4], "kind": "network" },
{ "id": "ingest", "label": "INGEST", "pos": [3, 5], "checks": [
{ "type": "launchd", "label": "org.waterschurch.resi-propresenter", "name": "BRIDGE" },
{ "type": "launchd", "label": "org.waterschurch.lobby-captions", "name": "CAPTIONS" },
{ "type": "process", "match": "/Applications/ProdDash.app/Contents/MacOS/ProdDash", "name": "PRODDASH" },
{ "type": "disk", "path": "/", "warn_gb": 25, "err_gb": 8 }
] },
{ "id": "led", "label": "LED", "pos": [3, 6], "checks": [{ "type": "conn", "conn": "VX1000" }, { "type": "conn", "conn": "UHDJr" }] },
{ "id": "arena", "label": "ARENA", "pos": [3, 7], "checks": [{ "type": "conn", "conn": "arena" }] },
{ "id": "axient1", "label": "AXIENT 1", "checks": [{ "type": "shure", "conn": "Axient_1", "channels": 4 }] },
{ "id": "axient2", "label": "AXIENT 2", "checks": [{ "type": "shure", "conn": "Axient_2", "channels": 4 }] },
{ "id": "ulxd", "label": "ULXD", "checks": [{ "type": "shure", "conn": "ULXD-4", "channels": 1 }] },
{ "id": "psm1", "label": "PSM 1", "checks": [{ "type": "psm", "conn": "PSM_1", "channels": 2 }] },
{ "id": "psm2", "label": "PSM 2", "checks": [{ "type": "psm", "conn": "PSM_2", "channels": 2 }] },
{ "id": "psm3", "label": "PSM 3", "checks": [{ "type": "psm", "conn": "PSM_3", "channels": 2 }] }
]
}
+262
View File
@@ -0,0 +1,262 @@
"""Minimal CoreMIDI access through ctypes (no PyObjC, stdlib only).
Two things the status panel needs that Companion cannot see:
* sessions() - the live Network MIDI (RTP-MIDI) sessions on this Mac and the
peers currently joined to each, read from the network driver's
"apple.midirtp.session" entity property.
* Listener - timestamps the last message that arrived on a named MIDI
source, so a tile can say "last MIDI 3s ago".
"""
import ctypes
import ctypes.util
import plistlib
import threading
import time
_cm = ctypes.CDLL(ctypes.util.find_library("CoreMIDI"))
_cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation"))
_U32 = ctypes.c_uint32
_UTF8 = 0x08000100
_cm.MIDIGetNumberOfDevices.restype = ctypes.c_ulong
_cm.MIDIGetDevice.restype = _U32
_cm.MIDIGetDevice.argtypes = [ctypes.c_ulong]
_cm.MIDIDeviceGetNumberOfEntities.restype = ctypes.c_ulong
_cm.MIDIDeviceGetNumberOfEntities.argtypes = [_U32]
_cm.MIDIDeviceGetEntity.restype = _U32
_cm.MIDIDeviceGetEntity.argtypes = [_U32, ctypes.c_ulong]
_cm.MIDIGetNumberOfSources.restype = ctypes.c_ulong
_cm.MIDIGetSource.restype = _U32
_cm.MIDIGetSource.argtypes = [ctypes.c_ulong]
_cm.MIDIObjectGetStringProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
_cm.MIDIObjectGetDictionaryProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
_cm.MIDIObjectGetIntegerProperty.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32)]
_cf.CFStringCreateWithCString.restype = ctypes.c_void_p
_cf.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, _U32]
_cf.CFStringGetCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_long, _U32]
_cf.CFRelease.argtypes = [ctypes.c_void_p]
_cf.CFPropertyListCreateData.restype = ctypes.c_void_p
_cf.CFPropertyListCreateData.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, ctypes.c_ulong, ctypes.c_void_p]
_cf.CFDataGetLength.restype = ctypes.c_long
_cf.CFDataGetLength.argtypes = [ctypes.c_void_p]
_cf.CFDataGetBytePtr.restype = ctypes.c_void_p
_cf.CFDataGetBytePtr.argtypes = [ctypes.c_void_p]
# One lock around every CoreMIDI call: concurrent first calls from two threads
# (session reads vs. MIDIClientCreate) intermittently fail with -2.
_lock = threading.RLock()
def _cfstr(s):
return _cf.CFStringCreateWithCString(None, s.encode("utf-8"), _UTF8)
def _string_prop(obj, key):
out = ctypes.c_void_p()
k = _cfstr(key)
err = _cm.MIDIObjectGetStringProperty(obj, k, ctypes.byref(out))
_cf.CFRelease(k)
if err or not out.value:
return None
buf = ctypes.create_string_buffer(1024)
_cf.CFStringGetCString(out, buf, 1024, _UTF8)
_cf.CFRelease(out)
return buf.value.decode("utf-8", "replace")
def _dict_prop(obj, key):
out = ctypes.c_void_p()
k = _cfstr(key)
err = _cm.MIDIObjectGetDictionaryProperty(obj, k, ctypes.byref(out))
_cf.CFRelease(k)
if err or not out.value:
return None
data = _cf.CFPropertyListCreateData(None, out, 100, 0, None) # 100 = XML format
raw = ctypes.string_at(_cf.CFDataGetBytePtr(data), _cf.CFDataGetLength(data))
_cf.CFRelease(data)
_cf.CFRelease(out)
return plistlib.loads(raw)
def sessions():
"""{session name: {"port": int, "peers": [{"name", "address", "state"}]}}"""
found = {}
with _lock:
for i in range(_cm.MIDIGetNumberOfDevices()):
dev = _cm.MIDIGetDevice(i)
if _string_prop(dev, "driver") != "com.apple.AppleMIDINetworkDriver":
continue
for j in range(_cm.MIDIDeviceGetNumberOfEntities(dev)):
ent = _cm.MIDIDeviceGetEntity(dev, j)
sess = _dict_prop(ent, "apple.midirtp.session")
if sess is None:
continue
name = sess.get("dns-sd-name") or _string_prop(ent, "name") or "?"
found[name] = {
"port": sess.get("port"),
"peers": [
{"name": p.get("name", ""), "address": p.get("address", ""), "state": p.get("state", "")}
for p in sess.get("peers", [])
],
}
return found
def source_names():
with _lock:
return [_string_prop(_cm.MIDIGetSource(i), "displayName") or "" for i in range(_cm.MIDIGetNumberOfSources())]
# --- Listener -----------------------------------------------------------
class _MIDIPacket(ctypes.Structure):
_pack_ = 4
_fields_ = [("timeStamp", ctypes.c_uint64), ("length", ctypes.c_uint16), ("data", ctypes.c_ubyte * 256)]
class _MIDIPacketList(ctypes.Structure):
_pack_ = 4
_fields_ = [("numPackets", ctypes.c_uint32), ("packet", _MIDIPacket * 1)]
_READPROC = ctypes.CFUNCTYPE(None, ctypes.POINTER(_MIDIPacketList), ctypes.c_void_p, ctypes.c_void_p)
_cm.MIDIClientCreate.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(_U32)]
_cm.MIDIInputPortCreate.argtypes = [_U32, ctypes.c_void_p, _READPROC, ctypes.c_void_p, ctypes.POINTER(_U32)]
_cm.MIDIPortConnectSource.argtypes = [_U32, _U32, ctypes.c_void_p]
_cm.MIDIPortDisconnectSource.argtypes = [_U32, _U32]
_client = None
def _get_client():
global _client
with _lock:
if _client is None:
ref = _U32()
name = _cfstr("status-panel")
err = _cm.MIDIClientCreate(name, None, None, ctypes.byref(ref))
_cf.CFRelease(name)
if err:
raise OSError("MIDIClientCreate failed: %d" % err)
_client = ref.value
return _client
def describe(data):
"""Short human label for a MIDI message: 'NOTE 36', 'CC 7=64', 'PC 5', 'MTC'."""
if not data:
return ""
st = data[0] & 0xF0
if st == 0x90 and len(data) >= 3 and data[2]:
return "NOTE %d" % data[1]
if st in (0x80, 0x90) and len(data) >= 2:
return "OFF %d" % data[1]
if st == 0xB0 and len(data) >= 3:
return "CC %d=%d" % (data[1], data[2])
if st == 0xC0 and len(data) >= 2:
return "PC %d" % data[1]
if data[0] == 0xF1:
return "MTC"
if data[0] == 0xF0:
return "SYSEX"
return "0x%02X" % data[0]
class Listener:
"""Connects an input port to the named source (re-checked on poll())."""
def __init__(self, source_name):
self.source_name = source_name
self.last_time = None
self.last_msg = ""
self.count = 0
self._endpoint = None
self._port = None
self._proc = _READPROC(self._on_packets) # keep a reference or it is collected
def _on_packets(self, pktlist, _ref, _src):
try:
pl = pktlist.contents
if pl.numPackets:
pkt = pl.packet[0]
n = min(pkt.length, 256)
data = bytes(pkt.data[:n])
# Skip active sensing / clock so they do not mask real traffic.
if data and data[0] not in (0xF8, 0xFE):
self.last_msg = describe(data)
self.last_time = time.time()
self.count += 1
except Exception: # never raise into CoreMIDI's thread
pass
def _find(self):
with _lock:
for i in range(_cm.MIDIGetNumberOfSources()):
ep = _cm.MIDIGetSource(i)
if _string_prop(ep, "displayName") == self.source_name or _string_prop(ep, "name") == self.source_name:
return ep
return None
@property
def connected(self):
return self._endpoint is not None
def poll(self):
"""(Re)connect if the source appeared, disappeared or changed."""
with _lock:
self._poll()
def _poll(self):
ep = self._find()
if ep == self._endpoint:
return
if self._port is None:
ref = _U32()
name = _cfstr("status-panel in")
err = _cm.MIDIInputPortCreate(_get_client(), name, self._proc, None, ctypes.byref(ref))
_cf.CFRelease(name)
if err:
raise OSError("MIDIInputPortCreate failed: %d" % err)
self._port = ref.value
if self._endpoint is not None:
_cm.MIDIPortDisconnectSource(self._port, self._endpoint)
self._endpoint = None
if ep is not None and _cm.MIDIPortConnectSource(self._port, ep, None) == 0:
self._endpoint = ep
# --- test helper: a virtual source we can send into ----------------------
_cm.MIDISourceCreate.argtypes = [_U32, ctypes.c_void_p, ctypes.POINTER(_U32)]
_cm.MIDIReceived.argtypes = [_U32, ctypes.POINTER(_MIDIPacketList)]
_cm.MIDIEndpointDispose.argtypes = [_U32]
class VirtualSource:
"""Only used by the tests: a source whose messages a Listener can receive."""
def __init__(self, name):
ref = _U32()
cname = _cfstr(name)
with _lock:
err = _cm.MIDISourceCreate(_get_client(), cname, ctypes.byref(ref))
_cf.CFRelease(cname)
if err:
raise OSError("MIDISourceCreate failed: %d" % err)
self.ref = ref.value
def send(self, data):
pl = _MIDIPacketList()
pl.numPackets = 1
pl.packet[0].timeStamp = 0
pl.packet[0].length = len(data)
for i, b in enumerate(data):
pl.packet[0].data[i] = b
_cm.MIDIReceived(self.ref, ctypes.byref(pl))
def close(self):
_cm.MIDIEndpointDispose(self.ref)
+59
View File
@@ -0,0 +1,59 @@
#!/bin/zsh
# Install or update the status panel watchdog on this Mac (NA Ingest) as a LaunchAgent.
# Re-run after changing status_panel.py, coremidi.py or config.json in this folder.
#
# zsh install.sh install / update and (re)start
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
set -e
SRC=${0:A:h}
LABEL=org.waterschurch.status-panel
# App Support, not ~/Documents: launchd-run python3 cannot read ~/Documents.
DEST="$HOME/Library/Application Support/status-panel"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
DOMAIN="gui/$(id -u)"
if [[ "$1" == "--remove" ]]; then
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
echo "removed $LABEL (files left in $DEST)"
exit 0
fi
/usr/bin/python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$SRC/config.json"
mkdir -p "$DEST"
if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC/config.json" "$DEST/config.json"; then
cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)"
fi
cp "$SRC/status_panel.py" "$SRC/coremidi.py" "$SRC/config.json" "$DEST/"
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>$DEST/status_panel.py</string>
<string>--data-dir</string>
<string>$DEST</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>10</integer>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/status-panel.err.log</string>
</dict>
</plist>
EOF
launchctl bootout "$DOMAIN/$LABEL" 2>/dev/null || true
# bootout returns before the old job is gone; bootstrapping too early fails with "5: Input/output error".
for i in {1..50}; do launchctl print "$DOMAIN/$LABEL" >/dev/null 2>&1 || break; sleep 0.2; done
for attempt in 1 2 3; do
launchctl bootstrap "$DOMAIN" "$PLIST" && break
[[ $attempt == 3 ]] && { echo "launchctl bootstrap failed" >&2; exit 1; }
sleep 1
done
echo "installed $LABEL -> $DEST"
echo "log: ~/Library/Logs/status-panel.log"
+168
View File
@@ -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()
Binary file not shown.
+1151
View File
File diff suppressed because it is too large Load Diff
+617
View File
@@ -0,0 +1,617 @@
"""Offline tests: no Companion, no gear. Run: python3 -m unittest discover -s tests"""
import gzip
import json
import logging
import os
import re
import sys
import tempfile
import threading
import time
import unittest
import urllib.parse
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
import make_page # noqa: E402
import status_panel as sp # noqa: E402
sp.log.addHandler(logging.NullHandler())
sp.log.propagate = False
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
# The tests pin their own schedule so they do not depend on the site's choice.
CONFIG["service_windows"] = [{"days": ["sun"], "start": "07:00", "end": "12:30"}]
CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t in CONFIG["tiles"]]
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
def healthy_snapshot(now):
"""Everything in config.json present and fine."""
snap = sp.Snapshot()
labels = {c["conn"] for t in CONFIG["tiles"] for c in t.get("checks", []) if c.get("conn")}
snap.conns = {l: {"id": "id-" + l, "enabled": True, "category": "good", "message": None} for l in labels}
snap.conns_time = now
for i in range(1, 7):
snap.vars[("HyperDeck_%d" % i, "status")] = "Preview"
snap.vars[("HyperDeck_%d" % i, "slotId")] = "1"
snap.vars[("HyperDeck_%d" % i, "recordingTime")] = "28:00:00"
devices = set()
for t in CONFIG["tiles"]:
for c in t.get("checks", []):
if c["type"] == "dante":
devices |= set(c["devices"])
snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(sorted(devices))
snap.vars[("tascam", "error")] = "No Error"
snap.vars[("tascam", "caution")] = "No Caution"
for label, n in (("Axient_1", 4), ("Axient_2", 4), ("ULXD-4", 1)):
for ch in range(1, n + 1):
snap.vars[(label, "ch_%d_name" % ch)] = "VOX %d" % ch
snap.vars[(label, "ch_%d_battery_bars" % ch)] = "5"
snap.vars[(label, "ch_%d_interference_status" % ch)] = "NONE"
for label in ("PSM_1", "PSM_2", "PSM_3"):
for ch in (1, 2):
snap.vars[(label, "ch_%d_name" % ch)] = "PACK"
snap.vars[(label, "ch_%d_rf_mute" % ch)] = "UNMUTED"
snap.midi = {"NA Ingest": {"peers": [{"name": "Lighting PC", "address": "", "state": "running"},
{"name": "SuperRack-LiveBox", "address": "", "state": "running"}]}}
snap.midi_time = now
snap.mdns, snap.mdns_time = {"NA Tracks", "Lighting PC", "NA Ingest"}, now
snap.ping = {"10.1.3.227": 0, "10.6.7.13": 0, "10.1.0.1": 0}
snap.link = True
snap.launchd = {"org.waterschurch.resi-propresenter": 884, "org.waterschurch.lobby-captions": 900}
snap.launchd_time = now
snap.procs = {"/Applications/ProdDash.app/Contents/MacOS/ProdDash": True}
snap.http = {"https://central.resi.io/": True, "http://10.6.7.13:8080/": True}
snap.disk = {"/": 97}
return snap
def run_steps(engine, snap, start, seconds, step=1.0):
tiles, t = None, start
while t <= start + seconds:
snap.conns_time = t
tiles = engine.step(snap, t)
t += step
return tiles, t
def level(tile):
for name, ((b, f), _) in sp.COLORS.items():
if (tile["b"], tile["f"]) == (b, f):
return name
return "?"
class TestChecks(unittest.TestCase):
def setUp(self):
self.now = SUNDAY
self.snap = healthy_snapshot(self.now)
self.ctx = {"now": self.now, "service": True, "any_recording": False}
def test_all_green_when_healthy(self):
tiles = sp.Engine(CONFIG).step(self.snap, self.now)
bad = {k: v["x"] for k, v in tiles.items() if level(v) not in ("ok", "service", "off")}
self.assertEqual(bad, {})
self.assertEqual(tiles["all"]["x"], "ALL OK")
self.assertEqual(tiles["hd1"]["x"], "HD1\n28h")
self.assertEqual(tiles["dante"]["x"], "DANTE\n10/10")
def test_connection_states(self):
c = self.snap.conns["ATEM"]
c["category"], c["message"] = "error", "Network error: connect ETIMEDOUT 10.1.10.2:9910"
self.assertEqual(sp.conn_result(self.snap, "ATEM", self.now).msg, "NO ANSWER")
c["message"] = "connect EHOSTUNREACH"
self.assertEqual(sp.conn_result(self.snap, "ATEM", self.now).msg, "UNREACHABLE")
c["enabled"] = False
self.assertEqual(sp.conn_result(self.snap, "ATEM", self.now).level, sp.OFF)
self.assertEqual(sp.conn_result(self.snap, "nope", self.now).msg, "NOT IN COMPANION")
self.assertEqual(sp.conn_result(self.snap, "x32", self.now + 60).msg, "NO DATA")
def test_hyperdeck_disk_and_record_sync(self):
s = self.snap
s.vars[("HyperDeck_2", "slotId")] = "-"
s.vars[("HyperDeck_2", "recordingTime")] = "--:--:--"
out = dict(sp.check_hyperdeck({"conn": "HyperDeck_2"}, s, self.ctx))
self.assertEqual(out["disk"].msg, "NO DISK")
self.assertTrue(out["disk"].service_only)
s.vars[("HyperDeck_3", "recordingTime")] = "0:20:00"
self.assertEqual(dict(sp.check_hyperdeck({"conn": "HyperDeck_3"}, s, self.ctx))["disk"].msg, "LOW 20m")
s.vars[("HyperDeck_3", "recordingTime")] = "0:04:00"
self.assertEqual(dict(sp.check_hyperdeck({"conn": "HyperDeck_3"}, s, self.ctx))["disk"].level, sp.ERR)
ctx = dict(self.ctx, any_recording=True)
self.assertEqual(dict(sp.check_hyperdeck({"conn": "HyperDeck_1"}, s, ctx))["rec"].msg, "NOT REC")
s.vars[("HyperDeck_1", "status")] = "Record"
out = dict(sp.check_hyperdeck({"conn": "HyperDeck_1"}, s, ctx))
self.assertEqual(out["rec"].level, sp.OK)
self.assertEqual(out["conn"].info, "REC 28h")
def test_optional_deck(self):
for i in (2, 4):
self.snap.vars[("HyperDeck_%d" % i, "slotId")] = "-"
self.snap.vars[("HyperDeck_%d" % i, "recordingTime")] = "--:--:--"
for i in (1, 3, 5, 6):
self.snap.vars[("HyperDeck_%d" % i, "status")] = "Record"
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertEqual((level(tiles["hd2"]), tiles["hd2"]["x"]), ("off", "HD2\nNO DISK"))
self.assertEqual(level(tiles["rec"]), "ok")
self.assertEqual(tiles["all"]["x"], "ALL OK")
self.snap.vars[("HyperDeck_2", "slotId")] = "1" # disk goes in: now it is watched
self.snap.vars[("HyperDeck_2", "recordingTime")] = "20:00:00"
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertEqual(tiles["hd2"]["x"], "HD2\nNOT REC")
self.assertEqual(level(tiles["hd2"]), "err")
def test_one_deck_not_recording_blinks(self):
engine = sp.Engine(CONFIG)
for i in (1, 2, 3, 5, 6):
self.snap.vars[("HyperDeck_%d" % i, "status")] = "Record"
tiles, _ = run_steps(engine, self.snap, self.now, 4)
self.assertEqual(tiles["hd4"]["x"], "HD4\nNOT REC")
self.assertEqual(level(tiles["hd4"]), "err")
self.assertEqual(tiles["all"]["x"], "1 DOWN\nHD4 NOT REC")
self.assertEqual(tiles["rec"]["x"], "REC\nHD4 NOT REC")
def test_dante(self):
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(["NA-Tracks"])
out = dict(sp.check_dante({"conn": "audinate-dantecontroller",
"devices": {"NA-Tracks": "DANTE", "BOH-WING": "WING"}}, self.snap, self.ctx))
self.assertEqual(out["BOH-WING"].msg, "WING GONE")
self.assertEqual(out["NA-Tracks"].level, sp.OK)
self.snap.vars[("audinate-dantecontroller", "devices")] = None
out = dict(sp.check_dante({"conn": "audinate-dantecontroller", "devices": ["BOH-WING"]}, self.snap, self.ctx))
self.assertEqual(out["conn"].msg, "NO DATA")
def test_shure_psm_tascam(self):
s = self.snap
s.vars[("Axient_1", "ch_1_battery_bars")] = "1"
s.vars[("Axient_1", "ch_2_battery_bars")] = "2"
s.vars[("Axient_1", "ch_3_battery_bars")] = "Unknown"
s.vars[("Axient_1", "ch_4_interference_status")] = "DETECTED"
s.vars[("Axient_1", "ch_3_interference_status")] = "DETECTED" # transmitter off: ignored
out = dict(sp.check_shure({"conn": "Axient_1", "channels": 4}, s, self.ctx))
self.assertEqual((out["ch1_batt"].level, out["ch1_batt"].msg), (sp.ERR, "VOX 1 BATT"))
self.assertEqual(out["ch2_batt"].level, sp.WARN)
self.assertEqual(out["ch3_batt"].level, sp.OK)
self.assertEqual(out["ch3_rf"].level, sp.OK)
self.assertEqual(out["ch4_rf"].msg, "VOX 4 INTERF")
s.vars[("PSM_2", "ch_1_rf_mute")] = "MUTED"
self.assertEqual(dict(sp.check_psm({"conn": "PSM_2", "channels": 2}, s, self.ctx))["ch1_mute"].msg,
"PACK RF MUTE")
s.vars[("tascam", "error")] = "Media Error"
self.assertEqual(dict(sp.check_tascam({"conn": "tascam"}, s, self.ctx))["error"].msg, "MEDIA ERROR")
def test_rf_group_names_the_mic(self):
self.snap.vars[("Axient_2", "ch_3_battery_bars")] = "1"
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertEqual(tiles["rf"]["x"], "RF\nVOX 3 BATT")
self.snap.vars[("Axient_2", "ch_3_battery_bars")] = "5"
self.snap.conns["PSM_1"]["category"] = "error"
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertIn("PSM 1 OFFLINE", tiles["rf"]["x"])
def test_midi_peer_joining_is_not_an_error(self):
for p in self.snap.midi["NA Ingest"]["peers"]:
p["state"] = "connected" # joined, clock sync not finished yet
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 8)
self.assertEqual(tiles["lightpc"]["x"], "LIGHT PC\nMIDI OK")
def test_midi_peer_and_bonjour(self):
self.snap.midi["NA Ingest"]["peers"] = [p for p in self.snap.midi["NA Ingest"]["peers"]
if p["name"] != "Lighting PC"]
self.snap.mdns.discard("NA Tracks")
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertEqual(tiles["lightpc"]["x"], "LIGHT PC\nMIDI LOST")
self.assertEqual(tiles["tracks"]["x"], "TRACKS\nMIDI SESS GONE")
def test_bible_tv_app_down(self):
self.snap.http["http://10.6.7.13:8080/"] = False
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertEqual(tiles["bibletv"]["x"], "BIBLE TV\nAPP DOWN")
def test_http_intervals(self):
checks = dict(sp.http_checks(CONFIG))
self.assertEqual(checks["http://10.6.7.13:8080/"], 5)
self.assertEqual(checks["https://central.resi.io/"], 30)
def test_ingest_services(self):
del self.snap.launchd["org.waterschurch.resi-propresenter"]
self.snap.launchd["org.waterschurch.lobby-captions"] = None
self.snap.disk["/"] = 6
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, self.now, 4)
self.assertEqual(tiles["ingest"]["x"], "INGEST\nBRIDGE NOT LOADED\n+2")
class TestTextSize(unittest.TestCase):
def test_no_word_is_split(self):
for text in ("TASCAM\nOK", "SERVICE\nON\nmanual", "OBS\nOFFLINE", "INGEST\n97GB free",
"4 DOWN\nHD2 NO DISK", "REC\nHD4 NO DISK\n+1", "TRACKS\nMIDI SESS GONE"):
size = sp.fit_size(text)
longest = max(len(w) for w in text.split())
self.assertLessEqual(longest, int(sp.LINE_CAPACITY / size), text)
def test_short_text_stays_big(self):
self.assertGreaterEqual(sp.fit_size("HD1\n28h"), 29)
self.assertGreater(sp.fit_size("ATEM\nOK"), sp.fit_size("TASCAM\nOK"))
def test_every_tile_has_a_size(self):
tiles = sp.Engine(CONFIG).step(healthy_snapshot(SUNDAY), SUNDAY)
for t in tiles.values():
self.assertTrue(12 <= t["s"] <= 30, t)
class TestBehaviour(unittest.TestCase):
def setUp(self):
self.snap = healthy_snapshot(SUNDAY)
def test_debounce(self):
engine = sp.Engine(CONFIG)
self.snap.conns["x32"]["category"] = "error"
tiles = engine.step(self.snap, SUNDAY)
self.assertEqual(level(tiles["x32"]), "ok") # one bad reading is not enough
tiles, t = run_steps(engine, self.snap, SUNDAY + 1, 3)
self.assertEqual(level(tiles["x32"]), "err")
self.snap.conns["x32"]["category"] = "good"
self.snap.conns_time = t
self.assertEqual(level(engine.step(self.snap, t)["x32"]), "ok") # recovery is immediate
def test_blip_shorter_than_debounce_is_ignored(self):
engine = sp.Engine(CONFIG)
self.snap.conns["x32"]["category"] = "error"
run_steps(engine, self.snap, SUNDAY, 1)
self.snap.conns["x32"]["category"] = "good"
tiles, _ = run_steps(engine, self.snap, SUNDAY + 2, 5)
self.assertEqual(level(tiles["x32"]), "ok")
def test_flapping_shows_amber(self):
engine = sp.Engine(CONFIG)
t = SUNDAY
for _ in range(3):
self.snap.conns["x32"]["category"] = "error"
_, t = run_steps(engine, self.snap, t, 4)
self.snap.conns["x32"]["category"] = "good"
tiles, t = run_steps(engine, self.snap, t, 2)
self.assertEqual(level(tiles["x32"]), "warn")
self.assertEqual(tiles["x32"]["x"], "X32\nOFFLINE x3")
tiles, _ = run_steps(engine, self.snap, t + 700, 1) # quiet for 10 minutes -> green again
self.assertEqual(level(tiles["x32"]), "ok")
def test_ack_then_recover_then_fail_again(self):
engine = sp.Engine(CONFIG)
self.snap.conns["ATEM"]["category"] = "error"
tiles, t = run_steps(engine, self.snap, SUNDAY, 4)
self.assertEqual(level(tiles["atem"]), "err")
self.assertEqual(engine.ack("atem"), 1)
tiles, t = run_steps(engine, self.snap, t, 1)
self.assertEqual(level(tiles["atem"]), "ack")
self.assertEqual(level(tiles["all"]), "ack")
self.snap.conns["ATEM"]["category"] = "good"
tiles, t = run_steps(engine, self.snap, t, 1)
self.assertEqual(level(tiles["atem"]), "ok")
self.snap.conns["ATEM"]["category"] = "error"
tiles, t = run_steps(engine, self.snap, t, 4)
self.assertEqual(level(tiles["atem"]), "err") # a new failure blinks again
def test_group_and_master_ack(self):
engine = sp.Engine(CONFIG)
self.snap.conns["VX1000"]["category"] = "error"
self.snap.conns["x32"]["category"] = "error"
tiles, t = run_steps(engine, self.snap, SUNDAY, 4)
self.assertEqual(tiles["all"]["x"].split("\n")[0], "2 DOWN")
engine.ack("video")
tiles, t = run_steps(engine, self.snap, t, 1)
self.assertEqual(level(tiles["video"]), "ack")
self.assertEqual(level(tiles["x32"]), "err")
engine.ack("all")
tiles, t = run_steps(engine, self.snap, t, 1)
self.assertEqual(level(tiles["all"]), "ack")
def test_outside_service_nothing_blinks(self):
snap = healthy_snapshot(WEDNESDAY)
snap.conns["OBS-Youtube"]["category"] = "error" # OBS PC is off midweek
snap.conns["x32"]["category"] = "error"
snap.vars[("HyperDeck_3", "slotId")] = "-" # a deck that is always used
tiles, _ = run_steps(sp.Engine(CONFIG), snap, WEDNESDAY, 4)
self.assertEqual(level(tiles["obs"]), "idle")
self.assertEqual(level(tiles["hd3"]), "idle")
self.assertEqual(tiles["hd3"]["x"], "HD3\nNO DISK")
self.assertEqual(level(tiles["x32"]), "quiet")
self.assertEqual(tiles["all"]["x"], "1 DOWN\nX32 OFFLINE")
self.assertEqual(level(tiles["all"]), "quiet")
self.assertEqual(tiles["service"]["x"], "SERVICE\nOFF")
for t in tiles.values():
self.assertEqual((t["b"], t["f"]), (t["b2"], t["f2"]), t["x"]) # no blinking anywhere
def test_service_schedule_and_override(self):
path = os.path.join(tempfile.mkdtemp(), "service.json")
mode = sp.ServiceMode(CONFIG["service_windows"], path)
self.assertTrue(mode.on(SUNDAY))
self.assertFalse(mode.on(WEDNESDAY))
mode.set(True, WEDNESDAY) # button: on for a Wednesday event
self.assertTrue(mode.on(WEDNESDAY + 3600))
self.assertTrue(sp.ServiceMode(CONFIG["service_windows"], path).on(WEDNESDAY + 3600)) # survives restart
sunday_start = datetime(2026, 10, 11, 7, 0).timestamp()
self.assertTrue(mode.on(sunday_start)) # window edge clears the override
self.assertIsNone(mode.override)
mode.set(False, sunday_start + 60) # turned off early on Sunday
self.assertFalse(mode.on(sunday_start + 120))
self.assertFalse(mode.on(datetime(2026, 10, 11, 12, 31).timestamp()))
self.assertIsNone(mode.override) # end of window clears it too
def test_network_burst(self):
engine = sp.Engine(CONFIG)
t = SUNDAY
run_steps(engine, self.snap, t, 2)
for label in ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam"): # the Oct 5 10:21 drop
self.snap.conns[label]["category"] = "error"
tiles, t = run_steps(engine, self.snap, t + 3, 4)
self.assertEqual(tiles["net"]["x"], "NET\n5 DROPPED")
self.assertEqual(level(tiles["net"]), "err")
for label in ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam"):
self.snap.conns[label]["category"] = "good"
tiles, t = run_steps(engine, self.snap, t, 1)
self.assertTrue(tiles["net"]["x"].startswith("NET\nBLIP "))
self.assertEqual(level(tiles["net"]), "warn")
tiles, _ = run_steps(engine, self.snap, t + 200, 1)
self.assertEqual(tiles["net"]["x"], "NET\nOK")
def test_racks_powering_off_is_not_a_network_event(self):
engine = sp.Engine(CONFIG)
run_steps(engine, self.snap, SUNDAY, 2)
for label in ("Axient_1", "Axient_2", "ULXD-4", "PSM_1", "PSM_2", "PSM_3"):
self.snap.conns[label]["category"] = "error"
tiles, _ = run_steps(engine, self.snap, SUNDAY + 3, 4)
self.assertEqual(tiles["net"]["x"], "NET\nOK")
self.assertEqual(tiles["rf"]["x"].split("\n")[-1], "+5")
def test_gateway_and_link(self):
self.snap.ping["10.1.0.1"] = 2
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, SUNDAY, 1)
self.assertEqual(tiles["net"]["x"], "NET\nNO GATEWAY")
self.snap.link = False
tiles, _ = run_steps(sp.Engine(CONFIG), self.snap, SUNDAY, 1)
self.assertEqual(tiles["net"]["x"], "NET\nLINK DOWN\n+1")
def test_restart_targets(self):
engine = sp.Engine(CONFIG)
self.assertEqual(engine.restart_targets("hd3"), ["HyperDeck_3"])
self.assertEqual(engine.restart_targets("led"), ["VX1000", "UHDJr"])
self.assertEqual(engine.restart_targets("tracks"), ["ableton"]) # not the shared Dante one
self.assertEqual(engine.restart_targets("ingest"), [])
class FakeCompanion(BaseHTTPRequestHandler):
"""Just enough of Companion 5's HTTP API."""
store = {}
def log_message(self, *a):
pass
def _send(self, status, body):
data = body.encode() if isinstance(body, str) else body
self.send_response(status)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
s = self.store
path = urllib.parse.urlparse(self.path).path
if path == "/api/connections":
return self._send(200, json.dumps([{"id": c["id"], "label": l, "enabled": c["enabled"],
"status": {"category": c["category"], "message": c["message"]}}
for l, c in s["conns"].items()]))
m = re.match(r"^/api/variable/([^/]+)/([^/]+)/value$", path)
if m:
key = (urllib.parse.unquote(m.group(1)), urllib.parse.unquote(m.group(2)))
v = s["vars"].get(key)
return self._send(404, "Not found") if v is None else self._send(200, str(v))
m = re.match(r"^/api/custom-variable/([^/]+)/value$", path)
if m:
v = s["custom"].get(m.group(1))
return self._send(404, "Not found") if v is None else self._send(200, v)
self._send(404, "")
def do_POST(self):
s = self.store
u = urllib.parse.urlparse(self.path)
m = re.match(r"^/api/custom-variable/([^/]+)/value$", u.path)
if m:
if m.group(1) not in s["custom"]:
return self._send(404, "Not found")
s["custom"][m.group(1)] = urllib.parse.parse_qs(u.query, keep_blank_values=True)["value"][0]
return self._send(200, "ok")
m = re.match(r"^/api/connections/([^/]+)/restart$", u.path)
if m:
s["restarts"].append(m.group(1))
return self._send(200, "{}")
self._send(404, "")
class TestAgainstFakeCompanion(unittest.TestCase):
@classmethod
def setUpClass(cls):
snap = healthy_snapshot(time.time())
FakeCompanion.store = {
"conns": snap.conns,
"vars": dict(snap.vars),
"custom": {"sp_state": "", "sp_hb": "", "sp_cmd": "", "flash_tick": "0"},
"restarts": [],
}
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeCompanion)
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
cls.port = cls.server.server_address[1]
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def test_client_round_trip_and_long_state(self):
comp = sp.Companion("127.0.0.1", self.port)
self.assertEqual(len(comp.connections()), len(FakeCompanion.store["conns"]))
self.assertEqual(comp.variable("HyperDeck_1", "recordingTime"), "28:00:00")
self.assertIsNone(comp.variable("HyperDeck_1", "nope"))
snap = healthy_snapshot(time.time())
tiles = sp.Engine(CONFIG).step(snap, time.time())
js = json.dumps(tiles, separators=(",", ":"), sort_keys=True)
self.assertEqual(comp.set_custom("sp_state", js), (True, 200))
self.assertEqual(json.loads(comp.get_custom("sp_state"))["all"]["x"], "ALL OK")
self.assertEqual(comp.set_custom("missing", "x"), (False, 404))
print("\n sp_state is %d bytes, %d in the URL" % (len(js), len(urllib.parse.quote(js, safe=""))))
def test_commands(self):
comp = sp.Companion("127.0.0.1", self.port)
snap = sp.Snapshot()
sp.poll_connections(comp, snap)
engine = sp.Engine(CONFIG)
now = SUNDAY
sp.handle_commands(";restart hd3;restart led;service off;bogus", engine, comp, snap, now)
self.assertEqual(FakeCompanion.store["restarts"][-3:], ["id-HyperDeck_3", "id-VX1000", "id-UHDJr"])
self.assertFalse(engine.service.on(now))
sp.handle_commands("service toggle", engine, comp, snap, now)
self.assertTrue(engine.service.on(now))
def test_end_to_end_loop(self):
"""The real run() loop: it writes state and heartbeat, and consumes button commands."""
cfg = json.loads(json.dumps(CONFIG))
cfg["companion"]["port"] = self.port
# keep it offline: no pings, no web, no launchd, no bonjour lookups
cfg["network"]["gateway"] = None
keep = ("conn", "hyperdeck", "dante", "tascam", "shure", "psm")
for t in cfg["tiles"]:
if "checks" in t:
t["checks"] = [c for c in t["checks"] if c["type"] in keep] or [{"type": "conn", "conn": "x32"}]
store = FakeCompanion.store
store["conns"]["ATEM"]["category"] = "error"
store["custom"]["sp_cmd"] = ";ack atem"
data_dir = tempfile.mkdtemp()
originals = sp.poll_mdns, sp.poll_system, sp.poll_midi
sp.poll_mdns = lambda snap, seconds=3: None
sp.poll_system = lambda snap, cfg: None
sp.poll_midi = lambda snap, cfg: None # CoreMIDI has its own test
stop = threading.Event()
loop = threading.Thread(target=sp.run, args=(cfg, data_dir), kwargs={"stop": stop}, daemon=True)
try:
loop.start()
deadline = time.time() + 12
state = {}
while time.time() < deadline:
raw = store["custom"]["sp_state"]
state = json.loads(raw) if raw else {}
if state.get("atem", {}).get("x") == "ATEM\nOFFLINE":
break
time.sleep(0.25)
finally:
stop.set()
loop.join(timeout=15)
sp.poll_mdns, sp.poll_system, sp.poll_midi = originals
store["conns"]["ATEM"]["category"] = "good"
self.assertFalse(loop.is_alive()) # it shuts down cleanly
self.assertTrue(store["custom"]["sp_hb"].isdigit())
self.assertLess(abs(int(store["custom"]["sp_hb"]) - time.time()), 5)
self.assertEqual(store["custom"]["sp_cmd"], "") # the command was consumed
self.assertIn("atem", state)
self.assertIn("ATEM", state["atem"]["x"])
class TestPage(unittest.TestCase):
def setUp(self):
self.export = make_page.build(CONFIG)
self.controls = self.export["page"]["controls"]
def test_layout(self):
self.assertEqual(self.export["type"], "page")
self.assertEqual(self.export["oldPageNumber"], 19)
self.assertEqual(self.controls["0"]["0"], {"type": "pageup"})
self.assertEqual(self.controls["2"]["0"], {"type": "pagedown"})
positioned = [t for t in CONFIG["tiles"] if "pos" in t]
n = sum(1 for r in self.controls.values() for c in r.values() if c["type"] == "button-layered")
self.assertEqual(n, len(positioned))
# The master is where the smallest Stream Deck can see it.
self.assertEqual(CONFIG["tiles"][0]["pos"], [0, 1])
def test_each_button_reads_its_own_tile(self):
for t in CONFIG["tiles"]:
if "pos" not in t:
continue
r, c = (str(x) for x in t["pos"])
layers = {l["id"]: l for l in self.controls[r][c]["style"]["layers"]}
for lid, key in (("box0", "color"), ("text0", "color"), ("text0", "text"), ("text0", "fontsize")):
expr = layers[lid][key]
self.assertTrue(expr["isExpression"])
self.assertIn("'$.%s." % t["id"], expr["value"])
self.assertIn("$(custom:sp_hb) > 15", expr["value"])
self.assertIn("$(custom:flash_tick) == 1", layers["box0"]["color"]["value"])
def test_actions(self):
hd1 = self.controls["1"]["1"]["steps"]["0"]
up = hd1["action_sets"]["up"][0]["options"]["value"]
self.assertEqual(up, {"value": "$(custom:sp_cmd);ack hd1", "isExpression": False})
self.assertEqual(hd1["action_sets"]["2000"][0]["options"]["value"]["value"], "$(custom:sp_cmd);restart hd1")
self.assertEqual(hd1["options"]["runWhileHeld"], [2000])
master = self.controls["0"]["1"]["steps"]["0"]
self.assertIn("ackall", master["action_sets"]["up"][0]["options"]["value"]["value"])
self.assertNotIn("2000", master["action_sets"])
ingest = self.controls["3"]["5"]["steps"]["0"]
self.assertNotIn("2000", ingest["action_sets"]) # nothing to restart there
ids = [a["id"] for r in self.controls.values() for c in r.values()
for s in c.get("steps", {}).values() for al in s["action_sets"].values() for a in al]
self.assertEqual(len(ids), len(set(ids)))
def test_stale_text_and_file(self):
master = {l["id"]: l for l in self.controls["0"]["1"]["style"]["layers"]}
self.assertIn("`${'WATCHDOG\\nDOWN\\n'}${$(internal:instance_errors)} ERR`", master["text0"]["text"]["value"])
# No string '+' anywhere: in Companion 5 expressions it adds numbers.
for r in self.controls.values():
for c in r.values():
for layer in c.get("style", {}).get("layers", []):
for p in layer.values():
if isinstance(p, dict) and p.get("isExpression"):
self.assertNotRegex(p["value"], r"'\s*\+|\+\s*'")
path = os.path.join(tempfile.mkdtemp(), "p.companionconfig")
sys.argv = ["make_page.py", path]
make_page.main()
on_disk = make_page.build(sp.load_config(os.path.join(ROOT, "config.json")))
with gzip.open(path) as f:
self.assertEqual(json.load(f), json.loads(json.dumps(on_disk)))
class TestCoreMIDI(unittest.TestCase):
def test_listener_loopback(self):
import coremidi
src = coremidi.VirtualSource("status-panel-test")
try:
time.sleep(0.3)
lis = coremidi.Listener("status-panel-test")
lis.poll()
self.assertTrue(lis.connected)
src.send([0x90, 36, 100])
src.send([0xF8]) # clock is ignored
time.sleep(0.3)
self.assertEqual((lis.last_msg, lis.count), ("NOTE 36", 1))
snap = sp.Snapshot()
snap.listeners["status-panel-test"] = lis
res = sp.check_midi_rx({"source": "status-panel-test", "name": "IN"}, snap,
{"now": time.time(), "service": True})[0][1]
self.assertEqual(res.info, "IN 0s")
finally:
src.close()
time.sleep(0.3)
lis.poll()
self.assertFalse(lis.connected)
def test_describe(self):
import coremidi
self.assertEqual(coremidi.describe([0xB0, 7, 64]), "CC 7=64")
self.assertEqual(coremidi.describe([0xC2, 5]), "PC 5")
self.assertEqual(coremidi.describe([0x90, 60, 0]), "OFF 60")
self.assertEqual(coremidi.describe([0xF1, 0x20]), "MTC")
if __name__ == "__main__":
unittest.main()