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.
This commit is contained in:
Mitchell R 2026-05-13 13:03:51 +02:00
parent 77b58c07fd
commit b10958def7
5 changed files with 94 additions and 22 deletions

View file

@ -193,6 +193,34 @@ pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
} }
/// Send heartbeat with display geometry + hwmon. /// 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( pub fn heartbeat(
server: &str, server: &str,
key: &str, key: &str,

View file

@ -562,15 +562,30 @@ fn render_layout(display_id: u32, layout_id: u32) {
// Update per-display layout id BEFORE recomputing warm-cameras so the // Update per-display layout id BEFORE recomputing warm-cameras so the
// union across displays is correct. // 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) { if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
st.current_layout_id = Some(layout.id); st.current_layout_id = Some(layout.id);
} }
prev
}); });
info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)", info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)",
layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len()); 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() { if layout.cells.is_empty() {
warn!("layout has no cells"); warn!("layout has no cells");
recompute_global_state(); recompute_global_state();

View file

@ -224,10 +224,15 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
return; return;
} }
// Retry a few times — Node-RED may still be booting. // Retry with backoff — Node-RED may still be booting + initial flow load
const delaysMs = [2000, 5000, 10000, 30000]; // 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) { for (let attempt = 0; attempt < delaysMs.length; attempt += 1) {
await new Promise((r) => setTimeout(r, delaysMs[attempt])); 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); const result = await nodered.ensureServerConfig(this.config.selfUrl, plaintext);
if (result === "created") { if (result === "created") {
obs.log.info("nodered: provisioned bf-server-config at {url}", { obs.log.info("nodered: provisioned bf-server-config at {url}", {
@ -239,7 +244,6 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
obs.log.info("nodered: bf-server-config already present, skipping"); obs.log.info("nodered: bf-server-config already present, skipping");
return; return;
} }
// failed — retry
} }
obs.log.warn("nodered: provisioning bf-server-config gave up after retries"); obs.log.warn("nodered: provisioning bf-server-config gave up after retries");
} }

View file

@ -348,16 +348,34 @@ function registerKioskRoutes(
forwarded_to_nodered: false, forwarded_to_nodered: false,
}); });
// Best-effort forward to Node-RED // Best-effort forward to Node-RED. Topics that have a dedicated trigger
nodered.forward(body.topic, { // node (bf-trigger-layout-changed etc.) expect a FLAT payload matching
event_id: eventId, // what the admin-side emit produces — splat body.payload up to the top
kiosk_id: kiosk.id, // level and add kiosk_id. Generic camera events keep the wrapped shape
camera_id: body.camera_id ?? null, // the bf-kiosk-camera-event trigger consumes.
source_type: body.source_type ?? "system", const flatTopics = new Set([
property_op: body.property_op ?? null, "layout.changed",
payload: body.payload ?? {}, "kiosk.changed",
timestamp: new Date().toISOString(), "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 }; return { ok: true, event_id: eventId };
}); });

View file

@ -142,19 +142,22 @@ async function provisionServerConfig(
const ctrl = new AbortController(); const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs); const t = setTimeout(() => ctrl.abort(), timeoutMs);
try { 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`, { const getResp = await fetch(`${base}/nrdp/flows`, {
method: "GET", method: "GET",
headers: { accept: "application/json" }, headers: {
accept: "application/json",
"node-red-api-version": "v2",
},
signal: ctrl.signal, signal: ctrl.signal,
}); });
if (!getResp.ok) throw new Error(`GET /flows HTTP ${String(getResp.status)}`); 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 raw = (await getResp.json()) as NoderedFlowNode[] | { flows: NoderedFlowNode[]; rev?: string };
const flows: NoderedFlowNode[] = Array.isArray(raw) ? raw : (raw.flows ?? []); const flows: NoderedFlowNode[] = Array.isArray(raw) ? raw : (raw.flows ?? []);
const rev: string | undefined = Array.isArray(raw) ? undefined : raw.rev; 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")) { if (flows.some((n) => n.type === "bf-server-config")) {
return "exists"; return "exists";
} }
@ -164,8 +167,8 @@ async function provisionServerConfig(
type: "bf-server-config", type: "bf-server-config",
name: "BetterFrame (auto)", name: "BetterFrame (auto)",
server_url: serverUrl.replace(/\/+$/, ""), server_url: serverUrl.replace(/\/+$/, ""),
// Credentials get peeled off by Node-RED's runtime and stored encrypted // Node-RED extracts `credentials` on POST /flows and stores them in
// into flows_cred.json. The node body keeps only non-credential fields. // flows_cred.json. Confirmed by the editor's own save path.
credentials: { api_key: apiKey }, credentials: { api_key: apiKey },
}; };
@ -179,12 +182,16 @@ async function provisionServerConfig(
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
accept: "application/json", accept: "application/json",
"node-red-deployment-type": "nodes", "node-red-api-version": "v2",
"node-red-deployment-type": "full",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: ctrl.signal, 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"; return "created";
} finally { } finally {
clearTimeout(t); clearTimeout(t);