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

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