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 };