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

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

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

View File

@@ -23,6 +23,7 @@ from leds.panel_udp import (
animation_playlist,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_panel_animation_loop,
run_panel_playlist,
)
@@ -102,6 +103,9 @@ def main() -> int:
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
else:
run_panel_playlist(
@@ -113,6 +117,9 @@ def main() -> int:
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")
@@ -138,7 +145,7 @@ def main() -> int:
if args.linear:
with LedStrip(
count,
backend="spi",
backend=strip_cfg.backend,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
brightness=args.brightness,

View File

@@ -15,6 +15,7 @@ from leds.panel_udp import (
add_panel_network_args,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_panel_animation_loop,
)
@@ -36,12 +37,16 @@ def main() -> int:
if args.panel:
targets = panel_targets_from_args(args)
print(f"Panel -> {format_panel_targets(targets, args.port)}")
try:
run_panel_animation_loop(
targets,
args.port,
"rainbow",
brightness=args.brightness,
panel_id=args.panel_id,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")

View File

@@ -11,6 +11,7 @@ from leds.panel_udp import (
animation_playlist,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_panel_animation_loop,
run_panel_playlist,
)
@@ -47,6 +48,9 @@ def main() -> int:
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
else:
run_panel_playlist(
@@ -58,6 +62,9 @@ def main() -> int:
brightness=args.brightness,
panel_id=args.panel_id,
loop=not args.once,
sync_send=args.sync_send,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")

View File

@@ -8,19 +8,21 @@ import socket
import sys
import time
from leds.colors import BLUE, GREEN, OFF, RED, WHITE
from leds.panel_udp import (
add_panel_args,
apply_pin_arg,
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)),
("RED", RED),
("GREEN", GREEN),
("BLUE", BLUE),
("WHITE", WHITE),
("OFF", OFF),
)
@@ -31,7 +33,9 @@ def main() -> int:
args = parser.parse_args()
targets = panel_targets_from_args(args)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
has_udp = any(not t.local for t in targets)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if has_udp else None
targets = apply_pin_arg(args, sock, targets)
print(f"Color test -> {format_panel_targets(targets, args.port)}\n")
try:
@@ -46,7 +50,8 @@ def main() -> int:
return 1
finally:
send_solid_to_targets(sock, targets, args.port, (0, 0, 0), 1.0, args.panel_id)
sock.close()
if sock is not None:
sock.close()
return 0

View File

@@ -7,14 +7,9 @@ import argparse
import sys
import time
from leds.colors import RGB_CYCLE
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)")
@@ -28,7 +23,7 @@ def main() -> int:
try:
with PanelClient.from_args(args) as client:
while True:
for label, color in COLORS:
for label, color in RGB_CYCLE:
print(f" {label}")
client.fill(color)
time.sleep(args.pause)

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""Run a UDP-to-SPI bridge so a Pi-connected panel looks like a Pico adapter."""
from __future__ import annotations
from leds.panel_bridge import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""Positioning and sync test for multiple Pico panels.
"""Positioning and sync test for the hexagonal portal panels.
Runs a sequence of patterns on all panels (or one via --panel-index):
identify - panel index digit on each board (check physical mapping)
identify - panel index digit on each face (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
@@ -23,12 +23,15 @@ import time
from leds.panel_udp import (
HeadlessMatrix,
PanelMatrixBundle,
PanelTarget,
add_panel_args,
apply_pin_arg,
format_panel_targets,
panel_targets_from_args,
send_frame,
push_panel_frames,
)
from leds.array_config import panel_label
from leds.text import draw_text_centered
PANEL_COLORS = (
@@ -47,37 +50,35 @@ CORNER_COLORS = {
}
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 _matrices(targets: list[PanelTarget], brightness: float) -> PanelMatrixBundle:
return PanelMatrixBundle(targets, brightness)
def _push(
sock: socket.socket,
targets: list[PanelTarget],
matrices: list[tuple[PanelTarget, HeadlessMatrix]],
sock: socket.socket | None,
bundle: PanelMatrixBundle,
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)
push_panel_frames(sock, bundle.matrices, port, panel_id)
def _clear_all(
sock: socket.socket,
sock: socket.socket | None,
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)
with PanelMatrixBundle(targets, brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.clear()
_push(sock, bundle, port, panel_id)
def test_identify(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -85,18 +86,18 @@ def test_identify(
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)
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.matrices:
matrix.clear()
color = PANEL_COLORS[target.index % len(PANEL_COLORS)]
draw_text_centered(matrix, str(target.index), color)
print(f" {panel_label(target.index)} -> {target.label()}")
_push(sock, bundle, port, panel_id)
time.sleep(pause)
def test_corners(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -104,24 +105,24 @@ def test_corners(
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)
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.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, bundle, port, panel_id)
time.sleep(pause)
def test_crosshair(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -129,22 +130,22 @@ def test_crosshair(
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)
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.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, bundle, port, panel_id)
time.sleep(pause)
def test_columns(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -153,22 +154,22 @@ def test_columns(
) -> 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)
with _matrices(targets, brightness) as bundle:
for x in range(max_width):
for target, matrix in bundle.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, bundle, port, panel_id)
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
def test_rows(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -177,19 +178,19 @@ def test_rows(
) -> 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)
with _matrices(targets, brightness) as bundle:
for y in range(height):
for target, matrix in bundle.matrices:
matrix.clear()
for x in range(target.width):
matrix[x, y] = (255, 255, 255)
_push(sock, bundle, port, panel_id)
time.sleep(pause)
_clear_all(sock, targets, port, brightness, panel_id)
def test_flash(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -199,18 +200,18 @@ def test_flash(
) -> 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)
with _matrices(targets, brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.fill((255, 255, 255))
_push(sock, bundle, 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,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -220,20 +221,20 @@ def test_pulse(
) -> 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)
with _matrices(targets, brightness) as bundle:
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)
for _, matrix in bundle.matrices:
matrix.fill(color)
_push(sock, bundle, port, panel_id)
time.sleep(1.0 / fps)
_clear_all(sock, targets, port, brightness, panel_id)
def test_chain_ends(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -241,21 +242,21 @@ def test_chain_ends(
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)
with _matrices(targets, brightness) as bundle:
for target, matrix in bundle.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, bundle, port, panel_id)
time.sleep(pause)
def test_width(
sock: socket.socket,
sock: socket.socket | None,
targets: list[PanelTarget],
port: int,
brightness: float,
@@ -264,25 +265,26 @@ def test_width(
col_start: int,
col_end: int,
) -> None:
udp_targets = [t for t in targets if not t.local]
if not udp_targets:
print("Test: width — skipped (no UDP Pico panels in target list)")
return
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)")
probe_leds = probe_w * udp_targets[0].height
print("Test: width — one full column at a time (right-edge probe, UDP panels only)")
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:
for target in udp_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)
push_panel_frames(sock, burst, port, 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)
@@ -323,7 +325,9 @@ def main() -> int:
args = parser.parse_args()
targets = panel_targets_from_args(args)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
has_udp = any(not t.local for t in targets)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if has_udp else None
targets = apply_pin_arg(args, sock, targets)
print(f"Panel sync test -> {format_panel_targets(targets, args.port)}")
print("Ctrl+C to stop\n")
@@ -353,7 +357,8 @@ def main() -> int:
return 1
finally:
_clear_all(sock, targets, args.port, args.brightness, args.panel_id)
sock.close()
if sock is not None:
sock.close()
return 0

View File

@@ -10,13 +10,21 @@ import sys
import time
from leds.panel_udp import (
HeadlessMatrix,
PanelMatrixBundle,
add_panel_args,
apply_pin_arg,
format_panel_targets,
panel_targets_from_args,
send_frame,
push_panel_frames,
)
from leds.text import (
blit_mask,
blit_mask_gradient,
draw_font_text_centered,
draw_text,
draw_text_centered,
render_font_mask,
)
from leds.text import draw_text, draw_text_centered
DEFAULT_NAME = "JIMMY"
@@ -44,9 +52,40 @@ def main() -> int:
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")
parser.add_argument(
"--cursive",
action="store_true",
help="Italic script style (keeps letter case; auto-scrolls if too wide)",
)
parser.add_argument(
"--font-size",
type=int,
default=None,
help="Font point size (default: panel height + 3)",
)
parser.add_argument(
"--letter-spacing",
type=int,
default=0,
help="Pixels between cursive letters (default: 0)",
)
parser.add_argument(
"--speed",
type=float,
default=None,
help="Scroll delay in seconds (default: 0.15 cursive, 0.08 bitmap)",
)
parser.add_argument(
"--rainbow",
action="store_true",
help="Rainbow gradient across cursive text",
)
args = parser.parse_args()
text = (args.text or _default_name()).upper()
if args.cursive:
text = args.text or "Allyssia"
else:
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)
@@ -54,46 +93,114 @@ def main() -> int:
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)}")
has_udp = any(not t.local for t in targets)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if has_udp else None
targets = apply_pin_arg(args, sock, targets)
style = "cursive" if args.cursive else "bitmap"
cursive_masks: dict[int, tuple[int, list[tuple[int, int]]]] = {}
if args.cursive:
try:
for target in targets:
width, _, ons = render_font_mask(
text,
height=target.height,
size=args.font_size,
letter_spacing=args.letter_spacing,
)
cursive_masks[target.index] = (width, ons)
except (ImportError, FileNotFoundError, OSError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
# Auto-scroll when the rendered text is wider than any target panel.
do_scroll = args.scroll
if args.cursive and not do_scroll:
if any(
cursive_masks[t.index][0] > t.width for t in targets if t.index in cursive_masks
):
do_scroll = True
scroll_delay = args.speed
if scroll_delay is None:
scroll_delay = 0.15 if args.cursive else 0.08
print(
f"Displaying '{text}' ({style}"
f"{', scrolling' if do_scroll else ''})"
f" 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)")
with PanelMatrixBundle(targets, args.brightness) as bundle:
if do_scroll:
start = time.monotonic()
offsets = {
target.index: matrix.width for target, matrix in bundle.matrices
}
while True:
time.sleep(1)
phase = (time.monotonic() - start) * 0.55
for target, matrix in bundle.matrices:
matrix.clear()
if args.cursive:
tw, ons = cursive_masks[target.index]
if args.rainbow:
blit_mask_gradient(
matrix,
ons,
offsets[target.index],
0,
width=tw,
phase=phase,
cycles=2.5,
)
else:
blit_mask(matrix, ons, offsets[target.index], 0, color)
wrap = tw
else:
draw_text(matrix, text, offsets[target.index], 1, color)
wrap = len(text) * 6
offsets[target.index] -= 1
if offsets[target.index] < -wrap:
offsets[target.index] = matrix.width
push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
time.sleep(scroll_delay)
if args.hold > 0 and time.monotonic() - start >= args.hold:
break
else:
time.sleep(args.hold)
for target, matrix in bundle.matrices:
matrix.clear()
if args.cursive:
if args.rainbow:
tw, ons = cursive_masks[target.index]
x = max(0, (matrix.width - tw) // 2)
blit_mask_gradient(matrix, ons, x, 0, width=tw, phase=0.0)
else:
draw_font_text_centered(
matrix,
text,
color,
size=args.font_size,
letter_spacing=args.letter_spacing,
)
else:
draw_text_centered(matrix, text, color)
push_panel_frames(sock, bundle.matrices, args.port, 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()
with PanelMatrixBundle(targets, args.brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.clear()
push_panel_frames(sock, bundle.matrices, args.port, args.panel_id)
if sock is not None:
sock.close()
return 0

View File

@@ -11,6 +11,7 @@ from leds.panel_udp import (
add_panel_args,
format_panel_targets,
panel_targets_from_args,
parse_pins_arg,
run_panel_animation_loop,
)
@@ -35,6 +36,8 @@ def main() -> int:
fps=args.fps,
brightness=args.brightness,
panel_id=args.panel_id,
pin=args.pin,
pins=parse_pins_arg(args),
)
except KeyboardInterrupt:
print("\nStopped.")

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Web portal simulator — FastAPI + live reload."""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description="Portal web simulator (FastAPI)")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument(
"--reload",
action=argparse.BooleanOptionalAction,
default=True,
help="reload Python on code changes (default: on)",
)
parser.add_argument(
"--browser-reload",
action=argparse.BooleanOptionalAction,
default=True,
help="reload browser when web/ files change (default: on)",
)
args = parser.parse_args()
web_dir = Path(__file__).resolve().parent.parent / "web"
if not web_dir.is_dir():
print(f"Missing web directory: {web_dir}", file=sys.stderr)
return 1
try:
import uvicorn
except ImportError:
print(
"FastAPI simulator requires: pipenv install fastapi 'uvicorn[standard]'",
file=sys.stderr,
)
return 1
os.environ["PORTAL_DEV_RELOAD"] = "1" if args.browser_reload else "0"
leds_dir = Path(__file__).resolve().parent.parent / "leds"
reload_dirs = [str(web_dir), str(leds_dir)] if args.reload else None
print(f"Portal simulator → http://{args.host}:{args.port}/")
if args.reload:
print("Python reload: on")
if args.browser_reload:
print("Browser reload: on (watches web/)")
print("Ctrl+C to stop")
uvicorn.run(
"leds.portal_web:app",
host=args.host,
port=args.port,
reload=args.reload,
reload_dirs=reload_dirs,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Raise spidev bufsiz so full WS2812 SPI frames fit (405 LEDs ≈ 10 KiB)."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
from leds.backends.spi import _DEFAULT_BUFSIZ, spidev_bufsize
MODPROBE_D = Path("/etc/modprobe.d/spidev-bufsiz.conf")
MARKER = "# portal spidev bufsiz"
def main() -> int:
parser = argparse.ArgumentParser(
description="Configure spidev bufsiz for long WS2812 SPI strips"
)
parser.add_argument(
"--bufsiz",
type=int,
default=_DEFAULT_BUFSIZ,
help=f"Kernel SPI buffer size (default {_DEFAULT_BUFSIZ})",
)
parser.add_argument(
"--install",
action="store_true",
help="Write /etc/modprobe.d/spidev-bufsiz.conf and reload spidev (sudo)",
)
args = parser.parse_args()
current = spidev_bufsize()
print(f"spidev bufsiz: {current}")
print(f"405 LEDs need ~9762 bytes per frame.\n")
line = f"options spidev bufsiz={args.bufsiz}"
print("Add to /etc/modprobe.d/spidev-bufsiz.conf:\n")
print(f"{MARKER}")
print(line)
print("\nThen reload:\n sudo modprobe -r spidev && sudo modprobe spidev")
if not args.install:
return 0
try:
existing = MODPROBE_D.read_text() if MODPROBE_D.exists() else ""
if MARKER in existing:
print(f"\n{MODPROBE_D} already configured.")
else:
MODPROBE_D.write_text(f"{MARKER}\n{line}\n")
print(f"\nWrote {MODPROBE_D}.")
subprocess.run(["modprobe", "-r", "spidev"], check=False)
subprocess.run(["modprobe", "spidev", f"bufsiz={args.bufsiz}"], check=True)
print(f"Reloaded spidev (bufsiz={spidev_bufsize()}).")
except OSError as exc:
print(f"Error: {exc}", file=sys.stderr)
print("Re-run with sudo.", file=sys.stderr)
return 1
except subprocess.CalledProcessError as exc:
print(f"modprobe failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

153
examples/spi_panel_test.py Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Step-by-step drive test for a Pi-connected panel over SPI."""
from __future__ import annotations
import argparse
import os
import sys
import time
from leds.array_config import (
MATRIX_BRIGHTNESS,
MATRIX_SPI_BUS,
MATRIX_SPI_DEVICE,
matrix_pixel_index,
panel_layout,
panel_pixel_count,
)
from leds.backends.spi import spidev_bufsize_hint
from leds.config import panel_strip_config
from leds.matrix import LedMatrix
def _flash(matrix: LedMatrix, pause: float) -> None:
print("Test: RGB flash")
for name, color in (("red", (255, 0, 0)), ("green", (0, 255, 0)), ("blue", (0, 0, 255))):
print(f" {name}")
matrix.fill(color)
matrix.show()
time.sleep(pause)
matrix.clear()
matrix.show()
def _first_last(matrix: LedMatrix, pixels: int, pause: float) -> None:
print(f"Test: LED 0 red, LED {pixels - 1} green")
width = matrix.width
matrix.fill((0, 0, 0))
matrix.set_pixel(0, 0, (255, 0, 0))
last = pixels - 1
matrix.set_pixel(last % width, last // width, (0, 255, 0))
matrix.show()
time.sleep(pause)
matrix.clear()
matrix.show()
def _rows(matrix: LedMatrix, width: int, height: int, pause: float) -> None:
print("Test: one row at a time (white)")
for y in range(height):
matrix.fill((0, 0, 0))
for x in range(width):
matrix[x, y] = (255, 255, 255)
print(f" row {y}")
matrix.show()
time.sleep(pause)
matrix.clear()
matrix.show()
def _corners(matrix: LedMatrix, width: int, height: int, pause: float) -> None:
print("Test: corners")
corners = (
("top-left", 0, 0, (255, 0, 0)),
("top-right", width - 1, 0, (0, 255, 0)),
("bottom-left", 0, height - 1, (0, 0, 255)),
("bottom-right", width - 1, height - 1, (255, 255, 0)),
)
for label, x, y, color in corners:
matrix.fill((0, 0, 0))
matrix[x, y] = color
idx = matrix_pixel_index(x, y, width, height)
print(f" {label} ({x},{y}) -> LED {idx}")
matrix.show()
time.sleep(pause)
matrix.clear()
matrix.show()
def _chase(matrix: LedMatrix, pixels: int, pause: float, steps: int) -> None:
print(f"Test: chase ({steps} steps)")
width = matrix.width
for i in range(steps):
matrix.fill((0, 0, 0))
idx = i % pixels
matrix[idx % width, idx // width] = (0, 255, 255)
matrix.show()
time.sleep(pause)
matrix.clear()
matrix.show()
def main() -> int:
parser = argparse.ArgumentParser(description="SPI drive test for Pi-connected panel")
parser.add_argument("--panel-index", type=int, default=2, choices=range(5), metavar="N")
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("--spi-mhz", type=float, default=4.2)
parser.add_argument("--wire-order", default=None)
parser.add_argument("--pause", type=float, default=0.8)
parser.add_argument(
"--test",
choices=("all", "flash", "first-last", "rows", "corners", "chase"),
default="all",
)
parser.add_argument("--chase-steps", type=int, default=None)
args = parser.parse_args()
width, height = panel_layout(args.panel_index)
pixels = panel_pixel_count(args.panel_index)
hint = spidev_bufsize_hint(pixels)
if hint:
print(f"Warning: {hint}\n", file=sys.stderr)
cfg = panel_strip_config(
args.panel_index,
pixels,
spi_max_speed_hz=max(1, int(args.spi_mhz * 1_000_000)),
wire_order=args.wire_order,
brightness=args.brightness,
)
chase_steps = args.chase_steps if args.chase_steps is not None else pixels
print(f"SPI panel test: panel {args.panel_index}, {width}×{height} ({pixels} LEDs)")
try:
with LedMatrix(width, height, config0=cfg) as matrix:
if args.test in ("all", "flash"):
_flash(matrix, args.pause)
if args.test in ("all", "first-last"):
_first_last(matrix, pixels, args.pause)
if args.test in ("all", "corners"):
_corners(matrix, width, height, args.pause)
if args.test in ("all", "rows"):
_rows(matrix, width, height, max(args.pause * 0.6, 0.15))
if args.test in ("all", "chase"):
_chase(matrix, pixels, max(args.pause * 0.25, 0.05), chase_steps)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"SPI error: {exc}", file=sys.stderr)
return 1
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

114
examples/spi_rgb_test.py Normal file
View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Full-panel red / green / blue over SPI (GPIO 10 on Pi 5)."""
from __future__ import annotations
import argparse
import os
import sys
import time
from leds.array_config import (
MATRIX_BRIGHTNESS,
MATRIX_SPI_BUS,
MATRIX_SPI_DEVICE,
panel_layout,
panel_pixel_count,
)
from leds.colors import BLUE, GREEN, OFF, RED, WHITE
from leds.config import panel_strip_config, spi_strip_config
from leds.matrix import LedMatrix
COLORS = (
("RED", RED),
("GREEN", GREEN),
("BLUE", BLUE),
("WHITE", WHITE),
("OFF", OFF),
)
def main() -> int:
parser = argparse.ArgumentParser(
description="R/G/B color test on a WS2812 strip via SPI"
)
parser.add_argument(
"--panel-index",
type=int,
default=2,
choices=range(5),
metavar="N",
help="Panel layout for width×height (default: 2 = top / Pi)",
)
parser.add_argument(
"--count",
type=int,
default=None,
help="LED count override (default: panel width × 9)",
)
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(
"--spi-mhz",
type=float,
default=4.2,
help="SPI clock in MHz (default: 4.2)",
)
parser.add_argument("--pause", type=float, default=1.5)
args = parser.parse_args()
width, height = panel_layout(args.panel_index)
pixels = args.count if args.count is not None else panel_pixel_count(args.panel_index)
spi_hz = max(1, int(args.spi_mhz * 1_000_000))
if args.count is not None:
if pixels % width != 0:
parser.error(f"--count {pixels} is not a multiple of width {width}")
height = pixels // width
cfg = spi_strip_config(
pixels,
spi_bus=args.spi_bus,
spi_device=args.spi_device,
spi_max_speed_hz=spi_hz,
brightness=args.brightness,
passthrough=True,
swap_rg=True,
)
else:
cfg = panel_strip_config(
args.panel_index,
pixels,
spi_max_speed_hz=spi_hz,
brightness=args.brightness,
passthrough=True,
swap_rg=True,
)
print(
f"SPI RGB test: {width}×{height} ({pixels} LEDs), "
f"bus {args.spi_bus}, {args.spi_mhz:g} MHz, passthrough RGB"
)
try:
with LedMatrix(width, height, config0=cfg) as matrix:
for label, color in COLORS:
print(f" {label}")
matrix.fill(color)
matrix.show()
time.sleep(args.pause)
except KeyboardInterrupt:
print("\nStopped.")
except OSError as exc:
print(f"SPI error: {exc}", file=sys.stderr)
print("Enable SPI and wire DIN → GPIO 10 (/dev/spidev0.0).", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())