mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
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:
parent
44b0268def
commit
bd48c853e6
30 changed files with 951 additions and 80 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
54
nodered/src/bf-config-get.html
Normal file
54
nodered/src/bf-config-get.html
Normal 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>
|
||||
91
nodered/src/bf-config-get.js
Normal file
91
nodered/src/bf-config-get.js
Normal 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);
|
||||
};
|
||||
56
nodered/src/bf-config-set.html
Normal file
56
nodered/src/bf-config-set.html
Normal 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>
|
||||
102
nodered/src/bf-config-set.js
Normal file
102
nodered/src/bf-config-set.js
Normal 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);
|
||||
};
|
||||
|
|
@ -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" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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/<topic></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/<topic></code> to feed it.
|
||||
On match the message becomes
|
||||
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>.
|
||||
</div>
|
||||
</script>
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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: "" },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
23
nodered/src/bf-server-config.js
Normal file
23
nodered/src/bf-server-config.js
Normal 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" },
|
||||
},
|
||||
});
|
||||
};
|
||||
28
nodered/src/bf-trigger-camera-changed.html
Normal file
28
nodered/src/bf-trigger-camera-changed.html
Normal 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>
|
||||
38
nodered/src/bf-trigger-camera-changed.js
Normal file
38
nodered/src/bf-trigger-camera-changed.js
Normal 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);
|
||||
};
|
||||
28
nodered/src/bf-trigger-display-power.html
Normal file
28
nodered/src/bf-trigger-display-power.html
Normal 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>
|
||||
37
nodered/src/bf-trigger-display-power.js
Normal file
37
nodered/src/bf-trigger-display-power.js
Normal 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);
|
||||
};
|
||||
28
nodered/src/bf-trigger-kiosk-changed.html
Normal file
28
nodered/src/bf-trigger-kiosk-changed.html
Normal 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>
|
||||
45
nodered/src/bf-trigger-kiosk-changed.js
Normal file
45
nodered/src/bf-trigger-kiosk-changed.js
Normal 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);
|
||||
};
|
||||
28
nodered/src/bf-trigger-layout-changed.html
Normal file
28
nodered/src/bf-trigger-layout-changed.html
Normal 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>
|
||||
39
nodered/src/bf-trigger-layout-changed.js
Normal file
39
nodered/src/bf-trigger-layout-changed.js
Normal 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);
|
||||
};
|
||||
|
|
@ -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 --
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
42
server/src/shared/strip-secrets.ts
Normal file
42
server/src/shared/strip-secrets.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue