diff --git a/docs/rfp-node-flashing.md b/docs/rfp-node-flashing.md new file mode 100644 index 00000000..082aa656 --- /dev/null +++ b/docs/rfp-node-flashing.md @@ -0,0 +1,503 @@ +# RFP Infinity Flashing + +This document covers flashing for both the Infinity master and the ESP32-S3 render nodes. + +## Targets + +- Master target: `rfp_esp32s3_wroom1_n16r8_master` +- Conservative master cold-boot target: `rfp_esp32s3_wroom1_n16r8_master_coldboot` +- Standard node target: `rfp_esp32s3_wroom1_n16r8_3x106` +- Conservative cold-boot test target: `rfp_esp32s3_wroom1_n16r8_3x106_coldboot` + +Use a cold-boot target when a board only starts reliably after pressing `RESET` +following a long power loss. + +## Clean-Flash Warning + +`erase_flash` removes the complete flash contents, including WLED's saved +`cfg.json`, Wi-Fi credentials, static IP settings, presets, and filesystem data. + +The RFP master and node targets now compile the show Wi-Fi as firmware defaults: + +- SSID: `RFPLicht` +- Password: configured in the RFP build flags + +After a full erase the board can therefore join Wi-Fi again, but any runtime-only +settings that were stored through the WLED UI must be re-applied unless they are +also encoded as firmware defaults. + +## Roles + +- Master: + - Usually `192.168.178.10` + - Runs the `/infinity` web UI + - Accepts DMX and web commands + - Sends Infinity Sync packets to the nodes + - Keeps one dummy WLED pixel on `GPIO21` so the regular WLED UI remains + valid + - Keeps the real WLED status pixel exclusively on `GPIO48` + - Repairs old master `cfg.json` LED-bus entries so `GPIO48` is not reused as + a normal LED output +- Nodes: + - Usually `192.168.178.11` to `192.168.178.16` + - Render the LED output locally + - Receive Infinity Sync from the master + +The flash procedure is similar for both roles, but the PlatformIO target and `firmware.bin` are different. + +## WLED Backup Mode + +The regular WLED UI is intentionally kept available as a fallback. + +Important behavior: + +- The master uses the WLED UI only for a dummy backup pixel on `GPIO21`; the + actual onboard status pixel remains WLED's normal status pixel on `GPIO48`. +- The master does not render the show LEDs directly. +- The nodes can still be controlled through their regular WLED UI. +- If Infinity Sync is enabled, the master sends scene state about every `100 ms`. +- While those packets arrive, the node UI may appear to ignore changes because + the next Infinity packet overwrites the local WLED state. + +Use regular WLED control as backup in one of these ways: + +1. Preferred: open `/infinity` on the master and use the mode button in the + top bar: + - `Show Mode: ON` means Infinity Sync is active. + - `WLED Backup: ON` means Infinity Sync is stopped and regular WLED control + can be used. + +2. Or stop Infinity on the master by API: + +```bash +curl -X POST http://192.168.178.10/json/infinity \ + -H 'Content-Type: application/json' \ + -d '{"enabled":false}' +``` + +3. Or disable Infinity on one node for local testing: + +```bash +curl -X POST http://192.168.178.11/json/infinity \ + -H 'Content-Type: application/json' \ + -d '{"enabled":false}' +``` + +Re-enable later: + +```bash +curl -X POST http://192.168.178.10/json/infinity \ + -H 'Content-Type: application/json' \ + -d '{"enabled":true}' +``` + +For hotspot testing, replace the IPs with the current addresses, for example +`10.42.0.213`. + +## Build: Master + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \ +NPM_CONFIG_CACHE=$PWD/.npm-cache \ +PLATFORMIO_CORE_DIR=$PWD/.piohome \ +PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \ +PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \ +PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \ +PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \ +.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_master +``` + +Master firmware output: + +```text +.pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin +``` + +Conservative master cold-boot build: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \ +NPM_CONFIG_CACHE=$PWD/.npm-cache \ +PLATFORMIO_CORE_DIR=$PWD/.piohome \ +PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \ +PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \ +PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \ +PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \ +.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_master_coldboot +``` + +Master cold-boot firmware output: + +```text +.pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/firmware.bin +``` + +## Build: Nodes + +Standard build: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \ +NPM_CONFIG_CACHE=$PWD/.npm-cache \ +PLATFORMIO_CORE_DIR=$PWD/.piohome \ +PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \ +PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \ +PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \ +PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \ +.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106 +``` + +Cold-boot test build: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +PATH=/home/jan/Documents/RFP/Finanz_App/node/current/bin:$PATH \ +NPM_CONFIG_CACHE=$PWD/.npm-cache \ +PLATFORMIO_CORE_DIR=$PWD/.piohome \ +PLATFORMIO_PACKAGES_DIR=$PWD/.piohome/packages \ +PLATFORMIO_PLATFORMS_DIR=$PWD/.piohome/platforms \ +PLATFORMIO_CACHE_DIR=$PWD/.piohome/.cache \ +PLATFORMIO_BUILD_CACHE_DIR=$PWD/.piohome/buildcache \ +.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106_coldboot +``` + +Node firmware outputs: + +```text +.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin +.pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/firmware.bin +``` + +## WLAN Flash: Master + +Flash the master by OTA: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_network_flash.py flash \ + --targets 192.168.178.10 \ + --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin +``` + +## WLAN Flash: Single Node + +Standard firmware to one node: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_network_flash.py flash \ + --targets 192.168.178.11 \ + --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin +``` + +## WLAN Flash: Group + +Flash all six nodes in order: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_network_flash.py flash \ + --targets 192.168.178.11,192.168.178.12,192.168.178.13,192.168.178.14,192.168.178.15,192.168.178.16 \ + --start-from 192.168.178.11 \ + --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin +``` + +Resume a failed OTA run from a specific node: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_network_flash.py flash \ + --targets 192.168.178.11,192.168.178.12,192.168.178.13,192.168.178.14,192.168.178.15,192.168.178.16 \ + --start-from 192.168.178.14 \ + --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin +``` + +## WLAN Flash: Full Installation + +Build standard OTA firmware locally, flash all six nodes sequentially, then flash +the master last: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_update_all_ota.py +``` + +Dry-run without building or flashing: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_update_all_ota.py --dry-run --no-build +``` + +Resume after an interrupted run: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +.venv/bin/python tools/rfp_update_all_ota.py --start-from 192.168.178.14 +``` + +Useful variants: + +```bash +.venv/bin/python tools/rfp_update_all_ota.py --nodes-only +.venv/bin/python tools/rfp_update_all_ota.py --master-only +.venv/bin/python tools/rfp_update_all_ota.py --no-build +.venv/bin/python tools/rfp_update_all_ota.py --subnet 192.168.178.0/24 +``` + +The full-update helper is only for standard OTA builds: + +- Nodes: `rfp_esp32s3_wroom1_n16r8_3x106` +- Master: `rfp_esp32s3_wroom1_n16r8_master` + +Cold-boot targets remain USB clean-flash targets because OTA does not rewrite +the bootloader/flash-mode layout. + +Notes: + +- OTA only works when the laptop and nodes are already in the same IP network. +- The OTA helper flashes sequentially, verifies reboot, and then continues to the next node. +- The cold-boot test target should be flashed by USB, not by OTA. +- Reason: + - it changes flash/boot related build settings (`flash_mode`, `memory_type`) + - it also uses a different release name for validation + - OTA only updates the application image, not the full USB-style flash layout +- If you try the cold-boot target by OTA, the usual symptom is exactly this: + - upload looks "uncertain" + - device stays reachable + - uptime keeps increasing + - reboot cannot be proven +- Reboot verification is now strict: + - it records firmware version and uptime before upload + - it waits for the node to disappear from the network + - it waits for the node to come back + - if no offline transition is seen, it requires a clear uptime reset before declaring success +- This avoids false positives where a node stays reachable and the script would previously print `OK` too early. + +## USB Flash: Master + +Recommended clean-flash helper: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo +./flash_master.sh +``` + +Check the serial port: + +```bash +ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null +``` + +Flash the master via USB: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ + --chip esp32s3 \ + --port /dev/ttyACM0 \ + --baud 460800 \ + --before no_reset \ + --after hard_reset \ + write_flash -z \ + --flash_mode qio \ + --flash_freq 80m \ + --flash_size 16MB \ + 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_master/bootloader.bin \ + 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_master/partitions.bin \ + 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin +``` + +Conservative master cold-boot firmware: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ + --chip esp32s3 \ + --port /dev/ttyACM0 \ + --baud 460800 \ + --before no_reset \ + --after hard_reset \ + write_flash -z \ + --flash_mode qio \ + --flash_freq 80m \ + --flash_size 16MB \ + 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/bootloader.bin \ + 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/partitions.bin \ + 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/firmware.bin +``` + +Use USB for the cold-boot master target. OTA is not sufficient for this test +because the fix changes bootloader/flash-mode related build settings. + +## USB Flash: Single Node + +Recommended clean-flash helper: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo +./flash_node.sh +``` + +Check the serial port: + +```bash +ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null +``` + +Standard firmware: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ + --chip esp32s3 \ + --port /dev/ttyACM0 \ + --baud 460800 \ + --before no_reset \ + --after hard_reset \ + write_flash -z \ + --flash_mode qio \ + --flash_freq 80m \ + --flash_size 16MB \ + 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/bootloader.bin \ + 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/partitions.bin \ + 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin +``` + +Cold-boot test firmware: + +```bash +cd /home/jan/Documents/RFP/WLED-MM/repo + +sudo .venv/bin/python .piohome/packages/tool-esptoolpy/esptool.py \ + --chip esp32s3 \ + --port /dev/ttyACM0 \ + --baud 460800 \ + --before no_reset \ + --after hard_reset \ + write_flash -z \ + --flash_mode dio \ + --flash_freq 80m \ + --flash_size 16MB \ + 0x0 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/bootloader.bin \ + 0x8000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/partitions.bin \ + 0xe000 .piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin \ + 0x10000 .pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/firmware.bin +``` + +If upload does not start immediately: + +1. Hold `BOOT` +2. Tap `RESET` +3. Release `BOOT` + +Use USB for the first flash of `rfp_esp32s3_wroom1_n16r8_3x106_coldboot`. +After that, if you go back to the normal node firmware, OTA is fine again with the standard node target. + +## USB Flash: Group + +USB flashing always happens physically one board after another unless several boards are connected at the same time. + +Recommended workflow: + +1. Build the desired target once. +2. Plug in node 1 and flash it. +3. Unplug node 1, plug in node 2, repeat. +4. Continue until node 6 is done. + +If the same serial path is reused each time, the single-node USB command above is the repeatable group-flash procedure. + +## Quick Difference: Master vs Node + +- Master: + - Target: `rfp_esp32s3_wroom1_n16r8_master` + - Typical IP: `192.168.178.10` + - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_master/firmware.bin` +- Node: + - Target: `rfp_esp32s3_wroom1_n16r8_3x106` + - Typical IPs: `192.168.178.11` to `192.168.178.16` + - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin` +- Cold-boot test node: + - Target: `rfp_esp32s3_wroom1_n16r8_3x106_coldboot` + - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_3x106_coldboot/firmware.bin` +- Cold-boot test master: + - Target: `rfp_esp32s3_wroom1_n16r8_master_coldboot` + - Binary: `.pio/build/rfp_esp32s3_wroom1_n16r8_master_coldboot/firmware.bin` + +## Recommended Order + +1. Test the affected board with its cold-boot target: + - master: `rfp_esp32s3_wroom1_n16r8_master_coldboot` + - node: `rfp_esp32s3_wroom1_n16r8_3x106_coldboot` +2. Remove power for at least 20 to 30 seconds +3. Verify whether it now boots without pressing `RESET` +4. If it works, roll the same target to the remaining nodes +5. If it still fails, inspect the hardware power-up path on `EN`, 3.3V rail, and any external loads + +## Hand-off für andere (teamfähig) + +Use this section when you want to provide the procedure to other people without hard-coding your local paths. + +### Quickstart Template + +```bash +export RFP_REPO="/path/to/WLED-MM/repo" +export NODE_BIN="/path/to/node/bin" # optional if node is already in PATH + +cd "$RFP_REPO" + +PATH="$NODE_BIN:$PATH" \ +NPM_CONFIG_CACHE="$RFP_REPO/.npm-cache" \ +PLATFORMIO_CORE_DIR="$RFP_REPO/.piohome" \ +PLATFORMIO_PACKAGES_DIR="$RFP_REPO/.piohome/packages" \ +PLATFORMIO_PLATFORMS_DIR="$RFP_REPO/.piohome/platforms" \ +PLATFORMIO_CACHE_DIR="$RFP_REPO/.piohome/.cache" \ +PLATFORMIO_BUILD_CACHE_DIR="$RFP_REPO/.piohome/buildcache" \ +.venv/bin/python -m platformio run -e rfp_esp32s3_wroom1_n16r8_3x106 +``` + +### OTA group-flash template + +```bash +cd "$RFP_REPO" + +.venv/bin/python tools/rfp_network_flash.py flash \ + --targets ,,,,, \ + --start-from \ + --firmware .pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin +``` + +### Minimal hand-off checklist + +1. Confirm all nodes are reachable in the same network. +2. Build once, then flash. +3. Validate reboot behavior on each node. +4. If one node fails, resume with `--start-from` from that node. +5. Document which target was used (`standard` vs `coldboot`). + +### Rollback / removal plan for shared instructions + +1. Revert this doc section in git: + - `git checkout -- docs/rfp-node-flashing.md` +2. Or remove only the hand-off section manually if the rest should stay. +3. If a deployment should be rolled back, flash the previous known-good firmware bin with the same OTA/USB commands. diff --git a/tools/infinity_visualizer_server.py b/tools/infinity_visualizer_server.py new file mode 100644 index 00000000..395696ee --- /dev/null +++ b/tools/infinity_visualizer_server.py @@ -0,0 +1,361 @@ +#!/usr/bin/env python3 +"""Local Infinity visualizer for the exact Global-2D layer. + +The browser does not reimplement WLED effects. This server proxies the master +state and renders only the Infinity Global-2D layer for the 6 x 3 x 106 layout. +""" + +from __future__ import annotations + +import argparse +import errno +import json +import math +import socket +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +NODE_COUNT = 6 +ROWS = 3 +LEDS_PER_PANEL = 106 +OUTPUT_LABELS = ["UART6", "UART5", "UART4"] +MODE_NAMES = ["Off", "Center Pulse", "Checkerd", "Arrow", "Scan", "Snake", "Wave Line"] +VARIANT_NAMES = ["Expand / Classic / Line", "Reverse / Diagonal / Bands", "Outline / Checkerd", "Outline Reverse"] +BLEND_NAMES = ["Replace", "Add", "Multiply Mask", "Palette Tint"] +DIRECTION_NAMES = ["Left -> Right", "Right -> Left", "Top -> Bottom", "Bottom -> Top", "Outward", "Inward", "Ping Pong"] +BPM_MIN = 20 +BPM_MAX = 240 + +HTML = r""" +Infinity Local Visualizer +
+

Infinity Local Visualizer

Exact Global-2D layer preview. WLED base effects are not simulated.
+
Starting...
+
""" + + +def clamp_byte(value: float) -> int: + return max(0, min(255, int(round(value)))) + + +def smoothstep(edge0: float, edge1: float, x: float) -> float: + if edge0 == edge1: + return 0.0 if x < edge0 else 1.0 + x = max(0.0, min(1.0, (x - edge0) / (edge1 - edge0))) + return x * x * (3.0 - 2.0 * x) + + +def speed_to_bpm(speed: int) -> int: + speed = max(0, min(255, int(speed))) + return round(BPM_MIN + speed * (BPM_MAX - BPM_MIN) / 255.0) + + +def spatial_phase(now_us: int, speed: int) -> float: + seconds = (now_us % 60_000_000) / 1_000_000.0 + cps = speed_to_bpm(speed) / 60.0 + return seconds * cps + + +def panel_led_position(led: int) -> tuple[float, float, int]: + if led < 25: + return (led + 0.5) / 25.0, 0.0, 0 + if led < 52: + return 1.0, (led - 25 + 0.5) / 27.0, 1 + if led < 79: + return 1.0 - ((led - 52 + 0.5) / 27.0), 1.0, 2 + return 0.0, 1.0 - ((led - 79 + 0.5) / 27.0), 3 + + +def center_pulse_amount(col: int, row: int, led: int, now_us: int, speed: int, variant: int) -> int: + distance = abs(row - 1.0) + abs(col - 2.5) + max_distance = 3.5 + span = max_distance + 1.0 + front = (spatial_phase(now_us, speed) * span) % span + if variant in (1, 3): + front = max_distance - front + amount = 1.0 - smoothstep(0.0, 0.70, abs(distance - front)) + value = clamp_byte(amount * 255.0) + if variant in (2, 3): + _, _, side = panel_led_position(led) + if row == 1 and col in (2, 3): + return value if side in (0, 2) else 0 + if col == 0: + return value if side == 3 else 0 + if col == 5: + return value if side == 1 else 0 + if row == 0: + return value if side == 0 else 0 + if row == 2: + return value if side == 2 else 0 + return value + + +def checker_amount(col: int, row: int, led: int, now_us: int, speed: int, variant: int) -> int: + parity = (row + col) & 1 + step = int(math.floor(spatial_phase(now_us, speed))) + if variant in (1, 2): + x, y, _ = panel_led_position(led) + slash = variant == 2 and (step & 1) + first = y <= (1.0 - x if slash else x) + return 255 if ((parity == 0) == first) else 0 + return 255 if ((parity + step) & 1) == 0 else 0 + + +def wave_line_amount(col: int, row: int, now_us: int, speed: int, direction: int) -> int: + triangle = [0, 1, 2, 1] + step = int(math.floor(spatial_phase(now_us, speed))) + if direction in (2, 3): + phase = step if direction == 2 else -step + target = round(triangle[(row - phase) % 4] * ((NODE_COUNT - 1) / 2.0) / 2.0) + return 255 if col == min(target, NODE_COUNT - 1) else 0 + phase = -step if direction == 1 else step + target = round(triangle[(col - phase) % 4] * ((ROWS - 1) / 2.0)) + return 255 if row == min(target, ROWS - 1) else 0 + + +def arrow_amount(col: int, row: int, now_us: int, speed: int, direction: int, size: int) -> int: + horizontal = direction not in (2, 3) + major_count = NODE_COUNT if horizontal else ROWS + minor_count = ROWS if horizontal else NODE_COUNT + major = col if horizontal else row + minor = row if horizontal else col + gap = max(1, 1 + size // 86) - 1 + span = 3 + gap + movement = int(math.floor(spatial_phase(now_us, speed))) + band = 0 if abs(minor - ((minor_count - 1) / 2.0)) <= 0.55 else 1 + orientation_right = direction in (0, 2, 4) + target = 1 if band == 0 else (0 if orientation_right else 2) + local = major - movement if orientation_right else major + movement + return 255 if major_count > 0 and (local % span) == target else 0 + + +def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int, angle: int, option: int, direction: int) -> int: + x, y, _ = panel_led_position(led) + vertical = direction in (2, 3) + radians = math.radians((angle + (90 if vertical else 0)) % 360) + vx, vy = math.cos(radians), math.sin(radians) + progress = (col + x) * vx + (row + y) * vy + min_progress, max_progress = -3.0, 8.0 + width = 0.15 + (size / 255.0) * (1.60 if option == 1 else 0.85) + travel = (max_progress - min_progress) + width + phase = (spatial_phase(now_us, speed) * travel) % travel + if direction == 6: + phase = (spatial_phase(now_us, speed) * travel) % (travel * 2.0) + if phase > travel: + phase = (travel * 2.0) - phase + elif direction in (1, 3): + phase = travel - phase + center = min_progress + phase + if option == 1: + period = width * 2.0 + 0.35 + d = abs(((progress - center + period * 64.0) % period) - period * 0.5) + return clamp_byte((1.0 - smoothstep(width * 0.45, width * 0.75, d)) * 255.0) + return clamp_byte((1.0 - smoothstep(width * 0.5, width * 0.5 + 0.55, abs(progress - center))) * 255.0) + + +def snake_amount(col: int, row: int, now_us: int, speed: int, size: int, seed: int) -> int: + path_len = NODE_COUNT * ROWS + panel_index = row * NODE_COUNT + (NODE_COUNT - 1 - col if row & 1 else col) + step = int(math.floor(spatial_phase(now_us, speed))) + head = (step + seed % path_len) % path_len + length = 3 + max(1, size // 64) + for offset in range(length): + if panel_index == (head + path_len - (offset % path_len)) % path_len: + return max(55, 255 - offset * 38) + apple = (seed * 17 + (step // path_len) * 11 + 7) % path_len + return 180 if panel_index == apple else 0 + + +def blend(primary: list[int], secondary: list[int], amount: int) -> list[int]: + return [clamp_byte(secondary[i] + (primary[i] - secondary[i]) * amount / 255.0) for i in range(3)] + + +def layer_amount(mode: int, col: int, row: int, led: int, now_us: int, spatial: dict[str, Any], scene: dict[str, Any]) -> int: + speed = int(scene.get("speed", 128)) + variant = int(spatial.get("variant", 0)) + direction = int(spatial.get("direction", 0)) + size = int(spatial.get("size", 64)) + if mode == 1: + return center_pulse_amount(col, row, led, now_us, speed, variant) + if mode == 2: + return checker_amount(col, row, led, now_us, speed, variant) + if mode == 3: + return arrow_amount(col, row, now_us, speed, direction, size) + if mode == 4: + return scan_amount(col, row, led, now_us, speed, size, int(spatial.get("angle", 0)), int(spatial.get("option", 0)), direction) + if mode == 5: + return snake_amount(col, row, now_us, speed, size, int(scene.get("seed", 1))) + if mode == 6: + return wave_line_amount(col, row, now_us, speed, direction) + return 0 + + +def render_frame(state: dict[str, Any]) -> dict[str, Any]: + scene = state.get("scene", {}) + spatial = scene.get("spatial", {}) or {} + mode = int(spatial.get("mode", 0)) + strength = int(spatial.get("strength", 180)) + primary = scene.get("primary", [255, 160, 80])[:3] + secondary = scene.get("secondary", [0, 32, 255])[:3] + now_us = int(time.monotonic() * 1_000_000) + int(scene.get("phase", 0)) * 1000 + panels: list[list[list[list[int]]]] = [] + for row in range(ROWS): + row_panels = [] + for col in range(NODE_COUNT): + leds = [] + for led in range(LEDS_PER_PANEL): + amount = layer_amount(mode, col, row, led, now_us, spatial, scene) + amount = clamp_byte(amount * strength / 255.0) + leds.append(blend(primary, secondary, amount) if amount else [0, 0, 0]) + row_panels.append(leds) + panels.append(row_panels) + return { + "scene": scene, + "node_ips": state.get("node_ips", []), + "panels": panels, + "mode_name": MODE_NAMES[mode] if 0 <= mode < len(MODE_NAMES) else "Unknown", + "variant_name": VARIANT_NAMES[int(spatial.get("variant", 0))] if int(spatial.get("variant", 0)) < len(VARIANT_NAMES) else "Unknown", + "blend_name": BLEND_NAMES[int(spatial.get("blend", 2))] if int(spatial.get("blend", 2)) < len(BLEND_NAMES) else "Unknown", + "direction_name": DIRECTION_NAMES[int(spatial.get("direction", 0))] if int(spatial.get("direction", 0)) < len(DIRECTION_NAMES) else "Unknown", + "note": "2D layer exact; WLED base not simulated", + } + + +class VisualizerServer(ThreadingHTTPServer): + allow_reuse_address = True + master: str + timeout_s: float + + +class Handler(BaseHTTPRequestHandler): + server: VisualizerServer + + def log_message(self, fmt: str, *args: Any) -> None: + message = fmt % args + if '"GET /api/' in message and (' 502 ' in message or ' 504 ' in message): + return + sys.stderr.write("%s - %s\n" % (self.log_date_time_string(), message)) + + def send_bytes(self, status: int, content_type: str, body: bytes) -> None: + try: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def do_GET(self) -> None: + if self.path == "/" or self.path.startswith("/?"): + self.send_bytes(200, "text/html; charset=utf-8", HTML.encode("utf-8")) + return + if self.path.startswith("/api/infinity"): + self.proxy_infinity(self.master_from_query()) + return + if self.path.startswith("/api/frame"): + self.proxy_frame(self.master_from_query()) + return + if self.path == "/health": + self.send_bytes(200, "application/json", b'{"ok":true}') + return + self.send_bytes(404, "text/plain; charset=utf-8", b"not found") + + def master_from_query(self) -> str: + values = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + return values.get("master", [self.server.master])[0].strip() or self.server.master + + def fetch_master_state(self, master: str) -> dict[str, Any]: + url = f"http://{master}/json/infinity" + with urllib.request.urlopen(url, timeout=self.server.timeout_s) as response: + body = response.read() + return json.loads(body.decode("utf-8")) + + def proxy_infinity(self, master: str) -> None: + try: + state = self.fetch_master_state(master) + except (urllib.error.URLError, socket.timeout, TimeoutError) as exc: + self.send_bytes(504, "application/json", json.dumps({"error":"master unreachable","detail":str(exc)}).encode("utf-8")) + return + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + self.send_bytes(502, "application/json", json.dumps({"error":f"invalid master JSON: {exc}"}).encode("utf-8")) + return + self.send_bytes(200, "application/json", json.dumps(state).encode("utf-8")) + + def proxy_frame(self, master: str) -> None: + try: + state = self.fetch_master_state(master) + frame = render_frame(state) + except (urllib.error.URLError, socket.timeout, TimeoutError) as exc: + self.send_bytes(504, "application/json", json.dumps({"error":"master unreachable","detail":str(exc)}).encode("utf-8")) + return + except Exception as exc: # keep the operator UI alive and explicit + self.send_bytes(502, "application/json", json.dumps({"error":f"frame render failed: {exc}"}).encode("utf-8")) + return + self.send_bytes(200, "application/json", json.dumps(frame, separators=(",", ":")).encode("utf-8")) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Serve a local Infinity Global-2D visualizer.") + parser.add_argument("--master", default="10.42.0.213", help="Infinity master IP or hostname") + parser.add_argument("--bind", default="127.0.0.1", help="Local bind address") + parser.add_argument("--port", type=int, default=8765, help="Local HTTP port") + parser.add_argument("--no-port-fallback", action="store_true", help="Fail instead of trying the next ports when busy") + parser.add_argument("--timeout", type=float, default=1.2, help="Master request timeout in seconds") + return parser.parse_args() + + +def bind_server(args: argparse.Namespace) -> VisualizerServer: + last_error: OSError | None = None + ports = [args.port] if args.no_port_fallback else range(args.port, args.port + 50) + for port in ports: + try: + server = VisualizerServer((args.bind, port), Handler) + if port != args.port: + print(f"Port {args.port} is busy, using {port} instead.") + return server + except OSError as exc: + last_error = exc + if exc.errno != errno.EADDRINUSE or args.no_port_fallback: + break + if last_error and last_error.errno == errno.EADDRINUSE: + raise SystemExit(f"Could not start visualizer: ports {args.port}-{args.port + 49} are busy.") + raise last_error or RuntimeError("Could not bind visualizer server") + + +def main() -> int: + args = parse_args() + server = bind_server(args) + server.master = args.master + server.timeout_s = args.timeout + host, port = server.server_address[:2] + print(f"Infinity visualizer: http://{host}:{port}/?master={args.master}") + print(f"Proxying master: http://{args.master}/json/infinity") + print("Rendering exact Infinity Global-2D layer; WLED base effects are not simulated.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/rfp_network_flash.py b/tools/rfp_network_flash.py new file mode 100755 index 00000000..368e13bf --- /dev/null +++ b/tools/rfp_network_flash.py @@ -0,0 +1,388 @@ +#!/usr/bin/env python3 +""" +Discover WLED devices in local networks and flash them sequentially via OTA. +""" + +from __future__ import annotations + +import argparse +import ipaddress +import os +import re +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +import requests + + +DEFAULT_OUTPUT_FILE = "tools/discovered_wled_hosts.txt" + + +@dataclass +class WledHost: + ip: str + name: str + version: str + arch: str + + +@dataclass +class WledInfo(WledHost): + uptime_s: int + + +def _run(cmd: list[str]) -> str: + proc = subprocess.run(cmd, check=True, capture_output=True, text=True) + return proc.stdout + + +def local_networks() -> list[ipaddress.IPv4Network]: + """ + Read active IPv4 interfaces from `ip` and return private subnets. + """ + out = _run(["ip", "-o", "-4", "addr", "show", "scope", "global"]) + nets: list[ipaddress.IPv4Network] = [] + seen: set[str] = set() + for line in out.splitlines(): + match = re.search(r"\binet\s+(\d+\.\d+\.\d+\.\d+/\d+)\b", line) + if not match: + continue + iface_cidr = match.group(1) + iface_ip = ipaddress.ip_interface(iface_cidr) + net = iface_ip.network + if not iface_ip.ip.is_private: + continue + if net.num_addresses > 2048: + # avoid accidentally scanning very large ranges by default + net = ipaddress.ip_network(f"{iface_ip.ip}/24", strict=False) + key = str(net) + if key in seen: + continue + seen.add(key) + nets.append(net) + return nets + + +def parse_networks(raw: Iterable[str] | None) -> list[ipaddress.IPv4Network]: + if raw: + nets: list[ipaddress.IPv4Network] = [] + for item in raw: + nets.append(ipaddress.ip_network(item, strict=False)) + return nets + return local_networks() + + +def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None: + url = f"http://{ip}/json/info" + try: + resp = requests.get(url, timeout=timeout_s) + if resp.status_code != 200: + return None + data = resp.json() + except (requests.RequestException, ValueError): + return None + + # WLED info endpoint typically includes "name", "ver", and "arch" + ver = str(data.get("ver", "")).strip() + name = str(data.get("name", "")).strip() + arch = str(data.get("arch", "")).strip() + if not ver: + return None + + if not name: + name = "WLED" + if not arch: + arch = "-" + + uptime_s = int(data.get("uptime", 0) or 0) + return WledInfo(ip=ip, name=name, version=ver, arch=arch, uptime_s=uptime_s) + + +def probe_wled(ip: str, timeout_s: float) -> WledHost | None: + info = probe_wled_info(ip, timeout_s) + if info is None: + return None + return WledHost(ip=info.ip, name=info.name, version=info.version, arch=info.arch) + + +def discover_hosts( + nets: list[ipaddress.IPv4Network], + timeout_s: float, + workers: int, +) -> list[WledHost]: + candidates: list[str] = [] + for net in nets: + for host in net.hosts(): + candidates.append(str(host)) + + found: list[WledHost] = [] + with ThreadPoolExecutor(max_workers=workers) as executor: + futures = [executor.submit(probe_wled, ip, timeout_s) for ip in candidates] + for fut in as_completed(futures): + result = fut.result() + if result is not None: + found.append(result) + + found.sort(key=lambda h: tuple(int(part) for part in h.ip.split("."))) + return found + + +def read_targets_file(path: Path) -> list[str]: + targets: list[str] = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + targets.append(line.split()[0]) + return targets + + +def write_discovery(path: Path, hosts: list[WledHost]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = ["# ip name version arch"] + lines += [f"{h.ip} {h.name} {h.version} {h.arch}" for h in hosts] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def wait_for_online(ip: str, timeout_s: float, interval_s: float) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if probe_wled(ip, timeout_s=1.2) is not None: + return True + time.sleep(interval_s) + return False + + +def wait_for_offline(ip: str, timeout_s: float, interval_s: float) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if probe_wled(ip, timeout_s=1.2) is None: + return True + time.sleep(interval_s) + return False + + +def wait_for_online_info(ip: str, timeout_s: float, interval_s: float) -> WledInfo | None: + deadline = time.time() + timeout_s + while time.time() < deadline: + info = probe_wled_info(ip, timeout_s=1.2) + if info is not None: + return info + time.sleep(interval_s) + return None + + +def reboot_confirmed(before: WledInfo | None, after: WledInfo, offline_seen: bool) -> tuple[bool, str]: + if offline_seen: + return True, "offline transition observed" + if before is None: + return False, "device was not profiled before upload, and no offline transition was observed" + if after.uptime_s + 5 < before.uptime_s: + return True, f"uptime reset from {before.uptime_s}s to {after.uptime_s}s" + return False, f"device stayed reachable and uptime did not reset ({before.uptime_s}s -> {after.uptime_s}s)" + + +def ota_flash(ip: str, firmware: Path, connect_timeout_s: float, read_timeout_s: float) -> tuple[str, str]: + url = f"http://{ip}/update" + try: + with firmware.open("rb") as fh: + resp = requests.post( + url, + files={"update": (firmware.name, fh, "application/octet-stream")}, + timeout=(connect_timeout_s, read_timeout_s), + ) + except requests.ReadTimeout: + # Common with WLED OTA: upload is accepted but HTTP response never arrives before reboot. + return "uncertain", "read timeout after upload" + except requests.ConnectionError: + # Some devices close the socket abruptly when rebooting after successful OTA. + return "uncertain", "connection dropped during/after upload" + except requests.RequestException as exc: + return "failed", f"request failed: {exc}" + + text = (resp.text or "").lower() + if resp.status_code >= 400: + return "failed", f"http {resp.status_code}" + if "fail" in text or "error" in text: + return "failed", "device reported update failure" + return "ok", "ok" + + +def print_hosts(hosts: list[WledHost]) -> None: + if not hosts: + print("No WLED devices found.") + return + print(f"Found {len(hosts)} WLED device(s):") + print(f"{'IP':<16} {'Name':<24} {'Version':<18} {'Arch'}") + print("-" * 80) + for h in hosts: + print(f"{h.ip:<16} {h.name[:24]:<24} {h.version[:18]:<18} {h.arch}") + + +def cmd_discover(args: argparse.Namespace) -> int: + nets = parse_networks(args.subnet) + if not nets: + print("No private IPv4 networks found. Pass --subnet explicitly.") + return 2 + + print("Scanning networks:", ", ".join(str(n) for n in nets)) + hosts = discover_hosts(nets=nets, timeout_s=args.timeout, workers=args.workers) + print_hosts(hosts) + + if args.output: + out = Path(args.output) + write_discovery(out, hosts) + print(f"Saved discovery list: {out}") + return 0 + + +def resolve_targets(args: argparse.Namespace) -> list[str]: + targets: list[str] = [] + + if args.targets: + targets.extend([t.strip() for t in args.targets.split(",") if t.strip()]) + + if args.targets_file: + targets.extend(read_targets_file(Path(args.targets_file))) + + if args.discover: + nets = parse_networks(args.subnet) + hosts = discover_hosts(nets=nets, timeout_s=args.timeout, workers=args.workers) + targets.extend([h.ip for h in hosts]) + + unique: list[str] = [] + seen: set[str] = set() + for t in targets: + if t in seen: + continue + seen.add(t) + unique.append(t) + return unique + + +def cmd_flash(args: argparse.Namespace) -> int: + firmware = Path(args.firmware) + if not firmware.exists(): + print(f"Firmware file not found: {firmware}") + return 2 + + targets = resolve_targets(args) + if not targets: + print("No targets selected. Use --targets, --targets-file, or --discover.") + return 2 + if args.start_from: + if args.start_from not in targets: + print(f"--start-from target not found in target set: {args.start_from}") + return 2 + start_idx = targets.index(args.start_from) + targets = targets[start_idx:] + + print(f"Flashing {len(targets)} device(s) sequentially with: {firmware}") + failures: list[str] = [] + + for idx, ip in enumerate(targets, start=1): + before = probe_wled_info(ip, timeout_s=args.timeout) + if before is not None: + print( + f"[{idx}/{len(targets)}] {ip}: current firmware {before.version}, " + f"uptime {before.uptime_s}s, name '{before.name}'" + ) + print(f"[{idx}/{len(targets)}] {ip}: uploading...") + status, msg = ota_flash( + ip=ip, + firmware=firmware, + connect_timeout_s=args.connect_timeout, + read_timeout_s=args.upload_timeout, + ) + if status == "failed": + print(f"[{idx}/{len(targets)}] {ip}: FAILED ({msg})") + failures.append(ip) + continue + + if status == "uncertain": + print(f"[{idx}/{len(targets)}] {ip}: upload response uncertain ({msg}), verifying via reboot check...") + else: + print(f"[{idx}/{len(targets)}] {ip}: uploaded, waiting {args.reboot_wait:.1f}s for reboot...") + time.sleep(args.reboot_wait) + + offline_seen = wait_for_offline(ip=ip, timeout_s=args.offline_timeout, interval_s=0.5) + if offline_seen: + print(f"[{idx}/{len(targets)}] {ip}: reboot detected, device went offline.") + else: + print(f"[{idx}/{len(targets)}] {ip}: warning, no offline transition observed. Checking uptime reset...") + + after = wait_for_online_info(ip=ip, timeout_s=args.online_timeout, interval_s=1.0) + if after is None: + print(f"[{idx}/{len(targets)}] {ip}: no online response after upload window.") + failures.append(ip) + continue + + reboot_ok, reason = reboot_confirmed(before=before, after=after, offline_seen=offline_seen) + if not reboot_ok: + print(f"[{idx}/{len(targets)}] {ip}: FAILED (could not prove reboot: {reason})") + failures.append(ip) + continue + + print( + f"[{idx}/{len(targets)}] {ip}: OK " + f"(now {after.version}, uptime {after.uptime_s}s, {reason})" + ) + + if failures: + print("\nFailed targets:") + for ip in failures: + print(f"- {ip}") + return 1 + + print("\nAll targets flashed successfully.") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Discover and OTA-flash WLED devices.") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_discover = sub.add_parser("discover", help="Scan network(s) for WLED devices.") + p_discover.add_argument("--subnet", action="append", help="Subnet CIDR, repeatable (example: 192.168.1.0/24).") + p_discover.add_argument("--timeout", type=float, default=0.8, help="HTTP probe timeout in seconds.") + p_discover.add_argument("--workers", type=int, default=128, help="Parallel probe workers.") + p_discover.add_argument("--output", default=DEFAULT_OUTPUT_FILE, help="Output file for discovered hosts.") + p_discover.set_defaults(func=cmd_discover) + + p_flash = sub.add_parser("flash", help="Flash firmware.bin to selected hosts sequentially.") + p_flash.add_argument("--firmware", required=True, help="Path to firmware.bin") + p_flash.add_argument("--targets", help="Comma-separated IP list") + p_flash.add_argument("--targets-file", help="Text file with one IP per line") + p_flash.add_argument("--discover", action="store_true", help="Discover targets before flashing") + p_flash.add_argument("--subnet", action="append", help="Subnet CIDR for discovery mode") + p_flash.add_argument("--start-from", help="Start flashing from this IP within the resolved target list") + p_flash.add_argument("--timeout", type=float, default=0.8, help="HTTP probe timeout in seconds") + p_flash.add_argument("--workers", type=int, default=128, help="Parallel probe workers for discovery") + p_flash.add_argument("--connect-timeout", type=float, default=5.0, help="HTTP connect timeout in seconds") + p_flash.add_argument("--upload-timeout", type=float, default=90.0, help="HTTP upload timeout in seconds") + p_flash.add_argument("--reboot-wait", type=float, default=10.0, help="Sleep after upload before online check") + p_flash.add_argument("--offline-timeout", type=float, default=20.0, help="How long to wait for the device to disappear during reboot") + p_flash.add_argument("--online-timeout", type=float, default=60.0, help="How long to wait for device to come back") + p_flash.set_defaults(func=cmd_flash) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + try: + return int(args.func(args)) + except KeyboardInterrupt: + print("\nAborted by user (Ctrl+C).") + print("Tip: rerun with --start-from to continue at a specific device.") + return 130 + + +if __name__ == "__main__": + os.environ.setdefault("PYTHONUNBUFFERED", "1") + raise SystemExit(main()) diff --git a/tools/rfp_update_all_ota.py b/tools/rfp_update_all_ota.py new file mode 100755 index 00000000..f16509b5 --- /dev/null +++ b/tools/rfp_update_all_ota.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Build and OTA-update the full RFP Infinity installation. + +This script intentionally delegates flashing to tools/rfp_network_flash.py so +the actual OTA path stays the existing WLED /update workflow. +""" + +from __future__ import annotations + +import argparse +import ipaddress +import os +import subprocess +import sys +from pathlib import Path + + +NODE_ENV = "rfp_esp32s3_wroom1_n16r8_3x106" +MASTER_ENV = "rfp_esp32s3_wroom1_n16r8_master" +NODE_FIRMWARE = Path(".pio/build") / NODE_ENV / "firmware.bin" +MASTER_FIRMWARE = Path(".pio/build") / MASTER_ENV / "firmware.bin" +DEFAULT_SUBNET = "192.168.178.0/24" +NODE_HOSTS = range(11, 17) +MASTER_HOST = 10 + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def python_executable(root: Path) -> str: + local_python = root / ".venv/bin/python" + return str(local_python) if local_python.exists() else sys.executable + + +def platformio_env(root: Path) -> dict[str, str]: + env = os.environ.copy() + node_bin = "/home/jan/Documents/RFP/Finanz_App/node/current/bin" + env["PATH"] = f"{node_bin}:{env.get('PATH', '')}" + env["NPM_CONFIG_CACHE"] = str(root / ".npm-cache") + env["PLATFORMIO_CORE_DIR"] = str(root / ".piohome") + env["PLATFORMIO_PACKAGES_DIR"] = str(root / ".piohome/packages") + env["PLATFORMIO_PLATFORMS_DIR"] = str(root / ".piohome/platforms") + env["PLATFORMIO_CACHE_DIR"] = str(root / ".piohome/.cache") + env["PLATFORMIO_BUILD_CACHE_DIR"] = str(root / ".piohome/buildcache") + return env + + +def targets_from_subnet(subnet: str) -> tuple[list[str], str]: + network = ipaddress.ip_network(subnet, strict=False) + if network.version != 4: + raise ValueError("Only IPv4 subnets are supported.") + octets = str(network.network_address).split(".") + if len(octets) != 4: + raise ValueError(f"Invalid IPv4 subnet: {subnet}") + prefix = ".".join(octets[:3]) + return [f"{prefix}.{host}" for host in NODE_HOSTS], f"{prefix}.{MASTER_HOST}" + + +def run(cmd: list[str], root: Path, env: dict[str, str], dry_run: bool) -> None: + print("+ " + " ".join(cmd)) + if dry_run: + return + subprocess.run(cmd, cwd=root, env=env, check=True) + + +def build(root: Path, env_name: str, env: dict[str, str], dry_run: bool) -> None: + run([python_executable(root), "-m", "platformio", "run", "-e", env_name], root, env, dry_run) + + +def flash(root: Path, targets: list[str], firmware: Path, env: dict[str, str], dry_run: bool) -> None: + if not targets: + return + firmware_path = root / firmware + if not dry_run and not firmware_path.exists(): + raise FileNotFoundError(f"Firmware does not exist: {firmware_path}") + run( + [ + python_executable(root), + "tools/rfp_network_flash.py", + "flash", + "--targets", + ",".join(targets), + "--firmware", + str(firmware), + ], + root, + env, + dry_run, + ) + + +def filtered_plan(nodes: list[str], master: str, args: argparse.Namespace) -> list[tuple[str, str]]: + plan: list[tuple[str, str]] = [] + if not args.master_only: + plan.extend((ip, "node") for ip in nodes) + if not args.nodes_only: + plan.append((master, "master")) + + if not args.start_from: + return plan + + for index, (ip, _role) in enumerate(plan): + if ip == args.start_from: + return plan[index:] + raise ValueError(f"--start-from {args.start_from} is not in the selected update plan.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build and OTA-update RFP Infinity nodes, then master.") + role = parser.add_mutually_exclusive_group() + role.add_argument("--nodes-only", action="store_true", help="Only build/flash nodes .11-.16") + role.add_argument("--master-only", action="store_true", help="Only build/flash master .10") + parser.add_argument("--start-from", help="Resume at this IP, for example 192.168.178.14 or 192.168.178.10") + parser.add_argument("--subnet", default=DEFAULT_SUBNET, help=f"Show subnet used to derive .10-.16 targets (default: {DEFAULT_SUBNET})") + parser.add_argument("--no-build", action="store_true", help="Skip PlatformIO builds and flash existing firmware.bin files") + parser.add_argument("--dry-run", action="store_true", help="Print build and flash steps without executing them") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + root = repo_root() + env = platformio_env(root) + nodes, master = targets_from_subnet(args.subnet) + plan = filtered_plan(nodes, master, args) + node_targets = [ip for ip, role in plan if role == "node"] + master_targets = [ip for ip, role in plan if role == "master"] + + if not plan: + print("Nothing selected.") + return 0 + + print("RFP Infinity OTA update plan:") + for ip, role in plan: + print(f"- {ip} ({role})") + print() + + if not args.no_build: + if node_targets: + print(f"Building node firmware: {NODE_ENV}") + build(root, NODE_ENV, env, args.dry_run) + if master_targets: + print(f"Building master firmware: {MASTER_ENV}") + build(root, MASTER_ENV, env, args.dry_run) + + if node_targets: + print("Flashing nodes sequentially...") + flash(root, node_targets, NODE_FIRMWARE, env, args.dry_run) + if master_targets: + print("Flashing master last...") + flash(root, master_targets, MASTER_FIRMWARE, env, args.dry_run) + + print("OTA update plan completed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wled00/infinity_sync.cpp b/wled00/infinity_sync.cpp new file mode 100644 index 00000000..960c65fd --- /dev/null +++ b/wled00/infinity_sync.cpp @@ -0,0 +1,1634 @@ +#include "wled.h" + +#ifdef WLED_ENABLE_INFINITY_CONTROLLER + +#ifdef ARDUINO_ARCH_ESP32 +#include "esp_timer.h" +#endif + +namespace { + +constexpr uint32_t INFINITY_MAGIC = 0x59534649UL; // "IFSY" little-endian +constexpr uint8_t INFINITY_VERSION = 1; +constexpr uint16_t INFINITY_DEFAULT_PORT = 21325; +constexpr uint8_t INFINITY_NODE_COUNT = 6; +constexpr uint8_t INFINITY_PANEL_COUNT = 3; +constexpr uint16_t INFINITY_LEDS_PER_PANEL = 106; +constexpr uint16_t INFINITY_LEDS_PER_NODE = INFINITY_PANEL_COUNT * INFINITY_LEDS_PER_PANEL; +constexpr uint32_t INFINITY_CLOCK_INTERVAL_MS = 250; +constexpr uint32_t INFINITY_SCENE_INTERVAL_MS = 100; +constexpr uint32_t INFINITY_STATUS_INTERVAL_MS = 1000; +constexpr uint32_t INFINITY_NODE_TIMEOUT_MS = 3000; +constexpr uint32_t INFINITY_APPLY_DELAY_US = 120000; +constexpr uint16_t INFINITY_DMX_FOOTPRINT = 32; +constexpr uint8_t INFINITY_SPATIAL_OFF = 0; +constexpr uint8_t INFINITY_SPATIAL_CENTER_PULSE = 1; +constexpr uint8_t INFINITY_SPATIAL_CHECKER = 2; +constexpr uint8_t INFINITY_SPATIAL_ARROW = 3; +constexpr uint8_t INFINITY_SPATIAL_SCAN = 4; +constexpr uint8_t INFINITY_SPATIAL_SNAKE = 5; +constexpr uint8_t INFINITY_SPATIAL_WAVE_LINE = 6; +constexpr uint8_t INFINITY_SPATIAL_BLEND_REPLACE = 0; +constexpr uint8_t INFINITY_SPATIAL_BLEND_ADD = 1; +constexpr uint8_t INFINITY_SPATIAL_BLEND_MULTIPLY_MASK = 2; +constexpr uint8_t INFINITY_SPATIAL_BLEND_PALETTE_TINT = 3; +constexpr uint8_t INFINITY_SPATIAL_VARIANT_EXPAND = 0; +constexpr uint8_t INFINITY_SPATIAL_VARIANT_REVERSE = 1; +constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE = 2; +constexpr uint8_t INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE = 3; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_LTR = 0; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_RTL = 1; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_TTB = 2; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_BTT = 3; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_OUTWARD = 4; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_INWARD = 5; +constexpr uint8_t INFINITY_SPATIAL_DIRECTION_PINGPONG = 6; +constexpr uint8_t INFINITY_SPATIAL_OPTION_LINE = 0; +constexpr uint8_t INFINITY_SPATIAL_OPTION_BANDS = 1; +constexpr uint8_t INFINITY_BPM_MIN = 20; +constexpr uint8_t INFINITY_BPM_MAX = 240; + +enum InfinityPacketType : uint8_t { + INFINITY_PACKET_CLOCK_SYNC = 1, + INFINITY_PACKET_SCENE_STATE = 2, + INFINITY_PACKET_BEAT_TRIGGER = 3, + INFINITY_PACKET_NODE_STATUS = 4, +}; + +struct __attribute__((packed)) InfinityPacketHeader { + uint32_t magic; + uint8_t version; + uint8_t type; + uint16_t payloadSize; + uint32_t sequence; + uint64_t masterTimeUs; +}; + +struct __attribute__((packed)) InfinityScenePayload { + uint8_t effectId; + uint8_t presetId; + uint8_t brightness; + uint8_t speed; + uint8_t intensity; + uint8_t palette; + uint8_t primary[3]; + uint8_t secondary[3]; + uint8_t tertiary[3]; + uint8_t groupMask; + uint8_t direction; + uint8_t flags; + uint16_t transitionMs; + uint32_t seed; + uint16_t phase; + uint64_t applyAtUs; + uint8_t rowDimmer[3]; + uint8_t strobe; + uint8_t safetyFade; + uint8_t custom[3]; + uint8_t spatialMode; + uint8_t spatialStrength; + uint8_t spatialBlend; + uint8_t spatialVariant; + uint8_t spatialDirection; + uint8_t spatialSize; + uint16_t spatialAngle; + uint8_t spatialOption; +}; + +constexpr uint16_t INFINITY_SCENE_PAYLOAD_LEGACY_SIZE = __builtin_offsetof(InfinityScenePayload, spatialDirection); + +struct __attribute__((packed)) InfinityStatusPayload { + char nodeId[12]; + uint32_t uptimeMs; + int32_t masterOffsetUs; + uint32_t lastSceneSequence; + uint32_t packetsReceived; + uint8_t flags; + uint8_t activeEffect; + uint8_t activePreset; + uint8_t reserved; +}; + +struct InfinityNodeStatus { + IPAddress ip; + char nodeId[12]; + uint32_t lastSeenMs; + uint32_t uptimeMs; + int32_t masterOffsetUs; + uint32_t lastSceneSequence; + uint32_t packetsReceived; + uint8_t flags; + uint8_t activeEffect; + uint8_t activePreset; +}; + +WiFiUDP infinityUdp; +bool infinityUdpStarted = false; +bool infinityEnabled = true; +uint16_t infinityPort = INFINITY_DEFAULT_PORT; +IPAddress infinityMasterIp(192, 168, 178, 10); +IPAddress infinityNodeIps[INFINITY_NODE_COUNT] = { + IPAddress(192, 168, 178, 11), + IPAddress(192, 168, 178, 12), + IPAddress(192, 168, 178, 13), + IPAddress(192, 168, 178, 14), + IPAddress(192, 168, 178, 15), + IPAddress(192, 168, 178, 16), +}; +char infinityConfiguredNodeId[12] = "auto"; +char infinityRuntimeNodeId[12] = "node-xx"; + +InfinityScenePayload infinityScene = { + 0, // effectId + 0, // presetId + 128, // brightness + 128, // speed + 128, // intensity + 0, // palette + {255, 160, 80}, + {0, 32, 255}, + {0, 0, 0}, + 0x07, + 0, + 1, + 750, + 1, + 0, + 0, + {255, 255, 255}, + 0, + 0, + {0, 0, 0}, + INFINITY_SPATIAL_OFF, + 180, + INFINITY_SPATIAL_BLEND_MULTIPLY_MASK, + INFINITY_SPATIAL_VARIANT_EXPAND, + INFINITY_SPATIAL_DIRECTION_LTR, + 64, + 0, + INFINITY_SPATIAL_OPTION_LINE, +}; +InfinityScenePayload pendingScene; +bool hasPendingScene = false; +uint32_t infinitySequence = 0; +uint32_t lastClockSentMs = 0; +uint32_t lastSceneSentMs = 0; +uint32_t lastStatusSentMs = 0; +uint32_t lastDmxBeatValue = 0; +uint32_t lastDmxSyncResetValue = 0; +uint32_t packetsReceived = 0; +uint32_t packetsSent = 0; +uint32_t lastSceneSequence = 0; +int64_t masterOffsetUs = 0; +uint8_t lastAppliedPreset = 0; +uint8_t lastAppliedEffect = 0; +InfinityNodeStatus nodeStatuses[INFINITY_NODE_COUNT] = {}; + +bool isMasterBuild() { +#ifdef WLED_INFINITY_MASTER + return true; +#else + return false; +#endif +} + +bool isNodeBuild() { +#ifdef WLED_INFINITY_NODE + return true; +#else + return false; +#endif +} + +uint64_t localTimeUs() { +#ifdef ARDUINO_ARCH_ESP32 + return static_cast(esp_timer_get_time()); +#else + return static_cast(micros()); +#endif +} + +uint64_t masterTimeUs() { + if (isMasterBuild()) return localTimeUs(); + return static_cast(static_cast(localTimeUs()) + masterOffsetUs); +} + +uint8_t clampMode(uint8_t mode) { + const uint8_t count = strip.getModeCount(); + if (count == 0) return 0; + return mode < count ? mode : mode % count; +} + +String ipToString(const IPAddress& ip) { + return String(ip[0]) + '.' + String(ip[1]) + '.' + String(ip[2]) + '.' + String(ip[3]); +} + +void copyNodeId(char* dest, size_t len, const char* src) { + if (!dest || len == 0) return; + if (!src || src[0] == 0) src = "auto"; + strlcpy(dest, src, len); +} + +void refreshRuntimeNodeId() { + if (strcmp(infinityConfiguredNodeId, "auto") != 0 && infinityConfiguredNodeId[0] != 0) { + copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), infinityConfiguredNodeId); + return; + } + + if (isMasterBuild()) { + copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "master"); + return; + } + + IPAddress ip = Network.localIP(); + if (ip[0] == 192 && ip[1] == 168 && ip[2] == 178 && ip[3] >= 11 && ip[3] <= 16) { + snprintf(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "node-%02u", unsigned(ip[3] - 10)); + } else { + copyNodeId(infinityRuntimeNodeId, sizeof(infinityRuntimeNodeId), "node-xx"); + } +} + +uint16_t dmxTransitionMs(uint8_t value) { + return static_cast(value) * 40U; +} + +void hsvToRgb(uint8_t hue, uint8_t sat, uint8_t value, uint8_t rgb[3]) { + byte temp[3] = {0, 0, 0}; + colorHStoRGB(static_cast(hue) * 257U, sat, temp); + rgb[0] = (static_cast(temp[0]) * value) / 255U; + rgb[1] = (static_cast(temp[1]) * value) / 255U; + rgb[2] = (static_cast(temp[2]) * value) / 255U; +} + +uint32_t rgbToColor(const uint8_t rgb[3]) { + return RGBW32(rgb[0], rgb[1], rgb[2], 0); +} + +uint8_t scaleByte(uint8_t value, uint8_t scale) { + return (static_cast(value) * scale) / 255U; +} + +uint32_t scaleColor(uint32_t color, uint8_t scale) { + return RGBW32(scaleByte(R(color), scale), scaleByte(G(color), scale), scaleByte(B(color), scale), scaleByte(W(color), scale)); +} + +uint32_t addColorCapped(uint32_t base, uint32_t add) { + return RGBW32(qadd8(R(base), R(add)), qadd8(G(base), G(add)), qadd8(B(base), B(add)), qadd8(W(base), W(add))); +} + +uint32_t applySpatialBlend(uint32_t base, uint32_t layer, uint8_t amount, uint8_t strength, uint8_t blendMode) { + const uint8_t mask = scaleByte(amount, strength); + const uint32_t scaledLayer = scaleColor(layer, mask); + switch (blendMode) { + case INFINITY_SPATIAL_BLEND_REPLACE: + return color_blend(base, layer, mask); + case INFINITY_SPATIAL_BLEND_ADD: + return addColorCapped(base, scaledLayer); + case INFINITY_SPATIAL_BLEND_PALETTE_TINT: + return color_blend(scaleColor(base, mask), layer, mask); + case INFINITY_SPATIAL_BLEND_MULTIPLY_MASK: + default: + return scaleColor(base, mask); + } +} + +bool parseNodeColumn(uint8_t& column) { + if (strncmp(infinityRuntimeNodeId, "node-", 5) == 0) { + const uint8_t n = atoi(infinityRuntimeNodeId + 5); + if (n >= 1 && n <= INFINITY_NODE_COUNT) { + column = n - 1; + return true; + } + } + IPAddress ip = Network.localIP(); + if (ip[0] == 192 && ip[1] == 168 && ip[2] == 178 && ip[3] >= 11 && ip[3] <= 16) { + column = ip[3] - 11; + return true; + } + column = 0; + return false; +} + +float smoothstepf(float edge0, float edge1, float x) { + if (edge0 == edge1) return x < edge0 ? 0.0f : 1.0f; + x = (x - edge0) / (edge1 - edge0); + if (x < 0.0f) x = 0.0f; + if (x > 1.0f) x = 1.0f; + return x * x * (3.0f - 2.0f * x); +} + +uint8_t floatToAmount(float value) { + if (value <= 0.0f) return 0; + if (value >= 1.0f) return 255; + return static_cast(value * 255.0f + 0.5f); +} + +uint8_t speedToBpm(uint8_t speed) { + constexpr uint16_t range = INFINITY_BPM_MAX - INFINITY_BPM_MIN; + return INFINITY_BPM_MIN + ((static_cast(speed) * range + 127U) / 255U); +} + +float spatialPhase(uint64_t timeUs, uint8_t speed) { + const float seconds = static_cast(timeUs % 60000000ULL) / 1000000.0f; + const float cyclesPerSecond = static_cast(speedToBpm(speed)) / 60.0f; + return seconds * cyclesPerSecond; +} + +void panelLedPosition(uint16_t led, float& x, float& y, uint8_t& side) { + if (led < 25) { + side = 0; // top, left -> right + x = (led + 0.5f) / 25.0f; + y = 0.0f; + return; + } + if (led < 52) { + side = 1; // right, top -> bottom + x = 1.0f; + y = (led - 25 + 0.5f) / 27.0f; + return; + } + if (led < 79) { + side = 2; // bottom, right -> left + x = 1.0f - ((led - 52 + 0.5f) / 27.0f); + y = 1.0f; + return; + } + side = 3; // left, bottom -> top + x = 0.0f; + y = 1.0f - ((led - 79 + 0.5f) / 27.0f); +} + +uint8_t centerPulseAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t variant) { + constexpr float centerRow = 1.0f; + constexpr float centerCol = 2.5f; + constexpr float maxDistance = 3.5f; + const float distance = fabsf(static_cast(row) - centerRow) + fabsf(static_cast(column) - centerCol); + const float span = maxDistance + 1.0f; + float front = fmodf(spatialPhase(timeUs, speed) * span, span); + if (variant == INFINITY_SPATIAL_VARIANT_REVERSE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) front = maxDistance - front; + const float delta = fabsf(distance - front); + return floatToAmount(1.0f - smoothstepf(0.0f, 0.70f, delta)); +} + +uint8_t centerPulseOutlineAmount(uint8_t column, uint8_t row, uint16_t led, uint8_t panelAmount, uint8_t variant) { + float x = 0.0f, y = 0.0f; + uint8_t side = 0; + panelLedPosition(led, x, y, side); + if (variant == INFINITY_SPATIAL_VARIANT_OUTLINE || variant == INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) { + if (row == 1 && (column == 2 || column == 3)) { + return (side == 0 || side == 2) ? panelAmount : 0; + } + if (column == 0) return side == 3 ? panelAmount : 0; + if (column == 5) return side == 1 ? panelAmount : 0; + if (row == 0) return side == 0 ? panelAmount : 0; + if (row == 2) return side == 2 ? panelAmount : 0; + return panelAmount; + } + return panelAmount; +} + +uint8_t checkerdAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t variant, uint8_t speed) { + const uint8_t parity = (row + column) & 0x01; + const uint16_t step = static_cast(floorf(spatialPhase(timeUs, speed))); + if (variant == 1 || variant == 2) { + float x = 0.0f, y = 0.0f; + uint8_t side = 0; + panelLedPosition(led, x, y, side); + const bool slash = variant == 2 && (step & 0x01); + const bool first = slash ? (y <= 1.0f - x) : (y <= x); + return ((parity == 0) == first) ? 255 : 0; + } + return (((parity + step) & 0x01) == 0) ? 255 : 0; +} + +uint8_t waveLineAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed) { + static const uint8_t triangle[4] = {0, 1, 2, 1}; + const int32_t step = static_cast(floorf(spatialPhase(timeUs, speed))); + if (direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT) { + const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_TTB ? step : -step; + const uint8_t target = static_cast(roundf(triangle[((static_cast(row) - phase) % 4 + 4) % 4] * ((INFINITY_NODE_COUNT - 1) / 2.0f) / 2.0f)); + return column == min(target, INFINITY_NODE_COUNT - 1) ? 255 : 0; + } + const int32_t phase = direction == INFINITY_SPATIAL_DIRECTION_RTL ? -step : step; + const uint8_t target = static_cast(roundf(triangle[((static_cast(column) - phase) % 4 + 4) % 4] * ((INFINITY_PANEL_COUNT - 1) / 2.0f))); + return row == min(target, INFINITY_PANEL_COUNT - 1) ? 255 : 0; +} + +uint8_t arrowAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t direction, uint8_t speed, uint8_t size) { + const bool horizontal = !(direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT); + const uint8_t majorCount = horizontal ? INFINITY_NODE_COUNT : INFINITY_PANEL_COUNT; + const uint8_t minorCount = horizontal ? INFINITY_PANEL_COUNT : INFINITY_NODE_COUNT; + const uint8_t major = horizontal ? column : row; + const uint8_t minor = horizontal ? row : column; + const uint8_t gap = max(1, 1 + (size / 86)) - 1; + const uint8_t span = 3 + gap; + const int32_t movement = static_cast(floorf(spatialPhase(timeUs, speed))); + const float middleMinor = (static_cast(minorCount) - 1.0f) / 2.0f; + const uint8_t band = fabsf(static_cast(minor) - middleMinor) <= 0.55f ? 0 : 1; + const bool orientationRight = direction == INFINITY_SPATIAL_DIRECTION_LTR || direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_OUTWARD; + const uint8_t target = orientationRight ? (band == 0 ? 1 : 0) : (band == 0 ? 1 : 2); + const int32_t local = orientationRight + ? (static_cast(major) - movement) + : (static_cast(major) + movement); + return majorCount > 0 && ((local % span + span) % span) == target ? 255 : 0; +} + +void scanVector(uint16_t angle, float& vx, float& vy) { + const float radians = static_cast(angle) * 0.01745329252f; + vx = cosf(radians); + vy = sinf(radians); +} + +uint8_t scanAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs, uint8_t speed, uint8_t size, uint16_t angle, uint8_t option, uint8_t direction) { + float x = 0.0f, y = 0.0f; + uint8_t side = 0; + panelLedPosition(led, x, y, side); + float vx = 0.0f, vy = 0.0f; + const bool vertical = direction == INFINITY_SPATIAL_DIRECTION_TTB || direction == INFINITY_SPATIAL_DIRECTION_BTT; + scanVector((angle + (vertical ? 90 : 0)) % 360, vx, vy); + const float progress = (static_cast(column) + x) * vx + (static_cast(row) + y) * vy; + const float minProgress = -3.0f; + const float maxProgress = 8.0f; + const float width = 0.15f + (static_cast(size) / 255.0f) * (option == INFINITY_SPATIAL_OPTION_BANDS ? 1.60f : 0.85f); + const float travel = (maxProgress - minProgress) + width; + float phase = fmodf(spatialPhase(timeUs, speed) * travel, travel); + if (direction == INFINITY_SPATIAL_DIRECTION_PINGPONG) { + phase = fmodf(spatialPhase(timeUs, speed) * travel, travel * 2.0f); + if (phase > travel) phase = (travel * 2.0f) - phase; + } else if (direction == INFINITY_SPATIAL_DIRECTION_RTL || direction == INFINITY_SPATIAL_DIRECTION_BTT) { + phase = travel - phase; + } + const float center = minProgress + phase; + if (option == INFINITY_SPATIAL_OPTION_BANDS) { + const float period = width * 2.0f + 0.35f; + const float d = fabsf(fmodf(progress - center + period * 64.0f, period) - period * 0.5f); + return floatToAmount(1.0f - smoothstepf(width * 0.45f, width * 0.75f, d)); + } + return floatToAmount(1.0f - smoothstepf(width * 0.5f, width * 0.5f + 0.55f, fabsf(progress - center))); +} + +uint8_t snakeAmount(uint8_t column, uint8_t row, uint64_t timeUs, uint8_t speed, uint8_t size) { + const uint8_t pathLen = INFINITY_NODE_COUNT * INFINITY_PANEL_COUNT; + const uint8_t panelIndex = (row & 0x01) ? (row * INFINITY_NODE_COUNT + (INFINITY_NODE_COUNT - 1 - column)) : (row * INFINITY_NODE_COUNT + column); + const uint32_t step = static_cast(floorf(spatialPhase(timeUs, speed))); + const uint8_t head = (step + (infinityScene.seed % pathLen)) % pathLen; + const uint8_t length = 3 + max(1, size / 64); + for (uint8_t i = 0; i < length; i++) { + if (panelIndex == (head + pathLen - (i % pathLen)) % pathLen) return 255 - min(200, i * 38); + } + const uint8_t apple = (static_cast(infinityScene.seed * 17U + (step / pathLen) * 11U + 7U) % pathLen); + return panelIndex == apple ? 180 : 0; +} + +uint8_t spatialAmount(uint8_t column, uint8_t row, uint16_t led, uint64_t timeUs) { + switch (infinityScene.spatialMode) { + case INFINITY_SPATIAL_CENTER_PULSE: + return centerPulseOutlineAmount(column, row, led, centerPulseAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialVariant), infinityScene.spatialVariant); + case INFINITY_SPATIAL_CHECKER: + return checkerdAmount(column, row, led, timeUs, infinityScene.spatialVariant, infinityScene.speed); + case INFINITY_SPATIAL_ARROW: + return arrowAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed, infinityScene.spatialSize); + case INFINITY_SPATIAL_SCAN: + return scanAmount(column, row, led, timeUs, infinityScene.speed, infinityScene.spatialSize, infinityScene.spatialAngle, infinityScene.spatialOption, infinityScene.spatialDirection); + case INFINITY_SPATIAL_SNAKE: + return snakeAmount(column, row, timeUs, infinityScene.speed, infinityScene.spatialSize); + case INFINITY_SPATIAL_WAVE_LINE: + return waveLineAmount(column, row, timeUs, infinityScene.spatialDirection, infinityScene.speed); + default: + return 0; + } +} + +uint32_t spatialLayerColor(Segment& seg, uint16_t led, uint8_t amount) { + if (infinityScene.palette > 0) { + seg.setCurrentPalette(); + const uint16_t idx = (static_cast(led) * 255U) / (INFINITY_LEDS_PER_PANEL - 1U); + return seg.color_from_palette(idx + ((masterTimeUs() / 20000ULL) & 0xFF), false, true, 0, amount); + } + return color_blend(rgbToColor(infinityScene.secondary), rgbToColor(infinityScene.primary), amount); +} + +void setSegmentName(Segment& seg, const char* name) { + if (seg.name && strcmp(seg.name, name) == 0) return; + if (seg.name) { + delete[] seg.name; + seg.name = nullptr; + } + const size_t len = strlen(name); + seg.name = new(std::nothrow) char[len + 1]; + if (seg.name) strlcpy(seg.name, name, len + 1); +} + +void markSceneChanged(uint32_t delayUs = INFINITY_APPLY_DELAY_US) { + infinityScene.applyAtUs = masterTimeUs() + delayUs; + infinitySequence++; + lastSceneSentMs = 0; +} + +void updateStripTimebase(uint16_t phase) { + const int64_t remoteMs = static_cast(masterTimeUs() / 1000ULL) + phase; + strip.timebase = static_cast(remoteMs - static_cast(millis())); +} + +void applySceneToStrip(const InfinityScenePayload& scene) { + updateStripTimebase(scene.phase); + + const bool outputEnabled = (scene.flags & 0x01) != 0; + bri = outputEnabled ? scene.brightness : 0; + strip.setBrightness(scaledBri(bri), true); + transitionDelayTemp = scene.transitionMs; + + if (scene.presetId > 0 && scene.presetId != lastAppliedPreset) { + applyPreset(scene.presetId, CALL_MODE_NO_NOTIFY); + lastAppliedPreset = scene.presetId; + } + + const uint8_t effect = clampMode(scene.effectId); + const uint32_t colors[3] = { + rgbToColor(scene.primary), + rgbToColor(scene.secondary), + rgbToColor(scene.tertiary), + }; + const uint8_t segmentCount = strip.getSegmentsNum(); + + for (uint8_t i = 0; i < segmentCount; i++) { + Segment& seg = strip.getSegment(i); + if (!seg.isActive()) continue; + const uint8_t rowBit = (i < 3) ? (1U << i) : 0x07; + const bool rowEnabled = (scene.groupMask == 0) || ((scene.groupMask & rowBit) != 0); + const uint8_t rowDimmer = (i < 3) ? scene.rowDimmer[i] : 255; + seg.setOpacity(rowEnabled ? rowDimmer : 0); + const bool effectChanged = seg.mode != effect; + seg.setMode(effect, effectChanged, false); + seg.speed = scene.speed; + seg.intensity = scene.intensity; + seg.setPalette(scene.palette); + seg.setColor(0, colors[0]); + seg.setColor(1, colors[1]); + seg.setColor(2, colors[2]); + seg.setOption(SEG_OPTION_REVERSED, (scene.direction & 0x01) != 0); + seg.custom1 = scene.custom[0]; + seg.custom2 = scene.custom[1]; + seg.custom3 = scene.custom[2]; + } + + lastAppliedEffect = effect; + stateUpdated(CALL_MODE_NO_NOTIFY); +} + +void maybeApplyPendingScene() { + if (!hasPendingScene) return; + if (pendingScene.applyAtUs != 0 && masterTimeUs() + 2000ULL < pendingScene.applyAtUs) return; + infinityScene = pendingScene; + hasPendingScene = false; + applySceneToStrip(infinityScene); +} + +bool sendPacket(const IPAddress& target, InfinityPacketType type, const void* payload, uint16_t payloadSize) { + if (!infinityUdpStarted || !infinityEnabled) return false; + + InfinityPacketHeader header; + header.magic = INFINITY_MAGIC; + header.version = INFINITY_VERSION; + header.type = static_cast(type); + header.payloadSize = payloadSize; + header.sequence = ++infinitySequence; + header.masterTimeUs = masterTimeUs(); + + if (infinityUdp.beginPacket(target, infinityPort) == 0) return false; + infinityUdp.write(reinterpret_cast(&header), sizeof(header)); + if (payload != nullptr && payloadSize > 0) { + infinityUdp.write(reinterpret_cast(payload), payloadSize); + } + const bool sent = infinityUdp.endPacket() != 0; + if (sent) packetsSent++; + return sent; +} + +void sendToAllNodes(InfinityPacketType type, const void* payload, uint16_t payloadSize) { + for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { + sendPacket(infinityNodeIps[i], type, payload, payloadSize); + } +} + +void sendClockSync() { + sendToAllNodes(INFINITY_PACKET_CLOCK_SYNC, nullptr, 0); +} + +void sendSceneState() { + sendToAllNodes(INFINITY_PACKET_SCENE_STATE, &infinityScene, sizeof(infinityScene)); +} + +void sendBeatTrigger() { + sendToAllNodes(INFINITY_PACKET_BEAT_TRIGGER, &infinityScene, sizeof(infinityScene)); +} + +void sendNodeStatus() { + InfinityStatusPayload status = {}; + refreshRuntimeNodeId(); + strlcpy(status.nodeId, infinityRuntimeNodeId, sizeof(status.nodeId)); + status.uptimeMs = millis(); + status.masterOffsetUs = static_cast(masterOffsetUs); + status.lastSceneSequence = lastSceneSequence; + status.packetsReceived = packetsReceived; + status.flags = infinityEnabled ? 0x01 : 0x00; + status.activeEffect = lastAppliedEffect; + status.activePreset = lastAppliedPreset; + sendPacket(infinityMasterIp, INFINITY_PACKET_NODE_STATUS, &status, sizeof(status)); +} + +void storeNodeStatus(const IPAddress& remote, const InfinityStatusPayload& payload) { + uint8_t slot = INFINITY_NODE_COUNT; + for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { + if (nodeStatuses[i].ip == remote || strncmp(nodeStatuses[i].nodeId, payload.nodeId, sizeof(nodeStatuses[i].nodeId)) == 0) { + slot = i; + break; + } + } + if (slot == INFINITY_NODE_COUNT) { + for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { + if (nodeStatuses[i].lastSeenMs == 0) { + slot = i; + break; + } + } + } + if (slot == INFINITY_NODE_COUNT) return; + + nodeStatuses[slot].ip = remote; + strlcpy(nodeStatuses[slot].nodeId, payload.nodeId, sizeof(nodeStatuses[slot].nodeId)); + nodeStatuses[slot].lastSeenMs = millis(); + nodeStatuses[slot].uptimeMs = payload.uptimeMs; + nodeStatuses[slot].masterOffsetUs = payload.masterOffsetUs; + nodeStatuses[slot].lastSceneSequence = payload.lastSceneSequence; + nodeStatuses[slot].packetsReceived = payload.packetsReceived; + nodeStatuses[slot].flags = payload.flags; + nodeStatuses[slot].activeEffect = payload.activeEffect; + nodeStatuses[slot].activePreset = payload.activePreset; +} + +void handleIncomingPacket(const IPAddress& remote, const uint8_t* data, uint16_t len) { + if (len < sizeof(InfinityPacketHeader)) return; + const InfinityPacketHeader* header = reinterpret_cast(data); + if (header->magic != INFINITY_MAGIC || header->version != INFINITY_VERSION) return; + if (sizeof(InfinityPacketHeader) + header->payloadSize > len) return; + + const uint8_t* payload = data + sizeof(InfinityPacketHeader); + packetsReceived++; + + if (isNodeBuild() && (header->type == INFINITY_PACKET_CLOCK_SYNC || header->type == INFINITY_PACKET_SCENE_STATE || header->type == INFINITY_PACKET_BEAT_TRIGGER)) { + masterOffsetUs = static_cast(header->masterTimeUs) - static_cast(localTimeUs()); + } + + switch (header->type) { + case INFINITY_PACKET_CLOCK_SYNC: + updateStripTimebase(infinityScene.phase); + break; + + case INFINITY_PACKET_SCENE_STATE: + if (isNodeBuild() && header->payloadSize >= INFINITY_SCENE_PAYLOAD_LEGACY_SIZE && header->payloadSize <= sizeof(InfinityScenePayload)) { + pendingScene = infinityScene; + memcpy(&pendingScene, payload, header->payloadSize); + hasPendingScene = true; + lastSceneSequence = header->sequence; + } + break; + + case INFINITY_PACKET_BEAT_TRIGGER: + if (isNodeBuild()) { + strip.timebase = -static_cast(millis()); + for (uint8_t i = 0; i < strip.getSegmentsNum(); i++) strip.getSegment(i).markForReset(); + } + break; + + case INFINITY_PACKET_NODE_STATUS: + if (isMasterBuild() && header->payloadSize == sizeof(InfinityStatusPayload)) { + InfinityStatusPayload status; + memcpy(&status, payload, sizeof(status)); + storeNodeStatus(remote, status); + } + break; + + default: + break; + } +} + +void receivePackets() { + if (!infinityUdpStarted || !infinityEnabled) return; + for (uint8_t i = 0; i < 6; i++) { + const int packetSize = infinityUdp.parsePacket(); + if (packetSize <= 0) return; + if (packetSize > 256) { + while (infinityUdp.available()) infinityUdp.read(); + continue; + } + uint8_t buffer[256]; + const int len = infinityUdp.read(buffer, sizeof(buffer)); + if (len > 0) handleIncomingPacket(infinityUdp.remoteIP(), buffer, static_cast(len)); + } +} + +uint8_t jsonU8(JsonVariant value, uint8_t current) { + if (value.isNull()) return current; + int v = value.as(); + if (v < 0) v = 0; + if (v > 255) v = 255; + return static_cast(v); +} + +uint16_t jsonU16(JsonVariant value, uint16_t current) { + if (value.isNull()) return current; + long v = value.as(); + if (v < 0) v = 0; + if (v > 65535) v = 65535; + return static_cast(v); +} + +uint32_t jsonU32(JsonVariant value, uint32_t current) { + if (value.isNull()) return current; + long v = value.as(); + if (v < 0) v = 0; + return static_cast(v); +} + +void readRgb(JsonVariant value, uint8_t rgb[3]) { + if (!value.is()) return; + JsonArray arr = value.as(); + for (uint8_t i = 0; i < 3 && i < arr.size(); i++) rgb[i] = jsonU8(arr[i], rgb[i]); +} + +void writeRgb(JsonArray arr, const uint8_t rgb[3]) { + arr.add(rgb[0]); + arr.add(rgb[1]); + arr.add(rgb[2]); +} + +void serializeNodeIps(JsonArray arr) { + for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) arr.add(ipToString(infinityNodeIps[i])); +} + +void deserializeNodeIps(JsonVariant value) { + if (!value.is()) return; + JsonArray arr = value.as(); + for (uint8_t i = 0; i < INFINITY_NODE_COUNT && i < arr.size(); i++) { + if (!arr[i].is()) continue; + IPAddress parsed; + if (parsed.fromString(arr[i].as())) infinityNodeIps[i] = parsed; + } +} + +void serializeScene(JsonObject scene) { + scene["effect"] = infinityScene.effectId; + scene["preset"] = infinityScene.presetId; + scene["brightness"] = infinityScene.brightness; + scene["speed"] = infinityScene.speed; + scene["intensity"] = infinityScene.intensity; + scene["palette"] = infinityScene.palette; + writeRgb(scene.createNestedArray("primary"), infinityScene.primary); + writeRgb(scene.createNestedArray("secondary"), infinityScene.secondary); + writeRgb(scene.createNestedArray("tertiary"), infinityScene.tertiary); + scene["group_mask"] = infinityScene.groupMask; + scene["direction"] = infinityScene.direction; + scene["flags"] = infinityScene.flags; + scene["transition_ms"] = infinityScene.transitionMs; + scene["seed"] = infinityScene.seed; + scene["phase"] = infinityScene.phase; + scene["apply_at_us"] = static_cast(infinityScene.applyAtUs & 0xFFFFFFFFUL); + JsonArray rows = scene.createNestedArray("row_dimmer"); + rows.add(infinityScene.rowDimmer[0]); + rows.add(infinityScene.rowDimmer[1]); + rows.add(infinityScene.rowDimmer[2]); + scene["strobe"] = infinityScene.strobe; + scene["safety_fade"] = infinityScene.safetyFade; + JsonObject spatial = scene.createNestedObject("spatial"); + spatial["mode"] = infinityScene.spatialMode; + spatial["strength"] = infinityScene.spatialStrength; + spatial["blend"] = infinityScene.spatialBlend; + spatial["variant"] = infinityScene.spatialVariant; + spatial["direction"] = infinityScene.spatialDirection; + spatial["size"] = infinityScene.spatialSize; + spatial["angle"] = infinityScene.spatialAngle; + spatial["option"] = infinityScene.spatialOption; +} + +void deserializeScene(JsonObject scene) { + if (scene.isNull()) return; + infinityScene.effectId = jsonU8(scene["effect"], infinityScene.effectId); + infinityScene.presetId = jsonU8(scene["preset"], infinityScene.presetId); + infinityScene.brightness = jsonU8(scene["brightness"], infinityScene.brightness); + infinityScene.speed = jsonU8(scene["speed"], infinityScene.speed); + infinityScene.intensity = jsonU8(scene["intensity"], infinityScene.intensity); + infinityScene.palette = jsonU8(scene["palette"], infinityScene.palette); + readRgb(scene["primary"], infinityScene.primary); + readRgb(scene["secondary"], infinityScene.secondary); + readRgb(scene["tertiary"], infinityScene.tertiary); + infinityScene.groupMask = jsonU8(scene["group_mask"], infinityScene.groupMask) & 0x07; + if (infinityScene.groupMask == 0) infinityScene.groupMask = 0x07; + infinityScene.direction = jsonU8(scene["direction"], infinityScene.direction); + infinityScene.flags = jsonU8(scene["flags"], infinityScene.flags); + infinityScene.transitionMs = jsonU16(scene["transition_ms"], infinityScene.transitionMs); + infinityScene.seed = jsonU32(scene["seed"], infinityScene.seed); + infinityScene.phase = jsonU16(scene["phase"], infinityScene.phase); + if (scene["row_dimmer"].is()) { + JsonArray rows = scene["row_dimmer"].as(); + for (uint8_t i = 0; i < 3 && i < rows.size(); i++) infinityScene.rowDimmer[i] = jsonU8(rows[i], infinityScene.rowDimmer[i]); + } + infinityScene.strobe = jsonU8(scene["strobe"], infinityScene.strobe); + infinityScene.safetyFade = jsonU8(scene["safety_fade"], infinityScene.safetyFade); + if (scene["spatial"].is()) { + JsonObject spatial = scene["spatial"].as(); + infinityScene.spatialMode = jsonU8(spatial["mode"], infinityScene.spatialMode); + infinityScene.spatialStrength = jsonU8(spatial["strength"], infinityScene.spatialStrength); + infinityScene.spatialBlend = jsonU8(spatial["blend"], infinityScene.spatialBlend); + infinityScene.spatialVariant = jsonU8(spatial["variant"], infinityScene.spatialVariant); + infinityScene.spatialDirection = jsonU8(spatial["direction"], infinityScene.spatialDirection); + infinityScene.spatialSize = jsonU8(spatial["size"], infinityScene.spatialSize); + infinityScene.spatialAngle = jsonU16(spatial["angle"], infinityScene.spatialAngle); + infinityScene.spatialOption = jsonU8(spatial["option"], infinityScene.spatialOption); + } else { + infinityScene.spatialMode = jsonU8(scene["spatial_mode"], infinityScene.spatialMode); + infinityScene.spatialStrength = jsonU8(scene["spatial_strength"], infinityScene.spatialStrength); + infinityScene.spatialBlend = jsonU8(scene["spatial_blend"], infinityScene.spatialBlend); + infinityScene.spatialVariant = jsonU8(scene["spatial_variant"], infinityScene.spatialVariant); + infinityScene.spatialDirection = jsonU8(scene["spatial_direction"], infinityScene.spatialDirection); + infinityScene.spatialSize = jsonU8(scene["spatial_size"], infinityScene.spatialSize); + infinityScene.spatialAngle = jsonU16(scene["spatial_angle"], infinityScene.spatialAngle); + infinityScene.spatialOption = jsonU8(scene["spatial_option"], infinityScene.spatialOption); + } + if (infinityScene.spatialMode > INFINITY_SPATIAL_WAVE_LINE) infinityScene.spatialMode = INFINITY_SPATIAL_OFF; + if (infinityScene.spatialBlend > INFINITY_SPATIAL_BLEND_PALETTE_TINT) infinityScene.spatialBlend = INFINITY_SPATIAL_BLEND_MULTIPLY_MASK; + if (infinityScene.spatialVariant > INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE) infinityScene.spatialVariant = INFINITY_SPATIAL_VARIANT_EXPAND; + if (infinityScene.spatialDirection > INFINITY_SPATIAL_DIRECTION_PINGPONG) infinityScene.spatialDirection = INFINITY_SPATIAL_DIRECTION_LTR; + if (infinityScene.spatialAngle >= 360) infinityScene.spatialAngle %= 360; + if (infinityScene.spatialOption > INFINITY_SPATIAL_OPTION_BANDS) infinityScene.spatialOption = INFINITY_SPATIAL_OPTION_LINE; + markSceneChanged(); +} + +} // namespace + +void infinityInit() { + refreshRuntimeNodeId(); + if (infinityScene.applyAtUs == 0) infinityScene.applyAtUs = masterTimeUs() + INFINITY_APPLY_DELAY_US; +} + +void infinityNetworkBegin() { + if (!infinityEnabled || infinityPort == 0) return; + if (infinityUdpStarted) infinityUdp.stop(); + infinityUdpStarted = infinityUdp.begin(infinityPort); + refreshRuntimeNodeId(); + USER_PRINTF("Infinity Sync %s on UDP %u as %s\n", infinityUdpStarted ? "started" : "failed", infinityPort, infinityRuntimeNodeId); +} + +void infinityLoop() { + refreshRuntimeNodeId(); + receivePackets(); + maybeApplyPendingScene(); + + const uint32_t now = millis(); + if (isMasterBuild()) { + if (now - lastClockSentMs >= INFINITY_CLOCK_INTERVAL_MS) { + lastClockSentMs = now; + sendClockSync(); + } + if (now - lastSceneSentMs >= INFINITY_SCENE_INTERVAL_MS) { + lastSceneSentMs = now; + sendSceneState(); + } + } else if (isNodeBuild()) { + if (now - lastStatusSentMs >= INFINITY_STATUS_INTERVAL_MS) { + lastStatusSentMs = now; + sendNodeStatus(); + } + } +} + +void infinityPostStripInit() { + if (!isNodeBuild()) return; + if (busses.getNumBusses() < 3 || strip.getLengthTotal() != INFINITY_LEDS_PER_NODE) return; + + bool expectedBusses = true; + for (uint8_t i = 0; i < 3; i++) { + Bus* bus = busses.getBus(i); + if (bus == nullptr || bus->getLength() != INFINITY_LEDS_PER_PANEL || bus->getStart() != i * INFINITY_LEDS_PER_PANEL) { + expectedBusses = false; + break; + } + } + if (!expectedBusses) return; + + if (esp32SemTake(segmentMux, 2100) != pdTRUE) return; + strip._segments.clear(); + strip._segments.reserve(3); + strip._segments.push_back(Segment(0, 106)); + strip._segments.push_back(Segment(106, 212)); + strip._segments.push_back(Segment(212, 318)); + setSegmentName(strip._segments[0], "top"); + setSegmentName(strip._segments[1], "middle"); + setSegmentName(strip._segments[2], "bottom"); + for (segment& seg : strip._segments) seg.refreshLightCapabilities(); + strip.setMainSegmentId(0); + esp32SemGive(segmentMux); +} + +void infinityApplyDmx(uint8_t* data, uint16_t availableChannels) { + if (!isMasterBuild() || data == nullptr || availableChannels < 1) return; + + uint8_t ch[INFINITY_DMX_FOOTPRINT] = {0}; + const uint16_t count = availableChannels < INFINITY_DMX_FOOTPRINT ? availableChannels : INFINITY_DMX_FOOTPRINT; + for (uint16_t i = 0; i < count; i++) ch[i] = data[i]; + + infinityScene.brightness = ch[0]; + if (ch[1] == 0) infinityScene.flags &= ~0x01; + else infinityScene.flags |= 0x01; + infinityScene.presetId = ch[2]; + infinityScene.effectId = ch[3]; + infinityScene.speed = ch[4]; + infinityScene.intensity = ch[5]; + infinityScene.palette = ch[6]; + hsvToRgb(ch[7], ch[8], ch[9], infinityScene.primary); + infinityScene.direction = ch[10]; + infinityScene.transitionMs = dmxTransitionMs(ch[11]); + infinityScene.groupMask = ch[12] ? (ch[12] & 0x07) : 0x07; + infinityScene.rowDimmer[0] = ch[13]; + infinityScene.rowDimmer[1] = ch[14]; + infinityScene.rowDimmer[2] = ch[15]; + infinityScene.custom[0] = ch[18]; + infinityScene.custom[1] = ch[19]; + infinityScene.custom[2] = ch[20]; + infinityScene.seed = static_cast(ch[21]) * 0x01010101UL; + infinityScene.strobe = ch[22]; + infinityScene.safetyFade = ch[23]; + infinityScene.spatialMode = map(ch[24], 0, 255, 0, INFINITY_SPATIAL_WAVE_LINE); + infinityScene.spatialVariant = map(ch[25], 0, 255, 0, INFINITY_SPATIAL_VARIANT_OUTLINE_REVERSE); + infinityScene.spatialDirection = map(ch[26], 0, 255, 0, INFINITY_SPATIAL_DIRECTION_PINGPONG); + infinityScene.spatialBlend = map(ch[27], 0, 255, 0, INFINITY_SPATIAL_BLEND_PALETTE_TINT); + infinityScene.spatialStrength = ch[28]; + infinityScene.spatialSize = ch[29]; + infinityScene.spatialAngle = map(ch[30], 0, 255, 0, 359); + infinityScene.spatialOption = ch[31] > 127 ? INFINITY_SPATIAL_OPTION_BANDS : INFINITY_SPATIAL_OPTION_LINE; + + markSceneChanged(); + + if (ch[16] > 127 && lastDmxBeatValue <= 127) sendBeatTrigger(); + if (ch[17] > 127 && lastDmxSyncResetValue <= 127) { + masterOffsetUs = 0; + infinityScene.phase = 0; + markSceneChanged(50000); + sendClockSync(); + } + lastDmxBeatValue = ch[16]; + lastDmxSyncResetValue = ch[17]; +} + +void infinityHandleOverlayDraw() { + if (!infinityEnabled || !isNodeBuild()) return; + if (infinityScene.spatialMode == INFINITY_SPATIAL_OFF || infinityScene.spatialStrength == 0) return; + if (strip.getSegmentsNum() < INFINITY_PANEL_COUNT || strip.getLengthTotal() < INFINITY_LEDS_PER_NODE) return; + + uint8_t column = 0; + parseNodeColumn(column); + const uint64_t nowUs = masterTimeUs() + (static_cast(infinityScene.phase) * 1000ULL); + + for (uint8_t row = 0; row < INFINITY_PANEL_COUNT; row++) { + Segment& seg = strip.getSegment(row); + if (!seg.isActive()) continue; + + const uint16_t len = min(seg.length(), INFINITY_LEDS_PER_PANEL); + for (uint16_t led = 0; led < len; led++) { + const uint8_t amount = spatialAmount(column, row, led, nowUs); + if (amount == 0 && infinityScene.spatialBlend != INFINITY_SPATIAL_BLEND_REPLACE) { + if (infinityScene.spatialBlend == INFINITY_SPATIAL_BLEND_MULTIPLY_MASK || infinityScene.spatialBlend == INFINITY_SPATIAL_BLEND_PALETTE_TINT) { + seg.setPixelColor(led, BLACK); + } + continue; + } + const uint32_t base = seg.getPixelColor(led); + const uint32_t layer = spatialLayerColor(seg, led, amount); + seg.setPixelColor(led, applySpatialBlend(base, layer, amount, infinityScene.spatialStrength, infinityScene.spatialBlend)); + } + } +} + +void infinitySerializeJson(JsonObject root) { + refreshRuntimeNodeId(); + root["enabled"] = infinityEnabled; + root["role"] = isMasterBuild() ? "master" : "node"; + root["node_id"] = infinityRuntimeNodeId; + root["configured_node_id"] = infinityConfiguredNodeId; + root["port"] = infinityPort; + root["udp_started"] = infinityUdpStarted; + root["master_ip"] = ipToString(infinityMasterIp); + root["master_time_us"] = static_cast(masterTimeUs() & 0xFFFFFFFFUL); + root["master_offset_us"] = static_cast(masterOffsetUs); + root["packets_received"] = packetsReceived; + root["packets_sent"] = packetsSent; + serializeNodeIps(root.createNestedArray("node_ips")); + + JsonObject scene = root.createNestedObject("scene"); + serializeScene(scene); + + JsonArray nodes = root.createNestedArray("nodes"); + const uint32_t now = millis(); + for (uint8_t i = 0; i < INFINITY_NODE_COUNT; i++) { + JsonObject node = nodes.createNestedObject(); + node["expected_ip"] = ipToString(infinityNodeIps[i]); + node["ip"] = nodeStatuses[i].lastSeenMs ? ipToString(nodeStatuses[i].ip) : ""; + node["node_id"] = nodeStatuses[i].nodeId; + node["online"] = nodeStatuses[i].lastSeenMs && (now - nodeStatuses[i].lastSeenMs <= INFINITY_NODE_TIMEOUT_MS); + node["last_seen_ms"] = nodeStatuses[i].lastSeenMs ? now - nodeStatuses[i].lastSeenMs : 0; + node["uptime_ms"] = nodeStatuses[i].uptimeMs; + node["master_offset_us"] = nodeStatuses[i].masterOffsetUs; + node["scene_sequence"] = nodeStatuses[i].lastSceneSequence; + node["packets_received"] = nodeStatuses[i].packetsReceived; + node["effect"] = nodeStatuses[i].activeEffect; + node["preset"] = nodeStatuses[i].activePreset; + } +} + +bool infinityDeserializeJson(JsonObject root) { + if (root.isNull()) return false; + const bool hadSceneObject = root["scene"].is(); + const bool wasEnabled = infinityEnabled; + if (!root["enabled"].isNull()) infinityEnabled = root["enabled"].as(); + if (root["node_id"].is()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), root["node_id"].as()); + if (root["master_ip"].is()) { + IPAddress parsed; + if (parsed.fromString(root["master_ip"].as())) infinityMasterIp = parsed; + } + if (!root["port"].isNull()) { + uint16_t newPort = jsonU16(root["port"], infinityPort); + if (newPort != infinityPort && newPort > 0) { + infinityPort = newPort; + infinityNetworkBegin(); + } + } + deserializeNodeIps(root["node_ips"]); + if (wasEnabled != infinityEnabled) { + if (infinityEnabled) { + infinityNetworkBegin(); + } else if (infinityUdpStarted) { + infinityUdp.stop(); + infinityUdpStarted = false; + } + } + JsonObject scene = root["scene"].is() ? root["scene"].as() : root; + deserializeScene(scene); + if (isMasterBuild() && infinityUdpStarted) { + sendClockSync(); + if (hadSceneObject || !root["node_ips"].isNull()) sendSceneState(); + } + doSerializeConfig = true; + return true; +} + +void infinityDeserializeConfig(JsonObject interfaces) { + JsonObject cfg = interfaces["infinity"]; + if (cfg.isNull()) return; + if (!cfg["enabled"].isNull()) infinityEnabled = cfg["enabled"].as(); + if (!cfg["port"].isNull()) infinityPort = jsonU16(cfg["port"], infinityPort); + if (cfg["node_id"].is()) copyNodeId(infinityConfiguredNodeId, sizeof(infinityConfiguredNodeId), cfg["node_id"].as()); + if (cfg["master_ip"].is()) { + IPAddress parsed; + if (parsed.fromString(cfg["master_ip"].as())) infinityMasterIp = parsed; + } + deserializeNodeIps(cfg["node_ips"]); +} + +void infinitySerializeConfig(JsonObject interfaces) { + JsonObject cfg = interfaces.createNestedObject("infinity"); + cfg["enabled"] = infinityEnabled; + cfg["role"] = isMasterBuild() ? "master" : "node"; + cfg["node_id"] = infinityConfiguredNodeId; + cfg["master_ip"] = ipToString(infinityMasterIp); + cfg["port"] = infinityPort; + serializeNodeIps(cfg.createNestedArray("node_ips")); +} + +void serveInfinityJson(AsyncWebServerRequest* request) { + AsyncJsonResponse* response = new AsyncJsonResponse(6144, false); + JsonObject root = response->getRoot().as(); + infinitySerializeJson(root); + response->setLength(); + request->send(response); +} + +void serveInfinityPage(AsyncWebServerRequest* request) { + static const char PAGE_infinity[] PROGMEM = R"rawliteral( +Infinity Controller + +
+
+
+ +
+

Infinity Controller

+

WLED-style master surface for your installation

+
+
+
+ + WLED UI + Sync + +
+
+ +
+
+

Master

+
+
Loading master status…
+
+
+

Sync

+
+
Checking node target layout…
+
+
+ +
+
+
+

Scene

+
+
+ Output Enabled
+ Master sends scene state, nodes render locally on apply time. +
+ +
+
+
+
+ +

Global 2D

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Reverse Direction
Maps to the Infinity direction flag.
+ +
+

Colors

+
+
+
+
+
+

Rows

+
+
+
+
+
+
+ +
+

Nodes

+
+
+
+ +
+
+

Node Targets

+

These are the unicast destinations the master sends Infinity Sync to.

+
+
+
+

Usage

+

This page complements WLED. Use the regular WLED UI for generic device settings, Wi-Fi and firmware basics.

+
+ + +
+
+
+
+ +
+
Ready. Changes stay local until you press Apply Scene or Save Targets.
+ + +
+ + +)rawliteral"; + request->send_P(200, "text/html", PAGE_infinity); +} + +#endif // WLED_ENABLE_INFINITY_CONTROLLER