Add multi-panel Pico UDP firmware and Python LED control.

Pico panels get static IPs on 10.1.1.10–14 with per-panel LED counts, Makefile deploy targets, and Python examples for animations, sync tests, and direct Pi SPI control.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-28 22:46:08 +12:00
parent d8323bb9a3
commit d5cab2efdf
52 changed files with 4677 additions and 1 deletions

144
leds/strip.py Normal file
View File

@@ -0,0 +1,144 @@
"""Unified WS2812 strip API for Raspberry Pi 5, 4, and Zero."""
from __future__ import annotations
from typing import Literal, Sequence, Tuple, Union
from leds.backends.pio import PioBackend
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
Color = Union[Tuple[int, int, int], Sequence[int]]
BackendName = Literal["auto", "pio", "spi", "ws281x"]
def _clamp(value: int) -> int:
return max(0, min(255, int(value)))
def _normalize_color(color: Color) -> Tuple[int, int, int]:
if len(color) != 3:
raise ValueError("color must be (r, g, b)")
return _clamp(color[0]), _clamp(color[1]), _clamp(color[2])
def _apply_brightness(color: Tuple[int, int, int], brightness: float) -> Tuple[int, int, int]:
scale = max(0.0, min(1.0, brightness))
return (
int(color[0] * scale),
int(color[1] * scale),
int(color[2] * scale),
)
def _resolve_backend(name: BackendName) -> str:
if name != "auto":
if name == "ws281x" and not supports_ws281x():
raise RuntimeError(
f"{board_model()} does not support ws281x; use backend='pio' or 'spi'"
)
if name == "pio" and board_family() == "pi5" and not pio_available():
raise RuntimeError(pi5_setup_hint(10, 10))
return name
if board_family() == "pi5":
return "pio" if pio_available() else "spi"
return "ws281x"
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 4 / Zero: PWM on GPIO 18/13 via rpi_ws281x
"""
def __init__(
self,
count: int,
*,
backend: BackendName = "auto",
pin: int = 18,
channel: int = 0,
device: str = "/dev/leds0",
brightness: float = 1.0,
spi_bus: int = 0,
spi_device: int = 0,
) -> 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._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":
self._backend = SpiBackend(
count, spi_bus=spi_bus, spi_device=spi_device
)
else:
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
self._backend_name = resolved
@property
def count(self) -> int:
return self._count
@property
def backend(self) -> str:
return self._backend_name
@property
def brightness(self) -> float:
return self._brightness
@brightness.setter
def brightness(self, value: float) -> None:
self._brightness = max(0.0, min(1.0, value))
def __len__(self) -> int:
return self._count
def __getitem__(self, index: int) -> Tuple[int, int, int]:
return self._pixels[index]
def __setitem__(self, index: int, color: Color) -> None:
self._pixels[index] = _normalize_color(color)
def fill(self, color: Color) -> None:
rgb = _normalize_color(color)
self._pixels = [rgb] * self._count
def set_pixel(self, index: int, color: Color) -> None:
self[index] = color
def show(self) -> None:
grb = 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))
def clear(self) -> None:
self.fill((0, 0, 0))
self._backend.clear()
def close(self) -> None:
self._backend.close()
def __enter__(self) -> LedStrip:
return self
def __exit__(self, *_exc) -> None:
self.close()