feat: bf-status (action + trigger) + bf-snapshot (action) nodes

- 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
This commit is contained in:
Mitchell R 2026-05-13 02:29:12 +02:00
parent bd48c853e6
commit acb4a353f9
9 changed files with 293 additions and 3 deletions

View file

@ -13,12 +13,15 @@ BetterFrame admin REST API and kiosk event ingest.
| `bf-trigger-layout-changed` | Triggers | Fires on `layout.changed` | | `bf-trigger-layout-changed` | Triggers | Fires on `layout.changed` |
| `bf-trigger-kiosk-changed` | Triggers | Fires on `kiosk.changed` (connect/disconnect/heartbeat) | | `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-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-layout-switch` | BetterFrame | Switch a display's active layout |
| `bf-power` | BetterFrame | Wake / standby a kiosk display | | `bf-power` | BetterFrame | Wake / standby a kiosk display |
| `bf-fan` | BetterFrame | Set fan mode (auto/pwm) on a kiosk | | `bf-fan` | BetterFrame | Set fan mode (auto/pwm) on a kiosk |
| `bf-cameras` | BetterFrame | Fetch the camera list | | `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-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-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 ## Authentication

View file

@ -16,12 +16,15 @@
"bf-trigger-layout-changed": "src/bf-trigger-layout-changed.js", "bf-trigger-layout-changed": "src/bf-trigger-layout-changed.js",
"bf-trigger-kiosk-changed": "src/bf-trigger-kiosk-changed.js", "bf-trigger-kiosk-changed": "src/bf-trigger-kiosk-changed.js",
"bf-trigger-camera-changed": "src/bf-trigger-camera-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-layout-switch": "src/bf-layout-switch.js",
"bf-power": "src/bf-power.js", "bf-power": "src/bf-power.js",
"bf-fan": "src/bf-fan.js", "bf-fan": "src/bf-fan.js",
"bf-cameras": "src/bf-cameras.js", "bf-cameras": "src/bf-cameras.js",
"bf-config-get": "src/bf-config-get.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": [
"icons" "icons"

View file

@ -0,0 +1,40 @@
<script type="text/javascript">
RED.nodes.registerType("bf-snapshot", {
category: "BetterFrame",
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-server-config", required: true },
entity_id: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "bf snapshot";
},
paletteLabel: "bf snapshot",
});
</script>
<script type="text/html" data-template-name="bf-snapshot">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" />
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
<input type="text" id="node-input-config" />
</div>
<div class="form-row">
<label for="node-input-entity_id"><i class="fa fa-camera"></i> Entity ID</label>
<input type="number" id="node-input-entity_id" />
</div>
<div class="form-tips">
Pulls one JPEG frame from the camera entity's main stream.
Sets <code>msg.payload</code> = Buffer (binary JPEG) and
<code>msg.contentType</code> = <code>image/jpeg</code>.
Override via <code>msg.entity_id</code>. Errors on 502 (camera unreachable
or ffmpeg/gst missing on the server).
</div>
</script>

View file

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

View file

@ -0,0 +1,38 @@
<script type="text/javascript">
RED.nodes.registerType("bf-status", {
category: "BetterFrame",
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-server-config", required: true },
kiosk_id: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "bf status";
},
paletteLabel: "bf status",
});
</script>
<script type="text/html" data-template-name="bf-status">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" />
</div>
<div class="form-row">
<label for="node-input-config"><i class="fa fa-cog"></i> BF</label>
<input type="text" id="node-input-config" />
</div>
<div class="form-row">
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
<input type="number" id="node-input-kiosk_id" />
</div>
<div class="form-tips">
GETs <code>/api/admin/kiosks/:id</code> and sets <code>msg.payload</code> to the
kiosk object (name, enabled, last_seen_at, cpu_temp_c, fan_rpm, fan_pwm, etc.).
Override via <code>msg.kiosk_id</code>. Secrets are stripped server-side.
</div>
</script>

54
nodered/src/bf-status.js Normal file
View file

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

View file

@ -0,0 +1,34 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-status", {
category: "BetterFrame Triggers",
color: "#a6d4ff",
defaults: {
name: { value: "" },
kiosk_id: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "kiosk status";
},
paletteLabel: "Kiosk Status Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-status">
<div class="form-row">
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-input-name" placeholder="(optional)" />
</div>
<div class="form-row">
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
<input type="number" id="node-input-kiosk_id" placeholder="(blank = all kiosks)" />
</div>
<div class="form-tips">
Fires on <code>kiosk.status</code> heartbeats with hardware telemetry.
Wire <code>http in POST /in/kiosk/kiosk.status</code> in front of this node.
Emits <code>msg.payload = {kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm}</code>.
Leave Kiosk ID blank to receive heartbeats from all kiosks.
</div>
</script>

View file

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

View file

@ -192,14 +192,21 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
const cpu = typeof msg["cpu_temp_c"] === "number" ? msg["cpu_temp_c"] : null; 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 fanRpm = typeof msg["fan_rpm"] === "number" ? msg["fan_rpm"] : null;
const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null; const fanPwm = typeof msg["fan_pwm"] === "number" ? msg["fan_pwm"] : null;
nodered.forward("kiosk.changed", { const telemetry = {
kiosk_id: kiosk.id, kiosk_id: kiosk.id,
kiosk_name: kioskData.name, kiosk_name: kioskData.name,
event: "heartbeat",
cpu_temp_c: cpu, cpu_temp_c: cpu,
fan_rpm: fanRpm, fan_rpm: fanRpm,
fan_pwm: fanPwm, 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 { } catch {
// ignore malformed // ignore malformed