fix: trigger nodes self-register + move to angie-blocked path

Trigger nodes now self-contained inputs (inputs:0):
- Each registers POST /api/internal/<topic> on RED.httpNode
- Angie returns 404 for any /api/* not whitelisted (kiosk/pair/admin)
  so external requests cannot trigger BF nodes
- Server bridge POSTs direct to nodered container (bypasses Angie)
- nodered-bridge.ts updated to use /api/internal/<topic>
- 6 trigger nodes converted: display-power, layout-changed,
  kiosk-changed, camera-changed, status, kiosk-camera-event
- Optional per-node filters (display_id, kiosk_id, camera_id)
- close handler removes only this node's route layer
This commit is contained in:
Mitchell R 2026-05-13 02:42:37 +02:00
parent acb4a353f9
commit 887db013ef
15 changed files with 437 additions and 154 deletions

View file

@ -27,12 +27,30 @@ struct DisplayState {
is_asleep: bool, 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<Instant>,
}
thread_local! { thread_local! {
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across /// camera_id → PipelineEntry. Pool shared across all displays.
/// layout swaps for cameras still referenced or in preload_camera_ids. /// State machine: see WarmthState. Entries dropped when state goes Cold.
/// Shared across ALL displays — if two displays use the same camera the static WARM_CAMERAS: RefCell<HashMap<u32, PipelineEntry>>
/// pipeline is reused. The paintable can be attached to multiple Pictures.
static WARM_CAMERAS: RefCell<HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
= RefCell::new(HashMap::new()); = RefCell::new(HashMap::new());
/// Most recently rendered bundle. Used for layout-switch + idle revert. /// 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. // Compute warm vs hot camera sets per the CLAUDE.md model.
let mut globally_needed: std::collections::HashSet<u32> = std::collections::HashSet::new(); // 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<u32> = std::collections::HashSet::new();
let mut hot_set: std::collections::HashSet<u32> = std::collections::HashSet::new();
let mut max_cooling_secs: u32 = 0;
for (i, bd) in displays.iter().enumerate() { for (i, bd) in displays.iter().enumerate() {
let target_id = pick_initial_layout(bd); let target_id = pick_initial_layout(bd);
if let Some(target_id) = target_id { if let Some(target_id) = target_id {
if let Some(layout) = bd.layouts.iter().find(|l| l.id == target_id) { if let Some(layout) = bd.layouts.iter().find(|l| l.id == target_id) {
for cell in &layout.cells { for cell in &layout.cells {
if cell.content_type == "camera" { 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); let _ = gdk_monitors.get(i);
} }
// Stop pipelines for cameras no longer needed by any display. recompute_pool_states(&warm_set, &hot_set, max_cooling_secs);
WARM_CAMERAS.with(|w| {
let mut warm = w.borrow_mut();
let stale: Vec<u32> = 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);
}
}
});
// Build/reuse window per bundle display, then render its initial layout. // Build/reuse window per bundle display, then render its initial layout.
let mut new_state: HashMap<u32, DisplayState> = HashMap::new(); let mut new_state: HashMap<u32, DisplayState> = HashMap::new();

View file

@ -31,12 +31,40 @@ Configure once on a `bf-server-config` node and reference it from the others.
## Event ingest path ## Event ingest path
Trigger nodes are pure filters — they do not subscribe to the BF server. Trigger nodes are **self-contained** — each one registers its own
Wire an upstream `http in` node on `/in/kiosk/<topic>` (BetterFrame's `POST /in/<topic>` handler on Node-RED's user-facing HTTP server (via
authenticated kiosk-ingest endpoint, surfaced by the Angie proxy with `RED.httpNode.post`) when the flow is deployed. You do **not** need to wire
`auth_request` gating) and feed its `msg.payload` into the matching an upstream `http in` node anymore.
`bf-trigger-*` node. The server emits these topics from coordinator-ws
(kiosk WS lifecycle) and the admin routes (layout/power/camera mutations). The BetterFrame server's `nodered-bridge.forward(topic, payload)` posts
events directly to `http://<nodered-host>:1880/in/<topic>`. 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/<topic>` (kiosk-key gated) /
`/in/public/<topic>` (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/<topic>` 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 ## Installation

View file

@ -4,13 +4,13 @@
color: "#a6d4ff", color: "#a6d4ff",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
topic_pattern: { value: "camera.*" }, camera_id: { value: "" },
}, },
inputs: 1, inputs: 0,
outputs: 1, outputs: 1,
icon: "betterframe.svg", icon: "betterframe.svg",
label: function () { label: function () {
return this.name || this.topic_pattern || "kiosk camera event"; return this.name || "kiosk camera event";
}, },
paletteLabel: "Kiosk Camera Event", paletteLabel: "Kiosk Camera Event",
}); });
@ -22,14 +22,26 @@
<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-camera_id"><i class="fa fa-video-camera"></i> Camera ID</label>
<input type="text" id="node-input-topic_pattern" placeholder="camera.*" /> <input type="number" id="node-input-camera_id" placeholder="(blank = all cameras)" />
</div> </div>
<div class="form-tips"> <div class="form-tips">
Filter incoming kiosk camera events by topic. <code>*</code> is a wildcard. Fires on kiosk-forwarded camera events (ONVIF motion, line crossing, GPIO pulses
Defaults to <code>camera.*</code> (ONVIF motion, object detection, etc.). tagged to a camera, etc.).
Wire an upstream <b>http in</b> on <code>/in/kiosk/&lt;topic&gt;</code> to feed it. Listens on <code>POST /in/camera.event</code> internally — no upstream
On match the message becomes <code>http in</code> node required.
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>. Emits <code>msg = {topic, kiosk_id, camera_id, source_type, payload}</code>.
Leave Camera ID blank to receive events from all cameras.
</div> </div>
</script> </script>
<script type="text/html" data-help-name="bf-kiosk-camera-event">
<p>Fires on kiosk-originated camera events that the BetterFrame server has
authenticated and forwarded. Listens on <code>POST /in/camera.event</code>
internally — no upstream <code>http in</code> node needed.</p>
<p>Optional <b>Camera ID</b> filter limits this node to a single camera.
Further filtering on payload fields can be done with downstream switch nodes.</p>
<p>Previous releases used a glob-pattern (<code>camera.*</code>) over upstream
<code>http in</code> messages. That filter has been removed in favor of a single
fixed ingest topic.</p>
</script>

View file

@ -1,61 +1,73 @@
/** /**
* bf-kiosk-camera-event fire a flow whenever a BetterFrame kiosk camera * bf-kiosk-camera-event fires on kiosk-originated camera events forwarded
* event matching a topic pattern arrives. Defaults to `camera.*` (ONVIF * from the BetterFrame server (ONVIF motion, object detection, line crossing,
* motion, object detection, line crossing, etc.). * GPIO pulse tagged to a camera, etc.).
* *
* Two delivery paths can land here: * The server's api-http `/api/kiosk/events` endpoint persists each kiosk
* 1. The BF server has forwarded an authenticated kiosk event via the * event then calls `nodered.forward(topic, payload)` which POSTs to
* `/in/kiosk/<topic>` ingest endpoint. The flow operator wires an * `${noderedUrl}/in/<topic>`. This node self-registers a POST handler at a
* `http in` node on that path and connects it to this node we just * fixed route no upstream `http in` node required.
* filter by topic.
* 2. A separate flow injects msg.topic + msg.payload directly.
* *
* This is a pure filter/router. It does NOT itself subscribe to the BF * Optional config:
* server; that wiring is done with stock Node-RED http-in or websocket * - camera_id: only fire for that camera id
* nodes upstream.
* *
* Renamed from `bf-event-in` kept the same envelope shape for backward * Output msg shape (kept compatible with the previous filter version):
* compatibility with flows that consume the output message. * { topic, kiosk_id, camera_id, source_type, payload }
*/ */
module.exports = function (RED) { 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) { function BfKioskCameraEventNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; 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 handler(req, res) {
function toRegex(p) { const body = (req.body && typeof req.body === "object") ? req.body : {};
if (!p) return null; const kioskId = body.kiosk_id !== undefined ? body.kiosk_id
const escaped = p.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); : body.source_kiosk_id !== undefined ? body.source_kiosk_id
return new RegExp("^" + escaped + "$"); : null;
} const cameraId = body.camera_id !== undefined ? body.camera_id
const re = toRegex(pattern); : body.source_camera_id !== undefined ? body.source_camera_id
: null;
node.on("input", function (msg, send, done) { if (filterId !== null && Number(cameraId) !== filterId) {
// Common BF envelope shape: return res.status(200).end();
// { 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();
} }
const out = { const out = {
topic: String(topic), topic: body.topic ? String(body.topic) : TOPIC,
kiosk_id: msg.kiosk_id || body.kiosk_id || body.source_kiosk_id || null, kiosk_id: kioskId,
camera_id: msg.camera_id || body.camera_id || body.source_camera_id || null, camera_id: cameraId,
source_type: body.source_type || null, source_type: body.source_type || null,
payload: body.payload !== undefined ? body.payload : body, payload: body.payload !== undefined ? body.payload : body,
}; };
node.status({ fill: "green", shape: "dot", text: out.topic }); node.status({ fill: "green", shape: "dot", text: out.topic });
send(out); node.send(out);
done && done(); 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); RED.nodes.registerType("bf-kiosk-camera-event", BfKioskCameraEventNode);

View file

@ -4,8 +4,9 @@
color: "#a6d4ff", color: "#a6d4ff",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
camera_id: { value: "" },
}, },
inputs: 1, inputs: 0,
outputs: 1, outputs: 1,
icon: "betterframe.svg", icon: "betterframe.svg",
label: function () { label: function () {
@ -20,9 +21,23 @@
<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">
<label for="node-input-camera_id"><i class="fa fa-video-camera"></i> Camera ID</label>
<input type="number" id="node-input-camera_id" placeholder="(blank = all cameras)" />
</div>
<div class="form-tips"> <div class="form-tips">
Triggers when a camera is created, updated, or deleted in admin. 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. Listens on <code>POST /in/camera.changed</code> internally — no upstream
<code>http in</code> node required.
Emits <code>msg.payload = {camera_id, event}</code>. Emits <code>msg.payload = {camera_id, event}</code>.
Leave Camera ID blank to receive events from all cameras.
</div> </div>
</script> </script>
<script type="text/html" data-help-name="bf-trigger-camera-changed">
<p>Fires when a camera entity is created, updated, or deleted in admin
(manual create, ONVIF import, edit, delete, enable/disable).</p>
<p>Listens on <code>POST /in/camera.changed</code> internally — no upstream
<code>http in</code> node needed.</p>
<p>Optional <b>Camera ID</b> filter limits this node to a single camera.</p>
</script>

View file

@ -2,26 +2,35 @@
* bf-trigger-camera-changed fires when a camera entity is created, updated, * bf-trigger-camera-changed fires when a camera entity is created, updated,
* or deleted in admin. * or deleted in admin.
* *
* Topic filter: `camera.changed`. Server emits these from the admin camera * Topic filter: `camera.changed`. Server's nodered-bridge POSTs to
* routes (manual create, ONVIF import, edit, delete, enable/disable). * `${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" } * Output msg.payload: { camera_id, event: "created" | "updated" | "deleted" }
*/ */
module.exports = function (RED) { module.exports = function (RED) {
const TOPIC = "camera.changed";
const ROUTE = "/api/internal/" + TOPIC;
function BfTriggerCameraChangedNode(config) { function BfTriggerCameraChangedNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; 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) { function handler(req, res) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; const body = (req.body && typeof req.body === "object") ? req.body : {};
const topic = msg.topic || body.topic || "camera.changed"; const camId = body.camera_id !== undefined ? body.camera_id : null;
if (String(topic) !== "camera.changed") { if (filterId !== null && Number(camId) !== filterId) {
return done && done(); return res.status(200).end();
} }
const out = { const out = {
topic: "camera.changed", topic: TOPIC,
payload: { payload: {
camera_id: body.camera_id !== undefined ? body.camera_id : null, camera_id: camId,
event: body.event || null, event: body.event || null,
}, },
}; };
@ -30,8 +39,28 @@ module.exports = function (RED) {
shape: "dot", shape: "dot",
text: String(out.payload.camera_id || "") + " " + (out.payload.event || ""), text: String(out.payload.camera_id || "") + " " + (out.payload.event || ""),
}); });
send(out); node.send(out);
done && done(); 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); RED.nodes.registerType("bf-trigger-camera-changed", BfTriggerCameraChangedNode);

View file

@ -4,8 +4,9 @@
color: "#a6d4ff", color: "#a6d4ff",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
display_id: { value: "" },
}, },
inputs: 1, inputs: 0,
outputs: 1, outputs: 1,
icon: "betterframe.svg", icon: "betterframe.svg",
label: function () { label: function () {
@ -20,9 +21,23 @@
<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">
<label for="node-input-display_id"><i class="fa fa-desktop"></i> Display ID</label>
<input type="number" id="node-input-display_id" placeholder="(blank = all displays)" />
</div>
<div class="form-tips"> <div class="form-tips">
Triggers when display power changes (wake/standby). Fires on <code>display.power.changed</code> (wake/standby).
Wire <code>http in POST /in/kiosk/display.power.changed</code> in front of this node. Listens on <code>POST /in/display.power.changed</code> internally — no upstream
<code>http in</code> node required.
Emits <code>msg.payload = {display_id, kiosk_id, state}</code>. Emits <code>msg.payload = {display_id, kiosk_id, state}</code>.
Leave Display ID blank to receive events from all displays.
</div> </div>
</script> </script>
<script type="text/html" data-help-name="bf-trigger-display-power">
<p>Fires when a display's power state changes (admin wake/standby command).</p>
<p>Listens on <code>POST /in/display.power.changed</code> internally — no upstream
<code>http in</code> node needed. The BetterFrame server's nodered-bridge POSTs
events to that path directly.</p>
<p>Optional <b>Display ID</b> filter limits this node to a single display.</p>
</script>

View file

@ -1,36 +1,67 @@
/** /**
* bf-trigger-display-power fires when a display's power state changes. * bf-trigger-display-power fires when a display's power state changes.
* *
* Topic filter: `display.power.changed`. Server emits these from the admin * Topic filter: `display.power.changed`. Server's `nodered-bridge.forward`
* power routes (wake/standby) and the kiosk power-state-check probe (future). * 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 * Optional config:
* source landing the event body in msg.payload) into this node. * - display_id: only fire for that display id
* *
* Output msg.payload: { display_id, kiosk_id, state: "on" | "standby" } * Output msg.payload: { display_id, kiosk_id, state: "on" | "standby" }
*/ */
module.exports = function (RED) { module.exports = function (RED) {
const TOPIC = "display.power.changed";
const ROUTE = "/api/internal/" + TOPIC;
function BfTriggerDisplayPowerNode(config) { function BfTriggerDisplayPowerNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; 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) { function handler(req, res) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; const body = (req.body && typeof req.body === "object") ? req.body : {};
const topic = msg.topic || body.topic || "display.power.changed"; const displayId = body.display_id !== undefined ? body.display_id : null;
if (String(topic) !== "display.power.changed") { if (filterId !== null && Number(displayId) !== filterId) {
return done && done(); return res.status(200).end();
} }
const out = { const out = {
topic: "display.power.changed", topic: TOPIC,
payload: { payload: {
display_id: body.display_id !== undefined ? body.display_id : null, display_id: displayId,
kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null, kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null,
state: body.state || null, state: body.state || null,
}, },
}; };
node.status({ fill: "green", shape: "dot", text: out.payload.state || "changed" }); node.status({ fill: "green", shape: "dot", text: out.payload.state || "changed" });
send(out); node.send(out);
done && done(); 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); RED.nodes.registerType("bf-trigger-display-power", BfTriggerDisplayPowerNode);

View file

@ -4,8 +4,9 @@
color: "#a6d4ff", color: "#a6d4ff",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
kiosk_id: { value: "" },
}, },
inputs: 1, inputs: 0,
outputs: 1, outputs: 1,
icon: "betterframe.svg", icon: "betterframe.svg",
label: function () { label: function () {
@ -20,9 +21,22 @@
<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">
<label for="node-input-kiosk_id"><i class="fa fa-tv"></i> Kiosk ID</label>
<input type="number" id="node-input-kiosk_id" placeholder="(blank = all kiosks)" />
</div>
<div class="form-tips"> <div class="form-tips">
Triggers on kiosk WS connect/disconnect and heartbeats with hardware telemetry. 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. Listens on <code>POST /in/kiosk.changed</code> internally — no upstream
<code>http in</code> node required.
Emits <code>msg.payload = {kiosk_id, kiosk_name, event, cpu_temp_c?, fan_rpm?, fan_pwm?}</code>. Emits <code>msg.payload = {kiosk_id, kiosk_name, event, cpu_temp_c?, fan_rpm?, fan_pwm?}</code>.
Leave Kiosk ID blank to receive events from all kiosks.
</div> </div>
</script> </script>
<script type="text/html" data-help-name="bf-trigger-kiosk-changed">
<p>Fires on kiosk WS connect/disconnect and heartbeats with hardware telemetry.</p>
<p>Listens on <code>POST /in/kiosk.changed</code> internally — no upstream
<code>http in</code> node needed.</p>
<p>Optional <b>Kiosk ID</b> filter limits this node to a single kiosk.</p>
</script>

View file

@ -2,8 +2,12 @@
* bf-trigger-kiosk-changed fires on kiosk state changes (connect, disconnect, * bf-trigger-kiosk-changed fires on kiosk state changes (connect, disconnect,
* heartbeat with hardware telemetry). * heartbeat with hardware telemetry).
* *
* Topic filter: `kiosk.changed`. Server emits these from the coordinator-ws * Topic filter: `kiosk.changed`. Server's nodered-bridge POSTs to
* plugin on WS connect/disconnect and from heartbeat status messages. * `${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: * Output msg.payload:
* { kiosk_id, kiosk_name, * { kiosk_id, kiosk_name,
@ -11,20 +15,25 @@
* cpu_temp_c?: number, fan_rpm?: number, fan_pwm?: number } * cpu_temp_c?: number, fan_rpm?: number, fan_pwm?: number }
*/ */
module.exports = function (RED) { module.exports = function (RED) {
const TOPIC = "kiosk.changed";
const ROUTE = "/api/internal/" + TOPIC;
function BfTriggerKioskChangedNode(config) { function BfTriggerKioskChangedNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; 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) { function handler(req, res) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; const body = (req.body && typeof req.body === "object") ? req.body : {};
const topic = msg.topic || body.topic || "kiosk.changed"; const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null;
if (String(topic) !== "kiosk.changed") { if (filterId !== null && Number(kioskId) !== filterId) {
return done && done(); return res.status(200).end();
} }
const out = { const out = {
topic: "kiosk.changed", topic: TOPIC,
payload: { payload: {
kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null, kiosk_id: kioskId,
kiosk_name: body.kiosk_name || null, kiosk_name: body.kiosk_name || null,
event: body.event || null, event: body.event || null,
cpu_temp_c: body.cpu_temp_c !== undefined ? body.cpu_temp_c : null, cpu_temp_c: body.cpu_temp_c !== undefined ? body.cpu_temp_c : null,
@ -37,8 +46,28 @@ module.exports = function (RED) {
shape: "dot", shape: "dot",
text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + (out.payload.event || ""), text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + (out.payload.event || ""),
}); });
send(out); node.send(out);
done && done(); 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); RED.nodes.registerType("bf-trigger-kiosk-changed", BfTriggerKioskChangedNode);

View file

@ -4,8 +4,9 @@
color: "#a6d4ff", color: "#a6d4ff",
defaults: { defaults: {
name: { value: "" }, name: { value: "" },
display_id: { value: "" },
}, },
inputs: 1, inputs: 0,
outputs: 1, outputs: 1,
icon: "betterframe.svg", icon: "betterframe.svg",
label: function () { label: function () {
@ -20,9 +21,22 @@
<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">
<label for="node-input-display_id"><i class="fa fa-desktop"></i> Display ID</label>
<input type="number" id="node-input-display_id" placeholder="(blank = all displays)" />
</div>
<div class="form-tips"> <div class="form-tips">
Triggers when a display's active layout changes. Fires when a display's active layout changes.
Wire <code>http in POST /in/kiosk/layout.changed</code> in front of this node. Listens on <code>POST /in/layout.changed</code> internally — no upstream
<code>http in</code> node required.
Emits <code>msg.payload = {display_id, kiosk_id, layout_id, layout_name}</code>. Emits <code>msg.payload = {display_id, kiosk_id, layout_id, layout_name}</code>.
Leave Display ID blank to receive events from all displays.
</div> </div>
</script> </script>
<script type="text/html" data-help-name="bf-trigger-layout-changed">
<p>Fires when a display switches to a new layout (admin layout-switch).</p>
<p>Listens on <code>POST /in/layout.changed</code> internally — no upstream
<code>http in</code> node needed.</p>
<p>Optional <b>Display ID</b> filter limits this node to a single display.</p>
</script>

View file

@ -1,26 +1,35 @@
/** /**
* bf-trigger-layout-changed fires when a display switches to a new layout. * bf-trigger-layout-changed fires when a display switches to a new layout.
* *
* Topic filter: `layout.changed`. Server emits these from the admin layout- * Topic filter: `layout.changed`. Server's nodered-bridge POSTs to
* switch routes after delivering the WS command to the kiosk. * `${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 } * Output msg.payload: { display_id, kiosk_id, layout_id, layout_name }
*/ */
module.exports = function (RED) { module.exports = function (RED) {
const TOPIC = "layout.changed";
const ROUTE = "/api/internal/" + TOPIC;
function BfTriggerLayoutChangedNode(config) { function BfTriggerLayoutChangedNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; 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) { function handler(req, res) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; const body = (req.body && typeof req.body === "object") ? req.body : {};
const topic = msg.topic || body.topic || "layout.changed"; const displayId = body.display_id !== undefined ? body.display_id : null;
if (String(topic) !== "layout.changed") { if (filterId !== null && Number(displayId) !== filterId) {
return done && done(); return res.status(200).end();
} }
const out = { const out = {
topic: "layout.changed", topic: TOPIC,
payload: { payload: {
display_id: body.display_id !== undefined ? body.display_id : null, display_id: displayId,
kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null, kiosk_id: body.kiosk_id !== undefined ? body.kiosk_id : null,
layout_id: body.layout_id !== undefined ? body.layout_id : null, layout_id: body.layout_id !== undefined ? body.layout_id : null,
layout_name: body.layout_name || null, layout_name: body.layout_name || null,
@ -31,8 +40,28 @@ module.exports = function (RED) {
shape: "dot", shape: "dot",
text: out.payload.layout_name || String(out.payload.layout_id || ""), text: out.payload.layout_name || String(out.payload.layout_id || ""),
}); });
send(out); node.send(out);
done && done(); 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); RED.nodes.registerType("bf-trigger-layout-changed", BfTriggerLayoutChangedNode);

View file

@ -6,7 +6,7 @@
name: { value: "" }, name: { value: "" },
kiosk_id: { value: "" }, kiosk_id: { value: "" },
}, },
inputs: 1, inputs: 0,
outputs: 1, outputs: 1,
icon: "betterframe.svg", icon: "betterframe.svg",
label: function () { label: function () {
@ -27,8 +27,17 @@
</div> </div>
<div class="form-tips"> <div class="form-tips">
Fires on <code>kiosk.status</code> heartbeats with hardware telemetry. Fires on <code>kiosk.status</code> heartbeats with hardware telemetry.
Wire <code>http in POST /in/kiosk/kiosk.status</code> in front of this node. Listens on <code>POST /in/kiosk.status</code> internally — no upstream
<code>http in</code> node required.
Emits <code>msg.payload = {kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm}</code>. Emits <code>msg.payload = {kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm}</code>.
Leave Kiosk ID blank to receive heartbeats from all kiosks. Leave Kiosk ID blank to receive heartbeats from all kiosks.
</div> </div>
</script> </script>
<script type="text/html" data-help-name="bf-trigger-status">
<p>Pure telemetry stream from kiosk WS heartbeats — separate from the
connect/disconnect envelope on <code>kiosk.changed</code>.</p>
<p>Listens on <code>POST /in/kiosk.status</code> internally — no upstream
<code>http in</code> node needed.</p>
<p>Optional <b>Kiosk ID</b> filter limits this node to a single kiosk.</p>
</script>

View file

@ -1,35 +1,34 @@
/** /**
* bf-trigger-status fires on kiosk heartbeat telemetry. * bf-trigger-status fires on kiosk heartbeat telemetry.
* *
* Topic filter: `kiosk.status`. Server emits this from coordinator-ws each * Topic filter: `kiosk.status`. Server's nodered-bridge POSTs to
* time a kiosk pushes a status frame over the WS channel, separate from * `${noderedUrl}/in/kiosk.status` directly. This node self-registers its
* the connect/disconnect/heartbeat envelope on `kiosk.changed`. Listening * own POST handler no upstream `http in` node required.
* here gives you a pure telemetry stream (no connect/disconnect noise).
* *
* 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: * Output msg.payload:
* { kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm } * { kiosk_id, kiosk_name, cpu_temp_c, fan_rpm, fan_pwm }
*/ */
module.exports = function (RED) { module.exports = function (RED) {
const TOPIC = "kiosk.status";
const ROUTE = "/api/internal/" + TOPIC;
function BfTriggerStatusNode(config) { function BfTriggerStatusNode(config) {
RED.nodes.createNode(this, config); RED.nodes.createNode(this, config);
const node = this; const node = this;
const filterIdRaw = (config.kiosk_id || "").toString().trim(); 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) { function handler(req, res) {
const body = (msg && msg.payload && typeof msg.payload === "object") ? msg.payload : {}; const body = (req.body && typeof req.body === "object") ? req.body : {};
const topic = msg.topic || body.topic || "kiosk.status";
if (String(topic) !== "kiosk.status") {
return done && done();
}
const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null; const kioskId = body.kiosk_id !== undefined ? body.kiosk_id : null;
if (filterId !== null && Number(kioskId) !== filterId) { if (filterId !== null && Number(kioskId) !== filterId) {
return done && done(); return res.status(200).end();
} }
const out = { const out = {
topic: "kiosk.status", topic: TOPIC,
payload: { payload: {
kiosk_id: kioskId, kiosk_id: kioskId,
kiosk_name: body.kiosk_name || null, kiosk_name: body.kiosk_name || null,
@ -44,8 +43,28 @@ module.exports = function (RED) {
shape: "dot", shape: "dot",
text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + tempStr, text: (out.payload.kiosk_name || String(out.payload.kiosk_id || "")) + " " + tempStr,
}); });
send(out); node.send(out);
done && done(); 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); RED.nodes.registerType("bf-trigger-status", BfTriggerStatusNode);

View file

@ -84,7 +84,10 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder
// Internal server-to-Node-RED delivery for events the backend already // Internal server-to-Node-RED delivery for events the backend already
// authenticated, such as kiosk ONVIF/GPIO ingest. // 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, { fetch(url, {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },