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

168
leds/matrix.py Normal file
View File

@@ -0,0 +1,168 @@
"""2D WS2812 matrix mapped onto one or two strips."""
from __future__ import annotations
from typing import Literal, Sequence, Tuple
from leds.array_config import (
MATRIX_BRIGHTNESS,
MATRIX_HEIGHT,
MATRIX_ROWS_PER_STRIP,
MATRIX_SERPENTINE,
MATRIX_WIDTH,
)
from leds.config import StripConfig, default_dual_strip_configs
from leds.strip import Color, LedStrip, _normalize_color
from leds.strips import LedStrips
Serpentine = Literal["rows", "none"]
class LedMatrix:
"""
45×9 matrix on one WS2812 data line (405 LEDs).
Any size works: LedMatrix(width=50, height=12) → 600 LEDs, etc.
"""
def __init__(
self,
width: int = MATRIX_WIDTH,
height: int = MATRIX_HEIGHT,
*,
rows_per_strip: Sequence[int] | None = MATRIX_ROWS_PER_STRIP,
serpentine: Serpentine = MATRIX_SERPENTINE,
brightness: float = MATRIX_BRIGHTNESS,
config0: StripConfig | None = None,
config1: StripConfig | None = None,
) -> None:
if width < 1 or height < 1:
raise ValueError("width and height must be positive")
self.width = width
self.height = height
self.serpentine = serpentine
self._strip: LedStrip | None = None
self._strips: LedStrips | None = None
if rows_per_strip is None:
self._rows_per_strip = (height,)
count0 = width * height
if config0 is None:
from leds.config import default_strip_config
config0 = default_strip_config(count0, brightness=brightness)
self._strip = LedStrip(
count0,
backend=config0.backend,
pin=config0.pin,
channel=config0.channel,
device=config0.device,
brightness=config0.brightness,
spi_bus=config0.spi_bus,
spi_device=config0.spi_device,
)
else:
total_rows = sum(rows_per_strip)
if total_rows != height:
raise ValueError(
f"rows_per_strip sums to {total_rows}, expected {height}"
)
counts = [width * r for r in rows_per_strip]
self._rows_per_strip = tuple(rows_per_strip)
if config0 is None or config1 is None:
defaults = default_dual_strip_configs(
counts[0], counts[1], brightness=brightness
)
config0 = config0 or defaults[0]
config1 = config1 or defaults[1]
self._strips = LedStrips(
counts[0],
counts[1],
config0=config0,
config1=config1,
brightness=brightness,
)
def _locate(self, x: int, y: int) -> Tuple[int, int]:
if not (0 <= x < self.width and 0 <= y < self.height):
raise IndexError(f"({x}, {y}) out of range for {self.width}×{self.height}")
row_offset = 0
for strip_id, num_rows in enumerate(self._rows_per_strip):
if y < row_offset + num_rows:
local_y = y - row_offset
col = x
if self.serpentine == "rows" and y % 2 == 1:
col = self.width - 1 - x
return strip_id, local_y * self.width + col
row_offset += num_rows
raise IndexError(f"({x}, {y}) out of range")
def index_at(self, x: int, y: int) -> int:
"""Strip index for (x, y) on a single-strip matrix."""
strip_id, index = self._locate(x, y)
if strip_id != 0 or self._strip is None:
raise RuntimeError("index_at requires a single-strip matrix")
return index
@property
def strip(self) -> LedStrip | None:
return self._strip
def __getitem__(self, pos: Tuple[int, int]) -> Tuple[int, int, int]:
x, y = pos
strip_id, index = self._locate(x, y)
if self._strip is not None:
return self._strip[index]
assert self._strips is not None
return self._strips[strip_id][index]
def __setitem__(self, pos: Tuple[int, int], color: Color) -> None:
x, y = pos
strip_id, index = self._locate(x, y)
rgb = _normalize_color(color)
if self._strip is not None:
self._strip[index] = rgb
else:
assert self._strips is not None
self._strips[strip_id][index] = rgb
def set_pixel(self, x: int, y: int, color: Color) -> None:
self[x, y] = color
def fill(self, color: Color) -> None:
rgb = _normalize_color(color)
if self._strip is not None:
self._strip.fill(rgb)
else:
assert self._strips is not None
self._strips[0].fill(rgb)
self._strips[1].fill(rgb)
def show(self) -> None:
if self._strip is not None:
self._strip.show()
else:
assert self._strips is not None
self._strips.show()
def clear(self) -> None:
if self._strip is not None:
self._strip.clear()
else:
assert self._strips is not None
self._strips.clear()
def close(self) -> None:
if self._strip is not None:
self._strip.close()
elif self._strips is not None:
self._strips.close()
def __enter__(self) -> LedMatrix:
return self
def __exit__(self, *_exc) -> None:
self.close()