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>
215 lines
6.0 KiB
Python
215 lines
6.0 KiB
Python
"""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())
|