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

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