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:
@@ -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 0–4 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
54
leds/backends/rpi5_ws2812.py
Normal file
54
leds/backends/rpi5_ws2812.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
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())
|
||||
@@ -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 0–29, got {pin}")
|
||||
if not 0 <= strip <= 255:
|
||||
raise ValueError(f"strip must be 0–255, got {strip}")
|
||||
if length < 0 or length > 0xFFFF:
|
||||
raise ValueError(f"length must be 0–65535, 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
69
leds/portal_sim.py
Normal 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
145
leds/portal_web.py
Normal 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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
169
leds/text.py
169
leds/text.py
@@ -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
47
leds/wire.py
Normal 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)
|
||||
Reference in New Issue
Block a user