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

View File

@@ -3,11 +3,45 @@
from __future__ import annotations
import os
from typing import Literal
MATRIX_WIDTH = 45
MATRIX_HEIGHT = 9
MATRIX_PIXELS = MATRIX_WIDTH * MATRIX_HEIGHT # 405
# --- Hexagonal portal -------------------------------------------------------
# Bottom face = floor (no LEDs). Panels 04 numbered clockwise from bottom-left.
# Panel 2 is the top face. All five panels are Pico UDP adapters.
#
# Viewed from outside:
#
# [2] TOP
# [1] [3]
# top-left top-right
#
# [0] [4]
# bottom-left bottom-right
#
# Floor (no LEDs) is below.
TOP_PANEL_INDEX = 2
PORTAL_PANEL_ORDER_CLOCKWISE: tuple[int, ...] = (0, 1, 2, 3, 4)
SIDE_PANEL_ORDER: tuple[int, ...] = (0, 1, 3, 4)
PANEL_POSITION_BY_INDEX: dict[int, str] = {
0: "bottom-left",
1: "top-left",
2: "top",
3: "top-right",
4: "bottom-right",
}
PortalFace = Literal["top", "side", "floor"]
PANEL_FACE_BY_INDEX: dict[int, PortalFace] = {
2: "top",
0: "side",
1: "side",
3: "side",
4: "side",
}
# Per-panel width (height is always 9 rows). Edit to match your hardware.
# Panel 4 may still be 38 or 39 — run: panel_sync_test.py --test width --panel-index 4
PANEL_HEIGHT = 9
@@ -21,6 +55,24 @@ PANEL_WIDTH_BY_INDEX: dict[int, int] = {
DEFAULT_PANEL_WIDTH = 45
PANEL_COUNT = 5
# Panels driven in-process by the animation script (SPI/PIO on this Pi).
LOCAL_PANEL_INDICES: frozenset[int] = frozenset()
# Optional: Pi UDP→SPI bridge panels (examples/panel_spi_bridge.py). Empty = all Pico.
PI_BRIDGE_PANEL_INDICES: frozenset[int] = frozenset()
# Optional: two panels on one Pico (GP27 + GP28).
# Example — panels 0 and 1 on 10.1.1.10, strips 0 and 1:
# PANEL_HOST_BY_INDEX = {0: "10.1.1.10", 1: "10.1.1.10"}
# PANEL_STRIP_BY_INDEX = {0: 0, 1: 1}
# PANEL_WS2812_PIN_BY_INDEX = {0: 28, 1: 27}
PANEL_HOST_BY_INDEX: dict[int, str] = {}
PANEL_STRIP_BY_INDEX: dict[int, int] = {}
PANEL_WS2812_PIN_BY_INDEX: dict[int, int] = {}
# Optional SPI bus override per panel index (Pi 5: 0 = GPIO 10, 1 = GPIO 20).
PANEL_SPI_BUS_BY_INDEX: dict[int, int] = {}
# SPI data line: bus 0 = GPIO 10 (MOSI), bus 1 = GPIO 20 (SPI1 MOSI)
MATRIX_SPI_BUS = 0
MATRIX_SPI_DEVICE = 0
@@ -34,8 +86,9 @@ MATRIX_FLIP_Y = False
MATRIX_BRIGHTNESS = 0.25
# LED wire order for direct SPI on Pi (not used for Pico UDP — firmware packs GRB).
WIRE_ORDER = os.environ.get("PORTAL_WIRE_ORDER", "grb").lower()
# Optional wire reorder for direct SPI tests only (set PORTAL_WIRE_ORDER env).
# UDP/bridge/firmware pass logical RGB through unchanged (default rgb = no swap).
WIRE_ORDER = os.environ.get("PORTAL_WIRE_ORDER", "rgb").lower()
def panel_width(panel_index: int) -> int:
@@ -51,6 +104,22 @@ def panel_pixel_count(panel_index: int) -> int:
return w * h
def panel_face(panel_index: int) -> PortalFace | None:
return PANEL_FACE_BY_INDEX.get(panel_index)
def panel_label(panel_index: int) -> str:
face = panel_face(panel_index)
pos = PANEL_POSITION_BY_INDEX.get(panel_index)
if face == "top":
return f"panel {panel_index} (top / Pi)"
if pos:
return f"panel {panel_index} ({pos})"
if face == "side":
return f"panel {panel_index} (side wall)"
return f"panel {panel_index}"
def matrix_pixel_index(
x: int,
y: int,

View File

@@ -34,7 +34,7 @@ class PioBackend:
frame = bytearray(self._led_count * 4)
for i in range(self._led_count):
base = i * 3
g, r, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
r, g, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
offset = i * 4
frame[offset] = r
frame[offset + 1] = g

View File

@@ -0,0 +1,54 @@
"""WS2812 over SPI via the rpi5-ws2812 package (Pi 5, GPIO 10)."""
from __future__ import annotations
import numpy as np
from rpi5_ws2812.ws2812 import WS2812SpiDriver
from leds.backends.spi import spidev_bufsize_hint
class Rpi5Ws2812Backend:
def __init__(
self,
led_count: int,
*,
spi_bus: int = 0,
spi_device: int = 0,
max_speed_hz: int = 4_200_000,
) -> None:
hint = spidev_bufsize_hint(led_count)
if hint:
raise OSError(hint)
self._led_count = led_count
self._driver = WS2812SpiDriver(spi_bus, spi_device, led_count)
self._driver._device.max_speed_hz = max_speed_hz
@property
def led_count(self) -> int:
return self._led_count
def write_grb(self, rgb_bytes: bytes) -> None:
"""RGB bytes passed through to SPI unchanged."""
if len(rgb_bytes) != self._led_count * 3:
raise ValueError(
f"expected {self._led_count * 3} bytes, got {len(rgb_bytes)}"
)
buf = np.frombuffer(rgb_bytes, dtype=np.uint8).reshape(self._led_count, 3)
self._driver.write(buf)
def clear(self) -> None:
self._driver.clear()
def close(self) -> None:
try:
self.clear()
finally:
self._driver._device.close()
def __enter__(self) -> Rpi5Ws2812Backend:
return self
def __exit__(self, *_exc) -> None:
self.close()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from array import array
from pathlib import Path
try:
from spidev import SpiDev
@@ -16,6 +17,48 @@ else:
_LED_ZERO = 0b1100_0000
_LED_ONE = 0b1111_1100
_PREAMBLE = 42
_DEFAULT_BUFSIZ = 65536
def spidev_bufsize() -> int:
try:
return int(Path("/sys/module/spidev/parameters/bufsiz").read_text().strip())
except (OSError, ValueError):
return 4096
def spi_frame_bytes(led_count: int) -> int:
return _PREAMBLE + led_count * 24
def leds_from_truncated_frame(bufsize: int) -> int:
payload = max(0, bufsize - _PREAMBLE)
return payload // 24
def spidev_bufsize_hint(led_count: int) -> str:
required = spi_frame_bytes(led_count)
bufsize = spidev_bufsize()
if bufsize >= required:
return ""
suggested = max(_DEFAULT_BUFSIZ, required + 1024)
truncated = leds_from_truncated_frame(bufsize)
return (
f"spidev bufsiz is {bufsize} but {led_count} LEDs need a {required}-byte SPI frame "
f"(only ~{truncated} LEDs update at the current limit).\n"
f"Fix now:\n"
f" sudo modprobe -r spidev\n"
f" sudo modprobe spidev bufsiz={suggested}\n"
f"Permanent:\n"
f" pipenv run python examples/setup_spi_bufsiz.py --install"
)
def _write_spi_frame(device: SpiDev, frame: array) -> None:
required = len(frame)
if required > spidev_bufsize():
raise OSError(spidev_bufsize_hint((required - _PREAMBLE) // 24))
device.writebytes2(frame)
class SpiBackend:
@@ -39,12 +82,16 @@ class SpiBackend:
self._device.mode = 0b00
self._device.lsbfirst = False
frame_len = _PREAMBLE + led_count * 24
frame_len = spi_frame_bytes(led_count)
self._frame = array("B", [0] * frame_len)
self._clear_frame = array("B", [0] * frame_len)
for i in range(_PREAMBLE, frame_len):
self._clear_frame[i] = _LED_ZERO
hint = spidev_bufsize_hint(led_count)
if hint:
raise OSError(hint)
@property
def led_count(self) -> int:
return self._led_count
@@ -62,11 +109,10 @@ class SpiBackend:
out[pos] = _LED_ONE if value & bit else _LED_ZERO
pos += 1
# writebytes2 handles large frames (400+ LEDs); writebytes is capped at 4 KiB.
self._device.writebytes2(out)
_write_spi_frame(self._device, out)
def clear(self) -> None:
self._device.writebytes2(self._clear_frame)
_write_spi_frame(self._device, self._clear_frame)
def close(self) -> None:
try:

View File

@@ -7,6 +7,20 @@ from typing import Tuple
RGB = Tuple[int, int, int]
# Logical RGB (R, G, B) — same as neopixel: RED=(255,0,0), GREEN=(0,255,0), etc.
# Firmware and Pi SPI convert to GRB on the wire (NeoPixel default pixel_order).
RED: RGB = (255, 0, 0)
GREEN: RGB = (0, 255, 0)
BLUE: RGB = (0, 0, 255)
WHITE: RGB = (255, 255, 255)
OFF: RGB = (0, 0, 0)
RGB_CYCLE: tuple[tuple[str, RGB], ...] = (
("RED", RED),
("GREEN", GREEN),
("BLUE", BLUE),
)
def wheel(pos: int) -> RGB:
pos = pos % 256

View File

@@ -13,7 +13,7 @@ except ImportError:
MATRIX_SPI_BUS = 0
MATRIX_SPI_DEVICE = 0
BackendName = Literal["auto", "pio", "spi", "ws281x"]
BackendName = Literal["auto", "pio", "spi", "rpi5_ws2812", "ws281x"]
@dataclass
@@ -25,7 +25,41 @@ class StripConfig:
device: str = "/dev/leds0"
spi_bus: int = 0
spi_device: int = 0
spi_max_speed_hz: int | None = None
wire_order: str | None = None
brightness: float = 1.0
passthrough: bool = False
swap_rg: bool = True
def panel_strip_config(
panel_index: int,
count: int,
*,
spi_max_speed_hz: int | None = None,
wire_order: str | None = None,
brightness: float = 1.0,
passthrough: bool = True,
swap_rg: bool = True,
) -> StripConfig:
from leds.array_config import LOCAL_PANEL_INDICES, PANEL_SPI_BUS_BY_INDEX
if panel_index in PANEL_SPI_BUS_BY_INDEX:
spi_bus = PANEL_SPI_BUS_BY_INDEX[panel_index]
elif panel_index in LOCAL_PANEL_INDICES:
spi_bus = pi5_second_spi_bus()
else:
spi_bus = MATRIX_SPI_BUS
return spi_strip_config(
count,
spi_bus=spi_bus,
spi_device=MATRIX_SPI_DEVICE,
spi_max_speed_hz=spi_max_speed_hz,
wire_order=wire_order,
brightness=brightness,
passthrough=passthrough,
swap_rg=swap_rg,
)
def spi_strip_config(
@@ -33,14 +67,25 @@ def spi_strip_config(
*,
spi_bus: int = MATRIX_SPI_BUS,
spi_device: int = MATRIX_SPI_DEVICE,
spi_max_speed_hz: int | None = None,
wire_order: str | None = None,
brightness: float = 1.0,
backend: BackendName | None = None,
passthrough: bool = False,
swap_rg: bool = True,
) -> StripConfig:
if backend is None:
backend = "rpi5_ws2812" if board_family() == "pi5" else "spi"
return StripConfig(
count,
backend="spi",
backend=backend,
spi_bus=spi_bus,
spi_device=spi_device,
spi_max_speed_hz=spi_max_speed_hz,
wire_order=wire_order,
brightness=brightness,
passthrough=passthrough,
swap_rg=swap_rg,
)

View File

@@ -61,6 +61,10 @@ class LedMatrix:
brightness=config0.brightness,
spi_bus=config0.spi_bus,
spi_device=config0.spi_device,
spi_max_speed_hz=config0.spi_max_speed_hz,
wire_order=config0.wire_order,
passthrough=config0.passthrough,
swap_rg=config0.swap_rg,
)
else:
total_rows = sum(rows_per_strip)
@@ -148,6 +152,12 @@ class LedMatrix:
assert self._strips is not None
self._strips.show()
def show_rgb_bytes(self, rgb: bytes) -> None:
"""Push a linear RGB frame (row-major) straight to the strip."""
if self._strip is None:
raise RuntimeError("show_rgb_bytes requires a single-strip matrix")
self._strip.show_rgb_bytes(rgb)
def clear(self) -> None:
if self._strip is not None:
self._strip.clear()

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())

View File

@@ -1,31 +1,39 @@
"""UDP helpers for Pico panel firmware."""
"""UDP helpers for Pico panel firmware and Pi-direct panels."""
from __future__ import annotations
import argparse
import asyncio
import os
import socket
import subprocess
import time
from dataclasses import dataclass
from typing import Iterable, Sequence, Tuple
from typing import Any, Iterable, Sequence, Tuple
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
LOCAL_PANEL_INDICES,
MATRIX_HEIGHT,
MATRIX_PIXELS,
MATRIX_WIDTH,
PANEL_COUNT,
PANEL_HOST_BY_INDEX,
PANEL_STRIP_BY_INDEX,
PANEL_WS2812_PIN_BY_INDEX,
PI_BRIDGE_PANEL_INDICES,
matrix_pixel_index,
panel_layout,
panel_pixel_count,
)
from leds.colors import dim
RGB = Tuple[int, int, int]
PANEL_IP_NET = "10.1.1"
PANEL_IP_BASE = 10
PANEL_MAX_FPS = 25.0
RGB = Tuple[int, int, int]
PANEL_MAX_FPS = 15.0
PANEL_SEND_TIMEOUT_S = 0.05
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
@@ -44,15 +52,37 @@ class PanelTarget:
host: str
width: int
height: int
local: bool = False
strip: int = 0
pin: int | None = None
@property
def pixels(self) -> int:
return self.width * self.height
def label(self) -> str:
if self.local:
return f"panel {self.index} Pi-direct ({self.width}×{self.height})"
strip = f" strip {self.strip}" if self.strip else ""
pin = f" GP{self.pin}" if self.pin is not None else ""
if self.index >= 0:
return f"{self.host} ({self.width}×{self.height}, panel {self.index})"
return f"{self.host} ({self.width}×{self.height})"
return f"{self.host} ({self.width}×{self.height}, panel {self.index}{strip}{pin})"
return f"{self.host} ({self.width}×{self.height}{strip}{pin})"
def _make_panel_target(index: int, width: int, height: int) -> PanelTarget:
local = index in LOCAL_PANEL_INDICES
if local:
host = "local"
elif index in PANEL_HOST_BY_INDEX:
host = PANEL_HOST_BY_INDEX[index]
elif index in PI_BRIDGE_PANEL_INDICES:
host = "127.0.0.1" # panel_spi_bridge on this Pi — no 10.1.1.12 on lo
else:
host = panel_ip(index)
strip = PANEL_STRIP_BY_INDEX.get(index, 0)
pin = PANEL_WS2812_PIN_BY_INDEX.get(index)
return PanelTarget(index, host, width, height, local=local, strip=strip, pin=pin)
def panel_hosts(panel_index: int | None = None, count: int = PANEL_COUNT) -> list[str]:
@@ -73,22 +103,81 @@ def resolve_panel_targets(
w = width
if height is not None:
h = height
return [PanelTarget(panel_index, panel_ip(panel_index), w, h)]
return [_make_panel_target(panel_index, w, h)]
if host:
w = width if width is not None else MATRIX_WIDTH
h = height if height is not None else MATRIX_HEIGHT
return [PanelTarget(-1, host, w, h)]
return [
PanelTarget(i, panel_ip(i), *panel_layout(i))
_make_panel_target(i, *panel_layout(i))
for i in range(panels)
]
def format_panel_targets(targets: Sequence[PanelTarget], port: int) -> str:
if len(targets) == 1:
return f"{targets[0].label()}:{port}"
t = targets[0]
suffix = "" if t.local else f":{port}"
return f"{t.label()}{suffix}"
layouts = ", ".join(f"{t.width}×{t.height}" for t in targets)
return f"{len(targets)} panels ({targets[0].host}{targets[-1].host}):{port} [{layouts}]"
udp = [t for t in targets if not t.local]
if udp:
return (
f"{len(targets)} panels ({udp[0].host}{udp[-1].host}):{port} [{layouts}]"
)
return f"{len(targets)} Pi-direct panels [{layouts}]"
def host_is_reachable(host: str) -> bool:
"""Return False for hosts that would block UDP sends (ARP INCOMPLETE/FAILED).
A host may have neigh entries on more than one interface (e.g. eth0 REACHABLE
and wlan0 FAILED). Treat it as reachable if *any* entry is usable.
"""
if host in ("127.0.0.1", "localhost", "::1"):
return True
try:
result = subprocess.run(
["ip", "neigh", "show", host],
capture_output=True,
text=True,
timeout=1,
check=False,
)
lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
if lines:
if any(
"FAILED" not in ln and "INCOMPLETE" not in ln
for ln in lines
):
return True
except (OSError, subprocess.SubprocessError):
pass
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "1", host],
capture_output=True,
timeout=2,
check=False,
)
return result.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def filter_reachable_targets(targets: Sequence[PanelTarget]) -> list[PanelTarget]:
"""Drop offline UDP panels so sendto() does not stall the animation loop on ARP."""
reachable: list[PanelTarget] = []
for target in targets:
if target.local:
reachable.append(target)
elif host_is_reachable(target.host):
reachable.append(target)
else:
print(f"Skipping offline panel {target.index} ({target.host})")
if not reachable:
raise OSError("No reachable panels on the network")
return reachable
def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
@@ -113,6 +202,29 @@ def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--panel-id", type=int, default=None, help="Prefix UDP frames with panel id")
parser.add_argument(
"--pin",
type=int,
default=None,
metavar="GP",
help="Set strip 0 WS2812 GPIO over UDP before sending frames",
)
parser.add_argument(
"--pins",
default=None,
metavar="GP[:LEDS],...",
help="Bind strips 0..n-1, e.g. 28,27 or 28:405,27:351 (per-pin length)",
)
parser.add_argument(
"--include-offline",
action="store_true",
help="Send to all configured panels even if unreachable (ARP stalls hit background thread only)",
)
parser.add_argument(
"--sync-send",
action="store_true",
help="Send UDP on the animation thread (blocks; not recommended)",
)
def add_panel_args(parser: argparse.ArgumentParser) -> None:
@@ -139,13 +251,18 @@ def add_panel_args(parser: argparse.ArgumentParser) -> None:
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
return resolve_panel_targets(
targets = resolve_panel_targets(
args.host,
args.panel_index,
args.panels,
width=getattr(args, "width", None),
height=getattr(args, "height", None),
)
if getattr(args, "include_offline", False):
return targets
if args.panel_index is not None or args.host:
return targets
return filter_reachable_targets(targets)
def panel_count_from_args(args: argparse.Namespace) -> int:
@@ -205,6 +322,79 @@ class HeadlessMatrix:
return bytes(out)
class PanelMatrixBundle:
"""Drawing surfaces for UDP (headless) and Pi-direct (LedMatrix) panels."""
def __init__(
self,
targets: Sequence[PanelTarget],
brightness: float,
) -> None:
from leds.config import panel_strip_config
from leds.matrix import LedMatrix
self.targets = list(targets)
self.matrices: list[tuple[PanelTarget, Any]] = []
self._local: list[LedMatrix] = []
for target in self.targets:
if target.local:
matrix = LedMatrix(
target.width,
target.height,
config0=panel_strip_config(
target.index, target.pixels, brightness=brightness
),
)
self._local.append(matrix)
self.matrices.append((target, matrix))
else:
self.matrices.append(
(target, HeadlessMatrix(target.width, target.height, brightness=brightness))
)
def close(self) -> None:
for matrix in self._local:
matrix.close()
def __enter__(self) -> PanelMatrixBundle:
return self
def __exit__(self, *_exc) -> None:
self.close()
def push_panel_frame(
sock: socket.socket | None,
target: PanelTarget,
matrix: Any,
port: int,
panel_id: int | None,
) -> None:
if target.local:
matrix.show()
else:
if sock is None:
raise ValueError("UDP panel requires a socket")
send_frame(
sock,
target.host,
port,
matrix.rgb_bytes(),
panel_id,
strip=target.strip,
)
def push_panel_frames(
sock: socket.socket | None,
matrices: Sequence[tuple[PanelTarget, Any]],
port: int,
panel_id: int | None,
) -> None:
for target, matrix in matrices:
push_panel_frame(sock, target, matrix, port, panel_id)
def solid_frame_bytes(
color: RGB,
count: int = MATRIX_PIXELS,
@@ -225,33 +415,264 @@ def pixels_to_frame_bytes(
return bytes(out)
def encode_frame(rgb: bytes, panel_id: int | None = None, *, strip: int = 0) -> bytes:
"""Prefix strip index (or explicit panel_id) so multi-strip Picos route correctly."""
prefix = panel_id if panel_id is not None else strip
if prefix is None:
return rgb
# Always prefix when strip!=0; strip 0 may omit for tiny payloads but
# prefixing is harmless and keeps multi-strip hosts consistent.
if panel_id is None and strip == 0:
return rgb
return bytes([prefix & 0xFF]) + rgb
def encode_pin_command(pin: int, strip: int = 0, length: int = 0) -> bytes:
"""UDP PIN command.
strip 0, no length → 5 bytes ``PIN\\0`` + gpio.
Otherwise → 8 bytes ``PIN\\0`` + strip + gpio + len_lo + len_hi (little-endian).
"""
if not 0 <= pin <= 29:
raise ValueError(f"WS2812 pin must be 029, got {pin}")
if not 0 <= strip <= 255:
raise ValueError(f"strip must be 0255, got {strip}")
if length < 0 or length > 0xFFFF:
raise ValueError(f"length must be 065535, got {length}")
if strip == 0 and length == 0:
return b"PIN\0" + bytes([pin & 0xFF])
return b"PIN\0" + bytes(
[strip & 0xFF, pin & 0xFF, length & 0xFF, (length >> 8) & 0xFF]
)
def send_ws2812_pin(
sock: socket.socket,
host: str,
port: int,
pin: int,
strip: int = 0,
length: int = 0,
) -> None:
sock.sendto(encode_pin_command(pin, strip, length), (host, port))
def send_ws2812_pin_to_targets(
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
pin: int,
strip: int = 0,
length: int = 0,
) -> None:
if sock is None:
return
for target in targets:
if target.local:
continue
send_ws2812_pin(sock, target.host, port, pin, strip, length)
extra = f" leds={length}" if length else ""
print(f"WS2812 strip {strip} GP{pin}{extra} -> {target.host}:{port}")
def configure_target_pins(
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
) -> None:
"""Send PIN for each target that has pin set (length = panel pixel count)."""
if sock is None:
return
seen: set[tuple[str, int, int]] = set()
for target in targets:
if target.local or target.pin is None:
continue
key = (target.host, target.strip, target.pin)
if key in seen:
continue
seen.add(key)
length = target.pixels
send_ws2812_pin(sock, target.host, port, target.pin, target.strip, length)
print(
f"WS2812 strip {target.strip} GP{target.pin} leds={length} "
f"-> {target.host}:{port}"
)
def setup_panel_pins(
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
*,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
"""Apply array_config pins, then optional CLI overrides.
``pins`` entries are ``(gpio, length|None)`` — length None means unset.
"""
configure_target_pins(sock, targets, port)
if sock is None:
return
if pins:
hosts = sorted({t.host for t in targets if not t.local})
for host in hosts:
for strip, (gp, length) in enumerate(pins):
leds = length or 0
send_ws2812_pin(sock, host, port, gp, strip, leds)
extra = f" leds={leds}" if leds else ""
print(f"WS2812 strip {strip} GP{gp}{extra} -> {host}:{port}")
return
if pin is not None:
send_ws2812_pin_to_targets(sock, targets, port, pin, strip=0)
def parse_pins_arg(args: argparse.Namespace) -> list[tuple[int, int | None]] | None:
"""Parse ``--pins 28,27`` or ``--pins 28:405,27:351`` → [(gpio, leds|None), ...]."""
raw = getattr(args, "pins", None)
if not raw:
return None
out: list[tuple[int, int | None]] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
if ":" in part:
gp_s, led_s = part.split(":", 1)
out.append((int(gp_s.strip()), int(led_s.strip())))
else:
out.append((int(part), None))
return out or None
def expand_targets_for_pins(
targets: Sequence[PanelTarget],
pins: Sequence[tuple[int, int | None]],
) -> list[PanelTarget]:
"""One PanelTarget per strip so frames reach every GPIO from ``--pins``."""
out: list[PanelTarget] = []
for target in targets:
if target.local:
out.append(target)
continue
for strip, (gp, length) in enumerate(pins):
if length and length > 0 and target.height > 0 and length % target.height == 0:
width = length // target.height
height = target.height
elif length and length > 0:
width, height = length, 1
else:
width, height = target.width, target.height
out.append(
PanelTarget(
target.index,
target.host,
width,
height,
local=False,
strip=strip,
pin=gp,
)
)
return out
def apply_pin_arg(
args: argparse.Namespace,
sock: socket.socket | None,
targets: Sequence[PanelTarget],
) -> list[PanelTarget]:
"""Send PIN commands; return targets expanded for ``--pins`` multi-strip."""
pins = parse_pins_arg(args)
pin = getattr(args, "pin", None)
setup_panel_pins(
sock,
targets,
args.port,
pin=pin,
pins=pins,
)
if pins:
return expand_targets_for_pins(targets, pins)
if pin is not None:
return [
PanelTarget(
t.index,
t.host,
t.width,
t.height,
local=t.local,
strip=t.strip,
pin=pin if not t.local else t.pin,
)
for t in targets
]
return list(targets)
def send_frame(
sock: socket.socket,
host: str,
port: int,
rgb: bytes,
panel_id: int | None,
*,
strip: int = 0,
) -> None:
payload = (bytes([panel_id & 0xFF]) + rgb) if panel_id is not None else rgb
sock.sendto(payload, (host, port))
# Firmware expects logical RGB; converts to GRB on the wire.
sock.sendto(encode_frame(rgb, panel_id, strip=strip), (host, port))
async def send_frames_async(
sock: socket.socket | None,
matrices: Sequence[tuple[PanelTarget, Any]],
port: int,
panel_id: int | None,
*,
sync_send: bool = False,
send_timeout: float = PANEL_SEND_TIMEOUT_S,
) -> None:
"""Send one frame to every panel (UDP async, Pi-direct via show())."""
if sync_send:
push_panel_frames(sock, matrices, port, panel_id)
return
tasks: list[asyncio.Task[None]] = []
for target, matrix in matrices:
if target.local:
matrix.show()
else:
tasks.append(
asyncio.create_task(
asyncio.to_thread(
send_frame,
sock,
target.host,
port,
matrix.rgb_bytes(),
panel_id,
strip=target.strip,
)
)
)
if not tasks:
return
_, pending = await asyncio.wait(tasks, timeout=send_timeout)
for task in pending:
task.cancel()
def send_solid_to_targets(
sock: socket.socket,
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
color: RGB,
brightness: float,
panel_id: int | None,
) -> None:
for target in targets:
send_frame(
sock,
target.host,
port,
solid_frame_bytes(color, target.pixels, brightness),
panel_id,
)
with PanelMatrixBundle(targets, brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.fill(color)
push_panel_frames(sock, bundle.matrices, port, panel_id)
class PanelClient:
@@ -278,17 +699,27 @@ class PanelClient:
self.port = port
self.panel_id = panel_id
self.brightness = brightness
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._has_local = any(t.local for t in self.targets)
self._bundle: PanelMatrixBundle | None = None
if self._has_local:
self._bundle = PanelMatrixBundle(self.targets, brightness)
self._sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if any(not t.local for t in self.targets)
else None
)
@classmethod
def from_args(cls, args: argparse.Namespace) -> PanelClient:
return cls(
client = cls(
panel_targets_from_args(args),
args.port,
pixel_count=panel_count_from_args(args),
panel_id=args.panel_id,
brightness=args.brightness,
)
client.targets = apply_pin_arg(args, client._sock, client.targets)
return client
@property
def pixel_count(self) -> int:
@@ -301,7 +732,11 @@ class PanelClient:
return [t.host for t in self.targets]
def close(self) -> None:
self._sock.close()
if self._sock is not None:
self._sock.close()
if self._bundle is not None:
self._bundle.close()
self._bundle = None
def __enter__(self) -> PanelClient:
return self
@@ -314,11 +749,42 @@ class PanelClient:
if len(self.targets) != 1:
raise ValueError("send_bytes requires a single panel target")
target = self.targets[0]
if target.local:
if self._bundle is None:
raise RuntimeError("local panel bundle not initialized")
_, matrix = self._bundle.matrices[0]
if len(rgb) != target.pixels * 3:
raise ValueError(f"expected {target.pixels * 3} bytes, got {len(rgb)}")
for i in range(target.pixels):
base = i * 3
matrix.set_pixel(
i % target.width,
i // target.width,
(rgb[base], rgb[base + 1], rgb[base + 2]),
)
matrix.show()
return
if self._sock is None:
raise RuntimeError("UDP socket not initialized")
if len(rgb) != target.pixels * 3:
raise ValueError(f"expected {target.pixels * 3} bytes, got {len(rgb)}")
send_frame(self._sock, target.host, self.port, rgb, self.panel_id)
send_frame(
self._sock,
target.host,
self.port,
rgb,
self.panel_id,
strip=target.strip,
)
def fill(self, color: RGB = (0, 0, 0)) -> None:
if self._bundle is not None:
for _, matrix in self._bundle.matrices:
matrix.fill(color)
push_panel_frames(self._sock, self._bundle.matrices, self.port, self.panel_id)
return
if self._sock is None:
raise RuntimeError("UDP socket not initialized")
for target in self.targets:
send_frame(
self._sock,
@@ -326,6 +792,7 @@ class PanelClient:
self.port,
solid_frame_bytes(color, target.pixels, self.brightness),
self.panel_id,
strip=target.strip,
)
def send_pixels(self, pixels: list[RGB]) -> None:
@@ -349,8 +816,59 @@ class PanelClient:
def _panel_matrices(
targets: Sequence[PanelTarget],
brightness: float,
) -> list[tuple[PanelTarget, HeadlessMatrix]]:
return [(t, HeadlessMatrix(t.width, t.height, brightness=brightness)) for t in targets]
) -> PanelMatrixBundle:
return PanelMatrixBundle(targets, brightness)
async def run_panel_playlist_async(
targets: Sequence[PanelTarget],
port: int,
playlist: list[str],
*,
duration: float = 12.0,
fps_override: float | None = None,
brightness: float = 0.25,
panel_id: int | None = None,
loop: bool = True,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
bundle = _panel_matrices(targets, brightness)
sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if any(not t.local for t in targets)
else None
)
try:
setup_panel_pins(sock, targets, port, pin=pin, pins=pins)
while True:
for name in playlist:
draw = ANIMATIONS[name]
fps = min(fps_override or DEFAULT_FPS.get(name, 30), PANEL_MAX_FPS)
delay = 1.0 / fps
frames = max(int(duration * fps), 1)
print(f" {name} ({fps:.0f} fps, {frames} frames)")
for frame in range(frames):
t0 = time.monotonic()
for _, matrix in bundle.matrices:
draw(matrix, frame)
await send_frames_async(
sock, bundle.matrices, port, panel_id, sync_send=sync_send
)
elapsed = time.monotonic() - t0
if elapsed < delay:
await asyncio.sleep(delay - elapsed)
if not loop:
break
print(" --- loop ---")
for _, matrix in bundle.matrices:
matrix.clear()
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
finally:
if sock is not None:
sock.close()
bundle.close()
def run_panel_playlist(
@@ -363,33 +881,69 @@ def run_panel_playlist(
brightness: float = 0.25,
panel_id: int | None = None,
loop: bool = True,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
matrices = _panel_matrices(targets, brightness)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
asyncio.run(
run_panel_playlist_async(
targets,
port,
playlist,
duration=duration,
fps_override=fps_override,
brightness=brightness,
panel_id=panel_id,
loop=loop,
sync_send=sync_send,
pin=pin,
pins=pins,
)
)
async def run_panel_animation_loop_async(
targets: Sequence[PanelTarget],
port: int,
animation: str,
*,
fps: float | None = None,
brightness: float = 0.25,
panel_id: int | None = None,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
draw = ANIMATIONS[animation]
rate = min(fps or DEFAULT_FPS.get(animation, 30), PANEL_MAX_FPS)
interval = 1.0 / rate
bundle = _panel_matrices(targets, brightness)
sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if any(not t.local for t in targets)
else None
)
frame = 0
try:
setup_panel_pins(
sock, targets, port, pin=pin, pins=pins
)
while True:
for name in playlist:
draw = ANIMATIONS[name]
fps = min(fps_override or DEFAULT_FPS.get(name, 30), PANEL_MAX_FPS)
delay = 1.0 / fps
frames = max(int(duration * fps), 1)
print(f" {name} ({fps:.0f} fps, {frames} frames)")
for frame in range(frames):
t0 = time.monotonic()
for target, matrix in matrices:
draw(matrix, frame)
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
elapsed = time.monotonic() - t0
if elapsed < delay:
time.sleep(delay - elapsed)
if not loop:
break
print(" --- loop ---")
for target, matrix in matrices:
matrix.clear()
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
t0 = time.monotonic()
for _, matrix in bundle.matrices:
draw(matrix, frame)
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
frame += 1
elapsed = time.monotonic() - t0
if elapsed < interval:
await asyncio.sleep(interval - elapsed)
finally:
sock.close()
for _, matrix in bundle.matrices:
matrix.clear()
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
if sock is not None:
sock.close()
bundle.close()
def run_panel_animation_loop(
@@ -400,28 +954,23 @@ def run_panel_animation_loop(
fps: float | None = None,
brightness: float = 0.25,
panel_id: int | None = None,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
draw = ANIMATIONS[animation]
rate = min(fps or DEFAULT_FPS.get(animation, 30), PANEL_MAX_FPS)
interval = 1.0 / rate
matrices = _panel_matrices(targets, brightness)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
frame = 0
try:
while True:
t0 = time.monotonic()
for target, matrix in matrices:
draw(matrix, frame)
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
frame += 1
elapsed = time.monotonic() - t0
if elapsed < interval:
time.sleep(interval - elapsed)
finally:
for target, matrix in matrices:
matrix.clear()
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
sock.close()
asyncio.run(
run_panel_animation_loop_async(
targets,
port,
animation,
fps=fps,
brightness=brightness,
panel_id=panel_id,
sync_send=sync_send,
pin=pin,
pins=pins,
)
)
def animation_playlist(animation: str) -> list[str]:

69
leds/portal_sim.py Normal file
View File

@@ -0,0 +1,69 @@
"""Render portal animations for the web simulator."""
from __future__ import annotations
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
MATRIX_BRIGHTNESS,
PANEL_COUNT,
PANEL_FACE_BY_INDEX,
PANEL_POSITION_BY_INDEX,
PORTAL_PANEL_ORDER_CLOCKWISE,
TOP_PANEL_INDEX,
panel_layout,
panel_label,
)
from leds.panel_udp import HeadlessMatrix
def portal_config() -> dict:
panels = []
for index in range(PANEL_COUNT):
width, height = panel_layout(index)
panels.append(
{
"index": index,
"width": width,
"height": height,
"position": PANEL_POSITION_BY_INDEX.get(index),
"face": PANEL_FACE_BY_INDEX.get(index),
"label": panel_label(index),
}
)
return {
"panels": panels,
"topPanel": TOP_PANEL_INDEX,
"orderClockwise": list(PORTAL_PANEL_ORDER_CLOCKWISE),
"animations": list(ANIMATIONS.keys()),
"defaultFps": DEFAULT_FPS,
"defaultBrightness": MATRIX_BRIGHTNESS,
}
def render_portal_frame(
animation: str,
frame: int,
*,
brightness: float = MATRIX_BRIGHTNESS,
) -> dict:
if animation not in ANIMATIONS:
raise ValueError(f"unknown animation: {animation}")
draw = ANIMATIONS[animation]
panels = []
for index in range(PANEL_COUNT):
width, height = panel_layout(index)
matrix = HeadlessMatrix(width, height, brightness=brightness)
draw(matrix, frame)
panels.append(
{
"index": index,
"width": width,
"height": height,
"rgb": list(matrix.rgb_bytes()),
}
)
return {
"frame": frame,
"animation": animation,
"panels": panels,
}

145
leds/portal_web.py Normal file
View File

@@ -0,0 +1,145 @@
"""FastAPI app for the portal web simulator."""
from __future__ import annotations
import asyncio
import json
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from leds.animations import DEFAULT_FPS
from leds.portal_sim import portal_config, render_portal_frame
WEB_DIR = Path(__file__).resolve().parent.parent / "web"
_reload_queues: list[asyncio.Queue[str]] = []
def _dev_reload_enabled() -> bool:
return os.environ.get("PORTAL_DEV_RELOAD", "0") == "1"
async def _notify_reload_clients() -> None:
for queue in list(_reload_queues):
await queue.put("reload")
async def _watch_web_files() -> None:
from watchfiles import awatch
async for _changes in awatch(WEB_DIR):
await _notify_reload_clients()
@asynccontextmanager
async def _lifespan(_app: FastAPI):
watcher: asyncio.Task | None = None
if _dev_reload_enabled():
watcher = asyncio.create_task(_watch_web_files())
yield
if watcher is not None:
watcher.cancel()
try:
await watcher
except asyncio.CancelledError:
pass
def create_app() -> FastAPI:
app = FastAPI(title="Portal Simulator", lifespan=_lifespan)
@app.get("/")
async def index() -> HTMLResponse:
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
if _dev_reload_enabled():
snippet = '<script type="module" src="/static/js/dev-reload.js"></script>'
html = html.replace("</body>", f" {snippet}\n </body>")
return HTMLResponse(html)
@app.get("/api/config")
async def api_config() -> dict:
return portal_config()
@app.get("/api/frame")
async def api_frame(
animation: str = Query("rainbow"),
frame: int = Query(0),
brightness: float = Query(0.35),
) -> dict:
try:
return await asyncio.to_thread(
render_portal_frame, animation, frame, brightness=brightness
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/stream")
async def api_stream(
animation: str = Query("rainbow"),
brightness: float = Query(0.35),
fps: float = Query(None),
) -> StreamingResponse:
rate = fps if fps is not None else DEFAULT_FPS.get(animation, 25)
rate = max(1.0, min(rate, 60.0))
delay = 1.0 / rate
async def generate() -> object:
frame = 0
while True:
payload = await asyncio.to_thread(
render_portal_frame,
animation,
frame,
brightness=brightness,
)
yield f"data: {json.dumps(payload)}\n\n"
frame += 1
await asyncio.sleep(delay)
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
@app.get("/api/dev/reload")
async def api_dev_reload() -> StreamingResponse:
if not _dev_reload_enabled():
raise HTTPException(status_code=404)
queue: asyncio.Queue[str] = asyncio.Queue()
_reload_queues.append(queue)
async def generate() -> object:
try:
yield "data: connected\n\n"
while True:
message = await queue.get()
yield f"data: {message}\n\n"
finally:
if queue in _reload_queues:
_reload_queues.remove(queue)
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
@app.get("/favicon.ico", include_in_schema=False)
async def favicon() -> FileResponse:
path = WEB_DIR / "favicon.ico"
if path.is_file():
return FileResponse(path)
raise HTTPException(status_code=404)
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
return app
app = create_app()

View File

@@ -5,13 +5,15 @@ from __future__ import annotations
from typing import Literal, Sequence, Tuple, Union
from leds.backends.pio import PioBackend
from leds.backends.rpi5_ws2812 import Rpi5Ws2812Backend
from leds.backends.spi import SpiBackend
from leds.backends.ws281x import Ws281xBackend
from leds.config import pi5_setup_hint
from leds.detect import board_family, board_model, pio_available, supports_ws281x
from leds.wire import normalize_wire_order, rgb_bytes_to_wire, swap_rg_bytes
Color = Union[Tuple[int, int, int], Sequence[int]]
BackendName = Literal["auto", "pio", "spi", "ws281x"]
BackendName = Literal["auto", "pio", "spi", "rpi5_ws2812", "ws281x"]
def _clamp(value: int) -> int:
@@ -41,10 +43,12 @@ def _resolve_backend(name: BackendName) -> str:
)
if name == "pio" and board_family() == "pi5" and not pio_available():
raise RuntimeError(pi5_setup_hint(10, 10))
if name == "rpi5_ws2812" and board_family() != "pi5":
raise RuntimeError("rpi5_ws2812 backend requires Raspberry Pi 5")
return name
if board_family() == "pi5":
return "pio" if pio_available() else "spi"
return "rpi5_ws2812"
return "ws281x"
@@ -53,7 +57,8 @@ class LedStrip:
Drive a WS2812 / NeoPixel strip on Pi 5, 4, or Zero.
Default backends:
- Pi 5: ws2812-pio on GPIO 18/13 (/dev/leds0, /dev/leds1)
- Pi 5: rpi5-ws2812 SPI on GPIO 10 (/dev/spidev0.0)
- Pi 5 (pio): ws2812-pio on GPIO 18/13 (/dev/leds0, /dev/leds1) when backend='pio'
- Pi 4 / Zero: PWM on GPIO 18/13 via rpi_ws281x
"""
@@ -68,20 +73,35 @@ class LedStrip:
brightness: float = 1.0,
spi_bus: int = 0,
spi_device: int = 0,
spi_max_speed_hz: int | None = None,
wire_order: str | None = None,
passthrough: bool = False,
swap_rg: bool = True,
) -> None:
if count < 1:
raise ValueError("count must be at least 1")
self._count = count
self._brightness = max(0.0, min(1.0, brightness))
self._passthrough = passthrough
self._swap_rg = swap_rg
self._wire_order = normalize_wire_order(wire_order)
self._pixels: list[Tuple[int, int, int]] = [(0, 0, 0)] * count
resolved = _resolve_backend(backend)
if resolved == "pio":
self._backend = PioBackend(count, device=device)
elif resolved == "spi":
spi_kwargs: dict[str, int] = {}
if spi_max_speed_hz is not None:
spi_kwargs["max_speed_hz"] = spi_max_speed_hz
self._backend = SpiBackend(
count, spi_bus=spi_bus, spi_device=spi_device
count, spi_bus=spi_bus, spi_device=spi_device, **spi_kwargs
)
elif resolved == "rpi5_ws2812":
hz = spi_max_speed_hz if spi_max_speed_hz is not None else 4_200_000
self._backend = Rpi5Ws2812Backend(
count, spi_bus=spi_bus, spi_device=spi_device, max_speed_hz=hz
)
else:
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
@@ -121,14 +141,43 @@ class LedStrip:
self[index] = color
def show(self) -> None:
grb = bytearray(self._count * 3)
self._write_frame(self._logical_frame_bytes())
def show_rgb_bytes(self, rgb: bytes) -> None:
"""Push pre-dimmed RGB frame to the strip (skips pixel buffer)."""
if len(rgb) != self._count * 3:
raise ValueError(f"expected {self._count * 3} bytes, got {len(rgb)}")
scale = self._brightness
if scale >= 1.0:
logical = rgb
else:
dimmed = bytearray(len(rgb))
for i in range(0, len(rgb), 3):
dimmed[i] = int(rgb[i] * scale)
dimmed[i + 1] = int(rgb[i + 1] * scale)
dimmed[i + 2] = int(rgb[i + 2] * scale)
logical = bytes(dimmed)
self._write_frame(logical)
def _write_frame(self, logical_rgb: bytes) -> None:
frame = swap_rg_bytes(logical_rgb) if self._swap_rg else logical_rgb
if self._passthrough:
self._backend.write_grb(frame)
return
self._backend.write_grb(self._encode_frame_bytes(frame))
def _logical_frame_bytes(self) -> bytes:
logical = bytearray(self._count * 3)
for i, (r, g, b) in enumerate(self._pixels):
r, g, b = _apply_brightness((r, g, b), self._brightness)
base = i * 3
grb[base] = g
grb[base + 1] = r
grb[base + 2] = b
self._backend.write_grb(bytes(grb))
logical[base] = r
logical[base + 1] = g
logical[base + 2] = b
return bytes(logical)
def _encode_frame_bytes(self, logical_rgb: bytes) -> bytes:
return rgb_bytes_to_wire(logical_rgb, self._wire_order)
def clear(self) -> None:
self.fill((0, 0, 0))

View File

@@ -72,4 +72,8 @@ def _strip_from_config(config: StripConfig) -> LedStrip:
brightness=config.brightness,
spi_bus=config.spi_bus,
spi_device=config.spi_device,
spi_max_speed_hz=config.spi_max_speed_hz,
wire_order=config.wire_order,
passthrough=config.passthrough,
swap_rg=config.swap_rg,
)

View File

@@ -1,7 +1,8 @@
"""5×7 bitmap text for LED matrices."""
"""5×7 bitmap text and optional TrueType (cursive) rendering for LED matrices."""
from __future__ import annotations
from pathlib import Path
from typing import Tuple
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
@@ -53,6 +54,15 @@ CHAR_WIDTH = 5
CHAR_HEIGHT = 7
CHAR_SPACING = 1
CURSIVE_FONT_CANDIDATES = (
# Prefer readable italics at 9px; true script fonts turn to mush.
"/usr/share/fonts/truetype/liberation/LiberationSerif-Italic.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Italic.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
"/usr/share/fonts/opentype/dancingscript/DancingScript-Bold.otf",
"/usr/share/fonts/opentype/dancingscript/DancingScript-Regular.otf",
)
def text_width(text: str, spacing: int = CHAR_SPACING) -> int:
text = text.upper()
@@ -122,3 +132,160 @@ def draw_text_centered(
x = max(0, (w - tw) // 2)
y = max(0, (h - th) // 2)
draw_text(surface, text, x, y, color, scale=scale, spacing=spacing)
def resolve_cursive_font(path: str | None = None) -> str:
if path:
if not Path(path).is_file():
raise FileNotFoundError(f"font not found: {path}")
return path
for candidate in CURSIVE_FONT_CANDIDATES:
if Path(candidate).is_file():
return candidate
raise FileNotFoundError(
"No cursive font found. Install with: sudo apt install fonts-dancingscript"
)
def render_font_mask(
text: str,
*,
height: int = MATRIX_HEIGHT,
font_path: str | None = None,
size: int | None = None,
threshold: int = 100,
letter_spacing: int = 1,
) -> tuple[int, int, list[tuple[int, int]]]:
"""Rasterize ``text`` into on-pixels for an LED row height.
Letters are drawn one-by-one with spacing so names stay readable on
short matrices. Returns ``(width, height, [(x, y), ...])``.
"""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError as exc:
raise ImportError(
"Pillow is required for cursive text (pipenv install pillow)"
) from exc
path = resolve_cursive_font(font_path)
# Slightly larger than panel height reads better after vertical fit.
point_size = size if size is not None else max(10, height + 3)
font = ImageFont.truetype(path, point_size)
glyphs: list[Image.Image] = []
for ch in text:
bbox = font.getbbox(ch)
gw = max(1, bbox[2] - bbox[0])
gh = max(1, bbox[3] - bbox[1])
g = Image.new("L", (gw + 2, height), 0)
draw = ImageDraw.Draw(g)
y = (height - gh) // 2 - bbox[1]
draw.text((1 - bbox[0], y), ch, font=font, fill=255)
glyphs.append(g)
total_w = sum(g.width for g in glyphs)
if glyphs:
total_w += letter_spacing * (len(glyphs) - 1)
canvas = Image.new("L", (max(1, total_w), height), 0)
x = 0
for i, g in enumerate(glyphs):
canvas.paste(g.point(lambda v: 255 if v > threshold else 0), (x, 0))
x += g.width + (letter_spacing if i < len(glyphs) - 1 else 0)
pixels = canvas.load()
xs = [
xx
for xx in range(canvas.width)
for yy in range(height)
if pixels[xx, yy] > 0
]
if not xs:
return 0, height, []
left, right = min(xs), max(xs) + 1
ons: list[tuple[int, int]] = []
for yy in range(height):
for xx in range(left, right):
if pixels[xx, yy] > 0:
ons.append((xx - left, yy))
return right - left, height, ons
def blit_mask(
surface,
ons: list[tuple[int, int]],
x: int,
y: int,
color: RGB,
) -> None:
for px, py in ons:
_set(surface, x + px, y + py, color)
def blit_mask_gradient(
surface,
ons: list[tuple[int, int]],
x: int,
y: int,
*,
width: int,
phase: float = 0.0,
cycles: float = 2.0,
) -> None:
"""Blit mask with a horizontal rainbow (hue shifts with ``phase``).
``cycles`` = how many full rainbows span the text width (higher = more colorful).
"""
from leds.colors import hsv_to_rgb
span = max(1, width)
for px, py in ons:
hue = ((px / span) * cycles + phase) % 1.0
_set(surface, x + px, y + py, hsv_to_rgb(hue, 1.0, 1.0))
def draw_font_text(
surface,
text: str,
x: int,
y: int,
color: RGB,
*,
font_path: str | None = None,
size: int | None = None,
letter_spacing: int = 1,
) -> int:
"""Draw TrueType text; returns rendered width in pixels."""
h = getattr(surface, "height", MATRIX_HEIGHT)
width, _, ons = render_font_mask(
text,
height=h,
font_path=font_path,
size=size,
letter_spacing=letter_spacing,
)
blit_mask(surface, ons, x, y, color)
return width
def draw_font_text_centered(
surface,
text: str,
color: RGB,
*,
font_path: str | None = None,
size: int | None = None,
letter_spacing: int = 1,
) -> int:
w = getattr(surface, "width", MATRIX_WIDTH)
h = getattr(surface, "height", MATRIX_HEIGHT)
width, _, ons = render_font_mask(
text,
height=h,
font_path=font_path,
size=size,
letter_spacing=letter_spacing,
)
x = max(0, (w - width) // 2)
blit_mask(surface, ons, x, 0, color)
return width

47
leds/wire.py Normal file
View File

@@ -0,0 +1,47 @@
"""Map logical RGB to WS2812 wire byte order."""
from __future__ import annotations
from leds.array_config import WIRE_ORDER
# wire[i] = rgb_channel[perm[i]] where rgb = (r, g, b)
_PERM: dict[str, tuple[int, int, int]] = {
"rgb": (0, 1, 2),
"grb": (1, 0, 2),
"rbg": (0, 2, 1),
"brg": (2, 0, 1),
"bgr": (2, 1, 0),
"gbr": (1, 2, 0),
}
def normalize_wire_order(order: str | None = None) -> str:
key = (order or WIRE_ORDER).lower()
if key not in _PERM:
raise ValueError(f"unknown wire order {key!r}; use one of {sorted(_PERM)}")
return key
def rgb_to_wire(r: int, g: int, b: int, order: str | None = None) -> tuple[int, int, int]:
perm = _PERM[normalize_wire_order(order)]
ch = (r, g, b)
return ch[perm[0]], ch[perm[1]], ch[perm[2]]
def rgb_bytes_to_wire(rgb: bytes, order: str | None = None) -> bytes:
perm = _PERM[normalize_wire_order(order)]
out = bytearray(len(rgb))
for i in range(0, len(rgb), 3):
ch = rgb[i], rgb[i + 1], rgb[i + 2]
out[i] = ch[perm[0]]
out[i + 1] = ch[perm[1]]
out[i + 2] = ch[perm[2]]
return bytes(out)
def swap_rg_bytes(rgb: bytes) -> bytes:
"""Swap R and G channels (hardware examples only — not the web simulator)."""
out = bytearray(rgb)
for i in range(0, len(out), 3):
out[i], out[i + 1] = out[i + 1], out[i]
return bytes(out)