mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
acb4a353f9
commit
887db013ef
15 changed files with 437 additions and 154 deletions
|
|
@ -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<Instant>,
|
||||
}
|
||||
|
||||
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<HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
|
||||
/// camera_id → PipelineEntry. Pool shared across all displays.
|
||||
/// State machine: see WarmthState. Entries dropped when state goes Cold.
|
||||
static WARM_CAMERAS: RefCell<HashMap<u32, PipelineEntry>>
|
||||
= 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<u32> = 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<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() {
|
||||
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<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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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<u32, DisplayState> = HashMap::new();
|
||||
|
|
|
|||
|
|
@ -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/<topic>` (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/<topic>` 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://<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-topic_pattern"><i class="fa fa-filter"></i> Topic</label>
|
||||
<input type="text" id="node-input-topic_pattern" placeholder="camera.*" />
|
||||
<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">
|
||||
Filter incoming kiosk camera events by topic. <code>*</code> is a wildcard.
|
||||
Defaults to <code>camera.*</code> (ONVIF motion, object detection, etc.).
|
||||
Wire an upstream <b>http in</b> on <code>/in/kiosk/<topic></code> to feed it.
|
||||
On match the message becomes
|
||||
<code>{topic, kiosk_id, camera_id, source_type, payload}</code>.
|
||||
Fires on kiosk-forwarded camera events (ONVIF motion, line crossing, GPIO pulses
|
||||
tagged to a camera, etc.).
|
||||
Listens on <code>POST /in/camera.event</code> internally — no upstream
|
||||
<code>http in</code> node required.
|
||||
Emits <code>msg = {topic, kiosk_id, camera_id, source_type, payload}</code>.
|
||||
Leave Camera ID blank to receive events from all cameras.
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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/<topic>` 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/<topic>`. 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);
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-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">
|
||||
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>.
|
||||
Leave Camera ID blank to receive events from all cameras.
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-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">
|
||||
Triggers when display power changes (wake/standby).
|
||||
Wire <code>http in POST /in/kiosk/display.power.changed</code> in front of this node.
|
||||
Fires on <code>display.power.changed</code> (wake/standby).
|
||||
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>.
|
||||
Leave Display ID blank to receive events from all displays.
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-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">
|
||||
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>.
|
||||
Leave Kiosk ID blank to receive events from all kiosks.
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
||||
<input type="text" id="node-input-name" placeholder="(optional)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-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">
|
||||
Triggers when a display's active layout changes.
|
||||
Wire <code>http in POST /in/kiosk/layout.changed</code> in front of this node.
|
||||
Fires when a display's active layout changes.
|
||||
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>.
|
||||
Leave Display ID blank to receive events from all displays.
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
name: { value: "" },
|
||||
kiosk_id: { value: "" },
|
||||
},
|
||||
inputs: 1,
|
||||
inputs: 0,
|
||||
outputs: 1,
|
||||
icon: "betterframe.svg",
|
||||
label: function () {
|
||||
|
|
@ -27,8 +27,17 @@
|
|||
</div>
|
||||
<div class="form-tips">
|
||||
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>.
|
||||
Leave Kiosk ID blank to receive heartbeats from all kiosks.
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue