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

@@ -1,31 +1,39 @@
"""UDP helpers for Pico panel firmware."""
"""UDP helpers for Pico panel firmware and Pi-direct panels."""
from __future__ import annotations
import argparse
import asyncio
import os
import socket
import subprocess
import time
from dataclasses import dataclass
from typing import Iterable, Sequence, Tuple
from typing import Any, Iterable, Sequence, Tuple
from leds.animations import ANIMATIONS, DEFAULT_FPS
from leds.array_config import (
LOCAL_PANEL_INDICES,
MATRIX_HEIGHT,
MATRIX_PIXELS,
MATRIX_WIDTH,
PANEL_COUNT,
PANEL_HOST_BY_INDEX,
PANEL_STRIP_BY_INDEX,
PANEL_WS2812_PIN_BY_INDEX,
PI_BRIDGE_PANEL_INDICES,
matrix_pixel_index,
panel_layout,
panel_pixel_count,
)
from leds.colors import dim
RGB = Tuple[int, int, int]
PANEL_IP_NET = "10.1.1"
PANEL_IP_BASE = 10
PANEL_MAX_FPS = 25.0
RGB = Tuple[int, int, int]
PANEL_MAX_FPS = 15.0
PANEL_SEND_TIMEOUT_S = 0.05
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
@@ -44,15 +52,37 @@ class PanelTarget:
host: str
width: int
height: int
local: bool = False
strip: int = 0
pin: int | None = None
@property
def pixels(self) -> int:
return self.width * self.height
def label(self) -> str:
if self.local:
return f"panel {self.index} Pi-direct ({self.width}×{self.height})"
strip = f" strip {self.strip}" if self.strip else ""
pin = f" GP{self.pin}" if self.pin is not None else ""
if self.index >= 0:
return f"{self.host} ({self.width}×{self.height}, panel {self.index})"
return f"{self.host} ({self.width}×{self.height})"
return f"{self.host} ({self.width}×{self.height}, panel {self.index}{strip}{pin})"
return f"{self.host} ({self.width}×{self.height}{strip}{pin})"
def _make_panel_target(index: int, width: int, height: int) -> PanelTarget:
local = index in LOCAL_PANEL_INDICES
if local:
host = "local"
elif index in PANEL_HOST_BY_INDEX:
host = PANEL_HOST_BY_INDEX[index]
elif index in PI_BRIDGE_PANEL_INDICES:
host = "127.0.0.1" # panel_spi_bridge on this Pi — no 10.1.1.12 on lo
else:
host = panel_ip(index)
strip = PANEL_STRIP_BY_INDEX.get(index, 0)
pin = PANEL_WS2812_PIN_BY_INDEX.get(index)
return PanelTarget(index, host, width, height, local=local, strip=strip, pin=pin)
def panel_hosts(panel_index: int | None = None, count: int = PANEL_COUNT) -> list[str]:
@@ -73,22 +103,81 @@ def resolve_panel_targets(
w = width
if height is not None:
h = height
return [PanelTarget(panel_index, panel_ip(panel_index), w, h)]
return [_make_panel_target(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))
_make_panel_target(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}"
t = targets[0]
suffix = "" if t.local else f":{port}"
return f"{t.label()}{suffix}"
layouts = ", ".join(f"{t.width}×{t.height}" for t in targets)
return f"{len(targets)} panels ({targets[0].host}{targets[-1].host}):{port} [{layouts}]"
udp = [t for t in targets if not t.local]
if udp:
return (
f"{len(targets)} panels ({udp[0].host}{udp[-1].host}):{port} [{layouts}]"
)
return f"{len(targets)} Pi-direct panels [{layouts}]"
def host_is_reachable(host: str) -> bool:
"""Return False for hosts that would block UDP sends (ARP INCOMPLETE/FAILED).
A host may have neigh entries on more than one interface (e.g. eth0 REACHABLE
and wlan0 FAILED). Treat it as reachable if *any* entry is usable.
"""
if host in ("127.0.0.1", "localhost", "::1"):
return True
try:
result = subprocess.run(
["ip", "neigh", "show", host],
capture_output=True,
text=True,
timeout=1,
check=False,
)
lines = [ln for ln in result.stdout.splitlines() if ln.strip()]
if lines:
if any(
"FAILED" not in ln and "INCOMPLETE" not in ln
for ln in lines
):
return True
except (OSError, subprocess.SubprocessError):
pass
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "1", host],
capture_output=True,
timeout=2,
check=False,
)
return result.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def filter_reachable_targets(targets: Sequence[PanelTarget]) -> list[PanelTarget]:
"""Drop offline UDP panels so sendto() does not stall the animation loop on ARP."""
reachable: list[PanelTarget] = []
for target in targets:
if target.local:
reachable.append(target)
elif host_is_reachable(target.host):
reachable.append(target)
else:
print(f"Skipping offline panel {target.index} ({target.host})")
if not reachable:
raise OSError("No reachable panels on the network")
return reachable
def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
@@ -113,6 +202,29 @@ def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
)
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")
parser.add_argument(
"--pin",
type=int,
default=None,
metavar="GP",
help="Set strip 0 WS2812 GPIO over UDP before sending frames",
)
parser.add_argument(
"--pins",
default=None,
metavar="GP[:LEDS],...",
help="Bind strips 0..n-1, e.g. 28,27 or 28:405,27:351 (per-pin length)",
)
parser.add_argument(
"--include-offline",
action="store_true",
help="Send to all configured panels even if unreachable (ARP stalls hit background thread only)",
)
parser.add_argument(
"--sync-send",
action="store_true",
help="Send UDP on the animation thread (blocks; not recommended)",
)
def add_panel_args(parser: argparse.ArgumentParser) -> None:
@@ -139,13 +251,18 @@ def add_panel_args(parser: argparse.ArgumentParser) -> None:
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
return resolve_panel_targets(
targets = resolve_panel_targets(
args.host,
args.panel_index,
args.panels,
width=getattr(args, "width", None),
height=getattr(args, "height", None),
)
if getattr(args, "include_offline", False):
return targets
if args.panel_index is not None or args.host:
return targets
return filter_reachable_targets(targets)
def panel_count_from_args(args: argparse.Namespace) -> int:
@@ -205,6 +322,79 @@ class HeadlessMatrix:
return bytes(out)
class PanelMatrixBundle:
"""Drawing surfaces for UDP (headless) and Pi-direct (LedMatrix) panels."""
def __init__(
self,
targets: Sequence[PanelTarget],
brightness: float,
) -> None:
from leds.config import panel_strip_config
from leds.matrix import LedMatrix
self.targets = list(targets)
self.matrices: list[tuple[PanelTarget, Any]] = []
self._local: list[LedMatrix] = []
for target in self.targets:
if target.local:
matrix = LedMatrix(
target.width,
target.height,
config0=panel_strip_config(
target.index, target.pixels, brightness=brightness
),
)
self._local.append(matrix)
self.matrices.append((target, matrix))
else:
self.matrices.append(
(target, HeadlessMatrix(target.width, target.height, brightness=brightness))
)
def close(self) -> None:
for matrix in self._local:
matrix.close()
def __enter__(self) -> PanelMatrixBundle:
return self
def __exit__(self, *_exc) -> None:
self.close()
def push_panel_frame(
sock: socket.socket | None,
target: PanelTarget,
matrix: Any,
port: int,
panel_id: int | None,
) -> None:
if target.local:
matrix.show()
else:
if sock is None:
raise ValueError("UDP panel requires a socket")
send_frame(
sock,
target.host,
port,
matrix.rgb_bytes(),
panel_id,
strip=target.strip,
)
def push_panel_frames(
sock: socket.socket | None,
matrices: Sequence[tuple[PanelTarget, Any]],
port: int,
panel_id: int | None,
) -> None:
for target, matrix in matrices:
push_panel_frame(sock, target, matrix, port, panel_id)
def solid_frame_bytes(
color: RGB,
count: int = MATRIX_PIXELS,
@@ -225,33 +415,264 @@ def pixels_to_frame_bytes(
return bytes(out)
def encode_frame(rgb: bytes, panel_id: int | None = None, *, strip: int = 0) -> bytes:
"""Prefix strip index (or explicit panel_id) so multi-strip Picos route correctly."""
prefix = panel_id if panel_id is not None else strip
if prefix is None:
return rgb
# Always prefix when strip!=0; strip 0 may omit for tiny payloads but
# prefixing is harmless and keeps multi-strip hosts consistent.
if panel_id is None and strip == 0:
return rgb
return bytes([prefix & 0xFF]) + rgb
def encode_pin_command(pin: int, strip: int = 0, length: int = 0) -> bytes:
"""UDP PIN command.
strip 0, no length → 5 bytes ``PIN\\0`` + gpio.
Otherwise → 8 bytes ``PIN\\0`` + strip + gpio + len_lo + len_hi (little-endian).
"""
if not 0 <= pin <= 29:
raise ValueError(f"WS2812 pin must be 029, got {pin}")
if not 0 <= strip <= 255:
raise ValueError(f"strip must be 0255, got {strip}")
if length < 0 or length > 0xFFFF:
raise ValueError(f"length must be 065535, got {length}")
if strip == 0 and length == 0:
return b"PIN\0" + bytes([pin & 0xFF])
return b"PIN\0" + bytes(
[strip & 0xFF, pin & 0xFF, length & 0xFF, (length >> 8) & 0xFF]
)
def send_ws2812_pin(
sock: socket.socket,
host: str,
port: int,
pin: int,
strip: int = 0,
length: int = 0,
) -> None:
sock.sendto(encode_pin_command(pin, strip, length), (host, port))
def send_ws2812_pin_to_targets(
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
pin: int,
strip: int = 0,
length: int = 0,
) -> None:
if sock is None:
return
for target in targets:
if target.local:
continue
send_ws2812_pin(sock, target.host, port, pin, strip, length)
extra = f" leds={length}" if length else ""
print(f"WS2812 strip {strip} GP{pin}{extra} -> {target.host}:{port}")
def configure_target_pins(
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
) -> None:
"""Send PIN for each target that has pin set (length = panel pixel count)."""
if sock is None:
return
seen: set[tuple[str, int, int]] = set()
for target in targets:
if target.local or target.pin is None:
continue
key = (target.host, target.strip, target.pin)
if key in seen:
continue
seen.add(key)
length = target.pixels
send_ws2812_pin(sock, target.host, port, target.pin, target.strip, length)
print(
f"WS2812 strip {target.strip} GP{target.pin} leds={length} "
f"-> {target.host}:{port}"
)
def setup_panel_pins(
sock: socket.socket | None,
targets: Sequence[PanelTarget],
port: int,
*,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
"""Apply array_config pins, then optional CLI overrides.
``pins`` entries are ``(gpio, length|None)`` — length None means unset.
"""
configure_target_pins(sock, targets, port)
if sock is None:
return
if pins:
hosts = sorted({t.host for t in targets if not t.local})
for host in hosts:
for strip, (gp, length) in enumerate(pins):
leds = length or 0
send_ws2812_pin(sock, host, port, gp, strip, leds)
extra = f" leds={leds}" if leds else ""
print(f"WS2812 strip {strip} GP{gp}{extra} -> {host}:{port}")
return
if pin is not None:
send_ws2812_pin_to_targets(sock, targets, port, pin, strip=0)
def parse_pins_arg(args: argparse.Namespace) -> list[tuple[int, int | None]] | None:
"""Parse ``--pins 28,27`` or ``--pins 28:405,27:351`` → [(gpio, leds|None), ...]."""
raw = getattr(args, "pins", None)
if not raw:
return None
out: list[tuple[int, int | None]] = []
for part in raw.split(","):
part = part.strip()
if not part:
continue
if ":" in part:
gp_s, led_s = part.split(":", 1)
out.append((int(gp_s.strip()), int(led_s.strip())))
else:
out.append((int(part), None))
return out or None
def expand_targets_for_pins(
targets: Sequence[PanelTarget],
pins: Sequence[tuple[int, int | None]],
) -> list[PanelTarget]:
"""One PanelTarget per strip so frames reach every GPIO from ``--pins``."""
out: list[PanelTarget] = []
for target in targets:
if target.local:
out.append(target)
continue
for strip, (gp, length) in enumerate(pins):
if length and length > 0 and target.height > 0 and length % target.height == 0:
width = length // target.height
height = target.height
elif length and length > 0:
width, height = length, 1
else:
width, height = target.width, target.height
out.append(
PanelTarget(
target.index,
target.host,
width,
height,
local=False,
strip=strip,
pin=gp,
)
)
return out
def apply_pin_arg(
args: argparse.Namespace,
sock: socket.socket | None,
targets: Sequence[PanelTarget],
) -> list[PanelTarget]:
"""Send PIN commands; return targets expanded for ``--pins`` multi-strip."""
pins = parse_pins_arg(args)
pin = getattr(args, "pin", None)
setup_panel_pins(
sock,
targets,
args.port,
pin=pin,
pins=pins,
)
if pins:
return expand_targets_for_pins(targets, pins)
if pin is not None:
return [
PanelTarget(
t.index,
t.host,
t.width,
t.height,
local=t.local,
strip=t.strip,
pin=pin if not t.local else t.pin,
)
for t in targets
]
return list(targets)
def send_frame(
sock: socket.socket,
host: str,
port: int,
rgb: bytes,
panel_id: int | None,
*,
strip: int = 0,
) -> None:
payload = (bytes([panel_id & 0xFF]) + rgb) if panel_id is not None else rgb
sock.sendto(payload, (host, port))
# Firmware expects logical RGB; converts to GRB on the wire.
sock.sendto(encode_frame(rgb, panel_id, strip=strip), (host, port))
async def send_frames_async(
sock: socket.socket | None,
matrices: Sequence[tuple[PanelTarget, Any]],
port: int,
panel_id: int | None,
*,
sync_send: bool = False,
send_timeout: float = PANEL_SEND_TIMEOUT_S,
) -> None:
"""Send one frame to every panel (UDP async, Pi-direct via show())."""
if sync_send:
push_panel_frames(sock, matrices, port, panel_id)
return
tasks: list[asyncio.Task[None]] = []
for target, matrix in matrices:
if target.local:
matrix.show()
else:
tasks.append(
asyncio.create_task(
asyncio.to_thread(
send_frame,
sock,
target.host,
port,
matrix.rgb_bytes(),
panel_id,
strip=target.strip,
)
)
)
if not tasks:
return
_, pending = await asyncio.wait(tasks, timeout=send_timeout)
for task in pending:
task.cancel()
def send_solid_to_targets(
sock: socket.socket,
sock: socket.socket | None,
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,
)
with PanelMatrixBundle(targets, brightness) as bundle:
for _, matrix in bundle.matrices:
matrix.fill(color)
push_panel_frames(sock, bundle.matrices, port, panel_id)
class PanelClient:
@@ -278,17 +699,27 @@ class PanelClient:
self.port = port
self.panel_id = panel_id
self.brightness = brightness
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._has_local = any(t.local for t in self.targets)
self._bundle: PanelMatrixBundle | None = None
if self._has_local:
self._bundle = PanelMatrixBundle(self.targets, brightness)
self._sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if any(not t.local for t in self.targets)
else None
)
@classmethod
def from_args(cls, args: argparse.Namespace) -> PanelClient:
return cls(
client = cls(
panel_targets_from_args(args),
args.port,
pixel_count=panel_count_from_args(args),
panel_id=args.panel_id,
brightness=args.brightness,
)
client.targets = apply_pin_arg(args, client._sock, client.targets)
return client
@property
def pixel_count(self) -> int:
@@ -301,7 +732,11 @@ class PanelClient:
return [t.host for t in self.targets]
def close(self) -> None:
self._sock.close()
if self._sock is not None:
self._sock.close()
if self._bundle is not None:
self._bundle.close()
self._bundle = None
def __enter__(self) -> PanelClient:
return self
@@ -314,11 +749,42 @@ class PanelClient:
if len(self.targets) != 1:
raise ValueError("send_bytes requires a single panel target")
target = self.targets[0]
if target.local:
if self._bundle is None:
raise RuntimeError("local panel bundle not initialized")
_, matrix = self._bundle.matrices[0]
if len(rgb) != target.pixels * 3:
raise ValueError(f"expected {target.pixels * 3} bytes, got {len(rgb)}")
for i in range(target.pixels):
base = i * 3
matrix.set_pixel(
i % target.width,
i // target.width,
(rgb[base], rgb[base + 1], rgb[base + 2]),
)
matrix.show()
return
if self._sock is None:
raise RuntimeError("UDP socket not initialized")
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)
send_frame(
self._sock,
target.host,
self.port,
rgb,
self.panel_id,
strip=target.strip,
)
def fill(self, color: RGB = (0, 0, 0)) -> None:
if self._bundle is not None:
for _, matrix in self._bundle.matrices:
matrix.fill(color)
push_panel_frames(self._sock, self._bundle.matrices, self.port, self.panel_id)
return
if self._sock is None:
raise RuntimeError("UDP socket not initialized")
for target in self.targets:
send_frame(
self._sock,
@@ -326,6 +792,7 @@ class PanelClient:
self.port,
solid_frame_bytes(color, target.pixels, self.brightness),
self.panel_id,
strip=target.strip,
)
def send_pixels(self, pixels: list[RGB]) -> None:
@@ -349,8 +816,59 @@ class PanelClient:
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]
) -> PanelMatrixBundle:
return PanelMatrixBundle(targets, brightness)
async def run_panel_playlist_async(
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,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
bundle = _panel_matrices(targets, brightness)
sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if any(not t.local for t in targets)
else None
)
try:
setup_panel_pins(sock, targets, port, pin=pin, pins=pins)
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 _, matrix in bundle.matrices:
draw(matrix, frame)
await send_frames_async(
sock, bundle.matrices, port, panel_id, sync_send=sync_send
)
elapsed = time.monotonic() - t0
if elapsed < delay:
await asyncio.sleep(delay - elapsed)
if not loop:
break
print(" --- loop ---")
for _, matrix in bundle.matrices:
matrix.clear()
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
finally:
if sock is not None:
sock.close()
bundle.close()
def run_panel_playlist(
@@ -363,33 +881,69 @@ def run_panel_playlist(
brightness: float = 0.25,
panel_id: int | None = None,
loop: bool = True,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
matrices = _panel_matrices(targets, brightness)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
asyncio.run(
run_panel_playlist_async(
targets,
port,
playlist,
duration=duration,
fps_override=fps_override,
brightness=brightness,
panel_id=panel_id,
loop=loop,
sync_send=sync_send,
pin=pin,
pins=pins,
)
)
async def run_panel_animation_loop_async(
targets: Sequence[PanelTarget],
port: int,
animation: str,
*,
fps: float | None = None,
brightness: float = 0.25,
panel_id: int | None = None,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | None = None,
) -> None:
draw = ANIMATIONS[animation]
rate = min(fps or DEFAULT_FPS.get(animation, 30), PANEL_MAX_FPS)
interval = 1.0 / rate
bundle = _panel_matrices(targets, brightness)
sock = (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
if any(not t.local for t in targets)
else None
)
frame = 0
try:
setup_panel_pins(
sock, targets, port, pin=pin, pins=pins
)
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)
t0 = time.monotonic()
for _, matrix in bundle.matrices:
draw(matrix, frame)
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
frame += 1
elapsed = time.monotonic() - t0
if elapsed < interval:
await asyncio.sleep(interval - elapsed)
finally:
sock.close()
for _, matrix in bundle.matrices:
matrix.clear()
await send_frames_async(sock, bundle.matrices, port, panel_id, sync_send=sync_send)
if sock is not None:
sock.close()
bundle.close()
def run_panel_animation_loop(
@@ -400,28 +954,23 @@ def run_panel_animation_loop(
fps: float | None = None,
brightness: float = 0.25,
panel_id: int | None = None,
sync_send: bool = False,
pin: int | None = None,
pins: Sequence[tuple[int, int | None]] | 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()
asyncio.run(
run_panel_animation_loop_async(
targets,
port,
animation,
fps=fps,
brightness=brightness,
panel_id=panel_id,
sync_send=sync_send,
pin=pin,
pins=pins,
)
)
def animation_playlist(animation: str) -> list[str]: