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