Add BPM speed control and OTA update workflow
Some checks failed
WLED CI / wled_build (push) Has been cancelled
Deploy Nightly / wled_build (push) Has been cancelled
Deploy Nightly / Deploy nightly (push) Has been cancelled

This commit is contained in:
jan
2026-04-25 22:48:13 +02:00
parent 95137a6d65
commit ebc4498d89
5 changed files with 3045 additions and 0 deletions

503
docs/rfp-node-flashing.md Normal file
View File

@@ -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 <ip1>,<ip2>,<ip3>,<ip4>,<ip5>,<ip6> \
--start-from <ip1> \
--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.

View File

@@ -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"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Infinity Local Visualizer</title>
<style>
:root{--bg:#0f1115;--panel:#181b22;--line:#303640;--text:#eef2f6;--muted:#9aa5b5;--good:#35d07f;--bad:#ff637d;--warn:#ffd166;--accent:#4da3ff}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1320px;margin:0 auto;padding:14px}header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}h1{margin:0;font-size:22px}.sub,.muted{color:var(--muted)}button,input{font:inherit;border:1px solid var(--line);background:#11151b;color:var(--text);border-radius:6px;padding:8px 10px}button{cursor:pointer;font-weight:650}.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.toolbar input{width:170px}.status{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px;margin-bottom:12px}.metric,.panel,.log{background:var(--panel);border:1px solid var(--line);border-radius:8px}.metric{padding:10px;min-width:0}.metric span{display:block;color:var(--muted);font-size:11px;text-transform:uppercase}.metric strong{display:block;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.nodes{display:grid;grid-template-columns:repeat(6,minmax(128px,1fr));gap:12px}.panel{padding:8px;min-width:0}.panel-head{display:flex;justify-content:space-between;gap:8px;margin-bottom:6px;color:var(--muted);font-size:12px}.panel-head strong{color:var(--text)}canvas{width:100%;aspect-ratio:1/1;background:#05070b;border:1px solid #222b35;border-radius:6px;display:block}.log{margin-top:12px;padding:10px;min-height:38px}.ok{color:var(--good)}.bad{color:var(--bad)}.warn{color:var(--warn)}@media(max-width:900px){header{align-items:flex-start;flex-direction:column}.status{grid-template-columns:1fr}.nodes{grid-template-columns:repeat(3,minmax(92px,1fr));gap:8px}.toolbar,.toolbar input{width:100%}button{flex:1 1 auto}}
</style></head><body><main>
<header><div><h1>Infinity Local Visualizer</h1><div class="sub">Exact Global-2D layer preview. WLED base effects are not simulated.</div></div><div class="toolbar"><input id="master" aria-label="Master IP"><button id="apply">Connect</button><button id="pause">Pause</button><a id="masterLink" href="#" target="_blank"><button type="button">Master UI</button></a></div></header>
<section class="status" id="status"></section><section class="nodes" id="nodes"></section><div class="log" id="log">Starting...</div>
<script>
const NODE_COUNT=6, ROWS=3, LEDS=106, OUTPUT_LABELS=["UART6","UART5","UART4"];
let frame=null, paused=false, refreshInFlight=false;const q=id=>document.getElementById(id);const masterIp=()=>q("master").value.trim()||new URLSearchParams(location.search).get("master")||"10.42.0.213";
function ensureNodes(){if(q("nodes").children.length)return;q("nodes").innerHTML=Array.from({length:ROWS},(_,row)=>Array.from({length:NODE_COUNT},(_,node)=>`<article class="panel"><div class="panel-head"><strong>ESP${node+1} ${OUTPUT_LABELS[row]}</strong><span id="meta${node}_${row}">106 LEDs</span></div><canvas width="160" height="160" id="c${node}_${row}"></canvas></article>`).join("")).join("")}
function drawPanel(canvas, leds){const ctx=canvas.getContext("2d");ctx.clearRect(0,0,160,160);ctx.fillStyle="#05070b";ctx.fillRect(0,0,160,160);ctx.strokeStyle="#1e2732";ctx.lineWidth=2;ctx.strokeRect(23,23,114,114);for(let i=0;i<leds.length;i++){let x=0,y=0;if(i<25){x=26+i*(108/24);y=24}else if(i<52){x=136;y=26+(i-25)*(108/26)}else if(i<79){x=136-(i-52)*(108/26);y=136}else{x=24;y=136-(i-79)*(108/26)}const c=leds[i];ctx.fillStyle=`rgb(${c[0]},${c[1]},${c[2]})`;ctx.beginPath();ctx.arc(x,y,2.1,0,Math.PI*2);ctx.fill()}}
function render(){ensureNodes();const s=frame?.scene||{}, spatial=s.spatial||{};for(let row=0;row<ROWS;row++){for(let node=0;node<NODE_COUNT;node++){q(`meta${node}_${row}`).textContent=frame?.node_ips?.[node]||"virtual";drawPanel(q(`c${node}_${row}`),frame?.panels?.[row]?.[node]||Array.from({length:LEDS},()=>[0,0,0]));}}q("status").innerHTML=[["Master",masterIp()],["2D Mode",frame?.mode_name||"Off"],["Variant",frame?.variant_name||"-"],["Direction",frame?.direction_name||"-"],["Blend",frame?.blend_name||"-"],["Layer",frame?.note||"2D layer exact"]].map(([k,v])=>`<div class="metric"><span>${k}</span><strong>${v}</strong></div>`).join("")}
async function refresh(){if(paused||refreshInFlight)return;refreshInFlight=true;const ip=masterIp();q("masterLink").href=`http://${ip}/infinity`;try{const response=await fetch(`/api/frame?master=${encodeURIComponent(ip)}`,{cache:"no-store"});if(!response.ok)throw new Error((await response.text()).replace(/[{}\"]/g,""));frame=await response.json();q("log").innerHTML=`<span class="ok">connected</span> ${new Date().toLocaleTimeString()} via ${ip}. <span class="warn">WLED base not simulated; showing exact Infinity 2D layer only.</span>`;render()}catch(error){q("log").innerHTML=`<span class="bad">master offline</span> ${ip} · ${error.message}`}finally{refreshInFlight=false}}
q("master").value=masterIp();q("apply").onclick=refresh;q("pause").onclick=()=>{paused=!paused;q("pause").textContent=paused?"Resume":"Pause"};setInterval(refresh,250);refresh();
</script></main></body></html>"""
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())

388
tools/rfp_network_flash.py Executable file
View File

@@ -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 <ip> to continue at a specific device.")
return 130
if __name__ == "__main__":
os.environ.setdefault("PYTHONUNBUFFERED", "1")
raise SystemExit(main())

159
tools/rfp_update_all_ota.py Executable file
View File

@@ -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())

1634
wled00/infinity_sync.cpp Normal file

File diff suppressed because it is too large Load Diff