Add service health indicators to LED matrix viz
Validate and Test / validate-patches (push) Failing after 13s
Validate and Test / validate-patches (push) Failing after 13s
- Column 12 shows service health (top row = service dots) - Periodic diagnostic flash every 10s (idle only) - Services monitored: midi-bridge, pd-synth, viz, prometheus, audio - MIDI note viz uses columns 0-11, rows 1-7
This commit is contained in:
+101
-16
@@ -1,24 +1,45 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Listen for MIDI events via UDP and display note activity on LED matrix.
|
import socket, msgpack, time, os, select, json, subprocess
|
||||||
|
|
||||||
Event-driven: renders and sends frame immediately on each MIDI event.
|
|
||||||
Background 1 FPS refresh catches any lost frames from the router.
|
|
||||||
"""
|
|
||||||
import socket, msgpack, time, os, select
|
|
||||||
|
|
||||||
VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082"))
|
VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082"))
|
||||||
ROUTER_SOCK = os.environ.get("ROUTER_SOCK", "/var/run/arduino-router.sock")
|
ROUTER_SOCK = os.environ.get("ROUTER_SOCK", "/var/run/arduino-router.sock")
|
||||||
|
|
||||||
COLS = 13
|
COLS = 13
|
||||||
ROWS = 8
|
ROWS = 8
|
||||||
|
|
||||||
REFRESH_INTERVAL = 1.0
|
REFRESH_INTERVAL = 1.0
|
||||||
|
|
||||||
STATS_FILE = "/tmp/viz-stats.json"
|
STATS_FILE = "/tmp/viz-stats.json"
|
||||||
|
DIAG_INTERVAL = 10.0
|
||||||
|
DIAG_DURATION = 0.3
|
||||||
_poll = select.poll()
|
_poll = select.poll()
|
||||||
POLLIN = select.POLLIN
|
POLLIN = select.POLLIN
|
||||||
|
|
||||||
SOCK_BUFFER = 4096
|
SOCK_BUFFER = 4096
|
||||||
|
SERVICES = ["midi-bridge", "pd-synth-onboard", "led-matrix-viz", "prometheus-exporter"]
|
||||||
|
STATUS_CACHE = {}
|
||||||
|
LAST_STATUS_CHECK = 0.0
|
||||||
|
|
||||||
|
STATUS_ROW = 0
|
||||||
|
SERVICE_COLS = {0: 0, 1: 1, 2: 2, 3: 3}
|
||||||
|
AUDIO_COL = 4
|
||||||
|
NOTE_ROWS = 7
|
||||||
|
NOTE_ROW_OFFSET = 1
|
||||||
|
|
||||||
|
AUDIO_SIGNAL_FILE = "/tmp/audio-signal-detect"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_services():
|
||||||
|
global STATUS_CACHE, LAST_STATUS_CHECK
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - LAST_STATUS_CHECK < 5.0:
|
||||||
|
return STATUS_CACHE
|
||||||
|
for svc in SERVICES:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3)
|
||||||
|
STATUS_CACHE[svc] = r.stdout.strip() == "active"
|
||||||
|
except Exception:
|
||||||
|
STATUS_CACHE[svc] = False
|
||||||
|
STATUS_CACHE["audio"] = os.path.exists(AUDIO_SIGNAL_FILE) and (time.time() - os.path.getmtime(AUDIO_SIGNAL_FILE) < 5.0)
|
||||||
|
LAST_STATUS_CHECK = now
|
||||||
|
return STATUS_CACHE
|
||||||
|
|
||||||
|
|
||||||
def pack_frame(pixels):
|
def pack_frame(pixels):
|
||||||
@@ -38,10 +59,31 @@ def render(active_notes):
|
|||||||
pixels = [[0] * COLS for _ in range(ROWS)]
|
pixels = [[0] * COLS for _ in range(ROWS)]
|
||||||
note_min, note_max = 36, 96
|
note_min, note_max = 36, 96
|
||||||
for note in active_notes:
|
for note in active_notes:
|
||||||
row = 7 - int((note - note_min) / (note_max - note_min) * 7)
|
row = NOTE_ROWS - 1 - int((note - note_min) / (note_max - note_min) * (NOTE_ROWS - 1))
|
||||||
row = max(0, min(7, row))
|
row = max(0, min(NOTE_ROWS - 1, row)) + NOTE_ROW_OFFSET
|
||||||
col = note % 12
|
col = note % 12
|
||||||
pixels[row][col] = 1
|
pixels[row][col] = 1
|
||||||
|
status = _check_services()
|
||||||
|
for idx, svc in enumerate(SERVICES):
|
||||||
|
col = SERVICE_COLS[idx]
|
||||||
|
if status.get(svc):
|
||||||
|
pixels[STATUS_ROW][col] = 1
|
||||||
|
if status.get("audio"):
|
||||||
|
pixels[STATUS_ROW][AUDIO_COL] = 1
|
||||||
|
return pack_frame(pixels)
|
||||||
|
|
||||||
|
|
||||||
|
def _diag_frame():
|
||||||
|
pixels = [[0] * COLS for _ in range(ROWS)]
|
||||||
|
status = _check_services()
|
||||||
|
for idx, svc in enumerate(SERVICES):
|
||||||
|
col = SERVICE_COLS[idx]
|
||||||
|
if status.get(svc):
|
||||||
|
for r in range(ROWS):
|
||||||
|
pixels[r][col] = 1
|
||||||
|
if status.get("audio"):
|
||||||
|
for r in range(ROWS):
|
||||||
|
pixels[r][AUDIO_COL] = 1
|
||||||
return pack_frame(pixels)
|
return pack_frame(pixels)
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +105,24 @@ def try_send(router, data):
|
|||||||
return router, False
|
return router, False
|
||||||
|
|
||||||
|
|
||||||
|
def _send_frame(router, packer, params, msg_id):
|
||||||
|
data = packer.pack([0, msg_id, "draw_frame", params])
|
||||||
|
router, ok = try_send(router, data)
|
||||||
|
if ok:
|
||||||
|
router.setblocking(False)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = router.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
except (BlockingIOError, OSError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
router.setblocking(True)
|
||||||
|
router.settimeout(0.01)
|
||||||
|
return router, ok
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
@@ -85,6 +145,11 @@ def main():
|
|||||||
bytes_received = 0
|
bytes_received = 0
|
||||||
|
|
||||||
last_refresh = time.monotonic()
|
last_refresh = time.monotonic()
|
||||||
|
last_diag = time.monotonic()
|
||||||
|
diag_active = False
|
||||||
|
diag_start = 0.0
|
||||||
|
pre_diag_params = None
|
||||||
|
|
||||||
stats_last_write = time.monotonic()
|
stats_last_write = time.monotonic()
|
||||||
fps_sample_start = time.monotonic()
|
fps_sample_start = time.monotonic()
|
||||||
fps_sample_frames = 0
|
fps_sample_frames = 0
|
||||||
@@ -94,6 +159,27 @@ def main():
|
|||||||
while True:
|
while True:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
refresh_due = (now - last_refresh) >= REFRESH_INTERVAL
|
refresh_due = (now - last_refresh) >= REFRESH_INTERVAL
|
||||||
|
diag_due = (now - last_diag) >= DIAG_INTERVAL and not diag_active and len(active_notes) == 0
|
||||||
|
|
||||||
|
if diag_due:
|
||||||
|
diag_active = True
|
||||||
|
diag_start = now
|
||||||
|
pre_diag_params = last_sent_params
|
||||||
|
msg_id = (msg_id + 1) % 65535
|
||||||
|
router, ok = _send_frame(router, packer, _diag_frame(), msg_id)
|
||||||
|
if ok:
|
||||||
|
frames_sent += 1
|
||||||
|
|
||||||
|
if diag_active and (now - diag_start) >= DIAG_DURATION:
|
||||||
|
diag_active = False
|
||||||
|
last_diag = now
|
||||||
|
if pre_diag_params is not None:
|
||||||
|
msg_id = (msg_id + 1) % 65535
|
||||||
|
params = render(active_notes)
|
||||||
|
router, ok = _send_frame(router, packer, params, msg_id)
|
||||||
|
if ok:
|
||||||
|
frames_sent += 1
|
||||||
|
last_sent_params = params
|
||||||
|
|
||||||
try:
|
try:
|
||||||
events = _poll.poll(5)
|
events = _poll.poll(5)
|
||||||
@@ -130,6 +216,9 @@ def main():
|
|||||||
active_notes.discard(note)
|
active_notes.discard(note)
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
|
if diag_active:
|
||||||
|
diag_active = False
|
||||||
|
last_diag = now
|
||||||
events_processed += 1
|
events_processed += 1
|
||||||
params = render(active_notes)
|
params = render(active_notes)
|
||||||
if params != last_sent_params:
|
if params != last_sent_params:
|
||||||
@@ -137,13 +226,11 @@ def main():
|
|||||||
pending_bytes = packer.pack([0, msg_id, "draw_frame", params])
|
pending_bytes = packer.pack([0, msg_id, "draw_frame", params])
|
||||||
last_sent_params = params
|
last_sent_params = params
|
||||||
|
|
||||||
# Try to send pending frame — no POLLOUT dependency
|
|
||||||
if pending_bytes is not None:
|
if pending_bytes is not None:
|
||||||
router, ok = try_send(router, pending_bytes)
|
router, ok = try_send(router, pending_bytes)
|
||||||
if ok:
|
if ok:
|
||||||
pending_bytes = None
|
pending_bytes = None
|
||||||
frames_sent += 1
|
frames_sent += 1
|
||||||
# Drain pending responses so router buffer stays clear
|
|
||||||
router.setblocking(False)
|
router.setblocking(False)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -158,11 +245,10 @@ def main():
|
|||||||
else:
|
else:
|
||||||
frames_dropped += 1
|
frames_dropped += 1
|
||||||
|
|
||||||
# Background refresh: resend last params to recover from router drops
|
|
||||||
if refresh_due:
|
if refresh_due:
|
||||||
last_refresh = now
|
last_refresh = now
|
||||||
fps_sample_frames += 1
|
fps_sample_frames += 1
|
||||||
if last_sent_params is not None and pending_bytes is None:
|
if not diag_active and last_sent_params is not None and pending_bytes is None:
|
||||||
msg_id = (msg_id + 1) % 65535
|
msg_id = (msg_id + 1) % 65535
|
||||||
refresh_bytes = packer.pack([0, msg_id, "draw_frame", last_sent_params])
|
refresh_bytes = packer.pack([0, msg_id, "draw_frame", last_sent_params])
|
||||||
router, ok = try_send(router, refresh_bytes)
|
router, ok = try_send(router, refresh_bytes)
|
||||||
@@ -179,7 +265,6 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
def _write_stats(sent, dropped, fps, active_count, events, bytes_rcv, packets):
|
def _write_stats(sent, dropped, fps, active_count, events, bytes_rcv, packets):
|
||||||
import json
|
|
||||||
try:
|
try:
|
||||||
with open(STATS_FILE, "w") as f:
|
with open(STATS_FILE, "w") as f:
|
||||||
json.dump({
|
json.dump({
|
||||||
|
|||||||
Reference in New Issue
Block a user