node-red-contrib-hik-media-buffer 1.1.41 → 1.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -8
- package/hik-media-buffer.js +91 -108
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,19 +11,24 @@ This node only detects **_"FieldDetection"_** and **_"LineDetection"_** alarms b
|
|
|
11
11
|
|
|
12
12
|
To configure the node you need to enter the **_IP, user and password of the NVR_**, you can also choose the **_protocol_** and **_port_** to use.</br>
|
|
13
13
|
You must also enter, by pressing the **_"add"_** button, the **_channel and the correspective IP of the camera_**, finally you must enter the **_password of the cameras_**.</br>
|
|
14
|
+
The node name is the name of the customer.</br>
|
|
14
15
|
|
|
15
16
|
This below is an example of msg output:</br>
|
|
16
17
|
|
|
17
18
|
```javascript
|
|
18
19
|
msg = {
|
|
19
|
-
payload: object
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
payload: object
|
|
21
|
+
tipo_messaggio: "evento" // Type of alarm deteced (event or status)
|
|
22
|
+
nome_cliente: "test" // Customer name (name of the node)
|
|
23
|
+
nome_telecamera: "Ufficio" // Camera name on hiklvision
|
|
24
|
+
ip_telecamera: "192.168.62.9" // IP of the camera
|
|
25
|
+
tipo_evento: "LineDetection" // Type of event deteced
|
|
26
|
+
timestamp_epoch: 1780645403 // Timestamp of the event
|
|
27
|
+
stato_telecamera: "ONLINE" // Status of the camera
|
|
28
|
+
channel: "2" // Channel of the camera
|
|
29
|
+
foto_base64: "/9j/2wCEAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSop..." // Buffer of the image base64
|
|
30
|
+
video_base64: "AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAABtdtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPo..." // Buffer of the image base64
|
|
31
|
+
_msgid: "942a8c0c56860f42"
|
|
27
32
|
};
|
|
28
33
|
```
|
|
29
34
|
## HIK SNAPSHOT NODE
|
package/hik-media-buffer.js
CHANGED
|
@@ -12,21 +12,25 @@ module.exports = function(RED) {
|
|
|
12
12
|
const node = this;
|
|
13
13
|
|
|
14
14
|
node.name = config.name || "TEST";
|
|
15
|
-
node.host = config.host;
|
|
15
|
+
node.host = config.host; // IP dell'NVR centrale
|
|
16
16
|
node.port = config.port || "80";
|
|
17
17
|
node.protocol = config.protocol || "http";
|
|
18
18
|
node.user = config.user;
|
|
19
19
|
node.pass = config.pass;
|
|
20
|
-
node.camPass = config.camPass || config.pass;
|
|
21
|
-
node.cameras = config.cameras || [];
|
|
22
20
|
|
|
23
21
|
let streamRequest = null;
|
|
24
22
|
let isClosing = false;
|
|
25
23
|
let lastTriggerTime = {};
|
|
26
24
|
let nvrOnline = true;
|
|
27
|
-
|
|
25
|
+
|
|
26
|
+
// Strutture dinamiche per monitorare canali e stati senza liste fisse
|
|
27
|
+
let statoCanale = {};
|
|
28
|
+
let ultimoAllarmeTempo = {};
|
|
28
29
|
|
|
29
30
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
31
|
+
|
|
32
|
+
// La tua EventList originale per l'alertStream
|
|
33
|
+
const EventList = ["FieldDetection", "LineDetection"];
|
|
30
34
|
|
|
31
35
|
// CARTELLA TEMPORANEA
|
|
32
36
|
const baseStorage = path.join(os.tmpdir(), "hik_temp_media");
|
|
@@ -36,66 +40,81 @@ module.exports = function(RED) {
|
|
|
36
40
|
|
|
37
41
|
function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
|
|
38
42
|
|
|
39
|
-
// --- PRENDE IL NOME
|
|
40
|
-
async function getCameraName(
|
|
41
|
-
const
|
|
42
|
-
username: node.user,
|
|
43
|
-
password: node.camPass
|
|
44
|
-
});
|
|
45
|
-
|
|
43
|
+
// --- PRENDE IL NOME DEL CANALE DIRETTAMENTE DALL'NVR ---
|
|
44
|
+
async function getCameraName(channelID) {
|
|
45
|
+
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
46
46
|
try {
|
|
47
|
-
const res = await
|
|
47
|
+
const res = await nvrAuth.request({
|
|
48
48
|
method: 'GET',
|
|
49
|
-
url: `${node.protocol}://${
|
|
49
|
+
url: `${node.protocol}://${node.host}:${node.port}/ISAPI/System/Video/inputs/channels/${channelID}/overlays/channelNameOverlay`,
|
|
50
50
|
timeout: 5000,
|
|
51
51
|
httpsAgent: node.protocol === "https" ? httpsAgent : undefined
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
const data = res.data.toString();
|
|
55
55
|
const match = data.match(/<name>([^<]+)<\/name>/i);
|
|
56
|
-
|
|
57
|
-
if (match && match[1]) {
|
|
58
|
-
return match[1].trim();
|
|
59
|
-
} else {
|
|
60
|
-
return `Canale_${cam.channel}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
56
|
+
return match && match[1] ? match[1].trim() : `Canale_${channelID}`;
|
|
63
57
|
} catch (e) {
|
|
64
|
-
return `
|
|
58
|
+
return `Canale_${channelID}`;
|
|
65
59
|
}
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
// --- CONTROLLO STATUS
|
|
62
|
+
// --- CONTROLLO STATUS DINAMICO DEI CANALI DIGITALI DALL'NVR ---
|
|
69
63
|
async function checkCameras() {
|
|
70
64
|
if (isClosing) return;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
65
|
+
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Interroghiamo l'NVR sullo stato globale dei suoi canali proxy [ps. 57-58 del manuale]
|
|
69
|
+
const res = await nvrAuth.request({
|
|
70
|
+
method: 'GET',
|
|
71
|
+
url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/InputProxy/channels/status`,
|
|
72
|
+
timeout: 5000,
|
|
73
|
+
httpsAgent: node.protocol === "https" ? httpsAgent : undefined
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
nvrOnline = true;
|
|
77
|
+
const xmlData = res.data.toString();
|
|
78
|
+
|
|
79
|
+
// Regex globale per estrarre TUTTI i blocchi <InputProxyChannelStatus> presenti nell'XML dell'NVR
|
|
80
|
+
const bloccoRegex = /<InputProxyChannelStatus[\s\\S]*?>([\s\S]*?)<\/InputProxyChannelStatus>/gi;
|
|
81
|
+
let matchBlocco;
|
|
82
|
+
|
|
83
|
+
while ((matchBlocco = bloccoRegex.exec(xmlData)) !== null) {
|
|
84
|
+
const bloccoContenuto = matchBlocco[1];
|
|
85
|
+
|
|
86
|
+
// Estraiamo l'ID del canale e il suo stato dal blocco attuale
|
|
87
|
+
const idMatch = bloccoContenuto.match(/<id>(\d+)<\/id>/i);
|
|
88
|
+
const statusMatch = bloccoContenuto.match(/<status>([^<]+)<\/status>/i);
|
|
89
|
+
|
|
90
|
+
if (idMatch && statusMatch) {
|
|
91
|
+
const ch = idMatch[1];
|
|
92
|
+
const statusStr = statusMatch[1].toLowerCase();
|
|
93
|
+
|
|
94
|
+
// Determiniamo se per l'NVR la telecamera su questo canale è attiva
|
|
95
|
+
const isOnline = statusStr.includes("online") || statusStr.includes("recording");
|
|
96
|
+
|
|
97
|
+
if (isOnline && statoCanale[ch] === false) {
|
|
98
|
+
const nomeOnline = await getCameraName(ch);
|
|
99
|
+
node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", nome_cliente: node.name, nome_telecamera: nomeOnline, ip_nvr: node.host, channel: ch.toString(), msg: "Camera ripristinata su NVR" } });
|
|
100
|
+
statoCanale[ch] = true;
|
|
101
|
+
} else if (!isOnline && statoCanale[ch] !== false && statoCanale[ch] !== undefined) {
|
|
102
|
+
node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", nome_cliente: node.name, nome_telecamera: `Canale_${ch}`, ip_nvr: node.host, channel: ch.toString(), msg: "Camera scollegata o offline su NVR" } });
|
|
103
|
+
statoCanale[ch] = false;
|
|
104
|
+
} else if (statoCanale[ch] === undefined) {
|
|
105
|
+
// Primo avvio: memorizziamo lo stato iniziale senza inviare messaggi di stato a vuoto
|
|
106
|
+
statoCanale[ch] = isOnline;
|
|
107
|
+
}
|
|
91
108
|
}
|
|
92
109
|
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
nvrOnline = false; // Se fallisce la GET, è l'NVR ad essere irraggiungibile
|
|
93
112
|
}
|
|
94
113
|
updateNodeStatus();
|
|
95
114
|
}
|
|
96
115
|
|
|
97
116
|
function updateNodeStatus() {
|
|
98
|
-
const offlineCams = Object.values(
|
|
117
|
+
const offlineCams = Object.values(statoCanale).filter(v => v === false).length;
|
|
99
118
|
if (!nvrOnline) {
|
|
100
119
|
node.status({fill:"red", shape:"ring", text:"NVR Offline"});
|
|
101
120
|
} else if (offlineCams > 0) {
|
|
@@ -105,39 +124,33 @@ module.exports = function(RED) {
|
|
|
105
124
|
}
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
// Il controllo camere gira in background ogni 30 secondi interrogando solo l'NVR
|
|
108
128
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
109
129
|
|
|
110
|
-
// --- DOWNLOAD, SALVATAGGIO, CONVERSIONE E RIMOZIONE
|
|
130
|
+
// --- DOWNLOAD, SALVATAGGIO, CONVERSIONE E RIMOZIONE DALL'NVR ---
|
|
111
131
|
async function downloadMedia(evento, channelID) {
|
|
112
|
-
const camera = node.cameras.find(c => c.channel == channelID);
|
|
113
|
-
if (!camera) return;
|
|
114
|
-
|
|
115
|
-
const nomeCamera = await getCameraName(camera);
|
|
116
|
-
|
|
117
132
|
const nowTime = Date.now();
|
|
118
|
-
if(!lastTriggerTime[
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (nowTime - lastTriggerTime[camera.ip] < 5000) return;
|
|
122
|
-
lastTriggerTime[camera.ip] = nowTime;
|
|
133
|
+
if (!lastTriggerTime[channelID]) lastTriggerTime[channelID] = 0;
|
|
134
|
+
if (nowTime - lastTriggerTime[channelID] < 5000) return;
|
|
135
|
+
lastTriggerTime[channelID] = nowTime;
|
|
123
136
|
|
|
124
|
-
const
|
|
137
|
+
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
125
138
|
const referenceTime = new Date();
|
|
126
139
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
127
140
|
const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
|
|
128
141
|
const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
|
|
129
142
|
|
|
130
143
|
node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
|
|
131
|
-
|
|
144
|
+
const nomeCamera = await getCameraName(channelID);
|
|
132
145
|
|
|
133
|
-
|
|
146
|
+
await new Promise(resolve => setTimeout(resolve, 6000));
|
|
147
|
+
const baseUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt`;
|
|
134
148
|
|
|
135
|
-
// struttura del payload per Python
|
|
136
149
|
let output = {
|
|
137
150
|
tipo_messaggio: "evento",
|
|
138
151
|
nome_cliente: node.name,
|
|
139
152
|
nome_telecamera: nomeCamera,
|
|
140
|
-
|
|
153
|
+
ip_nvr: node.host,
|
|
141
154
|
tipo_evento: evento,
|
|
142
155
|
timestamp_epoch: timestamp,
|
|
143
156
|
stato_telecamera: "ONLINE",
|
|
@@ -146,15 +159,18 @@ module.exports = function(RED) {
|
|
|
146
159
|
video_base64: null
|
|
147
160
|
};
|
|
148
161
|
|
|
149
|
-
|
|
150
162
|
let fileDaCancellare = [];
|
|
163
|
+
const parsedChannel = parseInt(channelID, 10);
|
|
164
|
+
const trackVideo = (parsedChannel * 100) + 1; // Formula dinamica NVR (*100+1) [cite: 958]
|
|
165
|
+
const trackFoto = (parsedChannel * 100) + 3; // Formula dinamica NVR (*100+3) [cite: 958]
|
|
166
|
+
|
|
167
|
+
const tracks = [{ id: trackVideo.toString(), type: "video" }, { id: trackFoto.toString(), type: "foto" }];
|
|
151
168
|
|
|
152
169
|
try {
|
|
153
|
-
const tracks = [{ id: "201" }, { id: "203" }];
|
|
154
170
|
for (let t of tracks) {
|
|
155
171
|
const searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>LAST_EVENT</searchID><trackIDList><trackID>${t.id}</trackID></trackIDList><timeSpanList><timeSpan><startTime>${startTime}</startTime><endTime>${endTime}</endTime></timeSpan></timeSpanList><maxResults>100</maxResults><metadataList><metadataDescriptor>//recordType.meta.std-cgi.com/${evento}</metadataDescriptor></metadataList></CMSearchDescription>`;
|
|
156
172
|
|
|
157
|
-
const resSearch = await
|
|
173
|
+
const resSearch = await nvrAuth.request({
|
|
158
174
|
method: 'POST', url: `${baseUrl}/search`, data: searchXml, headers: { "Content-Type": "application/xml" }
|
|
159
175
|
});
|
|
160
176
|
|
|
@@ -171,24 +187,17 @@ module.exports = function(RED) {
|
|
|
171
187
|
});
|
|
172
188
|
|
|
173
189
|
let buffer = Buffer.from(resDown.data);
|
|
174
|
-
if (t.
|
|
175
|
-
// SALVA FOTO IN LOCALE
|
|
190
|
+
if (t.type === "foto") {
|
|
176
191
|
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
177
192
|
fs.writeFileSync(fullImgPath, buffer);
|
|
178
|
-
|
|
179
|
-
// La convertiamo subito in testo per il payload
|
|
180
193
|
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
181
|
-
|
|
182
|
-
// Registriamo il file per la distruzione futura
|
|
183
194
|
fileDaCancellare.push(fullImgPath);
|
|
184
195
|
} else {
|
|
185
|
-
// SALVA VIDEO IN LOCALE-
|
|
186
196
|
if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
|
|
187
197
|
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
188
198
|
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
189
199
|
fs.writeFileSync(rawPath, buffer);
|
|
190
200
|
|
|
191
|
-
// Eseguiamo ffmpeg localmente
|
|
192
201
|
await new Promise((resolve) => {
|
|
193
202
|
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
194
203
|
if (!err && fs.existsSync(fixedPath)) {
|
|
@@ -205,20 +214,12 @@ module.exports = function(RED) {
|
|
|
205
214
|
}
|
|
206
215
|
}
|
|
207
216
|
|
|
208
|
-
// Spediamo il pacchetto completo verso l'HTTP Request tramite Node-RED
|
|
209
217
|
if (output.foto_base64 || output.video_base64) {
|
|
210
218
|
node.send({ payload: output });
|
|
211
219
|
|
|
212
|
-
// TIMER A 2 MINUTI PER LA PULIZIA DEL DISCO
|
|
213
220
|
setTimeout(() => {
|
|
214
221
|
for (let file of fileDaCancellare) {
|
|
215
|
-
try {
|
|
216
|
-
if (fs.existsSync(file)) {
|
|
217
|
-
fs.unlinkSync(file);
|
|
218
|
-
}
|
|
219
|
-
} catch (err) {
|
|
220
|
-
node.error(`Errore durante la pulizia del file temporaneo ${file}: ${err.message}`);
|
|
221
|
-
}
|
|
222
|
+
try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
|
|
222
223
|
}
|
|
223
224
|
}, 120000);
|
|
224
225
|
}
|
|
@@ -226,7 +227,7 @@ module.exports = function(RED) {
|
|
|
226
227
|
} catch (e) {
|
|
227
228
|
node.error(`Errore Download Cam ${channelID}: ${e.message}`);
|
|
228
229
|
}
|
|
229
|
-
|
|
230
|
+
node.status({fill:"green", shape:"ring", text:"In ascolto"});
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
// --- ALERT STREAM ---
|
|
@@ -234,46 +235,27 @@ module.exports = function(RED) {
|
|
|
234
235
|
if (isClosing) return;
|
|
235
236
|
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
236
237
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|
|
238
|
+
|
|
237
239
|
nvrAuth.request({ method: 'GET', url: url, responseType: 'stream', httpsAgent: node.protocol === "https" ? httpsAgent : undefined })
|
|
238
240
|
.then(response => {
|
|
239
241
|
streamRequest = response;
|
|
240
242
|
if (!nvrOnline) { node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", ip: node.host, msg: "NVR Online", nome_cliente: node.name } }); nvrOnline = true; }
|
|
241
243
|
updateNodeStatus();
|
|
244
|
+
|
|
242
245
|
response.data.on('data', (chunk) => {
|
|
243
|
-
// Convertiamo in minuscolo per sicurezza nel parsing del testo
|
|
244
246
|
const data = chunk.toString().toLowerCase();
|
|
245
247
|
|
|
246
|
-
// Controlliamo che sia un evento attivo (allarme o heartbeat che sia)
|
|
247
248
|
if (data.includes("active")) {
|
|
248
|
-
|
|
249
|
-
// 1. ESTRAZIONE DINAMICA DELL'EVENTO NATIVO (Pagina 32 del manuale)
|
|
250
|
-
let ev = data.match(/<eventtype>([^<]+)<\/eventtype>/i)?.[1] || "unknown";
|
|
251
|
-
ev = ev.trim();
|
|
252
|
-
|
|
253
|
-
// 2. FILTRO FONDAMENTALE: Se è l'heartbeat di test o è vuoto, FERMA TUTTO
|
|
254
|
-
if (ev === "unknown" || ev === "heartbeat") {
|
|
255
|
-
return; // Non entra in downloadMedia e azzera il loop infinito!
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// 3. RECUPERO DEL CANALE REALE (Dall'XML della telecamera o NVR)
|
|
259
249
|
const chMatch = data.match(/<channelid>(\d+)<\/channelid>/i);
|
|
260
250
|
if (chMatch) {
|
|
261
|
-
let
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const camTermica = node.cameras.find(c => c.channel == "2");
|
|
268
|
-
if (camTermica && node.host === camTermica.ip && channelID === "1") {
|
|
269
|
-
channelID = "2";
|
|
251
|
+
let ev = "Unknown";
|
|
252
|
+
for (let e of EventList) {
|
|
253
|
+
if (data.includes(e.toLowerCase())) {
|
|
254
|
+
ev = e;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
270
257
|
}
|
|
271
|
-
|
|
272
|
-
// Aspetta 2 secondi per dare tempo alla telecamera di scrivere l'immagine prima di chiedergliela
|
|
273
|
-
setTimeout(() => {
|
|
274
|
-
// Lancia la tua funzione downloadMedia originale che è giusta!
|
|
275
|
-
downloadMedia(ev, channelID);
|
|
276
|
-
}, 2000);
|
|
258
|
+
downloadMedia(ev, chMatch[1]);
|
|
277
259
|
}
|
|
278
260
|
}
|
|
279
261
|
});
|
|
@@ -290,6 +272,7 @@ module.exports = function(RED) {
|
|
|
290
272
|
|
|
291
273
|
startAlertStream();
|
|
292
274
|
setTimeout(checkCameras, 2000);
|
|
275
|
+
|
|
293
276
|
node.on('close', (done) => {
|
|
294
277
|
isClosing = true;
|
|
295
278
|
clearInterval(heartbeatInterval);
|