UNPKG

node-red-contrib-hikvision-ultimate

Version:

A native set of nodes for Hikvision (and compatible) Cameras, Alarms, Radars, NVR, Doorbells, etc.

528 lines (474 loc) 28.9 kB
module.exports = (RED) => { const { createHttpClient } = require('./utils/httpClient'); // const AbortController = require('abort-controller'); const { XMLParser } = require("fast-xml-parser"); const https = require('https'); function ANPRconfig(config) { RED.nodes.createNode(this, config) var node = this node.port = config.port || 80; const rawHost = (config.host || "").toString().toLowerCase(); const hostHasBanana = rawHost.indexOf("banana") > -1; node.debug = (config.debuglevel === "yes") || hostHasBanana; node.host = rawHost.replace("banana", "") + ":" + node.port; node.protocol = config.protocol || "http"; node.nodeClients = []; // Stores the registered clients node.isConnected = true; // Assume it's connected, to signal the disconnection on start node.isClosing = false; node.timerInitPlateReader = null; node.timerQueryForPlates = null; node.lastPicName = ""; node.errorDescription = ""; // Contains the error description in case of connection error. node.authentication = config.authentication || "digest"; var controller = null; // Abortcontroller node.setAllClientsStatus = ({ fill, shape, text }) => { function nextStatus(oClient) { oClient.setNodeStatus({ fill: fill, shape: shape, text: text }); } node.nodeClients.map(nextStatus); } const buildClient = (authentication, username, password) => { const mode = (authentication || "digest").toLowerCase(); return createHttpClient({ username, password, authentication: mode === "basic" ? "basic" : "digest", logger: node.debug ? RED.log : undefined }); }; // 14/07/2021 custom agent as global variable, to avoid issue with self signed certificates const customHttpsAgent = new https.Agent({ rejectUnauthorized: false, keepAlive: true }); // 14/12/2020 Get the infos from the camera RED.httpAdmin.get("/hikvisionUltimateGetInfoCamANPR", RED.auth.needsPermission('ANPRconfig.read'), function (req, res) { var jParams = JSON.parse(decodeURIComponent(req.query.params));// Retrieve node.id of the config node. var _nodeServer = null; var clientInfo; let passwordToUse = jParams.password; if (jParams.password === "__PWRD__") { _nodeServer = RED.nodes.getNode(req.query.nodeID); if (!_nodeServer || !_nodeServer.credentials || !_nodeServer.credentials.password) { res.json({ error: "Missing stored credentials" }); return; } passwordToUse = _nodeServer.credentials.password; } clientInfo = buildClient(jParams.authentication, jParams.user, passwordToUse); var opt = { // These properties are part of the Fetch Standard method: "GET", headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below) body: null, // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream redirect: 'follow', // set to `manual` to extract redirect headers, `error` to reject redirect signal: null, // pass an instance of AbortSignal to optionally abort requests // The following properties are node-fetch extensions follow: 20, // maximum redirect count. 0 to not follow redirect timeout: 5000, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies). Signal is recommended instead. compress: false, // support gzip/deflate content encoding. false to disable size: 0, // maximum response body size in bytes. 0 to disable agent: jParams.protocol === "https" ? customHttpsAgent : null // http(s).Agent instance or function that returns an instance (see below) }; try { (async () => { try { const resInfo = await clientInfo.fetch(jParams.protocol + "://" + jParams.host + ":" + jParams.port + "/ISAPI/System/deviceInfo", opt); const body = await resInfo.text(); const parser = new XMLParser(); try { let jObj = parser.parse(body); res.json(jObj); return; } catch (error) { res.json(error); return; } } catch (error) { RED.log.error("Errore hikvisionUltimateGetInfoCamANPR " + error.message); res.json(error); } })(); } catch (err) { res.json(err); } }); //#region "PLATES ANPR" // Sort the plates, in any case, even if the anpr camera returns a sorted list. It's not always true! function sortPlates(a, b) { try { const aRaw = (a && (a.picName || (a.Plate && a.Plate.picName))) || ""; const bRaw = (b && (b.picName || (b.Plate && b.Plate.picName))) || ""; // Prefer numeric comparison (BigInt) to avoid any surprises, // but fall back to string comparison if parsing fails. const aStr = String(aRaw); const bStr = String(bRaw); try { const aNum = BigInt(aStr); const bNum = BigInt(bStr); if (aNum < bNum) return -1; if (aNum > bNum) return 1; return 0; } catch (error) { if (aStr < bStr) return -1; if (aStr > bStr) return 1; return 0; } } catch (error) { return 0; } } // Function to get the plate list from the camera async function getPlates(_lastPicName) { if (_lastPicName == undefined || _lastPicName == null || _lastPicName == "") return null; if (node.debug) RED.log.info("ANPR-config: getPlates request for picName " + _lastPicName); var client; client = buildClient(node.authentication, node.credentials.user, node.credentials.password); controller = new globalThis.AbortController(); // For aborting the stream request var options = { // These properties are part of the Fetch Standard method: 'POST', headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below) body: "<AfterTime><picTime>" + _lastPicName + "</picTime></AfterTime>", // request body. can be null, a string, a Buffer, a Blob, or a Node.js Readable stream redirect: 'follow', // set to `manual` to extract redirect headers, `error` to reject redirect signal: controller.signal, // pass an instance of AbortSignal to optionally abort requests // The following properties are node-fetch extensions follow: 20, // maximum redirect count. 0 to not follow redirect timeout: 5000, // req/res timeout in ms, it resets on redirect. 0 to disable (OS limit applies). Signal is recommended instead. compress: false, // support gzip/deflate content encoding. false to disable size: 0, // maximum response body size in bytes. 0 to disable agent: node.protocol === "https" ? customHttpsAgent : null // http(s).Agent instance or function that returns an instance (see below) }; try { const response = await client.fetch(node.protocol + "://" + node.host + "/ISAPI/Traffic/channels/1/vehicleDetect/plates", options); if (node.debug) RED.log.info("ANPR-config: getPlates response status " + response.status); if (response.status >= 200 && response.status <= 300) { //node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Connected." }); } else { node.setAllClientsStatus({ fill: "red", shape: "ring", text: response.statusText || " unknown response code" }); //console.log("BANANA Error response " + response.statusText); throw new Error("Error response: " + response.statusText || " unknown response code"); } //#region "BODY" if (response.ok) { var body = ""; body = await response.text(); var sRet = body.toString(); if (sRet === null || sRet === undefined || sRet.length === 0) { if (node.debug) RED.log.warn("ANPR-config: getPlates empty response body"); throw new Error("Empty response body"); } if (node.debug) RED.log.info("ANPR-config: getPlates body length " + sRet.length); // console.log("BANANA ANPR: " + sRet); var oPlates = null; try { var i = sRet.indexOf("<"); // Get only the XML, starting with "<" if (i > -1) { sRet = sRet.substring(i); // 2024-XX-XX: ensure picName and similar fields stay as strings const parser = new XMLParser({ parseTagValue: false }); try { let result = parser.parse(sRet); try { // 21/05/2023 The result must always be an array if (!Array.isArray(result.Plates.Plate) && result.Plates.hasOwnProperty("Plate")) { // There is 1 element, that i must transform in an array let a = new Array(1); a[0] = result.Plates.Plate; delete result.Plates.Plate; result.Plates.Plate = a; } } catch (error) { oPlates = result; } oPlates = result; } catch (error) { oPlates = null; } } else { i = sRet.indexOf("{") // It's a Json if (i > -1) { sRet = sRet.substring(i); try { oPlates = JSON.parse(sRet); } catch (error) { if (node.debug) RED.log.warn("ANPR-config: getPlates JSON parse error " + (error.message || "")); oPlates = null; } } else { // Invalid body if (node.debug) RED.log.info("ANPR-config: DecodingBody: Invalid Json " + sRet); // console.log("BANANA ANPR-config: DecodingBody: Invalid Json " + sRet); throw new Error("Error Invalid Json: " + sRet); } } // console.log("BANANA GIASONE " + JSON.stringify(oPlates)); // Working the plates. Must be sure, that no error occurs, before acknolwedging the plate last picName if (oPlates && oPlates.Plates !== null && oPlates.Plates !== undefined) { // Send connection OK if (!node.isConnected) { node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", errorDescription: "", payload: false }); }) } node.errorDescription = ""; // Reset the error message node.isConnected = true; //console.log("BANANA JSON PLATES: " + JSON.stringify(oPlates)); if (oPlates.Plates.hasOwnProperty("Plate")) { if (node.debug) { const plateCount = Array.isArray(oPlates.Plates.Plate) ? oPlates.Plates.Plate.length : 1; RED.log.info("ANPR-config: getPlates parsed " + plateCount + " plate(s)"); } // If the plate is an array, returns a sorted list, otherwise a single plate. if (Array.isArray(oPlates.Plates.Plate)) { oPlates.Plates.Plate = oPlates.Plates.Plate.sort(sortPlates); //console.log("BANANA LISTA MULTIPLA PLATES ORDINATE:" + JSON.stringify(oPlates)); } return oPlates; } else { // Returns the object, empty. return oPlates; } } else { // Error in parsing XML if (node.debug) RED.log.info("ANPR-config: Error: oPlates.Plates is null"); throw new Error("Error: oPlates.Plates is null"); } } catch (error) { if (node.debug) RED.log.warn("ANPR-config: getPlates parsing error " + (error.message || "")); if (node.debug) RED.log.warn("ANPR-config: ERRORE CATCHATO getPlates:" + (error.message || "")); // console.log("BANANA ANPR-config: ERRORE CATCHATO initPlateReader: " + error); throw new Error("Error getPlates: " + (error.message || "")); } } //#endregion } catch (err) { // Main Error node.errorDescription = err.message || " unknown error"; node.setAllClientsStatus({ fill: "grey", shape: "ring", text: node.errorDescription + " Retry..." }); if (node.isConnected) { node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", errorDescription: node.errorDescription, payload: true }); }) } // Abort request if (controller !== null) { try { controller.abort(); } catch (error) { } } node.isConnected = false; return null; }; }; // 30/01/2021 From a list of plates, returns the most recent picname async function returnMostRecentPicnameFromList(_PlatesObject, _updateNodeStatusText) { // Sets the default to be returned in case of error let d = new Date(); let sRet = (d.getFullYear() + ("0" + (d.getMonth() + 1)).slice(-2) + ("0" + d.getDate()).slice(-2) + ("0" + d.getHours()).slice(-2) + ("0" + d.getMinutes()).slice(-2) + ("0" + d.getSeconds()).slice(-2) + "0000").toString(); // Is there plates? if (!_PlatesObject.Plates.hasOwnProperty("Plate")) { // No plate list // No previously plates found, set a default datetime if (_updateNodeStatusText) node.setAllClientsStatus({ fill: "grey", shape: "ring", text: "No previously plates found." }); return sRet; }; if (Array.isArray(_PlatesObject.Plates.Plate) && _PlatesObject.Plates.Plate.length > 0) { // 31/01/2021 reliability check: oggi ho scoperto che al passaggio di una macchina, la telecamera la registrato // il passaggio e mi ha aggiunto come ultimo item, anche il primo item della sua lista (quindi duplicandolo, uguale uguale), quindi // con stessa targa, stesso orario vecchio, ecc.. // Ovviamente il picname è diventato quello vecchio lì, quindi, visto che appena 2 targhe prima c'era la mia, mi ha aperto il cancello // Pick up the last plate by the most recent datetime instead of by the last item in the list (format 202001010101010000) if (_updateNodeStatusText) node.setAllClientsStatus({ fill: "grey", shape: "ring", text: "Found " + _PlatesObject.Plates.Plate.length + " old plates." }); try { let nMostRecent = 0; let nCurPicName = 0; for (let index = 0; index < _PlatesObject.Plates.Plate.length; index++) { const element = _PlatesObject.Plates.Plate[index]; if (node.debug) RED.log.info("BANANA nMostRecent:" + nMostRecent + " nCurPicName:" + nCurPicName); try { if (element.hasOwnProperty("picName")) { if (typeof element.picName === 'string') { nCurPicName = BigInt(element.picName); } else { nCurPicName = element.picName; } if (nCurPicName > nMostRecent) nMostRecent = nCurPicName; } } catch (error) { console.log(error); } } sRet = nMostRecent.toString(); } catch (error) { // Error, return default current datetime sRet = (d.getFullYear() + ("0" + (d.getMonth() + 1)).slice(-2) + ("0" + d.getDate()).slice(-2) + ("0" + d.getHours()).slice(-2) + ("0" + d.getMinutes()).slice(-2) + ("0" + d.getSeconds()).slice(-2) + "0000").toString(); } } else { // It's a single plate try { sRet = _PlatesObject.Plates.Plate.picName.toString(); if (_updateNodeStatusText) node.setAllClientsStatus({ fill: "grey", shape: "ring", text: "Found 1 ignored plates. It's " + sRet }); } catch (error) { // Some sort of error, set the lastpicname with the current dateteim sRet = (d.getFullYear() + ("0" + (d.getMonth() + 1)).slice(-2) + ("0" + d.getDate()).slice(-2) + ("0" + d.getHours()).slice(-2) + ("0" + d.getMinutes()).slice(-2) + ("0" + d.getSeconds()).slice(-2) + "0000").toString(); if (_updateNodeStatusText) node.setAllClientsStatus({ fill: "red", shape: "ring", text: "Error in initplates. Set lastPicName to " + sRet }); RED.log.error("Hikvision-Ultimate: ANPR-config: initPlateReader: Error in initplates. Set lastPicName to " + sRet + ". " + error.message); } } return sRet; } // At start, reads the last recognized plate and starts listening from the time last plate was recognized. // This avoid output all the previoulsy plate list, stored by the camera. node.initPlateReader = () => { if (node.isClosing) return; (async () => { var oPlates = null; try { // Get current time in format 202101301301320000 oPlates = await getPlates("202001301301320000"); } catch (error) { oPlates = null; } if (oPlates === null) { if (node.isClosing) return; if (node.timerInitPlateReader !== null) clearTimeout(node.timerInitPlateReader); node.timerInitPlateReader = setTimeout(node.initPlateReader, 10000); // Restart initPlateReader } else { // console.log("BANANA STRIGONE " + JSON.stringify(oPlates)) try { node.lastPicName = await returnMostRecentPicnameFromList(oPlates, true); //console.log("lastPicName:" + node.lastPicName); } catch (error) { if (node.isClosing) return; if (node.timerInitPlateReader !== null) clearTimeout(node.timerInitPlateReader); node.timerInitPlateReader = setTimeout(node.initPlateReader, 10000); // Restart initPlateReader return; } setTimeout(() => { if (node.isClosing) return; node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Waiting for vehicle..." }); }, 2000); if (node.timerQueryForPlates !== null) clearTimeout(node.timerQueryForPlates); node.timerQueryForPlates = setTimeout(node.queryForPlates, 3000); // Start main polling thread } })(); }; node.queryForPlates = () => { if (node.isClosing) return; // console.log("BANANA queryForPlates"); if (node.lastPicName === "") { // Should not be here! node.setAllClientsStatus({ fill: "red", shape: "ring", text: "Cacchio, non dovrei essere qui." }); if (node.isConnected) { node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", payload: null, connected: false }); }) } node.isConnected = false; if (node.timerInitPlateReader !== null) clearTimeout(node.timerInitPlateReader); node.timerInitPlateReader = setTimeout(node.initPlateReader, 10000); // Restart whole process. } else { (async () => { var oPlates = null; try { oPlates = await getPlates(node.lastPicName); } catch (error) { oPlates = null; } if (oPlates === null) { // An error was occurred. if (node.isClosing) return; if (node.timerInitPlateReader !== null) clearTimeout(node.timerInitPlateReader); node.timerInitPlateReader = setTimeout(node.initPlateReader, 2000); // Restart initPlateReader from scratch } else { if (oPlates.Plates.hasOwnProperty("Plate")) { // Check wether is an array of plates or a single plate if (Array.isArray(oPlates.Plates.Plate) && oPlates.Plates.Plate.length > 0) { // Send the message to the child nodes oPlates.Plates.Plate.forEach(oPlate => { const picNameString = (oPlate && oPlate.picName !== undefined && oPlate.picName !== null) ? oPlate.picName.toString() : ""; node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", plate: oPlate, payload: oPlate.plateNumber, picName: picNameString, connected: true }); }) }) // Set the picname of the most recent plate in this filtered list node.lastPicName = await returnMostRecentPicnameFromList(oPlates, false); } // else { // // It's a single plate // try { // node.lastPicName = oPlates.Plates.Plate.picName; // var oPlate = oPlates.Plates.Plate; // node.nodeClients.forEach(oClient => { // oClient.sendPayload({ topic: oClient.topic || "", plate: oPlate, payload: oPlate.plateNumber, connected: true }); // }) // } catch (error) { // let d = new Date(); // let sRet = (d.getFullYear() + ("0" + (d.getMonth() + 1)).slice(-2) + ("0" + d.getDate()).slice(-2) + ("0" + d.getHours()).slice(-2) + ("0" + d.getMinutes()).slice(-2) + ("0" + d.getSeconds()).slice(-2) + "0000").toString(); // node.lastPicName = sRet; // RED.log.error("Hikvision-Ultimate: ANPR-config: queryForPlates: Error in It's a single plate. Set lastPicName to " + node.lastPicName + ". " + error.message); // } // //console.log("BANANA SINGOLA PLATE: " + oPlate.plateNumber); // } } else { // No new plates found } if (node.isClosing) return; if (node.timerQueryForPlates !== null) clearTimeout(node.timerQueryForPlates); node.timerQueryForPlates = setTimeout(node.queryForPlates, 3000); // Call the function again. } })(); } }; // Start! if (node.timerInitPlateReader !== null) clearTimeout(node.timerInitPlateReader); node.timerInitPlateReader = setTimeout(node.initPlateReader, 10000); // First connection. //#endregion //#region "FUNCTIONS" node.on('close', function (removed, done) { node.isClosing = true; if (controller !== null) { try { controller.abort(); } catch (error) { } } if (node.timerInitPlateReader !== null) clearTimeout(node.timerInitPlateReader); if (node.timerQueryForPlates !== null) clearTimeout(node.timerQueryForPlates); done(); }); node.addClient = (_Node) => { // Check if node already exists if (node.nodeClients.filter(x => x.id === _Node.id).length === 0) { // Add _Node to the clients array node.nodeClients.push(_Node) } try { _Node.setNodeStatus({ fill: "grey", shape: "ring", text: "Waiting for connection" }); } catch (error) { } }; node.removeClient = (_Node) => { // Remove the client node from the clients array //if (node.debug) RED.log.info( "BEFORE Node " + _Node.id + " has been unsubscribed from receiving KNX messages. " + node.nodeClients.length); try { node.nodeClients = node.nodeClients.filter(x => x.id !== _Node.id) } catch (error) { } //if (node.debug) RED.log.info("AFTER Node " + _Node.id + " has been unsubscribed from receiving KNX messages. " + node.nodeClients.length); // If no clien nodes, disconnect from bus. if (node.nodeClients.length === 0) { } }; //#endregion } RED.nodes.registerType("ANPR-config", ANPRconfig, { credentials: { user: { type: "text" }, password: { type: "password" } } }); }