feat: restructure Node-RED nodes + server event emission

Renames:
- bf-config → bf-server-config (config node clarity)
- bf-event-in → bf-kiosk-camera-event (specific camera trigger)

New trigger nodes (input-only, under "BetterFrame Triggers"):
- bf-trigger-display-power, bf-trigger-layout-changed,
  bf-trigger-kiosk-changed, bf-trigger-camera-changed

New flow nodes:
- bf-config-get: query state by type (displays/kiosks/cameras/layouts/
  entities, or by-id)
- bf-config-set: mutate via typed setters (default-layout, enabled,
  priority, name)

Server-side event emission:
- shared/strip-secrets.ts: recursive password scrub
- New JSON admin endpoints: GET/POST /api/admin/{displays,kiosks,
  layouts,entities}[/:id]
- Coordinator-ws fires kiosk.changed on connect/disconnect/heartbeat
- Layout/power/camera routes call nodered.forward() on state change
This commit is contained in:
Mitchell R 2026-05-13 02:26:08 +02:00
parent 44b0268def
commit bd48c853e6
30 changed files with 951 additions and 80 deletions

View file

@ -5,27 +5,35 @@ BetterFrame admin REST API and kiosk event ingest.
## Nodes
| Node | Type | Purpose |
| Node | Category | Purpose |
| --- | --- | --- |
| `bf-config` | config | Shared server URL + admin API key |
| `bf-event-in` | input | Filter incoming kiosk events by topic glob |
| `bf-layout-switch` | action | Switch a display's active layout |
| `bf-power` | action | Wake / standby a kiosk display |
| `bf-fan` | action | Set fan mode (auto/pwm) on a kiosk |
| `bf-cameras` | query | Fetch the camera list |
| `bf-server-config` | config | Shared server URL + admin API key |
| `bf-kiosk-camera-event` | Triggers | Filter incoming kiosk camera events (default `camera.*`) |
| `bf-trigger-display-power` | Triggers | Fires on `display.power.changed` |
| `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-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) |
## Authentication
All action/query nodes use an **admin-scoped API key** created in the
BetterFrame admin UI. The key is sent as `Authorization: Bearer bf-...`.
Configure once on a `bf-config` node and reference it from the others.
Configure once on a `bf-server-config` node and reference it from the others.
## Event ingest path
`bf-event-in` is a pure filter — it does not subscribe to the BF server.
Trigger nodes are pure filters — they do not subscribe to the BF server.
Wire an upstream `http in` node on `/in/kiosk/<topic>` (BetterFrame's
authenticated kiosk-ingest endpoint, surfaced by the Angie proxy with
`auth_request` gating) and feed its `msg.payload` into `bf-event-in`.
`auth_request` gating) and feed its `msg.payload` into the matching
`bf-trigger-*` node. The server emits these topics from coordinator-ws
(kiosk WS lifecycle) and the admin routes (layout/power/camera mutations).
## Installation

View file

@ -10,12 +10,18 @@
"node-red": {
"version": ">=3.0.0",
"nodes": {
"bf-config": "src/bf-config.js",
"bf-event-in": "src/bf-event-in.js",
"bf-server-config": "src/bf-server-config.js",
"bf-kiosk-camera-event": "src/bf-kiosk-camera-event.js",
"bf-trigger-display-power": "src/bf-trigger-display-power.js",
"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-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-cameras": "src/bf-cameras.js",
"bf-config-get": "src/bf-config-get.js",
"bf-config-set": "src/bf-config-set.js"
},
"icons": [
"icons"

View file

@ -4,7 +4,7 @@
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-config", required: true },
config: { value: "", type: "bf-server-config", required: true },
label: { value: "" },
},
inputs: 1,

View file

@ -16,8 +16,8 @@ module.exports = function (RED) {
node.on("input", async (msg, send, done) => {
if (!cfg || !cfg.server_url || !cfg.api_key) {
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
return done(new Error("bf-config server_url + api_key required"));
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
return done(new Error("bf-server-config server_url + api_key required"));
}
const filterLabel = (msg.label || config.label || "").trim().toLowerCase();
const url = cfg.server_url + "/api/admin/cameras";

View file

@ -0,0 +1,54 @@
<script type="text/javascript">
RED.nodes.registerType("bf-config-get", {
category: "BetterFrame",
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-server-config", required: true },
type: { value: "cameras", required: true },
id: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || ("bf get " + (this.type || ""));
},
paletteLabel: "Get Config",
});
</script>
<script type="text/html" data-template-name="bf-config-get">
<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-type"><i class="fa fa-list"></i> Type</label>
<select id="node-input-type">
<option value="displays">displays</option>
<option value="kiosks">kiosks</option>
<option value="cameras">cameras</option>
<option value="layouts">layouts</option>
<option value="entities">entities</option>
<option value="display-by-id">display-by-id</option>
<option value="kiosk-by-id">kiosk-by-id</option>
<option value="camera-by-id">camera-by-id</option>
<option value="layout-by-id">layout-by-id</option>
<option value="entity-by-id">entity-by-id</option>
</select>
</div>
<div class="form-row">
<label for="node-input-id"><i class="fa fa-hashtag"></i> ID</label>
<input type="text" id="node-input-id" placeholder="(only for *-by-id)" />
</div>
<div class="form-tips">
Fetches BetterFrame state via admin API. Override <code>msg.type</code> or
<code>msg.id</code> per-message. Result lands on <code>msg.payload</code>.
Credential fields are stripped server-side.
</div>
</script>

View file

@ -0,0 +1,91 @@
/**
* bf-config-get fetch BetterFrame state via admin API.
*
* config.type selects the resource. Some types accept an optional id (passed
* via config.id or msg.id). Responses are JSON; secrets (password_hash,
* key_hash, onvif_password, totp_secret_encrypted, etc.) are stripped on the
* server side.
*
* Supported types:
* displays /api/admin/displays msg.payload = [Display]
* kiosks /api/admin/kiosks msg.payload = [Kiosk]
* cameras /api/admin/cameras msg.payload = [Camera]
* layouts /api/admin/layouts msg.payload = [Layout]
* entities /api/admin/entities msg.payload = [Entity]
* display-by-id /api/admin/displays/:id msg.payload = Display
* kiosk-by-id /api/admin/kiosks/:id msg.payload = Kiosk
* camera-by-id /api/admin/cameras/:id msg.payload = Camera
* layout-by-id /api/admin/layouts/:id msg.payload = Layout
* entity-by-id /api/admin/entities/:id msg.payload = Entity
*/
module.exports = function (RED) {
// Map type → {path(id) → string, listKey: string | null }
// listKey: when set, response shape is {<listKey>: [...]}, we unwrap it.
// For -by-id types, response shape is {<singularKey>: {...}} which is
// unwrapped from the first key in the response object.
const ROUTES = {
"displays": { build: () => "/api/admin/displays", listKey: "displays" },
"kiosks": { build: () => "/api/admin/kiosks", listKey: "kiosks" },
"cameras": { build: () => "/api/admin/cameras", listKey: "cameras" },
"layouts": { build: () => "/api/admin/layouts", listKey: "layouts" },
"entities": { build: () => "/api/admin/entities", listKey: "entities" },
"display-by-id": { build: (id) => "/api/admin/displays/" + encodeURIComponent(id), listKey: "display" },
"kiosk-by-id": { build: (id) => "/api/admin/kiosks/" + encodeURIComponent(id), listKey: "kiosk" },
"camera-by-id": { build: (id) => "/api/admin/cameras/" + encodeURIComponent(id), listKey: "camera" },
"layout-by-id": { build: (id) => "/api/admin/layouts/" + encodeURIComponent(id), listKey: "layout" },
"entity-by-id": { build: (id) => "/api/admin/entities/" + encodeURIComponent(id), listKey: "entity" },
};
function BfConfigGetNode(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 type = (msg.type || config.type || "").trim();
const route = ROUTES[type];
if (!route) {
node.status({ fill: "red", shape: "ring", text: "bad type" });
return done(new Error("unknown type: " + type));
}
const id = msg.id !== undefined && msg.id !== "" ? msg.id : config.id;
const needsId = type.endsWith("-by-id");
if (needsId && (id === undefined || id === null || id === "")) {
node.status({ fill: "red", shape: "ring", text: "missing id" });
return done(new Error("id required for type " + type));
}
const url = cfg.server_url + route.build(id);
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();
// Unwrap the predictable envelope shape.
let payload;
if (route.listKey && data && Object.prototype.hasOwnProperty.call(data, route.listKey)) {
payload = data[route.listKey];
} else {
payload = data;
}
msg.payload = payload;
const n = Array.isArray(payload) ? payload.length : 1;
node.status({ fill: "green", shape: "dot", text: type + " (" + String(n) + ")" });
send(msg);
done();
} catch (err) {
node.status({ fill: "red", shape: "ring", text: err.message });
done(err);
}
});
}
RED.nodes.registerType("bf-config-get", BfConfigGetNode);
};

View file

@ -0,0 +1,56 @@
<script type="text/javascript">
RED.nodes.registerType("bf-config-set", {
category: "BetterFrame",
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-server-config", required: true },
type: { value: "kiosk.enabled", required: true },
id: { value: "" },
value: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || ("bf set " + (this.type || ""));
},
paletteLabel: "Set Config",
});
</script>
<script type="text/html" data-template-name="bf-config-set">
<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-type"><i class="fa fa-list"></i> Type</label>
<select id="node-input-type">
<option value="display.default-layout">display.default-layout</option>
<option value="kiosk.enabled">kiosk.enabled</option>
<option value="camera.enabled">camera.enabled</option>
<option value="layout.priority">layout.priority</option>
<option value="entity.name">entity.name</option>
</select>
</div>
<div class="form-row">
<label for="node-input-id"><i class="fa fa-hashtag"></i> ID</label>
<input type="text" id="node-input-id" placeholder="entity id" />
</div>
<div class="form-row">
<label for="node-input-value"><i class="fa fa-pencil"></i> Default value</label>
<input type="text" id="node-input-value" placeholder="(or use msg.payload)" />
</div>
<div class="form-tips">
Mutates BetterFrame state. Override <code>msg.type</code>, <code>msg.id</code>,
or <code>msg.payload</code> per-message. <code>msg.payload</code> is the new
value; the static <em>Default value</em> field is only used when payload is
absent. Response (updated entity, passwords stripped) replaces
<code>msg.payload</code>.
</div>
</script>

View file

@ -0,0 +1,102 @@
/**
* bf-config-set mutate BetterFrame state via admin API.
*
* config.type selects the mutation. config.id (or msg.id) identifies the
* target entity. The new value comes from msg.payload (or config.value).
* Wire format is uniform: `POST /api/admin/<resource>/:id/<field>` with
* body `{ value: <new value> }`.
*
* Response body is the updated entity (passwords stripped), placed on
* msg.payload.
*
* Supported types:
* display.default-layout POST /api/admin/displays/:id/default-layout {value: layout_id|null}
* kiosk.enabled POST /api/admin/kiosks/:id/enabled {value: boolean}
* camera.enabled POST /api/admin/cameras/:id/enabled {value: boolean}
* layout.priority POST /api/admin/layouts/:id/priority {value: "hot"|"normal"|"cold"}
* entity.name POST /api/admin/entities/:id/name {value: string}
*/
module.exports = function (RED) {
const ROUTES = {
"display.default-layout": (id) => "/api/admin/displays/" + encodeURIComponent(id) + "/default-layout",
"kiosk.enabled": (id) => "/api/admin/kiosks/" + encodeURIComponent(id) + "/enabled",
"camera.enabled": (id) => "/api/admin/cameras/" + encodeURIComponent(id) + "/enabled",
"layout.priority": (id) => "/api/admin/layouts/" + encodeURIComponent(id) + "/priority",
"entity.name": (id) => "/api/admin/entities/" + encodeURIComponent(id) + "/name",
};
function coerceValue(type, raw) {
if (type === "kiosk.enabled" || type === "camera.enabled") {
if (typeof raw === "string") {
const t = raw.trim().toLowerCase();
if (t === "true" || t === "1" || t === "yes" || t === "on") return true;
if (t === "false" || t === "0" || t === "no" || t === "off" || t === "") return false;
}
return Boolean(raw);
}
if (type === "display.default-layout") {
if (raw === null || raw === undefined || raw === "") return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
return raw;
}
function BfConfigSetNode(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 type = (msg.type || config.type || "").trim();
const build = ROUTES[type];
if (!build) {
node.status({ fill: "red", shape: "ring", text: "bad type" });
return done(new Error("unknown type: " + type));
}
const id = msg.id !== undefined && msg.id !== "" ? msg.id : config.id;
if (id === undefined || id === null || id === "") {
node.status({ fill: "red", shape: "ring", text: "missing id" });
return done(new Error("id required"));
}
// Pick the new value. msg.payload wins, then msg.value, then config.value.
const rawValue = msg.payload !== undefined
? msg.payload
: (msg.value !== undefined ? msg.value : config.value);
const value = coerceValue(type, rawValue);
const url = cfg.server_url + build(id);
try {
const r = await fetch(url, {
method: "POST",
headers: {
authorization: "Bearer " + cfg.api_key,
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({ value: value }),
});
if (!r.ok) throw new Error("HTTP " + r.status);
const data = await r.json();
// Unwrap the predictable envelope shape — first key holds the entity.
let payload = data;
if (data && typeof data === "object" && !Array.isArray(data)) {
const keys = Object.keys(data);
if (keys.length === 1) payload = data[keys[0]];
}
msg.payload = payload;
node.status({ fill: "green", shape: "dot", text: type });
send(msg);
done();
} catch (err) {
node.status({ fill: "red", shape: "ring", text: err.message });
done(err);
}
});
}
RED.nodes.registerType("bf-config-set", BfConfigSetNode);
};

View file

@ -1,21 +0,0 @@
/**
* bf-config shared config node holding BetterFrame server URL + admin API key.
*
* Other bf-* action/query nodes reference this via `config.config` in their
* editor UI. The API key is treated as `credentials` so Node-RED encrypts it
* at rest.
*/
module.exports = function (RED) {
function BfConfigNode(n) {
RED.nodes.createNode(this, n);
this.name = n.name;
this.server_url = (n.server_url || "").replace(/\/+$/, "");
// credentials.api_key is auto-merged onto `this` by Node-RED.
this.api_key = (this.credentials && this.credentials.api_key) || "";
}
RED.nodes.registerType("bf-config", BfConfigNode, {
credentials: {
api_key: { type: "password" },
},
});
};

View file

@ -4,7 +4,7 @@
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-config", required: true },
config: { value: "", type: "bf-server-config", required: true },
kiosk_id: { value: "" },
mode: { value: "auto" },
pwm: { value: 128 },

View file

@ -13,8 +13,8 @@ module.exports = function (RED) {
node.on("input", async (msg, send, done) => {
if (!cfg || !cfg.server_url || !cfg.api_key) {
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
return done(new Error("bf-config server_url + api_key required"));
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) {

View file

@ -1,35 +1,35 @@
<script type="text/javascript">
RED.nodes.registerType("bf-event-in", {
category: "BetterFrame",
RED.nodes.registerType("bf-kiosk-camera-event", {
category: "BetterFrame Triggers",
color: "#a6d4ff",
defaults: {
name: { value: "" },
topic_pattern: { value: "" },
topic_pattern: { value: "camera.*" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || this.topic_pattern || "bf-event-in";
return this.name || this.topic_pattern || "kiosk camera event";
},
paletteLabel: "bf event in",
paletteLabel: "Kiosk Camera Event",
});
</script>
<script type="text/html" data-template-name="bf-event-in">
<script type="text/html" data-template-name="bf-kiosk-camera-event">
<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-topic_pattern"><i class="fa fa-filter"></i> Topic</label>
<input type="text" id="node-input-topic_pattern" placeholder="gpio.button.*" />
<input type="text" id="node-input-topic_pattern" placeholder="camera.*" />
</div>
<div class="form-tips">
Filter incoming BF events by topic. <code>*</code> is a wildcard.
Wire an upstream <b>http in</b> on <code>/in/kiosk/&lt;topic&gt;</code> (or any source
that puts the event body in <code>msg.payload</code>) to feed this node.
On match the message becomes:
Filter incoming kiosk camera events by topic. <code>*</code> is a wildcard.
Defaults to <code>camera.*</code> (ONVIF motion, object detection, etc.).
Wire an upstream <b>http in</b> on <code>/in/kiosk/&lt;topic&gt;</code> to feed it.
On match the message becomes
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>.
</div>
</script>

View file

@ -1,6 +1,7 @@
/**
* bf-event-in fire a flow whenever a BetterFrame kiosk event matching a
* topic pattern arrives.
* bf-kiosk-camera-event fire a flow whenever a BetterFrame kiosk camera
* event matching a topic pattern arrives. Defaults to `camera.*` (ONVIF
* motion, object detection, line crossing, etc.).
*
* Two delivery paths can land here:
* 1. The BF server has forwarded an authenticated kiosk event via the
@ -9,17 +10,20 @@
* filter by topic.
* 2. A separate flow injects msg.topic + msg.payload directly.
*
* In other words, bf-event-in is a pure filter/router. It does NOT itself
* subscribe to the BF server; that wiring is done with stock Node-RED http-in
* or websocket nodes upstream.
* This is a pure filter/router. It does NOT itself subscribe to the BF
* server; that wiring is done with stock Node-RED http-in or websocket
* nodes upstream.
*
* Renamed from `bf-event-in` kept the same envelope shape for backward
* compatibility with flows that consume the output message.
*/
module.exports = function (RED) {
function BfEventInNode(config) {
function BfKioskCameraEventNode(config) {
RED.nodes.createNode(this, config);
const node = this;
const pattern = (config.topic_pattern || "").trim();
const pattern = (config.topic_pattern || "camera.*").trim();
// Convert glob-ish pattern to RegExp: `gpio.button.*` → /^gpio\.button\..*$/
// Convert glob-ish pattern to RegExp: `camera.*` → /^camera\..*$/
function toRegex(p) {
if (!p) return null;
const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@ -54,5 +58,5 @@ module.exports = function (RED) {
done && done();
});
}
RED.nodes.registerType("bf-event-in", BfEventInNode);
RED.nodes.registerType("bf-kiosk-camera-event", BfKioskCameraEventNode);
};

View file

@ -4,7 +4,7 @@
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-config", required: true },
config: { value: "", type: "bf-server-config", required: true },
display_id: { value: "" },
layout_id: { value: "" },
},

View file

@ -15,8 +15,8 @@ module.exports = function (RED) {
node.on("input", async (msg, send, done) => {
if (!cfg || !cfg.server_url || !cfg.api_key) {
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
return done(new Error("bf-config server_url + api_key required"));
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
return done(new Error("bf-server-config server_url + api_key required"));
}
const displayId = msg.display_id || config.display_id;
const layoutId = msg.layout_id || config.layout_id;

View file

@ -4,7 +4,7 @@
color: "#a6d4ff",
defaults: {
name: { value: "" },
config: { value: "", type: "bf-config", required: true },
config: { value: "", type: "bf-server-config", required: true },
kiosk_id: { value: "" },
mode: { value: "wake" },
},

View file

@ -14,8 +14,8 @@ module.exports = function (RED) {
node.on("input", async (msg, send, done) => {
if (!cfg || !cfg.server_url || !cfg.api_key) {
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
return done(new Error("bf-config server_url + api_key required"));
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;
const mode = (msg.mode || config.mode || "wake").toLowerCase();

View file

@ -1,5 +1,5 @@
<script type="text/javascript">
RED.nodes.registerType("bf-config", {
RED.nodes.registerType("bf-server-config", {
category: "config",
defaults: {
name: { value: "" },
@ -9,12 +9,12 @@
api_key: { type: "password" },
},
label: function () {
return this.name || this.server_url || "bf-config";
return this.name || this.server_url || "bf-server-config";
},
});
</script>
<script type="text/html" data-template-name="bf-config">
<script type="text/html" data-template-name="bf-server-config">
<div class="form-row">
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
<input type="text" id="node-config-input-name" placeholder="BetterFrame" />

View file

@ -0,0 +1,23 @@
/**
* bf-server-config shared config node holding BetterFrame server URL + admin
* API key. Every other bf-* node references one of these in its editor UI via
* the `config` field. The API key is stored as credentials so Node-RED encrypts
* it at rest.
*
* Previously named `bf-config`; renamed to disambiguate from the new
* `bf-config-get` / `bf-config-set` flow nodes.
*/
module.exports = function (RED) {
function BfServerConfigNode(n) {
RED.nodes.createNode(this, n);
this.name = n.name;
this.server_url = (n.server_url || "").replace(/\/+$/, "");
// credentials.api_key is auto-merged onto `this` by Node-RED.
this.api_key = (this.credentials && this.credentials.api_key) || "";
}
RED.nodes.registerType("bf-server-config", BfServerConfigNode, {
credentials: {
api_key: { type: "password" },
},
});
};

View file

@ -0,0 +1,28 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-camera-changed", {
category: "BetterFrame Triggers",
color: "#a6d4ff",
defaults: {
name: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "camera changed";
},
paletteLabel: "Camera Changed Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-camera-changed">
<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-tips">
Triggers when a camera is created, updated, or deleted in admin.
Wire <code>http in POST /in/kiosk/camera.changed</code> in front of this node.
Emits <code>msg.payload = {camera_id, event}</code>.
</div>
</script>

View file

@ -0,0 +1,38 @@
/**
* bf-trigger-camera-changed fires when a camera entity is created, updated,
* or deleted in admin.
*
* Topic filter: `camera.changed`. Server emits these from the admin camera
* routes (manual create, ONVIF import, edit, delete, enable/disable).
*
* Output msg.payload: { camera_id, event: "created" | "updated" | "deleted" }
*/
module.exports = function (RED) {
function BfTriggerCameraChangedNode(config) {
RED.nodes.createNode(this, config);
const node = this;
node.on("input", function (msg, send, done) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {};
const topic = msg.topic || body.topic || "camera.changed";
if (String(topic) !== "camera.changed") {
return done && done();
}
const out = {
topic: "camera.changed",
payload: {
camera_id: body.camera_id !== undefined ? body.camera_id : null,
event: body.event || null,
},
};
node.status({
fill: "green",
shape: "dot",
text: String(out.payload.camera_id || "") + " " + (out.payload.event || ""),
});
send(out);
done && done();
});
}
RED.nodes.registerType("bf-trigger-camera-changed", BfTriggerCameraChangedNode);
};

View file

@ -0,0 +1,28 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-display-power", {
category: "BetterFrame Triggers",
color: "#a6d4ff",
defaults: {
name: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "display power";
},
paletteLabel: "Display Power Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-display-power">
<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-tips">
Triggers when display power changes (wake/standby).
Wire <code>http in POST /in/kiosk/display.power.changed</code> in front of this node.
Emits <code>msg.payload = {display_id, kiosk_id, state}</code>.
</div>
</script>

View file

@ -0,0 +1,37 @@
/**
* bf-trigger-display-power fires when a display's power state changes.
*
* Topic filter: `display.power.changed`. Server emits these from the admin
* power routes (wake/standby) and the kiosk power-state-check probe (future).
*
* Wire an upstream `http in POST /in/kiosk/display.power.changed` (or any
* source landing the event body in msg.payload) into this node.
*
* Output msg.payload: { display_id, kiosk_id, state: "on" | "standby" }
*/
module.exports = function (RED) {
function BfTriggerDisplayPowerNode(config) {
RED.nodes.createNode(this, config);
const node = this;
node.on("input", function (msg, send, done) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {};
const topic = msg.topic || body.topic || "display.power.changed";
if (String(topic) !== "display.power.changed") {
return done && done();
}
const out = {
topic: "display.power.changed",
payload: {
display_id: body.display_id !== undefined ? body.display_id : null,
kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null,
state: body.state || null,
},
};
node.status({ fill: "green", shape: "dot", text: out.payload.state || "changed" });
send(out);
done && done();
});
}
RED.nodes.registerType("bf-trigger-display-power", BfTriggerDisplayPowerNode);
};

View file

@ -0,0 +1,28 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-kiosk-changed", {
category: "BetterFrame Triggers",
color: "#a6d4ff",
defaults: {
name: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "kiosk changed";
},
paletteLabel: "Kiosk Changed Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-kiosk-changed">
<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-tips">
Triggers on kiosk WS connect/disconnect and heartbeats with hardware telemetry.
Wire <code>http in POST /in/kiosk/kiosk.changed</code> in front of this node.
Emits <code>msg.payload = {kiosk_id, kiosk_name, event, cpu_temp_c?, fan_rpm?, fan_pwm?}</code>.
</div>
</script>

View file

@ -0,0 +1,45 @@
/**
* bf-trigger-kiosk-changed fires on kiosk state changes (connect, disconnect,
* heartbeat with hardware telemetry).
*
* Topic filter: `kiosk.changed`. Server emits these from the coordinator-ws
* plugin on WS connect/disconnect and from heartbeat status messages.
*
* Output msg.payload:
* { kiosk_id, kiosk_name,
* event: "connected" | "disconnected" | "heartbeat",
* cpu_temp_c?: number, fan_rpm?: number, fan_pwm?: number }
*/
module.exports = function (RED) {
function BfTriggerKioskChangedNode(config) {
RED.nodes.createNode(this, config);
const node = this;
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.changed";
if (String(topic) !== "kiosk.changed") {
return done && done();
}
const out = {
topic: "kiosk.changed",
payload: {
kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null,
kiosk_name: body.kiosk_name || null,
event: body.event || 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,
},
};
node.status({
fill: "green",
shape: "dot",
text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + (out.payload.event || ""),
});
send(out);
done && done();
});
}
RED.nodes.registerType("bf-trigger-kiosk-changed", BfTriggerKioskChangedNode);
};

View file

@ -0,0 +1,28 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-layout-changed", {
category: "BetterFrame Triggers",
color: "#a6d4ff",
defaults: {
name: { value: "" },
},
inputs: 1,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "layout changed";
},
paletteLabel: "Layout Changed Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-layout-changed">
<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-tips">
Triggers when a display's active layout changes.
Wire <code>http in POST /in/kiosk/layout.changed</code> in front of this node.
Emits <code>msg.payload = {display_id, kiosk_id, layout_id, layout_name}</code>.
</div>
</script>

View file

@ -0,0 +1,39 @@
/**
* bf-trigger-layout-changed fires when a display switches to a new layout.
*
* Topic filter: `layout.changed`. Server emits these from the admin layout-
* switch routes after delivering the WS command to the kiosk.
*
* Output msg.payload: { display_id, kiosk_id, layout_id, layout_name }
*/
module.exports = function (RED) {
function BfTriggerLayoutChangedNode(config) {
RED.nodes.createNode(this, config);
const node = this;
node.on("input", function (msg, send, done) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {};
const topic = msg.topic || body.topic || "layout.changed";
if (String(topic) !== "layout.changed") {
return done && done();
}
const out = {
topic: "layout.changed",
payload: {
display_id: body.display_id !== undefined ? body.display_id : null,
kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null,
layout_id: body.layout_id !== undefined ? body.layout_id : null,
layout_name: body.layout_name || null,
},
};
node.status({
fill: "green",
shape: "dot",
text: out.payload.layout_name || String(out.payload.layout_id || ""),
});
send(out);
done && done();
});
}
RED.nodes.registerType("bf-trigger-layout-changed", BfTriggerLayoutChangedNode);
};

View file

@ -35,6 +35,7 @@ import {
import { discover as onvifDiscover } from "../../shared/onvif.js";
import { generateBundle } from "../../shared/bundle.js";
import { captureSnapshot } from "../../shared/snapshot.js";
import { stripSecrets } from "../../shared/strip-secrets.js";
interface DiscoverAddStream {
profile_name: string;
@ -57,6 +58,13 @@ function htmlFragment(markup: unknown): Response {
});
}
function jsonResponse(value: unknown, status: number = 200): Response {
return new Response(JSON.stringify(stripSecrets(value)), {
status,
headers: { "content-type": "application/json" },
});
}
function isHtmxRequest(event: Parameters<typeof getRequestHeader>[0]): boolean {
return getRequestHeader(event, "hx-request") === "true";
}
@ -123,8 +131,8 @@ function importDiscoveredCamera(
username: string,
password: string,
streams: DiscoverAddStream[],
): void {
if (streams.length === 0) return;
): number | null {
if (streams.length === 0) return null;
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
const name = uniqueCameraName(deps, rawName || "ONVIF camera");
@ -151,6 +159,7 @@ function importDiscoveredCamera(
is_discovered: true,
});
}
return cam.id;
}
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
@ -363,6 +372,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
}
notifyKiosks();
deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created" });
return new Response(null, {
status: 302,
@ -423,14 +433,20 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const rawName = formValue(body?.[`camera_${idx}_name`]).trim() || "ONVIF camera";
const streams = parseDiscoveredStreams(formValue(body?.[`camera_${idx}_streams_json`]));
if (streams.length === 0) continue;
importDiscoveredCamera(deps, rawName, username, password, streams);
const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
if (camId != null) {
deps.nodered.forward("camera.changed", { camera_id: camId, event: "created" });
}
imported += 1;
}
} else {
const rawName = formValue(body?.["name"]).trim() || "ONVIF camera";
const streams = parseDiscoveredStreams(formValue(body?.["streams_json"]));
if (streams.length > 0) {
importDiscoveredCamera(deps, rawName, username, password, streams);
const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
if (camId != null) {
deps.nodered.forward("camera.changed", { camera_id: camId, event: "created" });
}
imported += 1;
}
}
@ -1093,6 +1109,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}
}
notifyKiosks();
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" });
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
});
@ -1131,6 +1148,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const id = Number(getRouterParam(event, "id"));
deps.repo.deleteCamera(id);
notifyKiosks();
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted" });
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
});
@ -1259,11 +1277,23 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
// ---- Layout switch ----------------------------------------------------
const emitLayoutChanged = (displayId: number | null, kioskId: number | null, layoutId: number) => {
const layout = deps.repo.getLayoutById(layoutId);
deps.nodered.forward("layout.changed", {
display_id: displayId,
kiosk_id: kioskId,
layout_id: layoutId,
layout_name: layout?.name ?? null,
});
};
const kioskLayoutSwitch = (event: any) => {
const id = Number(getRouterParam(event, "id"));
const layoutId = Number(getRouterParam(event, "layoutId"));
if (Number.isFinite(id) && Number.isFinite(layoutId)) {
getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId });
const displays = deps.repo.listDisplaysForKiosk(id);
emitLayoutChanged(displays[0]?.id ?? null, id, layoutId);
}
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
};
@ -1282,6 +1312,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
layout_id: layoutId,
});
}
emitLayoutChanged(displayId, display?.kiosk_id ?? null, layoutId);
}
return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
};
@ -1295,15 +1326,27 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
});
// ---- CEC power commands -----------------------------------------------
const emitDisplayPower = (kioskId: number, state: "on" | "standby") => {
const displays = deps.repo.listDisplaysForKiosk(kioskId);
const displayId = displays[0]?.id ?? null;
deps.nodered.forward("display.power.changed", {
display_id: displayId,
kiosk_id: kioskId,
state,
});
};
app.post("/admin/kiosks/:id/power/standby", (event) => {
const id = Number(getRouterParam(event, "id"));
getCoordinator().sendToKiosk(id, { type: "standby" });
emitDisplayPower(id, "standby");
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
app.post("/admin/kiosks/:id/power/wake", (event) => {
const id = Number(getRouterParam(event, "id"));
getCoordinator().sendToKiosk(id, { type: "wake" });
emitDisplayPower(id, "on");
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
@ -1321,7 +1364,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
});
// ---- JSON API (admin scope) — used by Node-RED bf-cameras node ----------
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
//
// All payloads run through `stripSecrets` so credential-bearing fields
// (key_hash, onvif_password, totp_secret_encrypted, etc.) never leak to
// automation clients. List shapes are kept thin (id/name/type/enabled +
// labels where useful); detail shapes return the full row minus secrets.
app.get("/api/admin/cameras", (_event) => {
const cameras = deps.repo.listCameras();
const payload = cameras.map((c) => ({
@ -1331,10 +1380,167 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
enabled: c.enabled,
labels: deps.repo.cameraLabelNames(c.id),
}));
return new Response(JSON.stringify({ cameras: payload }), {
status: 200,
headers: { "content-type": "application/json" },
return jsonResponse({ cameras: payload });
});
app.get("/api/admin/cameras/:id", (event) => {
const id = Number(getRouterParam(event, "id"));
const cam = deps.repo.getCameraById(id);
if (!cam) return jsonResponse({ error: "not_found" }, 404);
const streams = deps.repo.listCameraStreams(id);
return jsonResponse({
camera: { ...cam, labels: deps.repo.cameraLabelNames(id), streams },
});
});
app.get("/api/admin/displays", (_event) => {
const displays = deps.repo.listDisplays();
return jsonResponse({ displays });
});
app.get("/api/admin/displays/:id", (event) => {
const id = Number(getRouterParam(event, "id"));
const display = deps.repo.getDisplayById(id);
if (!display) return jsonResponse({ error: "not_found" }, 404);
const attachedLayouts = deps.repo.listLayoutsForDisplay(id);
return jsonResponse({ display: { ...display, attached_layouts: attachedLayouts } });
});
app.get("/api/admin/kiosks", (_event) => {
const kiosks = deps.repo.listKiosks();
const now = Date.now();
const payload = kiosks.map((k) => ({
id: k.id,
name: k.name,
enabled: k.enabled,
hardware_model: k.hardware_model,
last_seen_at: k.last_seen_at,
online: k.last_seen_at
? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000
: false,
cpu_temp_c: k.cpu_temp_c,
fan_rpm: k.fan_rpm,
fan_pwm: k.fan_pwm,
}));
return jsonResponse({ kiosks: payload });
});
app.get("/api/admin/kiosks/:id", (event) => {
const id = Number(getRouterParam(event, "id"));
const kiosk = deps.repo.getKioskById(id);
if (!kiosk) return jsonResponse({ error: "not_found" }, 404);
const displays = deps.repo.listDisplaysForKiosk(id);
const labels = deps.repo.listKioskLabels(id).map((kl) => ({
label_id: kl.label_id,
name: kl.name,
role: kl.role,
}));
return jsonResponse({ kiosk: { ...kiosk, displays, labels } });
});
app.get("/api/admin/layouts", (_event) => {
const layouts = deps.repo.listLayouts();
return jsonResponse({ layouts });
});
app.get("/api/admin/layouts/:id", (event) => {
const id = Number(getRouterParam(event, "id"));
const layout = deps.repo.getLayoutById(id);
if (!layout) return jsonResponse({ error: "not_found" }, 404);
const cells = deps.repo.layoutCells(id);
const displays = deps.repo.listDisplaysForLayout(id);
return jsonResponse({ layout: { ...layout, cells, displays } });
});
app.get("/api/admin/entities", (_event) => {
const entities = deps.repo.listEntities();
return jsonResponse({ entities });
});
app.get("/api/admin/entities/:id", (event) => {
const id = Number(getRouterParam(event, "id"));
const entity = deps.repo.getEntityById(id);
if (!entity) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ entity });
});
// ---- JSON mutation API — used by Node-RED bf-config-set node ------------
//
// Body shape: { value: <new value> } — keeps the wire format uniform
// across all set ops. Returns the post-mutation entity.
app.post("/api/admin/displays/:id/default-layout", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const raw = body["value"] ?? body["default_layout_id"];
const layoutId = raw == null || raw === "" ? null : Number(raw);
if (raw != null && raw !== "" && !Number.isFinite(layoutId)) {
return jsonResponse({ error: "invalid_value" }, 400);
}
if (layoutId != null) {
const attached = deps.repo.listLayoutsForDisplay(id);
if (!attached.some((l) => l.id === layoutId)) {
return jsonResponse({ error: "layout_not_attached" }, 400);
}
}
deps.repo.updateDisplay(id, { default_layout_id: layoutId } as any);
notifyKiosks();
const display = deps.repo.getDisplayById(id);
return jsonResponse({ display });
});
app.post("/api/admin/kiosks/:id/enabled", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const enabled = Boolean(body["value"] ?? body["enabled"]);
deps.repo.updateKiosk(id, { enabled } as any);
const kiosk = deps.repo.getKioskById(id);
if (!kiosk) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ kiosk });
});
app.post("/api/admin/cameras/:id/enabled", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const enabled = Boolean(body["value"] ?? body["enabled"]);
deps.repo.updateCamera(id, { enabled } as any);
notifyKiosks();
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" });
const camera = deps.repo.getCameraById(id);
if (!camera) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ camera });
});
app.post("/api/admin/layouts/:id/priority", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const value = String(body["value"] ?? body["priority"] ?? "").toLowerCase();
if (value !== "hot" && value !== "normal" && value !== "cold") {
return jsonResponse({ error: "invalid_priority" }, 400);
}
deps.repo.updateLayout(id, { priority: value } as any);
notifyKiosks();
const layout = deps.repo.getLayoutById(id);
if (!layout) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ layout });
});
app.post("/api/admin/entities/:id/name", async (event) => {
const id = Number(getRouterParam(event, "id"));
const body = (await readBody<Record<string, unknown>>(event)) ?? {};
const name = String(body["value"] ?? body["name"] ?? "").trim();
if (!name || name.length > 128) {
return jsonResponse({ error: "invalid_name" }, 400);
}
const existing = deps.repo.getEntityByName(name);
if (existing && existing.id !== id) {
return jsonResponse({ error: "name_in_use" }, 400);
}
deps.repo.updateEntity(id, { name });
notifyKiosks();
const entity = deps.repo.getEntityById(id);
if (!entity) return jsonResponse({ error: "not_found" }, 404);
return jsonResponse({ entity });
});
// ---- Dashboard entity sync — pull tabs from Node-RED, mirror as entities --

View file

@ -26,6 +26,7 @@ import { getRepo } from "../../shared/plugin-registry.js";
import { initSecrets } from "../../shared/secrets.js";
import { createAuth } from "../../shared/auth.js";
import { setCoordinator } from "../../shared/coordinator-registry.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
// ---- Config -----------------------------------------------------------------
@ -104,6 +105,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
private httpServer?: HttpServer;
private wss?: WebSocketServer;
private pingInterval?: ReturnType<typeof setInterval>;
private nodered?: NoderedBridge;
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
super(cfg);
@ -115,6 +117,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
{ dataDir: this.config.dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
);
const nodered = initNoderedBridge(
{ baseUrl: this.config.noderedUrl },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
);
this.nodered = nodered;
const auth = createAuth(repo, secrets, {
sessionIdleSeconds: this.config.sessionIdleSeconds,
sessionMaxSeconds: this.config.sessionMaxSeconds,
@ -169,13 +177,29 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
connectedKiosks.set(kiosk.id, { id: kiosk.id, name: kioskData.name, ws });
obs.log.info("kiosk connected: {name}", { name: kioskData.name });
ws.send(JSON.stringify({ type: "connected", kiosk_id: kiosk.id }));
nodered.forward("kiosk.changed", {
kiosk_id: kiosk.id,
kiosk_name: kioskData.name,
event: "connected",
});
ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === "pong") return;
if (msg.type === "status") {
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
if (msg["type"] === "pong") return;
if (msg["type"] === "status") {
obs.log.info("kiosk status: {data}", { data: data.toString() });
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", {
kiosk_id: kiosk.id,
kiosk_name: kioskData.name,
event: "heartbeat",
cpu_temp_c: cpu,
fan_rpm: fanRpm,
fan_pwm: fanPwm,
});
}
} catch {
// ignore malformed
@ -185,6 +209,11 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
ws.on("close", () => {
connectedKiosks.delete(kiosk.id);
obs.log.info("kiosk disconnected: {name}", { name: kioskData.name });
nodered.forward("kiosk.changed", {
kiosk_id: kiosk.id,
kiosk_name: kioskData.name,
event: "disconnected",
});
});
});
} catch (err) {

View file

@ -0,0 +1,42 @@
/**
* strip-secrets recursively remove credential-like keys from an object
* before serializing to JSON. Used by admin API JSON responses so callers
* (Node-RED, scripted clients) never receive password hashes, encrypted
* TOTP secrets, kiosk keys, etc.
*
* The set is conservative: anything matching a known secret-bearing key
* name is dropped. Add keys here when new credential fields appear.
*/
const SECRET_KEYS: ReadonlySet<string> = new Set([
"password",
"password_hash",
"key_hash",
"onvif_password",
"kiosk_key",
"totp_secret_encrypted",
"csrf_token",
"recovery_codes_hashed",
// wire-side variants in case bf-config-* responses ever proxy them
"api_key",
"cluster_key",
"cluster_key_encrypted",
]);
export function stripSecrets<T>(value: T): T {
return stripInner(value) as T;
}
function stripInner(v: unknown): unknown {
if (v === null || v === undefined) return v;
if (Array.isArray(v)) return v.map((item) => stripInner(item));
if (typeof v === "object") {
const out: Record<string, unknown> = {};
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
if (SECRET_KEYS.has(k)) continue;
out[k] = stripInner(val);
}
return out;
}
return v;
}