UNPKG

node-red-contrib-hikvision-ultimate

Version:

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

944 lines (844 loc) 51.4 kB
module.exports = (RED) => { const { createHttpClient } = require('./utils/httpClient'); const hikDiscovery = require('./utils/hikDiscovery'); const { createAlertStreamParser } = require('./utils/alertStreamParser'); const NONCE_COUNT = '00000001'; const { XMLParser } = require("fast-xml-parser"); const https = require('https'); const http = require('http'); const crypto = require('crypto'); // Helper function to parse the WWW-Authenticate header function parseDigestHeader(header) { if (!header) return {}; const trimmed = header.trim(); const paramsPart = trimmed.replace(/^digest\s+/i, ''); const challenge = {}; const regex = /([a-z0-9_-]+)=("(?:[^"\\]|\\.)*"|[^,]*)/ig; let match; while ((match = regex.exec(paramsPart)) !== null) { let value = match[2].trim(); if (value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1); } challenge[match[1]] = value; } return challenge; } function pickDigestQop(qop) { if (!qop) return null; const qops = qop.split(",").map(item => item.trim().toLowerCase()).filter(Boolean); if (qops.includes("auth")) return "auth"; return qops[0] || null; } function normalizeDiscoveredDevice(device) { const normalized = Object.assign({}, device); const host = normalized.IPv4Address || normalized.ipv4Address || normalized.IPAddress || normalized.ipAddress || ""; const port = normalized.HttpPort || normalized.httpPort || normalized.Port || normalized.port || 80; const description = normalized.DeviceDescription || normalized.deviceDescription || normalized.DeviceName || normalized.deviceName || ""; const model = normalized.Model || normalized.model || ""; const firmware = normalized.SoftwareVersion || normalized.softwareVersion || normalized.FirmwareVersion || normalized.firmwareVersion || ""; const serial = normalized.DeviceSN || normalized.SerialNO || normalized.SerialNumber || normalized.serialNumber || ""; normalized.host = host.toString(); normalized.port = port.toString(); normalized.name = description ? description.toString() : (model ? model.toString() : normalized.host); normalized.model = model ? model.toString() : ""; normalized.firmware = firmware ? firmware.toString() : ""; normalized.serialNumber = serial ? serial.toString() : ""; normalized.label = [ normalized.name, normalized.host ? "(" + normalized.host + ":" + normalized.port + ")" : "", normalized.model, normalized.firmware ].filter(Boolean).join(" "); return normalized; } RED.httpAdmin.get("/hikvisionUltimateDiscoverOnlineDevices", RED.auth.needsPermission('Hikvisionconfig.read'), async function (req, res) { try { const timeoutMs = Number(req.query.timeoutMs); const devices = await hikDiscovery.Discover(Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 2500); const normalizedDevices = devices .map(normalizeDiscoveredDevice) .filter(device => device.host) .sort((a, b) => a.host.localeCompare(b.host, undefined, { numeric: true })); res.json(normalizedDevices); } catch (error) { RED.log.error("Hikvision-config: discovery failed: " + (error.message || " unknown error")); res.json([]); } }); function Hikvisionconfig(config) { RED.nodes.createNode(this, config) var node = this node.port = config.port || 80; node.debug = (config.debuglevel === undefined || config.debuglevel === "no") ? false : true; node.name = config.name || config.host || ""; node.host = config.host;// + ":" + node.port; node.protocol = config.protocol || "http"; node.nodeClients = []; // Stores the registered clients node.isConnected = true; // Assumes, that is already connected. node.isClosing = false; node.timerCheckHeartBeat = null; node.errorDescription = ""; // Contains the error description in case of connection error. node.authentication = config.authentication || "digest"; node.deviceinfo = config.deviceinfo || {}; const configuredStreamTimeout = config.streamtimeout; const streamTimeoutMinutes = configuredStreamTimeout === undefined || configuredStreamTimeout === null || configuredStreamTimeout === "" ? 0 : Number(configuredStreamTimeout); const safeStreamTimeoutMinutes = Number.isFinite(streamTimeoutMinutes) && streamTimeoutMinutes > 0 ? streamTimeoutMinutes : 0; node.streamtimeout = safeStreamTimeoutMinutes; node.streamTimeoutMs = safeStreamTimeoutMinutes > 0 ? safeStreamTimeoutMinutes * 60000 : 0; node.heartBeatTimerDisconnectionCounter = 0; node.heartbeattimerdisconnectionlimit = config.heartbeattimerdisconnectionlimit || 2; var controller = null; // AbortController let transferProtocol = null; // can be http or https class (per-node) let streamRequest = null; // Per-node alert stream request let reconnectTimer = null; let reconnectReason = ""; const intentionallyClosedRequests = new WeakSet(); let reconnectDelayMs = 2000; const MAX_ABSOLUTE_EVENT_AGE_MS = 2 * 60 * 1000; // Drop events older than 2 minutes const MAX_RELATIVE_BATCH_EVENT_AGE_MS = 20 * 1000; // In large recovered batches, keep only near-tail events const MAX_FORWARD_EVENTS_PER_BATCH = 128; // Safety cap to avoid flooding downstream nodes const RECENT_EVENT_DEDUP_TTL_MS = 30 * 1000; const recentEventKeys = new Map(); node.onLineHikvisionDevicesDiscoverList = null; // 12/01/2023 holds the online devices, used in the HTML if (node.debug) RED.log.info("Hikvision-config: initialized " + (node.name || node.host) + " (" + node.protocol + "://" + node.host + ":" + node.port + ")"); 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, keepAlive: true, // Mantiene vive le connessioni maxSockets: 10, // Numero massimo di connessioni simultanee }); const customHttpAgent = new http.Agent({ rejectUnauthorized: false, keepAlive: true, // Mantiene vive le connessioni maxSockets: 10, // Numero massimo di connessioni simultanee }); // 14/12/2020 Get the infos from the camera RED.httpAdmin.get("/hikvisionUltimateGetInfoCam", RED.auth.needsPermission('Hikvisionconfig.read'), function (req, res) { var jParams = JSON.parse(decodeURIComponent(req.query.params));// Retrieve node.id of the config node. var _nodeServer = null; var clientInfo; const requestedAuth = (jParams.authentication || "digest").toLowerCase(); const authentication = requestedAuth === "basic" ? "basic" : "digest"; 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 = createHttpClient({ username: jParams.user, password: passwordToUse, authentication, logger: node.debug ? RED.log : undefined }); 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: 15000, // 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, // http(s).Agent instance or function that returns an instance (see below) agent: jParams.protocol === "https" ? customHttpsAgent : customHttpAgent,// 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 hikvisionUltimateGetInfoCam " + error.message); res.json(error); } })(); } catch (err) { res.json(err); } }); // This function starts the heartbeat timer, to detect the disconnection from the server node.resetHeartBeatTimer = () => { if (node.isClosing) return; // Reset node.timerCheckHeartBeat if (node.timerCheckHeartBeat !== null) clearTimeout(node.timerCheckHeartBeat); node.timerCheckHeartBeat = setTimeout(() => { if (node.isClosing) return; 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) { } } try { startAlarmStream() } catch (error) { } } else { // 28/12/2020 Connection attempt limit reached node.heartBeatTimerDisconnectionCounter = 0; if (node.isConnected) { if (node.errorDescription === "") node.errorDescription = "Timeout waiting 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(() => { if (node.isClosing) return; try { startAlarmStream() } catch (error) { } }, 2000); // Reconnect } }, 40000); } //#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(/^--/, ''); } function clearReconnectTimer() { if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; } } function scheduleReconnect(reason, delayMs) { if (node.isClosing) return; if (reconnectTimer !== null) return; reconnectReason = reason || "unknown"; const effectiveDelayMs = delayMs || reconnectDelayMs; reconnectDelayMs = Math.min(reconnectDelayMs * 2, 30000); reconnectTimer = setTimeout(() => { reconnectTimer = null; if (node.isClosing) return; try { if (node.debug) RED.log.warn("Hikvision-config: reconnecting stream (" + reconnectReason + ")"); startAlarmStream(); } catch (error) { if (node.debug) RED.log.error("Hikvision-config: reconnect scheduling failed: " + (error.message || " unknown error")); } }, effectiveDelayMs); } function pruneRecentEventKeys(nowMs) { recentEventKeys.forEach((seenMs, key) => { if ((nowMs - seenMs) > RECENT_EVENT_DEDUP_TTL_MS) recentEventKeys.delete(key); }); } function buildEventDedupKey(eventEntry) { if (!eventEntry || typeof eventEntry !== "object") return ""; const channelID = eventEntry.channelID !== undefined ? eventEntry.channelID : eventEntry.dynChannelID; return [ eventEntry.dateTime || "", eventEntry.eventType || "", eventEntry.eventState || "", channelID !== undefined ? channelID : "", eventEntry.activePostCount !== undefined ? eventEntry.activePostCount : "", eventEntry.targetID || eventEntry.bkgUrl || eventEntry.picName || eventEntry.eventDescription || "" ].map(value => value.toString()).join("|"); } // Function to continue with authenticated request function continueWithAuthenticatedRequest(options) { if (node.debug) RED.log.info('Hikvision-config: Starting authenticated stream...'); try { node.setAllClientsStatus({ fill: 'green', shape: 'dot', text: 'Stream running' }); clearReconnectTimer(); streamRequest = transferProtocol.request(options, continueWithStream); const activeStreamRequest = streamRequest; reconnectDelayMs = 2000; if (node.streamTimeoutMs > 0) { streamRequest.setTimeout(node.streamTimeoutMs, () => { if (node.debug) { const minutes = node.streamtimeout; RED.log.error(`Hikvision-config: Connection timeout after ${minutes} minute${minutes === 1 ? '' : 's'}`); } try { stopStream(); } catch (error) { } scheduleReconnect("request timeout"); }); } streamRequest.on('error', (err) => { if (intentionallyClosedRequests.has(activeStreamRequest)) return; if (node.debug) RED.log.error('Hikvision-config: Stream error: ' + err.message); try { stopStream(); } catch (error) { } scheduleReconnect("request error: " + (err.message || "unknown")); }); streamRequest.on('close', () => { if (node.isClosing || intentionallyClosedRequests.has(activeStreamRequest)) return; scheduleReconnect("request closed"); }); streamRequest.end(); } catch (err) { if (node.debug) RED.log.error('Hikvision-config: continueWithAuthenticatedRequest: Stream error: ' + err.message); scheduleReconnect("request setup exception"); } } // Function to handle the response stream function continueWithStream(res) { controller = new globalThis.AbortController(); // For aborting the stream request try { if (res.statusCode >= 200 && res.statusCode <= 300) { node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Waiting for event." }); } else { node.setAllClientsStatus({ fill: "red", shape: "ring", text: res.statusMessage + ' ' + res.statusCode }); // if (node.debug) RED.log.error("BANANA Error response " + response.statusMessage + ' ' + response.statusCode); node.errorDescription = "StatusResponse problem " + res.statusMessage + ' ' + res.statusCode; stopStream(); throw new Error("StatusResponse " + res.statusMessage + ' ' + res.statusCode); } 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; reconnectDelayMs = 2000; node.resetHeartBeatTimer(); try { const contentType = res.headers['content-type']; if (!contentType) { if (node.debug) RED.log.error("Hikvision-config: No Content-Type in response"); stopStream(); scheduleReconnect("missing content-type"); return; } if (contentType.includes('multipart')) { let boundary = extractBoundary(contentType); if (!boundary) { if (node.debug) RED.log.error("Hikvision-config: Failed to extract boundary from multipart stream"); boundary = "boundary" } const alertStreamParser = createAlertStreamParser({ boundary, onActivity: () => node.resetHeartBeatTimer(), onXml: handleXML, onJson: handleJSON, onImage: handleIMG, onUnsupported: (contentType) => { if (node.debug) RED.log.warn("Hikvision-config: unsupported stream part content type " + contentType); }, onError: (error) => { if (node.debug) RED.log.error("Hikvision-config: alert stream parser error: " + (error.message || " unknown error")); } }); const onRawData = (chunk) => { alertStreamParser.feed(chunk); }; const cleanupRawDataListener = () => { res.removeListener('data', onRawData); }; res.on('data', onRawData); res.once('end', () => { alertStreamParser.flush(); cleanupRawDataListener(); }); res.once('close', () => { alertStreamParser.flush(); cleanupRawDataListener(); }); res.once('error', cleanupRawDataListener); res.once('end', () => { if (node.isClosing) return; if (node.debug) RED.log.warn("Hikvision-config: stream response ended, reconnecting"); stopStream(); scheduleReconnect("response ended"); }); res.once('close', () => { if (node.isClosing) return; if (node.debug) RED.log.warn("Hikvision-config: stream response closed, reconnecting"); stopStream(); scheduleReconnect("response closed"); }); res.once('error', (err) => { if (node.isClosing) return; if (node.debug) RED.log.error("Hikvision-config: stream response error: " + (err.message || " unknown error")); stopStream(); scheduleReconnect("response error"); }); } else if (contentType.includes('application/xml') || contentType.includes('text/xml')) { const alertStreamParser = createAlertStreamParser({ boundary: "boundary", onActivity: () => node.resetHeartBeatTimer(), onXml: handleXML, onJson: handleJSON, onImage: handleIMG, onUnsupported: (partContentType) => { if (node.debug) RED.log.warn("Hikvision-config: unsupported stream part content type " + partContentType); }, onError: (error) => { if (node.debug) RED.log.error("Hikvision-config: alert stream parser error: " + (error.message || " unknown error")); } }); res.on('data', (chunk) => alertStreamParser.feed(chunk)); } else { node.errorDescription = "Unsupported Content-Type: " + contentType; if (node.debug) RED.log.error("Hikvision-config: Unsupported Content-Type " + contentType); stopStream(); scheduleReconnect("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")); stopStream(); scheduleReconnect("stream pipeline exception"); } } catch (error) { // Main Error // Abort request //node.errorDescription = "Fetch error " + JSON.stringify(error, Object.getOwnPropertyNames(error)); node.errorDescription = "Request error " + (error.message || " unknown error"); if (node.debug) RED.log.error("Hikvision-config: REQUEST ERROR: " + (error.message || " unknown error")); scheduleReconnect("response handling error"); }; // ++++++++++++++++++++++++++++++++++++++++++++++++++ } // Function to stop the stream function stopStream() { try { if (streamRequest) { const requestToStop = streamRequest; intentionallyClosedRequests.add(requestToStop); // Keep an error listener attached while destroying the request. // Node 24 can still emit ECONNRESET/socket hang up during TLS teardown. requestToStop.on('error', () => { }); requestToStop.destroy(); streamRequest = null; } } catch (err) { if (node.debug) RED.log.error("Hikvision-config: stopStream error " + (err.message || " unknown error")); } } // ------------------------------------------------ async function startAlarmStream() { if (node.isClosing) return; if (node.debug) RED.log.info("Hikvision-config: opening alert stream for " + (node.name || node.host) + " using " + node.authentication); stopStream(); clearReconnectTimer(); node.resetHeartBeatTimer(); // First thing, start the heartbeat timer. node.setAllClientsStatus({ fill: "grey", shape: "ring", text: "Connecting..." }); // Make an initial request to get authentication challenge if (node.protocol === "http") transferProtocol = require('http'); if (node.protocol === "https") transferProtocol = require('https'); // Reuse one socket for the digest 401 challenge and the authenticated stream, like curl does. const transferProtocolAgent = new transferProtocol.Agent({ rejectUnauthorized: false, keepAlive: true, maxSockets: 1 }); const streamHeaders = { "Accept": "*/*", "Cache-Control": "no-cache", "User-Agent": "node-red-contrib-hikvision-ultimate" }; if (node.authentication === 'basic') { const options = { hostname: node.host, port: node.port, path: '/ISAPI/Event/notification/alertStream', method: 'GET', headers: { ...streamHeaders, 'Authorization': 'Basic ' + Buffer.from(`${node.credentials.user}:${node.credentials.password}`).toString('base64') }, protocol: node.protocol + ":", agent: transferProtocolAgent }; continueWithAuthenticatedRequest(options); return; } // Digest authentication logic const initialOptions = { hostname: node.host, port: node.port, path: '/ISAPI/Event/notification/alertStream', method: 'GET', headers: streamHeaders, agent: transferProtocolAgent, protocol: node.protocol + ":" }; try { const initialReq = transferProtocol.request(initialOptions, (response) => { // Handle 401 response with WWW-Authenticate header if (response.statusCode === 401) { // Extract authenticate header const authHeader = response.headers['www-authenticate']; if (!authHeader) { if (node.debug) RED.log.error('Hikvision-config: Missing WWW-Authenticate header in response'); response.resume(); scheduleReconnect("missing digest challenge"); return; } if (!authHeader.startsWith('Digest ')) { if (node.debug) RED.log.error('Hikvision-config: Server does not support Digest authentication'); response.resume(); scheduleReconnect("digest not supported by server"); return; } // Parse the challenge const challenge = parseDigestHeader(authHeader); const qop = pickDigestQop(challenge.qop); const algorithm = (challenge.algorithm || 'MD5').toUpperCase(); const nonceCount = NONCE_COUNT; // Create digest authentication header const ha1 = crypto.createHash('md5').update(`${node.credentials.user}:${challenge.realm}:${node.credentials.password}`).digest('hex'); const ha2 = crypto.createHash('md5').update(`${'GET'}:${'/ISAPI/Event/notification/alertStream'}`).digest('hex'); const cnonce = crypto.randomBytes(8).toString('hex'); let digestInput = `${ha1}:${challenge.nonce}:${ha2}`; if (qop) digestInput = `${ha1}:${challenge.nonce}:${nonceCount}:${cnonce}:${qop}:${ha2}`; const digestResponse = crypto.createHash('md5').update(digestInput).digest('hex'); const authParts = [ `username="${node.credentials.user}"`, `realm="${challenge.realm || ''}"`, `nonce="${challenge.nonce || ''}"`, `uri="${'/ISAPI/Event/notification/alertStream'}"`, `response="${digestResponse}"`, `algorithm="${algorithm}"` ]; if (challenge.opaque) authParts.push(`opaque="${challenge.opaque}"`); if (qop) { authParts.push(`qop=${qop}`); authParts.push(`nc=${nonceCount}`); authParts.push(`cnonce="${cnonce}"`); } const authString = `Digest ${authParts.join(', ')}`; // Now make the authenticated request const options = { hostname: node.host, port: node.port, path: '/ISAPI/Event/notification/alertStream', method: 'GET', headers: { ...streamHeaders, 'Authorization': authString }, agent: transferProtocolAgent, protocol: node.protocol + ":" }; response.once('end', () => { continueWithAuthenticatedRequest(options); }); response.once('error', (err) => { if (node.debug) RED.log.error('Hikvision-config: Initial digest response error: ' + (err.message || " unknown error")); scheduleReconnect("initial digest response error"); }); // Drain the 401 body before opening the authenticated long-lived stream, mirroring curl's digest flow. response.resume(); } else { // If no auth needed (unlikely but possible) continueWithStream(response); } }); streamRequest = initialReq; initialReq.on('error', (err) => { if (node.isClosing || intentionallyClosedRequests.has(initialReq)) return; if (node.debug) RED.log.error('Hikvision-config: Initial request error: ' + err.message); scheduleReconnect("initial request error: " + (err.message || "unknown")); }); initialReq.setTimeout(15000, () => { if (node.debug) RED.log.error('Hikvision-config: Initial request timeout'); stopStream(); scheduleReconnect("initial request timeout"); }); initialReq.end(); } catch (error) { if (node.debug) RED.log.error('Hikvision-config: StartAlarmStream: ' + (error.message || " unknown error")); scheduleReconnect("start stream exception"); } }; //#region "HANDLE STREAM MESSAGE" // Handle the complete stream message // ################################### async function handleIMG(result, extension) { try { if (node.debug) RED.log.info("Hikvision-config: image part received (" + result.length + " bytes, " + extension + ")"); node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", payload: result, type: 'img', extension: extension }); }); } catch (error) { if (node.debug) RED.log.error("Hikvision-config: image part handling error: " + (error.message || " unknown error")); } } async function handleXML(result) { try { const isHeartbeatEvent = (eventEntry) => { if (!eventEntry || typeof eventEntry !== "object") return false; const eventType = eventEntry.eventType !== undefined ? eventEntry.eventType.toString().toLowerCase() : ""; const eventState = eventEntry.eventState !== undefined ? eventEntry.eventState.toString().toLowerCase() : ""; const activePostCount = Number(eventEntry.activePostCount); return eventType === "videoloss" && eventState === "inactive" && (activePostCount === 0 || activePostCount === 1); }; const summarizeEvent = (eventEntry) => { if (!eventEntry || typeof eventEntry !== "object") return "unknown event"; const eventType = eventEntry.eventType || "unknown"; const eventState = eventEntry.eventState || "unknown"; const channelID = eventEntry.channelID || eventEntry.dynChannelID || "unknown"; const dateTime = eventEntry.dateTime || "no timestamp"; return eventType + " " + eventState + " channel " + channelID + " at " + dateTime; }; const parseEventDateMs = (eventEntry) => { if (!eventEntry || typeof eventEntry !== "object") return null; if (!eventEntry.dateTime) return null; const parsed = Date.parse(eventEntry.dateTime.toString()); return Number.isFinite(parsed) ? parsed : null; }; const parser = new XMLParser(); const parsedXML = parser.parse(Buffer.isBuffer(result) ? result.toString("utf8") : result.toString()); // Some devices/firmwares occasionally deliver multiple EventNotificationAlert // entries in a single parsed object/array: normalize to a flat events list. const eventsToForward = []; if (parsedXML !== null && parsedXML !== undefined) { const root = parsedXML.EventNotificationAlert !== undefined ? parsedXML.EventNotificationAlert : parsedXML; if (Array.isArray(root)) { root.forEach((ev) => { if (ev && typeof ev === "object") eventsToForward.push(ev); }); } else if (root && typeof root === "object") { eventsToForward.push(root); } } const normalizedEvents = []; eventsToForward.forEach((eventEntry) => { if (eventEntry.channelID === undefined && eventEntry.dynChannelID !== undefined) { // API Version 1.0 eventEntry.channelID = eventEntry.dynChannelID; } normalizedEvents.push(eventEntry); }); if (node.debug) { const interestingEvents = normalizedEvents.filter(eventEntry => !isHeartbeatEvent(eventEntry)); if (interestingEvents.length > 0) { interestingEvents.forEach(eventEntry => { RED.log.info("Hikvision-config: event received " + summarizeEvent(eventEntry)); }); } else if (normalizedEvents.length > 0) { RED.log.debug("Hikvision-config: heartbeat event received"); } } let eventsAfterStaleFilter = normalizedEvents; let droppedStaleEvents = 0; let droppedOverflowEvents = 0; if (normalizedEvents.length > 1) { const nowMs = Date.now(); let newestEventMs = null; normalizedEvents.forEach((eventEntry) => { const eventMs = parseEventDateMs(eventEntry); if (eventMs !== null && (newestEventMs === null || eventMs > newestEventMs)) newestEventMs = eventMs; }); eventsAfterStaleFilter = normalizedEvents.filter((eventEntry) => { const eventMs = parseEventDateMs(eventEntry); if (eventMs === null) return true; if (nowMs >= eventMs && (nowMs - eventMs) > MAX_ABSOLUTE_EVENT_AGE_MS) { droppedStaleEvents += 1; return false; } if (newestEventMs !== null && (newestEventMs - eventMs) > MAX_RELATIVE_BATCH_EVENT_AGE_MS) { droppedStaleEvents += 1; return false; } return true; }); } if (eventsAfterStaleFilter.length > MAX_FORWARD_EVENTS_PER_BATCH) { droppedOverflowEvents = eventsAfterStaleFilter.length - MAX_FORWARD_EVENTS_PER_BATCH; eventsAfterStaleFilter = eventsAfterStaleFilter.slice(-MAX_FORWARD_EVENTS_PER_BATCH); } const nowForDedup = Date.now(); let droppedDuplicateEvents = 0; pruneRecentEventKeys(nowForDedup); eventsAfterStaleFilter = eventsAfterStaleFilter.filter((eventEntry) => { const dedupKey = buildEventDedupKey(eventEntry); if (!dedupKey) return true; if (recentEventKeys.has(dedupKey)) { droppedDuplicateEvents += 1; return false; } recentEventKeys.set(dedupKey, nowForDedup); return true; }); if (node.debug && normalizedEvents.length > 1) { RED.log.warn("Hikvision-config: XML batch with " + normalizedEvents.length + " events received"); if (droppedStaleEvents > 0) RED.log.warn("Hikvision-config: dropped " + droppedStaleEvents + " stale event(s) from recovered batch"); if (droppedOverflowEvents > 0) RED.log.warn("Hikvision-config: dropped " + droppedOverflowEvents + " overflow event(s) from recovered batch"); if (droppedDuplicateEvents > 0) RED.log.warn("Hikvision-config: dropped " + droppedDuplicateEvents + " duplicate event(s) from stream parser fallback"); RED.log.warn("Hikvision-config: forwarding " + eventsAfterStaleFilter.length + " event(s) after stale/batch filtering"); } eventsAfterStaleFilter.forEach((eventEntry) => { node.nodeClients.forEach(oClient => { oClient.sendPayload({ topic: oClient.topic || "", payload: eventEntry, type: 'event' }); }); }); } catch (error) { if (node.debug) RED.log.error("Hikvision-config: XML parse/handling error: " + (error.message || " unknown error")); } } 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("Hikvision-config: JSON parse/handling error: " + (error.message || " unknown error")); } } // ################################### //#endregion setTimeout(() => { if (node.isClosing) return; try { startAlarmStream(); } catch (error) { } }, 10000); // First connection. //#endregion //#region GENERIC GET OT PUT CALL // Function to get or post generic data on camera node.request = async function (_callerNode, _method, _URL, _body, _fromXMLNode) { if (_fromXMLNode === undefined) _fromXMLNode = false; // 07/10/2021 Does the request come from an XML node? var clientGenericRequest; const authMode = (node.authentication || "digest").toLowerCase(); clientGenericRequest = createHttpClient({ username: node.credentials.user, password: node.credentials.password, authentication: authMode === "basic" ? "basic" : "digest", logger: node.debug ? RED.log : undefined }); var reqController = new globalThis.AbortController(); // For aborting the stream request var options = { // These properties are part of the Fetch Standard method: _method.toString().toUpperCase(), headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below) body: _body, // 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: reqController.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: 15000, // 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 : customHttpAgent // http(s).Agent instance or function that returns an instance (see below), }; try { if (!_URL.startsWith("/")) _URL = "/" + _URL; // 07/10/2021 Strip the body in case of GET and HEAD (otherwise, Fetch thwors an error) if (options.method.toString().toUpperCase() === "GET" || options.method.toString().toUpperCase() === "HEAD") delete (options.body) const response = await clientGenericRequest.fetch(node.protocol + "://" + node.host + _URL, options); if (response.status >= 200 && response.status < 300) { //node.setAllClientsStatus({ fill: "green", shape: "ring", text: "Connected." }); } else { // _callerNode.setNodeStatus({ fill: "red", shape: "ring", text: response.statusMessage + ' ' + response.statusCode || " unknown response code" }); // // 07/04/2021 Wrong URL? Send this and is captured by picture node to try another url // _callerNode.sendPayload({ topic: _callerNode.topic || "", payload: false, wrongResponse: response.status }); // throw new Error("Error response: " + response.statusMessage + ' ' + response.statusCode || " unknown response code"); } if (response.ok) { var body = ""; // 07/10/2021 If the request comes from XML node, return any response if (_fromXMLNode) { body = await response.buffer(); // "data:image/png;base64," + //_callerNode.sendPayload({ topic: _callerNode.topic || "", payload: body.toString("base64")}); const parser = new XMLParser(); try { let result = parser.parse(body.toString()); _callerNode.sendPayload({ topic: _callerNode.name || "", payload: result }); } catch (error) { _callerNode.sendPayload({ topic: _callerNode.name || "", payload: body.toString() }); } } else if (_URL.toLowerCase().includes("/ptzctrl/")) {// Based on URL, will return the appropriate encoded body _callerNode.sendPayload({ topic: _callerNode.topic || "", payload: true }); } else if (_URL.toLowerCase().includes("/streaming")) { body = await response.buffer(); // "data:image/png;base64," + //_callerNode.sendPayload({ topic: _callerNode.topic || "", payload: body.toString("base64")}); _callerNode.sendPayload({ topic: _callerNode.topic || "", payload: body }); } } else { if (_fromXMLNode) { // 07/10/2021 If the request comes from XML node, return any response _callerNode.sendPayload({ topic: _callerNode.name || "", wrongResponse: response.status }); } else if (_URL.toLowerCase().includes("/ptzctrl/")) { } else if (_URL.toLowerCase().includes("/streaming/") || _URL.toLowerCase().includes("/streamingproxy/")) { _callerNode.setNodeStatus({ fill: "red", shape: "ring", text: response.statusMessage + ' ' + response.statusCode || " unknown response code" }); // 07/04/2021 Wrong URL? Send this and is captured by picture node to try another url _callerNode.sendPayload({ topic: _callerNode.topic || "", payload: false, wrongResponse: response.status }); } else { _callerNode.setNodeStatus({ fill: "red", shape: "ring", text: response.statusMessage + ' ' + response.statusCode || " unknown response code" }); } throw new Error("Error response: " + response.statusMessage + ' ' + response.statusCode || " unknown response code"); } } catch (err) { //console.log("ORRORE " + err.message); // Main Error _callerNode.setNodeStatus({ fill: "grey", shape: "ring", text: "clientGenericRequest.fetch error: " + err.message }); _callerNode.sendPayload({ topic: _callerNode.topic || "", errorDescription: err.message, payload: true }); if (node.debug) RED.log.error("Hikvision-config: clientGenericRequest.fetch error " + err.message); // Abort request if (reqController !== null) { try { //if (reqController !== null) reqController.abort().then(ok => { }).catch(err => { }); if (reqController !== null) reqController.abort(); } catch (error) { } } } }; //#endregion //#region "FUNCTIONS" node.on('close', function (removed, done) { node.isClosing = true; clearReconnectTimer(); stopStream(); if (controller !== null) { try { controller.abort(); } catch (error) { } } if (node.timerCheckHeartBeat !== null) clearTimeout(node.timerCheckHeartBeat); setTimeout(() => { done(); }, 2000); }); node.addClient = (_Node) => { // Check if node already exists if (node.nodeClien