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