From b10958def7cd14e68d20e89c38c3fb571aacbdaa Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 13 May 2026 13:03:51 +0200 Subject: [PATCH] fix(nodered): kiosk-side layout.changed events + provisioning retries Three related fixes: 1. Idle reverts (and any other kiosk-initiated layout switch) now POST layout.changed to /api/kiosk/event. Previously the server only emitted on admin-initiated switches, so Node-RED never saw the idle revert. 2. Server's /api/kiosk/event splays the payload to the top level when the topic has a dedicated trigger node (layout.changed, kiosk.changed, kiosk.status, display.power.changed, camera.changed). The trigger nodes expect flat shapes matching the admin emit; the old wrapped shape left every field undefined. 3. Auto-provisioning of bf-server-config in Node-RED: extend retry window to ~5 min, log per attempt, force v2 API + full-deploy header so credentials inline get accepted, surface response body on failure. --- kiosk/src/server.rs | 28 ++++++++++++++ kiosk/src/ui.rs | 17 ++++++++- .../src/plugins/service-admin-http/index.ts | 10 +++-- server/src/plugins/service-api-http/index.ts | 38 ++++++++++++++----- server/src/shared/nodered-bridge.ts | 23 +++++++---- 5 files changed, 94 insertions(+), 22 deletions(-) diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index 314cb83..1333cc0 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -193,6 +193,34 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option { } /// Send heartbeat with display geometry + hwmon. +/// Report a kiosk-side layout switch to the server, which forwards to +/// node-red as a `layout.changed` event. Covers idle reverts and any other +/// switch the kiosk performs without an admin click (admin clicks already +/// emit server-side). +pub fn report_layout_change( + server: &str, + key: &str, + display_id: u32, + layout_id: u32, + layout_name: &str, +) { + let client = reqwest::blocking::Client::new(); + let _ = client + .post(format!("{server}/api/kiosk/event")) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ + "topic": "layout.changed", + "source_type": "system", + "payload": { + "display_id": display_id, + "layout_id": layout_id, + "layout_name": layout_name, + }, + })) + .timeout(Duration::from_secs(5)) + .send(); +} + pub fn heartbeat( server: &str, key: &str, diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 4d19f57..8607555 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -562,15 +562,30 @@ fn render_layout(display_id: u32, layout_id: u32) { // Update per-display layout id BEFORE recomputing warm-cameras so the // union across displays is correct. - DISPLAYS.with(|ds| { + let previous_layout_id = DISPLAYS.with(|ds| { + let prev = ds.borrow().get(&display_id).and_then(|s| s.current_layout_id); if let Some(st) = ds.borrow_mut().get_mut(&display_id) { st.current_layout_id = Some(layout.id); } + prev }); info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)", layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len()); + // Notify the server when the active layout actually changes so Node-RED + // sees idle reverts + any other kiosk-initiated switch. Skip when the + // layout id is unchanged (re-render of the same layout). + if previous_layout_id != Some(layout.id) { + let layout_name = layout.name.clone(); + let layout_id_for_report = layout.id; + let server = server_url.clone(); + let key = kiosk_key.clone(); + std::thread::spawn(move || { + server::report_layout_change(&server, &key, display_id, layout_id_for_report, &layout_name); + }); + } + if layout.cells.is_empty() { warn!("layout has no cells"); recompute_global_state(); diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 388f64c..ac462ff 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -224,10 +224,15 @@ export class Plugin extends BSBService, typeof Event return; } - // Retry a few times — Node-RED may still be booting. - const delaysMs = [2000, 5000, 10000, 30000]; + // Retry with backoff — Node-RED may still be booting + initial flow load + // can take 30-60s on the Pi. Total wait ~5 minutes worst case. + const delaysMs = [2000, 5000, 10000, 15000, 30000, 30000, 60000, 60000, 60000]; for (let attempt = 0; attempt < delaysMs.length; attempt += 1) { await new Promise((r) => setTimeout(r, delaysMs[attempt])); + obs.log.info("nodered: provisioning attempt {n} → {url}", { + n: attempt + 1, + url: this.config.selfUrl, + }); const result = await nodered.ensureServerConfig(this.config.selfUrl, plaintext); if (result === "created") { obs.log.info("nodered: provisioned bf-server-config at {url}", { @@ -239,7 +244,6 @@ export class Plugin extends BSBService, typeof Event obs.log.info("nodered: bf-server-config already present, skipping"); return; } - // failed — retry } obs.log.warn("nodered: provisioning bf-server-config gave up after retries"); } diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 4ac337d..dfc7f95 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -348,16 +348,34 @@ function registerKioskRoutes( forwarded_to_nodered: false, }); - // Best-effort forward to Node-RED - nodered.forward(body.topic, { - event_id: eventId, - kiosk_id: kiosk.id, - camera_id: body.camera_id ?? null, - source_type: body.source_type ?? "system", - property_op: body.property_op ?? null, - payload: body.payload ?? {}, - timestamp: new Date().toISOString(), - }); + // Best-effort forward to Node-RED. Topics that have a dedicated trigger + // node (bf-trigger-layout-changed etc.) expect a FLAT payload matching + // what the admin-side emit produces — splat body.payload up to the top + // level and add kiosk_id. Generic camera events keep the wrapped shape + // the bf-kiosk-camera-event trigger consumes. + const flatTopics = new Set([ + "layout.changed", + "kiosk.changed", + "kiosk.status", + "display.power.changed", + "camera.changed", + ]); + if (flatTopics.has(body.topic)) { + nodered.forward(body.topic, { + kiosk_id: kiosk.id, + ...(body.payload ?? {}), + }); + } else { + nodered.forward(body.topic, { + event_id: eventId, + kiosk_id: kiosk.id, + camera_id: body.camera_id ?? null, + source_type: body.source_type ?? "system", + property_op: body.property_op ?? null, + payload: body.payload ?? {}, + timestamp: new Date().toISOString(), + }); + } return { ok: true, event_id: eventId }; }); diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 0f1eae6..01ebb80 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -142,19 +142,22 @@ async function provisionServerConfig( const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeoutMs); try { - // GET current flows + revision + // GET current flows + revision. Use the "full" format so the response is + // always {flows, rev} — the bare default in some Node-RED versions returns + // a plain array which has no rev for the POST. const getResp = await fetch(`${base}/nrdp/flows`, { method: "GET", - headers: { accept: "application/json" }, + headers: { + accept: "application/json", + "node-red-api-version": "v2", + }, signal: ctrl.signal, }); if (!getResp.ok) throw new Error(`GET /flows HTTP ${String(getResp.status)}`); - // Node-RED returns either an array (legacy) or {flows, rev} (current). const raw = (await getResp.json()) as NoderedFlowNode[] | { flows: NoderedFlowNode[]; rev?: string }; const flows: NoderedFlowNode[] = Array.isArray(raw) ? raw : (raw.flows ?? []); const rev: string | undefined = Array.isArray(raw) ? undefined : raw.rev; - // Skip if ANY bf-server-config already exists — user owns it. if (flows.some((n) => n.type === "bf-server-config")) { return "exists"; } @@ -164,8 +167,8 @@ async function provisionServerConfig( type: "bf-server-config", name: "BetterFrame (auto)", server_url: serverUrl.replace(/\/+$/, ""), - // Credentials get peeled off by Node-RED's runtime and stored encrypted - // into flows_cred.json. The node body keeps only non-credential fields. + // Node-RED extracts `credentials` on POST /flows and stores them in + // flows_cred.json. Confirmed by the editor's own save path. credentials: { api_key: apiKey }, }; @@ -179,12 +182,16 @@ async function provisionServerConfig( headers: { "content-type": "application/json", accept: "application/json", - "node-red-deployment-type": "nodes", + "node-red-api-version": "v2", + "node-red-deployment-type": "full", }, body: JSON.stringify(body), signal: ctrl.signal, }); - if (!postResp.ok) throw new Error(`POST /flows HTTP ${String(postResp.status)}`); + if (!postResp.ok) { + const text = await postResp.text().catch(() => ""); + throw new Error(`POST /flows HTTP ${String(postResp.status)}: ${text.slice(0, 200)}`); + } return "created"; } finally { clearTimeout(t);