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

View File

@@ -0,0 +1 @@

58
leds/backends/pio.py Normal file
View File

@@ -0,0 +1,58 @@
"""WS2812 via Pi 5 kernel ws2812-pio driver (/dev/ledsN)."""
from __future__ import annotations
from pathlib import Path
class PioBackend:
def __init__(self, led_count: int, *, device: str = "/dev/leds0") -> None:
self._led_count = led_count
self._device = device
self._path = Path(device)
if not self._path.exists():
raise FileNotFoundError(
f"{device} not found — enable ws2812-pio in /boot/firmware/config.txt "
"and reboot. Run: pipenv run python examples/setup_pi5_leds.py"
)
@property
def led_count(self) -> int:
return self._led_count
@property
def device(self) -> str:
return self._device
def write_grb(self, grb_bytes: bytes) -> None:
if len(grb_bytes) != self._led_count * 3:
raise ValueError(
f"expected {self._led_count * 3} bytes, got {len(grb_bytes)}"
)
# Kernel driver expects RGBW uint32 little-endian per pixel (W=0 for RGB).
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]
offset = i * 4
frame[offset] = r
frame[offset + 1] = g
frame[offset + 2] = b
frame[offset + 3] = 0
with open(self._path, "wb") as dev:
dev.write(frame)
def clear(self) -> None:
with open(self._path, "wb") as dev:
dev.write(b"\x00" * (self._led_count * 4))
def close(self) -> None:
self.clear()
def __enter__(self) -> PioBackend:
return self
def __exit__(self, *_exc) -> None:
self.close()

81
leds/backends/spi.py Normal file
View File

@@ -0,0 +1,81 @@
"""WS2812 over SPI — works on Pi 5, 4, and Zero when SPI is enabled."""
from __future__ import annotations
from array import array
try:
from spidev import SpiDev
except ImportError as exc: # pragma: no cover - import guard
SpiDev = None
_SPI_IMPORT_ERROR = exc
else:
_SPI_IMPORT_ERROR = None
# WS2812 bit patterns via SPI, from https://github.com/mattaw/ws2812_spi_python
_LED_ZERO = 0b1100_0000
_LED_ONE = 0b1111_1100
_PREAMBLE = 42
class SpiBackend:
def __init__(
self,
led_count: int,
*,
spi_bus: int = 0,
spi_device: int = 0,
max_speed_hz: int = 6_500_000,
) -> None:
if SpiDev is None:
raise ImportError(
"spidev is required for the SPI backend; install portal requirements"
) from _SPI_IMPORT_ERROR
self._led_count = led_count
self._device = SpiDev()
self._device.open(spi_bus, spi_device)
self._device.max_speed_hz = max_speed_hz
self._device.mode = 0b00
self._device.lsbfirst = False
frame_len = _PREAMBLE + led_count * 24
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
@property
def led_count(self) -> int:
return self._led_count
def write_grb(self, grb_bytes: bytes) -> None:
if len(grb_bytes) != self._led_count * 3:
raise ValueError(
f"expected {self._led_count * 3} bytes, got {len(grb_bytes)}"
)
out = self._frame
pos = _PREAMBLE
for value in grb_bytes:
for bit in (128, 64, 32, 16, 8, 4, 2, 1):
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)
def clear(self) -> None:
self._device.writebytes2(self._clear_frame)
def close(self) -> None:
try:
self.clear()
finally:
self._device.close()
def __enter__(self) -> SpiBackend:
return self
def __exit__(self, *_exc) -> None:
self.close()

73
leds/backends/ws281x.py Normal file
View File

@@ -0,0 +1,73 @@
"""WS2812 via PWM/DMA — Pi 4 and Zero only (not Pi 5)."""
from __future__ import annotations
try:
from rpi_ws281x import PixelStrip, ws
except ImportError as exc: # pragma: no cover - import guard
PixelStrip = None
ws = None
_WS281X_IMPORT_ERROR = exc
else:
_WS281X_IMPORT_ERROR = None
class Ws281xBackend:
def __init__(
self,
led_count: int,
*,
pin: int = 18,
freq_hz: int = 800_000,
dma: int = 10,
invert: bool = False,
channel: int = 0,
) -> None:
if PixelStrip is None:
raise ImportError(
"rpi-ws281x is required for the ws281x backend; install portal requirements"
) from _WS281X_IMPORT_ERROR
self._led_count = led_count
self._strip = PixelStrip(
led_count,
pin,
freq_hz,
dma,
invert,
255,
channel,
strip_type=ws.WS2811_STRIP_GRB,
)
self._strip.begin()
@property
def led_count(self) -> int:
return self._led_count
def write_grb(self, grb_bytes: bytes) -> None:
if len(grb_bytes) != self._led_count * 3:
raise ValueError(
f"expected {self._led_count * 3} bytes, got {len(grb_bytes)}"
)
strip = self._strip
for i in range(self._led_count):
base = i * 3
g, r, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
strip.setPixelColor(i, (r << 16) | (g << 8) | b)
strip.show()
def clear(self) -> None:
for i in range(self._led_count):
self._strip.setPixelColor(i, 0)
self._strip.show()
def close(self) -> None:
self.clear()
def __enter__(self) -> Ws281xBackend:
return self
def __exit__(self, *_exc) -> None:
self.close()