Add portal web simulator, SPI bridges, and Pico firmware updates.

Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 14:54:51 +12:00
parent d5cab2efdf
commit 5094c7bcee
78 changed files with 62251 additions and 832 deletions

214
leds/panel_bridge.py Normal file
View File

@@ -0,0 +1,214 @@
"""UDP-to-SPI bridge — makes a Pi-connected panel look like Pico firmware."""
from __future__ import annotations
import argparse
import queue
import socket
import sys
import threading
from typing import Sequence
from leds.array_config import MATRIX_BRIGHTNESS, panel_layout, panel_pixel_count
from leds.config import panel_strip_config
from leds.matrix import LedMatrix
from leds.panel_udp import DEFAULT_PORT
# Larger kernel RX buffer so SPI show() does not drop bursts from the animator.
_SOCKET_RCVBUF = 1 << 20
_DEFAULT_SPI_MHZ = 4.2
def decode_panel_frame(
data: bytes,
pixel_count: int,
panel_id: int,
) -> bytes | None:
"""Match Pico firmware: raw RGB, or panel_id byte + RGB."""
rgb_len = pixel_count * 3
if len(data) == rgb_len:
return data
if len(data) == rgb_len + 1:
prefix = data[0]
if prefix != 255 and prefix != (panel_id & 0xFF):
return None
return data[1:]
return None
def _recv_latest_frame(
sock: socket.socket,
bufsize: int,
pixel_count: int,
panel_id: int,
) -> bytes | None:
"""Block for one datagram, then drain the queue and return the newest frame."""
rgb: bytes | None = None
while rgb is None:
try:
data, _addr = sock.recvfrom(bufsize)
except InterruptedError:
continue
rgb = decode_panel_frame(data, pixel_count, panel_id)
sock.setblocking(False)
try:
while True:
try:
data, _addr = sock.recvfrom(bufsize)
except BlockingIOError:
break
newer = decode_panel_frame(data, pixel_count, panel_id)
if newer is not None:
rgb = newer
finally:
sock.setblocking(True)
return rgb
def _receiver_loop(
sock: socket.socket,
bufsize: int,
pixel_count: int,
panel_id: int,
frames: queue.Queue[bytes],
stop: threading.Event,
) -> None:
while not stop.is_set():
try:
rgb = _recv_latest_frame(sock, bufsize, pixel_count, panel_id)
except OSError:
if stop.is_set():
return
raise
if rgb is None:
continue
try:
frames.put_nowait(rgb)
except queue.Full:
try:
frames.get_nowait()
except queue.Empty:
pass
frames.put_nowait(rgb)
def add_bridge_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--panel-index",
type=int,
required=True,
choices=range(5),
metavar="N",
help="Panel index (sets LED count / SPI layout)",
)
parser.add_argument(
"--bind",
default="0.0.0.0",
help="Bind address (default: 0.0.0.0; animators send to 127.0.0.1)",
)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--brightness", type=float, default=MATRIX_BRIGHTNESS)
parser.add_argument(
"--spi-mhz",
type=float,
default=_DEFAULT_SPI_MHZ,
help=f"SPI clock in MHz (default {_DEFAULT_SPI_MHZ})",
)
parser.add_argument(
"--wire-order",
default=None,
help="ignored (passthrough RGB); kept for compatibility",
)
def run_bridge(
panel_index: int,
*,
bind: str | None = None,
port: int = DEFAULT_PORT,
brightness: float = MATRIX_BRIGHTNESS,
spi_mhz: float = _DEFAULT_SPI_MHZ,
wire_order: str | None = None, # unused; kept for compatibility
) -> None:
width, height = panel_layout(panel_index)
pixels = panel_pixel_count(panel_index)
bind_addr = bind if bind is not None else "0.0.0.0"
spi_hz = max(1, int(spi_mhz * 1_000_000))
# UDP frames are already dimmed by the animator; avoid scaling twice on SPI.
strip_cfg = panel_strip_config(
panel_index,
pixels,
brightness=1.0,
spi_max_speed_hz=spi_hz,
passthrough=True,
swap_rg=False,
)
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_RCVBUF, _SOCKET_RCVBUF)
try:
sock.bind((bind_addr, port))
except OSError as exc:
raise OSError(f"Cannot bind {bind_addr}:{port}: {exc}") from exc
print(
f"Panel {panel_index} bridge on {bind_addr}:{port} "
f"({width}×{height}, {pixels} LEDs, SPI bus {strip_cfg.spi_bus}, "
f"{spi_mhz:g} MHz, rpi5-ws2812, passthrough)"
)
print("Ctrl+C to stop")
bufsize = pixels * 3 + 1
frames: queue.Queue[bytes] = queue.Queue(maxsize=1)
stop = threading.Event()
receiver = threading.Thread(
target=_receiver_loop,
args=(sock, bufsize, pixels, panel_index, frames, stop),
name="panel-bridge-recv",
daemon=True,
)
receiver.start()
try:
with LedMatrix(width, height, config0=strip_cfg) as matrix:
while True:
rgb = frames.get()
while True:
try:
rgb = frames.get_nowait()
except queue.Empty:
break
matrix.show_rgb_bytes(rgb)
finally:
stop.set()
sock.close()
receiver.join(timeout=0.5)
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="UDP-to-SPI bridge (Pi panel emulates Pico firmware)"
)
add_bridge_args(parser)
args = parser.parse_args(argv)
try:
run_bridge(
args.panel_index,
bind=args.bind,
port=args.port,
brightness=args.brightness,
spi_mhz=args.spi_mhz,
wire_order=args.wire_order,
)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except OSError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())