mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06: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
|
## Nodes
|
||||||
|
|
||||||
| Node | Type | Purpose |
|
| Node | Category | Purpose |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `bf-config` | config | Shared server URL + admin API key |
|
| `bf-server-config` | config | Shared server URL + admin API key |
|
||||||
| `bf-event-in` | input | Filter incoming kiosk events by topic glob |
|
| `bf-kiosk-camera-event` | Triggers | Filter incoming kiosk camera events (default `camera.*`) |
|
||||||
| `bf-layout-switch` | action | Switch a display's active layout |
|
| `bf-trigger-display-power` | Triggers | Fires on `display.power.changed` |
|
||||||
| `bf-power` | action | Wake / standby a kiosk display |
|
| `bf-trigger-layout-changed` | Triggers | Fires on `layout.changed` |
|
||||||
| `bf-fan` | action | Set fan mode (auto/pwm) on a kiosk |
|
| `bf-trigger-kiosk-changed` | Triggers | Fires on `kiosk.changed` (connect/disconnect/heartbeat) |
|
||||||
| `bf-cameras` | query | Fetch the camera list |
|
| `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
|
## Authentication
|
||||||
|
|
||||||
All action/query nodes use an **admin-scoped API key** created in the
|
All action/query nodes use an **admin-scoped API key** created in the
|
||||||
BetterFrame admin UI. The key is sent as `Authorization: Bearer bf-...`.
|
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
|
## 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
|
Wire an upstream `http in` node on `/in/kiosk/<topic>` (BetterFrame's
|
||||||
authenticated kiosk-ingest endpoint, surfaced by the Angie proxy with
|
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
|
## Installation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,18 @@
|
||||||
"node-red": {
|
"node-red": {
|
||||||
"version": ">=3.0.0",
|
"version": ">=3.0.0",
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"bf-config": "src/bf-config.js",
|
"bf-server-config": "src/bf-server-config.js",
|
||||||
"bf-event-in": "src/bf-event-in.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-layout-switch": "src/bf-layout-switch.js",
|
||||||
"bf-power": "src/bf-power.js",
|
"bf-power": "src/bf-power.js",
|
||||||
"bf-fan": "src/bf-fan.js",
|
"bf-fan": "src/bf-fan.js",
|
||||||
"bf-cameras": "src/bf-cameras.js"
|
"bf-cameras": "src/bf-cameras.js",
|
||||||
|
"bf-config-get": "src/bf-config-get.js",
|
||||||
|
"bf-config-set": "src/bf-config-set.js"
|
||||||
},
|
},
|
||||||
"icons": [
|
"icons": [
|
||||||
"icons"
|
"icons"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
color: "#a6d4ff",
|
color: "#a6d4ff",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
config: { value: "", type: "bf-config", required: true },
|
config: { value: "", type: "bf-server-config", required: true },
|
||||||
label: { value: "" },
|
label: { value: "" },
|
||||||
},
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ module.exports = function (RED) {
|
||||||
|
|
||||||
node.on("input", async (msg, send, done) => {
|
node.on("input", async (msg, send, done) => {
|
||||||
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
||||||
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
|
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
|
||||||
return done(new Error("bf-config server_url + api_key required"));
|
return done(new Error("bf-server-config server_url + api_key required"));
|
||||||
}
|
}
|
||||||
const filterLabel = (msg.label || config.label || "").trim().toLowerCase();
|
const filterLabel = (msg.label || config.label || "").trim().toLowerCase();
|
||||||
const url = cfg.server_url + "/api/admin/cameras";
|
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",
|
color: "#a6d4ff",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
config: { value: "", type: "bf-config", required: true },
|
config: { value: "", type: "bf-server-config", required: true },
|
||||||
kiosk_id: { value: "" },
|
kiosk_id: { value: "" },
|
||||||
mode: { value: "auto" },
|
mode: { value: "auto" },
|
||||||
pwm: { value: 128 },
|
pwm: { value: 128 },
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ module.exports = function (RED) {
|
||||||
|
|
||||||
node.on("input", async (msg, send, done) => {
|
node.on("input", async (msg, send, done) => {
|
||||||
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
||||||
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
|
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
|
||||||
return done(new Error("bf-config server_url + api_key required"));
|
return done(new Error("bf-server-config server_url + api_key required"));
|
||||||
}
|
}
|
||||||
const kioskId = msg.kiosk_id || config.kiosk_id;
|
const kioskId = msg.kiosk_id || config.kiosk_id;
|
||||||
if (!kioskId) {
|
if (!kioskId) {
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,35 @@
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
RED.nodes.registerType("bf-event-in", {
|
RED.nodes.registerType("bf-kiosk-camera-event", {
|
||||||
category: "BetterFrame",
|
category: "BetterFrame Triggers",
|
||||||
color: "#a6d4ff",
|
color: "#a6d4ff",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
topic_pattern: { value: "" },
|
topic_pattern: { value: "camera.*" },
|
||||||
},
|
},
|
||||||
inputs: 1,
|
inputs: 1,
|
||||||
outputs: 1,
|
outputs: 1,
|
||||||
icon: "betterframe.svg",
|
icon: "betterframe.svg",
|
||||||
label: function () {
|
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>
|
||||||
|
|
||||||
<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">
|
<div class="form-row">
|
||||||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-input-topic_pattern"><i class="fa fa-filter"></i> Topic</label>
|
<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>
|
||||||
<div class="form-tips">
|
<div class="form-tips">
|
||||||
Filter incoming BF events by topic. <code>*</code> is a wildcard.
|
Filter incoming kiosk camera events by topic. <code>*</code> is a wildcard.
|
||||||
Wire an upstream <b>http in</b> on <code>/in/kiosk/<topic></code> (or any source
|
Defaults to <code>camera.*</code> (ONVIF motion, object detection, etc.).
|
||||||
that puts the event body in <code>msg.payload</code>) to feed this node.
|
Wire an upstream <b>http in</b> on <code>/in/kiosk/<topic></code> to feed it.
|
||||||
On match the message becomes:
|
On match the message becomes
|
||||||
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>.
|
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>.
|
||||||
</div>
|
</div>
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* bf-event-in — fire a flow whenever a BetterFrame kiosk event matching a
|
* bf-kiosk-camera-event — fire a flow whenever a BetterFrame kiosk camera
|
||||||
* topic pattern arrives.
|
* event matching a topic pattern arrives. Defaults to `camera.*` (ONVIF
|
||||||
|
* motion, object detection, line crossing, etc.).
|
||||||
*
|
*
|
||||||
* Two delivery paths can land here:
|
* Two delivery paths can land here:
|
||||||
* 1. The BF server has forwarded an authenticated kiosk event via the
|
* 1. The BF server has forwarded an authenticated kiosk event via the
|
||||||
|
|
@ -9,17 +10,20 @@
|
||||||
* filter by topic.
|
* filter by topic.
|
||||||
* 2. A separate flow injects msg.topic + msg.payload directly.
|
* 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
|
* This is a pure filter/router. It does NOT itself subscribe to the BF
|
||||||
* subscribe to the BF server; that wiring is done with stock Node-RED http-in
|
* server; that wiring is done with stock Node-RED http-in or websocket
|
||||||
* or websocket nodes upstream.
|
* 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) {
|
module.exports = function (RED) {
|
||||||
function BfEventInNode(config) {
|
function BfKioskCameraEventNode(config) {
|
||||||
RED.nodes.createNode(this, config);
|
RED.nodes.createNode(this, config);
|
||||||
const node = this;
|
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) {
|
function toRegex(p) {
|
||||||
if (!p) return null;
|
if (!p) return null;
|
||||||
const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
||||||
|
|
@ -54,5 +58,5 @@ module.exports = function (RED) {
|
||||||
done && done();
|
done && done();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
RED.nodes.registerType("bf-event-in", BfEventInNode);
|
RED.nodes.registerType("bf-kiosk-camera-event", BfKioskCameraEventNode);
|
||||||
};
|
};
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
color: "#a6d4ff",
|
color: "#a6d4ff",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
config: { value: "", type: "bf-config", required: true },
|
config: { value: "", type: "bf-server-config", required: true },
|
||||||
display_id: { value: "" },
|
display_id: { value: "" },
|
||||||
layout_id: { value: "" },
|
layout_id: { value: "" },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ module.exports = function (RED) {
|
||||||
|
|
||||||
node.on("input", async (msg, send, done) => {
|
node.on("input", async (msg, send, done) => {
|
||||||
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
||||||
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
|
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
|
||||||
return done(new Error("bf-config server_url + api_key required"));
|
return done(new Error("bf-server-config server_url + api_key required"));
|
||||||
}
|
}
|
||||||
const displayId = msg.display_id || config.display_id;
|
const displayId = msg.display_id || config.display_id;
|
||||||
const layoutId = msg.layout_id || config.layout_id;
|
const layoutId = msg.layout_id || config.layout_id;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
color: "#a6d4ff",
|
color: "#a6d4ff",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
config: { value: "", type: "bf-config", required: true },
|
config: { value: "", type: "bf-server-config", required: true },
|
||||||
kiosk_id: { value: "" },
|
kiosk_id: { value: "" },
|
||||||
mode: { value: "wake" },
|
mode: { value: "wake" },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ module.exports = function (RED) {
|
||||||
|
|
||||||
node.on("input", async (msg, send, done) => {
|
node.on("input", async (msg, send, done) => {
|
||||||
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
if (!cfg || !cfg.server_url || !cfg.api_key) {
|
||||||
node.status({ fill: "red", shape: "ring", text: "missing bf-config" });
|
node.status({ fill: "red", shape: "ring", text: "missing bf-server-config" });
|
||||||
return done(new Error("bf-config server_url + api_key required"));
|
return done(new Error("bf-server-config server_url + api_key required"));
|
||||||
}
|
}
|
||||||
const kioskId = msg.kiosk_id || config.kiosk_id;
|
const kioskId = msg.kiosk_id || config.kiosk_id;
|
||||||
const mode = (msg.mode || config.mode || "wake").toLowerCase();
|
const mode = (msg.mode || config.mode || "wake").toLowerCase();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
RED.nodes.registerType("bf-config", {
|
RED.nodes.registerType("bf-server-config", {
|
||||||
category: "config",
|
category: "config",
|
||||||
defaults: {
|
defaults: {
|
||||||
name: { value: "" },
|
name: { value: "" },
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
api_key: { type: "password" },
|
api_key: { type: "password" },
|
||||||
},
|
},
|
||||||
label: function () {
|
label: function () {
|
||||||
return this.name || this.server_url || "bf-config";
|
return this.name || this.server_url || "bf-server-config";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script type="text/html" data-template-name="bf-config">
|
<script type="text/html" data-template-name="bf-server-config">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
<label for="node-config-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||||
<input type="text" id="node-config-input-name" placeholder="BetterFrame" />
|
<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 { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||||
import { generateBundle } from "../../shared/bundle.js";
|
import { generateBundle } from "../../shared/bundle.js";
|
||||||
import { captureSnapshot } from "../../shared/snapshot.js";
|
import { captureSnapshot } from "../../shared/snapshot.js";
|
||||||
|
import { stripSecrets } from "../../shared/strip-secrets.js";
|
||||||
|
|
||||||
interface DiscoverAddStream {
|
interface DiscoverAddStream {
|
||||||
profile_name: string;
|
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 {
|
function isHtmxRequest(event: Parameters<typeof getRequestHeader>[0]): boolean {
|
||||||
return getRequestHeader(event, "hx-request") === "true";
|
return getRequestHeader(event, "hx-request") === "true";
|
||||||
}
|
}
|
||||||
|
|
@ -123,8 +131,8 @@ function importDiscoveredCamera(
|
||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
streams: DiscoverAddStream[],
|
streams: DiscoverAddStream[],
|
||||||
): void {
|
): number | null {
|
||||||
if (streams.length === 0) return;
|
if (streams.length === 0) return null;
|
||||||
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
|
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
|
||||||
const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
|
const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
|
||||||
const name = uniqueCameraName(deps, rawName || "ONVIF camera");
|
const name = uniqueCameraName(deps, rawName || "ONVIF camera");
|
||||||
|
|
@ -151,6 +159,7 @@ function importDiscoveredCamera(
|
||||||
is_discovered: true,
|
is_discovered: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return cam.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
|
||||||
|
|
@ -363,6 +372,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
|
deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created" });
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
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 rawName = formValue(body?.[`camera_${idx}_name`]).trim() || "ONVIF camera";
|
||||||
const streams = parseDiscoveredStreams(formValue(body?.[`camera_${idx}_streams_json`]));
|
const streams = parseDiscoveredStreams(formValue(body?.[`camera_${idx}_streams_json`]));
|
||||||
if (streams.length === 0) continue;
|
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;
|
imported += 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const rawName = formValue(body?.["name"]).trim() || "ONVIF camera";
|
const rawName = formValue(body?.["name"]).trim() || "ONVIF camera";
|
||||||
const streams = parseDiscoveredStreams(formValue(body?.["streams_json"]));
|
const streams = parseDiscoveredStreams(formValue(body?.["streams_json"]));
|
||||||
if (streams.length > 0) {
|
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;
|
imported += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1093,6 +1109,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
|
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" });
|
||||||
|
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
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"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
deps.repo.deleteCamera(id);
|
deps.repo.deleteCamera(id);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
|
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted" });
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1259,11 +1277,23 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Layout switch ----------------------------------------------------
|
// ---- 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 kioskLayoutSwitch = (event: any) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
const layoutId = Number(getRouterParam(event, "layoutId"));
|
const layoutId = Number(getRouterParam(event, "layoutId"));
|
||||||
if (Number.isFinite(id) && Number.isFinite(layoutId)) {
|
if (Number.isFinite(id) && Number.isFinite(layoutId)) {
|
||||||
getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: 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}` } });
|
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,
|
layout_id: layoutId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
emitLayoutChanged(displayId, display?.kiosk_id ?? null, layoutId);
|
||||||
}
|
}
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
|
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 -----------------------------------------------
|
// ---- 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) => {
|
app.post("/admin/kiosks/:id/power/standby", (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
getCoordinator().sendToKiosk(id, { type: "standby" });
|
getCoordinator().sendToKiosk(id, { type: "standby" });
|
||||||
|
emitDisplayPower(id, "standby");
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/kiosks/:id/power/wake", (event) => {
|
app.post("/admin/kiosks/:id/power/wake", (event) => {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
getCoordinator().sendToKiosk(id, { type: "wake" });
|
getCoordinator().sendToKiosk(id, { type: "wake" });
|
||||||
|
emitDisplayPower(id, "on");
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
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}` } });
|
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) => {
|
app.get("/api/admin/cameras", (_event) => {
|
||||||
const cameras = deps.repo.listCameras();
|
const cameras = deps.repo.listCameras();
|
||||||
const payload = cameras.map((c) => ({
|
const payload = cameras.map((c) => ({
|
||||||
|
|
@ -1331,10 +1380,167 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
enabled: c.enabled,
|
enabled: c.enabled,
|
||||||
labels: deps.repo.cameraLabelNames(c.id),
|
labels: deps.repo.cameraLabelNames(c.id),
|
||||||
}));
|
}));
|
||||||
return new Response(JSON.stringify({ cameras: payload }), {
|
return jsonResponse({ cameras: payload });
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 --
|
// ---- 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 { initSecrets } from "../../shared/secrets.js";
|
||||||
import { createAuth } from "../../shared/auth.js";
|
import { createAuth } from "../../shared/auth.js";
|
||||||
import { setCoordinator } from "../../shared/coordinator-registry.js";
|
import { setCoordinator } from "../../shared/coordinator-registry.js";
|
||||||
|
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -104,6 +105,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
private httpServer?: HttpServer;
|
private httpServer?: HttpServer;
|
||||||
private wss?: WebSocketServer;
|
private wss?: WebSocketServer;
|
||||||
private pingInterval?: ReturnType<typeof setInterval>;
|
private pingInterval?: ReturnType<typeof setInterval>;
|
||||||
|
private nodered?: NoderedBridge;
|
||||||
|
|
||||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
super(cfg);
|
super(cfg);
|
||||||
|
|
@ -115,6 +117,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
{ dataDir: this.config.dataDir },
|
{ dataDir: this.config.dataDir },
|
||||||
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
{ 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, {
|
const auth = createAuth(repo, secrets, {
|
||||||
sessionIdleSeconds: this.config.sessionIdleSeconds,
|
sessionIdleSeconds: this.config.sessionIdleSeconds,
|
||||||
sessionMaxSeconds: this.config.sessionMaxSeconds,
|
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 });
|
connectedKiosks.set(kiosk.id, { id: kiosk.id, name: kioskData.name, ws });
|
||||||
obs.log.info("kiosk connected: {name}", { name: kioskData.name });
|
obs.log.info("kiosk connected: {name}", { name: kioskData.name });
|
||||||
ws.send(JSON.stringify({ type: "connected", kiosk_id: kiosk.id }));
|
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) => {
|
ws.on("message", (data) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data.toString());
|
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
|
||||||
if (msg.type === "pong") return;
|
if (msg["type"] === "pong") return;
|
||||||
if (msg.type === "status") {
|
if (msg["type"] === "status") {
|
||||||
obs.log.info("kiosk status: {data}", { data: data.toString() });
|
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 {
|
} catch {
|
||||||
// ignore malformed
|
// ignore malformed
|
||||||
|
|
@ -185,6 +209,11 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
connectedKiosks.delete(kiosk.id);
|
connectedKiosks.delete(kiosk.id);
|
||||||
obs.log.info("kiosk disconnected: {name}", { name: kioskData.name });
|
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) {
|
} 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