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,
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/<topic></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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue