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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
firmware/panel/build/
|
||||||
|
firmware/_deps/
|
||||||
26
Makefile
Normal file
26
Makefile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Portal — convenience targets for panel firmware
|
||||||
|
#
|
||||||
|
# make deploy Build and flash panel firmware over USB
|
||||||
|
# make build Build only
|
||||||
|
|
||||||
|
.PHONY: build deploy flash upload monitor clean help
|
||||||
|
|
||||||
|
_PANEL_MAKE_FLAGS :=
|
||||||
|
ifdef PANEL_ID
|
||||||
|
_PANEL_MAKE_FLAGS += PANEL_ID=$(PANEL_ID)
|
||||||
|
endif
|
||||||
|
ifdef NO_DHCP
|
||||||
|
_PANEL_MAKE_FLAGS += NO_DHCP=$(NO_DHCP)
|
||||||
|
endif
|
||||||
|
ifdef NUM_LEDS
|
||||||
|
_PANEL_MAKE_FLAGS += NUM_LEDS=$(NUM_LEDS)
|
||||||
|
endif
|
||||||
|
ifdef WS2812_PIN
|
||||||
|
_PANEL_MAKE_FLAGS += WS2812_PIN=$(WS2812_PIN)
|
||||||
|
endif
|
||||||
|
|
||||||
|
help:
|
||||||
|
@$(MAKE) -C firmware/panel help
|
||||||
|
|
||||||
|
build deploy flash upload monitor clean:
|
||||||
|
@$(MAKE) -C firmware/panel $(MAKECMDGOALS) $(_PANEL_MAKE_FLAGS)
|
||||||
12
Pipfile
Normal file
12
Pipfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[[source]]
|
||||||
|
url = "https://pypi.org/simple"
|
||||||
|
verify_ssl = true
|
||||||
|
name = "pypi"
|
||||||
|
|
||||||
|
[packages]
|
||||||
|
portal = {editable = true, path = "."}
|
||||||
|
|
||||||
|
[dev-packages]
|
||||||
|
|
||||||
|
[requires]
|
||||||
|
python_version = "3.13"
|
||||||
39
Pipfile.lock
generated
Normal file
39
Pipfile.lock
generated
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"hash": {
|
||||||
|
"sha256": "2d6705b77e526b8f5f55e85b57b98d5e5249097e4d02415229aabbd0744e0fa1"
|
||||||
|
},
|
||||||
|
"pipfile-spec": 6,
|
||||||
|
"requires": {
|
||||||
|
"python_version": "3.13"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"name": "pypi",
|
||||||
|
"url": "https://pypi.org/simple",
|
||||||
|
"verify_ssl": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"portal": {
|
||||||
|
"editable": true,
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
"rpi-ws281x": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:00ce6db771436b778d0930245cf8ea2aae11008cc5fd67d57789c5422af3ee55"
|
||||||
|
],
|
||||||
|
"markers": "python_version >= '3.6'",
|
||||||
|
"version": "==5.0.0"
|
||||||
|
},
|
||||||
|
"spidev": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:07d0da112ca944df41f080811bb123b910df63bb38b66712289bc4fd9d682185",
|
||||||
|
"sha256:2bc02fb8c6312d519ebf1f4331067427c0921d3f77b8bcaf05189a2e8b8382c0"
|
||||||
|
],
|
||||||
|
"version": "==3.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"develop": {}
|
||||||
|
}
|
||||||
64
README.md
64
README.md
@@ -1,2 +1,66 @@
|
|||||||
# portal
|
# portal
|
||||||
|
|
||||||
|
WS2812 LED control for Raspberry Pi 5 and Pico panel adapters.
|
||||||
|
|
||||||
|
## Panel IPs
|
||||||
|
|
||||||
|
Five panels on `10.1.1.10`–`10.1.1.14` (panel 0 → `.10`, panel 4 → `.14`). Examples drive **all 5** by default.
|
||||||
|
|
||||||
|
## Panel sizes
|
||||||
|
|
||||||
|
Panels are **9 rows** tall; width varies by panel. Edit `PANEL_WIDTH_BY_INDEX` in `leds/array_config.py`:
|
||||||
|
|
||||||
|
| Panel | IP | Size | LEDs | Flash |
|
||||||
|
|-------|-----|------|------|-------|
|
||||||
|
| 0 | 10.1.1.10 | 9×39 | 351 | `make deploy PANEL_ID=0 NUM_LEDS=351` |
|
||||||
|
| 1 | 10.1.1.11 | 9×45 | 405 | `make deploy PANEL_ID=1 NUM_LEDS=405` |
|
||||||
|
| 2 | 10.1.1.12 | 9×45 | 405 | `make deploy PANEL_ID=2 NUM_LEDS=405` |
|
||||||
|
| 3 | 10.1.1.13 | 9×45 | 405 | `make deploy PANEL_ID=3 NUM_LEDS=405` |
|
||||||
|
| 4 | 10.1.1.14 | 9×39? | 351 | `make deploy PANEL_ID=4 NUM_LEDS=351` |
|
||||||
|
|
||||||
|
Panel 4 may be **38 or 39** wide — find the exact width:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy PANEL_ID=4 NUM_LEDS=387 # 43 cols max for probe
|
||||||
|
pipenv run python examples/panel_sync_test.py --test width --panel-index 4
|
||||||
|
```
|
||||||
|
|
||||||
|
Last column that lights → set `PANEL_WIDTH_BY_INDEX[4]` in `leds/array_config.py` to **column + 1**, then re-flash with `NUM_LEDS=width×9` (342 for 38, 351 for 39).
|
||||||
|
|
||||||
|
Animations render at each panel's own width automatically.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Pico panel (UDP)
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|----------------|
|
||||||
|
| `pipenv run python examples/panel_rgb_cycle.py` | Red / green / blue on all 5 panels |
|
||||||
|
| `pipenv run python examples/panel_color_test.py` | One-shot RGB + white test |
|
||||||
|
| `pipenv run python examples/panel_test.py` | Layout tests (corners, rows, chase) |
|
||||||
|
| `pipenv run python examples/panel_sync_test.py` | Positioning + sync across all panels |
|
||||||
|
| `pipenv run python examples/panel_animations.py` | All animations on all panels |
|
||||||
|
| `pipenv run python examples/panel_animations.py rolling` | One animation |
|
||||||
|
| `pipenv run python examples/panel_text.py` | Show your name |
|
||||||
|
| `pipenv run python examples/animations.py --panel` | Same as panel_animations |
|
||||||
|
|
||||||
|
Single panel only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipenv run python examples/panel_animations.py --panel-index 1 # 9×45 @ 10.1.1.11
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pi direct (SPI on GPIO 10)
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|----------------|
|
||||||
|
| `pipenv run python examples/animations.py` | All animations on matrix |
|
||||||
|
| `pipenv run python examples/matrix_demo.py` | Rainbow only |
|
||||||
|
| `pipenv run python examples/led_demo.py` | Dual-strip chase + rainbow |
|
||||||
|
|
||||||
|
### Firmware
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy PANEL_ID=0 NUM_LEDS=351
|
||||||
|
make monitor
|
||||||
|
```
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ SPI1_RX = board.GP12
|
|||||||
SPI1_CSn = board.GP9
|
SPI1_CSn = board.GP9
|
||||||
W5500_RSTn = board.GP13
|
W5500_RSTn = board.GP13
|
||||||
|
|
||||||
pixel_pin = board.GP28
|
pixel_pin = board.GP27
|
||||||
num_pixels = 2
|
num_pixels = 2
|
||||||
|
|
||||||
print("Wiznet5k SimpleServer Test (DHCP)")
|
print("Wiznet5k SimpleServer Test (DHCP)")
|
||||||
|
|||||||
170
examples/animations.py
Executable file
170
examples/animations.py
Executable 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
68
examples/fix_spi1_config.py
Executable 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
99
examples/led_demo.py
Executable 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
79
examples/matrix_demo.py
Executable 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
71
examples/panel_animations.py
Executable 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
54
examples/panel_color_test.py
Executable 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
44
examples/panel_rgb_cycle.py
Executable 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
361
examples/panel_sync_test.py
Normal 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 (0–4)")
|
||||||
|
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
145
examples/panel_test.py
Executable 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
101
examples/panel_text.py
Executable 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
48
examples/panel_udp_send.py
Executable 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
69
examples/setup_pi5_leds.py
Executable 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())
|
||||||
74
firmware/panel/CMakeLists.txt
Normal file
74
firmware/panel/CMakeLists.txt
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.13)
|
||||||
|
|
||||||
|
set(PICO_BOARD pico CACHE STRING "Board type")
|
||||||
|
|
||||||
|
include(pico_sdk_import.cmake)
|
||||||
|
|
||||||
|
project(portal_panel C CXX ASM)
|
||||||
|
set(CMAKE_C_STANDARD 11)
|
||||||
|
|
||||||
|
pico_sdk_init()
|
||||||
|
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
iolibrary
|
||||||
|
GIT_REPOSITORY https://github.com/Wiznet/ioLibrary_Driver.git
|
||||||
|
GIT_TAG master
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(iolibrary)
|
||||||
|
|
||||||
|
set(IOLIB_DIR ${iolibrary_SOURCE_DIR})
|
||||||
|
|
||||||
|
add_executable(panel
|
||||||
|
main.c
|
||||||
|
wizchip_spi.c
|
||||||
|
timer.c
|
||||||
|
ws2812_led.cpp
|
||||||
|
WS2812.cpp
|
||||||
|
${IOLIB_DIR}/Ethernet/socket.c
|
||||||
|
${IOLIB_DIR}/Ethernet/wizchip_conf.c
|
||||||
|
${IOLIB_DIR}/Ethernet/W5500/w5500.c
|
||||||
|
${IOLIB_DIR}/Internet/DHCP/dhcp.c
|
||||||
|
)
|
||||||
|
|
||||||
|
pico_generate_pio_header(panel ${CMAKE_CURRENT_LIST_DIR}/WS2812.pio)
|
||||||
|
|
||||||
|
target_include_directories(panel PRIVATE
|
||||||
|
${CMAKE_CURRENT_LIST_DIR}
|
||||||
|
${IOLIB_DIR}/Ethernet
|
||||||
|
${IOLIB_DIR}/Internet/DHCP
|
||||||
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(panel PRIVATE
|
||||||
|
_WIZCHIP_=W5500
|
||||||
|
)
|
||||||
|
|
||||||
|
if (DEFINED PANEL_ID_BUILD)
|
||||||
|
target_compile_definitions(panel PRIVATE PANEL_ID=${PANEL_ID_BUILD})
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED NUM_LEDS_BUILD)
|
||||||
|
target_compile_definitions(panel PRIVATE NUM_LEDS=${NUM_LEDS_BUILD})
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED WS2812_PIN_BUILD)
|
||||||
|
target_compile_definitions(panel PRIVATE PIN_WS2812=${WS2812_PIN_BUILD})
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
option(PORTAL_USE_DHCP "Use DHCP for W5500" OFF)
|
||||||
|
if (PORTAL_USE_DHCP)
|
||||||
|
target_compile_definitions(panel PRIVATE USE_DHCP=1)
|
||||||
|
else ()
|
||||||
|
target_compile_definitions(panel PRIVATE USE_DHCP=0)
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
target_link_libraries(panel
|
||||||
|
pico_stdlib
|
||||||
|
hardware_spi
|
||||||
|
hardware_pio
|
||||||
|
)
|
||||||
|
|
||||||
|
pico_enable_stdio_usb(panel 1)
|
||||||
|
pico_enable_stdio_uart(panel 0)
|
||||||
|
|
||||||
|
pico_add_extra_outputs(panel)
|
||||||
113
firmware/panel/Makefile
Normal file
113
firmware/panel/Makefile
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Portal panel firmware — build and USB deploy
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make # build only
|
||||||
|
# make deploy # build + flash over USB (picotool)
|
||||||
|
# make clean
|
||||||
|
#
|
||||||
|
# Options (passed to cmake on configure/reconfigure):
|
||||||
|
# make deploy PANEL_ID=0
|
||||||
|
# make deploy NO_DHCP=1
|
||||||
|
# make deploy WS2812_PIN=28 # if data wire is on GP28 not GP27
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# PICO_SDK_PATH default: ~/pico/pico-sdk
|
||||||
|
# SERIAL_PORT default: first /dev/ttyACM* or /dev/ttyACM0
|
||||||
|
|
||||||
|
PICO_SDK_PATH ?= $(HOME)/pico/pico-sdk
|
||||||
|
BUILD_DIR := build
|
||||||
|
UF2 := $(BUILD_DIR)/panel.uf2
|
||||||
|
CMAKE_STAMP := $(BUILD_DIR)/.cmake_stamp
|
||||||
|
JOBS ?= $(shell nproc 2>/dev/null || echo 4)
|
||||||
|
SERIAL_PORT ?= $(shell ls /dev/ttyACM* 2>/dev/null | head -1)
|
||||||
|
ifeq ($(SERIAL_PORT),)
|
||||||
|
SERIAL_PORT := /dev/ttyACM0
|
||||||
|
endif
|
||||||
|
SERIAL_BAUD ?= 115200
|
||||||
|
|
||||||
|
CMAKE_ARGS :=
|
||||||
|
ifdef PANEL_ID
|
||||||
|
CMAKE_ARGS += -DPANEL_ID_BUILD=$(PANEL_ID)
|
||||||
|
# Panels 0 and 4 are 9×39 (351); others are 9×45 (405). Always pass NUM_LEDS so
|
||||||
|
# cmake cache cannot keep a stale count from a previous PANEL_ID flash.
|
||||||
|
ifeq ($(PANEL_ID),0)
|
||||||
|
NUM_LEDS ?= 351
|
||||||
|
else ifeq ($(PANEL_ID),4)
|
||||||
|
NUM_LEDS ?= 351
|
||||||
|
else
|
||||||
|
NUM_LEDS ?= 405
|
||||||
|
endif
|
||||||
|
CMAKE_ARGS += -DNUM_LEDS_BUILD=$(NUM_LEDS)
|
||||||
|
else
|
||||||
|
ifdef NUM_LEDS
|
||||||
|
CMAKE_ARGS += -DNUM_LEDS_BUILD=$(NUM_LEDS)
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
ifdef WS2812_PIN
|
||||||
|
CMAKE_ARGS += -DWS2812_PIN_BUILD=$(WS2812_PIN)
|
||||||
|
endif
|
||||||
|
ifeq ($(NO_DHCP),0)
|
||||||
|
CMAKE_ARGS += -DPORTAL_USE_DHCP=ON
|
||||||
|
else
|
||||||
|
CMAKE_ARGS += -DPORTAL_USE_DHCP=OFF
|
||||||
|
endif
|
||||||
|
|
||||||
|
CMAKE_STAMP_BODY := $(strip $(CMAKE_ARGS))
|
||||||
|
|
||||||
|
SOURCES := $(wildcard *.c *.cpp *.h) WS2812.pio CMakeLists.txt pico_sdk_import.cmake
|
||||||
|
|
||||||
|
.PHONY: all build configure reconfigure deploy flash upload monitor clean help
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Targets:"
|
||||||
|
@echo " make build Build panel.uf2"
|
||||||
|
@echo " make deploy Build and flash over USB"
|
||||||
|
@echo " make flash Alias for deploy"
|
||||||
|
@echo " make monitor USB serial console (picocom, Ctrl+A Ctrl+X to quit)"
|
||||||
|
@echo " make clean Remove build directory"
|
||||||
|
@echo " make reconfigure Re-run cmake (e.g. after changing PANEL_ID)"
|
||||||
|
|
||||||
|
build: configure
|
||||||
|
@echo "==> Building panel firmware"
|
||||||
|
PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake --build $(BUILD_DIR) -j$(JOBS)
|
||||||
|
@test -f "$(UF2)" || (echo "Error: build did not produce panel.uf2" && exit 1)
|
||||||
|
@echo "==> Build OK: $(UF2)"
|
||||||
|
|
||||||
|
configure:
|
||||||
|
@test -d "$(PICO_SDK_PATH)" || (echo "Error: pico-sdk not found at $(PICO_SDK_PATH) (set PICO_SDK_PATH)" && exit 1)
|
||||||
|
@command -v cmake >/dev/null || (echo "Error: cmake is required" && exit 1)
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@STAMP='$(CMAKE_STAMP_BODY)'; \
|
||||||
|
if [ ! -f "$(BUILD_DIR)/CMakeCache.txt" ] || [ ! -f "$(CMAKE_STAMP)" ] || [ "$$(cat $(CMAKE_STAMP))" != "$$STAMP" ]; then \
|
||||||
|
echo "==> Configuring cmake in $(BUILD_DIR) $(CMAKE_ARGS)"; \
|
||||||
|
PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS); \
|
||||||
|
echo "$$STAMP" > "$(CMAKE_STAMP)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
reconfigure:
|
||||||
|
@test -d "$(PICO_SDK_PATH)" || (echo "Error: pico-sdk not found at $(PICO_SDK_PATH) (set PICO_SDK_PATH)" && exit 1)
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
@echo "==> Reconfiguring cmake in $(BUILD_DIR) $(CMAKE_ARGS)"
|
||||||
|
PICO_SDK_PATH="$(PICO_SDK_PATH)" cmake -S . -B $(BUILD_DIR) $(CMAKE_ARGS)
|
||||||
|
@echo '$(CMAKE_STAMP_BODY)' > $(CMAKE_STAMP)
|
||||||
|
|
||||||
|
deploy: build
|
||||||
|
@command -v picotool >/dev/null || (echo "Error: picotool not found (https://github.com/raspberrypi/picotool)" && exit 1)
|
||||||
|
@picotool version 2>&1 | grep -q 'without USB support' && \
|
||||||
|
(echo "Error: picotool was built without USB support" && exit 1) || true
|
||||||
|
@echo "==> Uploading $(UF2) over USB"
|
||||||
|
@picotool load -x -f "$(UF2)"
|
||||||
|
|
||||||
|
flash: deploy
|
||||||
|
upload: deploy
|
||||||
|
|
||||||
|
monitor:
|
||||||
|
@command -v picocom >/dev/null || (echo "Error: picocom not found (sudo apt install picocom)" && exit 1)
|
||||||
|
@test -e "$(SERIAL_PORT)" || (echo "Error: $(SERIAL_PORT) not found (plug in Pico USB, or set SERIAL_PORT=...)" && exit 1)
|
||||||
|
@echo "==> Serial monitor on $(SERIAL_PORT) ($(SERIAL_BAUD) baud, Ctrl+A Ctrl+X to quit)"
|
||||||
|
picocom --baud $(SERIAL_BAUD) --flow n --echo $(SERIAL_PORT)
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
125
firmware/panel/README.md
Normal file
125
firmware/panel/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Portal panel firmware (Pico SDK)
|
||||||
|
|
||||||
|
C firmware for a **Pico + W5500 + WS2812** panel adapter. Replaces the slow CircuitPython `adapter/code.py` path with native UDP receive and PIO WS2812 output.
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
Matches `adapter/code.py`:
|
||||||
|
|
||||||
|
| Signal | GPIO |
|
||||||
|
|--------|------|
|
||||||
|
| W5500 CS | GP9 |
|
||||||
|
| SPI1 SCK | GP10 |
|
||||||
|
| SPI1 MOSI | GP11 |
|
||||||
|
| SPI1 MISO | GP12 |
|
||||||
|
| W5500 RST | GP13 |
|
||||||
|
| Status LED | GP25 |
|
||||||
|
| WS2812 data | GP27 |
|
||||||
|
|
||||||
|
Default: **405 LEDs** (45×9 matrix chain), one data pin per panel.
|
||||||
|
|
||||||
|
WS2812 output uses [ForsakenNGS/Pico_WS2812](https://github.com/ForsakenNGS/Pico_WS2812) (FORMAT_GRB on GP27).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires [pico-sdk](https://github.com/raspberrypi/pico-sdk) and the ARM GCC toolchain (`arm-none-eabi-gcc`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repo root
|
||||||
|
make deploy # build + flash over USB
|
||||||
|
make build # build only
|
||||||
|
|
||||||
|
# Or from this directory
|
||||||
|
make deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual cmake (one-time pico-sdk setup):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/raspberrypi/pico-sdk.git ~/pico/pico-sdk
|
||||||
|
cd ~/pico/pico-sdk && git submodule update --init
|
||||||
|
export PICO_SDK_PATH=~/pico/pico-sdk
|
||||||
|
|
||||||
|
mkdir -p build && cd build
|
||||||
|
cmake ..
|
||||||
|
make -j$(nproc)
|
||||||
|
```
|
||||||
|
|
||||||
|
UF2 output: `build/panel.uf2` — hold BOOTSEL and copy to the Pico.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Panel 0 of 5 → 10.1.1.10, unique MAC, UDP panel_id filter
|
||||||
|
make deploy PANEL_ID=0 # 10.1.1.10
|
||||||
|
make deploy PANEL_ID=1 # 10.1.1.11
|
||||||
|
|
||||||
|
# DHCP instead of static 10.1.1.10–14
|
||||||
|
make deploy PANEL_ID=0 NO_DHCP=0
|
||||||
|
|
||||||
|
# Fewer LEDs for bench test
|
||||||
|
make deploy NUM_LEDS=2
|
||||||
|
```
|
||||||
|
|
||||||
|
Static IP and MAC are derived from `PANEL_ID`:
|
||||||
|
|
||||||
|
| Panel | IP | MAC |
|
||||||
|
|-------|-----|-----|
|
||||||
|
| 0 | 10.1.1.10 | 02:50:52:54:4C:00 |
|
||||||
|
| 1 | 10.1.1.11 | 02:50:52:54:4C:01 |
|
||||||
|
| … | … | … |
|
||||||
|
| 4 | 10.1.1.14 | 02:50:52:54:4C:04 |
|
||||||
|
| 255 (bench) | 10.1.1.19 | 02:50:52:54:4C:FF |
|
||||||
|
|
||||||
|
Edit `board_config.h` for gateway, subnet, and pin changes.
|
||||||
|
|
||||||
|
## UDP protocol (port 50007)
|
||||||
|
|
||||||
|
| Payload | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| **1215 bytes** | Raw RGB (`405 × 3`), show immediately |
|
||||||
|
| **1216 bytes** | `panel_id` (byte 0) + RGB; `255` = accept on any panel |
|
||||||
|
| **4 bytes `SHOW`** | Push buffered pixels to the strip (sync helper) |
|
||||||
|
|
||||||
|
Pi sends **RGB** order; firmware converts to WS2812 **GRB**.
|
||||||
|
|
||||||
|
Target throughput: ~30–50 fps per panel (vs ~10–20 fps on CircuitPython).
|
||||||
|
|
||||||
|
## Pi test sender
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipenv run python examples/panel_udp_send.py --host 192.168.2.111 --animation rainbow
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flashing
|
||||||
|
|
||||||
|
Requires [picotool](https://github.com/raspberrypi/picotool) with libusb support:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Or `./scripts/panel_deploy.sh` (same thing).
|
||||||
|
|
||||||
|
Or manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
picotool load -x -f build/panel.uf2
|
||||||
|
```
|
||||||
|
|
||||||
|
`-f` forces a USB reboot into BOOTSEL when the panel is already running.
|
||||||
|
|
||||||
|
Legacy UF2 drag-and-drop also works: hold BOOTSEL, plug USB, copy `panel.uf2` to `RPI-RP2`.
|
||||||
|
|
||||||
|
Serial debug:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make monitor
|
||||||
|
# or: picocom /dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
Ctrl+A then Ctrl+X to exit picocom.
|
||||||
|
|
||||||
|
## Multi-panel (5 Picos)
|
||||||
|
|
||||||
|
Flash each Pico with `PANEL_ID` 0–4 (`make deploy PANEL_ID=N`). Each gets `10.1.1.1N` (10–14), a unique MAC, and UDP filtering. Send frames from the Pi to each panel’s IP, or use a leading `panel_id` byte with `255` broadcast filtering.
|
||||||
92
firmware/panel/WS2812.cpp
Normal file
92
firmware/panel/WS2812.cpp
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/* WS2812 driver — from https://github.com/ForsakenNGS/Pico_WS2812 (BSD-style) */
|
||||||
|
|
||||||
|
#include "WS2812.hpp"
|
||||||
|
|
||||||
|
#include "WS2812.pio.h"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
WS2812::WS2812(uint pin, uint length, PIO pio, uint sm) {
|
||||||
|
initialize(pin, length, pio, sm, NONE, GREEN, RED, BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
WS2812::WS2812(uint pin, uint length, PIO pio, uint sm, DataFormat format) {
|
||||||
|
switch (format) {
|
||||||
|
case FORMAT_RGB:
|
||||||
|
initialize(pin, length, pio, sm, NONE, RED, GREEN, BLUE);
|
||||||
|
break;
|
||||||
|
case FORMAT_GRB:
|
||||||
|
initialize(pin, length, pio, sm, NONE, GREEN, RED, BLUE);
|
||||||
|
break;
|
||||||
|
case FORMAT_WRGB:
|
||||||
|
initialize(pin, length, pio, sm, WHITE, RED, GREEN, BLUE);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WS2812::~WS2812() {
|
||||||
|
delete[] data;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WS2812::initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2,
|
||||||
|
DataByte b3, DataByte b4) {
|
||||||
|
this->pin = pin;
|
||||||
|
this->length = length;
|
||||||
|
this->pio = pio;
|
||||||
|
this->sm = sm;
|
||||||
|
this->data = new uint32_t[length];
|
||||||
|
this->bytes[0] = b1;
|
||||||
|
this->bytes[1] = b2;
|
||||||
|
this->bytes[2] = b3;
|
||||||
|
this->bytes[3] = b4;
|
||||||
|
uint offset = pio_add_program(pio, &ws2812_program);
|
||||||
|
uint bits = (b1 == NONE ? 24 : 32);
|
||||||
|
ws2812_program_init(pio, sm, offset, pin, 800000, bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t WS2812::convertData(uint32_t rgbw) {
|
||||||
|
uint32_t result = 0;
|
||||||
|
for (uint b = 0; b < 4; b++) {
|
||||||
|
switch (bytes[b]) {
|
||||||
|
case RED:
|
||||||
|
result |= (rgbw & 0xFF);
|
||||||
|
break;
|
||||||
|
case GREEN:
|
||||||
|
result |= (rgbw & 0xFF00) >> 8;
|
||||||
|
break;
|
||||||
|
case BLUE:
|
||||||
|
result |= (rgbw & 0xFF0000) >> 16;
|
||||||
|
break;
|
||||||
|
case WHITE:
|
||||||
|
result |= (rgbw & 0xFF000000) >> 24;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result <<= 8;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void WS2812::setPixelColor(uint index, uint32_t color) {
|
||||||
|
if (index < length) {
|
||||||
|
data[index] = convertData(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WS2812::setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue) {
|
||||||
|
setPixelColor(index, RGB(red, green, blue));
|
||||||
|
}
|
||||||
|
|
||||||
|
void WS2812::fill(uint32_t color) {
|
||||||
|
color = convertData(color);
|
||||||
|
for (uint i = 0; i < length; i++) {
|
||||||
|
data[i] = color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WS2812::show() {
|
||||||
|
for (uint i = 0; i < length; i++) {
|
||||||
|
pio_sm_put_blocking(pio, sm, data[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
firmware/panel/WS2812.hpp
Normal file
38
firmware/panel/WS2812.hpp
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#ifndef WS2812_H
|
||||||
|
#define WS2812_H
|
||||||
|
|
||||||
|
#include "hardware/pio.h"
|
||||||
|
#include "pico/types.h"
|
||||||
|
|
||||||
|
class WS2812 {
|
||||||
|
public:
|
||||||
|
enum DataByte { NONE = 0, RED = 1, GREEN = 2, BLUE = 3, WHITE = 4 };
|
||||||
|
enum DataFormat { FORMAT_RGB = 0, FORMAT_GRB = 1, FORMAT_WRGB = 2 };
|
||||||
|
|
||||||
|
WS2812(uint pin, uint length, PIO pio, uint sm);
|
||||||
|
WS2812(uint pin, uint length, PIO pio, uint sm, DataFormat format);
|
||||||
|
~WS2812();
|
||||||
|
|
||||||
|
static uint32_t RGB(uint8_t red, uint8_t green, uint8_t blue) {
|
||||||
|
return (uint32_t)(blue) << 16 | (uint32_t)(green) << 8 | (uint32_t)(red);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPixelColor(uint index, uint32_t color);
|
||||||
|
void setPixelColor(uint index, uint8_t red, uint8_t green, uint8_t blue);
|
||||||
|
void fill(uint32_t color);
|
||||||
|
void show();
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint pin;
|
||||||
|
uint length;
|
||||||
|
PIO pio;
|
||||||
|
uint sm;
|
||||||
|
DataByte bytes[4];
|
||||||
|
uint32_t *data;
|
||||||
|
|
||||||
|
void initialize(uint pin, uint length, PIO pio, uint sm, DataByte b1, DataByte b2,
|
||||||
|
DataByte b3, DataByte b4);
|
||||||
|
uint32_t convertData(uint32_t rgbw);
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif
|
||||||
44
firmware/panel/WS2812.pio
Normal file
44
firmware/panel/WS2812.pio
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
;
|
||||||
|
; WS2812 PIO — from https://github.com/ForsakenNGS/Pico_WS2812
|
||||||
|
;
|
||||||
|
.program ws2812
|
||||||
|
.side_set 1
|
||||||
|
|
||||||
|
.define public T1 2
|
||||||
|
.define public T2 5
|
||||||
|
.define public T3 3
|
||||||
|
|
||||||
|
.lang_opt python sideset_init = pico.PIO.OUT_HIGH
|
||||||
|
.lang_opt python out_init = pico.PIO.OUT_HIGH
|
||||||
|
.lang_opt python out_shiftdir = 1
|
||||||
|
|
||||||
|
.wrap_target
|
||||||
|
bitloop:
|
||||||
|
out x, 1 side 0 [T3 - 1]
|
||||||
|
jmp !x send_zero side 1 [T1 - 1]
|
||||||
|
send_one:
|
||||||
|
jmp bitloop side 1 [T2 - 1]
|
||||||
|
send_zero:
|
||||||
|
nop side 0 [T2 - 1]
|
||||||
|
.wrap
|
||||||
|
|
||||||
|
% c-sdk {
|
||||||
|
#include "hardware/clocks.h"
|
||||||
|
|
||||||
|
static inline void ws2812_program_init(PIO pio, uint sm, uint offset, uint pin, float freq, uint bits) {
|
||||||
|
pio_gpio_init(pio, pin);
|
||||||
|
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
|
||||||
|
|
||||||
|
pio_sm_config c = ws2812_program_get_default_config(offset);
|
||||||
|
sm_config_set_sideset_pins(&c, pin);
|
||||||
|
sm_config_set_out_shift(&c, false, true, bits);
|
||||||
|
sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);
|
||||||
|
|
||||||
|
int cycles_per_bit = ws2812_T1 + ws2812_T2 + ws2812_T3;
|
||||||
|
float div = (float)clock_get_hz(clk_sys) / (freq * (float)cycles_per_bit);
|
||||||
|
sm_config_set_clkdiv(&c, div);
|
||||||
|
|
||||||
|
pio_sm_init(pio, sm, offset, &c);
|
||||||
|
pio_sm_set_enabled(pio, sm, true);
|
||||||
|
}
|
||||||
|
%}
|
||||||
53
firmware/panel/board_config.h
Normal file
53
firmware/panel/board_config.h
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#ifndef BOARD_CONFIG_H
|
||||||
|
#define BOARD_CONFIG_H
|
||||||
|
|
||||||
|
/* Matches adapter/code.py — W5500 on SPI1, WS2812 on GP27 */
|
||||||
|
|
||||||
|
#define PIN_LED_STATUS 25
|
||||||
|
|
||||||
|
#define SPI_PORT spi1
|
||||||
|
#define SPI_CLK_MHZ 20
|
||||||
|
|
||||||
|
#define PIN_CS 9
|
||||||
|
#define PIN_SCK 10
|
||||||
|
#define PIN_MOSI 11
|
||||||
|
#define PIN_MISO 12
|
||||||
|
#define PIN_RST 13
|
||||||
|
|
||||||
|
#ifndef PIN_WS2812
|
||||||
|
#define PIN_WS2812 27
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifndef NUM_LEDS
|
||||||
|
#define NUM_LEDS 405
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define LED_RGB_BYTES (NUM_LEDS * 3)
|
||||||
|
|
||||||
|
/* W5500 socket assignments */
|
||||||
|
#define SOCKET_DHCP 0
|
||||||
|
#define SOCKET_UDP 1
|
||||||
|
|
||||||
|
/* UDP port (same as CircuitPython adapter) */
|
||||||
|
#define UDP_PORT 50007
|
||||||
|
|
||||||
|
/* Compile-time panel index for filtered frames (0–4). 255 = accept all. */
|
||||||
|
#ifndef PANEL_ID
|
||||||
|
#define PANEL_ID 255
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* 1 = DHCP, 0 = static IP below */
|
||||||
|
#ifndef USE_DHCP
|
||||||
|
#define USE_DHCP 0
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#define STATIC_IP_OCT4 ((PANEL_ID) == 255 ? 19u : (10u + (unsigned)(PANEL_ID)))
|
||||||
|
#define STATIC_IP {10, 1, 1, (uint8_t)STATIC_IP_OCT4}
|
||||||
|
#define STATIC_SN {255, 255, 255, 0}
|
||||||
|
#define STATIC_GW {10, 1, 1, 1}
|
||||||
|
#define STATIC_DNS {10, 1, 1, 1}
|
||||||
|
|
||||||
|
/* Locally administered; last byte = PANEL_ID (unique per panel 0–4). */
|
||||||
|
#define MAC_ADDR {0x02, 0x50, 0x52, 0x54, 0x4C, (uint8_t)PANEL_ID}
|
||||||
|
|
||||||
|
#endif
|
||||||
219
firmware/panel/main.c
Normal file
219
firmware/panel/main.c
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Portal panel firmware — W5500 UDP + WS2812 (Pico SDK).
|
||||||
|
*
|
||||||
|
* UDP protocol (port 50007):
|
||||||
|
* - N×3 bytes: raw RGB (N = NUM_LEDS), show immediately
|
||||||
|
* - N×3+1 bytes: panel_id (byte 0) + RGB; panel_id 255 = any panel
|
||||||
|
* - 4 bytes "SHOW": refresh buffered pixels (for split frame/show sync)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "board_config.h"
|
||||||
|
#include "wizchip_conf.h"
|
||||||
|
#include "dhcp.h"
|
||||||
|
#include "socket.h"
|
||||||
|
#include "timer.h"
|
||||||
|
#include "wizchip_spi.h"
|
||||||
|
#include "ws2812_led.h"
|
||||||
|
|
||||||
|
#include "hardware/gpio.h"
|
||||||
|
#include "pico/stdlib.h"
|
||||||
|
|
||||||
|
#define SOCKET_UDP 1
|
||||||
|
#define SOCKET_DHCP 0
|
||||||
|
#define ETH_BUF_SIZE 2048
|
||||||
|
#define DHCP_RETRY_MAX 10
|
||||||
|
|
||||||
|
static wiz_NetInfo g_net_info = {
|
||||||
|
.mac = MAC_ADDR,
|
||||||
|
.ip = STATIC_IP,
|
||||||
|
.sn = STATIC_SN,
|
||||||
|
.gw = STATIC_GW,
|
||||||
|
.dns = STATIC_DNS,
|
||||||
|
#if USE_DHCP
|
||||||
|
.dhcp = NETINFO_DHCP,
|
||||||
|
#else
|
||||||
|
.dhcp = NETINFO_STATIC,
|
||||||
|
#endif
|
||||||
|
};
|
||||||
|
|
||||||
|
static uint8_t g_eth_buf[ETH_BUF_SIZE];
|
||||||
|
static uint8_t g_pixel_buf[LED_RGB_BYTES];
|
||||||
|
static volatile uint16_t g_ms_tick;
|
||||||
|
static uint8_t g_dhcp_ready;
|
||||||
|
|
||||||
|
static void dhcp_timer_cb(void) {
|
||||||
|
g_ms_tick++;
|
||||||
|
if (g_ms_tick >= 999) {
|
||||||
|
g_ms_tick = 0;
|
||||||
|
DHCP_time_handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void dhcp_assign(void) {
|
||||||
|
getIPfromDHCP(g_net_info.ip);
|
||||||
|
getGWfromDHCP(g_net_info.gw);
|
||||||
|
getSNfromDHCP(g_net_info.sn);
|
||||||
|
getDNSfromDHCP(g_net_info.dns);
|
||||||
|
g_net_info.dhcp = NETINFO_DHCP;
|
||||||
|
network_initialize(g_net_info);
|
||||||
|
print_network_information(g_net_info);
|
||||||
|
g_dhcp_ready = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void dhcp_conflict(void) {
|
||||||
|
printf("DHCP conflict\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
static int network_bring_up(void) {
|
||||||
|
#if USE_DHCP
|
||||||
|
uint8_t retries = 0;
|
||||||
|
DHCP_init(SOCKET_DHCP, g_eth_buf);
|
||||||
|
reg_dhcp_cbfunc(dhcp_assign, dhcp_assign, dhcp_conflict);
|
||||||
|
wizchip_1ms_timer_initialize(dhcp_timer_cb);
|
||||||
|
|
||||||
|
while (!g_dhcp_ready && retries < DHCP_RETRY_MAX) {
|
||||||
|
int8_t rv = DHCP_run();
|
||||||
|
if (rv == DHCP_IP_LEASED) {
|
||||||
|
g_dhcp_ready = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (rv == DHCP_FAILED) {
|
||||||
|
retries++;
|
||||||
|
}
|
||||||
|
wizchip_delay_ms(250);
|
||||||
|
}
|
||||||
|
if (!g_dhcp_ready) {
|
||||||
|
printf("DHCP failed, using static IP\n");
|
||||||
|
g_net_info.dhcp = NETINFO_STATIC;
|
||||||
|
network_initialize(g_net_info);
|
||||||
|
print_network_information(g_net_info);
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
network_initialize(g_net_info);
|
||||||
|
print_network_information(g_net_info);
|
||||||
|
#endif
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int udp_socket_open(void) {
|
||||||
|
int8_t sn = socket(SOCKET_UDP, Sn_MR_UDP, UDP_PORT, 0);
|
||||||
|
if (sn != SOCKET_UDP) {
|
||||||
|
printf("UDP socket open failed: %d\n", sn);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
printf("UDP listening on port %d\n", UDP_PORT);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void led_boot_test(void) {
|
||||||
|
printf("LED boot test on GP%d (%d pixels)\n", PIN_WS2812, NUM_LEDS);
|
||||||
|
ws2812_fill(255, 0, 0);
|
||||||
|
ws2812_show();
|
||||||
|
sleep_ms(500);
|
||||||
|
ws2812_fill(0, 255, 0);
|
||||||
|
ws2812_show();
|
||||||
|
sleep_ms(500);
|
||||||
|
ws2812_fill(0, 0, 0);
|
||||||
|
ws2812_show();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int handle_frame(const uint8_t *rgb, uint16_t len) {
|
||||||
|
if (len != LED_RGB_BYTES) {
|
||||||
|
printf("frame reject len=%u want=%u\n", (unsigned)len, (unsigned)LED_RGB_BYTES);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
ws2812_set_rgb(rgb, NUM_LEDS);
|
||||||
|
ws2812_show();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handle_packet(const uint8_t *data, int16_t len) {
|
||||||
|
if (len == 4 && memcmp(data, "SHOW", 4) == 0) {
|
||||||
|
ws2812_set_rgb(g_pixel_buf, NUM_LEDS);
|
||||||
|
ws2812_show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len == LED_RGB_BYTES) {
|
||||||
|
memcpy(g_pixel_buf, data, LED_RGB_BYTES);
|
||||||
|
handle_frame(data, (uint16_t)len);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (len == LED_RGB_BYTES + 1) {
|
||||||
|
uint8_t panel = data[0];
|
||||||
|
if (panel != 255 && panel != (uint8_t)PANEL_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
memcpy(g_pixel_buf, data + 1, LED_RGB_BYTES);
|
||||||
|
handle_frame(data + 1, LED_RGB_BYTES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void poll_udp(void) {
|
||||||
|
while (1) {
|
||||||
|
uint16_t rx = getSn_RX_RSR(SOCKET_UDP);
|
||||||
|
if (rx == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rx > ETH_BUF_SIZE) {
|
||||||
|
rx = ETH_BUF_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t src_ip[4];
|
||||||
|
uint16_t src_port;
|
||||||
|
int16_t n = recvfrom(SOCKET_UDP, g_eth_buf, rx, src_ip, &src_port);
|
||||||
|
if (n <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handle_packet(g_eth_buf, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
stdio_init_all();
|
||||||
|
sleep_ms(2000);
|
||||||
|
|
||||||
|
gpio_init(PIN_LED_STATUS);
|
||||||
|
gpio_set_dir(PIN_LED_STATUS, GPIO_OUT);
|
||||||
|
|
||||||
|
printf("portal panel firmware (Pico SDK)\n");
|
||||||
|
printf("LEDs=%d panel_id=%d mac=02:50:52:54:4C:%02X ip=10.1.1.%u\n",
|
||||||
|
NUM_LEDS, PANEL_ID, (unsigned)PANEL_ID, (unsigned)STATIC_IP_OCT4);
|
||||||
|
|
||||||
|
wizchip_spi_initialize();
|
||||||
|
wizchip_cris_initialize();
|
||||||
|
wizchip_reset();
|
||||||
|
wizchip_initialize();
|
||||||
|
wizchip_check();
|
||||||
|
|
||||||
|
ws2812_init(PIN_WS2812, NUM_LEDS);
|
||||||
|
memset(g_pixel_buf, 0, sizeof(g_pixel_buf));
|
||||||
|
led_boot_test();
|
||||||
|
|
||||||
|
network_bring_up();
|
||||||
|
if (udp_socket_open() != 0) {
|
||||||
|
while (1) {
|
||||||
|
gpio_xor_mask(1u << PIN_LED_STATUS);
|
||||||
|
sleep_ms(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t heartbeat = 0;
|
||||||
|
while (1) {
|
||||||
|
#if USE_DHCP
|
||||||
|
DHCP_run();
|
||||||
|
#endif
|
||||||
|
poll_udp();
|
||||||
|
if (++heartbeat >= 500) {
|
||||||
|
gpio_xor_mask(1u << PIN_LED_STATUS);
|
||||||
|
heartbeat = 0;
|
||||||
|
}
|
||||||
|
sleep_ms(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
firmware/panel/pico_sdk_import.cmake
Normal file
54
firmware/panel/pico_sdk_import.cmake
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# This can be dropped into any project as a standalone CMake file
|
||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_PATH} AND (NOT PICO_SDK_PATH))
|
||||||
|
set(PICO_SDK_PATH $ENV{PICO_SDK_PATH})
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT} AND (NOT PICO_SDK_FETCH_FROM_GIT))
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT $ENV{PICO_SDK_FETCH_FROM_GIT})
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
if (DEFINED ENV{PICO_SDK_FETCH_FROM_GIT_PATH} AND (NOT PICO_SDK_FETCH_FROM_GIT_PATH))
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_PATH $ENV{PICO_SDK_FETCH_FROM_GIT_PATH})
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(PICO_SDK_PATH "${PICO_SDK_PATH}" CACHE PATH "Path to the Raspberry Pi Pico SDK")
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT "${PICO_SDK_FETCH_FROM_GIT}" CACHE BOOL "Set to ON to fetch Pico SDK from git")
|
||||||
|
set(PICO_SDK_FETCH_FROM_GIT_PATH "${PICO_SDK_FETCH_FROM_GIT_PATH}" CACHE FILEPATH "Location to download Pico SDK")
|
||||||
|
|
||||||
|
if (NOT PICO_SDK_PATH)
|
||||||
|
if (PICO_SDK_FETCH_FROM_GIT)
|
||||||
|
include(FetchContent)
|
||||||
|
set(FETCHCONTENT_BASE_DIR_SAVE ${FETCHCONTENT_BASE_DIR})
|
||||||
|
if (PICO_SDK_FETCH_FROM_GIT_PATH)
|
||||||
|
get_filename_component(FETCHCONTENT_BASE_DIR "${PICO_SDK_FETCH_FROM_GIT_PATH}" REALPATH)
|
||||||
|
endif ()
|
||||||
|
FetchContent_Declare(
|
||||||
|
pico_sdk
|
||||||
|
GIT_REPOSITORY https://github.com/raspberrypi/pico-sdk
|
||||||
|
GIT_TAG master
|
||||||
|
)
|
||||||
|
if (NOT pico_sdk)
|
||||||
|
FetchContent_Populate(pico_sdk)
|
||||||
|
set(PICO_SDK_PATH ${pico_sdk_SOURCE_DIR})
|
||||||
|
endif ()
|
||||||
|
set(FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR_SAVE})
|
||||||
|
else ()
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"PICO_SDK_PATH is not set. Clone pico-sdk and export PICO_SDK_PATH, "
|
||||||
|
"or set PICO_SDK_FETCH_FROM_GIT=ON.")
|
||||||
|
endif ()
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
get_filename_component(PICO_SDK_PATH "${PICO_SDK_PATH}" REALPATH BASE_DIR "${CMAKE_BINARY_DIR}")
|
||||||
|
if (NOT EXISTS ${PICO_SDK_PATH})
|
||||||
|
message(FATAL_ERROR "Directory '${PICO_SDK_PATH}' not found")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
set(PICO_SDK_INIT_CMAKE_FILE ${PICO_SDK_PATH}/pico_sdk_init.cmake)
|
||||||
|
if (NOT EXISTS ${PICO_SDK_INIT_CMAKE_FILE})
|
||||||
|
message(FATAL_ERROR "pico_sdk_init.cmake not found in ${PICO_SDK_PATH}")
|
||||||
|
endif ()
|
||||||
|
|
||||||
|
include(${PICO_SDK_INIT_CMAKE_FILE})
|
||||||
21
firmware/panel/timer.c
Normal file
21
firmware/panel/timer.c
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#include "timer.h"
|
||||||
|
|
||||||
|
static struct repeating_timer g_timer;
|
||||||
|
static void (*g_callback)(void);
|
||||||
|
|
||||||
|
void wizchip_1ms_timer_initialize(void (*callback)(void)) {
|
||||||
|
g_callback = callback;
|
||||||
|
add_repeating_timer_us(-1000, wizchip_1ms_timer_callback, NULL, &g_timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool wizchip_1ms_timer_callback(struct repeating_timer *t) {
|
||||||
|
(void)t;
|
||||||
|
if (g_callback != NULL) {
|
||||||
|
g_callback();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void wizchip_delay_ms(uint32_t ms) {
|
||||||
|
sleep_ms(ms);
|
||||||
|
}
|
||||||
10
firmware/panel/timer.h
Normal file
10
firmware/panel/timer.h
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#ifndef PANEL_TIMER_H
|
||||||
|
#define PANEL_TIMER_H
|
||||||
|
|
||||||
|
#include "pico/stdlib.h"
|
||||||
|
|
||||||
|
void wizchip_1ms_timer_initialize(void (*callback)(void));
|
||||||
|
bool wizchip_1ms_timer_callback(struct repeating_timer *t);
|
||||||
|
void wizchip_delay_ms(uint32_t ms);
|
||||||
|
|
||||||
|
#endif
|
||||||
121
firmware/panel/wizchip_spi.c
Normal file
121
firmware/panel/wizchip_spi.c
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* W5500 SPI port for portal panel adapter (SPI1, GP9–13).
|
||||||
|
* Derived from WIZnet-PICO-C port/ioLibrary_Driver (BSD-3-Clause).
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
#include "board_config.h"
|
||||||
|
#include "wizchip_conf.h"
|
||||||
|
#include "wizchip_spi.h"
|
||||||
|
|
||||||
|
#include "hardware/gpio.h"
|
||||||
|
#include "hardware/spi.h"
|
||||||
|
#include "pico/binary_info.h"
|
||||||
|
#include "pico/critical_section.h"
|
||||||
|
#include "pico/stdlib.h"
|
||||||
|
|
||||||
|
static critical_section_t g_wizchip_cri_sec;
|
||||||
|
|
||||||
|
static inline void wizchip_select(void) {
|
||||||
|
gpio_put(PIN_CS, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static inline void wizchip_deselect(void) {
|
||||||
|
gpio_put(PIN_CS, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t wizchip_read(void) {
|
||||||
|
uint8_t rx = 0;
|
||||||
|
uint8_t tx = 0xff;
|
||||||
|
spi_read_blocking(SPI_PORT, tx, &rx, 1);
|
||||||
|
return rx;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wizchip_write(uint8_t tx) {
|
||||||
|
spi_write_blocking(SPI_PORT, &tx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wizchip_critical_section_lock(void) {
|
||||||
|
critical_section_enter_blocking(&g_wizchip_cri_sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void wizchip_critical_section_unlock(void) {
|
||||||
|
critical_section_exit(&g_wizchip_cri_sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void wizchip_reset(void) {
|
||||||
|
gpio_init(PIN_RST);
|
||||||
|
gpio_set_dir(PIN_RST, GPIO_OUT);
|
||||||
|
gpio_put(PIN_RST, 0);
|
||||||
|
sleep_ms(100);
|
||||||
|
gpio_put(PIN_RST, 1);
|
||||||
|
sleep_ms(100);
|
||||||
|
bi_decl(bi_1pin_with_name(PIN_RST, "W5500 RESET"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void wizchip_spi_initialize(void) {
|
||||||
|
spi_init(SPI_PORT, SPI_CLK_MHZ * 1000 * 1000);
|
||||||
|
|
||||||
|
gpio_set_function(PIN_SCK, GPIO_FUNC_SPI);
|
||||||
|
gpio_set_function(PIN_MOSI, GPIO_FUNC_SPI);
|
||||||
|
gpio_set_function(PIN_MISO, GPIO_FUNC_SPI);
|
||||||
|
|
||||||
|
bi_decl(bi_3pins_with_func(PIN_MISO, PIN_MOSI, PIN_SCK, GPIO_FUNC_SPI));
|
||||||
|
|
||||||
|
gpio_init(PIN_CS);
|
||||||
|
gpio_set_dir(PIN_CS, GPIO_OUT);
|
||||||
|
gpio_put(PIN_CS, 1);
|
||||||
|
bi_decl(bi_1pin_with_name(PIN_CS, "W5500 CS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void wizchip_cris_initialize(void) {
|
||||||
|
critical_section_init(&g_wizchip_cri_sec);
|
||||||
|
reg_wizchip_cris_cbfunc(wizchip_critical_section_lock, wizchip_critical_section_unlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
void wizchip_initialize(void) {
|
||||||
|
wizchip_deselect();
|
||||||
|
reg_wizchip_cs_cbfunc(wizchip_select, wizchip_deselect);
|
||||||
|
reg_wizchip_spi_cbfunc(wizchip_read, wizchip_write);
|
||||||
|
|
||||||
|
uint8_t memsize[2][8] = {
|
||||||
|
{2, 2, 2, 2, 2, 2, 2, 2},
|
||||||
|
{2, 2, 2, 2, 2, 2, 2, 2},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ctlwizchip(CW_INIT_WIZCHIP, (void *)memsize) == -1) {
|
||||||
|
printf("W5500 init failed\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t link = PHY_LINK_OFF;
|
||||||
|
do {
|
||||||
|
if (ctlwizchip(CW_GET_PHYLINK, (void *)&link) == -1) {
|
||||||
|
printf("PHY link unknown\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} while (link == PHY_LINK_OFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
void wizchip_check(void) {
|
||||||
|
if (getVERSIONR() != 0x04) {
|
||||||
|
printf("W5500 version mismatch: 0x%02x\n", getVERSIONR());
|
||||||
|
while (1) {
|
||||||
|
tight_loop_contents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void network_initialize(wiz_NetInfo net_info) {
|
||||||
|
ctlnetwork(CN_SET_NETINFO, (void *)&net_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
void print_network_information(wiz_NetInfo net_info) {
|
||||||
|
ctlnetwork(CN_GET_NETINFO, (void *)&net_info);
|
||||||
|
printf("MAC %02X:%02X:%02X:%02X:%02X:%02X\n",
|
||||||
|
net_info.mac[0], net_info.mac[1], net_info.mac[2],
|
||||||
|
net_info.mac[3], net_info.mac[4], net_info.mac[5]);
|
||||||
|
printf("IP %d.%d.%d.%d\n",
|
||||||
|
net_info.ip[0], net_info.ip[1], net_info.ip[2], net_info.ip[3]);
|
||||||
|
}
|
||||||
14
firmware/panel/wizchip_spi.h
Normal file
14
firmware/panel/wizchip_spi.h
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#ifndef WIZCHIP_SPI_H
|
||||||
|
#define WIZCHIP_SPI_H
|
||||||
|
|
||||||
|
#include "wizchip_conf.h"
|
||||||
|
|
||||||
|
void wizchip_spi_initialize(void);
|
||||||
|
void wizchip_cris_initialize(void);
|
||||||
|
void wizchip_reset(void);
|
||||||
|
void wizchip_initialize(void);
|
||||||
|
void wizchip_check(void);
|
||||||
|
void network_initialize(wiz_NetInfo net_info);
|
||||||
|
void print_network_information(wiz_NetInfo net_info);
|
||||||
|
|
||||||
|
#endif
|
||||||
39
firmware/panel/ws2812_led.cpp
Normal file
39
firmware/panel/ws2812_led.cpp
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* C API wrapper around ForsakenNGS/Pico_WS2812 */
|
||||||
|
|
||||||
|
#include "ws2812_led.h"
|
||||||
|
|
||||||
|
#include "WS2812.hpp"
|
||||||
|
|
||||||
|
#include "hardware/pio.h"
|
||||||
|
#include "pico/stdlib.h"
|
||||||
|
|
||||||
|
static WS2812 *s_strip;
|
||||||
|
|
||||||
|
extern "C" void ws2812_init(unsigned int pin, unsigned int num_leds) {
|
||||||
|
uint sm = pio_claim_unused_sm(pio0, true);
|
||||||
|
s_strip = new WS2812(pin, num_leds, pio0, sm, WS2812::FORMAT_GRB);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void ws2812_set_rgb(const uint8_t *rgb, unsigned int count) {
|
||||||
|
if (s_strip == nullptr || rgb == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (unsigned int i = 0; i < count; i++) {
|
||||||
|
s_strip->setPixelColor(i, rgb[i * 3 + 0], rgb[i * 3 + 1], rgb[i * 3 + 2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void ws2812_fill(uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
if (s_strip == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_strip->fill(WS2812::RGB(r, g, b));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" void ws2812_show(void) {
|
||||||
|
if (s_strip == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_strip->show();
|
||||||
|
sleep_us(300);
|
||||||
|
}
|
||||||
19
firmware/panel/ws2812_led.h
Normal file
19
firmware/panel/ws2812_led.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#ifndef WS2812_LED_H
|
||||||
|
#define WS2812_LED_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
void ws2812_init(unsigned int pin, unsigned int num_leds);
|
||||||
|
void ws2812_set_rgb(const uint8_t *rgb, unsigned int count);
|
||||||
|
void ws2812_fill(uint8_t r, uint8_t g, uint8_t b);
|
||||||
|
void ws2812_show(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif
|
||||||
33
leds/__init__.py
Normal file
33
leds/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from leds.animations import ANIMATIONS, DEFAULT_FPS
|
||||||
|
from leds.array_config import (
|
||||||
|
MATRIX_HEIGHT,
|
||||||
|
MATRIX_PIXELS,
|
||||||
|
MATRIX_ROWS_PER_STRIP,
|
||||||
|
MATRIX_WIDTH,
|
||||||
|
)
|
||||||
|
from leds.config import StripConfig, default_dual_strip_configs, default_strip_config, pi5_setup_hint
|
||||||
|
from leds.detect import board_family, board_model, pio_available, supports_ws281x
|
||||||
|
from leds.colors import wheel
|
||||||
|
from leds.matrix import LedMatrix
|
||||||
|
from leds.strip import LedStrip
|
||||||
|
from leds.strips import LedStrips
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ANIMATIONS",
|
||||||
|
"DEFAULT_FPS",
|
||||||
|
"LedMatrix",
|
||||||
|
"LedStrip",
|
||||||
|
"LedStrips",
|
||||||
|
"MATRIX_HEIGHT",
|
||||||
|
"MATRIX_PIXELS",
|
||||||
|
"MATRIX_ROWS_PER_STRIP",
|
||||||
|
"MATRIX_WIDTH",
|
||||||
|
"StripConfig",
|
||||||
|
"board_family",
|
||||||
|
"board_model",
|
||||||
|
"default_dual_strip_configs",
|
||||||
|
"default_strip_config",
|
||||||
|
"pio_available",
|
||||||
|
"pi5_setup_hint",
|
||||||
|
"supports_ws281x",
|
||||||
|
]
|
||||||
330
leds/animations.py
Normal file
330
leds/animations.py
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
"""Animations for LedMatrix and LedStrip."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from typing import Callable, Union
|
||||||
|
|
||||||
|
from leds.colors import dim, heat_color, hsv_to_rgb, wheel
|
||||||
|
from leds.matrix import LedMatrix
|
||||||
|
from leds.strip import LedStrip
|
||||||
|
|
||||||
|
Surface = Union[LedMatrix, LedStrip]
|
||||||
|
DrawFn = Callable[[Surface, int], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_matrix(surface: Surface) -> bool:
|
||||||
|
return hasattr(surface, "width") and hasattr(surface, "height")
|
||||||
|
|
||||||
|
|
||||||
|
def _size(surface: Surface) -> tuple[int, int]:
|
||||||
|
if _is_matrix(surface):
|
||||||
|
return surface.width, surface.height
|
||||||
|
n = len(surface)
|
||||||
|
return n, 1
|
||||||
|
|
||||||
|
|
||||||
|
def _set_index(surface: Surface, x: int, y: int, color: tuple[int, int, int]) -> None:
|
||||||
|
if isinstance(surface, LedStrip):
|
||||||
|
w, _ = _size(surface)
|
||||||
|
surface[y * w + x] = color
|
||||||
|
else:
|
||||||
|
surface[x, y] = color
|
||||||
|
|
||||||
|
|
||||||
|
def _each_pixel(surface: Surface):
|
||||||
|
if _is_matrix(surface):
|
||||||
|
w, h = surface.width, surface.height
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
yield x, y
|
||||||
|
else:
|
||||||
|
for i in range(len(surface)):
|
||||||
|
yield i, 0
|
||||||
|
|
||||||
|
|
||||||
|
def rainbow_scroll(surface: Surface, frame: int) -> None:
|
||||||
|
if isinstance(surface, LedStrip):
|
||||||
|
n = len(surface)
|
||||||
|
for i in range(n):
|
||||||
|
surface[i] = wheel(frame * 2 + i * 256 // max(n, 1))
|
||||||
|
return
|
||||||
|
|
||||||
|
w, h = surface.width, surface.height
|
||||||
|
strip = getattr(surface, "strip", None)
|
||||||
|
if strip is not None and hasattr(surface, "index_at"):
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
strip[surface.index_at(x, y)] = wheel(x * 3 + y * 8 + frame * 2)
|
||||||
|
else:
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
surface[x, y] = wheel(x * 3 + y * 8 + frame * 2)
|
||||||
|
|
||||||
|
|
||||||
|
def plasma(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
t = frame * 0.08
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
v = (
|
||||||
|
math.sin(x * 0.15 + t)
|
||||||
|
+ math.sin(y * 0.2 - t * 1.3)
|
||||||
|
+ math.sin((x + y) * 0.1 + t * 0.7)
|
||||||
|
) / 3.0
|
||||||
|
_set_index(surface, x, y, hsv_to_rgb((v + 1) / 2, 1.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def sine_wave(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
wave = math.sin(x * 0.25 + frame * 0.15) * math.cos(y * 0.35 - frame * 0.1)
|
||||||
|
_set_index(surface, x, y, dim((0, 80, 200), (wave + 1) / 2))
|
||||||
|
|
||||||
|
|
||||||
|
def scanner(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
pos = frame % max(w * 2 - 2, 1)
|
||||||
|
if pos >= w:
|
||||||
|
pos = w * 2 - 2 - pos
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
for y in range(h):
|
||||||
|
for dx in range(-3, 4):
|
||||||
|
x = pos + dx
|
||||||
|
if 0 <= x < w:
|
||||||
|
_set_index(surface, x, y, dim((255, 0, 0), 1.0 - abs(dx) / 4.0))
|
||||||
|
|
||||||
|
|
||||||
|
def comet(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
head = frame % (w + 12)
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
for y in range(h):
|
||||||
|
for i in range(12):
|
||||||
|
x = head - i
|
||||||
|
if 0 <= x < w:
|
||||||
|
_set_index(surface, x, y, dim(wheel(frame * 3 + i * 10), (12 - i) / 12))
|
||||||
|
|
||||||
|
|
||||||
|
def pulse(surface: Surface, frame: int) -> None:
|
||||||
|
level = (math.sin(frame * 0.12) + 1) / 2
|
||||||
|
color = dim((120, 0, 180), 0.15 + level * 0.85)
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill(color)
|
||||||
|
else:
|
||||||
|
surface.fill(color)
|
||||||
|
|
||||||
|
|
||||||
|
def sparkle(surface: Surface, frame: int) -> None:
|
||||||
|
for x, y in _each_pixel(surface):
|
||||||
|
if _is_matrix(surface):
|
||||||
|
r, g, b = surface[x, y]
|
||||||
|
else:
|
||||||
|
r, g, b = surface[x]
|
||||||
|
c = (r >> 2, g >> 2, b >> 2)
|
||||||
|
_set_index(surface, x, y, c)
|
||||||
|
w, h = _size(surface)
|
||||||
|
for _ in range(6):
|
||||||
|
_set_index(surface, random.randint(0, w - 1), random.randint(0, h - 1), (220, 220, 255))
|
||||||
|
|
||||||
|
|
||||||
|
def fire(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
size = w * h
|
||||||
|
if not hasattr(fire, "_heat") or len(fire._heat) != size:
|
||||||
|
fire._heat = [0.0] * size
|
||||||
|
|
||||||
|
heat: list[float] = fire._heat
|
||||||
|
for x in range(w):
|
||||||
|
heat[(h - 1) * w + x] = random.random() * 0.5 + 0.5
|
||||||
|
|
||||||
|
for y in range(h - 2, -1, -1):
|
||||||
|
for x in range(w):
|
||||||
|
below = (y + 1) * w + x
|
||||||
|
left = below - 1 if x > 0 else below
|
||||||
|
right = below + 1 if x < w - 1 else below
|
||||||
|
decay = random.uniform(0.0, 0.18)
|
||||||
|
heat[y * w + x] = max(0.0, (heat[left] + heat[below] + heat[right]) / 3 - decay)
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
_set_index(surface, x, y, heat_color(heat[y * w + x]))
|
||||||
|
|
||||||
|
|
||||||
|
def rain(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
if not hasattr(rain, "_drops"):
|
||||||
|
rain._drops = [
|
||||||
|
{"x": random.randint(0, w - 1), "y": random.randint(-h, 0), "speed": random.randint(1, 3)}
|
||||||
|
for _ in range(max(w // 2, 1))
|
||||||
|
]
|
||||||
|
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
|
||||||
|
for drop in rain._drops:
|
||||||
|
drop["y"] += drop["speed"]
|
||||||
|
if drop["y"] >= h:
|
||||||
|
drop["y"] = random.randint(-3, -1)
|
||||||
|
drop["x"] = random.randint(0, w - 1)
|
||||||
|
drop["speed"] = random.randint(1, 3)
|
||||||
|
x, y = drop["x"], int(drop["y"])
|
||||||
|
if 0 <= y < h:
|
||||||
|
_set_index(surface, x, y, (0, 220, 80))
|
||||||
|
if y > 0:
|
||||||
|
_set_index(surface, x, y - 1, dim((0, 220, 80), 0.4))
|
||||||
|
|
||||||
|
|
||||||
|
def stripes(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
offset = frame % max(w, 1)
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
band = (x + offset) // 3 % 3
|
||||||
|
if band == 0:
|
||||||
|
_set_index(surface, x, y, (200, 0, 0))
|
||||||
|
elif band == 1:
|
||||||
|
_set_index(surface, x, y, (0, 200, 0))
|
||||||
|
else:
|
||||||
|
_set_index(surface, x, y, (0, 0, 200))
|
||||||
|
|
||||||
|
|
||||||
|
def bounce(surface: Surface, frame: int) -> None:
|
||||||
|
w, h = _size(surface)
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
t = frame * 0.2
|
||||||
|
x = int((math.sin(t) + 1) / 2 * (w - 1))
|
||||||
|
y = int((math.cos(t * 1.3) + 1) / 2 * (h - 1))
|
||||||
|
for dy in range(-1, 2):
|
||||||
|
for dx in range(-1, 2):
|
||||||
|
px, py = x + dx, y + dy
|
||||||
|
if 0 <= px < w and 0 <= py < h:
|
||||||
|
_set_index(surface, px, py, wheel(frame * 5))
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_line(x0: int, y0: int, x1: int, y1: int):
|
||||||
|
"""Yield pixel coordinates along a Bresenham line."""
|
||||||
|
dx = abs(x1 - x0)
|
||||||
|
dy = -abs(y1 - y0)
|
||||||
|
sx = 1 if x0 < x1 else -1
|
||||||
|
sy = 1 if y0 < y1 else -1
|
||||||
|
err = dx + dy
|
||||||
|
x, y = x0, y0
|
||||||
|
while True:
|
||||||
|
yield x, y
|
||||||
|
if x == x1 and y == y1:
|
||||||
|
break
|
||||||
|
e2 = 2 * err
|
||||||
|
if e2 >= dy:
|
||||||
|
err += dy
|
||||||
|
x += sx
|
||||||
|
if e2 <= dx:
|
||||||
|
err += dx
|
||||||
|
y += sy
|
||||||
|
|
||||||
|
|
||||||
|
def rolling(surface: Surface, frame: int) -> None:
|
||||||
|
"""Full-height band rolling left to right across the panel."""
|
||||||
|
w, h = _size(surface)
|
||||||
|
tail = 10
|
||||||
|
head = frame % (w + tail)
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
for y in range(h):
|
||||||
|
hue_shift = y * 18 + frame * 3
|
||||||
|
for i in range(tail):
|
||||||
|
x = head - i
|
||||||
|
if 0 <= x < w:
|
||||||
|
bright = (tail - i) / tail
|
||||||
|
_set_index(surface, x, y, dim(wheel(hue_shift + i * 8), bright))
|
||||||
|
|
||||||
|
|
||||||
|
def spin_line(surface: Surface, frame: int) -> None:
|
||||||
|
"""Line from panel center spinning like a radar sweep."""
|
||||||
|
w, h = _size(surface)
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
else:
|
||||||
|
surface.fill((0, 0, 0))
|
||||||
|
|
||||||
|
if not _is_matrix(surface):
|
||||||
|
n = len(surface)
|
||||||
|
pos = int((frame * 3) % max(n, 1))
|
||||||
|
for i in range(8):
|
||||||
|
idx = (pos - i) % n
|
||||||
|
surface[idx] = dim(wheel(frame * 5 + i * 20), (8 - i) / 8)
|
||||||
|
return
|
||||||
|
|
||||||
|
cx = (w - 1) / 2.0
|
||||||
|
cy = (h - 1) / 2.0
|
||||||
|
angle = frame * 0.14
|
||||||
|
reach = max(w, h) * 1.2
|
||||||
|
x1 = int(cx + math.cos(angle) * reach)
|
||||||
|
y1 = int(cy + math.sin(angle) * reach)
|
||||||
|
color = wheel(frame * 4)
|
||||||
|
|
||||||
|
for x, y in _iter_line(int(cx), int(cy), x1, y1):
|
||||||
|
if 0 <= x < w and 0 <= y < h:
|
||||||
|
_set_index(surface, x, y, color)
|
||||||
|
|
||||||
|
hub_x, hub_y = int(cx), int(cy)
|
||||||
|
if 0 <= hub_x < w and 0 <= hub_y < h:
|
||||||
|
_set_index(surface, hub_x, hub_y, (255, 255, 255))
|
||||||
|
|
||||||
|
|
||||||
|
def solid(surface: Surface, frame: int) -> None:
|
||||||
|
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255)]
|
||||||
|
if _is_matrix(surface):
|
||||||
|
surface.fill(colors[frame % 3])
|
||||||
|
else:
|
||||||
|
surface.fill(colors[frame % 3])
|
||||||
|
|
||||||
|
|
||||||
|
ANIMATIONS: dict[str, DrawFn] = {
|
||||||
|
"solid": solid,
|
||||||
|
"rainbow": rainbow_scroll,
|
||||||
|
"plasma": plasma,
|
||||||
|
"wave": sine_wave,
|
||||||
|
"scanner": scanner,
|
||||||
|
"comet": comet,
|
||||||
|
"pulse": pulse,
|
||||||
|
"sparkle": sparkle,
|
||||||
|
"fire": fire,
|
||||||
|
"rain": rain,
|
||||||
|
"stripes": stripes,
|
||||||
|
"bounce": bounce,
|
||||||
|
"rolling": rolling,
|
||||||
|
"spin_line": spin_line,
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_FPS: dict[str, float] = {
|
||||||
|
"solid": 1,
|
||||||
|
"rainbow": 30,
|
||||||
|
"plasma": 25,
|
||||||
|
"wave": 25,
|
||||||
|
"scanner": 35,
|
||||||
|
"comet": 30,
|
||||||
|
"pulse": 30,
|
||||||
|
"sparkle": 40,
|
||||||
|
"fire": 35,
|
||||||
|
"rain": 30,
|
||||||
|
"stripes": 25,
|
||||||
|
"bounce": 35,
|
||||||
|
"rolling": 30,
|
||||||
|
"spin_line": 28,
|
||||||
|
}
|
||||||
69
leds/array_config.py
Normal file
69
leds/array_config.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""LED matrix layout defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
MATRIX_WIDTH = 45
|
||||||
|
MATRIX_HEIGHT = 9
|
||||||
|
MATRIX_PIXELS = MATRIX_WIDTH * MATRIX_HEIGHT # 405
|
||||||
|
|
||||||
|
# Per-panel width (height is always 9 rows). Edit to match your hardware.
|
||||||
|
# Panel 4 may still be 38 or 39 — run: panel_sync_test.py --test width --panel-index 4
|
||||||
|
PANEL_HEIGHT = 9
|
||||||
|
PANEL_WIDTH_BY_INDEX: dict[int, int] = {
|
||||||
|
0: 39,
|
||||||
|
1: 45,
|
||||||
|
2: 45,
|
||||||
|
3: 45,
|
||||||
|
4: 39,
|
||||||
|
}
|
||||||
|
DEFAULT_PANEL_WIDTH = 45
|
||||||
|
PANEL_COUNT = 5
|
||||||
|
|
||||||
|
# SPI data line: bus 0 = GPIO 10 (MOSI), bus 1 = GPIO 20 (SPI1 MOSI)
|
||||||
|
MATRIX_SPI_BUS = 0
|
||||||
|
MATRIX_SPI_DEVICE = 0
|
||||||
|
|
||||||
|
# One continuous chain on a single data pin; rows all run left → right.
|
||||||
|
MATRIX_ROWS_PER_STRIP = None
|
||||||
|
MATRIX_SERPENTINE = "none" # use "rows" for zigzag / serpentine wiring
|
||||||
|
|
||||||
|
# Set True if DIN enters at the bottom row instead of the top.
|
||||||
|
MATRIX_FLIP_Y = False
|
||||||
|
|
||||||
|
MATRIX_BRIGHTNESS = 0.25
|
||||||
|
|
||||||
|
# LED wire order for direct SPI on Pi (not used for Pico UDP — firmware packs GRB).
|
||||||
|
WIRE_ORDER = os.environ.get("PORTAL_WIRE_ORDER", "grb").lower()
|
||||||
|
|
||||||
|
|
||||||
|
def panel_width(panel_index: int) -> int:
|
||||||
|
return PANEL_WIDTH_BY_INDEX.get(panel_index, DEFAULT_PANEL_WIDTH)
|
||||||
|
|
||||||
|
|
||||||
|
def panel_layout(panel_index: int) -> tuple[int, int]:
|
||||||
|
return panel_width(panel_index), PANEL_HEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
def panel_pixel_count(panel_index: int) -> int:
|
||||||
|
w, h = panel_layout(panel_index)
|
||||||
|
return w * h
|
||||||
|
|
||||||
|
|
||||||
|
def matrix_pixel_index(
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
width: int = MATRIX_WIDTH,
|
||||||
|
height: int = MATRIX_HEIGHT,
|
||||||
|
*,
|
||||||
|
serpentine: str = MATRIX_SERPENTINE,
|
||||||
|
flip_y: bool = MATRIX_FLIP_Y,
|
||||||
|
) -> int:
|
||||||
|
"""Map (x, y) to strip index. Default: every row left → right."""
|
||||||
|
if flip_y:
|
||||||
|
y = height - 1 - y
|
||||||
|
col = x
|
||||||
|
if serpentine == "rows" and y % 2 == 1:
|
||||||
|
col = width - 1 - x
|
||||||
|
return y * width + col
|
||||||
1
leds/backends/__init__.py
Normal file
1
leds/backends/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
58
leds/backends/pio.py
Normal file
58
leds/backends/pio.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""WS2812 via Pi 5 kernel ws2812-pio driver (/dev/ledsN)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class PioBackend:
|
||||||
|
def __init__(self, led_count: int, *, device: str = "/dev/leds0") -> None:
|
||||||
|
self._led_count = led_count
|
||||||
|
self._device = device
|
||||||
|
self._path = Path(device)
|
||||||
|
if not self._path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"{device} not found — enable ws2812-pio in /boot/firmware/config.txt "
|
||||||
|
"and reboot. Run: pipenv run python examples/setup_pi5_leds.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> str:
|
||||||
|
return self._device
|
||||||
|
|
||||||
|
def write_grb(self, grb_bytes: bytes) -> None:
|
||||||
|
if len(grb_bytes) != self._led_count * 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected {self._led_count * 3} bytes, got {len(grb_bytes)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kernel driver expects RGBW uint32 little-endian per pixel (W=0 for RGB).
|
||||||
|
frame = bytearray(self._led_count * 4)
|
||||||
|
for i in range(self._led_count):
|
||||||
|
base = i * 3
|
||||||
|
g, r, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
|
||||||
|
offset = i * 4
|
||||||
|
frame[offset] = r
|
||||||
|
frame[offset + 1] = g
|
||||||
|
frame[offset + 2] = b
|
||||||
|
frame[offset + 3] = 0
|
||||||
|
|
||||||
|
with open(self._path, "wb") as dev:
|
||||||
|
dev.write(frame)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with open(self._path, "wb") as dev:
|
||||||
|
dev.write(b"\x00" * (self._led_count * 4))
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def __enter__(self) -> PioBackend:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
81
leds/backends/spi.py
Normal file
81
leds/backends/spi.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""WS2812 over SPI — works on Pi 5, 4, and Zero when SPI is enabled."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from array import array
|
||||||
|
|
||||||
|
try:
|
||||||
|
from spidev import SpiDev
|
||||||
|
except ImportError as exc: # pragma: no cover - import guard
|
||||||
|
SpiDev = None
|
||||||
|
_SPI_IMPORT_ERROR = exc
|
||||||
|
else:
|
||||||
|
_SPI_IMPORT_ERROR = None
|
||||||
|
|
||||||
|
# WS2812 bit patterns via SPI, from https://github.com/mattaw/ws2812_spi_python
|
||||||
|
_LED_ZERO = 0b1100_0000
|
||||||
|
_LED_ONE = 0b1111_1100
|
||||||
|
_PREAMBLE = 42
|
||||||
|
|
||||||
|
|
||||||
|
class SpiBackend:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
led_count: int,
|
||||||
|
*,
|
||||||
|
spi_bus: int = 0,
|
||||||
|
spi_device: int = 0,
|
||||||
|
max_speed_hz: int = 6_500_000,
|
||||||
|
) -> None:
|
||||||
|
if SpiDev is None:
|
||||||
|
raise ImportError(
|
||||||
|
"spidev is required for the SPI backend; install portal requirements"
|
||||||
|
) from _SPI_IMPORT_ERROR
|
||||||
|
|
||||||
|
self._led_count = led_count
|
||||||
|
self._device = SpiDev()
|
||||||
|
self._device.open(spi_bus, spi_device)
|
||||||
|
self._device.max_speed_hz = max_speed_hz
|
||||||
|
self._device.mode = 0b00
|
||||||
|
self._device.lsbfirst = False
|
||||||
|
|
||||||
|
frame_len = _PREAMBLE + led_count * 24
|
||||||
|
self._frame = array("B", [0] * frame_len)
|
||||||
|
self._clear_frame = array("B", [0] * frame_len)
|
||||||
|
for i in range(_PREAMBLE, frame_len):
|
||||||
|
self._clear_frame[i] = _LED_ZERO
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
def write_grb(self, grb_bytes: bytes) -> None:
|
||||||
|
if len(grb_bytes) != self._led_count * 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected {self._led_count * 3} bytes, got {len(grb_bytes)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
out = self._frame
|
||||||
|
pos = _PREAMBLE
|
||||||
|
for value in grb_bytes:
|
||||||
|
for bit in (128, 64, 32, 16, 8, 4, 2, 1):
|
||||||
|
out[pos] = _LED_ONE if value & bit else _LED_ZERO
|
||||||
|
pos += 1
|
||||||
|
|
||||||
|
# writebytes2 handles large frames (400+ LEDs); writebytes is capped at 4 KiB.
|
||||||
|
self._device.writebytes2(out)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._device.writebytes2(self._clear_frame)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
try:
|
||||||
|
self.clear()
|
||||||
|
finally:
|
||||||
|
self._device.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> SpiBackend:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
73
leds/backends/ws281x.py
Normal file
73
leds/backends/ws281x.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""WS2812 via PWM/DMA — Pi 4 and Zero only (not Pi 5)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
from rpi_ws281x import PixelStrip, ws
|
||||||
|
except ImportError as exc: # pragma: no cover - import guard
|
||||||
|
PixelStrip = None
|
||||||
|
ws = None
|
||||||
|
_WS281X_IMPORT_ERROR = exc
|
||||||
|
else:
|
||||||
|
_WS281X_IMPORT_ERROR = None
|
||||||
|
|
||||||
|
|
||||||
|
class Ws281xBackend:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
led_count: int,
|
||||||
|
*,
|
||||||
|
pin: int = 18,
|
||||||
|
freq_hz: int = 800_000,
|
||||||
|
dma: int = 10,
|
||||||
|
invert: bool = False,
|
||||||
|
channel: int = 0,
|
||||||
|
) -> None:
|
||||||
|
if PixelStrip is None:
|
||||||
|
raise ImportError(
|
||||||
|
"rpi-ws281x is required for the ws281x backend; install portal requirements"
|
||||||
|
) from _WS281X_IMPORT_ERROR
|
||||||
|
|
||||||
|
self._led_count = led_count
|
||||||
|
self._strip = PixelStrip(
|
||||||
|
led_count,
|
||||||
|
pin,
|
||||||
|
freq_hz,
|
||||||
|
dma,
|
||||||
|
invert,
|
||||||
|
255,
|
||||||
|
channel,
|
||||||
|
strip_type=ws.WS2811_STRIP_GRB,
|
||||||
|
)
|
||||||
|
self._strip.begin()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def led_count(self) -> int:
|
||||||
|
return self._led_count
|
||||||
|
|
||||||
|
def write_grb(self, grb_bytes: bytes) -> None:
|
||||||
|
if len(grb_bytes) != self._led_count * 3:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected {self._led_count * 3} bytes, got {len(grb_bytes)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
strip = self._strip
|
||||||
|
for i in range(self._led_count):
|
||||||
|
base = i * 3
|
||||||
|
g, r, b = grb_bytes[base], grb_bytes[base + 1], grb_bytes[base + 2]
|
||||||
|
strip.setPixelColor(i, (r << 16) | (g << 8) | b)
|
||||||
|
strip.show()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for i in range(self._led_count):
|
||||||
|
self._strip.setPixelColor(i, 0)
|
||||||
|
self._strip.show()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.clear()
|
||||||
|
|
||||||
|
def __enter__(self) -> Ws281xBackend:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
68
leds/colors.py
Normal file
68
leds/colors.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Color helpers for LED animations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
RGB = Tuple[int, int, int]
|
||||||
|
|
||||||
|
|
||||||
|
def wheel(pos: int) -> RGB:
|
||||||
|
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 heat_color(temperature: float) -> RGB:
|
||||||
|
"""Map 0.0–1.0 heat to black → red → yellow → white."""
|
||||||
|
t = max(0.0, min(1.0, temperature))
|
||||||
|
if t < 0.25:
|
||||||
|
return (int(t * 4 * 255), 0, 0)
|
||||||
|
if t < 0.5:
|
||||||
|
return (255, int((t - 0.25) * 4 * 255), 0)
|
||||||
|
if t < 0.75:
|
||||||
|
return (255, 255, int((t - 0.5) * 4 * 255))
|
||||||
|
return (255, 255, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def hsv_to_rgb(h: float, s: float, v: float) -> RGB:
|
||||||
|
h = h % 1.0
|
||||||
|
i = int(h * 6)
|
||||||
|
f = h * 6 - i
|
||||||
|
p = int(v * (1 - s) * 255)
|
||||||
|
q = int(v * (1 - f * s) * 255)
|
||||||
|
t = int(v * (1 - (1 - f) * s) * 255)
|
||||||
|
v255 = int(v * 255)
|
||||||
|
match i % 6:
|
||||||
|
case 0:
|
||||||
|
return (v255, t, p)
|
||||||
|
case 1:
|
||||||
|
return (q, v255, p)
|
||||||
|
case 2:
|
||||||
|
return (p, v255, t)
|
||||||
|
case 3:
|
||||||
|
return (p, q, v255)
|
||||||
|
case 4:
|
||||||
|
return (t, p, v255)
|
||||||
|
case _:
|
||||||
|
return (v255, p, q)
|
||||||
|
|
||||||
|
|
||||||
|
def dim(color: RGB, scale: float) -> RGB:
|
||||||
|
scale = max(0.0, min(1.0, scale))
|
||||||
|
return (int(color[0] * scale), int(color[1] * scale), int(color[2] * scale))
|
||||||
|
|
||||||
|
|
||||||
|
def blend(a: RGB, b: RGB, t: float) -> RGB:
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
return (
|
||||||
|
int(a[0] + (b[0] - a[0]) * t),
|
||||||
|
int(a[1] + (b[1] - a[1]) * t),
|
||||||
|
int(a[2] + (b[2] - a[2]) * t),
|
||||||
|
)
|
||||||
151
leds/config.py
Normal file
151
leds/config.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""Default wiring for one or two WS2812 strips per board."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from leds.detect import board_family, pio_available, pio_device, pi5_second_spi_bus
|
||||||
|
|
||||||
|
try:
|
||||||
|
from leds.array_config import MATRIX_SPI_BUS, MATRIX_SPI_DEVICE
|
||||||
|
except ImportError:
|
||||||
|
MATRIX_SPI_BUS = 0
|
||||||
|
MATRIX_SPI_DEVICE = 0
|
||||||
|
|
||||||
|
BackendName = Literal["auto", "pio", "spi", "ws281x"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StripConfig:
|
||||||
|
count: int
|
||||||
|
backend: BackendName = "auto"
|
||||||
|
pin: int = 18
|
||||||
|
channel: int = 0
|
||||||
|
device: str = "/dev/leds0"
|
||||||
|
spi_bus: int = 0
|
||||||
|
spi_device: int = 0
|
||||||
|
brightness: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def spi_strip_config(
|
||||||
|
count: int,
|
||||||
|
*,
|
||||||
|
spi_bus: int = MATRIX_SPI_BUS,
|
||||||
|
spi_device: int = MATRIX_SPI_DEVICE,
|
||||||
|
brightness: float = 1.0,
|
||||||
|
) -> StripConfig:
|
||||||
|
return StripConfig(
|
||||||
|
count,
|
||||||
|
backend="spi",
|
||||||
|
spi_bus=spi_bus,
|
||||||
|
spi_device=spi_device,
|
||||||
|
brightness=brightness,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_strip_config(count: int, *, brightness: float = 1.0) -> StripConfig:
|
||||||
|
family = board_family()
|
||||||
|
if family == "pi5":
|
||||||
|
if pio_available():
|
||||||
|
return StripConfig(
|
||||||
|
count,
|
||||||
|
backend="pio",
|
||||||
|
pin=18,
|
||||||
|
device=pio_device(0) or "/dev/leds0",
|
||||||
|
brightness=brightness,
|
||||||
|
)
|
||||||
|
return spi_strip_config(count, brightness=brightness)
|
||||||
|
return StripConfig(
|
||||||
|
count,
|
||||||
|
backend="ws281x",
|
||||||
|
pin=18,
|
||||||
|
channel=0,
|
||||||
|
brightness=brightness,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_dual_strip_configs(
|
||||||
|
count0: int,
|
||||||
|
count1: int,
|
||||||
|
*,
|
||||||
|
brightness: float = 1.0,
|
||||||
|
) -> tuple[StripConfig, StripConfig]:
|
||||||
|
"""
|
||||||
|
Suggested wiring for two independent strips:
|
||||||
|
|
||||||
|
Pi 5 (SPI — requires dtoverlay=spi1-3cs under [all], not [cm5]):
|
||||||
|
strip 0 → GPIO 10 (SPI0 MOSI), /dev/spidev0.0
|
||||||
|
strip 1 → GPIO 20 (SPI1 MOSI), /dev/spidev1.0
|
||||||
|
|
||||||
|
Pi 5 (ws2812-pio — alternative):
|
||||||
|
strip 0 → GPIO 18, /dev/leds0
|
||||||
|
strip 1 → GPIO 13, /dev/leds1
|
||||||
|
|
||||||
|
Pi 4 / Zero:
|
||||||
|
strip 0 → GPIO 18 (PWM channel 0)
|
||||||
|
strip 1 → GPIO 13 (PWM channel 1)
|
||||||
|
"""
|
||||||
|
family = board_family()
|
||||||
|
if family == "pi5":
|
||||||
|
if pio_available():
|
||||||
|
return (
|
||||||
|
StripConfig(
|
||||||
|
count0,
|
||||||
|
backend="pio",
|
||||||
|
pin=18,
|
||||||
|
device=pio_device(0) or "/dev/leds0",
|
||||||
|
brightness=brightness,
|
||||||
|
),
|
||||||
|
StripConfig(
|
||||||
|
count1,
|
||||||
|
backend="pio",
|
||||||
|
pin=13,
|
||||||
|
device=pio_device(1) or "/dev/leds1",
|
||||||
|
brightness=brightness,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
StripConfig(
|
||||||
|
count0,
|
||||||
|
backend="spi",
|
||||||
|
spi_bus=0,
|
||||||
|
spi_device=0,
|
||||||
|
brightness=brightness,
|
||||||
|
),
|
||||||
|
StripConfig(
|
||||||
|
count1,
|
||||||
|
backend="spi",
|
||||||
|
spi_bus=pi5_second_spi_bus(),
|
||||||
|
spi_device=0,
|
||||||
|
brightness=brightness,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
StripConfig(
|
||||||
|
count0,
|
||||||
|
backend="ws281x",
|
||||||
|
pin=18,
|
||||||
|
channel=0,
|
||||||
|
brightness=brightness,
|
||||||
|
),
|
||||||
|
StripConfig(
|
||||||
|
count1,
|
||||||
|
backend="ws281x",
|
||||||
|
pin=13,
|
||||||
|
channel=1,
|
||||||
|
brightness=brightness,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pi5_setup_hint(count0: int, count1: int) -> str:
|
||||||
|
return (
|
||||||
|
"Pi 5 second SPI bus not found. Add under [all] in /boot/firmware/config.txt:\n"
|
||||||
|
" dtparam=spi=on\n"
|
||||||
|
" dtoverlay=spi1-3cs\n"
|
||||||
|
"Remove dtparam=spi1=on (ignored on Pi 5). Do not put the overlay under [cm5]\n"
|
||||||
|
"unless you have a Compute Module 5.\n"
|
||||||
|
"Wire strip 0 DIN → GPIO 10, strip 1 DIN → GPIO 20, then reboot."
|
||||||
|
)
|
||||||
64
leds/detect.py
Normal file
64
leds/detect.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Raspberry Pi model detection for choosing a WS2812 backend."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def board_model() -> str:
|
||||||
|
try:
|
||||||
|
with open("/proc/device-tree/model", "rb") as f:
|
||||||
|
return f.read().rstrip(b"\x00").decode("ascii")
|
||||||
|
except OSError:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def board_family() -> str:
|
||||||
|
model = board_model().lower()
|
||||||
|
if "raspberry pi 5" in model:
|
||||||
|
return "pi5"
|
||||||
|
if "raspberry pi 4" in model:
|
||||||
|
return "pi4"
|
||||||
|
if "zero 2" in model:
|
||||||
|
return "pi_zero2"
|
||||||
|
if "zero" in model:
|
||||||
|
return "pi_zero"
|
||||||
|
if "raspberry pi 3" in model:
|
||||||
|
return "pi3"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def supports_ws281x() -> bool:
|
||||||
|
return board_family() != "pi5"
|
||||||
|
|
||||||
|
|
||||||
|
def pio_device(index: int = 0) -> str | None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(f"/dev/leds{index}")
|
||||||
|
return str(path) if path.exists() else None
|
||||||
|
|
||||||
|
|
||||||
|
def pio_available() -> bool:
|
||||||
|
return pio_device(0) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def pi5_second_spi_bus() -> int:
|
||||||
|
"""
|
||||||
|
Second strip on Pi 5 SPI — prefer spi1 from dtoverlay=spi1-3cs
|
||||||
|
(/dev/spidev1.0, MOSI on GPIO 20). Falls back to built-in spidev10.0.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if Path("/dev/spidev1.0").exists():
|
||||||
|
return 1
|
||||||
|
if Path("/dev/spidev10.0").exists():
|
||||||
|
return 10
|
||||||
|
raise RuntimeError(
|
||||||
|
"No second SPI device found. Add to /boot/firmware/config.txt under [all]:\n"
|
||||||
|
" dtoverlay=spi1-3cs\n"
|
||||||
|
"Then reboot. Wire strip 1 DIN to GPIO 20 (SPI1 MOSI).\n"
|
||||||
|
"Note: dtparam=spi1=on is ignored on Pi 5; use the overlay in [all], not [cm5]."
|
||||||
|
)
|
||||||
168
leds/matrix.py
Normal file
168
leds/matrix.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""2D WS2812 matrix mapped onto one or two strips."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Sequence, Tuple
|
||||||
|
|
||||||
|
from leds.array_config import (
|
||||||
|
MATRIX_BRIGHTNESS,
|
||||||
|
MATRIX_HEIGHT,
|
||||||
|
MATRIX_ROWS_PER_STRIP,
|
||||||
|
MATRIX_SERPENTINE,
|
||||||
|
MATRIX_WIDTH,
|
||||||
|
)
|
||||||
|
from leds.config import StripConfig, default_dual_strip_configs
|
||||||
|
from leds.strip import Color, LedStrip, _normalize_color
|
||||||
|
from leds.strips import LedStrips
|
||||||
|
|
||||||
|
Serpentine = Literal["rows", "none"]
|
||||||
|
|
||||||
|
|
||||||
|
class LedMatrix:
|
||||||
|
"""
|
||||||
|
45×9 matrix on one WS2812 data line (405 LEDs).
|
||||||
|
|
||||||
|
Any size works: LedMatrix(width=50, height=12) → 600 LEDs, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
width: int = MATRIX_WIDTH,
|
||||||
|
height: int = MATRIX_HEIGHT,
|
||||||
|
*,
|
||||||
|
rows_per_strip: Sequence[int] | None = MATRIX_ROWS_PER_STRIP,
|
||||||
|
serpentine: Serpentine = MATRIX_SERPENTINE,
|
||||||
|
brightness: float = MATRIX_BRIGHTNESS,
|
||||||
|
config0: StripConfig | None = None,
|
||||||
|
config1: StripConfig | None = None,
|
||||||
|
) -> None:
|
||||||
|
if width < 1 or height < 1:
|
||||||
|
raise ValueError("width and height must be positive")
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.serpentine = serpentine
|
||||||
|
self._strip: LedStrip | None = None
|
||||||
|
self._strips: LedStrips | None = None
|
||||||
|
|
||||||
|
if rows_per_strip is None:
|
||||||
|
self._rows_per_strip = (height,)
|
||||||
|
count0 = width * height
|
||||||
|
if config0 is None:
|
||||||
|
from leds.config import default_strip_config
|
||||||
|
|
||||||
|
config0 = default_strip_config(count0, brightness=brightness)
|
||||||
|
self._strip = LedStrip(
|
||||||
|
count0,
|
||||||
|
backend=config0.backend,
|
||||||
|
pin=config0.pin,
|
||||||
|
channel=config0.channel,
|
||||||
|
device=config0.device,
|
||||||
|
brightness=config0.brightness,
|
||||||
|
spi_bus=config0.spi_bus,
|
||||||
|
spi_device=config0.spi_device,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total_rows = sum(rows_per_strip)
|
||||||
|
if total_rows != height:
|
||||||
|
raise ValueError(
|
||||||
|
f"rows_per_strip sums to {total_rows}, expected {height}"
|
||||||
|
)
|
||||||
|
counts = [width * r for r in rows_per_strip]
|
||||||
|
self._rows_per_strip = tuple(rows_per_strip)
|
||||||
|
if config0 is None or config1 is None:
|
||||||
|
defaults = default_dual_strip_configs(
|
||||||
|
counts[0], counts[1], brightness=brightness
|
||||||
|
)
|
||||||
|
config0 = config0 or defaults[0]
|
||||||
|
config1 = config1 or defaults[1]
|
||||||
|
self._strips = LedStrips(
|
||||||
|
counts[0],
|
||||||
|
counts[1],
|
||||||
|
config0=config0,
|
||||||
|
config1=config1,
|
||||||
|
brightness=brightness,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _locate(self, x: int, y: int) -> Tuple[int, int]:
|
||||||
|
if not (0 <= x < self.width and 0 <= y < self.height):
|
||||||
|
raise IndexError(f"({x}, {y}) out of range for {self.width}×{self.height}")
|
||||||
|
|
||||||
|
row_offset = 0
|
||||||
|
for strip_id, num_rows in enumerate(self._rows_per_strip):
|
||||||
|
if y < row_offset + num_rows:
|
||||||
|
local_y = y - row_offset
|
||||||
|
col = x
|
||||||
|
if self.serpentine == "rows" and y % 2 == 1:
|
||||||
|
col = self.width - 1 - x
|
||||||
|
return strip_id, local_y * self.width + col
|
||||||
|
row_offset += num_rows
|
||||||
|
|
||||||
|
raise IndexError(f"({x}, {y}) out of range")
|
||||||
|
|
||||||
|
def index_at(self, x: int, y: int) -> int:
|
||||||
|
"""Strip index for (x, y) on a single-strip matrix."""
|
||||||
|
strip_id, index = self._locate(x, y)
|
||||||
|
if strip_id != 0 or self._strip is None:
|
||||||
|
raise RuntimeError("index_at requires a single-strip matrix")
|
||||||
|
return index
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strip(self) -> LedStrip | None:
|
||||||
|
return self._strip
|
||||||
|
|
||||||
|
def __getitem__(self, pos: Tuple[int, int]) -> Tuple[int, int, int]:
|
||||||
|
x, y = pos
|
||||||
|
strip_id, index = self._locate(x, y)
|
||||||
|
if self._strip is not None:
|
||||||
|
return self._strip[index]
|
||||||
|
assert self._strips is not None
|
||||||
|
return self._strips[strip_id][index]
|
||||||
|
|
||||||
|
def __setitem__(self, pos: Tuple[int, int], color: Color) -> None:
|
||||||
|
x, y = pos
|
||||||
|
strip_id, index = self._locate(x, y)
|
||||||
|
rgb = _normalize_color(color)
|
||||||
|
if self._strip is not None:
|
||||||
|
self._strip[index] = rgb
|
||||||
|
else:
|
||||||
|
assert self._strips is not None
|
||||||
|
self._strips[strip_id][index] = rgb
|
||||||
|
|
||||||
|
def set_pixel(self, x: int, y: int, color: Color) -> None:
|
||||||
|
self[x, y] = color
|
||||||
|
|
||||||
|
def fill(self, color: Color) -> None:
|
||||||
|
rgb = _normalize_color(color)
|
||||||
|
if self._strip is not None:
|
||||||
|
self._strip.fill(rgb)
|
||||||
|
else:
|
||||||
|
assert self._strips is not None
|
||||||
|
self._strips[0].fill(rgb)
|
||||||
|
self._strips[1].fill(rgb)
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
if self._strip is not None:
|
||||||
|
self._strip.show()
|
||||||
|
else:
|
||||||
|
assert self._strips is not None
|
||||||
|
self._strips.show()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
if self._strip is not None:
|
||||||
|
self._strip.clear()
|
||||||
|
else:
|
||||||
|
assert self._strips is not None
|
||||||
|
self._strips.clear()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._strip is not None:
|
||||||
|
self._strip.close()
|
||||||
|
elif self._strips is not None:
|
||||||
|
self._strips.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> LedMatrix:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
432
leds/panel_udp.py
Normal file
432
leds/panel_udp.py
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
"""UDP helpers for Pico panel firmware."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Sequence, Tuple
|
||||||
|
|
||||||
|
from leds.animations import ANIMATIONS, DEFAULT_FPS
|
||||||
|
from leds.array_config import (
|
||||||
|
MATRIX_HEIGHT,
|
||||||
|
MATRIX_PIXELS,
|
||||||
|
MATRIX_WIDTH,
|
||||||
|
PANEL_COUNT,
|
||||||
|
matrix_pixel_index,
|
||||||
|
panel_layout,
|
||||||
|
panel_pixel_count,
|
||||||
|
)
|
||||||
|
from leds.colors import dim
|
||||||
|
|
||||||
|
PANEL_IP_NET = "10.1.1"
|
||||||
|
PANEL_IP_BASE = 10
|
||||||
|
PANEL_MAX_FPS = 25.0
|
||||||
|
|
||||||
|
RGB = Tuple[int, int, int]
|
||||||
|
|
||||||
|
DEFAULT_PORT = int(os.environ.get("PORTAL_PANEL_PORT", "50007"))
|
||||||
|
|
||||||
|
|
||||||
|
def panel_ip(panel_id: int) -> str:
|
||||||
|
return f"{PANEL_IP_NET}.{PANEL_IP_BASE + panel_id}"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOST = panel_ip(0)
|
||||||
|
DEFAULT_PANEL_HOSTS = [panel_ip(i) for i in range(PANEL_COUNT)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PanelTarget:
|
||||||
|
index: int
|
||||||
|
host: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pixels(self) -> int:
|
||||||
|
return self.width * self.height
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
if self.index >= 0:
|
||||||
|
return f"{self.host} ({self.width}×{self.height}, panel {self.index})"
|
||||||
|
return f"{self.host} ({self.width}×{self.height})"
|
||||||
|
|
||||||
|
|
||||||
|
def panel_hosts(panel_index: int | None = None, count: int = PANEL_COUNT) -> list[str]:
|
||||||
|
return [t.host for t in resolve_panel_targets(panel_index=panel_index, panels=count)]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_panel_targets(
|
||||||
|
host: str | None = None,
|
||||||
|
panel_index: int | None = None,
|
||||||
|
panels: int = PANEL_COUNT,
|
||||||
|
*,
|
||||||
|
width: int | None = None,
|
||||||
|
height: int | None = None,
|
||||||
|
) -> list[PanelTarget]:
|
||||||
|
if panel_index is not None:
|
||||||
|
w, h = panel_layout(panel_index)
|
||||||
|
if width is not None:
|
||||||
|
w = width
|
||||||
|
if height is not None:
|
||||||
|
h = height
|
||||||
|
return [PanelTarget(panel_index, panel_ip(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))
|
||||||
|
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}"
|
||||||
|
layouts = ", ".join(f"{t.width}×{t.height}" for t in targets)
|
||||||
|
return f"{len(targets)} panels ({targets[0].host} … {targets[-1].host}):{port} [{layouts}]"
|
||||||
|
|
||||||
|
|
||||||
|
def add_panel_network_args(parser: argparse.ArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--host",
|
||||||
|
default=None,
|
||||||
|
help="Single panel IP override (not needed; default uses 10.1.1.10–14)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--panel-index",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
choices=range(PANEL_COUNT),
|
||||||
|
metavar="N",
|
||||||
|
help=f"Drive only panel N (0–{PANEL_COUNT - 1}); default all {PANEL_COUNT}",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--panels",
|
||||||
|
type=int,
|
||||||
|
default=PANEL_COUNT,
|
||||||
|
help=f"Number of panels when driving all (default {PANEL_COUNT})",
|
||||||
|
)
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def add_panel_args(parser: argparse.ArgumentParser) -> None:
|
||||||
|
add_panel_network_args(parser)
|
||||||
|
parser.add_argument(
|
||||||
|
"--width",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Override panel width (default from leds/array_config.py per panel)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--height",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help=f"Override panel height (default {MATRIX_HEIGHT})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--count",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="LEDs per frame (default: panel width × height)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--brightness", type=float, default=0.35)
|
||||||
|
|
||||||
|
|
||||||
|
def panel_targets_from_args(args: argparse.Namespace) -> list[PanelTarget]:
|
||||||
|
return resolve_panel_targets(
|
||||||
|
args.host,
|
||||||
|
args.panel_index,
|
||||||
|
args.panels,
|
||||||
|
width=getattr(args, "width", None),
|
||||||
|
height=getattr(args, "height", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def panel_count_from_args(args: argparse.Namespace) -> int:
|
||||||
|
if args.count is not None:
|
||||||
|
return args.count
|
||||||
|
if args.panel_index is not None:
|
||||||
|
return panel_pixel_count(args.panel_index)
|
||||||
|
if args.width is not None:
|
||||||
|
h = args.height if args.height is not None else MATRIX_HEIGHT
|
||||||
|
return args.width * h
|
||||||
|
return MATRIX_PIXELS
|
||||||
|
|
||||||
|
|
||||||
|
class HeadlessMatrix:
|
||||||
|
"""Matrix buffer for UDP senders — no local SPI/PIO."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
width: int = MATRIX_WIDTH,
|
||||||
|
height: int = MATRIX_HEIGHT,
|
||||||
|
*,
|
||||||
|
brightness: float = 0.25,
|
||||||
|
) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.brightness = brightness
|
||||||
|
self._pixels: list[RGB] = [(0, 0, 0)] * (width * height)
|
||||||
|
|
||||||
|
def _index_at(self, x: int, y: int) -> int:
|
||||||
|
return matrix_pixel_index(x, y, self.width, self.height)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._pixels)
|
||||||
|
|
||||||
|
def __getitem__(self, pos: Tuple[int, int]) -> RGB:
|
||||||
|
return self._pixels[self._index_at(*pos)]
|
||||||
|
|
||||||
|
def __setitem__(self, pos: Tuple[int, int], color: RGB) -> None:
|
||||||
|
self._pixels[self._index_at(*pos)] = color
|
||||||
|
|
||||||
|
def fill(self, color: RGB) -> None:
|
||||||
|
self._pixels = [color] * len(self._pixels)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.fill((0, 0, 0))
|
||||||
|
|
||||||
|
def set_index(self, index: int, color: RGB) -> None:
|
||||||
|
if 0 <= index < len(self._pixels):
|
||||||
|
self._pixels[index] = color
|
||||||
|
|
||||||
|
def rgb_bytes(self) -> bytes:
|
||||||
|
out = bytearray(len(self._pixels) * 3)
|
||||||
|
for i, (r, g, b) in enumerate(self._pixels):
|
||||||
|
r, g, b = dim((r, g, b), self.brightness)
|
||||||
|
base = i * 3
|
||||||
|
out[base], out[base + 1], out[base + 2] = r, g, b
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def solid_frame_bytes(
|
||||||
|
color: RGB,
|
||||||
|
count: int = MATRIX_PIXELS,
|
||||||
|
brightness: float = 1.0,
|
||||||
|
) -> bytes:
|
||||||
|
r, g, b = dim(color, brightness)
|
||||||
|
return bytes((r, g, b)) * count
|
||||||
|
|
||||||
|
|
||||||
|
def pixels_to_frame_bytes(
|
||||||
|
pixels: Iterable[RGB],
|
||||||
|
brightness: float = 1.0,
|
||||||
|
) -> bytes:
|
||||||
|
out = bytearray()
|
||||||
|
for r, g, b in pixels:
|
||||||
|
r, g, b = dim((r, g, b), brightness)
|
||||||
|
out.extend((r, g, b))
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def send_frame(
|
||||||
|
sock: socket.socket,
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
rgb: bytes,
|
||||||
|
panel_id: int | None,
|
||||||
|
) -> None:
|
||||||
|
payload = (bytes([panel_id & 0xFF]) + rgb) if panel_id is not None else rgb
|
||||||
|
sock.sendto(payload, (host, port))
|
||||||
|
|
||||||
|
|
||||||
|
def send_solid_to_targets(
|
||||||
|
sock: socket.socket,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PanelClient:
|
||||||
|
"""Send frames to one or more portal panel firmware targets."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
targets: PanelTarget | Sequence[PanelTarget] | str,
|
||||||
|
port: int = DEFAULT_PORT,
|
||||||
|
*,
|
||||||
|
pixel_count: int | None = None,
|
||||||
|
panel_id: int | None = None,
|
||||||
|
brightness: float = 0.35,
|
||||||
|
) -> None:
|
||||||
|
if isinstance(targets, PanelTarget):
|
||||||
|
self.targets = [targets]
|
||||||
|
elif isinstance(targets, str):
|
||||||
|
count = pixel_count or MATRIX_PIXELS
|
||||||
|
w = MATRIX_WIDTH
|
||||||
|
h = count // w if count % w == 0 else MATRIX_HEIGHT
|
||||||
|
self.targets = [PanelTarget(-1, targets, w, h)]
|
||||||
|
else:
|
||||||
|
self.targets = list(targets)
|
||||||
|
self.port = port
|
||||||
|
self.panel_id = panel_id
|
||||||
|
self.brightness = brightness
|
||||||
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_args(cls, args: argparse.Namespace) -> PanelClient:
|
||||||
|
return cls(
|
||||||
|
panel_targets_from_args(args),
|
||||||
|
args.port,
|
||||||
|
pixel_count=panel_count_from_args(args),
|
||||||
|
panel_id=args.panel_id,
|
||||||
|
brightness=args.brightness,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pixel_count(self) -> int:
|
||||||
|
if len(self.targets) != 1:
|
||||||
|
raise ValueError("pixel_count requires a single panel target")
|
||||||
|
return self.targets[0].pixels
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hosts(self) -> list[str]:
|
||||||
|
return [t.host for t in self.targets]
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._sock.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> PanelClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.clear()
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def send_bytes(self, rgb: bytes) -> None:
|
||||||
|
if len(self.targets) != 1:
|
||||||
|
raise ValueError("send_bytes requires a single panel target")
|
||||||
|
target = self.targets[0]
|
||||||
|
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)
|
||||||
|
|
||||||
|
def fill(self, color: RGB = (0, 0, 0)) -> None:
|
||||||
|
for target in self.targets:
|
||||||
|
send_frame(
|
||||||
|
self._sock,
|
||||||
|
target.host,
|
||||||
|
self.port,
|
||||||
|
solid_frame_bytes(color, target.pixels, self.brightness),
|
||||||
|
self.panel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_pixels(self, pixels: list[RGB]) -> None:
|
||||||
|
if len(self.targets) != 1:
|
||||||
|
raise ValueError("send_pixels requires a single panel target")
|
||||||
|
if len(pixels) != self.pixel_count:
|
||||||
|
raise ValueError(f"expected {self.pixel_count} pixels, got {len(pixels)}")
|
||||||
|
self.send_bytes(pixels_to_frame_bytes(pixels, self.brightness))
|
||||||
|
|
||||||
|
def set_indices(self, indices: Iterable[int], color: RGB) -> None:
|
||||||
|
pixels = [(0, 0, 0)] * self.pixel_count
|
||||||
|
for i in indices:
|
||||||
|
if 0 <= i < self.pixel_count:
|
||||||
|
pixels[i] = color
|
||||||
|
self.send_pixels(pixels)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.fill((0, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
def run_panel_playlist(
|
||||||
|
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,
|
||||||
|
) -> None:
|
||||||
|
matrices = _panel_matrices(targets, brightness)
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_panel_animation_loop(
|
||||||
|
targets: Sequence[PanelTarget],
|
||||||
|
port: int,
|
||||||
|
animation: str,
|
||||||
|
*,
|
||||||
|
fps: float | None = None,
|
||||||
|
brightness: float = 0.25,
|
||||||
|
panel_id: int | 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()
|
||||||
|
|
||||||
|
|
||||||
|
def animation_playlist(animation: str) -> list[str]:
|
||||||
|
names = list(ANIMATIONS.keys())
|
||||||
|
if animation == "all":
|
||||||
|
# Skip solid — 1 fps looks frozen on UDP panels; start with motion.
|
||||||
|
return [n for n in names if n != "solid"]
|
||||||
|
return [animation]
|
||||||
144
leds/strip.py
Normal file
144
leds/strip.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""Unified WS2812 strip API for Raspberry Pi 5, 4, and Zero."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
from leds.backends.pio import PioBackend
|
||||||
|
from leds.backends.spi import SpiBackend
|
||||||
|
from leds.backends.ws281x import Ws281xBackend
|
||||||
|
from leds.config import pi5_setup_hint
|
||||||
|
from leds.detect import board_family, board_model, pio_available, supports_ws281x
|
||||||
|
|
||||||
|
Color = Union[Tuple[int, int, int], Sequence[int]]
|
||||||
|
BackendName = Literal["auto", "pio", "spi", "ws281x"]
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: int) -> int:
|
||||||
|
return max(0, min(255, int(value)))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_color(color: Color) -> Tuple[int, int, int]:
|
||||||
|
if len(color) != 3:
|
||||||
|
raise ValueError("color must be (r, g, b)")
|
||||||
|
return _clamp(color[0]), _clamp(color[1]), _clamp(color[2])
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_brightness(color: Tuple[int, int, int], brightness: float) -> Tuple[int, int, int]:
|
||||||
|
scale = max(0.0, min(1.0, brightness))
|
||||||
|
return (
|
||||||
|
int(color[0] * scale),
|
||||||
|
int(color[1] * scale),
|
||||||
|
int(color[2] * scale),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_backend(name: BackendName) -> str:
|
||||||
|
if name != "auto":
|
||||||
|
if name == "ws281x" and not supports_ws281x():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{board_model()} does not support ws281x; use backend='pio' or 'spi'"
|
||||||
|
)
|
||||||
|
if name == "pio" and board_family() == "pi5" and not pio_available():
|
||||||
|
raise RuntimeError(pi5_setup_hint(10, 10))
|
||||||
|
return name
|
||||||
|
|
||||||
|
if board_family() == "pi5":
|
||||||
|
return "pio" if pio_available() else "spi"
|
||||||
|
return "ws281x"
|
||||||
|
|
||||||
|
|
||||||
|
class LedStrip:
|
||||||
|
"""
|
||||||
|
Drive a WS2812 / NeoPixel strip on Pi 5, 4, or Zero.
|
||||||
|
|
||||||
|
Default backends:
|
||||||
|
- Pi 5: ws2812-pio on GPIO 18/13 (/dev/leds0, /dev/leds1)
|
||||||
|
- Pi 4 / Zero: PWM on GPIO 18/13 via rpi_ws281x
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
count: int,
|
||||||
|
*,
|
||||||
|
backend: BackendName = "auto",
|
||||||
|
pin: int = 18,
|
||||||
|
channel: int = 0,
|
||||||
|
device: str = "/dev/leds0",
|
||||||
|
brightness: float = 1.0,
|
||||||
|
spi_bus: int = 0,
|
||||||
|
spi_device: int = 0,
|
||||||
|
) -> None:
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError("count must be at least 1")
|
||||||
|
|
||||||
|
self._count = count
|
||||||
|
self._brightness = max(0.0, min(1.0, brightness))
|
||||||
|
self._pixels: list[Tuple[int, int, int]] = [(0, 0, 0)] * count
|
||||||
|
|
||||||
|
resolved = _resolve_backend(backend)
|
||||||
|
if resolved == "pio":
|
||||||
|
self._backend = PioBackend(count, device=device)
|
||||||
|
elif resolved == "spi":
|
||||||
|
self._backend = SpiBackend(
|
||||||
|
count, spi_bus=spi_bus, spi_device=spi_device
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._backend = Ws281xBackend(count, pin=pin, channel=channel)
|
||||||
|
|
||||||
|
self._backend_name = resolved
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count(self) -> int:
|
||||||
|
return self._count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend(self) -> str:
|
||||||
|
return self._backend_name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> float:
|
||||||
|
return self._brightness
|
||||||
|
|
||||||
|
@brightness.setter
|
||||||
|
def brightness(self, value: float) -> None:
|
||||||
|
self._brightness = max(0.0, min(1.0, value))
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self._count
|
||||||
|
|
||||||
|
def __getitem__(self, index: int) -> Tuple[int, int, int]:
|
||||||
|
return self._pixels[index]
|
||||||
|
|
||||||
|
def __setitem__(self, index: int, color: Color) -> None:
|
||||||
|
self._pixels[index] = _normalize_color(color)
|
||||||
|
|
||||||
|
def fill(self, color: Color) -> None:
|
||||||
|
rgb = _normalize_color(color)
|
||||||
|
self._pixels = [rgb] * self._count
|
||||||
|
|
||||||
|
def set_pixel(self, index: int, color: Color) -> None:
|
||||||
|
self[index] = color
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
grb = bytearray(self._count * 3)
|
||||||
|
for i, (r, g, b) in enumerate(self._pixels):
|
||||||
|
r, g, b = _apply_brightness((r, g, b), self._brightness)
|
||||||
|
base = i * 3
|
||||||
|
grb[base] = g
|
||||||
|
grb[base + 1] = r
|
||||||
|
grb[base + 2] = b
|
||||||
|
self._backend.write_grb(bytes(grb))
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.fill((0, 0, 0))
|
||||||
|
self._backend.clear()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._backend.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> LedStrip:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
75
leds/strips.py
Normal file
75
leds/strips.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Control two WS2812 strips with board-appropriate defaults."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from leds.config import StripConfig, default_dual_strip_configs
|
||||||
|
from leds.strip import LedStrip
|
||||||
|
|
||||||
|
|
||||||
|
class LedStrips:
|
||||||
|
"""Drive two WS2812 strips. Use default configs or pass StripConfig per strip."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
count0: int,
|
||||||
|
count1: int,
|
||||||
|
*,
|
||||||
|
config0: StripConfig | None = None,
|
||||||
|
config1: StripConfig | None = None,
|
||||||
|
brightness: float = 1.0,
|
||||||
|
) -> None:
|
||||||
|
if config0 is None or config1 is None:
|
||||||
|
defaults = default_dual_strip_configs(
|
||||||
|
count0, count1, brightness=brightness
|
||||||
|
)
|
||||||
|
config0 = config0 or defaults[0]
|
||||||
|
config1 = config1 or defaults[1]
|
||||||
|
|
||||||
|
self._strips = (
|
||||||
|
_strip_from_config(config0),
|
||||||
|
_strip_from_config(config1),
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strip0(self) -> LedStrip:
|
||||||
|
return self._strips[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strip1(self) -> LedStrip:
|
||||||
|
return self._strips[1]
|
||||||
|
|
||||||
|
def __getitem__(self, index: int) -> LedStrip:
|
||||||
|
if index not in (0, 1):
|
||||||
|
raise IndexError("strip index must be 0 or 1")
|
||||||
|
return self._strips[index]
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
for strip in self._strips:
|
||||||
|
strip.show()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for strip in self._strips:
|
||||||
|
strip.clear()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
for strip in self._strips:
|
||||||
|
strip.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> LedStrips:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_exc) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_from_config(config: StripConfig) -> LedStrip:
|
||||||
|
return LedStrip(
|
||||||
|
config.count,
|
||||||
|
backend=config.backend,
|
||||||
|
pin=config.pin,
|
||||||
|
channel=config.channel,
|
||||||
|
device=config.device,
|
||||||
|
brightness=config.brightness,
|
||||||
|
spi_bus=config.spi_bus,
|
||||||
|
spi_device=config.spi_device,
|
||||||
|
)
|
||||||
124
leds/text.py
Normal file
124
leds/text.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""5×7 bitmap text for LED matrices."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from leds.array_config import MATRIX_HEIGHT, MATRIX_WIDTH, matrix_pixel_index
|
||||||
|
|
||||||
|
RGB = Tuple[int, int, int]
|
||||||
|
|
||||||
|
# Each letter: 7 rows, 5 LSBs = columns (bit 4 = left).
|
||||||
|
_FONT: dict[str, tuple[int, ...]] = {
|
||||||
|
" ": (0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00),
|
||||||
|
"A": (0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11),
|
||||||
|
"B": (0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E),
|
||||||
|
"C": (0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E),
|
||||||
|
"D": (0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E),
|
||||||
|
"E": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F),
|
||||||
|
"F": (0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10),
|
||||||
|
"G": (0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E),
|
||||||
|
"H": (0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11),
|
||||||
|
"I": (0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E),
|
||||||
|
"J": (0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C),
|
||||||
|
"K": (0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11),
|
||||||
|
"L": (0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F),
|
||||||
|
"M": (0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x11),
|
||||||
|
"N": (0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11),
|
||||||
|
"O": (0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E),
|
||||||
|
"P": (0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10),
|
||||||
|
"Q": (0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D),
|
||||||
|
"R": (0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11),
|
||||||
|
"S": (0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E),
|
||||||
|
"T": (0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04),
|
||||||
|
"U": (0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E),
|
||||||
|
"V": (0x11, 0x11, 0x11, 0x11, 0x0A, 0x0A, 0x04),
|
||||||
|
"W": (0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11),
|
||||||
|
"X": (0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11),
|
||||||
|
"Y": (0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04),
|
||||||
|
"Z": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F),
|
||||||
|
"0": (0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E),
|
||||||
|
"1": (0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E),
|
||||||
|
"2": (0x0E, 0x11, 0x01, 0x06, 0x08, 0x10, 0x1F),
|
||||||
|
"3": (0x1F, 0x02, 0x04, 0x06, 0x01, 0x11, 0x0E),
|
||||||
|
"4": (0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02),
|
||||||
|
"5": (0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E),
|
||||||
|
"6": (0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E),
|
||||||
|
"7": (0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08),
|
||||||
|
"8": (0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E),
|
||||||
|
"9": (0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C),
|
||||||
|
}
|
||||||
|
|
||||||
|
CHAR_WIDTH = 5
|
||||||
|
CHAR_HEIGHT = 7
|
||||||
|
CHAR_SPACING = 1
|
||||||
|
|
||||||
|
|
||||||
|
def text_width(text: str, spacing: int = CHAR_SPACING) -> int:
|
||||||
|
text = text.upper()
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
return len(text) * CHAR_WIDTH + (len(text) - 1) * spacing
|
||||||
|
|
||||||
|
|
||||||
|
def _set(surface, x: int, y: int, color: RGB) -> None:
|
||||||
|
if hasattr(surface, "width") and hasattr(surface, "height"):
|
||||||
|
if 0 <= x < surface.width and 0 <= y < surface.height:
|
||||||
|
surface[x, y] = color
|
||||||
|
else:
|
||||||
|
w = getattr(surface, "width", MATRIX_WIDTH)
|
||||||
|
idx = matrix_pixel_index(x, y, w)
|
||||||
|
if 0 <= idx < len(surface):
|
||||||
|
surface[idx] = color
|
||||||
|
|
||||||
|
|
||||||
|
def draw_char(
|
||||||
|
surface,
|
||||||
|
ch: str,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
color: RGB,
|
||||||
|
*,
|
||||||
|
scale: int = 1,
|
||||||
|
) -> None:
|
||||||
|
glyph = _FONT.get(ch.upper(), _FONT[" "])
|
||||||
|
for row, bits in enumerate(glyph):
|
||||||
|
for col in range(CHAR_WIDTH):
|
||||||
|
if bits & (1 << (CHAR_WIDTH - 1 - col)):
|
||||||
|
for sy in range(scale):
|
||||||
|
for sx in range(scale):
|
||||||
|
_set(surface, x + col * scale + sx, y + row * scale + sy, color)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_text(
|
||||||
|
surface,
|
||||||
|
text: str,
|
||||||
|
x: int,
|
||||||
|
y: int,
|
||||||
|
color: RGB,
|
||||||
|
*,
|
||||||
|
scale: int = 1,
|
||||||
|
spacing: int = CHAR_SPACING,
|
||||||
|
) -> None:
|
||||||
|
cursor = x
|
||||||
|
step = (CHAR_WIDTH + spacing) * scale
|
||||||
|
for ch in text.upper():
|
||||||
|
draw_char(surface, ch, cursor, y, color, scale=scale)
|
||||||
|
cursor += step
|
||||||
|
|
||||||
|
|
||||||
|
def draw_text_centered(
|
||||||
|
surface,
|
||||||
|
text: str,
|
||||||
|
color: RGB,
|
||||||
|
*,
|
||||||
|
scale: int = 1,
|
||||||
|
spacing: int = CHAR_SPACING,
|
||||||
|
) -> None:
|
||||||
|
w = getattr(surface, "width", MATRIX_WIDTH)
|
||||||
|
h = getattr(surface, "height", MATRIX_HEIGHT)
|
||||||
|
tw = text_width(text, spacing) * scale
|
||||||
|
th = CHAR_HEIGHT * scale
|
||||||
|
x = max(0, (w - tw) // 2)
|
||||||
|
y = max(0, (h - th) // 2)
|
||||||
|
draw_text(surface, text, x, y, color, scale=scale, spacing=spacing)
|
||||||
17
pyproject.toml
Normal file
17
pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "portal"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "WS2812 LED control for Raspberry Pi"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"spidev>=3.6",
|
||||||
|
"rpi-ws281x>=5.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["leds*"]
|
||||||
116
scripts/panel_deploy.sh
Executable file
116
scripts/panel_deploy.sh
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build and upload portal panel firmware over USB.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/panel_deploy.sh
|
||||||
|
# ./scripts/panel_deploy.sh --build-only
|
||||||
|
# ./scripts/panel_deploy.sh --upload-only
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --build-only Build, do not upload
|
||||||
|
# --upload-only Upload existing build, skip compile
|
||||||
|
# --panel-id N Pass -DPANEL_ID_BUILD=N to cmake
|
||||||
|
# --no-dhcp Pass -DPORTAL_USE_DHCP=OFF to cmake
|
||||||
|
# --num-leds N Pass -DNUM_LEDS_BUILD=N to cmake
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# PICO_SDK_PATH Path to pico-sdk (default: ~/pico/pico-sdk)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BUILD_DIR="${ROOT}/firmware/panel/build"
|
||||||
|
PICO_SDK_PATH="${PICO_SDK_PATH:-${HOME}/pico/pico-sdk}"
|
||||||
|
JOBS="$(nproc)"
|
||||||
|
|
||||||
|
DO_BUILD=1
|
||||||
|
DO_UPLOAD=1
|
||||||
|
CMAKE_EXTRA=()
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'
|
||||||
|
exit "${1:-0}"
|
||||||
|
}
|
||||||
|
|
||||||
|
while (($# > 0)); do
|
||||||
|
case "$1" in
|
||||||
|
--build-only)
|
||||||
|
DO_UPLOAD=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--upload-only)
|
||||||
|
DO_BUILD=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--panel-id)
|
||||||
|
[[ $# -ge 2 ]] || die "--panel-id requires a number"
|
||||||
|
CMAKE_EXTRA+=("-DPANEL_ID_BUILD=${2}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--no-dhcp)
|
||||||
|
CMAKE_EXTRA+=("-DPORTAL_USE_DHCP=OFF")
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--num-leds)
|
||||||
|
[[ $# -ge 2 ]] || die "--num-leds requires a number"
|
||||||
|
CMAKE_EXTRA+=("-DNUM_LEDS_BUILD=${2}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "unknown option: $1 (try --help)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
build_firmware() {
|
||||||
|
[[ -d "${PICO_SDK_PATH}" ]] || die "pico-sdk not found at ${PICO_SDK_PATH} (set PICO_SDK_PATH)"
|
||||||
|
command -v cmake >/dev/null || die "cmake is required"
|
||||||
|
command -v make >/dev/null || die "make is required"
|
||||||
|
|
||||||
|
export PICO_SDK_PATH
|
||||||
|
mkdir -p "${BUILD_DIR}"
|
||||||
|
|
||||||
|
echo "==> Building panel firmware in ${BUILD_DIR}"
|
||||||
|
if [[ ! -f "${BUILD_DIR}/CMakeCache.txt" ]]; then
|
||||||
|
cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
|
||||||
|
elif ((${#CMAKE_EXTRA[@]} > 0)); then
|
||||||
|
cmake -S "${ROOT}/firmware/panel" -B "${BUILD_DIR}" "${CMAKE_EXTRA[@]}"
|
||||||
|
fi
|
||||||
|
cmake --build "${BUILD_DIR}" -j "${JOBS}"
|
||||||
|
|
||||||
|
[[ -f "${BUILD_DIR}/panel.uf2" ]] || die "build did not produce panel.uf2"
|
||||||
|
echo "==> Build OK: ${BUILD_DIR}/panel.uf2"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_usb() {
|
||||||
|
command -v picotool >/dev/null || die "picotool not found (install from https://github.com/raspberrypi/picotool)"
|
||||||
|
if picotool version 2>&1 | grep -q 'without USB support'; then
|
||||||
|
die "picotool was built without USB support; rebuild with libusb-1.0-0-dev installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Uploading over USB"
|
||||||
|
if ! picotool load -x -f "${BUILD_DIR}/panel.uf2" 2>&1 | tee /tmp/picotool_load.log; then
|
||||||
|
die "picotool load failed"
|
||||||
|
fi
|
||||||
|
if ! grep -q 'Loading into Flash' /tmp/picotool_load.log; then
|
||||||
|
die "picotool did not flash (hold BOOTSEL, plug USB, retry)"
|
||||||
|
fi
|
||||||
|
echo "==> USB upload OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((DO_BUILD)); then
|
||||||
|
build_firmware
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((DO_UPLOAD)); then
|
||||||
|
[[ -f "${BUILD_DIR}/panel.uf2" ]] || die "panel.uf2 not found; run without --upload-only first"
|
||||||
|
upload_usb
|
||||||
|
fi
|
||||||
117
scripts/setup_ap.sh
Executable file
117
scripts/setup_ap.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Set up a WiFi access point on wlan0 and share internet from ethernet (eth0).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo ./scripts/setup_ap.sh
|
||||||
|
# sudo PORTAL_AP_SSID=mywifi PORTAL_AP_PASSWORD=secret ./scripts/setup_ap.sh
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# PORTAL_AP_SSID WiFi name (default: portal)
|
||||||
|
# PORTAL_AP_PASSWORD WPA password, 8+ chars (default: portal1234)
|
||||||
|
# PORTAL_AP_CON_NAME NetworkManager connection name (default: portal-ap)
|
||||||
|
# PORTAL_AP_IF AP interface (default: wlan0)
|
||||||
|
# PORTAL_WAN_IF Uplink interface (default: eth0)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSID="${PORTAL_AP_SSID:-portal}"
|
||||||
|
PASSWORD="${PORTAL_AP_PASSWORD:-portal1234}"
|
||||||
|
CON_NAME="${PORTAL_AP_CON_NAME:-portal-ap}"
|
||||||
|
AP_IF="${PORTAL_AP_IF:-wlan0}"
|
||||||
|
WAN_IF="${PORTAL_WAN_IF:-eth0}"
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
die "run as root: sudo $0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ((${#PASSWORD} < 8)); then
|
||||||
|
die "PORTAL_AP_PASSWORD must be at least 8 characters"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v nmcli >/dev/null; then
|
||||||
|
die "NetworkManager (nmcli) is required"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! systemctl is-active --quiet NetworkManager; then
|
||||||
|
die "NetworkManager is not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ip link show "${WAN_IF}" >/dev/null 2>&1; then
|
||||||
|
die "uplink interface ${WAN_IF} not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ip link show "${AP_IF}" >/dev/null 2>&1; then
|
||||||
|
die "AP interface ${AP_IF} not found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ip link show "${WAN_IF}" | grep -q "state UP"; then
|
||||||
|
echo "Warning: ${WAN_IF} is not UP — connect ethernet before clients can reach the internet." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NM shared AP mode uses hostapd on many Pi images.
|
||||||
|
if ! command -v hostapd >/dev/null; then
|
||||||
|
echo "Installing hostapd..."
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y hostapd
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Allow forwarding between AP clients and ethernet.
|
||||||
|
sysctl -w net.ipv4.ip_forward=1 >/dev/null
|
||||||
|
cat >/etc/sysctl.d/99-portal-ap.conf <<'EOF'
|
||||||
|
# portal AP: share ethernet to WiFi clients
|
||||||
|
net.ipv4.ip_forward=1
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rfkill unblock wifi >/dev/null 2>&1 || true
|
||||||
|
nmcli radio wifi on >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if nmcli -t -f NAME connection show | grep -Fxq "${CON_NAME}"; then
|
||||||
|
echo "Updating existing connection '${CON_NAME}'..."
|
||||||
|
nmcli connection delete "${CON_NAME}" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Creating access point '${SSID}' on ${AP_IF}..."
|
||||||
|
nmcli connection add type wifi ifname "${AP_IF}" con-name "${CON_NAME}" \
|
||||||
|
autoconnect yes \
|
||||||
|
ssid "${SSID}" \
|
||||||
|
802-11-wireless.mode ap \
|
||||||
|
802-11-wireless.band bg \
|
||||||
|
ipv4.method shared \
|
||||||
|
ipv6.method ignore \
|
||||||
|
wifi-sec.key-mgmt wpa-psk \
|
||||||
|
wifi-sec.psk "${PASSWORD}" >/dev/null
|
||||||
|
|
||||||
|
# Prefer ethernet for upstream internet when both are available.
|
||||||
|
nmcli connection modify "${CON_NAME}" ipv4.never-default no
|
||||||
|
if nmcli -t -f NAME connection show | grep -Fxq "Wired connection 1"; then
|
||||||
|
nmcli connection modify "Wired connection 1" connection.autoconnect-priority 100
|
||||||
|
fi
|
||||||
|
nmcli connection modify "${CON_NAME}" connection.autoconnect-priority 50
|
||||||
|
|
||||||
|
echo "Starting access point..."
|
||||||
|
nmcli connection up "${CON_NAME}"
|
||||||
|
|
||||||
|
AP_IP="$(nmcli -g IP4.ADDRESS device show "${AP_IF}" 2>/dev/null | cut -d/ -f1)"
|
||||||
|
WAN_IP="$(nmcli -g IP4.ADDRESS device show "${WAN_IF}" 2>/dev/null | cut -d/ -f1 || true)"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Access point is running.
|
||||||
|
|
||||||
|
SSID: ${SSID}
|
||||||
|
Password: ${PASSWORD}
|
||||||
|
AP IP: ${AP_IP:-unknown} (${AP_IF})
|
||||||
|
Uplink: ${WAN_IF} ${WAN_IP:+($WAN_IP)}
|
||||||
|
|
||||||
|
Clients receive DHCP from the Pi and internet is shared via NAT through ${WAN_IF}.
|
||||||
|
The AP starts automatically on boot.
|
||||||
|
|
||||||
|
Stop it with:
|
||||||
|
sudo ./scripts/stop_ap.sh
|
||||||
|
|
||||||
|
EOF
|
||||||
62
scripts/stop_ap.sh
Executable file
62
scripts/stop_ap.sh
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop the portal access point and return wlan0 to WiFi client mode.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CON_NAME="${PORTAL_AP_CON_NAME:-portal-ap}"
|
||||||
|
WLAN_IF="${PORTAL_AP_IF:-wlan0}"
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
echo "Error: run as root: sudo $0" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v nmcli >/dev/null; then
|
||||||
|
echo "Error: NetworkManager (nmcli) is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if nmcli -t -f NAME connection show | grep -Fxq "${CON_NAME}"; then
|
||||||
|
nmcli connection down "${CON_NAME}" 2>/dev/null || true
|
||||||
|
nmcli connection modify "${CON_NAME}" connection.autoconnect no
|
||||||
|
echo "Access point '${CON_NAME}' stopped (autoconnect disabled)."
|
||||||
|
else
|
||||||
|
echo "Connection '${CON_NAME}' not found."
|
||||||
|
fi
|
||||||
|
|
||||||
|
rfkill unblock wifi >/dev/null 2>&1 || true
|
||||||
|
nmcli radio wifi on >/dev/null 2>&1 || true
|
||||||
|
nmcli device disconnect "${WLAN_IF}" 2>/dev/null || true
|
||||||
|
nmcli device wifi rescan 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Saved client profiles, newest first (skip the AP connection).
|
||||||
|
mapfile -t CLIENT_CONS < <(
|
||||||
|
nmcli -t -f NAME,TYPE,TIMESTAMP connection show | awk -F: -v ap="${CON_NAME}" '
|
||||||
|
$2 == "802-11-wireless" && $1 != ap { print $3 "\t" $1 }
|
||||||
|
' | sort -rn | cut -f2
|
||||||
|
)
|
||||||
|
|
||||||
|
CONNECTED=0
|
||||||
|
for client_con in "${CLIENT_CONS[@]}"; do
|
||||||
|
key_mgmt="$(nmcli -g 802-11-wireless-security.key-mgmt connection show "${client_con}" 2>/dev/null || true)"
|
||||||
|
if [[ -z "${key_mgmt}" ]]; then
|
||||||
|
echo "Skipping '${client_con}': missing 802-11-wireless-security.key-mgmt."
|
||||||
|
echo " Fix: sudo nmcli connection delete ${client_con}"
|
||||||
|
echo " sudo nmcli device wifi connect <SSID> password <pass>"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if nmcli connection up "${client_con}" ifname "${WLAN_IF}" 2>/dev/null; then
|
||||||
|
echo "wlan0 connected as client: ${client_con}"
|
||||||
|
CONNECTED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((CONNECTED == 0)); then
|
||||||
|
echo "wlan0 is in client mode but no saved network connected."
|
||||||
|
echo "List networks: nmcli device wifi list"
|
||||||
|
echo "Connect: sudo nmcli device wifi connect <SSID> password <pass>"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Remove AP permanently: sudo nmcli connection delete ${CON_NAME}"
|
||||||
Reference in New Issue
Block a user