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.
/// 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,

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

View file

@ -224,10 +224,15 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, 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");
}

View file

@ -348,7 +348,24 @@ function registerKioskRoutes(
forwarded_to_nodered: false,
});
// Best-effort forward to Node-RED
// 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,
@ -358,6 +375,7 @@ function registerKioskRoutes(
payload: body.payload ?? {},
timestamp: new Date().toISOString(),
});
}
return { ok: true, event_id: eventId };
});

View file

@ -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);