diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index a154963..8dc6b1c 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -27,12 +27,30 @@ struct DisplayState { is_asleep: bool, } +/// Pipeline lifecycle states (CLAUDE.md hot/warm/cooling/cold model): +/// - Hot: belongs to a priority=hot layout — keep warm forever +/// - Warm: actively rendered OR in active layout's preload list — decoding live +/// - Cooling: was warm, now not needed, kept alive until cooling_until +/// - Cold: removed from pool (no entry) +#[derive(Debug, Clone, Copy, PartialEq)] +enum WarmthState { + Hot, + Warm, + Cooling, +} + +struct PipelineEntry { + pipeline: gstreamer::Pipeline, + paintable: gtk::gdk::Paintable, + badge: char, + state: WarmthState, + cooling_until: Option, +} + thread_local! { - /// camera_id → (pipeline, paintable, badge). Pipelines stay warm across - /// layout swaps for cameras still referenced or in preload_camera_ids. - /// Shared across ALL displays — if two displays use the same camera the - /// pipeline is reused. The paintable can be attached to multiple Pictures. - static WARM_CAMERAS: RefCell> + /// camera_id → PipelineEntry. Pool shared across all displays. + /// State machine: see WarmthState. Entries dropped when state goes Cold. + static WARM_CAMERAS: RefCell> = RefCell::new(HashMap::new()); /// Most recently rendered bundle. Used for layout-switch + idle revert. @@ -394,35 +412,41 @@ fn render_bundle( } } - // Compute global warm-camera set: union across all displays' current/needed cameras. - let mut globally_needed: std::collections::HashSet = std::collections::HashSet::new(); + // Compute warm vs hot camera sets per the CLAUDE.md model. + // Warm = cameras in the currently-active layout (cells + preload) of any display. + // Hot = cameras referenced by ANY layout with priority=hot (kept warm always). + // Anything previously cached but in neither set transitions to Cooling. + let mut warm_set: std::collections::HashSet = std::collections::HashSet::new(); + let mut hot_set: std::collections::HashSet = std::collections::HashSet::new(); + let mut max_cooling_secs: u32 = 0; for (i, bd) in displays.iter().enumerate() { let target_id = pick_initial_layout(bd); if let Some(target_id) = target_id { if let Some(layout) = bd.layouts.iter().find(|l| l.id == target_id) { for cell in &layout.cells { if cell.content_type == "camera" { - if let Some(id) = cell.camera_id { globally_needed.insert(id); } + if let Some(id) = cell.camera_id { warm_set.insert(id); } } } - for id in &layout.preload_camera_ids { globally_needed.insert(*id); } + for id in &layout.preload_camera_ids { warm_set.insert(*id); } + if let Some(t) = layout.cooling_timeout_seconds { max_cooling_secs = max_cooling_secs.max(t); } + } + } + // Hot layouts keep their cameras warm even when not active. + for layout in &bd.layouts { + if layout.priority == "hot" { + for cell in &layout.cells { + if cell.content_type == "camera" { + if let Some(id) = cell.camera_id { hot_set.insert(id); } + } + } + for id in &layout.preload_camera_ids { hot_set.insert(*id); } } } - // Pre-touch the monitor binding to silence warnings about unused var. let _ = gdk_monitors.get(i); } - // Stop pipelines for cameras no longer needed by any display. - WARM_CAMERAS.with(|w| { - let mut warm = w.borrow_mut(); - let stale: Vec = warm.keys().filter(|id| !globally_needed.contains(id)).copied().collect(); - for id in stale { - if let Some((pipe, _, _)) = warm.remove(&id) { - info!("stopping pipeline for camera {id} (no longer needed by any display)"); - pipeline::stop(&pipe); - } - } - }); + recompute_pool_states(&warm_set, &hot_set, max_cooling_secs); // Build/reuse window per bundle display, then render its initial layout. let mut new_state: HashMap = HashMap::new(); diff --git a/nodered/README.md b/nodered/README.md index c6f5162..596c6ba 100644 --- a/nodered/README.md +++ b/nodered/README.md @@ -31,12 +31,40 @@ Configure once on a `bf-server-config` node and reference it from the others. ## Event ingest path -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 the matching -`bf-trigger-*` node. The server emits these topics from coordinator-ws -(kiosk WS lifecycle) and the admin routes (layout/power/camera mutations). +Trigger nodes are **self-contained** — each one registers its own +`POST /in/` handler on Node-RED's user-facing HTTP server (via +`RED.httpNode.post`) when the flow is deployed. You do **not** need to wire +an upstream `http in` node anymore. + +The BetterFrame server's `nodered-bridge.forward(topic, payload)` posts +events directly to `http://:1880/in/`. Each trigger +node listens on its own fixed topic: + +| Node | Internal route | +| --- | --- | +| `bf-trigger-display-power` | `POST /in/display.power.changed` | +| `bf-trigger-layout-changed` | `POST /in/layout.changed` | +| `bf-trigger-kiosk-changed` | `POST /in/kiosk.changed` | +| `bf-trigger-camera-changed` | `POST /in/camera.changed` | +| `bf-trigger-status` | `POST /in/kiosk.status` | +| `bf-kiosk-camera-event` | `POST /in/camera.event` | + +The server emits these topics from coordinator-ws (kiosk WS lifecycle) and +the admin routes (layout/power/camera mutations). Multiple instances of the +same trigger node on the same canvas are fine — Express runs all matching +handlers in registration order. + +If the Angie proxy fronts Node-RED, the otherwise-unmatched root paths +(public HTTP-in URLs) and the `/in/kiosk/` (kiosk-key gated) / +`/in/public/` (public, rate-limited) routes are still available +for user-authored flows that use stock `http in` nodes — those layers +strip the prefix before proxying. The trigger nodes' fixed +`/in/` routes are reserved for internal server-to-Node-RED +delivery and are not exposed through the proxy's gated surfaces by +default. + +Each trigger node also offers an optional ID filter (display_id / kiosk_id / +camera_id) so you can drop one node per entity without a downstream switch. ## Installation diff --git a/nodered/src/bf-kiosk-camera-event.html b/nodered/src/bf-kiosk-camera-event.html index ce60007..82353b3 100644 --- a/nodered/src/bf-kiosk-camera-event.html +++ b/nodered/src/bf-kiosk-camera-event.html @@ -4,13 +4,13 @@ color: "#a6d4ff", defaults: { name: { value: "" }, - topic_pattern: { value: "camera.*" }, + camera_id: { value: "" }, }, - inputs: 1, + inputs: 0, outputs: 1, icon: "betterframe.svg", label: function () { - return this.name || this.topic_pattern || "kiosk camera event"; + return this.name || "kiosk camera event"; }, paletteLabel: "Kiosk Camera Event", }); @@ -22,14 +22,26 @@
- - + +
- Filter incoming kiosk camera events by topic. * is a wildcard. - Defaults to camera.* (ONVIF motion, object detection, etc.). - Wire an upstream http in on /in/kiosk/<topic> to feed it. - On match the message becomes - {topic, kiosk_id, camera_id, source_type, payload}. + Fires on kiosk-forwarded camera events (ONVIF motion, line crossing, GPIO pulses + tagged to a camera, etc.). + Listens on POST /in/camera.event internally — no upstream + http in node required. + Emits msg = {topic, kiosk_id, camera_id, source_type, payload}. + Leave Camera ID blank to receive events from all cameras.
+ + diff --git a/nodered/src/bf-kiosk-camera-event.js b/nodered/src/bf-kiosk-camera-event.js index 33f940d..e2446d7 100644 --- a/nodered/src/bf-kiosk-camera-event.js +++ b/nodered/src/bf-kiosk-camera-event.js @@ -1,61 +1,73 @@ /** - * 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.). + * bf-kiosk-camera-event — fires on kiosk-originated camera events forwarded + * from the BetterFrame server (ONVIF motion, object detection, line crossing, + * GPIO pulse tagged to a camera, etc.). * - * Two delivery paths can land here: - * 1. The BF server has forwarded an authenticated kiosk event via the - * `/in/kiosk/` ingest endpoint. The flow operator wires an - * `http in` node on that path and connects it to this node — we just - * filter by topic. - * 2. A separate flow injects msg.topic + msg.payload directly. + * The server's api-http `/api/kiosk/events` endpoint persists each kiosk + * event then calls `nodered.forward(topic, payload)` which POSTs to + * `${noderedUrl}/in/`. This node self-registers a POST handler at a + * fixed route — no upstream `http in` node required. * - * 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. + * Optional config: + * - camera_id: only fire for that camera id * - * Renamed from `bf-event-in` — kept the same envelope shape for backward - * compatibility with flows that consume the output message. + * Output msg shape (kept compatible with the previous filter version): + * { topic, kiosk_id, camera_id, source_type, payload } */ module.exports = function (RED) { + // Fixed ingest route. Server-side forwarders that want this node to receive + // their event should POST to /in/camera.event. (Previous releases used a + // glob-pattern filter over upstream http-in messages; that path is gone.) + const TOPIC = "camera.event"; + const ROUTE = "/api/internal/" + TOPIC; + function BfKioskCameraEventNode(config) { RED.nodes.createNode(this, config); const node = this; - const pattern = (config.topic_pattern || "camera.*").trim(); + const filterIdRaw = (config.camera_id || "").toString().trim(); + const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null; - // Convert glob-ish pattern to RegExp: `camera.*` → /^camera\..*$/ - function toRegex(p) { - if (!p) return null; - const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); - return new RegExp("^" + escaped + "$"); - } - const re = toRegex(pattern); - - node.on("input", function (msg, send, done) { - // Common BF envelope shape: - // { topic, kiosk_id, camera_id, source_type, payload } - // We accept either a fully-formed msg or one where the body lives in - // msg.payload (typical for Node-RED http-in). - const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; - const topic = msg.topic || body.topic || ""; - if (!topic) { - node.status({ fill: "yellow", shape: "ring", text: "no topic" }); - return done && done(); - } - if (re && !re.test(String(topic))) { - // Filter miss — drop silently. - return done && done(); + function handler(req, res) { + const body = (req.body && typeof req.body === "object") ? req.body : {}; + const kioskId = body.kiosk_id !== undefined ? body.kiosk_id + : body.source_kiosk_id !== undefined ? body.source_kiosk_id + : null; + const cameraId = body.camera_id !== undefined ? body.camera_id + : body.source_camera_id !== undefined ? body.source_camera_id + : null; + if (filterId !== null && Number(cameraId) !== filterId) { + return res.status(200).end(); } const out = { - topic: String(topic), - kiosk_id: msg.kiosk_id || body.kiosk_id || body.source_kiosk_id || null, - camera_id: msg.camera_id || body.camera_id || body.source_camera_id || null, + topic: body.topic ? String(body.topic) : TOPIC, + kiosk_id: kioskId, + camera_id: cameraId, source_type: body.source_type || null, payload: body.payload !== undefined ? body.payload : body, }; node.status({ fill: "green", shape: "dot", text: out.topic }); - send(out); - done && done(); + node.send(out); + res.status(200).end(); + } + + RED.httpNode.post(ROUTE, handler); + + node.on("close", function (done) { + const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; + if (stack) { + for (let i = stack.length - 1; i >= 0; i--) { + const layer = stack[i]; + if (!layer || !layer.route || layer.route.path !== ROUTE) continue; + const inner = layer.route.stack; + if (Array.isArray(inner)) { + for (let j = inner.length - 1; j >= 0; j--) { + if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); + } + if (inner.length === 0) stack.splice(i, 1); + } + } + } + done(); }); } RED.nodes.registerType("bf-kiosk-camera-event", BfKioskCameraEventNode); diff --git a/nodered/src/bf-trigger-camera-changed.html b/nodered/src/bf-trigger-camera-changed.html index 3e3a29c..0e1dbe5 100644 --- a/nodered/src/bf-trigger-camera-changed.html +++ b/nodered/src/bf-trigger-camera-changed.html @@ -4,8 +4,9 @@ color: "#a6d4ff", defaults: { name: { value: "" }, + camera_id: { value: "" }, }, - inputs: 1, + inputs: 0, outputs: 1, icon: "betterframe.svg", label: function () { @@ -20,9 +21,23 @@ +
+ + +
Triggers when a camera is created, updated, or deleted in admin. - Wire http in POST /in/kiosk/camera.changed in front of this node. + Listens on POST /in/camera.changed internally — no upstream + http in node required. Emits msg.payload = {camera_id, event}. + Leave Camera ID blank to receive events from all cameras.
+ + diff --git a/nodered/src/bf-trigger-camera-changed.js b/nodered/src/bf-trigger-camera-changed.js index f623000..20c57ea 100644 --- a/nodered/src/bf-trigger-camera-changed.js +++ b/nodered/src/bf-trigger-camera-changed.js @@ -2,26 +2,35 @@ * 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). + * Topic filter: `camera.changed`. Server's nodered-bridge POSTs to + * `${noderedUrl}/in/camera.changed` directly. This node self-registers its + * own POST handler — no upstream `http in` node required. + * + * Optional config: + * - camera_id: only fire for that camera id * * Output msg.payload: { camera_id, event: "created" | "updated" | "deleted" } */ module.exports = function (RED) { + const TOPIC = "camera.changed"; + const ROUTE = "/api/internal/" + TOPIC; + function BfTriggerCameraChangedNode(config) { RED.nodes.createNode(this, config); const node = this; + const filterIdRaw = (config.camera_id || "").toString().trim(); + const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null; - node.on("input", function (msg, send, done) { - const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; - const topic = msg.topic || body.topic || "camera.changed"; - if (String(topic) !== "camera.changed") { - return done && done(); + function handler(req, res) { + const body = (req.body && typeof req.body === "object") ? req.body : {}; + const camId = body.camera_id !== undefined ? body.camera_id : null; + if (filterId !== null && Number(camId) !== filterId) { + return res.status(200).end(); } const out = { - topic: "camera.changed", + topic: TOPIC, payload: { - camera_id: body.camera_id !== undefined ? body.camera_id : null, + camera_id: camId, event: body.event || null, }, }; @@ -30,8 +39,28 @@ module.exports = function (RED) { shape: "dot", text: String(out.payload.camera_id || "") + " " + (out.payload.event || ""), }); - send(out); - done && done(); + node.send(out); + res.status(200).end(); + } + + RED.httpNode.post(ROUTE, handler); + + node.on("close", function (done) { + const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; + if (stack) { + for (let i = stack.length - 1; i >= 0; i--) { + const layer = stack[i]; + if (!layer || !layer.route || layer.route.path !== ROUTE) continue; + const inner = layer.route.stack; + if (Array.isArray(inner)) { + for (let j = inner.length - 1; j >= 0; j--) { + if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); + } + if (inner.length === 0) stack.splice(i, 1); + } + } + } + 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 index 2018a07..2192d93 100644 --- a/nodered/src/bf-trigger-display-power.html +++ b/nodered/src/bf-trigger-display-power.html @@ -4,8 +4,9 @@ color: "#a6d4ff", defaults: { name: { value: "" }, + display_id: { value: "" }, }, - inputs: 1, + inputs: 0, outputs: 1, icon: "betterframe.svg", label: function () { @@ -20,9 +21,23 @@ +
+ + +
- Triggers when display power changes (wake/standby). - Wire http in POST /in/kiosk/display.power.changed in front of this node. + Fires on display.power.changed (wake/standby). + Listens on POST /in/display.power.changed internally — no upstream + http in node required. Emits msg.payload = {display_id, kiosk_id, state}. + Leave Display ID blank to receive events from all displays.
+ + diff --git a/nodered/src/bf-trigger-display-power.js b/nodered/src/bf-trigger-display-power.js index 60a59f9..d502629 100644 --- a/nodered/src/bf-trigger-display-power.js +++ b/nodered/src/bf-trigger-display-power.js @@ -1,36 +1,67 @@ /** * 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). + * Topic filter: `display.power.changed`. Server's `nodered-bridge.forward` + * POSTs to `${noderedUrl}/in/display.power.changed` directly. This node + * registers its own POST handler on Node-RED's user-facing HTTP server — + * no upstream `http in` node required. * - * Wire an upstream `http in POST /in/kiosk/display.power.changed` (or any - * source landing the event body in msg.payload) into this node. + * Optional config: + * - display_id: only fire for that display id * * Output msg.payload: { display_id, kiosk_id, state: "on" | "standby" } */ module.exports = function (RED) { + const TOPIC = "display.power.changed"; + const ROUTE = "/api/internal/" + TOPIC; + function BfTriggerDisplayPowerNode(config) { RED.nodes.createNode(this, config); const node = this; + const filterIdRaw = (config.display_id || "").toString().trim(); + const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null; - node.on("input", function (msg, send, done) { - const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; - const topic = msg.topic || body.topic || "display.power.changed"; - if (String(topic) !== "display.power.changed") { - return done && done(); + function handler(req, res) { + const body = (req.body && typeof req.body === "object") ? req.body : {}; + const displayId = body.display_id !== undefined ? body.display_id : null; + if (filterId !== null && Number(displayId) !== filterId) { + return res.status(200).end(); } const out = { - topic: "display.power.changed", + topic: TOPIC, payload: { - display_id: body.display_id !== undefined ? body.display_id : null, + display_id: displayId, 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(); + node.send(out); + res.status(200).end(); + } + + RED.httpNode.post(ROUTE, handler); + + node.on("close", function (done) { + // Remove this node's specific route layer from the Express router. + // `app.post(path, handler)` creates a route layer whose inner stack + // holds the actual handler. Match by handler ref so other instances + // of the same node type aren't disturbed. + const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; + if (stack) { + for (let i = stack.length - 1; i >= 0; i--) { + const layer = stack[i]; + if (!layer || !layer.route || layer.route.path !== ROUTE) continue; + const inner = layer.route.stack; + if (Array.isArray(inner)) { + for (let j = inner.length - 1; j >= 0; j--) { + if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); + } + if (inner.length === 0) stack.splice(i, 1); + } + } + } + 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 index 8eea8fe..8f7c945 100644 --- a/nodered/src/bf-trigger-kiosk-changed.html +++ b/nodered/src/bf-trigger-kiosk-changed.html @@ -4,8 +4,9 @@ color: "#a6d4ff", defaults: { name: { value: "" }, + kiosk_id: { value: "" }, }, - inputs: 1, + inputs: 0, outputs: 1, icon: "betterframe.svg", label: function () { @@ -20,9 +21,22 @@ +
+ + +
Triggers on kiosk WS connect/disconnect and heartbeats with hardware telemetry. - Wire http in POST /in/kiosk/kiosk.changed in front of this node. + Listens on POST /in/kiosk.changed internally — no upstream + http in node required. Emits msg.payload = {kiosk_id, kiosk_name, event, cpu_temp_c?, fan_rpm?, fan_pwm?}. + Leave Kiosk ID blank to receive events from all kiosks.
+ + diff --git a/nodered/src/bf-trigger-kiosk-changed.js b/nodered/src/bf-trigger-kiosk-changed.js index b64b530..e9c80c2 100644 --- a/nodered/src/bf-trigger-kiosk-changed.js +++ b/nodered/src/bf-trigger-kiosk-changed.js @@ -2,8 +2,12 @@ * 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. + * Topic filter: `kiosk.changed`. Server's nodered-bridge POSTs to + * `${noderedUrl}/in/kiosk.changed` directly. This node self-registers its + * own POST handler — no upstream `http in` node required. + * + * Optional config: + * - kiosk_id: only fire for that kiosk id * * Output msg.payload: * { kiosk_id, kiosk_name, @@ -11,20 +15,25 @@ * cpu_temp_c?: number, fan_rpm?: number, fan_pwm?: number } */ module.exports = function (RED) { + const TOPIC = "kiosk.changed"; + const ROUTE = "/api/internal/" + TOPIC; + function BfTriggerKioskChangedNode(config) { RED.nodes.createNode(this, config); const node = this; + const filterIdRaw = (config.kiosk_id || "").toString().trim(); + const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null; - node.on("input", function (msg, send, done) { - const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; - const topic = msg.topic || body.topic || "kiosk.changed"; - if (String(topic) !== "kiosk.changed") { - return done && done(); + function handler(req, res) { + const body = (req.body && typeof req.body === "object") ? req.body : {}; + const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null; + if (filterId !== null && Number(kioskId) !== filterId) { + return res.status(200).end(); } const out = { - topic: "kiosk.changed", + topic: TOPIC, payload: { - kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null, + kiosk_id: kioskId, kiosk_name: body.kiosk_name || null, event: body.event || null, cpu_temp_c: body.cpu_temp_c !== undefined ? body.cpu_temp_c : null, @@ -37,8 +46,28 @@ module.exports = function (RED) { shape: "dot", text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + (out.payload.event || ""), }); - send(out); - done && done(); + node.send(out); + res.status(200).end(); + } + + RED.httpNode.post(ROUTE, handler); + + node.on("close", function (done) { + const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; + if (stack) { + for (let i = stack.length - 1; i >= 0; i--) { + const layer = stack[i]; + if (!layer || !layer.route || layer.route.path !== ROUTE) continue; + const inner = layer.route.stack; + if (Array.isArray(inner)) { + for (let j = inner.length - 1; j >= 0; j--) { + if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); + } + if (inner.length === 0) stack.splice(i, 1); + } + } + } + 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 index 48c3d4d..dbdb9e1 100644 --- a/nodered/src/bf-trigger-layout-changed.html +++ b/nodered/src/bf-trigger-layout-changed.html @@ -4,8 +4,9 @@ color: "#a6d4ff", defaults: { name: { value: "" }, + display_id: { value: "" }, }, - inputs: 1, + inputs: 0, outputs: 1, icon: "betterframe.svg", label: function () { @@ -20,9 +21,22 @@ +
+ + +
- Triggers when a display's active layout changes. - Wire http in POST /in/kiosk/layout.changed in front of this node. + Fires when a display's active layout changes. + Listens on POST /in/layout.changed internally — no upstream + http in node required. Emits msg.payload = {display_id, kiosk_id, layout_id, layout_name}. + Leave Display ID blank to receive events from all displays.
+ + diff --git a/nodered/src/bf-trigger-layout-changed.js b/nodered/src/bf-trigger-layout-changed.js index b6923f9..2db01e0 100644 --- a/nodered/src/bf-trigger-layout-changed.js +++ b/nodered/src/bf-trigger-layout-changed.js @@ -1,26 +1,35 @@ /** * 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. + * Topic filter: `layout.changed`. Server's nodered-bridge POSTs to + * `${noderedUrl}/in/layout.changed` directly. This node self-registers its + * own POST handler — no upstream `http in` node required. + * + * Optional config: + * - display_id: only fire for that display id * * Output msg.payload: { display_id, kiosk_id, layout_id, layout_name } */ module.exports = function (RED) { + const TOPIC = "layout.changed"; + const ROUTE = "/api/internal/" + TOPIC; + function BfTriggerLayoutChangedNode(config) { RED.nodes.createNode(this, config); const node = this; + const filterIdRaw = (config.display_id || "").toString().trim(); + const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null; - node.on("input", function (msg, send, done) { - const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; - const topic = msg.topic || body.topic || "layout.changed"; - if (String(topic) !== "layout.changed") { - return done && done(); + function handler(req, res) { + const body = (req.body && typeof req.body === "object") ? req.body : {}; + const displayId = body.display_id !== undefined ? body.display_id : null; + if (filterId !== null && Number(displayId) !== filterId) { + return res.status(200).end(); } const out = { - topic: "layout.changed", + topic: TOPIC, payload: { - display_id: body.display_id !== undefined ? body.display_id : null, + display_id: displayId, 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, @@ -31,8 +40,28 @@ module.exports = function (RED) { shape: "dot", text: out.payload.layout_name || String(out.payload.layout_id || ""), }); - send(out); - done && done(); + node.send(out); + res.status(200).end(); + } + + RED.httpNode.post(ROUTE, handler); + + node.on("close", function (done) { + const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; + if (stack) { + for (let i = stack.length - 1; i >= 0; i--) { + const layer = stack[i]; + if (!layer || !layer.route || layer.route.path !== ROUTE) continue; + const inner = layer.route.stack; + if (Array.isArray(inner)) { + for (let j = inner.length - 1; j >= 0; j--) { + if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); + } + if (inner.length === 0) stack.splice(i, 1); + } + } + } + done(); }); } RED.nodes.registerType("bf-trigger-layout-changed", BfTriggerLayoutChangedNode); diff --git a/nodered/src/bf-trigger-status.html b/nodered/src/bf-trigger-status.html index 8efc953..1cad11e 100644 --- a/nodered/src/bf-trigger-status.html +++ b/nodered/src/bf-trigger-status.html @@ -6,7 +6,7 @@ name: { value: "" }, kiosk_id: { value: "" }, }, - inputs: 1, + inputs: 0, outputs: 1, icon: "betterframe.svg", label: function () { @@ -27,8 +27,17 @@
Fires on kiosk.status heartbeats with hardware telemetry. - Wire http in POST /in/kiosk/kiosk.status in front of this node. + Listens on POST /in/kiosk.status internally — no upstream + http in node required. Emits msg.payload = {kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm}. Leave Kiosk ID blank to receive heartbeats from all kiosks.
+ + diff --git a/nodered/src/bf-trigger-status.js b/nodered/src/bf-trigger-status.js index 7e49c54..916adb5 100644 --- a/nodered/src/bf-trigger-status.js +++ b/nodered/src/bf-trigger-status.js @@ -1,35 +1,34 @@ /** * bf-trigger-status — fires on kiosk heartbeat telemetry. * - * Topic filter: `kiosk.status`. Server emits this from coordinator-ws each - * time a kiosk pushes a status frame over the WS channel, separate from - * the connect/disconnect/heartbeat envelope on `kiosk.changed`. Listening - * here gives you a pure telemetry stream (no connect/disconnect noise). + * Topic filter: `kiosk.status`. Server's nodered-bridge POSTs to + * `${noderedUrl}/in/kiosk.status` directly. This node self-registers its + * own POST handler — no upstream `http in` node required. * - * Optional config.kiosk_id filter — when set, only fires for that kiosk. + * Optional config: + * - kiosk_id: only fire for that kiosk id * * Output msg.payload: * { kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm } */ module.exports = function (RED) { + const TOPIC = "kiosk.status"; + const ROUTE = "/api/internal/" + TOPIC; + function BfTriggerStatusNode(config) { RED.nodes.createNode(this, config); const node = this; const filterIdRaw = (config.kiosk_id || "").toString().trim(); - const filterId = filterIdRaw ? Number(filterIdRaw) : null; + const filterId = filterIdRaw && !isNaN(Number(filterIdRaw)) ? Number(filterIdRaw) : null; - node.on("input", function (msg, send, done) { - const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; - const topic = msg.topic || body.topic || "kiosk.status"; - if (String(topic) !== "kiosk.status") { - return done && done(); - } + function handler(req, res) { + const body = (req.body && typeof req.body === "object") ? req.body : {}; const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null; if (filterId !== null && Number(kioskId) !== filterId) { - return done && done(); + return res.status(200).end(); } const out = { - topic: "kiosk.status", + topic: TOPIC, payload: { kiosk_id: kioskId, kiosk_name: body.kiosk_name || null, @@ -44,8 +43,28 @@ module.exports = function (RED) { shape: "dot", text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + tempStr, }); - send(out); - done && done(); + node.send(out); + res.status(200).end(); + } + + RED.httpNode.post(ROUTE, handler); + + node.on("close", function (done) { + const stack = RED.httpNode && RED.httpNode._router && RED.httpNode._router.stack; + if (stack) { + for (let i = stack.length - 1; i >= 0; i--) { + const layer = stack[i]; + if (!layer || !layer.route || layer.route.path !== ROUTE) continue; + const inner = layer.route.stack; + if (Array.isArray(inner)) { + for (let j = inner.length - 1; j >= 0; j--) { + if (inner[j] && inner[j].handle === handler) inner.splice(j, 1); + } + if (inner.length === 0) stack.splice(i, 1); + } + } + } + done(); }); } RED.nodes.registerType("bf-trigger-status", BfTriggerStatusNode); diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 5f1c88d..4cb6aa4 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -84,7 +84,10 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder // Internal server-to-Node-RED delivery for events the backend already // authenticated, such as kiosk ONVIF/GPIO ingest. - const url = `${base}/in/${encodeURIComponent(topic)}`; + // Use /api/internal/ — Angie returns 404 for any /api/* not whitelisted, + // so external requests cannot trigger BF nodes. Server bridge bypasses + // Angie (direct to nodered container). + const url = `${base}/api/internal/${encodeURIComponent(topic)}`; fetch(url, { method: "POST", headers: { "content-type": "application/json" },