mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06: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.
|
/// 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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue