UNPKG

node-red-contrib-hikvision-ultimate

Version:

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

647 lines (574 loc) 34.2 kB
const sha256 = require('./utils/Sha256').sha256 const { XMLParser, XMLBuilder } = require("fast-xml-parser"); const Dicer = require('dicer'); module.exports = (RED) => { const { createHttpClient } = require('./utils/httpClient'); // const AbortController = require('abort-controller'); const https = require('https'); function Hikvisionconfig(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; // Assumes, that is already connected. node.timerCheckHeartBeat = null; node.timerReadZonesStatus = null; node.errorDescription = ""; // Contains the error description in case of connection error. node.authentication = config.authentication || "sha256-salted"; node.deviceinfo = config.deviceinfo || {}; node.heartBeatTimerDisconnectionCounter = 0; node.heartbeattimerdisconnectionlimit = config.heartbeattimerdisconnectionlimit || 2; node.authCookie = '' // Contains the usable cookie for the authenticaded sessione node.optionsAlarmStream = {} node.clientAlarmStream = undefined var controller = null; // AbortController node.setAllClientsStatus = ({ fill, shape, text }) => { function nextStatus(oClient) { oClient.setNodeStatus({ fill: fill, shape: shape, text: text }) } node.nodeClients.map(nextStatus); } // 14/07/2021 custom agent as global variable, to avoid issue with self signed certificates const customHttpsAgent = new https.Agent({ rejectUnauthorized: false }); // This function starts the heartbeat timer, to detect the disconnection from the server node.resetHeartBeatTimer = () => { // Reset node.timerCheckHeartBeat if (node.timerCheckHeartBeat !== null) clearTimeout(node.timerCheckHeartBeat); node.timerCheckHeartBeat = setTimeout(() => { node.heartBeatTimerDisconnectionCounter += 1; if (node.heartBeatTimerDisconnectionCounter < node.heartbeattimerdisconnectionlimit) { // 28/12/2020 Retry again until connection attempt limit reached node.setAllClientsStatus({ fill: "yellow", shape: "ring", text: "Temporary lost connection. Attempt " + node.heartBeatTimerDisconnectionCounter + " of " + node.heartbeattimerdisconnectionlimit }); if (controller !== null) { try { controller.abort(); } catch (error) { } } setTimeout(startAlarmStream, 1000); // Reconnect } else { // 28/12/2020 Connection attempt limit reached node.heartBeatTimerDisconnectionCounter = 0; if (node.isConnected) { if (node.errorDescription === "") node.errorDescription = "Timeout waiting for heartbeat"; // In case of timeout of a stream, there is no error throwed. node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", errorDescription: node.errorDescription, payload: true }); }); node.setAllClientsStatus({ fill: "red", shape: "ring", text: "Lost connection...Retry... " + node.errorDescription }); } if (controller !== null) { try { controller.abort(); } catch (error) { } } node.isConnected = false; setTimeout(startAlarmStream, 1000); // Reconnect } }, 40000); } // 22/12/2022 sha salt password encoder function encodePassword(bodyAuthJsonData, _username, _password) { let result = '' if (bodyAuthJsonData.isIrreversible) { result = sha256(_username + bodyAuthJsonData.salt + _password) result = sha256(_username + bodyAuthJsonData.salt2 + result) result = sha256(result + bodyAuthJsonData.challenge) for (let f = 2; bodyAuthJsonData.iterations > f; f++) { result = sha256(result) } } else { result = sha256(_password) + bodyAuthJsonData.challenge for (let f = 1; bodyAuthJsonData.iterations > f; f++) { result = sha256(result) } } return result } //#region ALARMSTREAM // Funzione per estrarre il boundary dal Content-Type function extractBoundary(contentType) { if (!contentType) return null; const match = /boundary="?([^";]+)"?/i.exec(contentType); if (!match) return null; return match[1].trim().replace(/^--/, ''); } async function startAlarmStream() { node.resetHeartBeatTimer(); // First thing, start the heartbeat timer. node.setAllClientsStatus({ fill: "grey", shape: "ring", text: "Connecting..." }); if (node.authentication === "sha256-salted") { node.clientAlarmStream = createHttpClient({ authentication: "none" }); } else { const authMode = (node.authentication || "digest").toLowerCase(); node.clientAlarmStream = createHttpClient({ username: node.credentials.user, password: node.credentials.password, authentication: authMode === "basic" ? "basic" : "digest", logger: node.debug ? RED.log : undefined }); } // 22/12/2022 Start auth process // ################################## // Getting challenge and salt; something like this // <SessionLoginCap xmlns="http://www.hikvision.com/ver20/XMLSchema" version="2.0"> // <sessionID> // bb1eBANANb536161edf6894e5RAMA // </sessionID> // <challenge>2348972394Mychallenge</challenge> // <iterations>100</iterations> // <isSupportRTSPWithSession>true</isSupportRTSPWithSession> // <isIrreversible>true</isIrreversible> // <sessionIDVersion>2.1</sessionIDVersion> // <salt> // 234DFSDFS4564DGDFGDFGD456456 // </salt> // <salt2> // 234DFSDFS453453453453453453DFGDFGDFG64DGDFGDFGD456456 // </salt2> // </SessionLoginCap> controller = new globalThis.AbortController(); // For aborting the stream request node.optionsAlarmStream = { // 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: 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: 20000, // 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 }; try { const responseAuth = await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + "/ISAPI/Security/sessionLogin/capabilities?username=" + node.credentials.user, node.optionsAlarmStream) if (responseAuth.status >= 200 && responseAuth.status <= 300) { node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Communication established" }); } else { node.setAllClientsStatus({ fill: "red", shape: "ring", text: responseAuth.statusText || " unknown response code" }); // if (node.debug) RED.log.error("BANANA Error response " + response.statusText); node.errorDescription = "StatusResponse problem " + (responseAuth.statusText || " unknown status response code"); } // Get the XML Body of the salt and challenge const XMLBody = await responseAuth.text() // Transform it into Json let jSon = null; try { const parser = new XMLParser(); const result = parser.parse(XMLBody); jSon = JSON.parse(JSON.stringify(result)); if (node.debug) RED.log.error("BANANA SBANANATO XMLBoduAuth -> JSON " + JSON.stringify(result)); } catch (parseError) { throw new Error("AXPro auth capabilities parse error: " + (parseError.message || "unknown parse error")); } const sessionCapabilities = jSon?.SessionLoginCap; if (!sessionCapabilities) { throw new Error("AXPro auth capabilities missing SessionLoginCap"); } // jSon now contains the body in JSON Format. The simple thing is done. // Now i need to authenticate let bodyAuth = { sessionId: sessionCapabilities.sessionID, challenge: sessionCapabilities.challenge, iterations: sessionCapabilities.iterations, isIrreversible: sessionCapabilities.isIrreversible, salt: sessionCapabilities.salt || '', salt2: sessionCapabilities.salt2 || '' } // Finally, i've got the encoded salted password // Do not put Spaghetti in the cold water. Please be sure that water is warm and it's boiling. const encodedPassword = encodePassword(bodyAuth, node.credentials.user, node.credentials.password) // Build the XML body to pass to the alarm panel to login. // const xml2jsEngine = require('xml2js') // const XMLbuilder = new xml2jsEngine.Builder({ // headless: true, // renderOpts: { // pretty: false // } // }) // const XMLparser = new xml2jsEngine.Parser({ // attrkey: 'attr', // charkey: 'value', // explicitArray: false, // attrValueProcessors: [ // parseNumbers, parseBooleans // ], // valueProcessors: [ // parseNumbers, parseBooleans // ] // }) const jSonAuthSendBody = { SessionLogin: { userName: node.credentials.user, password: encodedPassword, sessionID: jSon.SessionLoginCap.sessionID, isSessionIDValidLongTerm: false, sessionIDVersion: 2.1 } } // Send the body to the alarm panel. //node.optionsAlarmStream.body = XMLbuilder.buildObject(jSonAuthSendBody) const XML_Builder = new XMLBuilder({}); node.optionsAlarmStream.body = XML_Builder.build(jSonAuthSendBody); node.optionsAlarmStream.method = 'POST' const responseSessionLogin = await node.clientAlarmStream.fetch(node.protocol + '://' + node.host + '/ISAPI/Security/sessionLogin?timeStamp=' + Date.now(), node.optionsAlarmStream) if (responseSessionLogin.status !== 200) throw Error('AXPro POST Auth: ' + responseSessionLogin.statusText); // Set the coockie session for this authenticated connection node.authCookie = responseSessionLogin.headers.get('set-cookie').split(';')[0] } catch (error) { node.setAllClientsStatus({ fill: "red", shape: "ring", text: error.message }); node.errorDescription = "Authentication problem " + error.message; } try { node.optionsAlarmStream.method = 'GET' delete (node.optionsAlarmStream.Authorization) delete (node.optionsAlarmStream.body) node.optionsAlarmStream.headers = { Cookie: node.authCookie } const res = await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + "/ISAPI/Event/notification/alertStream", node.optionsAlarmStream); if (res.status >= 200 && res.status <= 300) { node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Waiting for event." }); } else { node.setAllClientsStatus({ fill: "red", shape: "ring", text: res.statusText || " unknown response code" }); // if (node.debug) RED.log.error("BANANA Error response " + response.statusText); node.errorDescription = "StatusResponse problem " + (res.statusText || " unknown status response code"); throw new Error("StatusResponse " + (res.statusText || " unknown response code")); } if (res.ok) { if (!node.isConnected) { node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Connected." }); node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", errorDescription: "", payload: false }); }) node.errorDescription = ""; // Reset the error } node.isConnected = true; node.resetHeartBeatTimer(); try { const contentType = res.headers.get('content-type'); if (!contentType) { if (node.debug) RED.log.error("Hikvision-config: No Content-Type in response"); } if (contentType.includes('multipart')) { const boundary = extractBoundary(contentType); if (!boundary) { if (node.debug) RED.log.error("Hikvision-config: Failed to extract boundary from multipart stream"); } //console.log(`Receiving multipart stream with boundary: ${boundary}`); // Inizializza Dicer per il parsing del multipart const dicer = new Dicer({ boundary }); dicer.on('part', (part) => { let partData = []; let extension = 'bin'; // Default estensione per parti non riconosciute part.on('header', (header) => { try { node.resetHeartBeatTimer(); // Verifica il tipo di parte if (header['content-type'] && (header['content-type'][0].includes('image/jpeg') || header['content-type'][0].includes('image/jpg'))) { extension = 'jpg'; // Estensione corretta per immagini JPEG } else if (header['content-type'] && header['content-type'][0].includes('image/png')) { extension = 'png'; // Estensione corretta per immagini PNG } else if (header['content-type'] && header['content-type'][0].includes('application/xml')) { extension = 'xml'; // Estensione corretta per immagini PNG } else if (header['content-type'] && header['content-type'][0].includes('application/json')) { extension = 'json'; // Estensione corretta per immagini PNG } } catch (error) { } }); part.on('data', (data) => { try { node.resetHeartBeatTimer(); partData.push(data); // Aggiungi i chunk di dati alla parte } catch (error) { } }); part.on('end', () => { try { node.resetHeartBeatTimer(); const fullData = Buffer.concat(partData); // Unisci i chunk di dati switch (extension) { case 'xml': handleXML(fullData); break; case 'json': handleJSON(fullData); break; case 'jpg': case 'png': //const filename = generateFilename(extension); //saveFile(fullData, filename); // Salva l'immagine su disco handleIMG(fullData, extension); break; default: break; } } catch (error) { } }); part.on('error', (err) => { //console.error('Error in part:', err); }); }); dicer.on('finish', () => { //console.log('Finished parsing multipart stream.'); node.resetHeartBeatTimer(); }); dicer.on('error', (err) => { if (node.debug) RED.log.error('Error in Dicer:' + err.stack); }); const onRawData = () => { try { node.resetHeartBeatTimer(); } catch (error) { } }; const cleanupRawListener = () => { res.body.removeListener('data', onRawData); }; res.body.on('data', onRawData); res.body.once('end', cleanupRawListener); res.body.once('close', cleanupRawListener); res.body.once('error', cleanupRawListener); // Pipa lo stream multipart in Dicer res.body.pipe(dicer); res.body.on('error', (err) => { if (node.debug) RED.log.error("AXPro-config: alertStream body error: " + (err.message || " unknown error")); node.errorDescription = "alertStream error " + (err.message || " unknown error"); node.setAllClientsStatus({ fill: "red", shape: "ring", text: node.errorDescription }); node.isConnected = false; try { if (controller) controller.abort(); } catch (abortError) { } if (node.timerCheckHeartBeat !== null) clearTimeout(node.timerCheckHeartBeat); node.timerCheckHeartBeat = setTimeout(startAlarmStream, 2000); }); } else { //throw new Error('Unsupported Content-Type'); } } catch (error) { if (node.debug) RED.log.error("Hikvision-config: streamPipeline: Please be sure to have the latest Node.JS version installed: " + (error.message || " unknown error")); } } } catch (error) { // Main Error // Abort request //node.errorDescription = "Fetch error " + JSON.stringify(error, Object.getOwnPropertyNames(error)); node.errorDescription = "Fetch error " + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: FETCH ERROR: " + (error.message || " unknown error")); }; // Starts zone polling clearTimeout(node.timerReadZonesStatus) node.timerReadZonesStatus = setTimeout(startZonesStatusReading, 2000) }; // Start login and alamrstream setTimeout(startAlarmStream, 5000) //#endregion //#region "HANDLE STREAM MESSAGE" // Handle the complete stream message // ################################### async function handleIMG(result, extension) { try { if (node.debug) RED.log.error("BANANA SBANANATO IMG -> JSON buffer length " + result.length + " ext " + extension); node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", payload: result, type: 'img', extension: extension }); }); } catch (error) { if (node.debug) RED.log.error("BANANA ERRORE fast-xml-parser(sRet, function (err, result) " + error.message || ""); } } async function handleXML(result) { try { const parser = new XMLParser(); const oXML = parser.parse(result); if (node.debug) RED.log.error("BANANA SBANANATO XML -> JSON " + JSON.stringify(oXML)); node.nodeClients.forEach(oClient => { if (oXML !== undefined) oClient.sendPayload({ topic: oClient.topic || "", payload: oXML.EventNotificationAlert, type: 'event' }); }); } catch (error) { if (node.debug) RED.log.error("BANANA ERRORE fast-xml-parser(sRet, function (err, result) " + error.message || ""); } } async function handleJSON(result) { try { const oJSON = JSON.parse(result); if (oJSON !== null && oJSON !== undefined) { node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", payload: oJSON, type: 'event' }); }) } } catch (error) { if (node.debug) RED.log.error("BANANA ERRORE fast-xml-parser(sRet, function (err, result) " + error.message || ""); } } // ################################### //#endregion // Read zones status and outputs only changed ones // Wrapping the async function, for peace of mind async function startZonesStatusReading() { try { let optionsZonesStatusReading = { // These properties are part of the Fetch Standard method: 'GET', headers: { Cookie: node.authCookie }, // 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: 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: 0, // 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 }; try { delete (optionsZonesStatusReading.body) const responseZonesStatus = await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + "/ISAPI/SecurityCP/status/zones?format=json", optionsZonesStatusReading) if (responseZonesStatus.status >= 200 && responseZonesStatus.status <= 300) { // Output only the changed zone // Get the XML Body of the salt and challenge const result = await responseZonesStatus.text() const jSon = JSON.parse(result) node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", payload: { ZoneList: jSon.ZoneList } }); }) } else { node.setAllClientsStatus({ fill: "red", shape: "ring", text: responseZonesStatus.statusText || " unknown response code" }); // if (node.debug) RED.log.error("BANANA Error response " + response.statusText); node.errorDescription = "StatusResponse problem " + (responseZonesStatus.statusText || " unknown status response code"); } } catch (error) { node.setAllClientsStatus({ fill: "red", shape: "ring", text: "Unable to fetch zone state " + error.message }); } node.timerReadZonesStatus = setTimeout(startZonesStatusReading, 2000) } catch (error) { } } //#region "FUNCTIONS" node.on('close', function (removed, done) { if (controller !== null) { try { controller.abort(); } catch (error) { } } if (node.timerCheckHeartBeat !== null) clearTimeout(node.timerCheckHeartBeat); 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) { } }; //#endregion // Disarm Area node.disarmArea = async function (_area) { try { _area = Number(_area) let sURL = '/ISAPI/SecurityCP/control/disarm/' + _area + '?format=json' node.optionsAlarmStream.method = 'PUT' delete (node.optionsAlarmStream.body) await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + sURL, node.optionsAlarmStream); } catch (error) { node.errorDescription = "control/disarm " + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: control/disarm: " + (error.message || " unknown error")); } } // Arm Away Area node.armAwayArea = async function (_area) { try { _area = Number(_area) let sURL = '/ISAPI/SecurityCP/control/arm/' + _area + '?ways=away&format=json' node.optionsAlarmStream.method = 'PUT' delete (node.optionsAlarmStream.body) await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + sURL, node.optionsAlarmStream); } catch (error) { node.errorDescription = "control/arm " + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: control/arm: " + (error.message || " unknown error")); } } // Arm Stay Area node.armStayArea = async function (_area) { try { _area = Number(_area) let sURL = '/ISAPI/SecurityCP/control/arm/' + _area + '?ways=stay&format=json' node.optionsAlarmStream.method = 'PUT' delete (node.optionsAlarmStream.body) await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + sURL, node.optionsAlarmStream); } catch (error) { node.errorDescription = "control/armStay" + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: control/armStay: " + (error.message || " unknown error")); } } // Clear Alarm Area node.clearAlarmArea = async function (_area) { try { _area = Number(_area) let sURL = '/ISAPI/SecurityCP/control/clearAlarm/' + _area + '?format=json' node.optionsAlarmStream.method = 'PUT' delete (node.optionsAlarmStream.body) await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + sURL, node.optionsAlarmStream); } catch (error) { node.errorDescription = "control/clearAlarm" + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: control/clearAlarm: " + (error.message || " unknown error")); } } // Disarm All Areas node.disarmAllAreas = async function () { try { let sURL = '/ISAPI/SecurityCP/control/disarm?format=json' node.optionsAlarmStream.method = 'PUT' const SubSysList = []; for (let index = 1; index <= 32; index++) { const SubSys = { id: index, arming: "disarm", alarm: false }; SubSysList.push({ SubSys: SubSys }); } let body = JSON.stringify({ SubSysList: SubSysList });//{ "SubSysList": [{ "SubSys": { "id": area_id } }] } node.optionsAlarmStream.body = body; await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + sURL, node.optionsAlarmStream); } catch (error) { node.errorDescription = "control/disarmAllArea " + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: control/disarmAllArea: " + (error.message || " unknown error")); } } // Clear Alarm of all areas node.clearAllAlarmAreas = async function () { try { let sURL = '/ISAPI/SecurityCP/control/clearAlarm?format=json' node.optionsAlarmStream.method = 'PUT' const SubSysList = []; for (let index = 1; index <= 32; index++) { const SubSys = { id: index }; SubSysList.push({ SubSys: SubSys }); } let body = JSON.stringify({ SubSysList: SubSysList });//{ "SubSysList": [{ "SubSys": { "id": area_id } }] } node.optionsAlarmStream.body = body; await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + sURL, node.optionsAlarmStream); } catch (error) { node.errorDescription = "control/clearAllAlarmArea" + (error.message || " unknown error"); if (node.debug) RED.log.error("AXPro-config: control/clearAllAlarmArea: " + (error.message || " unknown error")); } } } RED.nodes.registerType("AXPro-config", Hikvisionconfig, { credentials: { user: { type: "text" }, password: { type: "password" } } }); }