diff --git a/nodered/README.md b/nodered/README.md index d425266..896b2b6 100644 --- a/nodered/README.md +++ b/nodered/README.md @@ -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/` (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 diff --git a/nodered/package.json b/nodered/package.json index 2de436f..0fe1ead 100644 --- a/nodered/package.json +++ b/nodered/package.json @@ -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" diff --git a/nodered/src/bf-cameras.html b/nodered/src/bf-cameras.html index 8e4ac71..45a9eb7 100644 --- a/nodered/src/bf-cameras.html +++ b/nodered/src/bf-cameras.html @@ -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, diff --git a/nodered/src/bf-cameras.js b/nodered/src/bf-cameras.js index 4c8876f..28fc04d 100644 --- a/nodered/src/bf-cameras.js +++ b/nodered/src/bf-cameras.js @@ -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"; diff --git a/nodered/src/bf-config-get.html b/nodered/src/bf-config-get.html new file mode 100644 index 0000000..551c086 --- /dev/null +++ b/nodered/src/bf-config-get.html @@ -0,0 +1,54 @@ + + + diff --git a/nodered/src/bf-config-get.js b/nodered/src/bf-config-get.js new file mode 100644 index 0000000..05ab686 --- /dev/null +++ b/nodered/src/bf-config-get.js @@ -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 {: [...]}, we unwrap it. + // For -by-id types, response shape is {: {...}} 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); +}; diff --git a/nodered/src/bf-config-set.html b/nodered/src/bf-config-set.html new file mode 100644 index 0000000..d2c3f72 --- /dev/null +++ b/nodered/src/bf-config-set.html @@ -0,0 +1,56 @@ + + + diff --git a/nodered/src/bf-config-set.js b/nodered/src/bf-config-set.js new file mode 100644 index 0000000..a985304 --- /dev/null +++ b/nodered/src/bf-config-set.js @@ -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//:id/` with + * body `{ 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); +}; diff --git a/nodered/src/bf-config.js b/nodered/src/bf-config.js deleted file mode 100644 index d11fd6a..0000000 --- a/nodered/src/bf-config.js +++ /dev/null @@ -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" }, - }, - }); -}; diff --git a/nodered/src/bf-fan.html b/nodered/src/bf-fan.html index d84a6d5..3127162 100644 --- a/nodered/src/bf-fan.html +++ b/nodered/src/bf-fan.html @@ -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 }, diff --git a/nodered/src/bf-fan.js b/nodered/src/bf-fan.js index 6058bb4..802ad14 100644 --- a/nodered/src/bf-fan.js +++ b/nodered/src/bf-fan.js @@ -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) { diff --git a/nodered/src/bf-event-in.html b/nodered/src/bf-kiosk-camera-event.html similarity index 58% rename from nodered/src/bf-event-in.html rename to nodered/src/bf-kiosk-camera-event.html index 833f929..ce60007 100644 --- a/nodered/src/bf-event-in.html +++ b/nodered/src/bf-kiosk-camera-event.html @@ -1,35 +1,35 @@ - diff --git a/nodered/src/bf-event-in.js b/nodered/src/bf-kiosk-camera-event.js similarity index 70% rename from nodered/src/bf-event-in.js rename to nodered/src/bf-kiosk-camera-event.js index 1ab9b29..33f940d 100644 --- a/nodered/src/bf-event-in.js +++ b/nodered/src/bf-kiosk-camera-event.js @@ -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); }; diff --git a/nodered/src/bf-layout-switch.html b/nodered/src/bf-layout-switch.html index c2d2d3f..8a7236e 100644 --- a/nodered/src/bf-layout-switch.html +++ b/nodered/src/bf-layout-switch.html @@ -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: "" }, }, diff --git a/nodered/src/bf-layout-switch.js b/nodered/src/bf-layout-switch.js index 6ef3870..5370461 100644 --- a/nodered/src/bf-layout-switch.js +++ b/nodered/src/bf-layout-switch.js @@ -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; diff --git a/nodered/src/bf-power.html b/nodered/src/bf-power.html index 7a1a1fe..05e4382 100644 --- a/nodered/src/bf-power.html +++ b/nodered/src/bf-power.html @@ -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" }, }, diff --git a/nodered/src/bf-power.js b/nodered/src/bf-power.js index 85187ff..64bcd03 100644 --- a/nodered/src/bf-power.js +++ b/nodered/src/bf-power.js @@ -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(); diff --git a/nodered/src/bf-config.html b/nodered/src/bf-server-config.html similarity index 86% rename from nodered/src/bf-config.html rename to nodered/src/bf-server-config.html index 3147507..1370693 100644 --- a/nodered/src/bf-config.html +++ b/nodered/src/bf-server-config.html @@ -1,5 +1,5 @@ - + + diff --git a/nodered/src/bf-trigger-camera-changed.js b/nodered/src/bf-trigger-camera-changed.js new file mode 100644 index 0000000..f623000 --- /dev/null +++ b/nodered/src/bf-trigger-camera-changed.js @@ -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); +}; diff --git a/nodered/src/bf-trigger-display-power.html b/nodered/src/bf-trigger-display-power.html new file mode 100644 index 0000000..2018a07 --- /dev/null +++ b/nodered/src/bf-trigger-display-power.html @@ -0,0 +1,28 @@ + + + diff --git a/nodered/src/bf-trigger-display-power.js b/nodered/src/bf-trigger-display-power.js new file mode 100644 index 0000000..60a59f9 --- /dev/null +++ b/nodered/src/bf-trigger-display-power.js @@ -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); +}; diff --git a/nodered/src/bf-trigger-kiosk-changed.html b/nodered/src/bf-trigger-kiosk-changed.html new file mode 100644 index 0000000..8eea8fe --- /dev/null +++ b/nodered/src/bf-trigger-kiosk-changed.html @@ -0,0 +1,28 @@ + + + diff --git a/nodered/src/bf-trigger-kiosk-changed.js b/nodered/src/bf-trigger-kiosk-changed.js new file mode 100644 index 0000000..b64b530 --- /dev/null +++ b/nodered/src/bf-trigger-kiosk-changed.js @@ -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); +}; diff --git a/nodered/src/bf-trigger-layout-changed.html b/nodered/src/bf-trigger-layout-changed.html new file mode 100644 index 0000000..48c3d4d --- /dev/null +++ b/nodered/src/bf-trigger-layout-changed.html @@ -0,0 +1,28 @@ + + + diff --git a/nodered/src/bf-trigger-layout-changed.js b/nodered/src/bf-trigger-layout-changed.js new file mode 100644 index 0000000..b6923f9 --- /dev/null +++ b/nodered/src/bf-trigger-layout-changed.js @@ -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); +}; diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index 7334ae3..b31c195 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -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[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,12 +1380,169 @@ 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: } — 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>(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>(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>(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>(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>(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 -- app.post("/admin/entities/sync-dashboards", async (event) => { const result = await syncDashboardsFromNodered(deps); diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 4e3e7a1..f4d7738 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -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, typeof Event private httpServer?: HttpServer; private wss?: WebSocketServer; private pingInterval?: ReturnType; + private nodered?: NoderedBridge; constructor(cfg: BSBServiceConstructor, typeof EventSchemas>) { super(cfg); @@ -115,6 +117,12 @@ export class Plugin extends BSBService, 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, 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; + 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, 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) { diff --git a/server/src/shared/strip-secrets.ts b/server/src/shared/strip-secrets.ts new file mode 100644 index 0000000..1367664 --- /dev/null +++ b/server/src/shared/strip-secrets.ts @@ -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 = 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(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 = {}; + for (const [k, val] of Object.entries(v as Record)) { + if (SECRET_KEYS.has(k)) continue; + out[k] = stripInner(val); + } + return out; + } + return v; +}