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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
firmware/panel/build/
firmware/_deps/

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
# Portal — convenience targets for panel firmware
#
# make deploy Build and flash panel firmware over USB
# make build Build only
.PHONY: build deploy flash upload monitor clean help
_PANEL_MAKE_FLAGS :=
ifdef PANEL_ID
_PANEL_MAKE_FLAGS += PANEL_ID=$(PANEL_ID)
endif
ifdef NO_DHCP
_PANEL_MAKE_FLAGS += NO_DHCP=$(NO_DHCP)
endif
ifdef NUM_LEDS
_PANEL_MAKE_FLAGS += NUM_LEDS=$(NUM_LEDS)
endif
ifdef WS2812_PIN
_PANEL_MAKE_FLAGS += WS2812_PIN=$(WS2812_PIN)
endif
help:
@$(MAKE) -C firmware/panel help
build deploy flash upload monitor clean:
@$(MAKE) -C firmware/panel $(MAKECMDGOALS) $(_PANEL_MAKE_FLAGS)

12
Pipfile Normal file
View File

@@ -0,0 +1,12 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
portal = {editable = true, path = "."}
[dev-packages]
[requires]
python_version = "3.13"

39
Pipfile.lock generated Normal file
View File

@@ -0,0 +1,39 @@
{
"_meta": {
"hash": {
"sha256": "2d6705b77e526b8f5f55e85b57b98d5e5249097e4d02415229aabbd0744e0fa1"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"portal": {
"editable": true,
"path": "."
},
"rpi-ws281x": {
"hashes": [
"sha256:00ce6db771436b778d0930245cf8ea2aae11008cc5fd67d57789c5422af3ee55"
],
"markers": "python_version >= '3.6'",
"version": "==5.0.0"
},
"spidev": {
"hashes": [
"sha256:07d0da112ca944df41f080811bb123b910df63bb38b66712289bc4fd9d682185",
"sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0"
],
"version": "==3.8"
}
},
"develop": {}
}

View File

@@ -1,2 +1,66 @@
# portal
WS2812 LED control for Raspberry Pi 5 and Pico panel adapters.
## Panel IPs
Five panels on `10.1.1.10``10.1.1.14` (panel 0 → `.10`, panel 4 → `.14`). Examples drive **all 5** by default.
## Panel sizes
Panels are **9 rows** tall; width varies by panel. Edit `PANEL_WIDTH_BY_INDEX` in `leds/array_config.py`:
| Panel | IP | Size | LEDs | Flash |
|-------|-----|------|------|-------|
| 0 | 10.1.1.10 | 9×39 | 351 | `make deploy PANEL_ID=0 NUM_LEDS=351` |
| 1 | 10.1.1.11 | 9×45 | 405 | `make deploy PANEL_ID=1 NUM_LEDS=405` |
| 2 | 10.1.1.12 | 9×45 | 405 | `make deploy PANEL_ID=2 NUM_LEDS=405` |
| 3 | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3 NUM_LEDS=405` |
| 4 | 10.1.1.14 | 9×39? | 351 | `make deploy PANEL_ID=4 NUM_LEDS=351` |
Panel 4 may be **38 or 39** wide — find the exact width:
```bash
make deploy PANEL_ID=4 NUM_LEDS=387 # 43 cols max for probe
pipenv run python examples/panel_sync_test.py --test width --panel-index 4
```
Last column that lights → set `PANEL_WIDTH_BY_INDEX[4]` in `leds/array_config.py` to **column + 1**, then re-flash with `NUM_LEDS=width×9` (342 for 38, 351 for 39).
Animations render at each panel's own width automatically.
## Examples
### Pico panel (UDP)
| Command | What it does |
|---------|----------------|
| `pipenv run python examples/panel_rgb_cycle.py` | Red / green / blue on all 5 panels |
| `pipenv run python examples/panel_color_test.py` | One-shot RGB + white test |
| `pipenv run python examples/panel_test.py` | Layout tests (corners, rows, chase) |
| `pipenv run python examples/panel_sync_test.py` | Positioning + sync across all panels |
| `pipenv run python examples/panel_animations.py` | All animations on all panels |
| `pipenv run python examples/panel_animations.py rolling` | One animation |
| `pipenv run python examples/panel_text.py` | Show your name |
| `pipenv run python examples/animations.py --panel` | Same as panel_animations |
Single panel only:
```bash
pipenv run python examples/panel_animations.py --panel-index 1 # 9×45 @ 10.1.1.11
```
### Pi direct (SPI on GPIO 10)
| Command | What it does |
|---------|----------------|
| `pipenv run python examples/animations.py` | All animations on matrix |
| `pipenv run python examples/matrix_demo.py` | Rainbow only |
| `pipenv run python examples/led_demo.py` | Dual-strip chase + rainbow |
### Firmware
```bash
make deploy PANEL_ID=0 NUM_LEDS=351
make monitor
```

View File

@@ -12,7 +12,7 @@ SPI1_RX = board.GP12
SPI1_CSn = board.GP9
W5500_RSTn = board.GP13
pixel_pin = board.GP28
pixel_pin = board.GP27
num_pixels = 2
print("Wiznet5k SimpleServer Test (DHCP)")

170
examples/animations.py Executable file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Run LED matrix animations locally (SPI/PIO) or on Pico panel (UDP)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from leds import LedMatrix, LedStrip
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
MATRIX_BRIGHTNESS,
MATRIX_HEIGHT,
MATRIX_SPI_BUS,
MATRIX_SPI_DEVICE,
MATRIX_WIDTH,
)
from leds.config import spi_strip_config
from leds.panel_udp import (
add_panel_network_args,
animation_playlist,
format_panel_targets,
panel_targets_from_args,
run_panel_animation_loop,
run_panel_playlist,
)
def _flash_test(surface, brightness: float) -> None:
for color in ((255, 0, 0), (0, 255, 0), (0, 0, 255)):
surface.fill(color)
surface.show()
time.sleep(0.35)
surface.fill((0, 0, 0))
surface.show()
def _run_local_playlist(surface, playlist: list[str], args: argparse.Namespace) -> None:
while True:
for name in playlist:
draw = ANIMATIONS[name]
fps = args.fps or DEFAULT_FPS.get(name, 30)
delay = 1.0 / fps
frames = max(int(args.duration * fps), 1)
print(f" {name} ({fps:.0f} fps)")
for frame in range(frames):
draw(surface, frame)
surface.show()
time.sleep(delay)
def main() -> int:
names = list(ANIMATIONS.keys())
parser = argparse.ArgumentParser(description="LED matrix animations")
parser.add_argument(
"animation",
nargs="?",
choices=names + ["all"],
default="all",
)
parser.add_argument("--width", type=int, default=MATRIX_WIDTH)
parser.add_argument("--height", type=int, default=MATRIX_HEIGHT)
parser.add_argument(
"--spi-bus",
type=int,
default=int(os.environ.get("PORTAL_SPI_BUS", MATRIX_SPI_BUS)),
)
parser.add_argument("--spi-device", type=int, default=MATRIX_SPI_DEVICE)
parser.add_argument("--brightness", type=float, default=MATRIX_BRIGHTNESS)
parser.add_argument("--duration", type=float, default=12.0)
parser.add_argument("--fps", type=float, default=None)
parser.add_argument("--linear", action="store_true")
parser.add_argument(
"--serpentine",
action="store_true",
help="zigzag odd rows (default: all rows left → right)",
)
parser.add_argument("--no-test", action="store_true")
parser.add_argument(
"--panel",
action="store_true",
help="send to Pico panel(s) over UDP instead of local SPI/PIO",
)
add_panel_network_args(parser)
parser.add_argument("--once", action="store_true", help="panel: play playlist once")
args = parser.parse_args()
playlist = animation_playlist(args.animation)
if args.panel:
targets = panel_targets_from_args(args)
print(f"Panel animations -> {format_panel_targets(targets, args.port)}")
print(f"Playlist: {', '.join(playlist)}")
try:
if len(playlist) == 1 and args.animation != "all":
run_panel_animation_loop(
targets,
args.port,
playlist[0],
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
)
else:
run_panel_playlist(
targets,
args.port,
playlist,
duration=args.duration,
fps_override=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
count = args.width * args.height
serpentine = "rows" if args.serpentine else "none"
device = f"/dev/spidev{args.spi_bus}.{args.spi_device}"
mode = f"linear {count}" if args.linear else f"{args.width}×{args.height}"
print(f"Matrix {mode} ({count} LEDs) → {device}")
strip_cfg = spi_strip_config(
count,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
)
try:
if args.linear:
with LedStrip(
count,
backend="spi",
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
) as surface:
if not args.no_test:
_flash_test(surface, args.brightness)
_run_local_playlist(surface, playlist, args)
else:
with LedMatrix(
args.width,
args.height,
serpentine=serpentine,
config0=strip_cfg,
) as surface:
if not args.no_test:
_flash_test(surface, args.brightness)
_run_local_playlist(surface, playlist, args)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
print("Try: --spi-bus 1 or --panel --host <pico-ip>", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

68
examples/fix_spi1_config.py Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Ensure dtoverlay=spi1-3cs is under [all] for Pi 5 Model B."""
from __future__ import annotations
import sys
from pathlib import Path
CONFIG = Path("/boot/firmware/config.txt")
OVERLAY = "dtoverlay=spi1-3cs"
def main() -> int:
if not CONFIG.exists():
print(f"Error: {CONFIG} not found", file=sys.stderr)
return 1
lines = CONFIG.read_text().splitlines()
out: list[str] = []
in_cm5 = False
has_overlay = False
inserted = False
for line in lines:
stripped = line.strip()
if stripped == "[cm5]":
in_cm5 = True
elif stripped.startswith("[") and stripped.endswith("]"):
if in_cm5 and not inserted and not has_overlay:
out.append(OVERLAY)
inserted = True
in_cm5 = False
if stripped == "dtparam=spi1=on":
continue
if stripped == OVERLAY:
has_overlay = True
if in_cm5:
continue
out.append(line)
continue
out.append(line)
if not has_overlay and not inserted:
if out and out[-1].strip():
out.append("")
out.append(OVERLAY)
new_text = "\n".join(out) + "\n"
if new_text == CONFIG.read_text():
print("config.txt already correct.")
else:
CONFIG.write_text(new_text)
print("Updated /boot/firmware/config.txt:")
print(f" - removed dtparam=spi1=on (ignored on Pi 5)")
print(f" - moved {OVERLAY} out of [cm5] into [all]")
print("Reboot for the change to take effect on boot.")
print(
"\nWiring:\n"
" strip 0 DIN → GPIO 10 (SPI0 MOSI) /dev/spidev0.0\n"
" strip 1 DIN → GPIO 20 (SPI1 MOSI) /dev/spidev1.0"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

99
examples/led_demo.py Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Two strips showing different patterns at the same time."""
import argparse
import sys
import time
from leds import LedStrips, board_family, board_model, default_dual_strip_configs, pi5_setup_hint
def _describe(cfg) -> str:
if cfg.backend == "pio":
return f"GPIO {cfg.pin}{cfg.device}"
if cfg.backend == "ws281x":
return f"GPIO {cfg.pin} (channel {cfg.channel})"
return f"/dev/spidev{cfg.spi_bus}.{cfg.spi_device}"
def _wheel(pos: int) -> tuple[int, int, int]:
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 chase(strip, color: tuple[int, int, int], step: int) -> None:
count = len(strip)
strip.fill((0, 0, 0))
strip[step % count] = color
def rainbow(strip, offset: int) -> None:
for i in range(len(strip)):
strip[i] = _wheel(offset + i * 256 // max(len(strip), 1))
def main() -> int:
parser = argparse.ArgumentParser(description="WS2812 dual-strip demo")
parser.add_argument(
"-n",
"--count",
type=int,
nargs=2,
default=[200, 200],
metavar=("STRIP0", "STRIP1"),
help="LED count per strip (default: 10 10)",
)
parser.add_argument(
"--brightness",
type=float,
default=0.25,
help="brightness 0.0-1.0 (default: 0.25)",
)
args = parser.parse_args()
print(f"Board: {board_model()}")
cfg0, cfg1 = default_dual_strip_configs(
args.count[0], args.count[1], brightness=args.brightness
)
print(f"Strip 0: {cfg0.backend}{_describe(cfg0)} (red chase)")
print(f"Strip 1: {cfg1.backend}{_describe(cfg1)} (rainbow)")
step = 0
try:
with LedStrips(args.count[0], args.count[1], brightness=args.brightness) as strips:
while True:
# strip 0: red dot chasing along the strip
chase(strips[0], (255, 0, 0), step)
# strip 1: rainbow scroll — completely independent
rainbow(strips[1], step * 4)
strips.show()
step += 1
time.sleep(0.05)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
if board_family() == "pi5":
print("\n" + pi5_setup_hint(args.count[0], args.count[1]), file=sys.stderr)
return 1
except OSError as exc:
print(f"Error: {exc}", file=sys.stderr)
if board_family() == "pi5" and "No such file" in str(exc):
print("\n" + pi5_setup_hint(args.count[0], args.count[1]), file=sys.stderr)
return 1
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

79
examples/matrix_demo.py Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Rainbow scroll on the LED matrix (local SPI or Pico panel)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from leds import ANIMATIONS, LedMatrix
from leds.array_config import MATRIX_BRIGHTNESS, MATRIX_HEIGHT, MATRIX_SPI_BUS, MATRIX_SPI_DEVICE, MATRIX_WIDTH
from leds.config import spi_strip_config
from leds.panel_udp import (
add_panel_network_args,
format_panel_targets,
panel_targets_from_args,
run_panel_animation_loop,
)
def main() -> int:
parser = argparse.ArgumentParser(description="LED matrix rainbow demo")
parser.add_argument("--brightness", type=float, default=MATRIX_BRIGHTNESS)
parser.add_argument(
"--spi-bus",
type=int,
default=int(os.environ.get("PORTAL_SPI_BUS", MATRIX_SPI_BUS)),
)
parser.add_argument("--spi-device", type=int, default=MATRIX_SPI_DEVICE)
parser.add_argument("--panel", action="store_true", help="send to Pico panel(s) over UDP")
add_panel_network_args(parser)
args = parser.parse_args()
print(f"Matrix: {MATRIX_WIDTH}×{MATRIX_HEIGHT} ({MATRIX_WIDTH * MATRIX_HEIGHT} LEDs)")
if args.panel:
targets = panel_targets_from_args(args)
try:
run_panel_animation_loop(
targets,
args.port,
"rainbow",
brightness=args.brightness,
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
draw = ANIMATIONS["rainbow"]
cfg = spi_strip_config(
MATRIX_WIDTH * MATRIX_HEIGHT,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,
)
try:
with LedMatrix(config0=cfg) as matrix:
frame = 0
while True:
draw(matrix, frame)
matrix.show()
frame += 1
time.sleep(1 / 30)
except KeyboardInterrupt:
print("\nStopped.")
return 0
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
print("Try: --panel --host <pico-ip>", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

71
examples/panel_animations.py Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Run LED matrix animations on Pico panel(s) over UDP."""
from __future__ import annotations
import argparse
import sys
from leds.panel_udp import (
add_panel_args,
animation_playlist,
format_panel_targets,
panel_targets_from_args,
run_panel_animation_loop,
run_panel_playlist,
)
def main() -> int:
from leds.animations import ANIMATIONS
names = list(ANIMATIONS.keys())
parser = argparse.ArgumentParser(description="Animations for Pico panel(s) over UDP")
parser.add_argument(
"animation",
nargs="?",
choices=names + ["all"],
default="all",
)
add_panel_args(parser)
parser.add_argument("--duration", type=float, default=12.0)
parser.add_argument("--fps", type=float, default=None)
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
targets = panel_targets_from_args(args)
playlist = animation_playlist(args.animation)
print(f"Panel animations -> {format_panel_targets(targets, args.port)}")
print(f"Playlist: {', '.join(playlist)}")
try:
if len(playlist) == 1 and args.animation != "all":
run_panel_animation_loop(
targets,
args.port,
playlist[0],
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
)
else:
run_panel_playlist(
targets,
args.port,
playlist,
duration=args.duration,
fps_override=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

54
examples/panel_color_test.py Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Full-panel R / G / B flash for Pico panel(s)."""
from __future__ import annotations
import argparse
import socket
import sys
import time
from leds.panel_udp import (
add_panel_args,
format_panel_targets,
panel_targets_from_args,
send_solid_to_targets,
)
COLORS = (
("RED", (255, 0, 0)),
("GREEN", (0, 255, 0)),
("BLUE", (0, 0, 255)),
("WHITE", (255, 255, 255)),
("OFF", (0, 0, 0)),
)
def main() -> int:
parser = argparse.ArgumentParser(description="R/G/B color test for Pico panel(s)")
add_panel_args(parser)
parser.add_argument("--pause", type=float, default=1.5)
args = parser.parse_args()
targets = panel_targets_from_args(args)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print(f"Color test -> {format_panel_targets(targets, args.port)}\n")
try:
for label, color in COLORS:
print(f" {label}")
send_solid_to_targets(sock, targets, args.port, color, args.brightness, args.panel_id)
time.sleep(args.pause)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
finally:
send_solid_to_targets(sock, targets, args.port, (0, 0, 0), 1.0, args.panel_id)
sock.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

44
examples/panel_rgb_cycle.py Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Cycle full-panel red / green / blue on Pico panel(s)."""
from __future__ import annotations
import argparse
import sys
import time
from leds.panel_udp import PanelClient, add_panel_args, format_panel_targets, panel_targets_from_args
COLORS = (
("RED", (255, 0, 0)),
("GREEN", (0, 255, 0)),
("BLUE", (0, 0, 255)),
)
def main() -> int:
parser = argparse.ArgumentParser(description="RGB cycle on Pico panel(s)")
add_panel_args(parser)
parser.add_argument("--pause", type=float, default=1.0)
args = parser.parse_args()
targets = panel_targets_from_args(args)
print(f"RGB cycle -> {format_panel_targets(targets, args.port)} (Ctrl+C to stop)")
try:
with PanelClient.from_args(args) as client:
while True:
for label, color in COLORS:
print(f" {label}")
client.fill(color)
time.sleep(args.pause)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

361
examples/panel_sync_test.py Normal file
View File

@@ -0,0 +1,361 @@
#!/usr/bin/env python3
"""Positioning and sync test for multiple Pico panels.
Runs a sequence of patterns on all panels (or one via --panel-index):
identify - panel index digit on each board (check physical mapping)
corners - red/green/blue/yellow corners (check orientation)
crosshair - center row + column
columns - sweep the same column index on every panel together
rows - sweep rows together
flash - simultaneous white flashes (check sync)
pulse - in-sync brightness pulse on all panels
width - sweep columns near right edge (find 38 vs 39 vs 40)
all - full sequence (default)
"""
from __future__ import annotations
import argparse
import math
import socket
import sys
import time
from leds.panel_udp import (
HeadlessMatrix,
PanelTarget,
add_panel_args,
format_panel_targets,
panel_targets_from_args,
send_frame,
)
from leds.text import draw_text_centered
PANEL_COLORS = (
(255, 80, 80),
(80, 255, 80),
(80, 160, 255),
(255, 200, 80),
(200, 80, 255),
)
CORNER_COLORS = {
"top-left": (255, 0, 0),
"top-right": (0, 255, 0),
"bottom-left": (0, 0, 255),
"bottom-right": (255, 255, 0),
}
def _matrices(targets: list[PanelTarget], brightness: float) -> list[tuple[PanelTarget, HeadlessMatrix]]:
return [(t, HeadlessMatrix(t.width, t.height, brightness=brightness)) for t in targets]
def _push(
sock: socket.socket,
targets: list[PanelTarget],
matrices: list[tuple[PanelTarget, HeadlessMatrix]],
port: int,
panel_id: int | None,
) -> None:
"""Send all panel frames in a tight burst for best sync."""
for target, matrix in matrices:
send_frame(sock, target.host, port, matrix.rgb_bytes(), panel_id)
def _clear_all(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
) -> None:
matrices = _matrices(targets, brightness)
for _, matrix in matrices:
matrix.clear()
_push(sock, targets, matrices, port, panel_id)
def test_identify(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
) -> None:
print("Test: identify — each panel shows its index (04)")
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
matrix.clear()
color = PANEL_COLORS[target.index % len(PANEL_COLORS)]
draw_text_centered(matrix, str(target.index), color)
print(f" panel {target.index} -> {target.host} ({target.width}×{target.height})")
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
def test_corners(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
) -> None:
print("Test: corners — TL=red TR=green BL=blue BR=yellow (all panels)")
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
w, h = target.width, target.height
matrix.clear()
points = {
"top-left": (0, 0),
"top-right": (w - 1, 0),
"bottom-left": (0, h - 1),
"bottom-right": (w - 1, h - 1),
}
for name, (x, y) in points.items():
matrix[x, y] = CORNER_COLORS[name]
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
def test_crosshair(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
) -> None:
print("Test: crosshair — center row + column (white)")
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
w, h = target.width, target.height
matrix.clear()
mid_y = h // 2
mid_x = w // 2
for x in range(w):
matrix[x, mid_y] = (255, 255, 255)
for y in range(h):
matrix[mid_x, y] = (200, 200, 200)
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
def test_columns(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
) -> None:
max_width = max(t.width for t in targets)
print(f"Test: column sweep — x=0..{max_width - 1} on all panels together")
for x in range(max_width):
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
matrix.clear()
if x >= target.width:
continue
hue = int(255 * x / max(max_width - 1, 1))
for y in range(target.height):
matrix[x, y] = (hue, 80, 255 - hue)
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
def test_rows(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
) -> None:
height = targets[0].height if targets else 9
print(f"Test: row sweep — y=0..{height - 1} on all panels together")
for y in range(height):
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
matrix.clear()
for x in range(target.width):
matrix[x, y] = (255, 255, 255)
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
def test_flash(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
count: int,
) -> None:
print(f"Test: sync flash — {count} white flashes on all panels")
for n in range(count):
matrices = _matrices(targets, brightness)
for _, matrix in matrices:
matrix.fill((255, 255, 255))
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
time.sleep(pause / 2)
print(f" flash {n + 1}/{count}")
def test_pulse(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
seconds: float,
fps: float,
) -> None:
print(f"Test: sync pulse — {seconds:.0f}s solid red fade (all panels)")
frames = max(int(seconds * fps), 1)
for frame in range(frames):
t = frame / frames
level = 0.15 + 0.85 * (0.5 - 0.5 * math.cos(t * 6.28318))
color = (int(255 * level), 0, 0)
matrices = _matrices(targets, min(brightness * level, 1.0))
for _, matrix in matrices:
matrix.fill(color)
_push(sock, targets, matrices, port, panel_id)
time.sleep(1.0 / fps)
_clear_all(sock, targets, port, brightness, panel_id)
def test_chain_ends(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
) -> None:
print("Test: chain ends — LED 0=red, last LED=green (per panel)")
matrices = _matrices(targets, brightness)
for target, matrix in matrices:
matrix.clear()
matrix[0, 0] = (255, 0, 0)
last = target.pixels - 1
x = last % target.width
y = last // target.width
matrix[x, y] = (0, 255, 0)
print(f" panel {target.index}: index 0 and {last} ({x},{y})")
_push(sock, targets, matrices, port, panel_id)
time.sleep(pause)
def test_width(
sock: socket.socket,
targets: list[PanelTarget],
port: int,
brightness: float,
panel_id: int | None,
pause: float,
col_start: int,
col_end: int,
) -> None:
probe_w = col_end + 1
probe_leds = probe_w * targets[0].height if targets else probe_w * 9
print("Test: width — one full column at a time (right-edge probe)")
print(f" Probing columns {col_start}{col_end} (sending {probe_w}×9 = {probe_leds} LEDs)")
print(" Flash firmware with at least NUM_LEDS={} first, e.g.:".format(probe_leds))
print(f" make deploy PANEL_ID=<n> NUM_LEDS={probe_leds}")
print(" The last column that lights a real LED → width = column + 1")
print(" (column 37 lit → width 38; column 38 lit → width 39)\n")
for x in range(col_start, col_end + 1):
burst: list[tuple[PanelTarget, HeadlessMatrix]] = []
for target in targets:
matrix = HeadlessMatrix(probe_w, target.height, brightness=brightness)
matrix.clear()
for y in range(target.height):
matrix[x, y] = (255, 255, 255)
probe_target = PanelTarget(target.index, target.host, probe_w, target.height)
burst.append((probe_target, matrix))
for probe_target, matrix in burst:
send_frame(sock, probe_target.host, port, matrix.rgb_bytes(), panel_id)
print(f" column {x} → width {x + 1} if this is the last real column")
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
TESTS = {
"identify": test_identify,
"corners": test_corners,
"crosshair": test_crosshair,
"columns": test_columns,
"rows": test_rows,
"flash": test_flash,
"pulse": test_pulse,
"ends": test_chain_ends,
"width": test_width,
}
ALL_ORDER = ("identify", "corners", "ends", "crosshair", "columns", "rows", "flash", "pulse")
def main() -> int:
parser = argparse.ArgumentParser(description="Multi-panel positioning and sync test")
add_panel_args(parser)
parser.add_argument(
"--test",
choices=[*TESTS.keys(), "all"],
default="all",
help="which test to run (default: full sequence)",
)
parser.add_argument("--pause", type=float, default=1.2, help="hold time per static pattern")
parser.add_argument("--column-pause", type=float, default=0.12, help="delay per column in sweep")
parser.add_argument("--row-pause", type=float, default=0.25, help="delay per row in sweep")
parser.add_argument("--flashes", type=int, default=3, help="count for sync flash test")
parser.add_argument("--pulse-seconds", type=float, default=4.0)
parser.add_argument("--fps", type=float, default=30.0)
parser.add_argument("--col-start", type=int, default=32, help="width test: first column")
parser.add_argument("--col-end", type=int, default=42, help="width test: last column")
args = parser.parse_args()
targets = panel_targets_from_args(args)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print(f"Panel sync test -> {format_panel_targets(targets, args.port)}")
print("Ctrl+C to stop\n")
tests = ALL_ORDER if args.test == "all" else (args.test,)
try:
for name in tests:
fn = TESTS[name]
if name == "flash":
fn(sock, targets, args.port, args.brightness, args.panel_id, args.pause, args.flashes)
elif name == "pulse":
fn(sock, targets, args.port, args.brightness, args.panel_id, args.pulse_seconds, args.fps)
elif name == "columns":
fn(sock, targets, args.port, args.brightness, args.panel_id, args.column_pause)
elif name == "rows":
fn(sock, targets, args.port, args.brightness, args.panel_id, args.row_pause)
elif name == "width":
fn(sock, targets, args.port, args.brightness, args.panel_id, args.pause, args.col_start, args.col_end)
else:
fn(sock, targets, args.port, args.brightness, args.panel_id, args.pause)
print()
print("Done.")
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
finally:
_clear_all(sock, targets, args.port, args.brightness, args.panel_id)
sock.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

145
examples/panel_test.py Executable file
View File

@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Step-by-step drive test for Pico panel firmware over UDP."""
from __future__ import annotations
import argparse
import sys
import time
from leds.array_config import MATRIX_HEIGHT, MATRIX_PIXELS, MATRIX_WIDTH, matrix_pixel_index
from leds.panel_udp import PanelClient, add_panel_args, format_panel_targets, panel_targets_from_args
def test_flash(client: PanelClient, pause: float) -> None:
print("Test: RGB flash (red, green, blue, off)")
for name, color in (("red", (255, 0, 0)), ("green", (0, 255, 0)), ("blue", (0, 0, 255))):
print(f" {name}")
client.fill(color)
time.sleep(pause)
client.clear()
time.sleep(pause / 2)
def test_corners(client: PanelClient, pause: float) -> None:
target = client.targets[0]
width, height = target.width, target.height
print("Test: matrix corners (left → right rows)")
corners = [
("top-left", matrix_pixel_index(0, 0, width, height), (255, 0, 0)),
("top-right", matrix_pixel_index(width - 1, 0, width, height), (0, 255, 0)),
("bottom-left", matrix_pixel_index(0, height - 1, width, height), (0, 0, 255)),
("bottom-right", matrix_pixel_index(width - 1, height - 1, width, height), (255, 255, 0)),
]
for label, index, color in corners:
if index >= client.pixel_count:
print(f" skip {label} (index {index})")
continue
print(f" {label} -> LED {index}")
client.set_indices([index], color)
time.sleep(pause)
client.clear()
time.sleep(pause / 2)
def test_rows(client: PanelClient, pause: float) -> None:
target = client.targets[0]
width, height = target.width, target.height
print("Test: one row at a time (white)")
width = min(width, client.pixel_count)
rows = min(height, (client.pixel_count + width - 1) // width)
for y in range(rows):
indices = [matrix_pixel_index(x, y, width, height) for x in range(width)]
indices = [i for i in indices if i < client.pixel_count]
print(f" row {y} ({len(indices)} LEDs)")
client.set_indices(indices, (255, 255, 255))
time.sleep(pause)
client.clear()
time.sleep(pause / 2)
def test_chase(client: PanelClient, pause: float, steps: int | None) -> None:
count = steps if steps is not None else min(client.pixel_count, 60)
print(f"Test: chase ({count} steps, cyan)")
for i in range(count):
client.set_indices([i % client.pixel_count], (0, 255, 255))
time.sleep(pause)
client.clear()
time.sleep(pause / 2)
def test_first_last(client: PanelClient, pause: float) -> None:
print("Test: first and last LED in chain")
last = client.pixel_count - 1
print(f" LED 0 = red, LED {last} = green")
pixels = [(0, 0, 0)] * client.pixel_count
pixels[0] = (255, 0, 0)
pixels[last] = (0, 255, 0)
client.send_pixels(pixels)
time.sleep(pause)
client.clear()
time.sleep(pause / 2)
TESTS = {
"flash": lambda c, p, _s: test_flash(c, p),
"first-last": lambda c, p, _s: test_first_last(c, p),
"corners": lambda c, p, _s: test_corners(c, p),
"rows": lambda c, p, _s: test_rows(c, p),
"chase": test_chase,
}
def run_all(client: PanelClient, pause: float, chase_steps: int | None) -> None:
test_flash(client, pause)
test_first_last(client, pause)
if client.pixel_count >= client.targets[0].width:
test_corners(client, pause)
test_rows(client, max(pause * 0.6, 0.15))
test_chase(client, max(pause * 0.25, 0.05), chase_steps)
client.clear()
print("Done.")
def main() -> int:
parser = argparse.ArgumentParser(description="Drive-test Pico panel LEDs over UDP")
add_panel_args(parser)
parser.add_argument("--pause", type=float, default=0.8)
parser.add_argument("--test", choices=[*TESTS.keys(), "all"], default="all")
parser.add_argument("--chase-steps", type=int, default=None)
parser.add_argument("--color", default=None, metavar="R,G,B")
args = parser.parse_args()
if args.count is not None and args.count < 1:
print("Error: --count must be at least 1", file=sys.stderr)
return 1
targets = panel_targets_from_args(args)
print(f"Panel drive test -> {format_panel_targets(targets, args.port)}")
try:
with PanelClient.from_args(args) as client:
if args.color:
parts = [int(p.strip()) for p in args.color.split(",")]
if len(parts) != 3:
raise SystemExit("--color must be R,G,B")
client.fill((parts[0], parts[1], parts[2]))
print(f"Sent solid color {tuple(parts)}")
return 0
if args.test == "all":
run_all(client, args.pause, args.chase_steps)
else:
TESTS[args.test](client, args.pause, args.chase_steps)
client.clear()
print("Done.")
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

101
examples/panel_text.py Executable file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Display text on Pico panel(s) over UDP."""
from __future__ import annotations
import argparse
import socket
import subprocess
import sys
import time
from leds.panel_udp import (
HeadlessMatrix,
add_panel_args,
format_panel_targets,
panel_targets_from_args,
send_frame,
)
from leds.text import draw_text, draw_text_centered
DEFAULT_NAME = "JIMMY"
def _default_name() -> str:
try:
out = subprocess.run(
["git", "config", "user.name"],
capture_output=True,
text=True,
check=False,
)
name = out.stdout.strip()
if name:
return name.upper()
except OSError:
pass
return DEFAULT_NAME
def main() -> int:
parser = argparse.ArgumentParser(description="Display text on LED panel(s)")
parser.add_argument("text", nargs="?", default=None)
add_panel_args(parser)
parser.add_argument("--color", default="255,200,80", metavar="R,G,B")
parser.add_argument("--hold", type=float, default=0.0, help="0 = show until Ctrl+C")
parser.add_argument("--scroll", action="store_true")
args = parser.parse_args()
text = (args.text or _default_name()).upper()
parts = [int(p.strip()) for p in args.color.split(",")]
if len(parts) != 3:
print("Error: --color must be R,G,B", file=sys.stderr)
return 1
color = (parts[0], parts[1], parts[2])
targets = panel_targets_from_args(args)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print(f"Displaying '{text}' on {format_panel_targets(targets, args.port)}")
try:
matrices = [
(target, HeadlessMatrix(target.width, target.height, brightness=args.brightness))
for target in targets
]
if args.scroll:
start = time.monotonic()
offsets = {target.index: matrix.width for target, matrix in matrices}
while True:
for target, matrix in matrices:
matrix.clear()
draw_text(matrix, text, offsets[target.index], 1, color)
send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
offsets[target.index] -= 1
if offsets[target.index] < -len(text) * 6:
offsets[target.index] = matrix.width
time.sleep(0.08)
if args.hold > 0 and time.monotonic() - start >= args.hold:
break
else:
for target, matrix in matrices:
matrix.clear()
draw_text_centered(matrix, text, color)
send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
if args.hold <= 0:
print("Showing (Ctrl+C to clear)")
while True:
time.sleep(1)
else:
time.sleep(args.hold)
except KeyboardInterrupt:
print("\nClearing.")
finally:
for target, matrix in matrices:
matrix.clear()
send_frame(sock, target.host, args.port, matrix.rgb_bytes(), args.panel_id)
sock.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

48
examples/panel_udp_send.py Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Send one animation to Pico panel(s) over UDP."""
from __future__ import annotations
import argparse
import sys
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.panel_udp import (
add_panel_args,
format_panel_targets,
panel_targets_from_args,
run_panel_animation_loop,
)
def main() -> int:
parser = argparse.ArgumentParser(description="UDP animation sender for Pico panel(s)")
add_panel_args(parser)
parser.add_argument("--animation", default="rainbow", choices=sorted(ANIMATIONS))
parser.add_argument("--fps", type=float, default=None)
parser.add_argument("--brightness", type=float, default=0.25)
args = parser.parse_args()
targets = panel_targets_from_args(args)
fps = args.fps or DEFAULT_FPS.get(args.animation, 30)
print(f"Sending {args.animation} to {format_panel_targets(targets, args.port)} @ {fps:.0f} fps")
try:
run_panel_animation_loop(
targets,
args.port,
args.animation,
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"Network error: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

69
examples/setup_pi5_leds.py Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Print or install Pi 5 ws2812-pio overlay lines for two strips."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
CONFIG = Path("/boot/firmware/config.txt")
MARKER = "# portal ws2812 strips"
def overlay_lines(count0: int, count1: int, gpio0: int, gpio1: int) -> list[str]:
return [
MARKER,
f"dtoverlay=ws2812-pio,gpio={gpio0},num_leds={count0}",
f"dtoverlay=ws2812-pio,gpio={gpio1},num_leds={count1}",
]
def main() -> int:
parser = argparse.ArgumentParser(
description="Configure Pi 5 ws2812-pio overlays for two LED strips"
)
parser.add_argument("-n", type=int, nargs=2, default=[10, 10], metavar=("STRIP0", "STRIP1"))
parser.add_argument("--gpio0", type=int, default=18, help="strip 0 data pin (default 18)")
parser.add_argument("--gpio1", type=int, default=13, help="strip 1 data pin (default 13)")
parser.add_argument(
"--install",
action="store_true",
help="append overlay lines to /boot/firmware/config.txt (requires sudo)",
)
args = parser.parse_args()
lines = overlay_lines(args.n[0], args.n[1], args.gpio0, args.gpio1)
print("Add these lines to /boot/firmware/config.txt under [all], then reboot:\n")
for line in lines:
print(line)
print(
f"\nWiring: strip 0 DIN → GPIO {args.gpio0}, strip 1 DIN → GPIO {args.gpio1}, "
"GND → Pi GND, 5V from external supply for long strips."
)
print("\nAfter reboot you should see /dev/leds0 and /dev/leds1.")
if not args.install:
return 0
if not CONFIG.exists():
print(f"Error: {CONFIG} not found", file=sys.stderr)
return 1
text = CONFIG.read_text()
if MARKER in text:
print("\nOverlay lines already present in config.txt.")
return 0
with CONFIG.open("a") as f:
f.write("\n")
for line in lines:
f.write(line + "\n")
print(f"\nAppended to {CONFIG}. Reboot to apply.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,74 @@
cmake_minimum_required(VERSION 3.13)
set(PICO_BOARD pico CACHE STRING "Board type")
include(pico_sdk_import.cmake)
project(portal_panel C CXX ASM)
set(CMAKE_C_STANDARD 11)
pico_sdk_init()
include(FetchContent)
FetchContent_Declare(
iolibrary
GIT_REPOSITORY https://github.com/Wiznet/ioLibrary_Driver.git
GIT_TAG master
)
FetchContent_MakeAvailable(iolibrary)
set(IOLIB_DIR ${iolibrary_SOURCE_DIR})
add_executable(panel
main.c
wizchip_spi.c
timer.c
ws2812_led.cpp
WS2812.cpp
${IOLIB_DIR}/Ethernet/socket.c
${IOLIB_DIR}/Ethernet/wizchip_conf.c
${IOLIB_DIR}/Ethernet/W5500/w5500.c
${IOLIB_DIR}/Internet/DHCP/dhcp.c
)
pico_generate_pio_header(panel ${CMAKE_CURRENT_LIST_DIR}/WS2812.pio)
target_include_directories(panel PRIVATE
${CMAKE_CURRENT_LIST_DIR}
${IOLIB_DIR}/Ethernet
${IOLIB_DIR}/Internet/DHCP
)
target_compile_definitions(panel PRIVATE
_WIZCHIP_=W5500
)
if (DEFINED PANEL_ID_BUILD)
target_compile_definitions(panel PRIVATE PANEL_ID=${PANEL_ID_BUILD})
endif ()
if (DEFINED NUM_LEDS_BUILD)
target_compile_definitions(panel PRIVATE NUM_LEDS=${NUM_LEDS_BUILD})
endif ()
if (DEFINED WS2812_PIN_BUILD)
target_compile_definitions(panel PRIVATE PIN_WS2812=${WS2812_PIN_BUILD})
endif ()
option(PORTAL_USE_DHCP "Use DHCP for W5500" OFF)
if (PORTAL_USE_DHCP)
target_compile_definitions(panel PRIVATE USE_DHCP=1)
else ()
target_compile_definitions(panel PRIVATE USE_DHCP=0)
endif ()
target_link_libraries(panel
pico_stdlib
hardware_spi
hardware_pio
)
pico_enable_stdio_usb(panel 1)
pico_enable_stdio_uart(panel 0)
pico_add_extra_outputs(panel)

113
firmware/panel/Makefile Normal file
View File

@@ -0,0 +1,113 @@
# Portal panel firmware — build and USB deploy
#
# Usage:
# make # build only
# make deploy # build + flash over USB (picotool)
# make clean
#
# Options (passed to cmake on configure/reconfigure):
# make deploy PANEL_ID=0
# make deploy NO_DHCP=1
# make deploy WS2812_PIN=28 # if data wire is on GP28 not GP27
#
# Environment:
# PICO_SDK_PATH default: ~/pico/pico-sdk
# SERIAL_PORT default: first /dev/ttyACM* or /dev/ttyACM0
PICO_SDK_PATH ?= $(HOME)/pico/pico-sdk
BUILD_DIR := build
UF2 := $(BUILD_DIR)/panel.uf2
CMAKE_STAMP := $(BUILD_DIR)/.cmake_stamp
JOBS ?= $(shell nproc 2>/dev/null || echo 4)
SERIAL_PORT ?= $(shell ls /dev/ttyACM* 2>/dev/null | head -1)
ifeq ($(SERIAL_PORT),)
SERIAL_PORT := /dev/ttyACM0
endif
SERIAL_BAUD ?= 115200
CMAKE_ARGS :=
ifdef PANEL_ID
CMAKE_ARGS += -DPANEL_ID_BUILD=$(PANEL_ID)
# Panels 0 and 4 are 9×39 (351); others are 9×45 (405). Always pass NUM_LEDS so
# cmake cache cannot keep a stale count from a previous PANEL_ID flash.
ifeq ($(PANEL_ID),0)
NUM_LEDS ?= 351
else ifeq ($(PANEL_ID),4)
NUM_LEDS ?= 351
else
NUM_LEDS ?= 405
endif
CMAKE_ARGS += -DNUM_LEDS_BUILD=$(NUM_LEDS)
else
ifdef NUM_LEDS
CMAKE_ARGS += -DNUM_LEDS_BUILD=$(NUM_LEDS)
endif
endif
ifdef WS2812_PIN
CMAKE_ARGS += -DWS2812_PIN_BUILD=$(WS2812_PIN)
endif
ifeq ($(NO_DHCP),0)
CMAKE_ARGS += -DPORTAL_USE_DHCP=ON
else
CMAKE_ARGS += -DPORTAL_USE_DHCP=OFF
endif
CMAKE_STAMP_BODY := $(strip $(CMAKE_ARGS))
SOURCES := $(wildcard *.c *.cpp *.h) WS2812.pio CMakeLists.txt pico_sdk_import.cmake
.PHONY: all build configure reconfigure deploy flash upload monitor clean help
all: build
help:
@echo "Targets:"
@echo " make build Build panel.uf2"
@echo " make deploy Build and flash over USB"
@echo " make flash Alias for deploy"
@echo " make monitor USB serial console (picocom, Ctrl+A Ctrl+X to quit)"
@echo " make clean Remove build directory"
@echo " make reconfigure Re-run cmake (e.g. after changing PANEL_ID)"
build: configure
@echo "==> Building panel firmware"
PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake --build $(BUILD_DIR) -j$(JOBS)
@test -f "$(UF2)" || (echo "Error: build did not produce panel.uf2" && exit 1)
@echo "==> Build OK: $(UF2)"
configure:
@test -d "$(PICO_SDK_PATH)" || (echo "Error: pico-sdk not found at $(PICO_SDK_PATH) (set PICO_SDK_PATH)" && exit 1)
@command -v cmake >/dev/null || (echo "Error: cmake is required" && exit 1)
@mkdir -p $(BUILD_DIR)
@STAMP='$(CMAKE_STAMP_BODY)'; \
if [ ! -f "$(BUILD_DIR)/CMakeCache.txt" ] || [ ! -f "$(CMAKE_STAMP)" ] || [ "$$(cat $(CMAKE_STAMP))" != "$$STAMP" ]; then \
echo "==> Configuring cmake in $(BUILD_DIR) $(CMAKE_ARGS)"; \
PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS); \
echo "$$STAMP" > "$(CMAKE_STAMP)"; \
fi
reconfigure:
@test -d "$(PICO_SDK_PATH)" || (echo "Error: pico-sdk not found at $(PICO_SDK_PATH) (set PICO_SDK_PATH)" && exit 1)
@mkdir -p $(BUILD_DIR)
@echo "==> Reconfiguring cmake in $(BUILD_DIR) $(CMAKE_ARGS)"
PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS)
@echo '$(CMAKE_STAMP_BODY)' > $(CMAKE_STAMP)
deploy: build
@command -v picotool >/dev/null || (echo "Error: picotool not found (https://github.com/raspberrypi/picotool)" && exit 1)
@picotool version 2>&1 | grep -q 'without USB support' && \
(echo "Error: picotool was built without USB support" && exit 1) || true
@echo "==> Uploading $(UF2) over USB"
@picotool load -x -f "$(UF2)"
flash: deploy
upload: deploy
monitor:
@command -v picocom >/dev/null || (echo "Error: picocom not found (sudo apt install picocom)" && exit 1)
@test -e "$(SERIAL_PORT)" || (echo "Error: $(SERIAL_PORT) not found (plug in Pico USB, or set SERIAL_PORT=...)" && exit 1)
@echo "==> Serial monitor on $(SERIAL_PORT) ($(SERIAL_BAUD) baud, Ctrl+A Ctrl+X to quit)"
picocom --baud $(SERIAL_BAUD) --flow n --echo $(SERIAL_PORT)
clean:
rm -rf $(BUILD_DIR)

125
firmware/panel/README.md Normal file
View File

@@ -0,0 +1,125 @@
# Portal panel firmware (Pico SDK)
C firmware for a **Pico + W5500 + WS2812** panel adapter. Replaces the slow CircuitPython `adapter/code.py` path with native UDP receive and PIO WS2812 output.
## Hardware
Matches `adapter/code.py`:
| Signal | GPIO |
|--------|------|
| W5500 CS | GP9 |
| SPI1 SCK | GP10 |
| SPI1 MOSI | GP11 |
| SPI1 MISO | GP12 |
| W5500 RST | GP13 |
| Status LED | GP25 |
| WS2812 data | GP27 |
Default: **405 LEDs** (45×9 matrix chain), one data pin per panel.
WS2812 output uses [ForsakenNGS/Pico_WS2812](https://github.com/ForsakenNGS/Pico_WS2812) (FORMAT_GRB on GP27).
## Build
Requires [pico-sdk](https://github.com/raspberrypi/pico-sdk) and the ARM GCC toolchain (`arm-none-eabi-gcc`).
```bash
# From repo root
make deploy # build + flash over USB
make build # build only
# Or from this directory
make deploy
```
Manual cmake (one-time pico-sdk setup):
```bash
git clone https://github.com/raspberrypi/pico-sdk.git ~/pico/pico-sdk
cd ~/pico/pico-sdk && git submodule update --init
export PICO_SDK_PATH=~/pico/pico-sdk
mkdir -p build && cd build
cmake ..
make -j$(nproc)
```
UF2 output: `build/panel.uf2` — hold BOOTSEL and copy to the Pico.
### Options
```bash
# Panel 0 of 5 → 10.1.1.10, unique MAC, UDP panel_id filter
make deploy PANEL_ID=0 # 10.1.1.10
make deploy PANEL_ID=1 # 10.1.1.11
# DHCP instead of static 10.1.1.1014
make deploy PANEL_ID=0 NO_DHCP=0
# Fewer LEDs for bench test
make deploy NUM_LEDS=2
```
Static IP and MAC are derived from `PANEL_ID`:
| Panel | IP | MAC |
|-------|-----|-----|
| 0 | 10.1.1.10 | 02:50:52:54:4C:00 |
| 1 | 10.1.1.11 | 02:50:52:54:4C:01 |
| … | … | … |
| 4 | 10.1.1.14 | 02:50:52:54:4C:04 |
| 255 (bench) | 10.1.1.19 | 02:50:52:54:4C:FF |
Edit `board_config.h` for gateway, subnet, and pin changes.
## UDP protocol (port 50007)
| Payload | Action |
|---------|--------|
| **1215 bytes** | Raw RGB (`405 × 3`), show immediately |
| **1216 bytes** | `panel_id` (byte 0) + RGB; `255` = accept on any panel |
| **4 bytes `SHOW`** | Push buffered pixels to the strip (sync helper) |
Pi sends **RGB** order; firmware converts to WS2812 **GRB**.
Target throughput: ~3050 fps per panel (vs ~1020 fps on CircuitPython).
## Pi test sender
```bash
pipenv run python examples/panel_udp_send.py --host 192.168.2.111 --animation rainbow
```
## Flashing
Requires [picotool](https://github.com/raspberrypi/picotool) with libusb support:
```bash
make deploy
```
Or `./scripts/panel_deploy.sh` (same thing).
Or manually:
```bash
picotool load -x -f build/panel.uf2
```
`-f` forces a USB reboot into BOOTSEL when the panel is already running.
Legacy UF2 drag-and-drop also works: hold BOOTSEL, plug USB, copy `panel.uf2` to `RPI-RP2`.
Serial debug:
```bash
make monitor
# or: picocom /dev/ttyACM0
```
Ctrl+A then Ctrl+X to exit picocom.
## Multi-panel (5 Picos)
Flash each Pico with `PANEL_ID` 04 (`make deploy PANEL_ID=N`). Each gets `10.1.1.1N` (1014), a unique MAC, and UDP filtering. Send frames from the Pi to each panels IP, or use a leading `panel_id` byte with `255` broadcast filtering.

92
firmware/panel/WS2812.cpp Normal file
View File

@@ -0,0 +1,92 @@
/* WS2812 driver — from https://github.com/ForsakenNGS/Pico_WS2812 (BSD-style) */
#include "WS2812.hpp"
#include "WS2812.pio.h"
#include <cstdlib>
WS2812::WS2812(uint pin, uint length, PIO pio, uint sm) {
initialize(pin, length, pio, sm, NONE, GREEN, RED, BLUE);
}
WS2812::WS2812(uint pin, uint length, PIO pio, uint sm, DataFormat format) {
switch (format) {
case FORMAT_RGB:
initialize(pin, length, pio, sm, NONE, RED, GREEN, BLUE);
break;
case FORMAT_GRB:
initialize(pin, length, pio, sm, NONE, GREEN, RED, BLUE);
break;
case FORMAT_WRGB:
initialize(pin, length, pio, sm, WHITE, RED, GREEN, BLUE);
break;
}
}
WS2812::~WS2812() {
delete[] data;
}
void WS2812::initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2,
DataByte b3, DataByte b4) {
this->pin = pin;
this->length = length;
this->pio = pio;
this->sm = sm;
this->data = new uint32_t[length];
this->bytes[0] = b1;
this->bytes[1] = b2;
this->bytes[2] = b3;
this->bytes[3] = b4;
uint offset = pio_add_program(pio, &ws2812_program);
uint bits = (b1 == NONE ? 24 : 32);
ws2812_program_init(pio, sm, offset, pin, 800000, bits);
}
uint32_t WS2812::convertData(uint32_t rgbw) {
uint32_t result = 0;
for (uint b = 0; b < 4; b++) {
switch (bytes[b]) {
case RED:
result |= (rgbw & 0xFF);
break;
case GREEN:
result |= (rgbw & 0xFF00) >> 8;
break;
case BLUE:
result |= (rgbw & 0xFF0000) >> 16;
break;
case WHITE:
result |= (rgbw & 0xFF000000) >> 24;
break;
default:
break;
}
result <<= 8;
}
return result;
}
void WS2812::setPixelColor(uint index, uint32_t color) {
if (index < length) {
data[index] = convertData(color);
}
}
void WS2812::setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue) {
setPixelColor(index, RGB(red, green, blue));
}
void WS2812::fill(uint32_t color) {
color = convertData(color);
for (uint i = 0; i < length; i++) {
data[i] = color;
}
}
void WS2812::show() {
for (uint i = 0; i < length; i++) {
pio_sm_put_blocking(pio, sm, data[i]);
}
}

38
firmware/panel/WS2812.hpp Normal file
View File

@@ -0,0 +1,38 @@
#ifndef WS2812_H
#define WS2812_H
#include "hardware/pio.h"
#include "pico/types.h"
class WS2812 {
public:
enum DataByte { NONE = 0, RED = 1, GREEN = 2, BLUE = 3, WHITE = 4 };
enum DataFormat { FORMAT_RGB = 0, FORMAT_GRB = 1, FORMAT_WRGB = 2 };
WS2812(uint pin, uint length, PIO pio, uint sm);
WS2812(uint pin, uint length, PIO pio, uint sm, DataFormat format);
~WS2812();
static uint32_t RGB(uint8_t red, uint8_t green, uint8_t blue) {
return (uint32_t)(blue) << 16 | (uint32_t)(green) << 8 | (uint32_t)(red);
}
void setPixelColor(uint index, uint32_t color);
void setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue);
void fill(uint32_t color);
void show();
private:
uint pin;
uint length;
PIO pio;
uint sm;
DataByte bytes[4];
uint32_t *data;
void initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2,
DataByte b3, DataByte b4);
uint32_t convertData(uint32_t rgbw);
};
#endif

44
firmware/panel/WS2812.pio Normal file
View File

@@ -0,0 +1,44 @@
;
; WS2812 PIO — from https://github.com/ForsakenNGS/Pico_WS2812
;
.program ws2812
.side_set 1
.define public T1 2
.define public T2 5
.define public T3 3
.lang_opt python sideset_init = pico.PIO.OUT_HIGH
.lang_opt python out_init = pico.PIO.OUT_HIGH
.lang_opt python out_shiftdir = 1
.wrap_target
bitloop:
out x, 1 side 0 [T3 - 1]
jmp !x send_zero side 1 [T1 - 1]
send_one:
jmp bitloop side 1 [T2 - 1]
send_zero:
nop side 0 [T2 - 1]
.wrap
% c-sdk {
#include "hardware/clocks.h"
static inline void ws2812_program_init(PIO pio, uint sm, uint offset, uint pin, float freq, uint bits) {
pio_gpio_init(pio, pin);
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
pio_sm_config c = ws2812_program_get_default_config(offset);
sm_config_set_sideset_pins(&c, pin);
sm_config_set_out_shift(&c, false, true, bits);
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);
int cycles_per_bit = ws2812_T1 + ws2812_T2 + ws2812_T3;
float div = (float)clock_get_hz(clk_sys) / (freq * (float)cycles_per_bit);
sm_config_set_clkdiv(&c, div);
pio_sm_init(pio, sm, offset, &c);
pio_sm_set_enabled(pio, sm, true);
}
%}

View File

@@ -0,0 +1,53 @@
#ifndef BOARD_CONFIG_H
#define BOARD_CONFIG_H
/* Matches adapter/code.py — W5500 on SPI1, WS2812 on GP27 */
#define PIN_LED_STATUS 25
#define SPI_PORT spi1
#define SPI_CLK_MHZ 20
#define PIN_CS 9
#define PIN_SCK 10
#define PIN_MOSI 11
#define PIN_MISO 12
#define PIN_RST 13
#ifndef PIN_WS2812
#define PIN_WS2812 27
#endif
#ifndef NUM_LEDS
#define NUM_LEDS 405
#endif
#define LED_RGB_BYTES (NUM_LEDS * 3)
/* W5500 socket assignments */
#define SOCKET_DHCP 0
#define SOCKET_UDP 1
/* UDP port (same as CircuitPython adapter) */
#define UDP_PORT 50007
/* Compile-time panel index for filtered frames (04). 255 = accept all. */
#ifndef PANEL_ID
#define PANEL_ID 255
#endif
/* 1 = DHCP, 0 = static IP below */
#ifndef USE_DHCP
#define USE_DHCP 0
#endif
#define STATIC_IP_OCT4 ((PANEL_ID) == 255 ? 19u : (10u + (unsigned)(PANEL_ID)))
#define STATIC_IP {10, 1, 1, (uint8_t)STATIC_IP_OCT4}
#define STATIC_SN {255, 255, 255, 0}
#define STATIC_GW {10, 1, 1, 1}
#define STATIC_DNS {10, 1, 1, 1}
/* Locally administered; last byte = PANEL_ID (unique per panel 04). */
#define MAC_ADDR {0x02, 0x50, 0x52, 0x54, 0x4C, (uint8_t)PANEL_ID}
#endif

219
firmware/panel/main.c Normal file
View File

@@ -0,0 +1,219 @@
/**
* Portal panel firmware — W5500 UDP + WS2812 (Pico SDK).
*
* UDP protocol (port 50007):
* - N×3 bytes: raw RGB (N = NUM_LEDS), show immediately
* - N×3+1 bytes: panel_id (byte 0) + RGB; panel_id 255 = any panel
* - 4 bytes "SHOW": refresh buffered pixels (for split frame/show sync)
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include "board_config.h"
#include "wizchip_conf.h"
#include "dhcp.h"
#include "socket.h"
#include "timer.h"
#include "wizchip_spi.h"
#include "ws2812_led.h"
#include "hardware/gpio.h"
#include "pico/stdlib.h"
#define SOCKET_UDP 1
#define SOCKET_DHCP 0
#define ETH_BUF_SIZE 2048
#define DHCP_RETRY_MAX 10
static wiz_NetInfo g_net_info = {
.mac = MAC_ADDR,
.ip = STATIC_IP,
.sn = STATIC_SN,
.gw = STATIC_GW,
.dns = STATIC_DNS,
#if USE_DHCP
.dhcp = NETINFO_DHCP,
#else
.dhcp = NETINFO_STATIC,
#endif
};
static uint8_t g_eth_buf[ETH_BUF_SIZE];
static uint8_t g_pixel_buf[LED_RGB_BYTES];
static volatile uint16_t g_ms_tick;
static uint8_t g_dhcp_ready;
static void dhcp_timer_cb(void) {
g_ms_tick++;
if (g_ms_tick >= 999) {
g_ms_tick = 0;
DHCP_time_handler();
}
}
static void dhcp_assign(void) {
getIPfromDHCP(g_net_info.ip);
getGWfromDHCP(g_net_info.gw);
getSNfromDHCP(g_net_info.sn);
getDNSfromDHCP(g_net_info.dns);
g_net_info.dhcp = NETINFO_DHCP;
network_initialize(g_net_info);
print_network_information(g_net_info);
g_dhcp_ready = 1;
}
static void dhcp_conflict(void) {
printf("DHCP conflict\n");
}
static int network_bring_up(void) {
#if USE_DHCP
uint8_t retries = 0;
DHCP_init(SOCKET_DHCP, g_eth_buf);
reg_dhcp_cbfunc(dhcp_assign, dhcp_assign, dhcp_conflict);
wizchip_1ms_timer_initialize(dhcp_timer_cb);
while (!g_dhcp_ready && retries < DHCP_RETRY_MAX) {
int8_t rv = DHCP_run();
if (rv == DHCP_IP_LEASED) {
g_dhcp_ready = 1;
break;
}
if (rv == DHCP_FAILED) {
retries++;
}
wizchip_delay_ms(250);
}
if (!g_dhcp_ready) {
printf("DHCP failed, using static IP\n");
g_net_info.dhcp = NETINFO_STATIC;
network_initialize(g_net_info);
print_network_information(g_net_info);
}
#else
network_initialize(g_net_info);
print_network_information(g_net_info);
#endif
return 0;
}
static int udp_socket_open(void) {
int8_t sn = socket(SOCKET_UDP, Sn_MR_UDP, UDP_PORT, 0);
if (sn != SOCKET_UDP) {
printf("UDP socket open failed: %d\n", sn);
return -1;
}
printf("UDP listening on port %d\n", UDP_PORT);
return 0;
}
static void led_boot_test(void) {
printf("LED boot test on GP%d (%d pixels)\n", PIN_WS2812, NUM_LEDS);
ws2812_fill(255, 0, 0);
ws2812_show();
sleep_ms(500);
ws2812_fill(0, 255, 0);
ws2812_show();
sleep_ms(500);
ws2812_fill(0, 0, 0);
ws2812_show();
}
static int handle_frame(const uint8_t *rgb, uint16_t len) {
if (len != LED_RGB_BYTES) {
printf("frame reject len=%u want=%u\n", (unsigned)len, (unsigned)LED_RGB_BYTES);
return -1;
}
ws2812_set_rgb(rgb, NUM_LEDS);
ws2812_show();
return 0;
}
static void handle_packet(const uint8_t *data, int16_t len) {
if (len == 4 && memcmp(data, "SHOW", 4) == 0) {
ws2812_set_rgb(g_pixel_buf, NUM_LEDS);
ws2812_show();
return;
}
if (len == LED_RGB_BYTES) {
memcpy(g_pixel_buf, data, LED_RGB_BYTES);
handle_frame(data, (uint16_t)len);
return;
}
if (len == LED_RGB_BYTES + 1) {
uint8_t panel = data[0];
if (panel != 255 && panel != (uint8_t)PANEL_ID) {
return;
}
memcpy(g_pixel_buf, data + 1, LED_RGB_BYTES);
handle_frame(data + 1, LED_RGB_BYTES);
return;
}
}
static void poll_udp(void) {
while (1) {
uint16_t rx = getSn_RX_RSR(SOCKET_UDP);
if (rx == 0) {
return;
}
if (rx > ETH_BUF_SIZE) {
rx = ETH_BUF_SIZE;
}
uint8_t src_ip[4];
uint16_t src_port;
int16_t n = recvfrom(SOCKET_UDP, g_eth_buf, rx, src_ip, &src_port);
if (n <= 0) {
return;
}
handle_packet(g_eth_buf, n);
}
}
int main(void) {
stdio_init_all();
sleep_ms(2000);
gpio_init(PIN_LED_STATUS);
gpio_set_dir(PIN_LED_STATUS, GPIO_OUT);
printf("portal panel firmware (Pico SDK)\n");
printf("LEDs=%d panel_id=%d mac=02:50:52:54:4C:%02X ip=10.1.1.%u\n",
NUM_LEDS, PANEL_ID, (unsigned)PANEL_ID, (unsigned)STATIC_IP_OCT4);
wizchip_spi_initialize();
wizchip_cris_initialize();
wizchip_reset();
wizchip_initialize();
wizchip_check();
ws2812_init(PIN_WS2812, NUM_LEDS);
memset(g_pixel_buf, 0, sizeof(g_pixel_buf));
led_boot_test();
network_bring_up();
if (udp_socket_open() != 0) {
while (1) {
gpio_xor_mask(1u << PIN_LED_STATUS);
sleep_ms(100);
}
}
uint32_t heartbeat = 0;
while (1) {
#if USE_DHCP
DHCP_run();
#endif
poll_udp();
if (++heartbeat >= 500) {
gpio_xor_mask(1u << PIN_LED_STATUS);
heartbeat = 0;
}
sleep_ms(1);
}
}

View File

@@ -0,0 +1,54 @@
# This can be dropped into any project as a standalone CMake file
# SPDX-License-Identifier: BSD-3-Clause
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
endif ()
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
endif ()
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch Pico SDK from git")
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "Location to download Pico SDK")
if (NOT PICO_SDK_PATH)
if (PICO_SDK_FETCH_FROM_GIT)
include(FetchContent)
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
if (PICO_SDK_FETCH_FROM_GIT_PATH)
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH)
endif ()
FetchContent_Declare(
pico_sdk
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
GIT_TAG master
)
if (NOT pico_sdk)
FetchContent_Populate(pico_sdk)
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
endif ()
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
else ()
message(FATAL_ERROR
"PICO_SDK_PATH is not set. Clone pico-sdk and export PICO_SDK_PATH, "
"or set PICO_SDK_FETCH_FROM_GIT=ON.")
endif ()
endif ()
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
if (NOT EXISTS ${PICO_SDK_PATH})
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
endif ()
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
message(FATAL_ERROR "pico_sdk_init.cmake not found in ${PICO_SDK_PATH}")
endif ()
include(${PICO_SDK_INIT_CMAKE_FILE})

21
firmware/panel/timer.c Normal file
View File

@@ -0,0 +1,21 @@
#include "timer.h"
static struct repeating_timer g_timer;
static void (*g_callback)(void);
void wizchip_1ms_timer_initialize(void (*callback)(void)) {
g_callback = callback;
add_repeating_timer_us(-1000, wizchip_1ms_timer_callback, NULL, &g_timer);
}
bool wizchip_1ms_timer_callback(struct repeating_timer *t) {
(void)t;
if (g_callback != NULL) {
g_callback();
}
return true;
}
void wizchip_delay_ms(uint32_t ms) {
sleep_ms(ms);
}

10
firmware/panel/timer.h Normal file
View File

@@ -0,0 +1,10 @@
#ifndef PANEL_TIMER_H
#define PANEL_TIMER_H
#include "pico/stdlib.h"
void wizchip_1ms_timer_initialize(void (*callback)(void));
bool wizchip_1ms_timer_callback(struct repeating_timer *t);
void wizchip_delay_ms(uint32_t ms);
#endif

View File

@@ -0,0 +1,121 @@
/**
* W5500 SPI port for portal panel adapter (SPI1, GP913).
* Derived from WIZnet-PICO-C port/ioLibrary_Driver (BSD-3-Clause).
*/
#include <stdio.h>
#include "board_config.h"
#include "wizchip_conf.h"
#include "wizchip_spi.h"
#include "hardware/gpio.h"
#include "hardware/spi.h"
#include "pico/binary_info.h"
#include "pico/critical_section.h"
#include "pico/stdlib.h"
static critical_section_t g_wizchip_cri_sec;
static inline void wizchip_select(void) {
gpio_put(PIN_CS, 0);
}
static inline void wizchip_deselect(void) {
gpio_put(PIN_CS, 1);
}
static uint8_t wizchip_read(void) {
uint8_t rx = 0;
uint8_t tx = 0xff;
spi_read_blocking(SPI_PORT, tx, &rx, 1);
return rx;
}
static void wizchip_write(uint8_t tx) {
spi_write_blocking(SPI_PORT, &tx, 1);
}
static void wizchip_critical_section_lock(void) {
critical_section_enter_blocking(&g_wizchip_cri_sec);
}
static void wizchip_critical_section_unlock(void) {
critical_section_exit(&g_wizchip_cri_sec);
}
void wizchip_reset(void) {
gpio_init(PIN_RST);
gpio_set_dir(PIN_RST, GPIO_OUT);
gpio_put(PIN_RST, 0);
sleep_ms(100);
gpio_put(PIN_RST, 1);
sleep_ms(100);
bi_decl(bi_1pin_with_name(PIN_RST, "W5500 RESET"));
}
void wizchip_spi_initialize(void) {
spi_init(SPI_PORT, SPI_CLK_MHZ * 1000 * 1000);
gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
bi_decl(bi_3pins_with_func(PIN_MISO, PIN_MOSI, PIN_SCK, GPIO_FUNC_SPI));
gpio_init(PIN_CS);
gpio_set_dir(PIN_CS, GPIO_OUT);
gpio_put(PIN_CS, 1);
bi_decl(bi_1pin_with_name(PIN_CS, "W5500 CS"));
}
void wizchip_cris_initialize(void) {
critical_section_init(&g_wizchip_cri_sec);
reg_wizchip_cris_cbfunc(wizchip_critical_section_lock, wizchip_critical_section_unlock);
}
void wizchip_initialize(void) {
wizchip_deselect();
reg_wizchip_cs_cbfunc(wizchip_select, wizchip_deselect);
reg_wizchip_spi_cbfunc(wizchip_read, wizchip_write);
uint8_t memsize[2][8] = {
{2, 2, 2, 2, 2, 2, 2, 2},
{2, 2, 2, 2, 2, 2, 2, 2},
};
if (ctlwizchip(CW_INIT_WIZCHIP, (void *)memsize) == -1) {
printf("W5500 init failed\n");
return;
}
uint8_t link = PHY_LINK_OFF;
do {
if (ctlwizchip(CW_GET_PHYLINK, (void *)&link) == -1) {
printf("PHY link unknown\n");
return;
}
} while (link == PHY_LINK_OFF);
}
void wizchip_check(void) {
if (getVERSIONR() != 0x04) {
printf("W5500 version mismatch: 0x%02x\n", getVERSIONR());
while (1) {
tight_loop_contents();
}
}
}
void network_initialize(wiz_NetInfo net_info) {
ctlnetwork(CN_SET_NETINFO, (void *)&net_info);
}
void print_network_information(wiz_NetInfo net_info) {
ctlnetwork(CN_GET_NETINFO, (void *)&net_info);
printf("MAC %02X:%02X:%02X:%02X:%02X:%02X\n",
net_info.mac[0], net_info.mac[1], net_info.mac[2],
net_info.mac[3], net_info.mac[4], net_info.mac[5]);
printf("IP %d.%d.%d.%d\n",
net_info.ip[0], net_info.ip[1], net_info.ip[2], net_info.ip[3]);
}

View File

@@ -0,0 +1,14 @@
#ifndef WIZCHIP_SPI_H
#define WIZCHIP_SPI_H
#include "wizchip_conf.h"
void wizchip_spi_initialize(void);
void wizchip_cris_initialize(void);
void wizchip_reset(void);
void wizchip_initialize(void);
void wizchip_check(void);
void network_initialize(wiz_NetInfo net_info);
void print_network_information(wiz_NetInfo net_info);
#endif

View File

@@ -0,0 +1,39 @@
/* C API wrapper around ForsakenNGS/Pico_WS2812 */
#include "ws2812_led.h"
#include "WS2812.hpp"
#include "hardware/pio.h"
#include "pico/stdlib.h"
static WS2812 *s_strip;
extern "C" void ws2812_init(unsigned int pin, unsigned int num_leds) {
uint sm = pio_claim_unused_sm(pio0, true);
s_strip = new WS2812(pin, num_leds, pio0, sm, WS2812::FORMAT_GRB);
}
extern "C" void ws2812_set_rgb(const uint8_t *rgb, unsigned int count) {
if (s_strip == nullptr || rgb == nullptr) {
return;
}
for (unsigned int i = 0; i < count; i++) {
s_strip->setPixelColor(i, rgb[i * 3 + 0], rgb[i * 3 + 1], rgb[i * 3 + 2]);
}
}
extern "C" void ws2812_fill(uint8_t r, uint8_t g, uint8_t b) {
if (s_strip == nullptr) {
return;
}
s_strip->fill(WS2812::RGB(r, g, b));
}
extern "C" void ws2812_show(void) {
if (s_strip == nullptr) {
return;
}
s_strip->show();
sleep_us(300);
}

View File

@@ -0,0 +1,19 @@
#ifndef WS2812_LED_H
#define WS2812_LED_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
void ws2812_init(unsigned int pin, unsigned int num_leds);
void ws2812_set_rgb(const uint8_t *rgb, unsigned int count);
void ws2812_fill(uint8_t r, uint8_t g, uint8_t b);
void ws2812_show(void);
#ifdef __cplusplus
}
#endif
#endif

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)

17
pyproject.toml Normal file
View File

@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "portal"
version = "0.1.0"
description = "WS2812 LED control for Raspberry Pi"
requires-python = ">=3.9"
dependencies = [
"spidev>=3.6",
"rpi-ws281x>=5.0.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["leds*"]

116
scripts/panel_deploy.sh Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Build and upload portal panel firmware over USB.
#
# Usage:
# ./scripts/panel_deploy.sh
# ./scripts/panel_deploy.sh --build-only
# ./scripts/panel_deploy.sh --upload-only
#
# Options:
# --build-only Build, do not upload
# --upload-only Upload existing build, skip compile
# --panel-id N Pass -DPANEL_ID_BUILD=N to cmake
# --no-dhcp Pass -DPORTAL_USE_DHCP=OFF to cmake
# --num-leds N Pass -DNUM_LEDS_BUILD=N to cmake
#
# Environment:
# PICO_SDK_PATH Path to pico-sdk (default: ~/pico/pico-sdk)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="${ROOT}/firmware/panel/build"
PICO_SDK_PATH="${PICO_SDK_PATH:-${HOME}/pico/pico-sdk}"
JOBS="$(nproc)"
DO_BUILD=1
DO_UPLOAD=1
CMAKE_EXTRA=()
die() {
echo "Error: $*" >&2
exit 1
}
usage() {
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
exit "${1:-0}"
}
while (($# > 0)); do
case "$1" in
--build-only)
DO_UPLOAD=0
shift
;;
--upload-only)
DO_BUILD=0
shift
;;
--panel-id)
[[ $# -ge 2 ]] || die "--panel-id requires a number"
CMAKE_EXTRA+=("-DPANEL_ID_BUILD=${2}")
shift 2
;;
--no-dhcp)
CMAKE_EXTRA+=("-DPORTAL_USE_DHCP=OFF")
shift
;;
--num-leds)
[[ $# -ge 2 ]] || die "--num-leds requires a number"
CMAKE_EXTRA+=("-DNUM_LEDS_BUILD=${2}")
shift 2
;;
-h|--help)
usage 0
;;
*)
die "unknown option: $1 (try --help)"
;;
esac
done
build_firmware() {
[[ -d "${PICO_SDK_PATH}" ]] || die "pico-sdk not found at ${PICO_SDK_PATH} (set PICO_SDK_PATH)"
command -v cmake >/dev/null || die "cmake is required"
command -v make >/dev/null || die "make is required"
export PICO_SDK_PATH
mkdir -p "${BUILD_DIR}"
echo "==> Building panel firmware in ${BUILD_DIR}"
if [[ ! -f "${BUILD_DIR}/CMakeCache.txt" ]]; then
cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
elif ((${#CMAKE_EXTRA[@]} > 0)); then
cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
fi
cmake --build "${BUILD_DIR}" -j "${JOBS}"
[[ -f "${BUILD_DIR}/panel.uf2" ]] || die "build did not produce panel.uf2"
echo "==> Build OK: ${BUILD_DIR}/panel.uf2"
}
upload_usb() {
command -v picotool >/dev/null || die "picotool not found (install from https://github.com/raspberrypi/picotool)"
if picotool version 2>&1 | grep -q 'without USB support'; then
die "picotool was built without USB support; rebuild with libusb-1.0-0-dev installed"
fi
echo "==> Uploading over USB"
if ! picotool load -x -f "${BUILD_DIR}/panel.uf2" 2>&1 | tee /tmp/picotool_load.log; then
die "picotool load failed"
fi
if ! grep -q 'Loading into Flash' /tmp/picotool_load.log; then
die "picotool did not flash (hold BOOTSEL, plug USB, retry)"
fi
echo "==> USB upload OK"
}
if ((DO_BUILD)); then
build_firmware
fi
if ((DO_UPLOAD)); then
[[ -f "${BUILD_DIR}/panel.uf2" ]] || die "panel.uf2 not found; run without --upload-only first"
upload_usb
fi

117
scripts/setup_ap.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Set up a WiFi access point on wlan0 and share internet from ethernet (eth0).
#
# Usage:
# sudo ./scripts/setup_ap.sh
# sudo PORTAL_AP_SSID=mywifi PORTAL_AP_PASSWORD=secret ./scripts/setup_ap.sh
#
# Environment:
# PORTAL_AP_SSID WiFi name (default: portal)
# PORTAL_AP_PASSWORD WPA password, 8+ chars (default: portal1234)
# PORTAL_AP_CON_NAME NetworkManager connection name (default: portal-ap)
# PORTAL_AP_IF AP interface (default: wlan0)
# PORTAL_WAN_IF Uplink interface (default: eth0)
set -euo pipefail
SSID="${PORTAL_AP_SSID:-portal}"
PASSWORD="${PORTAL_AP_PASSWORD:-portal1234}"
CON_NAME="${PORTAL_AP_CON_NAME:-portal-ap}"
AP_IF="${PORTAL_AP_IF:-wlan0}"
WAN_IF="${PORTAL_WAN_IF:-eth0}"
die() {
echo "Error: $*" >&2
exit 1
}
if [[ "${EUID}" -ne 0 ]]; then
die "run as root: sudo $0"
fi
if ((${#PASSWORD} < 8)); then
die "PORTAL_AP_PASSWORD must be at least 8 characters"
fi
if ! command -v nmcli >/dev/null; then
die "NetworkManager (nmcli) is required"
fi
if ! systemctl is-active --quiet NetworkManager; then
die "NetworkManager is not running"
fi
if ! ip link show "${WAN_IF}" >/dev/null 2>&1; then
die "uplink interface ${WAN_IF} not found"
fi
if ! ip link show "${AP_IF}" >/dev/null 2>&1; then
die "AP interface ${AP_IF} not found"
fi
if ! ip link show "${WAN_IF}" | grep -q "state UP"; then
echo "Warning: ${WAN_IF} is not UP — connect ethernet before clients can reach the internet." >&2
fi
# NM shared AP mode uses hostapd on many Pi images.
if ! command -v hostapd >/dev/null; then
echo "Installing hostapd..."
apt-get update -qq
apt-get install -y hostapd
fi
# Allow forwarding between AP clients and ethernet.
sysctl -w net.ipv4.ip_forward=1 >/dev/null
cat >/etc/sysctl.d/99-portal-ap.conf <<'EOF'
# portal AP: share ethernet to WiFi clients
net.ipv4.ip_forward=1
EOF
rfkill unblock wifi >/dev/null 2>&1 || true
nmcli radio wifi on >/dev/null 2>&1 || true
if nmcli -t -f NAME connection show | grep -Fxq "${CON_NAME}"; then
echo "Updating existing connection '${CON_NAME}'..."
nmcli connection delete "${CON_NAME}" >/dev/null
fi
echo "Creating access point '${SSID}' on ${AP_IF}..."
nmcli connection add type wifi ifname "${AP_IF}" con-name "${CON_NAME}" \
autoconnect yes \
ssid "${SSID}" \
802-11-wireless.mode ap \
802-11-wireless.band bg \
ipv4.method shared \
ipv6.method ignore \
wifi-sec.key-mgmt wpa-psk \
wifi-sec.psk "${PASSWORD}" >/dev/null
# Prefer ethernet for upstream internet when both are available.
nmcli connection modify "${CON_NAME}" ipv4.never-default no
if nmcli -t -f NAME connection show | grep -Fxq "Wired connection 1"; then
nmcli connection modify "Wired connection 1" connection.autoconnect-priority 100
fi
nmcli connection modify "${CON_NAME}" connection.autoconnect-priority 50
echo "Starting access point..."
nmcli connection up "${CON_NAME}"
AP_IP="$(nmcli -g IP4.ADDRESS device show "${AP_IF}" 2>/dev/null | cut -d/ -f1)"
WAN_IP="$(nmcli -g IP4.ADDRESS device show "${WAN_IF}" 2>/dev/null | cut -d/ -f1 || true)"
cat <<EOF
Access point is running.
SSID: ${SSID}
Password: ${PASSWORD}
AP IP: ${AP_IP:-unknown} (${AP_IF})
Uplink: ${WAN_IF} ${WAN_IP:+($WAN_IP)}
Clients receive DHCP from the Pi and internet is shared via NAT through ${WAN_IF}.
The AP starts automatically on boot.
Stop it with:
sudo ./scripts/stop_ap.sh
EOF

62
scripts/stop_ap.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Stop the portal access point and return wlan0 to WiFi client mode.
set -euo pipefail
CON_NAME="${PORTAL_AP_CON_NAME:-portal-ap}"
WLAN_IF="${PORTAL_AP_IF:-wlan0}"
if [[ "${EUID}" -ne 0 ]]; then
echo "Error: run as root: sudo $0" >&2
exit 1
fi
if ! command -v nmcli >/dev/null; then
echo "Error: NetworkManager (nmcli) is required" >&2
exit 1
fi
if nmcli -t -f NAME connection show | grep -Fxq "${CON_NAME}"; then
nmcli connection down "${CON_NAME}" 2>/dev/null || true
nmcli connection modify "${CON_NAME}" connection.autoconnect no
echo "Access point '${CON_NAME}' stopped (autoconnect disabled)."
else
echo "Connection '${CON_NAME}' not found."
fi
rfkill unblock wifi >/dev/null 2>&1 || true
nmcli radio wifi on >/dev/null 2>&1 || true
nmcli device disconnect "${WLAN_IF}" 2>/dev/null || true
nmcli device wifi rescan 2>/dev/null || true
sleep 2
# Saved client profiles, newest first (skip the AP connection).
mapfile -t CLIENT_CONS < <(
nmcli -t -f NAME,TYPE,TIMESTAMP connection show | awk -F: -v ap="${CON_NAME}" '
$2 == "802-11-wireless" && $1 != ap { print $3 "\t" $1 }
' | sort -rn | cut -f2
)
CONNECTED=0
for client_con in "${CLIENT_CONS[@]}"; do
key_mgmt="$(nmcli -g 802-11-wireless-security.key-mgmt connection show "${client_con}" 2>/dev/null || true)"
if [[ -z "${key_mgmt}" ]]; then
echo "Skipping '${client_con}': missing 802-11-wireless-security.key-mgmt."
echo " Fix: sudo nmcli connection delete ${client_con}"
echo " sudo nmcli device wifi connect <SSID> password <pass>"
continue
fi
if nmcli connection up "${client_con}" ifname "${WLAN_IF}" 2>/dev/null; then
echo "wlan0 connected as client: ${client_con}"
CONNECTED=1
break
fi
done
if ((CONNECTED == 0)); then
echo "wlan0 is in client mode but no saved network connected."
echo "List networks: nmcli device wifi list"
echo "Connect: sudo nmcli device wifi connect <SSID> password <pass>"
fi
echo "Remove AP permanently: sudo nmcli connection delete ${CON_NAME}"