node-red-contrib-hikvision-ultimate
Version:
A native set of nodes for Hikvision (and compatible) Cameras, Alarms, Radars, NVR, Doorbells, etc.
520 lines (450 loc) • 28.7 kB
JavaScript
module.exports = (RED) => {
const { createHttpClient } = require('./utils/httpClient');
// const AbortController = require('abort-controller');
const { XMLParser } = require("fast-xml-parser");
const readableStr = require('stream').Readable;
const https = require('https');
const http = require('http');
function Speakerconfig(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.errorDescription = ""; // Contains the error description in case of connection error.
node.authentication = config.authentication || "digest";
node.deviceinfo = config.deviceinfo || {};
var oReadable = new readableStr();
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, // 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 Speaker
RED.httpAdmin.get("/hikvisionUltimateGetInfoSpeaker", RED.auth.needsPermission('Speakerconfig.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 : 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 {
const result = parser.parse(body);
res.json(result);
} catch (parseError) {
RED.log.error("Errore hikvisionUltimateGetInfoSpeaker parser: " + parseError.message);
res.json({ error: parseError.message });
}
return;
} catch (error) {
RED.log.error("Errore hikvisionUltimateGetInfoCam " + error.message);
res.json(error);
}
})();
} catch (err) {
res.json(err);
}
});
// 17/94/2024 Get the files
RED.httpAdmin.get("/hikvisionUltimateGetSpeakerFiles", RED.auth.needsPermission('Speakerconfig.read'), function (req, res) {
const configNode = RED.nodes.getNode(req.query.nodeID);
if (!configNode) {
res.status(400).json({ error: "Speaker configuration non trovata. Salva e ridistribuisci il flow, poi riprova." });
return;
}
const credentials = configNode.credentials || {};
const username = credentials.user;
let passwordToUse = credentials.password;
if (!passwordToUse || passwordToUse === "__PWRD__") {
res.json({ error: "Password non disponibile. Salva e ridistribuisci il config node Speaker, poi riapri l’editor." });
return;
}
const clientInfo = buildClient(configNode.authentication, username, 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: configNode.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/AccessControl/EventCardLinkageCfg/CustomAudio?format=json", opt);
const resInfo = await clientInfo.fetch(configNode.protocol + "://" + configNode.host + "/ISAPI/AccessControl/EventCardLinkageCfg/CustomAudio?format=json", opt);
const body = await resInfo.json();
res.json(body.CustomAudioInfoList);
return;
} catch (error) {
RED.log.error("Errore hikvisionUltimateGetInfoSpeaker " + error.message);
res.json(error);
}
})();
} catch (err) {
res.json(err);
}
});
// 17/94/2024 Get the files
RED.httpAdmin.get("/hikvisionUltimateSpeakerTest", RED.auth.needsPermission('Speakerconfig.read'), function (req, res) {
const configNode = RED.nodes.getNode(req.query.nodeID);
if (!configNode) {
res.json({ error: "Speaker configuration non trovata. Salva e ridistribuisci il flow, poi riprova." });
return;
}
const credentials = configNode.credentials || {};
const username = credentials.user;
let passwordToUse = credentials.password;
if (!passwordToUse || passwordToUse === "__PWRD__") {
res.status(400).json({ error: "Password non disponibile. Salva e ridistribuisci il config node Speaker, poi riapri l’editor." });
return;
}
const customAudioID = req.query.customAudioID;
if (!customAudioID) {
res.status(400).json({ error: "customAudioID mancante" });
return;
}
const action = (req.query.action || "play").toLowerCase();
const volume = Math.min(100, Math.max(1, parseInt(req.query.volume, 10) || parseInt(configNode.volume, 10) || 2));
const clientInfo = buildClient(configNode.authentication, username, passwordToUse);
var opt = {
// These properties are part of the Fetch Standard
method: "PUT",
headers: { 'Content-Type': 'application/json' }, // request headers. format is the identical to that accepted by the Headers constructor (see below)
body: JSON.stringify({ "audioOutID": [1] }), // 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: configNode.protocol === "https" ? customHttpsAgent : customHttpAgent // http(s).Agent instance or function that returns an instance (see below)
};
try {
(async () => {
try {
const baseUrl = configNode.protocol + "://" + configNode.host;
if (action === "stop") {
const stopResponse = await clientInfo.fetch(baseUrl + "/ISAPI/AccessControl/EventCardLinkageCfg/CustomAudio/" + customAudioID + "/stop?format=json", opt);
if (!stopResponse.ok) {
throw new Error(stopResponse.statusText || "Stop request failed");
}
await stopResponse.json();
res.json({ stopped: true });
return;
} else {
try {
await clientInfo.fetch(baseUrl + "/ISAPI/AccessControl/EventCardLinkageCfg/CustomAudio/" + customAudioID + "/stop?format=json", opt);
} catch (ignore) {
// Ignora eventuali errori nello stop preventivo
}
const playResponse = await clientInfo.fetch(baseUrl + "/ISAPI/Event/triggers/notifications/AudioAlarm/AudioOut/1/PlayCustomAudioFile?format=json&customAudioID=" + customAudioID + "&audioVolume=" + volume + "&loopPlaybackTimes=1", opt);
if (!playResponse.ok) {
throw new Error(playResponse.statusText || "Play request failed");
}
await playResponse.json();
res.json({ playing: true });
return;
}
} catch (error) {
RED.log.error("Errore hikvisionUltimateGetInfoSpeaker " + error.message);
res.status(502).json({ error: error.message || "Errore durante la richiesta al dispositivo" });
}
})();
} catch (err) {
res.status(500).json({ error: err.message || "Errore interno" });
}
});
// PLAY THE FILE ALOUD VIA THE SPEAKER
node.playAloud = async function (_customAudioID, _volume) {
var clientInfo;
clientInfo = buildClient(node.authentication, node.credentials.user, node.credentials.password);
var opt = {
// These properties are part of the Fetch Standard
method: "PUT",
headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below)
body: JSON.stringify({ "audioOutID": [1] }), // request body.
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: node.protocol === "https" ? customHttpsAgent : customHttpAgent // http(s).Agent instance or function that returns an instance (see below)
};
try {
// STOP PLAYING PREVIOUS FILE, IF ANY
const resInfoStop = await clientInfo.fetch(node.protocol + "://" + node.host + "/ISAPI/AccessControl/EventCardLinkageCfg/CustomAudio/" + _customAudioID + "/stop?format=json", opt);
// PLAY THE FILE
const resInfo = await clientInfo.fetch(node.protocol + "://" + node.host + "/ISAPI/Event/triggers/notifications/AudioAlarm/AudioOut/1/PlayCustomAudioFile?format=json&customAudioID=" + _customAudioID + "&audioVolume=" + _volume + "&loopPlaybackTimes=1", opt);
const body = await resInfo.json();
if (body.statusCode === 1) {
return true;
} else {
return false;
}
} catch (error) {
RED.log.error("Errore playAloud Stop " + error.message);
return false;
}
};
node.stopFile = async function (_customAudioID) {
var jParams = node;
var clientInfo;
clientInfo = buildClient(jParams.authentication, jParams.credentials.user, jParams.credentials.password);
var opt = {
// These properties are part of the Fetch Standard
method: "PUT",
headers: {}, // request headers. format is the identical to that accepted by the Headers constructor (see below)
body: JSON.stringify({ "audioOutID": [1] }), // request body.
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 : customHttpAgent // http(s).Agent instance or function that returns an instance (see below)
};
// STOP PLAYING PREVIOUS FILE
try {
// const resInfo = await clientInfo.fetch(jParams.protocol + "://" + jParams.host + ":" + jParams.port + "/ISAPI/AccessControl/EventCardLinkageCfg/CustomAudio?format=json", opt);
const resInfo = await clientInfo.fetch(jParams.protocol + "://" + jParams.host + "/ISAPI/AccessControl/EventCardLinkageCfg/CustomAudio/" + _customAudioID + "/stop?format=json", opt);
const body = await resInfo.json();
if (body.statusCode === 1) {
return true;
} else {
return false;
}
} catch (error) {
RED.log.error("Errore stopFile Stop " + error.message);
return false;
}
}
//#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
let jSonStatus = JSON.parse(result);
if (node.debug) RED.log.info("Speaker-config: handleChunk: " + result);
node.nodeClients.forEach(oClient => {
oClient.sendPayload(jSonStatus);
});
} catch (error) {
if (node.debug) RED.log.error("Speaker-config: readStream error: " + (error.message || " unknown error"));
node.errorDescription = "readStream error " + (error.message || " unknown error");
throw (error);
}
}
// ###################################
//#endregion
//#region CallStatus
async function queryCallStatus() {
var clientCallStatus;
clientCallStatus = buildClient(node.authentication, node.credentials.user, node.credentials.password);
controller = new globalThis.AbortController(); // For aborting the stream request
var 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: 4000, // 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
};
try {
const response = await clientCallStatus.fetch(node.protocol + "://" + node.host + "/ISAPI/System/deviceInfo", optionsAlarmStream);
//const response = await clientCallStatus.fetch(node.protocol + "://" + node.host + "/ISAPI/VideoIntercom/callStatus?format=json", optionsAlarmStream);
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" });
// if (node.debug) RED.log.error("BANANA Error response " + response.statusText);
node.errorDescription = "StatusResponse problem " + (response.statusText || " unknown status response code");
throw new Error("StatusResponse " + (response.statusText || " unknown response code"));
}
if (response.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;
try {
if (node.debug) RED.log.info("Speaker-config: before Pipelining...");
if (oReadable !== null) oReadable.removeAllListeners() // 09/01/2023
oReadable = readableStr.from(response.body, { encoding: 'utf8' });
oReadable.on('data', async (chunk) => {
if (node.debug) RED.log.info("Speaker-config: oReadable.on('data') " + chunk);
if (chunk.includes("}")) {
try {
await handleChunk(chunk);
} catch (error) {
throw (error);
}
}
});
oReadable.on('end', function () {
if (node.debug) RED.log.info("Speaker-config: queryCallStatus: STREAMING HAS ENDED.");
if (node.timerCheckRing !== null) clearTimeout(node.timerCheckRing);
node.timerCheckRing = setTimeout(queryCallStatus, 2000);
});
oReadable.on('error', function (error) {
if (node.debug) RED.log.error("Speaker-config: queryCallStatus: " + (error.message || " unknown error"));
if (node.timerCheckRing !== null) clearTimeout(node.timerCheckRing);
node.timerCheckRing = setTimeout(queryCallStatus, 10000);
});
//await queryCallStatus(response.body, readStream);
} catch (error) {
if (node.debug) RED.log.error("Speaker-config: queryCallStatus: " + (error.message || " unknown error"));
// Signal disconnection
// ######################
if (node.isConnected) {
if (node.errorDescription === "") node.errorDescription = "queryCallStatus error " + (error.message || " unknown error");
node.nodeClients.forEach(oClient => {
oClient.sendPayload({ topic: oClient.topic || "", errorDescription: node.errorDescription, payload: true });
});
node.setAllClientsStatus({ fill: "red", shape: "ring", text: "queryCallStatus error...Retry... " + node.errorDescription });
node.isConnected = false;
}
// ######################
if (node.timerCheckRing !== null) clearTimeout(node.timerCheckRing);
node.timerCheckRing = setTimeout(queryCallStatus, 10000);
}
}
} 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("Speaker-config: FETCH ERROR: " + (error.message || " unknown error"));
// Signal disconnection
// ######################
if (node.isConnected) {
if (node.errorDescription === "") node.errorDescription = "Timeout waiting response " + (error.message || " unknown error"); // In case of timeout
node.nodeClients.forEach(oClient => {
oClient.sendPayload({ topic: oClient.topic || "", errorDescription: node.errorDescription, payload: true });
});
node.setAllClientsStatus({ fill: "red", shape: "ring", text: "Timeout waiting response...Retry... " + node.errorDescription });
node.isConnected = false;
}
// ######################
if (node.timerCheckRing !== null) clearTimeout(node.timerCheckRing);
node.timerCheckRing = setTimeout(queryCallStatus, 10000);
};
};
if (node.timerCheckRing !== null) clearTimeout(node.timerCheckRing);
node.timerCheckRing = setTimeout(queryCallStatus, 6000); // First connection.
//#endregion
//#region "Base FUNCTIONS"
node.on('close', function (removed, done) {
if (controller !== null) {
try {
controller.abort();
} catch (error) { }
}
if (node.timerCheckRing !== null) clearTimeout(node.timerCheckRing);
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("Speaker-config", Speakerconfig, {
credentials: {
user: { type: "text" },
password: { type: "password" }
}
});
}