Add portal web simulator, SPI bridges, and Pico firmware updates.

Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 14:54:51 +12:00
parent d5cab2efdf
commit 5094c7bcee
78 changed files with 62251 additions and 832 deletions

View File

@@ -34,7 +34,7 @@ class PioBackend:
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]
r, g, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
offset = i * 4
frame[offset] = r
frame[offset + 1] = g

View File

@@ -0,0 +1,54 @@
"""WS2812 over SPI via the rpi5-ws2812 package (Pi 5, GPIO 10)."""
from __future__ import annotations
import numpy as np
from rpi5_ws2812.ws2812 import WS2812SpiDriver
from leds.backends.spi import spidev_bufsize_hint
class Rpi5Ws2812Backend:
def __init__(
self,
led_count: int,
*,
spi_bus: int = 0,
spi_device: int = 0,
max_speed_hz: int = 4_200_000,
) -> None:
hint = spidev_bufsize_hint(led_count)
if hint:
raise OSError(hint)
self._led_count = led_count
self._driver = WS2812SpiDriver(spi_bus, spi_device, led_count)
self._driver._device.max_speed_hz = max_speed_hz
@property
def led_count(self) -> int:
return self._led_count
def write_grb(self, rgb_bytes: bytes) -> None:
"""RGB bytes passed through to SPI unchanged."""
if len(rgb_bytes) != self._led_count * 3:
raise ValueError(
f"expected {self._led_count * 3} bytes, got {len(rgb_bytes)}"
)
buf = np.frombuffer(rgb_bytes, dtype=np.uint8).reshape(self._led_count, 3)
self._driver.write(buf)
def clear(self) -> None:
self._driver.clear()
def close(self) -> None:
try:
self.clear()
finally:
self._driver._device.close()
def __enter__(self) -> Rpi5Ws2812Backend:
return self
def __exit__(self, *_exc) -> None:
self.close()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from array import array
from pathlib import Path
try:
from spidev import SpiDev
@@ -16,6 +17,48 @@ else:
_LED_ZERO = 0b1100_0000
_LED_ONE = 0b1111_1100
_PREAMBLE = 42
_DEFAULT_BUFSIZ = 65536
def spidev_bufsize() -> int:
try:
return int(Path("/sys/module/spidev/parameters/bufsiz").read_text().strip())
except (OSError, ValueError):
return 4096
def spi_frame_bytes(led_count: int) -> int:
return _PREAMBLE + led_count * 24
def leds_from_truncated_frame(bufsize: int) -> int:
payload = max(0, bufsize - _PREAMBLE)
return payload // 24
def spidev_bufsize_hint(led_count: int) -> str:
required = spi_frame_bytes(led_count)
bufsize = spidev_bufsize()
if bufsize >= required:
return ""
suggested = max(_DEFAULT_BUFSIZ, required + 1024)
truncated = leds_from_truncated_frame(bufsize)
return (
f"spidev bufsiz is {bufsize} but {led_count} LEDs need a {required}-byte SPI frame "
f"(only ~{truncated} LEDs update at the current limit).\n"
f"Fix now:\n"
f" sudo modprobe -r spidev\n"
f" sudo modprobe spidev bufsiz={suggested}\n"
f"Permanent:\n"
f" pipenv run python examples/setup_spi_bufsiz.py --install"
)
def _write_spi_frame(device: SpiDev, frame: array) -> None:
required = len(frame)
if required > spidev_bufsize():
raise OSError(spidev_bufsize_hint((required - _PREAMBLE) // 24))
device.writebytes2(frame)
class SpiBackend:
@@ -39,12 +82,16 @@ class SpiBackend:
self._device.mode = 0b00
self._device.lsbfirst = False
frame_len = _PREAMBLE + led_count * 24
frame_len = spi_frame_bytes(led_count)
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
hint = spidev_bufsize_hint(led_count)
if hint:
raise OSError(hint)
@property
def led_count(self) -> int:
return self._led_count
@@ -62,11 +109,10 @@ class SpiBackend:
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)
_write_spi_frame(self._device, out)
def clear(self) -> None:
self._device.writebytes2(self._clear_frame)
_write_spi_frame(self._device, self._clear_frame)
def close(self) -> None:
try: