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:
33
leds/__init__.py
Normal file
33
leds/__init__.py
Normal 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
330
leds/animations.py
Normal 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
69
leds/array_config.py
Normal 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
|
||||
1
leds/backends/__init__.py
Normal file
1
leds/backends/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
58
leds/backends/pio.py
Normal file
58
leds/backends/pio.py
Normal 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
81
leds/backends/spi.py
Normal 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
73
leds/backends/ws281x.py
Normal 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
68
leds/colors.py
Normal 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.0–1.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
151
leds/config.py
Normal 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
64
leds/detect.py
Normal 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
168
leds/matrix.py
Normal 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
432
leds/panel_udp.py
Normal 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.10–14)",
|
||||
)
|
||||
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
144
leds/strip.py
Normal 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
75
leds/strips.py
Normal 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
124
leds/text.py
Normal 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)
|
||||
Reference in New Issue
Block a user