From acb4a353f9a326584bb25413d28c9431cfd8324e Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 13 May 2026 02:29:12 +0200 Subject: [PATCH] feat: bf-status (action + trigger) + bf-snapshot (action) nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bf-status: query kiosk state by ID via /api/admin/kiosks/:id - bf-trigger-status: dedicated heartbeat-only topic kiosk.status (skips connect/disconnect noise from kiosk.changed) - bf-snapshot: GET /admin/entities/:id/snapshot as Buffer for motion → email/telegram flows - coordinator-ws now forwards both kiosk.changed (event=heartbeat) AND kiosk.status on every status message --- nodered/README.md | 3 + nodered/package.json | 5 +- nodered/src/bf-snapshot.html | 40 +++++++++++++ nodered/src/bf-snapshot.js | 59 +++++++++++++++++++ nodered/src/bf-status.html | 38 ++++++++++++ nodered/src/bf-status.js | 54 +++++++++++++++++ nodered/src/bf-trigger-status.html | 34 +++++++++++ nodered/src/bf-trigger-status.js | 52 ++++++++++++++++ .../plugins/service-coordinator-ws/index.ts | 11 +++- 9 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 nodered/src/bf-snapshot.html create mode 100644 nodered/src/bf-snapshot.js create mode 100644 nodered/src/bf-status.html create mode 100644 nodered/src/bf-status.js create mode 100644 nodered/src/bf-trigger-status.html create mode 100644 nodered/src/bf-trigger-status.js diff --git a/nodered/README.md b/nodered/README.md index 896b2b6..c6f5162 100644 --- a/nodered/README.md +++ b/nodered/README.md @@ -13,12 +13,15 @@ BetterFrame admin REST API and kiosk event ingest. | `bf-trigger-layout-changed` | Triggers | Fires on `layout.changed` | | `bf-trigger-kiosk-changed` | Triggers | Fires on `kiosk.changed` (connect/disconnect/heartbeat) | | `bf-trigger-camera-changed` | Triggers | Fires on `camera.changed` (created/updated/deleted) | +| `bf-trigger-status` | Triggers | Fires on `kiosk.status` (heartbeat-only telemetry; optional kiosk_id filter) | | `bf-layout-switch` | BetterFrame | Switch a display's active layout | | `bf-power` | BetterFrame | Wake / standby a kiosk display | | `bf-fan` | BetterFrame | Set fan mode (auto/pwm) on a kiosk | | `bf-cameras` | BetterFrame | Fetch the camera list | | `bf-config-get` | BetterFrame | Fetch BF state (displays/kiosks/cameras/layouts/entities, by id or full list) | | `bf-config-set` | BetterFrame | Mutate BF state (default layout, enabled, priority, name) | +| `bf-status` | BetterFrame | Fetch current kiosk state by ID (telemetry, last_seen_at, etc.) | +| `bf-snapshot` | BetterFrame | Fetch a JPEG snapshot for a camera entity (binary Buffer payload) | ## Authentication diff --git a/nodered/package.json b/nodered/package.json index 0fe1ead..2ab107c 100644 --- a/nodered/package.json +++ b/nodered/package.json @@ -16,12 +16,15 @@ "bf-trigger-layout-changed": "src/bf-trigger-layout-changed.js", "bf-trigger-kiosk-changed": "src/bf-trigger-kiosk-changed.js", "bf-trigger-camera-changed": "src/bf-trigger-camera-changed.js", + "bf-trigger-status": "src/bf-trigger-status.js", "bf-layout-switch": "src/bf-layout-switch.js", "bf-power": "src/bf-power.js", "bf-fan": "src/bf-fan.js", "bf-cameras": "src/bf-cameras.js", "bf-config-get": "src/bf-config-get.js", - "bf-config-set": "src/bf-config-set.js" + "bf-config-set": "src/bf-config-set.js", + "bf-status": "src/bf-status.js", + "bf-snapshot": "src/bf-snapshot.js" }, "icons": [ "icons" diff --git a/nodered/src/bf-snapshot.html b/nodered/src/bf-snapshot.html new file mode 100644 index 0000000..a8820a6 --- /dev/null +++ b/nodered/src/bf-snapshot.html @@ -0,0 +1,40 @@ + + + diff --git a/nodered/src/bf-snapshot.js b/nodered/src/bf-snapshot.js new file mode 100644 index 0000000..8fbd2c2 --- /dev/null +++ b/nodered/src/bf-snapshot.js @@ -0,0 +1,59 @@ +/** + * bf-snapshot — fetch a single JPEG frame for a camera entity. + * + * GETs /admin/entities/:id/snapshot, which pulls one frame from the entity's + * main RTSP stream via ffmpeg/gst on the server. Returns image/jpeg or 502. + * + * On success: msg.payload = Buffer (binary JPEG), msg.contentType = "image/jpeg". + * On 502 / network error: the node errors out with done(err) and shows red. + * + * config.entity_id: numeric (overridable by msg.entity_id) + * + * Typical use: motion event → bf-snapshot → email / telegram / save-to-disk. + */ +module.exports = function (RED) { + function BfSnapshotNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const cfg = RED.nodes.getNode(config.config); + + node.on("input", async (msg, send, done) => { + if (!cfg || !cfg.server_url || !cfg.api_key) { + node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" }); + return done(new Error("bf-server-config server_url + api_key required")); + } + const entityId = msg.entity_id || config.entity_id; + if (!entityId) { + node.status({ fill: "red", shape: "ring", text: "missing entity_id" }); + return done(new Error("entity_id required")); + } + const url = cfg.server_url + "/admin/entities/" + encodeURIComponent(String(entityId)) + + "/snapshot"; + try { + const r = await fetch(url, { + method: "GET", + headers: { + authorization: "Bearer " + cfg.api_key, + accept: "image/jpeg", + }, + }); + if (r.status === 502) { + node.status({ fill: "red", shape: "ring", text: "no snapshot" }); + return done(new Error("snapshot unavailable (HTTP 502)")); + } + if (!r.ok) throw new Error("HTTP " + r.status); + const ab = await r.arrayBuffer(); + const buf = Buffer.from(ab); + msg.payload = buf; + msg.contentType = "image/jpeg"; + node.status({ fill: "green", shape: "dot", text: String(buf.length) + " B" }); + send(msg); + done(); + } catch (err) { + node.status({ fill: "red", shape: "ring", text: err.message }); + done(err); + } + }); + } + RED.nodes.registerType("bf-snapshot", BfSnapshotNode); +}; diff --git a/nodered/src/bf-status.html b/nodered/src/bf-status.html new file mode 100644 index 0000000..96d192d --- /dev/null +++ b/nodered/src/bf-status.html @@ -0,0 +1,38 @@ + + + diff --git a/nodered/src/bf-status.js b/nodered/src/bf-status.js new file mode 100644 index 0000000..971b0a4 --- /dev/null +++ b/nodered/src/bf-status.js @@ -0,0 +1,54 @@ +/** + * bf-status — query the current state of a kiosk by ID. + * + * GETs /api/admin/kiosks/:id and returns the kiosk object as msg.payload. + * The server applies stripSecrets() before returning JSON, so key_hash and + * other credentials are already removed by the time we see them. + * + * Useful for: "what's the temperature right now?" / "is this kiosk online?" + * style polls. For push-driven telemetry use bf-trigger-status instead. + * + * config.kiosk_id: numeric (overridable by msg.kiosk_id) + */ +module.exports = function (RED) { + function BfStatusNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const cfg = RED.nodes.getNode(config.config); + + node.on("input", async (msg, send, done) => { + if (!cfg || !cfg.server_url || !cfg.api_key) { + node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" }); + return done(new Error("bf-server-config server_url + api_key required")); + } + const kioskId = msg.kiosk_id || config.kiosk_id; + if (!kioskId) { + node.status({ fill: "red", shape: "ring", text: "missing kiosk_id" }); + return done(new Error("kiosk_id required")); + } + const url = cfg.server_url + "/api/admin/kiosks/" + encodeURIComponent(String(kioskId)); + try { + const r = await fetch(url, { + method: "GET", + headers: { + authorization: "Bearer " + cfg.api_key, + accept: "application/json", + }, + }); + if (!r.ok) throw new Error("HTTP " + r.status); + const data = await r.json(); + // Server envelope is { kiosk: {...} } — unwrap to match bf-config-get. + const payload = data && data.kiosk ? data.kiosk : data; + msg.payload = payload; + const label = (payload && (payload.name || payload.id)) || kioskId; + node.status({ fill: "green", shape: "dot", text: String(label) }); + send(msg); + done(); + } catch (err) { + node.status({ fill: "red", shape: "ring", text: err.message }); + done(err); + } + }); + } + RED.nodes.registerType("bf-status", BfStatusNode); +}; diff --git a/nodered/src/bf-trigger-status.html b/nodered/src/bf-trigger-status.html new file mode 100644 index 0000000..8efc953 --- /dev/null +++ b/nodered/src/bf-trigger-status.html @@ -0,0 +1,34 @@ + + + diff --git a/nodered/src/bf-trigger-status.js b/nodered/src/bf-trigger-status.js new file mode 100644 index 0000000..7e49c54 --- /dev/null +++ b/nodered/src/bf-trigger-status.js @@ -0,0 +1,52 @@ +/** + * bf-trigger-status — fires on kiosk heartbeat telemetry. + * + * Topic filter: `kiosk.status`. Server emits this from coordinator-ws each + * time a kiosk pushes a status frame over the WS channel, separate from + * the connect/disconnect/heartbeat envelope on `kiosk.changed`. Listening + * here gives you a pure telemetry stream (no connect/disconnect noise). + * + * Optional config.kiosk_id filter — when set, only fires for that kiosk. + * + * Output msg.payload: + * { kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm } + */ +module.exports = function (RED) { + function BfTriggerStatusNode(config) { + RED.nodes.createNode(this, config); + const node = this; + const filterIdRaw = (config.kiosk_id || "").toString().trim(); + const filterId = filterIdRaw ? Number(filterIdRaw) : null; + + node.on("input", function (msg, send, done) { + const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; + const topic = msg.topic || body.topic || "kiosk.status"; + if (String(topic) !== "kiosk.status") { + return done && done(); + } + const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null; + if (filterId !== null && Number(kioskId) !== filterId) { + return done && done(); + } + const out = { + topic: "kiosk.status", + payload: { + kiosk_id: kioskId, + kiosk_name: body.kiosk_name || null, + cpu_temp_c: body.cpu_temp_c !== undefined ? body.cpu_temp_c : null, + fan_rpm: body.fan_rpm !== undefined ? body.fan_rpm : null, + fan_pwm: body.fan_pwm !== undefined ? body.fan_pwm : null, + }, + }; + const tempStr = out.payload.cpu_temp_c != null ? out.payload.cpu_temp_c + "C" : ""; + node.status({ + fill: "green", + shape: "dot", + text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + tempStr, + }); + send(out); + done && done(); + }); + } + RED.nodes.registerType("bf-trigger-status", BfTriggerStatusNode); +}; diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index f4d7738..6f4a3fd 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -192,14 +192,21 @@ export class Plugin extends BSBService, typeof Event const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null; const fanRpm = typeof msg["fan_rpm"] === "number" ? msg["fan_rpm"] : null; const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null; - nodered.forward("kiosk.changed", { + const telemetry = { kiosk_id: kiosk.id, kiosk_name: kioskData.name, - event: "heartbeat", cpu_temp_c: cpu, fan_rpm: fanRpm, fan_pwm: fanPwm, + }; + nodered.forward("kiosk.changed", { + ...telemetry, + event: "heartbeat", }); + // Dedicated status topic — same payload sans the event marker + // so bf-trigger-status can listen on a heartbeat-only channel + // without filtering connect/disconnect noise out. + nodered.forward("kiosk.status", telemetry); } } catch { // ignore malformed