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

@@ -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