node-red-contrib-hikvision-ultimate
Advanced tools
Comparing version 1.2.0 to 1.2.1
@@ -7,2 +7,6 @@ <p align="center"><img src='https://raw.githubusercontent.com/Supergiovane/node-red-contrib-hikvision-ultimate/master/img/logo.png' width="40%"></p> | ||
<p> | ||
<b>Version 1.2.1</b> September 2024<br/> | ||
- AX Pro: fixed an issue where occasionally, when malformed multipart data is sent from the AX Pro, there would be no NAK.<br/> | ||
</p> | ||
<p> | ||
<b>Version 1.2.0</b> September 2024<br/> | ||
@@ -9,0 +13,0 @@ - RAW Event Node: on the third pin, outputs the IMAGE related to the event (if any). The node will now filter the heartbeat signals.<br/> |
const { default: fetch } = require('node-fetch') | ||
const sha256 = require('./utils/Sha256').sha256 | ||
const { XMLParser, XMLBuilder } = require("fast-xml-parser"); | ||
const Dicer = require('dicer'); | ||
@@ -9,5 +10,5 @@ module.exports = (RED) => { | ||
const AbortController = require('abort-controller'); | ||
const readableStr = require('stream').Readable; | ||
const https = require('https'); | ||
function Hikvisionconfig(config) { | ||
@@ -34,3 +35,2 @@ RED.nodes.createNode(this, config) | ||
var controller = null; // AbortController | ||
var oReadable = new readableStr(); | ||
node.setAllClientsStatus = ({ fill, shape, text }) => { | ||
@@ -67,3 +67,3 @@ function nextStatus(oClient) { | ||
if (node.isConnected) { | ||
if (node.errorDescription === "") node.errorDescription = "Timeout waiting heartbeat"; // In case of timeout of a stream, there is no error throwed. | ||
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 => { | ||
@@ -109,2 +109,7 @@ oClient.sendPayload({ topic: oClient.topic || "", errorDescription: node.errorDescription, payload: true }); | ||
//#region ALARMSTREAM | ||
// Funzione per estrarre il boundary dal Content-Type | ||
function extractBoundary(contentType) { | ||
const match = contentType.match(/boundary=(.*)$/); | ||
return match ? match[1] : null; | ||
} | ||
async function startAlarmStream() { | ||
@@ -148,3 +153,3 @@ | ||
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. | ||
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 | ||
@@ -158,3 +163,2 @@ size: 0, // maximum response body size in bytes. 0 to disable | ||
const responseAuth = await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + "/ISAPI/Security/sessionLogin/capabilities?username=" + node.credentials.user, node.optionsAlarmStream) | ||
@@ -236,80 +240,3 @@ if (responseAuth.status >= 200 && responseAuth.status <= 300) { | ||
try { | ||
//#region "HANDLE STREAM MESSAGE" | ||
// Handle the complete stream message, enclosed into the --boundary stream string | ||
// If there is more boundary, process each one separately | ||
// ################################### | ||
async function handleChunk(result) { | ||
try { | ||
// 05/12/2020 process the data | ||
var aResults = result.split("--boundary"); | ||
if (node.debug) RED.log.info("SPLITTATO RESULT COUNT: ####### " + aResults.length + " ###################### FINE SPLITTATO RESULT"); | ||
aResults.forEach(async sRet => { | ||
if (sRet.trim() !== "") { | ||
if (node.debug) RED.log.error("BANANA PROCESSING" + sRet); | ||
try { | ||
//sRet = sRet.replace(/--boundary/g, ''); | ||
var i = sRet.indexOf("<"); // Get only the XML, starting with "<" | ||
if (i > -1) { | ||
sRet = sRet.substring(i); | ||
try { | ||
const parser = new XMLParser(); | ||
let result = parser.parse(sRet); | ||
if (node.debug) RED.log.error("BANANA SBANANATO XML -> JSON " + JSON.stringify(result)); | ||
if (result !== null && result !== undefined && result.hasOwnProperty("EventNotificationAlert")) { | ||
node.nodeClients.forEach(oClient => { | ||
if (result !== undefined) oClient.sendPayload({ topic: oClient.topic || "", payload: result.EventNotificationAlert }); | ||
}); | ||
} | ||
} catch (error) { | ||
sRet = ""; | ||
if (node.debug) RED.log.error("BANANA ERRORE fast-xml-parser(sRet, function (err, result) " + error.message || ""); | ||
} | ||
} else { | ||
i = sRet.indexOf("{") // It's a Json | ||
if (i > -1) { | ||
if (node.debug) RED.log.error("BANANA SBANANATO JSON " + sRet); | ||
sRet = sRet.substring(i); | ||
try { | ||
sRet = JSON.parse(sRet); | ||
// if (node.debug) RED.log.error("BANANA JSONATO: " + sRet); | ||
if (sRet !== null && sRet !== undefined) { | ||
node.nodeClients.forEach(oClient => { | ||
oClient.sendPayload({ topic: oClient.topic || "", payload: sRet }); | ||
}) | ||
} | ||
} catch (error) { | ||
sRet = ""; | ||
} | ||
} else { | ||
// Invalid body | ||
if (node.debug) RED.log.info("AXPro-config: DecodingBody Info only: Invalid Json " + sRet); | ||
} | ||
} | ||
// All is fine. Reset and restart the hearbeat timer | ||
// Hikvision sends an heartbeat alarm (videoloss), depending on firmware, every 300ms or more. | ||
// If this HeartBeat isn't received, abort the stream request and restart. | ||
node.resetHeartBeatTimer(); | ||
} catch (error) { | ||
// if (node.debug) RED.log.error("BANANA startAlarmStream decodifica body: " + error); | ||
if (node.debug) RED.log.error("AXPro-config: DecodingBody error: " + (error.message || " unknown error")); | ||
throw (error); | ||
} | ||
} else { | ||
if (node.debug) RED.log.info("SPLITTATO RESULT EMPTY: ####### " + sRet + " ###################### FINE SPLITTATO RESULT"); | ||
} | ||
}); | ||
} catch (error) { | ||
if (node.debug) RED.log.info("AXPro-config: readStream error: " + (error.message || " unknown error")); | ||
node.errorDescription = "readStream error " + (error.message || " unknown error"); | ||
throw (error); | ||
} | ||
} | ||
// ################################### | ||
//#endregion | ||
node.optionsAlarmStream.method = 'GET' | ||
@@ -320,12 +247,12 @@ delete (node.optionsAlarmStream.Authorization) | ||
const responseFromAxProAlarmStream = await node.clientAlarmStream.fetch(node.protocol + "://" + node.host + "/ISAPI/Event/notification/alertStream", node.optionsAlarmStream); | ||
if (responseFromAxProAlarmStream.status >= 200 && responseFromAxProAlarmStream.status <= 300) { | ||
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: responseFromAxProAlarmStream.statusText || " unknown response code" }); | ||
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 " + (responseFromAxProAlarmStream.statusText || " unknown status response code"); | ||
throw new Error("StatusResponse " + (responseFromAxProAlarmStream.statusText || " unknown response code")); | ||
node.errorDescription = "StatusResponse problem " + (res.statusText || " unknown status response code"); | ||
throw new Error("StatusResponse " + (res.statusText || " unknown response code")); | ||
} | ||
if (responseFromAxProAlarmStream.ok) { | ||
if (res.ok) { | ||
if (!node.isConnected) { | ||
@@ -339,46 +266,100 @@ node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Connected." }); | ||
node.isConnected = true; | ||
node.resetHeartBeatTimer(); | ||
try { | ||
if (node.debug) RED.log.info("AXPro-config: before Pipelining..."); | ||
if (oReadable !== null) oReadable.removeAllListeners() // 09/01/2023 | ||
oReadable = readableStr.from(responseFromAxProAlarmStream.body, { encoding: 'utf8' }); | ||
var result = ""; | ||
oReadable.on('data', async (chunk) => { | ||
result += chunk; | ||
if (result.indexOf("--boundary") > -1) { | ||
const contentType = res.headers.get('content-type'); | ||
if (!contentType) { | ||
if (node.debug) RED.log.error("Hikvision-config: No Content-Type in response"); | ||
// 11/01/2022 let's do some other checks on the event stream text | ||
let bMessageCanBeHandled = false; | ||
if (result.includes("</EventNotificationAlert>")) { | ||
// Is the XML | ||
bMessageCanBeHandled = true; | ||
} else if (result.includes("}")) { | ||
// Should be the JSON | ||
bMessageCanBeHandled = true; | ||
} | ||
} | ||
if (bMessageCanBeHandled) { | ||
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 { | ||
await handleChunk(result); | ||
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) { | ||
if (node.debug) RED.log.info("AXPro-config: Error handleChunk " + error.message || ""); | ||
} | ||
result = ""; | ||
} | ||
} | ||
}); | ||
oReadable.on('end', function () { | ||
// For some reason, some NVRs do end the stream. I must restart it. | ||
if (node.debug) RED.log.info("AXPro-config: streamPipeline: STREAMING HAS ENDED."); | ||
startAlarmStream(); | ||
}); | ||
}); | ||
oReadable.on('error', function (error) { | ||
if (node.debug) RED.log.error("AXPro-config: streamPipeline: " + (error.message || " unknown error")); | ||
}); | ||
part.on('data', (data) => { | ||
try { | ||
node.resetHeartBeatTimer(); | ||
partData.push(data); // Aggiungi i chunk di dati alla parte | ||
} catch (error) { | ||
} | ||
}); | ||
//await streamPipeline(response.body, readStream); | ||
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' || '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) => { | ||
console.error('Error in Dicer:', err); | ||
}); | ||
// Pipa lo stream multipart in Dicer | ||
res.body.pipe(dicer); | ||
} else { | ||
//throw new Error('Unsupported Content-Type'); | ||
} | ||
} catch (error) { | ||
if (node.debug) RED.log.error("AXPro-config: streamPipeline: Please be sure to have the latest Node.JS version installed: " + (error.message || " unknown 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")); | ||
} | ||
} | ||
@@ -401,3 +382,43 @@ | ||
//#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 " + JSON.stringify(oXML)); | ||
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 | ||
@@ -404,0 +425,0 @@ // Wrapping the async function, for peace of mind |
@@ -234,3 +234,2 @@ | ||
} | ||
if (contentType.includes('multipart')) { | ||
@@ -252,13 +251,16 @@ const boundary = extractBoundary(contentType); | ||
part.on('header', (header) => { | ||
//console.log('Part headers:', header); | ||
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 | ||
try { | ||
//console.log('Part headers:', header); | ||
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) { | ||
} | ||
@@ -268,29 +270,40 @@ }); | ||
part.on('data', (data) => { | ||
node.resetHeartBeatTimer(); | ||
partData.push(data); // Aggiungi i chunk di dati alla parte | ||
try { | ||
node.resetHeartBeatTimer(); | ||
partData.push(data); // Aggiungi i chunk di dati alla parte | ||
} catch (error) { | ||
} | ||
}); | ||
part.on('end', () => { | ||
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' || 'png': | ||
//const filename = generateFilename(extension); | ||
//saveFile(fullData, filename); // Salva l'immagine su disco | ||
handleIMG(fullData, extension); | ||
break; | ||
default: | ||
break; | ||
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' || '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.'); | ||
//console.log('Finished parsing multipart stream.'); | ||
}); | ||
@@ -297,0 +310,0 @@ |
{ | ||
"name": "node-red-contrib-hikvision-ultimate", | ||
"version": "1.2.0", | ||
"version": "1.2.1", | ||
"description": "A native set of nodes for Hikvision (and compatible) Cameras, Alarms, Radars, NVR, Doorbells, etc.", | ||
@@ -5,0 +5,0 @@ "author": "Supergiovane (https://github.com/Supergiovane)", |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
3402566
5430