mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
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:
parent
77b58c07fd
commit
b10958def7
5 changed files with 94 additions and 22 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue