feat(nodered): motion + ANPR + generic ONVIF event trigger nodes

Three new Node-RED trigger nodes in BetterFrame Triggers palette:

bf-trigger-motion (red) — fires on MotionAlarm, CellMotionDetector,
VideoAnalytics/Motion, FieldDetector topics. Outputs msg.active
(true/false) for motion start/stop. Camera ID filter optional.

bf-trigger-anpr (blue) — fires on LicensePlateRecognition, Plate,
ANPR, LPR, NumberPlate topics. Extracts msg.plate (string) and
msg.confidence (number) from vendor-specific payload fields
(Hikvision PlateNumber, Dahua plateNumber, etc.). Camera ID filter.

bf-trigger-event (green) — generic catch-all. Topic substring filter
+ camera ID filter. Outputs msg.source + msg.data as key-value objects
parsed from ONVIF SimpleItems. Use for line crossing, intrusion,
digital input, tamper, audio detection, or any unknown topic.

Server side: ONVIF events (source_type=onvif) now additionally forward
to the fixed onvif.event route so all three nodes receive events without
needing per-topic Node-RED route registration.
This commit is contained in:
Mitchell R 2026-05-23 02:17:05 +02:00
parent cc24eb14fc
commit 82ef29a23d
No known key found for this signature in database
8 changed files with 405 additions and 1 deletions

View file

@ -24,7 +24,10 @@
"bf-config-get": "src/bf-config-get.js", "bf-config-get": "src/bf-config-get.js",
"bf-config-set": "src/bf-config-set.js", "bf-config-set": "src/bf-config-set.js",
"bf-status": "src/bf-status.js", "bf-status": "src/bf-status.js",
"bf-snapshot": "src/bf-snapshot.js" "bf-snapshot": "src/bf-snapshot.js",
"bf-trigger-motion": "src/bf-trigger-motion.js",
"bf-trigger-anpr": "src/bf-trigger-anpr.js",
"bf-trigger-event": "src/bf-trigger-event.js"
}, },
"icons": [ "icons": [
"icons" "icons"

View file

@ -0,0 +1,42 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-anpr", {
category: "BetterFrame Triggers",
color: "#99ccff",
defaults: {
name: { value: "" },
camera_id: { value: "" },
},
inputs: 0,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "ANPR Trigger";
},
paletteLabel: "ANPR Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-anpr">
<div class="form-row">
<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">
Fires on ONVIF license plate recognition events. Outputs
<code>msg.plate</code> (string) and <code>msg.confidence</code> (number).
Works with Hikvision ANPR cameras (PlateNumber field), Dahua (plateNumber),
and other ONVIF-compliant vendors.
</div>
</script>
<script type="text/html" data-help-name="bf-trigger-anpr">
<p>Triggers on ONVIF ANPR (Automatic Number Plate Recognition) events.</p>
<p>Matches topics containing: LicensePlateRecognition, Plate, ANPR, LPR, NumberPlate.</p>
<p><b>msg.plate</b> contains the recognized plate string (or null if not parsed).
<b>msg.confidence</b> is the recognition confidence score (or null).</p>
<p>Use downstream nodes for plate allowlist/blocklist matching, logging, or gate control.</p>
</script>

View file

@ -0,0 +1,89 @@
/**
* bf-trigger-anpr fires on ONVIF license plate recognition events.
*
* Matches topics containing: LicensePlateRecognition, Plate, ANPR, LPR.
* Extracts plate number + confidence from payload data.
*
* Output: { topic, kiosk_id, camera_id, plate, confidence, payload }
*/
const { readJsonBody } = require("./_http-body.js");
const ANPR_PATTERNS = [
"LicensePlateRecognition",
"Plate",
"ANPR",
"LPR",
"NumberPlate",
];
module.exports = function (RED) {
const ROUTE = "/api/internal/onvif.anpr";
function BfTriggerAnprNode(config) {
RED.nodes.createNode(this, config);
const node = this;
const filterCam = config.camera_id ? Number(config.camera_id) : null;
async function handler(req, res) {
const body = await readJsonBody(req);
const topic = String(body.topic || "");
if (!ANPR_PATTERNS.some((p) => topic.includes(p))) {
return res.status(200).end();
}
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
if (filterCam !== null && Number(cameraId) !== filterCam) {
return res.status(200).end();
}
const data = body.payload?.data ?? body.payload ?? {};
// Hikvision uses PlateNumber, other vendors may vary.
const plate = data.PlateNumber ?? data.plateNumber ?? data.Plate
?? data.plate ?? data.Value ?? null;
const confidence = data.Confidence ?? data.confidence
?? data.Score ?? data.score ?? null;
const out = {
topic,
kiosk_id: body.kiosk_id ?? body.source_kiosk_id ?? null,
camera_id: cameraId,
plate: plate ? String(plate) : null,
confidence: confidence != null ? Number(confidence) : null,
payload: body.payload ?? body,
};
node.status({
fill: plate ? "blue" : "yellow",
shape: "dot",
text: plate || "plate detected",
});
node.send(out);
res.status(200).end();
}
RED.httpNode.post(ROUTE, handler);
const GENERIC_ROUTE = "/api/internal/onvif.event";
RED.httpNode.post(GENERIC_ROUTE, handler);
node.on("close", function (done) {
const stack = RED.httpNode?._router?.stack;
if (stack) {
for (let i = stack.length - 1; i >= 0; i--) {
const layer = stack[i];
if (!layer?.route) continue;
if (layer.route.path !== ROUTE && layer.route.path !== GENERIC_ROUTE) continue;
const inner = layer.route.stack;
if (Array.isArray(inner)) {
for (let j = inner.length - 1; j >= 0; j--) {
if (inner[j]?.handle === handler) inner.splice(j, 1);
}
if (inner.length === 0) stack.splice(i, 1);
}
}
}
done();
});
}
RED.nodes.registerType("bf-trigger-anpr", BfTriggerAnprNode);
};

View file

@ -0,0 +1,56 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-event", {
category: "BetterFrame Triggers",
color: "#ccddaa",
defaults: {
name: { value: "" },
camera_id: { value: "" },
topic_filter: { value: "" },
},
inputs: 0,
outputs: 1,
icon: "betterframe.svg",
label: function () {
if (this.name) return this.name;
if (this.topic_filter) return "Event: " + this.topic_filter;
return "ONVIF Event";
},
paletteLabel: "ONVIF Event",
});
</script>
<script type="text/html" data-template-name="bf-trigger-event">
<div class="form-row">
<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-row">
<label for="node-input-topic_filter"><i class="fa fa-filter"></i> Topic Filter</label>
<input type="text" id="node-input-topic_filter" placeholder="e.g. LineCrossing, DigitalInput" />
</div>
<div class="form-tips">
Generic ONVIF event trigger. Fires on any event topic from any camera.
Use <b>Topic Filter</b> for substring matching (e.g. <code>LineCrossing</code>,
<code>DigitalInput</code>, <code>Tamper</code>). Leave blank for all events.
Outputs <code>msg.source</code> (source SimpleItems) and <code>msg.data</code>
(data SimpleItems) as key-value objects.
</div>
</script>
<script type="text/html" data-help-name="bf-trigger-event">
<p>Generic catch-all for ONVIF events not covered by the specialized
Motion or ANPR trigger nodes.</p>
<p>Common topic filters:</p>
<ul>
<li><code>LineDetector/Crossed</code> — line crossing</li>
<li><code>FieldDetector/ObjectsInside</code> — intrusion zone</li>
<li><code>Device/Trigger/DigitalInput</code> — physical I/O</li>
<li><code>Tamper</code> — camera tamper detection</li>
<li><code>AudioDetection</code> — audio analytics</li>
</ul>
<p>Leave filter blank to receive ALL events from ALL cameras.</p>
</script>

View file

@ -0,0 +1,76 @@
/**
* bf-trigger-event fires on ANY ONVIF event from any camera.
*
* Generic catch-all for event topics not handled by the specialized
* motion/ANPR nodes. Configurable topic filter (substring match) and
* camera ID filter.
*
* Output: { topic, kiosk_id, camera_id, source, data, payload }
*/
const { readJsonBody } = require("./_http-body.js");
module.exports = function (RED) {
const ROUTE = "/api/internal/onvif.event";
function BfTriggerEventNode(config) {
RED.nodes.createNode(this, config);
const node = this;
const filterCam = config.camera_id ? Number(config.camera_id) : null;
const filterTopic = (config.topic_filter || "").trim();
async function handler(req, res) {
const body = await readJsonBody(req);
const topic = String(body.topic || "");
if (filterTopic && !topic.includes(filterTopic)) {
return res.status(200).end();
}
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
if (filterCam !== null && Number(cameraId) !== filterCam) {
return res.status(200).end();
}
const source = body.payload?.source ?? {};
const data = body.payload?.data ?? {};
const out = {
topic,
kiosk_id: body.kiosk_id ?? body.source_kiosk_id ?? null,
camera_id: cameraId,
source,
data,
payload: body.payload ?? body,
};
// Show the topic in the node status badge.
const shortTopic = topic.length > 40
? "..." + topic.slice(topic.length - 37)
: topic;
node.status({ fill: "green", shape: "dot", text: shortTopic });
node.send(out);
res.status(200).end();
}
RED.httpNode.post(ROUTE, handler);
node.on("close", function (done) {
const stack = RED.httpNode?._router?.stack;
if (stack) {
for (let i = stack.length - 1; i >= 0; i--) {
const layer = stack[i];
if (!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]?.handle === handler) inner.splice(j, 1);
}
if (inner.length === 0) stack.splice(i, 1);
}
}
}
done();
});
}
RED.nodes.registerType("bf-trigger-event", BfTriggerEventNode);
};

View file

@ -0,0 +1,41 @@
<script type="text/javascript">
RED.nodes.registerType("bf-trigger-motion", {
category: "BetterFrame Triggers",
color: "#ff9999",
defaults: {
name: { value: "" },
camera_id: { value: "" },
},
inputs: 0,
outputs: 1,
icon: "betterframe.svg",
label: function () {
return this.name || "Motion Trigger";
},
paletteLabel: "Motion Trigger",
});
</script>
<script type="text/html" data-template-name="bf-trigger-motion">
<div class="form-row">
<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">
Fires on ONVIF motion detection events (MotionAlarm, CellMotionDetector,
VideoAnalytics/Motion, FieldDetector). Outputs <code>msg.active</code>
(true/false) for motion start/stop. Leave Camera ID blank for all cameras.
</div>
</script>
<script type="text/html" data-help-name="bf-trigger-motion">
<p>Triggers on ONVIF motion events from cameras connected via BetterFrame kiosks.</p>
<p>Matches event topics containing: MotionAlarm, CellMotionDetector,
VideoAnalytics, FieldDetector.</p>
<p><b>msg.active</b> is <code>true</code> when motion starts, <code>false</code>
when it clears. Use a switch node downstream to filter on active only.</p>
</script>

View file

@ -0,0 +1,89 @@
/**
* bf-trigger-motion fires on ONVIF motion detection events.
*
* Matches topics containing: MotionAlarm, CellMotionDetector, Motion,
* VideoAnalytics/Motion. Camera ID filter optional.
*
* Output: { topic, kiosk_id, camera_id, active: boolean, payload }
*/
const { readJsonBody } = require("./_http-body.js");
const MOTION_PATTERNS = [
"MotionAlarm",
"CellMotionDetector",
"VideoAnalytics",
"Motion",
"FieldDetector",
];
module.exports = function (RED) {
const ROUTE = "/api/internal/onvif.motion";
function BfTriggerMotionNode(config) {
RED.nodes.createNode(this, config);
const node = this;
const filterCam = config.camera_id ? Number(config.camera_id) : null;
async function handler(req, res) {
const body = await readJsonBody(req);
const topic = String(body.topic || "");
// Only fire for motion-related topics.
if (!MOTION_PATTERNS.some((p) => topic.includes(p))) {
return res.status(200).end();
}
const cameraId = body.camera_id ?? body.source_camera_id ?? null;
if (filterCam !== null && Number(cameraId) !== filterCam) {
return res.status(200).end();
}
// Detect active/inactive from payload data.
const data = body.payload?.data ?? body.payload ?? {};
const active = data.IsMotion === "true" || data.State === "true"
|| data.Value === "true" || data.LogicalState === "true"
|| data.IsInside === "true";
const out = {
topic,
kiosk_id: body.kiosk_id ?? body.source_kiosk_id ?? null,
camera_id: cameraId,
active,
payload: body.payload ?? body,
};
node.status({
fill: active ? "red" : "green",
shape: "dot",
text: active ? "Motion detected" : "Clear",
});
node.send(out);
res.status(200).end();
}
RED.httpNode.post(ROUTE, handler);
// Also listen on the generic onvif event route as fallback.
const GENERIC_ROUTE = "/api/internal/onvif.event";
RED.httpNode.post(GENERIC_ROUTE, handler);
node.on("close", function (done) {
const stack = RED.httpNode?._router?.stack;
if (stack) {
for (let i = stack.length - 1; i >= 0; i--) {
const layer = stack[i];
if (!layer?.route) continue;
if (layer.route.path !== ROUTE && layer.route.path !== GENERIC_ROUTE) continue;
const inner = layer.route.stack;
if (Array.isArray(inner)) {
for (let j = inner.length - 1; j >= 0; j--) {
if (inner[j]?.handle === handler) inner.splice(j, 1);
}
if (inner.length === 0) stack.splice(i, 1);
}
}
}
done();
});
}
RED.nodes.registerType("bf-trigger-motion", BfTriggerMotionNode);
};

View file

@ -600,12 +600,20 @@ function registerKioskRoutes(
camera_id: body.camera_id ?? null, camera_id: body.camera_id ?? null,
source_type: body.source_type ?? "system", source_type: body.source_type ?? "system",
property_op: body.property_op ?? null, property_op: body.property_op ?? null,
topic: body.topic,
payload: body.payload ?? {}, payload: body.payload ?? {},
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
source: "kiosk", source: "kiosk",
}; };
nodered.forward(body.topic, out, markForwarded); nodered.forward(body.topic, out, markForwarded);
mqtt.publishEvent(kiosk.id, body.topic, out); mqtt.publishEvent(kiosk.id, body.topic, out);
// ONVIF events: also forward to the fixed onvif.event route so the
// bf-trigger-motion / bf-trigger-anpr / bf-trigger-event nodes
// receive them without needing per-topic route registration.
if (body.source_type === "onvif") {
nodered.forward("onvif.event", out);
}
} }
return { ok: true, event_id: eventId }; return { ok: true, event_id: eventId };