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>
209 lines
7.0 KiB
Python
Executable File
209 lines
7.0 KiB
Python
Executable File
#!/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 (
|
|
PanelMatrixBundle,
|
|
add_panel_args,
|
|
apply_pin_arg,
|
|
format_panel_targets,
|
|
panel_targets_from_args,
|
|
push_panel_frames,
|
|
)
|
|
from leds.text import (
|
|
blit_mask,
|
|
blit_mask_gradient,
|
|
draw_font_text_centered,
|
|
draw_text,
|
|
draw_text_centered,
|
|
render_font_mask,
|
|
)
|
|
|
|
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")
|
|
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()
|
|
|
|
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)
|
|
return 1
|
|
color = (parts[0], parts[1], parts[2])
|
|
|
|
targets = panel_targets_from_args(args)
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|