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:
214
leds/panel_bridge.py
Normal file
214
leds/panel_bridge.py
Normal 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())
|
||||
Reference in New Issue
Block a user