diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21c6461 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +firmware/panel/build/ +firmware/_deps/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ec004fd --- /dev/null +++ b/Makefile @@ -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) diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..5554374 --- /dev/null +++ b/Pipfile @@ -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" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..437e46c --- /dev/null +++ b/Pipfile.lock @@ -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": {} +} diff --git a/README.md b/README.md index 9d5af47..0864d62 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,66 @@ # 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 +``` diff --git a/adapter/code.py b/adapter/code.py index b8b2866..97d7ef2 100644 --- a/adapter/code.py +++ b/adapter/code.py @@ -12,7 +12,7 @@ SPI1_RX = board.GP12 SPI1_CSn = board.GP9 W5500_RSTn = board.GP13 -pixel_pin = board.GP28 +pixel_pin = board.GP27 num_pixels = 2 print("Wiznet5k SimpleServer Test (DHCP)") diff --git a/examples/animations.py b/examples/animations.py new file mode 100755 index 0000000..8343069 --- /dev/null +++ b/examples/animations.py @@ -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 ", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/fix_spi1_config.py b/examples/fix_spi1_config.py new file mode 100755 index 0000000..ef7a5e3 --- /dev/null +++ b/examples/fix_spi1_config.py @@ -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()) diff --git a/examples/led_demo.py b/examples/led_demo.py new file mode 100755 index 0000000..db40eb2 --- /dev/null +++ b/examples/led_demo.py @@ -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()) diff --git a/examples/matrix_demo.py b/examples/matrix_demo.py new file mode 100755 index 0000000..729dc97 --- /dev/null +++ b/examples/matrix_demo.py @@ -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 ", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/panel_animations.py b/examples/panel_animations.py new file mode 100755 index 0000000..19674d9 --- /dev/null +++ b/examples/panel_animations.py @@ -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()) diff --git a/examples/panel_color_test.py b/examples/panel_color_test.py new file mode 100755 index 0000000..16c5ce8 --- /dev/null +++ b/examples/panel_color_test.py @@ -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()) diff --git a/examples/panel_rgb_cycle.py b/examples/panel_rgb_cycle.py new file mode 100755 index 0000000..f9aa56e --- /dev/null +++ b/examples/panel_rgb_cycle.py @@ -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()) diff --git a/examples/panel_sync_test.py b/examples/panel_sync_test.py new file mode 100644 index 0000000..a572349 --- /dev/null +++ b/examples/panel_sync_test.py @@ -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= 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()) diff --git a/examples/panel_test.py b/examples/panel_test.py new file mode 100755 index 0000000..98fb113 --- /dev/null +++ b/examples/panel_test.py @@ -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()) diff --git a/examples/panel_text.py b/examples/panel_text.py new file mode 100755 index 0000000..4ad74e7 --- /dev/null +++ b/examples/panel_text.py @@ -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()) diff --git a/examples/panel_udp_send.py b/examples/panel_udp_send.py new file mode 100755 index 0000000..73aff50 --- /dev/null +++ b/examples/panel_udp_send.py @@ -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()) diff --git a/examples/setup_pi5_leds.py b/examples/setup_pi5_leds.py new file mode 100755 index 0000000..223e8e1 --- /dev/null +++ b/examples/setup_pi5_leds.py @@ -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()) diff --git a/firmware/panel/CMakeLists.txt b/firmware/panel/CMakeLists.txt new file mode 100644 index 0000000..53039e2 --- /dev/null +++ b/firmware/panel/CMakeLists.txt @@ -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) diff --git a/firmware/panel/Makefile b/firmware/panel/Makefile new file mode 100644 index 0000000..9cbb131 --- /dev/null +++ b/firmware/panel/Makefile @@ -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) diff --git a/firmware/panel/README.md b/firmware/panel/README.md new file mode 100644 index 0000000..f2a2ea9 --- /dev/null +++ b/firmware/panel/README.md @@ -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. diff --git a/firmware/panel/WS2812.cpp b/firmware/panel/WS2812.cpp new file mode 100644 index 0000000..5d64845 --- /dev/null +++ b/firmware/panel/WS2812.cpp @@ -0,0 +1,92 @@ +/* WS2812 driver — from https://github.com/ForsakenNGS/Pico_WS2812 (BSD-style) */ + +#include "WS2812.hpp" + +#include "WS2812.pio.h" + +#include + +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]); + } +} diff --git a/firmware/panel/WS2812.hpp b/firmware/panel/WS2812.hpp new file mode 100644 index 0000000..0f18b28 --- /dev/null +++ b/firmware/panel/WS2812.hpp @@ -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 diff --git a/firmware/panel/WS2812.pio b/firmware/panel/WS2812.pio new file mode 100644 index 0000000..61c7927 --- /dev/null +++ b/firmware/panel/WS2812.pio @@ -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); +} +%} diff --git a/firmware/panel/board_config.h b/firmware/panel/board_config.h new file mode 100644 index 0000000..724f133 --- /dev/null +++ b/firmware/panel/board_config.h @@ -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 diff --git a/firmware/panel/main.c b/firmware/panel/main.c new file mode 100644 index 0000000..f0f5caf --- /dev/null +++ b/firmware/panel/main.c @@ -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 +#include +#include + +#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); + } +} diff --git a/firmware/panel/pico_sdk_import.cmake b/firmware/panel/pico_sdk_import.cmake new file mode 100644 index 0000000..613e9aa --- /dev/null +++ b/firmware/panel/pico_sdk_import.cmake @@ -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}) diff --git a/firmware/panel/timer.c b/firmware/panel/timer.c new file mode 100644 index 0000000..d90d0af --- /dev/null +++ b/firmware/panel/timer.c @@ -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); +} diff --git a/firmware/panel/timer.h b/firmware/panel/timer.h new file mode 100644 index 0000000..590a84e --- /dev/null +++ b/firmware/panel/timer.h @@ -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 diff --git a/firmware/panel/wizchip_spi.c b/firmware/panel/wizchip_spi.c new file mode 100644 index 0000000..29bbf50 --- /dev/null +++ b/firmware/panel/wizchip_spi.c @@ -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 + +#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]); +} diff --git a/firmware/panel/wizchip_spi.h b/firmware/panel/wizchip_spi.h new file mode 100644 index 0000000..0112539 --- /dev/null +++ b/firmware/panel/wizchip_spi.h @@ -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 diff --git a/firmware/panel/ws2812_led.cpp b/firmware/panel/ws2812_led.cpp new file mode 100644 index 0000000..2bd4251 --- /dev/null +++ b/firmware/panel/ws2812_led.cpp @@ -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); +} diff --git a/firmware/panel/ws2812_led.h b/firmware/panel/ws2812_led.h new file mode 100644 index 0000000..3bc2db5 --- /dev/null +++ b/firmware/panel/ws2812_led.h @@ -0,0 +1,19 @@ +#ifndef WS2812_LED_H +#define WS2812_LED_H + +#include + +#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 diff --git a/leds/__init__.py b/leds/__init__.py new file mode 100644 index 0000000..928f6ad --- /dev/null +++ b/leds/__init__.py @@ -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", +] diff --git a/leds/animations.py b/leds/animations.py new file mode 100644 index 0000000..2d199d3 --- /dev/null +++ b/leds/animations.py @@ -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, +} diff --git a/leds/array_config.py b/leds/array_config.py new file mode 100644 index 0000000..81515e5 --- /dev/null +++ b/leds/array_config.py @@ -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 diff --git a/leds/backends/__init__.py b/leds/backends/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/leds/backends/__init__.py @@ -0,0 +1 @@ + diff --git a/leds/backends/pio.py b/leds/backends/pio.py new file mode 100644 index 0000000..87c06b3 --- /dev/null +++ b/leds/backends/pio.py @@ -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() diff --git a/leds/backends/spi.py b/leds/backends/spi.py new file mode 100644 index 0000000..cc69982 --- /dev/null +++ b/leds/backends/spi.py @@ -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() diff --git a/leds/backends/ws281x.py b/leds/backends/ws281x.py new file mode 100644 index 0000000..87e97ef --- /dev/null +++ b/leds/backends/ws281x.py @@ -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() diff --git a/leds/colors.py b/leds/colors.py new file mode 100644 index 0000000..aa19efe --- /dev/null +++ b/leds/colors.py @@ -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), + ) diff --git a/leds/config.py b/leds/config.py new file mode 100644 index 0000000..ae336c9 --- /dev/null +++ b/leds/config.py @@ -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." + ) diff --git a/leds/detect.py b/leds/detect.py new file mode 100644 index 0000000..5042b9d --- /dev/null +++ b/leds/detect.py @@ -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]." + ) diff --git a/leds/matrix.py b/leds/matrix.py new file mode 100644 index 0000000..93505cf --- /dev/null +++ b/leds/matrix.py @@ -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() diff --git a/leds/panel_udp.py b/leds/panel_udp.py new file mode 100644 index 0000000..0f29980 --- /dev/null +++ b/leds/panel_udp.py @@ -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] diff --git a/leds/strip.py b/leds/strip.py new file mode 100644 index 0000000..5465c7d --- /dev/null +++ b/leds/strip.py @@ -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() diff --git a/leds/strips.py b/leds/strips.py new file mode 100644 index 0000000..10dd6ae --- /dev/null +++ b/leds/strips.py @@ -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, + ) diff --git a/leds/text.py b/leds/text.py new file mode 100644 index 0000000..bac3421 --- /dev/null +++ b/leds/text.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6096d20 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/scripts/panel_deploy.sh b/scripts/panel_deploy.sh new file mode 100755 index 0000000..f599d84 --- /dev/null +++ b/scripts/panel_deploy.sh @@ -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 diff --git a/scripts/setup_ap.sh b/scripts/setup_ap.sh new file mode 100755 index 0000000..e9608d9 --- /dev/null +++ b/scripts/setup_ap.sh @@ -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 <&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 password " + 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 password " +fi + +echo "Remove AP permanently: sudo nmcli connection delete ${CON_NAME}"