Files
portal/leds/panel_bridge.py
Jimmy 5094c7bcee 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>
2026-07-30 14:54:51 +12:00

215 lines
6.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())