Backup RFP Infinity controller state before Resolume changes
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-05-14 12:31:13 +02:00
parent ebc4498d89
commit 4bc4e1257e
33 changed files with 3482 additions and 695 deletions

View File

@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""Local Infinity visualizer for the exact Global-2D layer.
"""Local Infinity visualizer for the Infinity 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.
The browser proxies the master state and renders the Infinity Global-2D layer.
Effect-mask modes use a synthetic color preview so geometry, masks and timing
can be checked without reimplementing the full WLED effect engine.
"""
from __future__ import annotations
@@ -24,28 +25,43 @@ 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"]
MODE_NAMES = ["Off", "Center Pulse", "Checkerd", "Arrow", "Scan", "Snake", "Wave Line", "Strobe", "Schlängeln", "Sunburst"]
VARIANT_NAMES = ["Expand / Classic / Line", "Reverse / Diagonal / Bands", "Outline / Checkerd", "Outline Reverse"]
BLEND_NAMES = ["Replace", "Add", "Multiply Mask", "Palette Tint"]
SCHLAENGELN_VARIANT_NAMES = ["Top Left", "Top Right", "Bottom Left", "Bottom Right"]
SUNBURST_VARIANT_NAMES = ["Still", "Wobble", "Rotate"]
DIRECTION_NAMES = ["Left -> Right", "Right -> Left", "Top -> Bottom", "Bottom -> Top", "Outward", "Inward", "Ping Pong"]
BPM_MIN = 20
BPM_MAX = 240
PANEL_GAP_RATIO = 0.50 # 8 cm gap for roughly 16 cm active panel aperture.
# Keep in sync with wled00/infinity_sync.cpp. Values are:
# rotation quarter-turns clockwise, mirror X, mirror Y.
PANEL_TRANSFORMS: list[list[tuple[int, bool, bool]]] = [
[(0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False)],
[(0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False)],
[(0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False), (0, False, False)],
]
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>
<header><div><h1>Infinity Local Visualizer</h1><div class="sub">Global-2D preview with synthetic effect colors and panel-orientation calibration.</div></div><div class="toolbar"><input id="master" aria-label="Master IP"><button id="apply">Connect</button><button id="pause">Pause</button><button id="calibrate">Calibrate</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"];
const PANEL_TRANSFORMS=[[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]],[[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0]]];
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";
let calibration=new URLSearchParams(location.search).get("cal")==="1";
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();
function ledLocal(i){if(i<25)return [(i+.5)/25,0];if(i<52)return [1,(i-25+.5)/27];if(i<79)return [1-((i-52+.5)/27),1];return [0,1-((i-79+.5)/27)]}
function transformedLocal(i,row,node){let [x,y]=ledLocal(i);const t=PANEL_TRANSFORMS[row]?.[node]||[0,0,0];if(t[1])x=1-x;if(t[2])y=1-y;for(let r=0;r<(t[0]&3);r++){const ox=x;x=1-y;y=ox}return [x,y]}
function ledPos(i,row,node){const [x,y]=transformedLocal(i,row,node);return [24+x*112,24+y*112]}
function drawPanel(canvas, leds, row, node, panelInfo){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++){const [x,y]=ledPos(i,row,node);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()}if(calibration){ctx.fillStyle="#ff4d4d";let [sx,sy]=ledPos(0,row,node);ctx.beginPath();ctx.arc(sx,sy,5,0,Math.PI*2);ctx.fill();ctx.strokeStyle="#35d07f";ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(sx,sy);const [ex,ey]=ledPos(12,row,node);ctx.lineTo(ex,ey);ctx.stroke();ctx.fillStyle="#eef2f6";ctx.font="12px system-ui";ctx.fillText(panelInfo?.label||"",30,46);ctx.fillText(panelInfo?.transform||"",30,62);ctx.strokeStyle="#ffd166";ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(80,18);ctx.lineTo(80,142);ctx.moveTo(18,80);ctx.lineTo(142,80);ctx.stroke()}}
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]),row,node,frame?.panel_info?.[row]?.[node]);}}q("status").innerHTML=[["Master",masterIp()],["2D Mode",frame?.mode_name||"Off"],["Variant",frame?.variant_name||"-"],["Direction",frame?.direction_name||"-"],["Layer",frame?.note||"2D preview"]].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">Synthetic effect preview; panel orientation uses shared calibration table.</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"};q("calibrate").onclick=()=>{calibration=!calibration;q("calibrate").textContent=calibration?"Hide Cal":"Calibrate";render()};q("calibrate").textContent=calibration?"Hide Cal":"Calibrate";setInterval(refresh,250);refresh();
</script></main></body></html>"""
@@ -65,12 +81,169 @@ def speed_to_bpm(speed: int) -> int:
return round(BPM_MIN + speed * (BPM_MAX - BPM_MIN) / 255.0)
def spatial_phase(now_us: int, speed: int) -> float:
def spatial_beat_position(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 spatial_step_position(now_us: int, speed: int) -> float:
# Two visible animation phases make one measured on/off panel cycle match the BPM value.
return spatial_beat_position(now_us, speed) * 2.0
def spatial_beat_index(now_us: int, speed: int) -> int:
return int(math.floor(spatial_beat_position(now_us, speed)))
def spatial_step_index(now_us: int, speed: int) -> int:
return int(math.floor(spatial_step_position(now_us, speed)))
def spatial_beat_frac(now_us: int, speed: int) -> float:
beat = spatial_beat_position(now_us, speed)
return beat - math.floor(beat)
def strobe_amount(now_us: int, speed: int, pulse_width: int) -> int:
pulse_width = max(0, min(255, int(pulse_width)))
duty = 0.01 + (pulse_width / 255.0) * 0.34
phase = spatial_beat_position(now_us, speed) * 8.0
return 255 if (phase - math.floor(phase)) < duty else 0
def serpentine_panel_index(col: int, row: int) -> int:
return row * NODE_COUNT + (NODE_COUNT - 1 - col if row & 1 else col)
def mirrored_serpentine_panel_index(col: int, row: int, variant: int) -> int:
if variant in (1, 3):
col = NODE_COUNT - 1 - col
if variant in (2, 3):
row = ROWS - 1 - row
return serpentine_panel_index(col, row)
def chain_length_from_size(size: int) -> int:
size = max(0, min(255, int(size)))
if size <= 64:
return max(1, size // 16)
return min(18, 4 + ((size - 64) * 14 + 95) // 191)
def snake_length_from_size(size: int) -> int:
return min(17, 3 + max(1, max(0, min(255, int(size))) // 64))
def spatial_hash(value: int) -> int:
value &= 0xFFFFFFFF
value ^= value >> 16
value = (value * 0x7FEB352D) & 0xFFFFFFFF
value ^= value >> 15
value = (value * 0x846CA68B) & 0xFFFFFFFF
value ^= value >> 16
return value & 0xFFFFFFFF
def grid_panel_index(col: int, row: int) -> int:
return row * NODE_COUNT + col
def grid_col(index: int) -> int:
return index % NODE_COUNT
def grid_row(index: int) -> int:
return index // NODE_COUNT
def snake_body_contains(body: list[int], position: int, skip_tail: int = 0) -> bool:
end = max(0, len(body) - skip_tail)
return position in body[:end]
def snake_spawn_apple(body: list[int], seed: int, generation: int) -> int:
candidate = spatial_hash(seed ^ (generation * 0x9E3779B9)) % (NODE_COUNT * ROWS)
for _ in range(NODE_COUNT * ROWS):
if not snake_body_contains(body, candidate):
return candidate
candidate = (candidate + 7) % (NODE_COUNT * ROWS)
return body[0]
def snake_neighbors(position: int) -> list[tuple[int, int]]:
col, row = grid_col(position), grid_row(position)
out: list[tuple[int, int]] = []
if col + 1 < NODE_COUNT:
out.append((0, grid_panel_index(col + 1, row)))
if row + 1 < ROWS:
out.append((2, grid_panel_index(col, row + 1)))
if col > 0:
out.append((1, grid_panel_index(col - 1, row)))
if row > 0:
out.append((3, grid_panel_index(col, row - 1)))
return out
def snake_distance(a: int, b: int) -> int:
return abs(grid_col(a) - grid_col(b)) + abs(grid_row(a) - grid_row(b))
def snake_reset(seed: int, epoch: int, target_length: int, generation: int) -> tuple[list[int], int, int]:
head = spatial_hash(seed ^ (epoch * 0x45D9F3B)) % (NODE_COUNT * ROWS)
body = [head] * min(target_length, 3)
generation += 1
apple = snake_spawn_apple(body, seed ^ epoch, generation)
return body, apple, generation
def snake_state_at(local_step: int, target_length: int, seed: int) -> tuple[list[int], int]:
body, apple, generation = snake_reset(seed, 0, target_length, 0)
base_order = [0, 2, 1, 3]
for step in range(local_step):
order_offset = spatial_hash(seed ^ (step * 0x9E3779B1) ^ generation) & 3
ordered = base_order[order_offset:] + base_order[:order_offset]
by_dir = dict(snake_neighbors(body[0]))
best = None
best_distance = 999
for direction in ordered:
if direction not in by_dir:
continue
nxt = by_dir[direction]
will_eat = nxt == apple
if snake_body_contains(body, nxt, 0 if will_eat else 1):
continue
distance = snake_distance(nxt, apple)
if distance < best_distance:
best_distance = distance
best = nxt
if best is None:
body, apple, generation = snake_reset(seed, step + 1, target_length, generation)
continue
ate = best == apple
new_len = min(target_length, len(body) + 1) if ate else len(body)
body = [best] + body[:new_len - 1]
if ate:
generation += 1
apple = snake_spawn_apple(body, seed ^ step, generation)
return body, apple
def triangle_step(step: int, max_index: int) -> int:
if max_index <= 0:
return 0
period = max_index * 2
phase = step % period
return period - phase if phase > max_index else phase
def schlaengeln_pingpong_position(step: int, offset: int, max_index: int) -> int:
if max_index <= 0:
return 0
period = max_index * 2
phase = (step + period - (offset % period)) % period
return period - phase if phase > max_index else phase
def panel_led_position(led: int) -> tuple[float, float, int]:
if led < 25:
return (led + 0.5) / 25.0, 0.0, 0
@@ -81,11 +254,75 @@ def panel_led_position(led: int) -> tuple[float, float, int]:
return 0.0, 1.0 - ((led - 79 + 0.5) / 27.0), 3
def apply_panel_transform(col: int, row: int, x: float, y: float) -> tuple[float, float]:
try:
rotation, mirror_x, mirror_y = PANEL_TRANSFORMS[row][col]
except IndexError:
return x, y
if mirror_x:
x = 1.0 - x
if mirror_y:
y = 1.0 - y
for _ in range(rotation & 3):
x, y = 1.0 - y, x
return x, y
def physical_panel_led_position(col: int, row: int, led: int) -> tuple[float, float]:
lx, ly, _ = panel_led_position(led)
lx, ly = apply_panel_transform(col, row, lx, ly)
pitch = 1.0 + PANEL_GAP_RATIO
return col * pitch + lx, row * pitch + ly
def apply_sunburst_panel_transform(x: float, y: float) -> tuple[float, float]:
# Match firmware: Sunburst gets the observed 90-degree-left correction,
# while Scan and all other modes keep the neutral global transform.
for _ in range(3):
x, y = 1.0 - y, x
return x, y
def sunburst_panel_led_position(col: int, row: int, led: int) -> tuple[float, float]:
lx, ly, _ = panel_led_position(led)
lx, ly = apply_sunburst_panel_transform(lx, ly)
pitch = 1.0 + PANEL_GAP_RATIO
return col * pitch + lx, row * pitch + ly
def physical_panel_center() -> tuple[float, float]:
pitch = 1.0 + PANEL_GAP_RATIO
return ((NODE_COUNT - 1) * pitch + 1.0) * 0.5, ((ROWS - 1) * pitch + 1.0) * 0.5
def hsv_to_rgb(hue: float, sat: float = 1.0, val: float = 1.0) -> list[int]:
hue = hue % 1.0
sector = hue * 6.0
i = int(math.floor(sector))
f = sector - i
p = val * (1.0 - sat)
q = val * (1.0 - sat * f)
t = val * (1.0 - sat * (1.0 - f))
if i == 0:
r, g, b = val, t, p
elif i == 1:
r, g, b = q, val, p
elif i == 2:
r, g, b = p, val, t
elif i == 3:
r, g, b = p, q, val
elif i == 4:
r, g, b = t, p, val
else:
r, g, b = val, p, q
return [clamp_byte(r * 255), clamp_byte(g * 255), clamp_byte(b * 255)]
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
front = spatial_step_position(now_us, speed) % span
if variant in (1, 3):
front = max_distance - front
amount = 1.0 - smoothstep(0.0, 0.70, abs(distance - front))
@@ -107,18 +344,18 @@ def center_pulse_amount(col: int, row: int, led: int, now_us: int, speed: int, v
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)))
step = spatial_step_index(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) == 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)))
step = spatial_step_index(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)
@@ -136,7 +373,7 @@ def arrow_amount(col: int, row: int, now_us: int, speed: int, direction: int, si
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)))
movement = spatial_step_index(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)
@@ -150,17 +387,24 @@ def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int
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
p00 = 0.0
p10 = NODE_COUNT * vx
p01 = ROWS * vy
p11 = p10 + p01
min_progress = min(p00, p10, p01, p11)
max_progress = max(p00, p10, p01, p11)
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
travel = max_progress - min_progress
if travel <= 0.001:
return 0
phase = spatial_step_position(now_us, speed) % travel
if direction == 6:
phase = (spatial_phase(now_us, speed) * travel) % (travel * 2.0)
phase = spatial_step_position(now_us, speed) % (travel * 2.0)
if phase > travel:
phase = (travel * 2.0) - phase
elif direction in (1, 3):
phase = travel - phase
center = min_progress + phase
center = min_progress + max(0.0, min(travel, phase))
if option == 1:
period = width * 2.0 + 0.35
d = abs(((progress - center + period * 64.0) % period) - period * 0.5)
@@ -168,41 +412,111 @@ def scan_amount(col: int, row: int, led: int, now_us: int, speed: int, size: int
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:
def snake_sample(col: int, row: int, now_us: int, speed: int, size: int, seed: int) -> tuple[int, str]:
panel_index = grid_panel_index(col, row)
target_length = snake_length_from_size(size)
body, apple = snake_state_at(spatial_step_index(now_us, speed) % 240, target_length, seed)
for offset, position in enumerate(body):
if panel_index == position:
return 255, "primary"
return (255, "secondary") if panel_index == apple else (0, "gradient")
def schlaengeln_sample(col: int, row: int, now_us: int, speed: int, size: int, variant: int, direction: int) -> tuple[int, str]:
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)
panel_index = mirrored_serpentine_panel_index(col, row, variant)
length = chain_length_from_size(size)
step = spatial_step_index(now_us, speed)
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
if direction == 6:
pos = schlaengeln_pingpong_position(step, offset, path_len - 1)
elif direction == 1:
head = (path_len - 1) - (step % path_len)
pos = (head + path_len - (offset % path_len)) % path_len
else:
pos = (step + path_len - (offset % path_len)) % path_len
if panel_index == pos:
return max(55, 255 - offset * 30), "gradient"
return 0, "gradient"
def sunburst_sample(col: int, row: int, led: int, now_us: int, speed: int, variant: int, option: int) -> tuple[int, str, float]:
x, y = sunburst_panel_led_position(col, row, led)
cx, cy = physical_panel_center()
dx, dy = x - cx, y - cy
wobble = math.sin(spatial_beat_position(now_us, speed) * math.tau) * 0.18 if variant == 1 else 0.0
rotation = spatial_beat_position(now_us, speed) * (math.tau / 24.0) if variant == 2 else 0.0
angle = (math.atan2(dy, dx) + wobble - rotation) % math.tau
active = math.cos(angle * 12.0) >= 0.0
if option == 1:
return 255, "primary" if active else "secondary", 0.0
if option == 2:
hue = (angle / math.tau) + (spatial_beat_position(now_us, speed) * 8.0 / 255.0)
return (255, "rainbow", hue) if active else (255, "black", 0.0)
return (255, "gradient", 0.0) if active else (255, "black", 0.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:
def layer_sample(mode: int, col: int, row: int, led: int, now_us: int, spatial: dict[str, Any], scene: dict[str, Any]) -> tuple[int, str]:
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)
return center_pulse_amount(col, row, led, now_us, speed, variant), "gradient"
if mode == 2:
return checker_amount(col, row, led, now_us, speed, variant)
return checker_amount(col, row, led, now_us, speed, variant), "gradient"
if mode == 3:
return arrow_amount(col, row, now_us, speed, direction, size)
return arrow_amount(col, row, now_us, speed, direction, size), "gradient"
if mode == 4:
return scan_amount(col, row, led, now_us, speed, size, int(spatial.get("angle", 0)), int(spatial.get("option", 0)), direction)
return scan_amount(col, row, led, now_us, speed, size, int(spatial.get("angle", 0)), int(spatial.get("option", 0)), direction), "gradient"
if mode == 5:
return snake_amount(col, row, now_us, speed, size, int(scene.get("seed", 1)))
return snake_sample(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
return wave_line_amount(col, row, now_us, speed, direction), "gradient"
if mode == 7:
return strobe_amount(now_us, speed, size), "gradient"
if mode == 8:
return schlaengeln_sample(col, row, now_us, speed, size, variant, direction)
if mode == 9:
amount, role, hue = sunburst_sample(col, row, led, now_us, speed, variant, int(spatial.get("option", 0)))
return amount, f"rainbow:{hue}" if role == "rainbow" else role
return 0, "gradient"
def synthetic_effect_color(col: int, row: int, led: int, now_us: int, scene: dict[str, Any]) -> list[int]:
x, y, _ = panel_led_position(led)
effect = int(scene.get("effect", 0))
palette = int(scene.get("palette", 0))
speed = int(scene.get("speed", 128))
phase = spatial_beat_position(now_us, speed)
hue = (col * 0.10 + row * 0.17 + x * 0.12 + y * 0.08 + phase * 0.07 + effect * 0.013 + palette * 0.021) % 1.0
if effect == 0:
return scene.get("primary", [255, 160, 80])[:3]
return hsv_to_rgb(hue, 0.82, 1.0)
def sample_color(primary: list[int], secondary: list[int], effect_color: list[int], amount: int, role: str) -> list[int]:
if role == "primary":
return [clamp_byte(channel * amount / 255.0) for channel in primary]
if role == "secondary":
return [clamp_byte(channel * amount / 255.0) for channel in secondary]
if role == "black":
return [0, 0, 0]
if role.startswith("rainbow:"):
try:
hue = float(role.split(":", 1)[1])
except (IndexError, ValueError):
hue = 0.0
return [clamp_byte(channel * amount / 255.0) for channel in hsv_to_rgb(hue)]
return [clamp_byte(channel * amount / 255.0) for channel in effect_color]
def is_direct_role(role: str) -> bool:
return role in ("primary", "secondary") or role.startswith("rainbow:")
def render_frame(state: dict[str, Any]) -> dict[str, Any]:
@@ -213,26 +527,47 @@ def render_frame(state: dict[str, Any]) -> dict[str, Any]:
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
speed = int(scene.get("speed", 128))
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])
amount, role = layer_sample(mode, col, row, led, now_us, spatial, scene)
if not is_direct_role(role):
amount = clamp_byte(amount * strength / 255.0)
effect_color = synthetic_effect_color(col, row, led, now_us, scene)
leds.append(sample_color(primary, secondary, effect_color, amount, role) if amount else [0, 0, 0])
row_panels.append(leds)
panels.append(row_panels)
panel_info = [
[
{
"label": f"ESP{col + 1} {OUTPUT_LABELS[row]}",
"transform": f"r{PANEL_TRANSFORMS[row][col][0] * 90} mx{int(PANEL_TRANSFORMS[row][col][1])} my{int(PANEL_TRANSFORMS[row][col][2])}",
}
for col in range(NODE_COUNT)
]
for row in range(ROWS)
]
return {
"scene": scene,
"node_ips": state.get("node_ips", []),
"panels": panels,
"panel_info": panel_info,
"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",
"variant_name": (
SCHLAENGELN_VARIANT_NAMES[int(spatial.get("variant", 0))]
if mode == 8 and int(spatial.get("variant", 0)) < len(SCHLAENGELN_VARIANT_NAMES)
else SUNBURST_VARIANT_NAMES[int(spatial.get("variant", 0))]
if mode == 9 and int(spatial.get("variant", 0)) < len(SUNBURST_VARIANT_NAMES)
else VARIANT_NAMES[int(spatial.get("variant", 0))]
if int(spatial.get("variant", 0)) < len(VARIANT_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",
"note": "2D layer with synthetic effect-color preview",
}
@@ -347,7 +682,7 @@ def main() -> int:
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.")
print("Rendering Infinity Global-2D layer with synthetic effect colors and panel calibration.")
try:
server.serve_forever()
except KeyboardInterrupt:

219
tools/rfp_master_usb_relay.py Executable file
View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""Relay RFP node OTA updates through the USB-connected Infinity master."""
from __future__ import annotations
import argparse
import base64
import json
import sys
import time
from pathlib import Path
try:
import serial
except ImportError: # pragma: no cover - depends on local PlatformIO venv
serial = None
DEFAULT_BAUD = 921600
BOOTSTRAP_BAUD = 115200
DEFAULT_STARTUP_DELAY = 4.0
NODE_RELEASE = "RFP_N16R8_NODE3x106_V20260511E"
NODE_HOSTS = range(11, 17)
def targets_from_subnet(subnet: str) -> list[str]:
prefix = ".".join(subnet.split(".")[:3])
return [f"{prefix}.{host}" for host in NODE_HOSTS]
def drain_serial(ser: "serial.Serial", quiet_s: float = 0.2, max_s: float = 2.0) -> None:
"""Discard boot/debug lines before starting the command protocol."""
deadline = time.monotonic() + max_s
quiet_deadline = time.monotonic() + quiet_s
while time.monotonic() < deadline and time.monotonic() < quiet_deadline:
raw = ser.readline()
if raw:
quiet_deadline = time.monotonic() + quiet_s
def open_master_serial(port: str, baud: int, startup_delay: float) -> "serial.Serial":
if serial is None:
raise RuntimeError("pyserial is not installed in this Python environment")
ser = serial.Serial(port, BOOTSTRAP_BAUD, timeout=1.0, write_timeout=30)
if startup_delay > 0:
time.sleep(startup_delay)
drain_serial(ser)
if baud != BOOTSTRAP_BAUD:
ser.write(bytes([0xB5])) # WLED serial command: switch baud rate.
ser.flush()
time.sleep(0.25)
ser.baudrate = baud
time.sleep(0.25)
drain_serial(ser)
return ser
def read_prefixed_line(ser: "serial.Serial", prefixes: tuple[str, ...], timeout_s: float) -> tuple[str, str]:
deadline = time.monotonic() + timeout_s
seen: list[str] = []
while time.monotonic() < deadline:
raw = ser.readline()
if not raw:
continue
line = raw.decode("utf-8", errors="replace").strip()
if line:
seen.append(line)
seen = seen[-5:]
for prefix in prefixes:
if line.startswith(prefix):
return prefix, line[len(prefix) :].strip()
detail = f"; last serial lines: {' | '.join(seen)}" if seen else ""
raise TimeoutError(f"timed out waiting for one of: {', '.join(prefixes)}{detail}")
def master_info(ser: "serial.Serial", target: str, timeout_s: float = 8.0) -> dict:
command = {"target": target}
ser.write(("RFPINFO1 " + json.dumps(command, separators=(",", ":")) + "\n").encode())
ser.flush()
prefix, payload = read_prefixed_line(ser, ("RFPINFO1 ", "RFPERR1 "), timeout_s)
if prefix == "RFPERR1 ":
raise RuntimeError(payload)
return json.loads(payload)
def relay_ota(ser: "serial.Serial", target: str, firmware: Path, expected_release: str, chunk_size: int) -> None:
size = firmware.stat().st_size
command = {
"target": target,
"size": size,
"release": expected_release,
"skipValidation": True,
"ackBytes": chunk_size,
}
ser.write(("RFPOTA1 " + json.dumps(command, separators=(",", ":")) + "\n").encode())
ser.flush()
prefix, payload = read_prefixed_line(ser, ("RFPREADY1 ", "RFPERR1 "), 12.0)
if prefix == "RFPERR1 ":
raise RuntimeError(payload)
ready = json.loads(payload)
if int(ready.get("proto", 1)) < 4 or int(ready.get("ackBytes", 0)) <= 0:
raise RuntimeError(
"USB relay master firmware is too old for base64 chunk mode. "
"Flash the master first, then rerun this command."
)
print(f"{target}: master ready {payload}")
ser.write(b"RFPDATA1\n")
ser.flush()
sent = 0
started = time.monotonic()
with firmware.open("rb") as fh:
while True:
chunk = fh.read(chunk_size)
if not chunk:
break
encoded = base64.b64encode(chunk).decode("ascii")
written = ser.write(f"RFPCHUNK1 {len(chunk)} {encoded}\n".encode("ascii"))
ser.flush()
if written <= 0:
raise RuntimeError(f"{target}: serial write returned {written}")
sent += len(chunk)
while True:
prefix, payload = read_prefixed_line(ser, ("RFPACK1 ", "RFPERR1 "), 30.0)
if prefix == "RFPERR1 ":
raise RuntimeError(payload)
ack = json.loads(payload).get("bytes", 0)
if int(ack) >= sent:
break
if sent == size or sent % (256 * 1024) < len(chunk):
elapsed = max(0.1, time.monotonic() - started)
print(f"{target}: streamed {sent}/{size} bytes ({sent / elapsed / 1024:.1f} KiB/s)")
prefix, payload = read_prefixed_line(ser, ("RFPDONE1 ", "RFPERR1 "), 45.0)
if prefix == "RFPERR1 ":
raise RuntimeError(payload)
print(f"{target}: relay done {payload}")
def release_from_info(info: dict) -> str:
return str(info.get("release") or info.get("rel") or "")
def wait_for_release(ser: "serial.Serial", target: str, expected_release: str, timeout_s: float) -> dict:
deadline = time.monotonic() + timeout_s
last_error = ""
while time.monotonic() < deadline:
try:
info = master_info(ser, target, timeout_s=8.0)
if release_from_info(info) == expected_release:
return info
last_error = f"release is {release_from_info(info)!r}"
except Exception as exc: # noqa: BLE001 - keep polling while node reboots
last_error = str(exc)
time.sleep(2.0)
raise TimeoutError(f"{target}: expected release {expected_release} did not appear ({last_error})")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Update RFP nodes through the USB-connected master.")
parser.add_argument("--port", required=True, help="Master serial port, for example /dev/ttyACM0")
parser.add_argument("--firmware", required=True, type=Path, help="Node firmware .bin")
parser.add_argument("--expect-release", default=NODE_RELEASE, help=f"Expected node release (default: {NODE_RELEASE})")
parser.add_argument("--targets", help="Comma-separated node IP list")
parser.add_argument("--subnet", default="192.168.178.0/24", help="Subnet used to derive .11-.16 when --targets is omitted")
parser.add_argument("--start-from", help="Resume from this target IP")
parser.add_argument("--baud", type=int, default=DEFAULT_BAUD, help=f"Relay baud rate after startup (default: {DEFAULT_BAUD})")
parser.add_argument(
"--startup-delay",
type=float,
default=DEFAULT_STARTUP_DELAY,
help=f"Seconds to wait after opening the master serial port (default: {DEFAULT_STARTUP_DELAY})",
)
parser.add_argument("--chunk-size", type=int, default=384, help="Raw firmware bytes per base64 serial chunk")
parser.add_argument("--force-current-release", action="store_true", help="Reflash even if release already matches")
return parser.parse_args()
def main() -> int:
args = parse_args()
firmware = args.firmware
if not firmware.exists():
print(f"Firmware file not found: {firmware}", file=sys.stderr)
return 2
targets = [item.strip() for item in args.targets.split(",") if item.strip()] if args.targets else targets_from_subnet(args.subnet)
if args.start_from:
if args.start_from not in targets:
print(f"--start-from target not found: {args.start_from}", file=sys.stderr)
return 2
targets = targets[targets.index(args.start_from) :]
print(f"Opening master serial relay on {args.port} at {args.baud} baud")
with open_master_serial(args.port, args.baud, args.startup_delay) as ser:
for index, target in enumerate(targets, start=1):
print(f"[{index}/{len(targets)}] {target}: checking current release through master...")
try:
info = master_info(ser, target)
current_release = release_from_info(info)
print(f"[{index}/{len(targets)}] {target}: current release {current_release or '-'}")
if current_release == args.expect_release and not args.force_current_release:
print(f"[{index}/{len(targets)}] {target}: SKIP (release already matches)")
continue
except Exception as exc: # noqa: BLE001
print(f"[{index}/{len(targets)}] {target}: info warning: {exc}")
print(f"[{index}/{len(targets)}] {target}: streaming OTA via master...")
relay_ota(ser, target, firmware, args.expect_release, args.chunk_size)
info = wait_for_release(ser, target, args.expect_release, timeout_s=150.0)
print(
f"[{index}/{len(targets)}] {target}: OK "
f"(release {release_from_info(info)}, uptime {info.get('uptime', '-') }s)"
)
print("Master USB relay update completed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -14,7 +14,7 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from typing import Any, Iterable
import requests
@@ -27,12 +27,23 @@ class WledHost:
ip: str
name: str
version: str
release: str
arch: str
@dataclass
class WledInfo(WledHost):
uptime_s: int
raw: dict[str, Any]
@dataclass
class OtaPreflight:
info: WledInfo | None
update_status: str
update_hint: str
firmware_size: int
ota_space_hint: str
def _run(cmd: list[str]) -> str:
@@ -86,8 +97,9 @@ def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None:
except (requests.RequestException, ValueError):
return None
# WLED info endpoint typically includes "name", "ver", and "arch"
# WLED info endpoint typically includes "name", "ver", "release"/"rel", and "arch".
ver = str(data.get("ver", "")).strip()
release = str(data.get("release") or data.get("rel") or "").strip()
name = str(data.get("name", "")).strip()
arch = str(data.get("arch", "")).strip()
if not ver:
@@ -99,14 +111,14 @@ def probe_wled_info(ip: str, timeout_s: float) -> WledInfo | None:
arch = "-"
uptime_s = int(data.get("uptime", 0) or 0)
return WledInfo(ip=ip, name=name, version=ver, arch=arch, uptime_s=uptime_s)
return WledInfo(ip=ip, name=name, version=ver, release=release, arch=arch, uptime_s=uptime_s, raw=data)
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)
return WledHost(ip=info.ip, name=info.name, version=info.version, release=info.release, arch=info.arch)
def discover_hosts(
@@ -143,8 +155,8 @@ def read_targets_file(path: Path) -> list[str]:
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]
lines = ["# ip name version release arch"]
lines += [f"{h.ip} {h.name} {h.version} {h.release or '-'} {h.arch}" for h in hosts]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -176,22 +188,198 @@ def wait_for_online_info(ip: str, timeout_s: float, interval_s: float) -> WledIn
return None
def reboot_confirmed(before: WledInfo | None, after: WledInfo, offline_seen: bool) -> tuple[bool, str]:
def reboot_confirmed(
before: WledInfo | None,
after: WledInfo,
offline_seen: bool,
transport_reset_seen: bool,
expected_release: str | None,
) -> tuple[bool, str]:
if offline_seen:
return True, "offline transition observed"
if before is None:
release_ok, release_reason = release_matches(after.release, expected_release)
if transport_reset_seen and release_ok:
return True, f"transport reset during upload and {release_reason}"
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"
release_ok, release_reason = release_matches(after.release, expected_release)
if transport_reset_seen and release_ok:
return True, (
"transport reset during upload and expected release is present "
f"({before.uptime_s}s -> {after.uptime_s}s; weak proof when flashing the same release)"
)
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]:
def release_matches(actual: str, expected: str | None) -> tuple[bool, str]:
if not expected:
return True, "no expected release configured"
if actual == expected:
return True, f"release matches {expected}"
if not actual:
return False, f"release is not exposed; expected {expected}"
return False, f"release mismatch: expected {expected}, got {actual}"
def _iter_numeric_fields(value: Any, prefix: str = "") -> Iterable[tuple[str, int]]:
if isinstance(value, dict):
for key, nested in value.items():
nested_prefix = f"{prefix}.{key}" if prefix else str(key)
yield from _iter_numeric_fields(nested, nested_prefix)
elif isinstance(value, list):
for index, nested in enumerate(value):
yield from _iter_numeric_fields(nested, f"{prefix}[{index}]")
elif isinstance(value, (int, float)) and not isinstance(value, bool):
yield prefix, int(value)
def ota_space_hint(info: WledInfo | None, firmware_size: int) -> str:
if info is None:
return "unknown, /json/info was not reachable"
candidates: list[tuple[str, int]] = []
for key, value in _iter_numeric_fields(info.raw):
lowered = key.lower()
# Runtime memory fields such as totalheap/freeheap are not OTA slots.
# They can contain the substring "ota" (for example "totalheap"), so
# exclude them before looking for OTA-related names.
if any(token in lowered for token in ("heap", "psram", "ram")):
continue
if any(token in lowered for token in ("sketch", "ota", "update")) and value > 0:
candidates.append((key, value))
# WLED does not consistently expose ESP.getFreeSketchSpace() in /json/info.
if not candidates:
return "not exposed by this firmware; if OTA still fails, USB-clean-flash may be required"
best_key, best_value = max(candidates, key=lambda item: item[1])
if best_value < firmware_size:
return f"WARNING: {best_key}={best_value} bytes is smaller than firmware={firmware_size} bytes"
return f"{best_key}={best_value} bytes, firmware={firmware_size} bytes"
def probe_update_page(ip: str, timeout_s: float) -> tuple[str, str]:
url = f"http://{ip}/update"
try:
resp = requests.get(url, timeout=timeout_s)
except requests.RequestException as exc:
return "unreachable", f"GET /update failed: {exc}"
text = (resp.text or "").lower()
if resp.status_code in (401, 403):
return "blocked", f"HTTP {resp.status_code}; OTA may be locked or authentication may be required"
if resp.status_code >= 400:
return "warning", f"HTTP {resp.status_code}"
if any(token in text for token in ("ota lock", "ota locked", "locked", "forbidden", "incorrect pin")):
return "blocked", "update page suggests OTA is locked or PIN/auth is required"
if any(token in text for token in ("update", "upload", "firmware")):
return "ok", "update page reachable"
return "warning", "update page reachable, but expected upload form text was not detected"
def preflight(ip: str, firmware: Path, timeout_s: float) -> OtaPreflight:
firmware_size = firmware.stat().st_size
info = probe_wled_info(ip, timeout_s=timeout_s)
update_status, update_hint = probe_update_page(ip, timeout_s=timeout_s)
return OtaPreflight(
info=info,
update_status=update_status,
update_hint=update_hint,
firmware_size=firmware_size,
ota_space_hint=ota_space_hint(info, firmware_size),
)
def print_preflight(index: int, total: int, ip: str, result: OtaPreflight) -> None:
prefix = f"[{index}/{total}] {ip}"
if result.info is None:
print(f"{prefix}: /json/info unavailable")
else:
print(
f"{prefix}: current firmware {result.info.version}, release '{result.info.release or '-'}', uptime {result.info.uptime_s}s, "
f"name '{result.info.name}', arch '{result.info.arch or '-'}'"
)
print(f"{prefix}: firmware size {result.firmware_size} bytes")
print(f"{prefix}: OTA space hint: {result.ota_space_hint}")
print(f"{prefix}: /update preflight: {result.update_status} ({result.update_hint})")
def request_reboot(ip: str, timeout_s: float) -> tuple[bool, str]:
url = f"http://{ip}/json/state"
try:
resp = requests.post(url, json={"rb": True}, timeout=timeout_s)
except requests.RequestException as exc:
return False, f"reboot request failed: {exc}"
if resp.status_code >= 400:
return False, f"reboot request returned HTTP {resp.status_code}"
return True, "reboot requested via /json/state"
def classify_update_response(text: str) -> tuple[str, str]:
normalized = " ".join((text or "").strip().split())
lowered = normalized.lower()
snippet = normalized[:180]
if "update successful" in lowered or "rebooting" in lowered:
return "ok", "ok"
if "update failed" in lowered or "could not activate the firmware" in lowered:
return "failed", f"device reported update failure: {snippet or 'empty response'}"
# WLED message pages contain generic scripts and wording; unknown 200 OK
# responses are verified by the reboot/release checks instead of string
# guessing here.
return "ok", "ok"
def ota_flash(
ip: str,
firmware: Path,
connect_timeout_s: float,
read_timeout_s: float,
skip_validation: bool,
backend: str,
) -> tuple[str, str]:
# Send skipValidation both as query and multipart field. Different WLED-MM
# builds have used different request parameter paths around OTA validation.
url = f"http://{ip}/update"
if skip_validation:
url += "?skipValidation=1"
if backend == "curl":
cmd = [
"curl",
"-sS",
"--connect-timeout",
str(max(1, int(connect_timeout_s))),
"--max-time",
str(max(1, int(read_timeout_s))),
"-F",
f"update=@{firmware}",
]
if skip_validation:
cmd += ["-F", "skipValidation=1"]
cmd.append(url)
try:
proc = subprocess.run(cmd, check=False, capture_output=True, text=True)
except OSError as exc:
return "failed", f"curl unavailable or failed to start: {exc}"
combined = " ".join((proc.stdout + " " + proc.stderr).strip().split())
snippet = combined[:180]
if proc.returncode == 0:
return classify_update_response(combined)
# WLED often closes the socket or reboots before curl receives a clean
# response. Treat transport drops as uncertain and prove success later.
if proc.returncode in (52, 55, 56):
return "transport_reset", f"curl exit {proc.returncode}: {snippet or 'connection dropped during/after upload'}"
if proc.returncode == 28:
return "uncertain", f"curl exit {proc.returncode}: {snippet or 'upload timed out'}"
return "failed", f"curl exit {proc.returncode}: {snippet or 'empty response'}"
try:
with firmware.open("rb") as fh:
resp = requests.post(
url,
data={"skipValidation": "1"} if skip_validation else None,
files={"update": (firmware.name, fh, "application/octet-stream")},
timeout=(connect_timeout_s, read_timeout_s),
)
@@ -200,16 +388,15 @@ def ota_flash(ip: str, firmware: Path, connect_timeout_s: float, read_timeout_s:
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"
return "transport_reset", "connection dropped during/after upload"
except requests.RequestException as exc:
return "failed", f"request failed: {exc}"
text = (resp.text or "").lower()
text = resp.text or ""
snippet = " ".join(text.strip().split())[:180]
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"
return "failed", f"http {resp.status_code}: {snippet or 'empty response'}"
return classify_update_response(text)
def print_hosts(hosts: list[WledHost]) -> None:
@@ -217,10 +404,11 @@ def print_hosts(hosts: list[WledHost]) -> None:
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)
print(f"{'IP':<16} {'Name':<24} {'Version':<18} {'Release':<30} {'Arch'}")
print("-" * 112)
for h in hosts:
print(f"{h.ip:<16} {h.name[:24]:<24} {h.version[:18]:<18} {h.arch}")
release = h.release or "-"
print(f"{h.ip:<16} {h.name[:24]:<24} {h.version[:18]:<18} {release[:30]:<30} {h.arch}")
def cmd_discover(args: argparse.Namespace) -> int:
@@ -285,35 +473,64 @@ def cmd_flash(args: argparse.Namespace) -> int:
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}'"
)
check = preflight(ip=ip, firmware=firmware, timeout_s=args.timeout)
print_preflight(idx, len(targets), ip, check)
before = check.info
if args.preflight_only:
if before is not None and args.expect_release:
release_ok, release_reason = release_matches(before.release, args.expect_release)
level = "ok" if release_ok else "warning"
print(f"[{idx}/{len(targets)}] {ip}: current release check: {level} ({release_reason})")
if before is None or check.update_status in ("blocked", "unreachable"):
print(f"[{idx}/{len(targets)}] {ip}: FAILED (preflight did not prove OTA readiness)")
failures.append(ip)
continue
if check.update_status == "blocked" and not args.ignore_preflight_warnings:
print(f"[{idx}/{len(targets)}] {ip}: FAILED (OTA preflight is blocked; use --ignore-preflight-warnings to try anyway)")
failures.append(ip)
continue
if args.skip_if_release_matches and before is not None and args.expect_release:
release_ok, release_reason = release_matches(before.release, args.expect_release)
if release_ok:
print(f"[{idx}/{len(targets)}] {ip}: SKIP ({release_reason}; use --force-same-release to reflash anyway)")
continue
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,
skip_validation=args.skip_validation,
backend=args.upload_backend,
)
if status == "failed":
print(f"[{idx}/{len(targets)}] {ip}: FAILED ({msg})")
failures.append(ip)
continue
if status == "uncertain":
transport_reset_seen = status == "transport_reset"
if status in ("uncertain", "transport_reset"):
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)
forced_reboot_used = False
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...")
if args.force_reboot_after_upload:
reboot_sent, reboot_msg = request_reboot(ip=ip, timeout_s=args.timeout)
print(f"[{idx}/{len(targets)}] {ip}: forced reboot fallback: {reboot_msg}")
if reboot_sent:
forced_reboot_used = True
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}: forced reboot detected, device went offline.")
after = wait_for_online_info(ip=ip, timeout_s=args.online_timeout, interval_s=1.0)
if after is None:
@@ -321,16 +538,30 @@ def cmd_flash(args: argparse.Namespace) -> int:
failures.append(ip)
continue
reboot_ok, reason = reboot_confirmed(before=before, after=after, offline_seen=offline_seen)
reboot_ok, reason = reboot_confirmed(
before=before,
after=after,
offline_seen=offline_seen,
transport_reset_seen=transport_reset_seen,
expected_release=args.expect_release,
)
if not reboot_ok:
print(f"[{idx}/{len(targets)}] {ip}: FAILED (could not prove reboot: {reason})")
failures.append(ip)
continue
release_ok, release_reason = release_matches(after.release, args.expect_release)
if not release_ok:
print(f"[{idx}/{len(targets)}] {ip}: FAILED ({release_reason})")
failures.append(ip)
continue
print(
f"[{idx}/{len(targets)}] {ip}: OK "
f"(now {after.version}, uptime {after.uptime_s}s, {reason})"
f"(now {after.version}, release '{after.release or '-'}', uptime {after.uptime_s}s, {reason}, {release_reason})"
)
if forced_reboot_used:
print(f"[{idx}/{len(targets)}] {ip}: warning, reboot was forced after an uncertain upload; verify the firmware manually in /json/info")
if failures:
print("\nFailed targets:")
@@ -338,7 +569,10 @@ def cmd_flash(args: argparse.Namespace) -> int:
print(f"- {ip}")
return 1
print("\nAll targets flashed successfully.")
if args.preflight_only:
print("\nAll targets passed preflight.")
else:
print("\nAll targets flashed successfully.")
return 0
@@ -367,6 +601,15 @@ def build_parser() -> argparse.ArgumentParser:
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.add_argument("--preflight-only", action="store_true", help="Only print /json/info and /update diagnostics, do not upload")
p_flash.add_argument("--ignore-preflight-warnings", action="store_true", help="Try upload even if /update preflight looks blocked")
p_flash.add_argument("--force-reboot-after-upload", action="store_true", help="After uncertain upload with no offline transition, request reboot via /json/state and verify uptime")
p_flash.add_argument("--expect-release", help="Require /json/info release/rel to match this value after OTA")
p_flash.add_argument("--skip-if-release-matches", action="store_true", help="Skip a target when its current release already matches --expect-release")
p_flash.add_argument("--force-same-release", dest="skip_if_release_matches", action="store_false", help="Reflash even when the current release already matches --expect-release")
p_flash.add_argument("--skip-validation", action="store_true", help="Send WLED skipValidation=1 for controlled migrations between release names")
p_flash.add_argument("--upload-backend", choices=("curl", "requests"), default="curl", help="HTTP upload implementation (default: curl, matching WLED helper scripts)")
p_flash.set_defaults(skip_if_release_matches=False)
p_flash.set_defaults(func=cmd_flash)
return parser

View File

@@ -17,8 +17,10 @@ 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"
NODE_RELEASE = "RFP_N16R8_NODE3x106_V20260511E"
MASTER_RELEASE = "RFP_N16R8_MASTER_V20260511E"
NODE_FIRMWARE_FALLBACK = Path(".pio/build") / NODE_ENV / "firmware.bin"
MASTER_FIRMWARE_FALLBACK = Path(".pio/build") / MASTER_ENV / "firmware.bin"
DEFAULT_SUBNET = "192.168.178.0/24"
NODE_HOSTS = range(11, 17)
MASTER_HOST = 10
@@ -68,26 +70,107 @@ 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:
def release_firmware(root: Path, expected_release: str, fallback: Path) -> Path:
release_dir = root / "build_output/release"
matches = sorted(release_dir.glob(f"WLEDMM_*_{expected_release}.bin"))
if len(matches) == 1:
return matches[0].relative_to(root)
if len(matches) > 1:
names = ", ".join(str(path.relative_to(root)) for path in matches)
raise RuntimeError(f"Multiple release firmware files match {expected_release}: {names}")
return fallback
def flash_one(
root: Path,
target: str,
firmware: Path,
expected_release: str,
env: dict[str, str],
args: argparse.Namespace,
) -> None:
firmware_path = root / firmware
if not args.dry_run and not firmware_path.exists():
raise FileNotFoundError(f"Firmware does not exist: {firmware_path}")
cmd = [
python_executable(root),
"tools/rfp_network_flash.py",
"flash",
"--targets",
target,
"--firmware",
str(firmware),
"--expect-release",
expected_release,
]
if args.preflight_only:
cmd.append("--preflight-only")
if args.skip_validation and not args.preflight_only:
cmd.append("--skip-validation")
if args.force_reboot_after_upload:
cmd.append("--force-reboot-after-upload")
if args.ignore_preflight_warnings:
cmd.append("--ignore-preflight-warnings")
if args.skip_current_release and not args.preflight_only:
cmd.append("--skip-if-release-matches")
if args.upload_timeout is not None:
cmd += ["--upload-timeout", str(args.upload_timeout)]
run(cmd, root, env, args.dry_run)
def flash(
root: Path,
targets: list[str],
firmware: Path,
expected_release: str,
env: dict[str, str],
args: argparse.Namespace,
) -> 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,
)
for target in targets:
try:
flash_one(root, target, firmware, expected_release, env, args)
except subprocess.CalledProcessError as exc:
print()
print(f"OTA failed at {target}; stopping before continuing to the remaining devices.")
print("After fixing the cause, resume with:")
print(f" {python_executable(root)} tools/rfp_update_all_ota.py --no-build --start-from {target}")
raise exc
def flash_nodes_via_master_usb(
root: Path,
targets: list[str],
firmware: Path,
expected_release: str,
env: dict[str, str],
args: argparse.Namespace,
) -> None:
if not targets:
return
cmd = [
python_executable(root),
"tools/rfp_master_usb_relay.py",
"--port",
args.port,
"--firmware",
str(firmware),
"--expect-release",
expected_release,
"--targets",
",".join(targets),
"--baud",
str(args.relay_baud),
"--startup-delay",
str(args.relay_startup_delay),
]
if args.start_from:
cmd += ["--start-from", args.start_from]
if not args.skip_current_release:
cmd.append("--force-current-release")
run(cmd, root, env, args.dry_run)
def filtered_plan(nodes: list[str], master: str, args: argparse.Namespace) -> list[tuple[str, str]]:
@@ -115,6 +198,17 @@ def parse_args() -> argparse.Namespace:
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")
parser.add_argument("--preflight-only", action="store_true", help="Only run per-device OTA diagnostics, do not upload")
parser.add_argument("--via-master-usb", action="store_true", help="Update nodes through the USB-connected master instead of direct PC-to-node HTTP")
parser.add_argument("--port", default="/dev/ttyACM0", help="Master serial port for --via-master-usb")
parser.add_argument("--relay-baud", type=int, default=921600, help="Master USB relay baud rate (default: 921600)")
parser.add_argument("--relay-startup-delay", type=float, default=4.0, help="Seconds to wait after opening master USB serial (default: 4)")
parser.add_argument("--ignore-preflight-warnings", action="store_true", help="Try OTA even if /update preflight looks blocked")
parser.add_argument("--force-reboot-after-upload", action="store_true", help="Request a reboot if upload response is uncertain and no reboot is observed")
parser.add_argument("--force-current-release", dest="skip_current_release", action="store_false", help="Reflash devices even when their current release already matches the selected firmware")
parser.add_argument("--upload-timeout", type=float, default=180.0, help="Per-device OTA upload timeout in seconds (default: 180)")
parser.add_argument("--no-skip-validation", dest="skip_validation", action="store_false", help="Do not send WLED skipValidation=1 during upload")
parser.set_defaults(skip_validation=True, skip_current_release=True)
return parser.parse_args()
@@ -144,12 +238,36 @@ def main() -> int:
print(f"Building master firmware: {MASTER_ENV}")
build(root, MASTER_ENV, env, args.dry_run)
node_firmware = release_firmware(root, NODE_RELEASE, NODE_FIRMWARE_FALLBACK)
master_firmware = release_firmware(root, MASTER_RELEASE, MASTER_FIRMWARE_FALLBACK)
print(f"Node firmware: {node_firmware} (expect release {NODE_RELEASE})")
print(f"Master firmware: {master_firmware} (expect release {MASTER_RELEASE})")
if args.skip_validation and not args.preflight_only:
print("WLED OTA release validation: skipValidation=1 for controlled RFP release-name migration")
if args.skip_current_release and not args.preflight_only:
print("Already matching devices: skipped by default; use --force-current-release to reflash them")
print()
if node_targets:
print("Flashing nodes sequentially...")
flash(root, node_targets, NODE_FIRMWARE, env, args.dry_run)
if args.via_master_usb:
if args.preflight_only:
print("USB master relay preflight is not separate; run without --preflight-only to query through the master.")
else:
print("Flashing nodes sequentially via USB-connected master...")
flash_nodes_via_master_usb(root, node_targets, node_firmware, NODE_RELEASE, env, args)
else:
print(("Preflighting" if args.preflight_only else "Flashing") + " nodes sequentially...")
flash(root, node_targets, node_firmware, NODE_RELEASE, env, args)
if master_targets:
print("Flashing master last...")
flash(root, master_targets, MASTER_FIRMWARE, env, args.dry_run)
if args.via_master_usb:
print(
"Skipping master firmware in --via-master-usb mode. "
"For full updates, use tools/rfp_update_master_usb_then_nodes.py "
"to flash the master by USB first and then relay node OTA."
)
else:
print(("Preflighting" if args.preflight_only else "Flashing") + " master last...")
flash(root, master_targets, master_firmware, MASTER_RELEASE, env, args)
print("OTA update plan completed.")
return 0

View File

@@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""Flash the USB-connected master first, then update nodes through it.
This is the preferred RFP show update path when the laptop is connected to the
master by USB but does not need to join the RFP Wi-Fi. It intentionally avoids
`erase_flash` and `uploadfs`; normal updates only replace bootloader,
partition table, boot_app0 and the app image.
"""
from __future__ import annotations
import argparse
import glob
import subprocess
import sys
import time
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parent
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
import rfp_update_all_ota as ota # noqa: E402
from rfp_master_usb_relay import ( # noqa: E402
DEFAULT_BAUD as DEFAULT_RELAY_BAUD,
DEFAULT_STARTUP_DELAY as DEFAULT_RELAY_STARTUP_DELAY,
master_info,
open_master_serial,
relay_ota,
release_from_info,
wait_for_release,
)
DEFAULT_FLASH_BAUD = 460800
BOOT_APP0 = Path(".piohome/packages/framework-arduinoespressif32/tools/partitions/boot_app0.bin")
def detect_port() -> str:
ports = sorted(glob.glob("/dev/ttyACM*") + glob.glob("/dev/ttyUSB*"))
if not ports:
raise RuntimeError("Kein /dev/ttyACM* oder /dev/ttyUSB* gefunden")
return ports[0]
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 maybe_stop_modemmanager(root: Path, env: dict[str, str], args: argparse.Namespace) -> None:
if args.no_stop_modemmanager:
return
print("Stopping ModemManager temporarily, if present...")
cmd = ["sudo", "systemctl", "stop", "ModemManager"]
print("+ " + " ".join(cmd))
if not args.dry_run:
subprocess.run(cmd, cwd=root, env=env, check=False)
def build_selected(root: Path, env: dict[str, str], args: argparse.Namespace, node_targets: list[str]) -> None:
if args.no_build:
return
if not args.nodes_only:
print(f"Building master firmware: {ota.MASTER_ENV}")
ota.build(root, ota.MASTER_ENV, env, args.dry_run)
if node_targets and not args.master_only:
print(f"Building node firmware: {ota.NODE_ENV}")
ota.build(root, ota.NODE_ENV, env, args.dry_run)
def require_file(root: Path, path: Path, label: str, dry_run: bool) -> None:
if dry_run:
return
full = root / path
if not full.exists():
raise FileNotFoundError(f"{label} fehlt: {full}")
def wait_for_port(port: str, timeout_s: float, dry_run: bool) -> None:
if dry_run:
return
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if Path(port).exists():
return
time.sleep(0.25)
raise TimeoutError(f"Serial-Port {port} ist nach {timeout_s:.0f}s nicht wieder erschienen")
def flash_master_usb(root: Path, master_firmware: Path, env: dict[str, str], args: argparse.Namespace) -> None:
build_dir = Path(".pio/build") / ota.MASTER_ENV
bootloader = build_dir / "bootloader.bin"
partitions = build_dir / "partitions.bin"
require_file(root, bootloader, "Master bootloader.bin", args.dry_run)
require_file(root, partitions, "Master partitions.bin", args.dry_run)
require_file(root, BOOT_APP0, "boot_app0.bin", args.dry_run)
require_file(root, master_firmware, "Master firmware", args.dry_run)
print("Flashing master over USB without erase_flash or uploadfs...")
cmd = [
ota.python_executable(root),
".piohome/packages/tool-esptoolpy/esptool.py",
"--chip",
"esp32s3",
"--port",
args.port,
"--baud",
str(args.flash_baud),
"--before",
"default_reset",
"--after",
"hard_reset",
"write_flash",
"-z",
"--flash_mode",
"qio",
"--flash_freq",
"80m",
"--flash_size",
"16MB",
"0x0",
str(bootloader),
"0x8000",
str(partitions),
"0xe000",
str(BOOT_APP0),
"0x10000",
str(master_firmware),
]
run(cmd, root, env, args.dry_run)
wait_for_port(args.port, args.port_timeout, args.dry_run)
def wait_for_master_release(ser, master_ip: str, expected_release: str, timeout_s: float) -> dict:
deadline = time.monotonic() + timeout_s
last_error = ""
while time.monotonic() < deadline:
try:
info = master_info(ser, master_ip, timeout_s=8.0)
current = release_from_info(info)
if current == expected_release:
return info
last_error = f"release is {current!r}"
except Exception as exc: # noqa: BLE001 - keep retrying while Wi-Fi starts
last_error = str(exc)
time.sleep(2.0)
raise TimeoutError(f"Master release {expected_release} wurde nicht sichtbar ({last_error})")
def update_nodes_with_open_master(ser, targets: list[str], node_firmware: Path, args: argparse.Namespace) -> None:
if not targets:
return
firmware = ota.repo_root() / node_firmware
for index, target in enumerate(targets, start=1):
print(f"[{index}/{len(targets)}] {target}: checking current release through master...")
try:
info = master_info(ser, target, timeout_s=8.0)
current_release = release_from_info(info)
print(f"[{index}/{len(targets)}] {target}: current release {current_release or '-'}")
if current_release == ota.NODE_RELEASE and not args.force_current_release:
print(f"[{index}/{len(targets)}] {target}: SKIP (release already matches)")
continue
except Exception as exc: # noqa: BLE001
print(f"[{index}/{len(targets)}] {target}: info warning: {exc}")
print(f"[{index}/{len(targets)}] {target}: streaming OTA via master...")
relay_ota(ser, target, firmware, ota.NODE_RELEASE, args.chunk_size)
info = wait_for_release(ser, target, ota.NODE_RELEASE, timeout_s=args.node_release_timeout)
print(
f"[{index}/{len(targets)}] {target}: OK "
f"(release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)"
)
def selected_node_targets(nodes: list[str], args: argparse.Namespace) -> list[str]:
if args.master_only:
return []
if not args.start_from:
return nodes
if args.start_from not in nodes:
raise ValueError(f"--start-from {args.start_from} ist keine Node-IP in diesem Plan")
return nodes[nodes.index(args.start_from) :]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Preferred RFP update: flash master by USB first, then update nodes via the master."
)
role = parser.add_mutually_exclusive_group()
role.add_argument("--master-only", action="store_true", help="Only flash and verify the USB-connected master")
role.add_argument("--nodes-only", action="store_true", help="Skip master flashing and only update nodes via the USB-connected master")
parser.add_argument("--port", help="Master serial port, for example /dev/ttyACM0. Auto-detected when omitted")
parser.add_argument("--subnet", default=ota.DEFAULT_SUBNET, help=f"Show subnet (default: {ota.DEFAULT_SUBNET})")
parser.add_argument("--start-from", help="Resume node updates from this IP, for example 192.168.178.14")
parser.add_argument("--no-build", action="store_true", help="Use existing build_output/release binaries")
parser.add_argument("--dry-run", action="store_true", help="Print steps without flashing")
parser.add_argument("--flash-baud", type=int, default=DEFAULT_FLASH_BAUD, help=f"USB esptool baud (default: {DEFAULT_FLASH_BAUD})")
parser.add_argument("--relay-baud", type=int, default=DEFAULT_RELAY_BAUD, help=f"Master relay baud (default: {DEFAULT_RELAY_BAUD})")
parser.add_argument(
"--relay-startup-delay",
type=float,
default=DEFAULT_RELAY_STARTUP_DELAY,
help=f"Seconds to wait after opening master serial (default: {DEFAULT_RELAY_STARTUP_DELAY})",
)
parser.add_argument("--master-release-timeout", type=float, default=90.0, help="Seconds to wait for master release after USB flash")
parser.add_argument("--node-release-timeout", type=float, default=150.0, help="Seconds to wait for each node release after OTA")
parser.add_argument("--port-timeout", type=float, default=20.0, help="Seconds to wait for serial port after USB reset")
parser.add_argument("--chunk-size", type=int, default=384, help="Raw firmware bytes per base64 relay chunk")
parser.add_argument("--force-current-release", action="store_true", help="Reflash nodes even when release already matches")
parser.add_argument("--no-stop-modemmanager", action="store_true", help="Do not stop ModemManager before USB flashing")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = ota.repo_root()
env = ota.platformio_env(root)
nodes, master = ota.targets_from_subnet(args.subnet)
node_targets = selected_node_targets(nodes, args)
if not args.port:
args.port = detect_port()
print("RFP Infinity USB-master update plan:")
if not args.nodes_only:
print(f"- {master} (master via USB on {args.port})")
if node_targets:
for ip in node_targets:
print(f"- {ip} (node via master relay)")
print()
build_selected(root, env, args, node_targets)
node_firmware = ota.release_firmware(root, ota.NODE_RELEASE, ota.NODE_FIRMWARE_FALLBACK)
master_firmware = ota.release_firmware(root, ota.MASTER_RELEASE, ota.MASTER_FIRMWARE_FALLBACK)
print(f"Master firmware: {master_firmware} (expect release {ota.MASTER_RELEASE})")
print(f"Node firmware: {node_firmware} (expect release {ota.NODE_RELEASE})")
print("Master USB flash: no erase_flash, no uploadfs, hard reset after app flash")
print()
if not args.nodes_only:
maybe_stop_modemmanager(root, env, args)
flash_master_usb(root, master_firmware, env, args)
if args.master_only and not node_targets:
print("Opening master serial to verify release...")
if not args.dry_run:
with open_master_serial(args.port, args.relay_baud, args.relay_startup_delay) as ser:
info = wait_for_master_release(ser, master, ota.MASTER_RELEASE, args.master_release_timeout)
print(f"Master OK (release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)")
print("Master USB update completed.")
return 0
print(f"Opening master serial relay on {args.port} at {args.relay_baud} baud")
if args.dry_run:
print("Dry run complete.")
return 0
with open_master_serial(args.port, args.relay_baud, args.relay_startup_delay) as ser:
info = wait_for_master_release(ser, master, ota.MASTER_RELEASE, args.master_release_timeout)
print(f"Master OK (release {release_from_info(info)}, uptime {info.get('uptime', '-')}s)")
update_nodes_with_open_master(ser, node_targets, node_firmware, args)
print("USB-master-first update completed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

36
tools/setup_rfp_env.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
NODE_BIN="/home/jan/Documents/RFP/Finanz_App/node/current/bin"
ENV_NAME="rfp_esp32s3_wroom1_n16r8_3x106"
cd "$ROOT_DIR"
if [[ ! -x ".venv/bin/python" ]]; then
python3 -m venv .venv
fi
.venv/bin/pip install -r requirements.txt
if [[ -x "${NODE_BIN}/node" ]]; then
export PATH="${NODE_BIN}:$PATH"
fi
export NPM_CONFIG_CACHE="$ROOT_DIR/.npm-cache"
npm ci
export PLATFORMIO_CORE_DIR="$ROOT_DIR/.piohome"
export PLATFORMIO_PACKAGES_DIR="$ROOT_DIR/.piohome/packages"
export PLATFORMIO_PLATFORMS_DIR="$ROOT_DIR/.piohome/platforms"
export PLATFORMIO_CACHE_DIR="$ROOT_DIR/.piohome/.cache"
export PLATFORMIO_BUILD_CACHE_DIR="$ROOT_DIR/.piohome/buildcache"
.venv/bin/python -m platformio run -e "$ENV_NAME"
cat <<'EOF'
Setup complete.
Firmware output:
.pio/build/rfp_esp32s3_wroom1_n16r8_3x106/firmware.bin
EOF