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

33
leds/__init__.py Normal file
View File

@@ -0,0 +1,33 @@
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
MATRIX_HEIGHT,
MATRIX_PIXELS,
MATRIX_ROWS_PER_STRIP,
MATRIX_WIDTH,
)
from leds.config import StripConfig, default_dual_strip_configs, default_strip_config, pi5_setup_hint
from leds.detect import board_family, board_model, pio_available, supports_ws281x
from leds.colors import wheel
from leds.matrix import LedMatrix
from leds.strip import LedStrip
from leds.strips import LedStrips
__all__ = [
"ANIMATIONS",
"DEFAULT_FPS",
"LedMatrix",
"LedStrip",
"LedStrips",
"MATRIX_HEIGHT",
"MATRIX_PIXELS",
"MATRIX_ROWS_PER_STRIP",
"MATRIX_WIDTH",
"StripConfig",
"board_family",
"board_model",
"default_dual_strip_configs",
"default_strip_config",
"pio_available",
"pi5_setup_hint",
"supports_ws281x",
]

330
leds/animations.py Normal file
View File

@@ -0,0 +1,330 @@
"""Animations for LedMatrix and LedStrip."""
from __future__ import annotations
import math
import random
from typing import Callable, Union
from leds.colors import dim, heat_color, hsv_to_rgb, wheel
from leds.matrix import LedMatrix
from leds.strip import LedStrip
Surface = Union[LedMatrix, LedStrip]
DrawFn = Callable[[Surface, int], None]
def _is_matrix(surface: Surface) -> bool:
return hasattr(surface, "width") and hasattr(surface, "height")
def _size(surface: Surface) -> tuple[int, int]:
if _is_matrix(surface):
return surface.width, surface.height
n = len(surface)
return n, 1
def _set_index(surface: Surface, x: int, y: int, color: tuple[int, int, int]) -> None:
if isinstance(surface, LedStrip):
w, _ = _size(surface)
surface[y * w + x] = color
else:
surface[x, y] = color
def _each_pixel(surface: Surface):
if _is_matrix(surface):
w, h = surface.width, surface.height
for y in range(h):
for x in range(w):
yield x, y
else:
for i in range(len(surface)):
yield i, 0
def rainbow_scroll(surface: Surface, frame: int) -> None:
if isinstance(surface, LedStrip):
n = len(surface)
for i in range(n):
surface[i] = wheel(frame * 2 + i * 256 // max(n, 1))
return
w, h = surface.width, surface.height
strip = getattr(surface, "strip", None)
if strip is not None and hasattr(surface, "index_at"):
for y in range(h):
for x in range(w):
strip[surface.index_at(x, y)] = wheel(x * 3 + y * 8 + frame * 2)
else:
for y in range(h):
for x in range(w):
surface[x, y] = wheel(x * 3 + y * 8 + frame * 2)
def plasma(surface: Surface, frame: int) -> None:
w, h = _size(surface)
t = frame * 0.08
for y in range(h):
for x in range(w):
v = (
math.sin(x * 0.15 + t)
+ math.sin(y * 0.2 - t * 1.3)
+ math.sin((x + y) * 0.1 + t * 0.7)
) / 3.0
_set_index(surface, x, y, hsv_to_rgb((v + 1) / 2, 1.0, 1.0))
def sine_wave(surface: Surface, frame: int) -> None:
w, h = _size(surface)
for y in range(h):
for x in range(w):
wave = math.sin(x * 0.25 + frame * 0.15) * math.cos(y * 0.35 - frame * 0.1)
_set_index(surface, x, y, dim((0, 80, 200), (wave + 1) / 2))
def scanner(surface: Surface, frame: int) -> None:
w, h = _size(surface)
pos = frame % max(w * 2 - 2, 1)
if pos >= w:
pos = w * 2 - 2 - pos
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for y in range(h):
for dx in range(-3, 4):
x = pos + dx
if 0 <= x < w:
_set_index(surface, x, y, dim((255, 0, 0), 1.0 - abs(dx) / 4.0))
def comet(surface: Surface, frame: int) -> None:
w, h = _size(surface)
head = frame % (w + 12)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for y in range(h):
for i in range(12):
x = head - i
if 0 <= x < w:
_set_index(surface, x, y, dim(wheel(frame * 3 + i * 10), (12 - i) / 12))
def pulse(surface: Surface, frame: int) -> None:
level = (math.sin(frame * 0.12) + 1) / 2
color = dim((120, 0, 180), 0.15 + level * 0.85)
if _is_matrix(surface):
surface.fill(color)
else:
surface.fill(color)
def sparkle(surface: Surface, frame: int) -> None:
for x, y in _each_pixel(surface):
if _is_matrix(surface):
r, g, b = surface[x, y]
else:
r, g, b = surface[x]
c = (r >> 2, g >> 2, b >> 2)
_set_index(surface, x, y, c)
w, h = _size(surface)
for _ in range(6):
_set_index(surface, random.randint(0, w - 1), random.randint(0, h - 1), (220, 220, 255))
def fire(surface: Surface, frame: int) -> None:
w, h = _size(surface)
size = w * h
if not hasattr(fire, "_heat") or len(fire._heat) != size:
fire._heat = [0.0] * size
heat: list[float] = fire._heat
for x in range(w):
heat[(h - 1) * w + x] = random.random() * 0.5 + 0.5
for y in range(h - 2, -1, -1):
for x in range(w):
below = (y + 1) * w + x
left = below - 1 if x > 0 else below
right = below + 1 if x < w - 1 else below
decay = random.uniform(0.0, 0.18)
heat[y * w + x] = max(0.0, (heat[left] + heat[below] + heat[right]) / 3 - decay)
for y in range(h):
for x in range(w):
_set_index(surface, x, y, heat_color(heat[y * w + x]))
def rain(surface: Surface, frame: int) -> None:
w, h = _size(surface)
if not hasattr(rain, "_drops"):
rain._drops = [
{"x": random.randint(0, w - 1), "y": random.randint(-h, 0), "speed": random.randint(1, 3)}
for _ in range(max(w // 2, 1))
]
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for drop in rain._drops:
drop["y"] += drop["speed"]
if drop["y"] >= h:
drop["y"] = random.randint(-3, -1)
drop["x"] = random.randint(0, w - 1)
drop["speed"] = random.randint(1, 3)
x, y = drop["x"], int(drop["y"])
if 0 <= y < h:
_set_index(surface, x, y, (0, 220, 80))
if y > 0:
_set_index(surface, x, y - 1, dim((0, 220, 80), 0.4))
def stripes(surface: Surface, frame: int) -> None:
w, h = _size(surface)
offset = frame % max(w, 1)
for y in range(h):
for x in range(w):
band = (x + offset) // 3 % 3
if band == 0:
_set_index(surface, x, y, (200, 0, 0))
elif band == 1:
_set_index(surface, x, y, (0, 200, 0))
else:
_set_index(surface, x, y, (0, 0, 200))
def bounce(surface: Surface, frame: int) -> None:
w, h = _size(surface)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
t = frame * 0.2
x = int((math.sin(t) + 1) / 2 * (w - 1))
y = int((math.cos(t * 1.3) + 1) / 2 * (h - 1))
for dy in range(-1, 2):
for dx in range(-1, 2):
px, py = x + dx, y + dy
if 0 <= px < w and 0 <= py < h:
_set_index(surface, px, py, wheel(frame * 5))
def _iter_line(x0: int, y0: int, x1: int, y1: int):
"""Yield pixel coordinates along a Bresenham line."""
dx = abs(x1 - x0)
dy = -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
x, y = x0, y0
while True:
yield x, y
if x == x1 and y == y1:
break
e2 = 2 * err
if e2 >= dy:
err += dy
x += sx
if e2 <= dx:
err += dx
y += sy
def rolling(surface: Surface, frame: int) -> None:
"""Full-height band rolling left to right across the panel."""
w, h = _size(surface)
tail = 10
head = frame % (w + tail)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
for y in range(h):
hue_shift = y * 18 + frame * 3
for i in range(tail):
x = head - i
if 0 <= x < w:
bright = (tail - i) / tail
_set_index(surface, x, y, dim(wheel(hue_shift + i * 8), bright))
def spin_line(surface: Surface, frame: int) -> None:
"""Line from panel center spinning like a radar sweep."""
w, h = _size(surface)
if _is_matrix(surface):
surface.fill((0, 0, 0))
else:
surface.fill((0, 0, 0))
if not _is_matrix(surface):
n = len(surface)
pos = int((frame * 3) % max(n, 1))
for i in range(8):
idx = (pos - i) % n
surface[idx] = dim(wheel(frame * 5 + i * 20), (8 - i) / 8)
return
cx = (w - 1) / 2.0
cy = (h - 1) / 2.0
angle = frame * 0.14
reach = max(w, h) * 1.2
x1 = int(cx + math.cos(angle) * reach)
y1 = int(cy + math.sin(angle) * reach)
color = wheel(frame * 4)
for x, y in _iter_line(int(cx), int(cy), x1, y1):
if 0 <= x < w and 0 <= y < h:
_set_index(surface, x, y, color)
hub_x, hub_y = int(cx), int(cy)
if 0 <= hub_x < w and 0 <= hub_y < h:
_set_index(surface, hub_x, hub_y, (255, 255, 255))
def solid(surface: Surface, frame: int) -> None:
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
if _is_matrix(surface):
surface.fill(colors[frame % 3])
else:
surface.fill(colors[frame % 3])
ANIMATIONS: dict[str, DrawFn] = {
"solid": solid,
"rainbow": rainbow_scroll,
"plasma": plasma,
"wave": sine_wave,
"scanner": scanner,
"comet": comet,
"pulse": pulse,
"sparkle": sparkle,
"fire": fire,
"rain": rain,
"stripes": stripes,
"bounce": bounce,
"rolling": rolling,
"spin_line": spin_line,
}
DEFAULT_FPS: dict[str, float] = {
"solid": 1,
"rainbow": 30,
"plasma": 25,
"wave": 25,
"scanner": 35,
"comet": 30,
"pulse": 30,
"sparkle": 40,
"fire": 35,
"rain": 30,
"stripes": 25,
"bounce": 35,
"rolling": 30,
"spin_line": 28,
}

69
leds/array_config.py Normal file
View File

@@ -0,0 +1,69 @@
"""LED matrix layout defaults."""
from __future__ import annotations
import os
MATRIX_WIDTH = 45
MATRIX_HEIGHT = 9
MATRIX_PIXELS = MATRIX_WIDTH * MATRIX_HEIGHT # 405
# 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
PANEL_WIDTH_BY_INDEX: dict[int, int] = {
0: 39,
1: 45,
2: 45,
3: 45,
4: 39,
}
DEFAULT_PANEL_WIDTH = 45
PANEL_COUNT = 5
# SPI data line: bus 0 = GPIO 10 (MOSI), bus 1 = GPIO 20 (SPI1 MOSI)
MATRIX_SPI_BUS = 0
MATRIX_SPI_DEVICE = 0
# One continuous chain on a single data pin; rows all run left → right.
MATRIX_ROWS_PER_STRIP = None
MATRIX_SERPENTINE = "none" # use "rows" for zigzag / serpentine wiring
# Set True if DIN enters at the bottom row instead of the top.
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()
def panel_width(panel_index: int) -> int:
return PANEL_WIDTH_BY_INDEX.get(panel_index, DEFAULT_PANEL_WIDTH)
def panel_layout(panel_index: int) -> tuple[int, int]:
return panel_width(panel_index), PANEL_HEIGHT
def panel_pixel_count(panel_index: int) -> int:
w, h = panel_layout(panel_index)
return w * h
def matrix_pixel_index(
x: int,
y: int,
width: int = MATRIX_WIDTH,
height: int = MATRIX_HEIGHT,
*,
serpentine: str = MATRIX_SERPENTINE,
flip_y: bool = MATRIX_FLIP_Y,
) -> int:
"""Map (x, y) to strip index. Default: every row left → right."""
if flip_y:
y = height - 1 - y
col = x
if serpentine == "rows" and y % 2 == 1:
col = width - 1 - x
return y * width + col

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

68
leds/colors.py Normal file
View File

@@ -0,0 +1,68 @@
"""Color helpers for LED animations."""
from __future__ import annotations
import math
from typing import Tuple
RGB = Tuple[int, int, int]
def wheel(pos: int) -> RGB:
pos = pos % 256
if pos < 85:
return (255 - pos * 3, pos * 3, 0)
if pos < 170:
pos -= 85
return (0, 255 - pos * 3, pos * 3)
pos -= 170
return (pos * 3, 0, 255 - pos * 3)
def heat_color(temperature: float) -> RGB:
"""Map 0.01.0 heat to black → red → yellow → white."""
t = max(0.0, min(1.0, temperature))
if t < 0.25:
return (int(t * 4 * 255), 0, 0)
if t < 0.5:
return (255, int((t - 0.25) * 4 * 255), 0)
if t < 0.75:
return (255, 255, int((t - 0.5) * 4 * 255))
return (255, 255, 255)
def hsv_to_rgb(h: float, s: float, v: float) -> RGB:
h = h % 1.0
i = int(h * 6)
f = h * 6 - i
p = int(v * (1 - s) * 255)
q = int(v * (1 - f * s) * 255)
t = int(v * (1 - (1 - f) * s) * 255)
v255 = int(v * 255)
match i % 6:
case 0:
return (v255, t, p)
case 1:
return (q, v255, p)
case 2:
return (p, v255, t)
case 3:
return (p, q, v255)
case 4:
return (t, p, v255)
case _:
return (v255, p, q)
def dim(color: RGB, scale: float) -> RGB:
scale = max(0.0, min(1.0, scale))
return (int(color[0] * scale), int(color[1] * scale), int(color[2] * scale))
def blend(a: RGB, b: RGB, t: float) -> RGB:
t = max(0.0, min(1.0, t))
return (
int(a[0] + (b[0] - a[0]) * t),
int(a[1] + (b[1] - a[1]) * t),
int(a[2] + (b[2] - a[2]) * t),
)

151
leds/config.py Normal file
View File

@@ -0,0 +1,151 @@
"""Default wiring for one or two WS2812 strips per board."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from leds.detect import board_family, pio_available, pio_device, pi5_second_spi_bus
try:
from leds.array_config import MATRIX_SPI_BUS, MATRIX_SPI_DEVICE
except ImportError:
MATRIX_SPI_BUS = 0
MATRIX_SPI_DEVICE = 0
BackendName = Literal["auto", "pio", "spi", "ws281x"]
@dataclass
class StripConfig:
count: int
backend: BackendName = "auto"
pin: int = 18
channel: int = 0
device: str = "/dev/leds0"
spi_bus: int = 0
spi_device: int = 0
brightness: float = 1.0
def spi_strip_config(
count: int,
*,
spi_bus: int = MATRIX_SPI_BUS,
spi_device: int = MATRIX_SPI_DEVICE,
brightness: float = 1.0,
) -> StripConfig:
return StripConfig(
count,
backend="spi",
spi_bus=spi_bus,
spi_device=spi_device,
brightness=brightness,
)
def default_strip_config(count: int, *, brightness: float = 1.0) -> StripConfig:
family = board_family()
if family == "pi5":
if pio_available():
return StripConfig(
count,
backend="pio",
pin=18,
device=pio_device(0) or "/dev/leds0",
brightness=brightness,
)
return spi_strip_config(count, brightness=brightness)
return StripConfig(
count,
backend="ws281x",
pin=18,
channel=0,
brightness=brightness,
)
def default_dual_strip_configs(
count0: int,
count1: int,
*,
brightness: float = 1.0,
) -> tuple[StripConfig, StripConfig]:
"""
Suggested wiring for two independent strips:
Pi 5 (SPI — requires dtoverlay=spi1-3cs under [all], not [cm5]):
strip 0 → GPIO 10 (SPI0 MOSI), /dev/spidev0.0
strip 1 → GPIO 20 (SPI1 MOSI), /dev/spidev1.0
Pi 5 (ws2812-pio — alternative):
strip 0 → GPIO 18, /dev/leds0
strip 1 → GPIO 13, /dev/leds1
Pi 4 / Zero:
strip 0 → GPIO 18 (PWM channel 0)
strip 1 → GPIO 13 (PWM channel 1)
"""
family = board_family()
if family == "pi5":
if pio_available():
return (
StripConfig(
count0,
backend="pio",
pin=18,
device=pio_device(0) or "/dev/leds0",
brightness=brightness,
),
StripConfig(
count1,
backend="pio",
pin=13,
device=pio_device(1) or "/dev/leds1",
brightness=brightness,
),
)
return (
StripConfig(
count0,
backend="spi",
spi_bus=0,
spi_device=0,
brightness=brightness,
),
StripConfig(
count1,
backend="spi",
spi_bus=pi5_second_spi_bus(),
spi_device=0,
brightness=brightness,
),
)
return (
StripConfig(
count0,
backend="ws281x",
pin=18,
channel=0,
brightness=brightness,
),
StripConfig(
count1,
backend="ws281x",
pin=13,
channel=1,
brightness=brightness,
),
)
def pi5_setup_hint(count0: int, count1: int) -> str:
return (
"Pi 5 second SPI bus not found. Add under [all] in /boot/firmware/config.txt:\n"
" dtparam=spi=on\n"
" dtoverlay=spi1-3cs\n"
"Remove dtparam=spi1=on (ignored on Pi 5). Do not put the overlay under [cm5]\n"
"unless you have a Compute Module 5.\n"
"Wire strip 0 DIN → GPIO 10, strip 1 DIN → GPIO 20, then reboot."
)

64
leds/detect.py Normal file
View File

@@ -0,0 +1,64 @@
"""Raspberry Pi model detection for choosing a WS2812 backend."""
from __future__ import annotations
from functools import lru_cache
@lru_cache(maxsize=1)
def board_model() -> str:
try:
with open("/proc/device-tree/model", "rb") as f:
return f.read().rstrip(b"\x00").decode("ascii")
except OSError:
return "Unknown"
@lru_cache(maxsize=1)
def board_family() -> str:
model = board_model().lower()
if "raspberry pi 5" in model:
return "pi5"
if "raspberry pi 4" in model:
return "pi4"
if "zero 2" in model:
return "pi_zero2"
if "zero" in model:
return "pi_zero"
if "raspberry pi 3" in model:
return "pi3"
return "unknown"
def supports_ws281x() -> bool:
return board_family() != "pi5"
def pio_device(index: int = 0) -> str | None:
from pathlib import Path
path = Path(f"/dev/leds{index}")
return str(path) if path.exists() else None
def pio_available() -> bool:
return pio_device(0) is not None
def pi5_second_spi_bus() -> int:
"""
Second strip on Pi 5 SPI — prefer spi1 from dtoverlay=spi1-3cs
(/dev/spidev1.0, MOSI on GPIO 20). Falls back to built-in spidev10.0.
"""
from pathlib import Path
if Path("/dev/spidev1.0").exists():
return 1
if Path("/dev/spidev10.0").exists():
return 10
raise RuntimeError(
"No second SPI device found. Add to /boot/firmware/config.txt under [all]:\n"
" dtoverlay=spi1-3cs\n"
"Then reboot. Wire strip 1 DIN to GPIO 20 (SPI1 MOSI).\n"
"Note: dtparam=spi1=on is ignored on Pi 5; use the overlay in [all], not [cm5]."
)

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

432
leds/panel_udp.py Normal file
View File

@@ -0,0 +1,432 @@
"""UDP helpers for Pico panel firmware."""
from __future__ import annotations
import argparse
import os
import socket
import time
from dataclasses import dataclass
from typing import Iterable, Sequence, Tuple
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
MATRIX_HEIGHT,
MATRIX_PIXELS,
MATRIX_WIDTH,
PANEL_COUNT,
matrix_pixel_index,
panel_layout,
panel_pixel_count,
)
from leds.colors import dim
PANEL_IP_NET = "10.1.1"
PANEL_IP_BASE = 10
PANEL_MAX_FPS = 25.0
RGB = Tuple[int, int, int]
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
def panel_ip(panel_id: int) -> str:
return f"{PANEL_IP_NET}.{PANEL_IP_BASE + panel_id}"
DEFAULT_HOST = panel_ip(0)
DEFAULT_PANEL_HOSTS = [panel_ip(i) for i in range(PANEL_COUNT)]
@dataclass(frozen=True)
class PanelTarget:
index: int
host: str
width: int
height: int
@property
def pixels(self) -> int:
return self.width * self.height
def label(self) -> str:
if self.index >= 0:
return f"{self.host} ({self.width}×{self.height}, panel {self.index})"
return f"{self.host} ({self.width}×{self.height})"
def panel_hosts(panel_index: int | None = None, count: int = PANEL_COUNT) -> list[str]:
return [t.host for t in resolve_panel_targets(panel_index=panel_index, panels=count)]
def resolve_panel_targets(
host: str | None = None,
panel_index: int | None = None,
panels: int = PANEL_COUNT,
*,
width: int | None = None,
height: int | None = None,
) -> list[PanelTarget]:
if panel_index is not None:
w, h = panel_layout(panel_index)
if width is not None:
w = width
if height is not None:
h = height
return [PanelTarget(panel_index, panel_ip(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))
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}"
layouts = ", ".join(f"{t.width}×{t.height}" for t in targets)
return f"{len(targets)} panels ({targets[0].host}{targets[-1].host}):{port} [{layouts}]"
def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--host",
default=None,
help="Single panel IP override (not needed; default uses 10.1.1.1014)",
)
parser.add_argument(
"--panel-index",
type=int,
default=None,
choices=range(PANEL_COUNT),
metavar="N",
help=f"Drive only panel N (0{PANEL_COUNT - 1}); default all {PANEL_COUNT}",
)
parser.add_argument(
"--panels",
type=int,
default=PANEL_COUNT,
help=f"Number of panels when driving all (default {PANEL_COUNT})",
)
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")
def add_panel_args(parser: argparse.ArgumentParser) -> None:
add_panel_network_args(parser)
parser.add_argument(
"--width",
type=int,
default=None,
help="Override panel width (default from leds/array_config.py per panel)",
)
parser.add_argument(
"--height",
type=int,
default=None,
help=f"Override panel height (default {MATRIX_HEIGHT})",
)
parser.add_argument(
"--count",
type=int,
default=None,
help="LEDs per frame (default: panel width × height)",
)
parser.add_argument("--brightness", type=float, default=0.35)
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
return resolve_panel_targets(
args.host,
args.panel_index,
args.panels,
width=getattr(args, "width", None),
height=getattr(args, "height", None),
)
def panel_count_from_args(args: argparse.Namespace) -> int:
if args.count is not None:
return args.count
if args.panel_index is not None:
return panel_pixel_count(args.panel_index)
if args.width is not None:
h = args.height if args.height is not None else MATRIX_HEIGHT
return args.width * h
return MATRIX_PIXELS
class HeadlessMatrix:
"""Matrix buffer for UDP senders — no local SPI/PIO."""
def __init__(
self,
width: int = MATRIX_WIDTH,
height: int = MATRIX_HEIGHT,
*,
brightness: float = 0.25,
) -> None:
self.width = width
self.height = height
self.brightness = brightness
self._pixels: list[RGB] = [(0, 0, 0)] * (width * height)
def _index_at(self, x: int, y: int) -> int:
return matrix_pixel_index(x, y, self.width, self.height)
def __len__(self) -> int:
return len(self._pixels)
def __getitem__(self, pos: Tuple[int, int]) -> RGB:
return self._pixels[self._index_at(*pos)]
def __setitem__(self, pos: Tuple[int, int], color: RGB) -> None:
self._pixels[self._index_at(*pos)] = color
def fill(self, color: RGB) -> None:
self._pixels = [color] * len(self._pixels)
def clear(self) -> None:
self.fill((0, 0, 0))
def set_index(self, index: int, color: RGB) -> None:
if 0 <= index < len(self._pixels):
self._pixels[index] = color
def rgb_bytes(self) -> bytes:
out = bytearray(len(self._pixels) * 3)
for i, (r, g, b) in enumerate(self._pixels):
r, g, b = dim((r, g, b), self.brightness)
base = i * 3
out[base], out[base + 1], out[base + 2] = r, g, b
return bytes(out)
def solid_frame_bytes(
color: RGB,
count: int = MATRIX_PIXELS,
brightness: float = 1.0,
) -> bytes:
r, g, b = dim(color, brightness)
return bytes((r, g, b)) * count
def pixels_to_frame_bytes(
pixels: Iterable[RGB],
brightness: float = 1.0,
) -> bytes:
out = bytearray()
for r, g, b in pixels:
r, g, b = dim((r, g, b), brightness)
out.extend((r, g, b))
return bytes(out)
def send_frame(
sock: socket.socket,
host: str,
port: int,
rgb: bytes,
panel_id: int | None,
) -> None:
payload = (bytes([panel_id & 0xFF]) + rgb) if panel_id is not None else rgb
sock.sendto(payload, (host, port))
def send_solid_to_targets(
sock: socket.socket,
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,
)
class PanelClient:
"""Send frames to one or more portal panel firmware targets."""
def __init__(
self,
targets: PanelTarget | Sequence[PanelTarget] | str,
port: int = DEFAULT_PORT,
*,
pixel_count: int | None = None,
panel_id: int | None = None,
brightness: float = 0.35,
) -> None:
if isinstance(targets, PanelTarget):
self.targets = [targets]
elif isinstance(targets, str):
count = pixel_count or MATRIX_PIXELS
w = MATRIX_WIDTH
h = count // w if count % w == 0 else MATRIX_HEIGHT
self.targets = [PanelTarget(-1, targets, w, h)]
else:
self.targets = list(targets)
self.port = port
self.panel_id = panel_id
self.brightness = brightness
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@classmethod
def from_args(cls, args: argparse.Namespace) -> PanelClient:
return cls(
panel_targets_from_args(args),
args.port,
pixel_count=panel_count_from_args(args),
panel_id=args.panel_id,
brightness=args.brightness,
)
@property
def pixel_count(self) -> int:
if len(self.targets) != 1:
raise ValueError("pixel_count requires a single panel target")
return self.targets[0].pixels
@property
def hosts(self) -> list[str]:
return [t.host for t in self.targets]
def close(self) -> None:
self._sock.close()
def __enter__(self) -> PanelClient:
return self
def __exit__(self, *_exc) -> None:
self.clear()
self.close()
def send_bytes(self, rgb: bytes) -> None:
if len(self.targets) != 1:
raise ValueError("send_bytes requires a single panel target")
target = self.targets[0]
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)
def fill(self, color: RGB = (0, 0, 0)) -> None:
for target in self.targets:
send_frame(
self._sock,
target.host,
self.port,
solid_frame_bytes(color, target.pixels, self.brightness),
self.panel_id,
)
def send_pixels(self, pixels: list[RGB]) -> None:
if len(self.targets) != 1:
raise ValueError("send_pixels requires a single panel target")
if len(pixels) != self.pixel_count:
raise ValueError(f"expected {self.pixel_count} pixels, got {len(pixels)}")
self.send_bytes(pixels_to_frame_bytes(pixels, self.brightness))
def set_indices(self, indices: Iterable[int], color: RGB) -> None:
pixels = [(0, 0, 0)] * self.pixel_count
for i in indices:
if 0 <= i < self.pixel_count:
pixels[i] = color
self.send_pixels(pixels)
def clear(self) -> None:
self.fill((0, 0, 0))
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]
def run_panel_playlist(
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,
) -> None:
matrices = _panel_matrices(targets, brightness)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
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)
finally:
sock.close()
def run_panel_animation_loop(
targets: Sequence[PanelTarget],
port: int,
animation: str,
*,
fps: float | None = None,
brightness: float = 0.25,
panel_id: int | 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()
def animation_playlist(animation: str) -> list[str]:
names = list(ANIMATIONS.keys())
if animation == "all":
# Skip solid — 1 fps looks frozen on UDP panels; start with motion.
return [n for n in names if n != "solid"]
return [animation]

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

75
leds/strips.py Normal file
View File

@@ -0,0 +1,75 @@
"""Control two WS2812 strips with board-appropriate defaults."""
from __future__ import annotations
from leds.config import StripConfig, default_dual_strip_configs
from leds.strip import LedStrip
class LedStrips:
"""Drive two WS2812 strips. Use default configs or pass StripConfig per strip."""
def __init__(
self,
count0: int,
count1: int,
*,
config0: StripConfig | None = None,
config1: StripConfig | None = None,
brightness: float = 1.0,
) -> None:
if config0 is None or config1 is None:
defaults = default_dual_strip_configs(
count0, count1, brightness=brightness
)
config0 = config0 or defaults[0]
config1 = config1 or defaults[1]
self._strips = (
_strip_from_config(config0),
_strip_from_config(config1),
)
@property
def strip0(self) -> LedStrip:
return self._strips[0]
@property
def strip1(self) -> LedStrip:
return self._strips[1]
def __getitem__(self, index: int) -> LedStrip:
if index not in (0, 1):
raise IndexError("strip index must be 0 or 1")
return self._strips[index]
def show(self) -> None:
for strip in self._strips:
strip.show()
def clear(self) -> None:
for strip in self._strips:
strip.clear()
def close(self) -> None:
for strip in self._strips:
strip.close()
def __enter__(self) -> LedStrips:
return self
def __exit__(self, *_exc) -> None:
self.close()
def _strip_from_config(config: StripConfig) -> LedStrip:
return LedStrip(
config.count,
backend=config.backend,
pin=config.pin,
channel=config.channel,
device=config.device,
brightness=config.brightness,
spi_bus=config.spi_bus,
spi_device=config.spi_device,
)

124
leds/text.py Normal file
View File

@@ -0,0 +1,124 @@
"""5×7 bitmap text for LED matrices."""
from __future__ import annotations
from typing import Tuple
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
RGB = Tuple[int, int, int]
# Each letter: 7 rows, 5 LSBs = columns (bit 4 = left).
_FONT: dict[str, tuple[int, ...]] = {
" ": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
"A": (0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11),
"B": (0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E),
"C": (0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E),
"D": (0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E),
"E": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F),
"F": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10),
"G": (0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E),
"H": (0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11),
"I": (0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E),
"J": (0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C),
"K": (0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11),
"L": (0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F),
"M": (0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11),
"N": (0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11),
"O": (0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E),
"P": (0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10),
"Q": (0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D),
"R": (0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11),
"S": (0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E),
"T": (0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04),
"U": (0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E),
"V": (0x11, 0x11, 0x11, 0x11, 0x0A, 0x0A, 0x04),
"W": (0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11),
"X": (0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11),
"Y": (0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04),
"Z": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F),
"0": (0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E),
"1": (0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E),
"2": (0x0E, 0x11, 0x01, 0x06, 0x08, 0x10, 0x1F),
"3": (0x1F, 0x02, 0x04, 0x06, 0x01, 0x11, 0x0E),
"4": (0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02),
"5": (0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E),
"6": (0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E),
"7": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08),
"8": (0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E),
"9": (0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C),
}
CHAR_WIDTH = 5
CHAR_HEIGHT = 7
CHAR_SPACING = 1
def text_width(text: str, spacing: int = CHAR_SPACING) -> int:
text = text.upper()
if not text:
return 0
return len(text) * CHAR_WIDTH + (len(text) - 1) * spacing
def _set(surface, x: int, y: int, color: RGB) -> None:
if hasattr(surface, "width") and hasattr(surface, "height"):
if 0 <= x < surface.width and 0 <= y < surface.height:
surface[x, y] = color
else:
w = getattr(surface, "width", MATRIX_WIDTH)
idx = matrix_pixel_index(x, y, w)
if 0 <= idx < len(surface):
surface[idx] = color
def draw_char(
surface,
ch: str,
x: int,
y: int,
color: RGB,
*,
scale: int = 1,
) -> None:
glyph = _FONT.get(ch.upper(), _FONT[" "])
for row, bits in enumerate(glyph):
for col in range(CHAR_WIDTH):
if bits & (1 << (CHAR_WIDTH - 1 - col)):
for sy in range(scale):
for sx in range(scale):
_set(surface, x + col * scale + sx, y + row * scale + sy, color)
def draw_text(
surface,
text: str,
x: int,
y: int,
color: RGB,
*,
scale: int = 1,
spacing: int = CHAR_SPACING,
) -> None:
cursor = x
step = (CHAR_WIDTH + spacing) * scale
for ch in text.upper():
draw_char(surface, ch, cursor, y, color, scale=scale)
cursor += step
def draw_text_centered(
surface,
text: str,
color: RGB,
*,
scale: int = 1,
spacing: int = CHAR_SPACING,
) -> None:
w = getattr(surface, "width", MATRIX_WIDTH)
h = getattr(surface, "height", MATRIX_HEIGHT)
tw = text_width(text, spacing) * scale
th = CHAR_HEIGHT * scale
x = max(0, (w - tw) // 2)
y = max(0, (h - th) // 2)
draw_text(surface, text, x, y, color, scale=scale, spacing=spacing)