From 82ef29a23d985b149d4165f6d9372a2c9f17a690 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sat, 23 May 2026 02:17:05 +0200 Subject: [PATCH] feat(nodered): motion + ANPR + generic ONVIF event trigger nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- nodered/package.json | 5 +- nodered/src/bf-trigger-anpr.html | 42 +++++++++ nodered/src/bf-trigger-anpr.js | 89 ++++++++++++++++++++ nodered/src/bf-trigger-event.html | 56 ++++++++++++ nodered/src/bf-trigger-event.js | 76 +++++++++++++++++ nodered/src/bf-trigger-motion.html | 41 +++++++++ nodered/src/bf-trigger-motion.js | 89 ++++++++++++++++++++ server/src/plugins/service-api-http/index.ts | 8 ++ 8 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 nodered/src/bf-trigger-anpr.html create mode 100644 nodered/src/bf-trigger-anpr.js create mode 100644 nodered/src/bf-trigger-event.html create mode 100644 nodered/src/bf-trigger-event.js create mode 100644 nodered/src/bf-trigger-motion.html create mode 100644 nodered/src/bf-trigger-motion.js diff --git a/nodered/package.json b/nodered/package.json index 2ab107c..2068493 100644 --- a/nodered/package.json +++ b/nodered/package.json @@ -24,7 +24,10 @@ "bf-config-get": "src/bf-config-get.js", "bf-config-set": "src/bf-config-set.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" diff --git a/nodered/src/bf-trigger-anpr.html b/nodered/src/bf-trigger-anpr.html new file mode 100644 index 0000000..0d4a30a --- /dev/null +++ b/nodered/src/bf-trigger-anpr.html @@ -0,0 +1,42 @@ + + + + + diff --git a/nodered/src/bf-trigger-anpr.js b/nodered/src/bf-trigger-anpr.js new file mode 100644 index 0000000..7ded382 --- /dev/null +++ b/nodered/src/bf-trigger-anpr.js @@ -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); +}; diff --git a/nodered/src/bf-trigger-event.html b/nodered/src/bf-trigger-event.html new file mode 100644 index 0000000..f85dfeb --- /dev/null +++ b/nodered/src/bf-trigger-event.html @@ -0,0 +1,56 @@ + + + + + diff --git a/nodered/src/bf-trigger-event.js b/nodered/src/bf-trigger-event.js new file mode 100644 index 0000000..2fa462a --- /dev/null +++ b/nodered/src/bf-trigger-event.js @@ -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); +}; diff --git a/nodered/src/bf-trigger-motion.html b/nodered/src/bf-trigger-motion.html new file mode 100644 index 0000000..288e7af --- /dev/null +++ b/nodered/src/bf-trigger-motion.html @@ -0,0 +1,41 @@ + + + + + diff --git a/nodered/src/bf-trigger-motion.js b/nodered/src/bf-trigger-motion.js new file mode 100644 index 0000000..8df9b26 --- /dev/null +++ b/nodered/src/bf-trigger-motion.js @@ -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); +}; diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 0c95e6f..0ff7039 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -600,12 +600,20 @@ function registerKioskRoutes( camera_id: body.camera_id ?? null, source_type: body.source_type ?? "system", property_op: body.property_op ?? null, + topic: body.topic, payload: body.payload ?? {}, timestamp: new Date().toISOString(), source: "kiosk", }; nodered.forward(body.topic, out, markForwarded); 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 };