Backup RFP Infinity controller state before Resolume changes
This commit is contained in:
@@ -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
219
tools/rfp_master_usb_relay.py
Executable 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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
272
tools/rfp_update_master_usb_then_nodes.py
Executable file
272
tools/rfp_update_master_usb_then_nodes.py
Executable 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
36
tools/setup_rfp_env.sh
Executable 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
|
||||
Reference in New Issue
Block a user