node-red-contrib-hik-media-buffer 1.1.113 → 1.1.115

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.
@@ -68,16 +68,14 @@ module.exports = function(RED) {
68
68
  });
69
69
 
70
70
  const xml = res.data.toString();
71
- // Regex per estrarre ogni blocco VideoInputChannel
72
71
  const channelBlocks = xml.match(/<VideoInputChannel>[\s\S]*?<\/VideoInputChannel>/g) || [];
73
72
 
74
73
  for (let block of channelBlocks) {
75
74
  const idMatch = block.match(/<id>(\d+)<\/id>/i);
76
- const statusMatch = block.match(/<resVerType>([^<]+)<\/resVerType>/i); // online / offline o simile in base al firmware
75
+ const statusMatch = block.match(/<resVerType>([^<]+)<\/resVerType>/i);
77
76
 
78
77
  if (idMatch) {
79
78
  const ch = idMatch[1];
80
- // Se non trova il tag esplicito o dice "online", assumiamo sia attiva
81
79
  const isOnline = statusMatch ? statusMatch[1].toLowerCase() === "online" : true;
82
80
 
83
81
  if (isOnline && statoCamera[ch] === false) {
@@ -92,9 +90,7 @@ module.exports = function(RED) {
92
90
  }
93
91
  }
94
92
  }
95
- } catch (e) {
96
- // Se l'NVR fallisce la chiamata generale, lo gestisce l'alert stream o lasciamo lo stato immutato
97
- }
93
+ } catch (e) {}
98
94
  updateNodeStatus();
99
95
  }
100
96
 
@@ -128,7 +124,7 @@ module.exports = function(RED) {
128
124
  const nomeCamera = await getCameraNameFromNVR(channelID);
129
125
  node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
130
126
 
131
- // Finestra temporale di 14 ore per la ricerca (adeguata al tuo esempio)
127
+ // Finestra temporale per la ricerca foto JSON
132
128
  const startSearchStr = referenceTime.toISOString().split('T')[0] + "T00:00:00 01:00";
133
129
  const endSearchStr = referenceTime.toISOString().split('T')[0] + "T23:59:59 01:00";
134
130
 
@@ -173,8 +169,9 @@ module.exports = function(RED) {
173
169
  httpsAgent: node.protocol === "https" ? httpsAgent : undefined
174
170
  });
175
171
 
176
- if (resFotoSearch.data?.EventSearchResult?.matchList?.[0]?.pictureUrl) {
177
- const urlFotoGrezzo = resFotoSearch.data.EventSearchResult.matchList[0].pictureUrl;
172
+ // CORRETTO: Adesso cerca dentro Targets e prende il pictureUrl grezzo come su Postman
173
+ if (resFotoSearch.data?.EventSearchResult?.Targets?.[0]?.pictureUrl) {
174
+ const urlFotoGrezzo = resFotoSearch.data.EventSearchResult.Targets[0].pictureUrl;
178
175
 
179
176
  const resDownFoto = await nvrAuth.request({
180
177
  method: 'GET',
@@ -190,7 +187,6 @@ module.exports = function(RED) {
190
187
  }
191
188
 
192
189
  // 2. SCARICAMENTO VIDEO (POST XML a search -> GET XML a download)
193
- // Usiamo una finestra temporale estesa in formato Z come richiesto dall'NVR
194
190
  const startVideoSearch = referenceTime.toISOString().split('T')[0] + "T00:00:00Z";
195
191
  const endVideoSearch = referenceTime.toISOString().split('T')[0] + "T23:59:59Z";
196
192
 
package/hik-snapshot.js CHANGED
@@ -1,106 +1,320 @@
1
1
  const axios = require('axios');
2
+ const AxiosDigestAuth = require('@mhoc/axios-digest-auth').default;
2
3
  const https = require('https');
3
- const mhocDigestSnapshot = require('@mhoc/axios-digest-auth');
4
- const DigestAuthClass = mhocDigestSnapshot.default;
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const { exec } = require('child_process');
5
8
 
6
9
  module.exports = function(RED) {
7
- function HikSnapshotNode(config) {
10
+ function HikMediaBufferNode(config) {
8
11
  RED.nodes.createNode(this, config);
9
12
  const node = this;
10
-
11
- // Recuperiamo il nome del nodo dalla configurazione (se vuoto usa l'host)
12
- node.nodeName = config.name || `NVR_${config.host}`;
13
-
14
- node.protocol = config.protocol || "http";
13
+
14
+ node.name = config.name || "TEST";
15
15
  node.host = config.host;
16
16
  node.port = config.port || "80";
17
+ node.protocol = config.protocol || "http";
17
18
  node.user = config.user;
18
19
  node.pass = config.pass;
19
- node.maxChannels = parseInt(config.channel) || 1;
20
+
21
+ let streamRequest = null;
22
+ let isClosing = false;
23
+ let lastTriggerTime = {};
24
+ let nvrOnline = true;
25
+ let statoCamera = {};
20
26
 
21
27
  const httpsAgent = new https.Agent({ rejectUnauthorized: false });
28
+ const EventList = ["FieldDetection", "LineDetection"];
22
29
 
23
- node.on('input', async function(msg) {
24
- if (msg.payload !== true) return;
30
+ // CARTELLA TEMPORANEA
31
+ const baseStorage = path.join(os.tmpdir(), "hik_temp_media");
32
+ if (!fs.existsSync(baseStorage)) fs.mkdirSync(baseStorage, { recursive: true });
25
33
 
26
- node.status({fill: "blue", shape: "dot", text: "Verifica canali..."});
34
+ node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
27
35
 
28
- const digest = new DigestAuthClass({
29
- username: node.user,
30
- password: node.pass
31
- });
32
-
33
- const data = new Date();
34
- const year = data.getFullYear();
35
- const month = data.getMonth() + 1;
36
- const day = data.getDate();
36
+ function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
37
+
38
+ // --- PRENDE IL NOME REALE DELLA TELECAMERA DALL'NVR ---
39
+ async function getCameraNameFromNVR(channelID) {
40
+ const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
41
+ try {
42
+ const res = await nvrAuth.request({
43
+ method: 'GET',
44
+ url: `${node.protocol}://${node.host}:${node.port}/ISAPI/System/Video/inputs/channels/${channelID}`,
45
+ timeout: 5000,
46
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
47
+ });
37
48
 
38
- let snapshotResults = [];
49
+ const data = res.data.toString();
50
+ // Cerchiamo il tag channelName che contiene il nome impostato sull'NVR
51
+ const match = data.match(/<channelName>([^<]+)<\/channelName>/i);
52
+ if (match && match[1]) return match[1].trim();
53
+ return `Canale ${channelID}`;
54
+ } catch (e) {
55
+ return `Canale ${channelID}`;
56
+ }
57
+ }
39
58
 
40
- for (let i = 1; i <= node.maxChannels; i++) {
41
- const chanId = i + "01";
42
- const snapUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/Streaming/channels/${chanId}/picture`;
43
- const recordUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/record/tracks/${chanId}/dailyDistribution`;
59
+ // --- CONTROLLO STATUS CAMERE (ONLINE/OFFLINE) INTERROGANDO L'NVR ---
60
+ async function checkCameras() {
61
+ if (isClosing || !nvrOnline) return;
62
+ const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
63
+ try {
64
+ const res = await nvrAuth.request({
65
+ method: 'GET',
66
+ url: `${node.protocol}://${node.host}:${node.port}/ISAPI/System/Video/inputs/channels`,
67
+ timeout: 8000,
68
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
69
+ });
44
70
 
45
- const recordXml = `<?xml version="1.0" encoding="utf-8"?><trackDailyParam><year>${year}</year><monthOfYear>${month}</monthOfYear><dayOfMonth>${day}</dayOfMonth></trackDailyParam>`;
46
-
47
- let resCanale = {
48
- name: node.nodeName, // <--- INIEZIONE RIGIDA DEL NOME DEL CONDOMINIO
49
- channel: i,
50
- photo: null,
51
- snapOk: false,
52
- isRecording: false
71
+ const xml = res.data.toString();
72
+ const channelBlocks = xml.match(/<VideoInputChannel>[\s\S]*?<\/VideoInputChannel>/g) || [];
73
+
74
+ for (let block of channelBlocks) {
75
+ const idMatch = block.match(/<id>(\d+)<\/id>/i);
76
+ // Hikvision usa il tag <online> true/false per lo stato della telecamera IP accoppiata
77
+ const onlineMatch = block.match(/<online>([^<]+)<\/online>/i) || block.match(/<onLine>([^<]+)<\/onLine>/i);
78
+
79
+ if (idMatch) {
80
+ const ch = idMatch[1];
81
+ // Se il tag dice true è online, altrimenti se dice false (o manca) è offline
82
+ const isOnline = onlineMatch ? onlineMatch[1].trim().toLowerCase() === "true" : true;
83
+
84
+ if (isOnline && statoCamera[ch] === false) {
85
+ const nomeOnline = await getCameraNameFromNVR(ch);
86
+ node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", nome_cliente: node.name, nome_telecamera: nomeOnline, ip_telecamera: node.host, channel: ch, msg: "Camera ripristinata" } });
87
+ statoCamera[ch] = true;
88
+ } else if (!isOnline && statoCamera[ch] !== false) {
89
+ node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", nome_cliente: node.name, nome_telecamera: `Camera ${ch}`, ip_telecamera: node.host, channel: ch, msg: "Camera non raggiungibile" } });
90
+ statoCamera[ch] = false;
91
+ } else if (statoCamera[ch] === undefined) {
92
+ statoCamera[ch] = isOnline;
93
+ }
94
+ }
95
+ }
96
+ } catch (e) {
97
+ node.error(`Errore durante il check delle camere: ${e.message}`);
98
+ }
99
+ updateNodeStatus();
100
+ }
101
+
102
+ function updateNodeStatus() {
103
+ const offlineCams = Object.values(statoCamera).filter(v => v === false).length;
104
+ if (!nvrOnline) {
105
+ node.status({fill:"red", shape:"ring", text:"NVR Offline"});
106
+ } else if (offlineCams > 0) {
107
+ node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`});
108
+ } else {
109
+ node.status({fill:"green", shape:"ring", text:"In ascolto"});
110
+ }
111
+ }
112
+
113
+ const heartbeatInterval = setInterval(checkCameras, 30000);
114
+
115
+ // --- DOWNLOAD CON URL E BODY SECONDO SPECIFICHE ---
116
+ async function downloadMedia(evento, channelID) {
117
+ const nowTime = Date.now();
118
+ if (!lastTriggerTime[channelID]) lastTriggerTime[channelID] = 0;
119
+ if (nowTime - lastTriggerTime[channelID] < 5000) return;
120
+ lastTriggerTime[channelID] = nowTime;
121
+
122
+ const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
123
+ const referenceTime = new Date();
124
+ const timestamp = Math.floor(referenceTime.getTime() / 1000);
125
+
126
+ const videoTrackID = (parseInt(channelID) * 100) + 1;
127
+
128
+ // Recupera il nome reale prima di inviare l'allarme
129
+ const nomeCamera = await getCameraNameFromNVR(channelID);
130
+ node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
131
+
132
+ const startSearchStr = referenceTime.toISOString().split('T')[0] + "T00:00:00 01:00";
133
+ const endSearchStr = referenceTime.toISOString().split('T')[0] + "T23:59:59 01:00";
134
+
135
+ let output = {
136
+ tipo_messaggio: "evento",
137
+ nome_cliente: node.name,
138
+ nome_telecamera: nomeCamera,
139
+ ip_telecamera: node.host,
140
+ tipo_evento: evento,
141
+ timestamp_epoch: timestamp,
142
+ stato_telecamera: "ONLINE",
143
+ channel: channelID.toString(),
144
+ foto_base64: null,
145
+ video_base64: null
146
+ };
147
+
148
+ let fileDaCancellare = [];
149
+
150
+ try {
151
+ // 1. SCARICAMENTO IMMAGINE
152
+ const payloadFoto = {
153
+ "EventSearchDescription": {
154
+ "searchID": "C5AFEE35-B1E0-4C01-83F8-47FD77892E4A",
155
+ "searchResultPosition": 0,
156
+ "maxResults": 30,
157
+ "timeSpanList": [{
158
+ "startTime": startSearchStr,
159
+ "endTime": endSearchStr
160
+ }],
161
+ "type": "all",
162
+ "channels": [parseInt(channelID)],
163
+ "eventType": "behavior",
164
+ "behavior": { "behaviorEventType": "linedetection" }
165
+ }
53
166
  };
54
167
 
55
- // 1. SNAPSHOT
56
- try {
57
- const responseSnap = await digest.request({
168
+ const resFotoSearch = await nvrAuth.request({
169
+ method: 'POST',
170
+ url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/eventRecordSearch?format=json`,
171
+ data: payloadFoto,
172
+ headers: { "Content-Type": "application/json" },
173
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
174
+ });
175
+
176
+ if (resFotoSearch.data?.EventSearchResult?.Targets?.[0]?.pictureUrl) {
177
+ const urlFotoGrezzo = resFotoSearch.data.EventSearchResult.Targets[0].pictureUrl;
178
+
179
+ const resDownFoto = await nvrAuth.request({
58
180
  method: 'GET',
59
- url: snapUrl,
181
+ url: urlFotoGrezzo,
60
182
  responseType: 'arraybuffer',
61
- httpsAgent: node.protocol === 'https' ? httpsAgent : undefined,
62
- timeout: 5000
183
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
63
184
  });
64
- resCanale.photo = responseSnap.data;
65
- resCanale.snapOk = true;
66
- } catch (err) {
67
- resCanale.snapError = err.message;
185
+
186
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}_ch${channelID}.jpg`);
187
+ fs.writeFileSync(fullImgPath, Buffer.from(resDownFoto.data));
188
+ output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
189
+ fileDaCancellare.push(fullImgPath);
68
190
  }
69
191
 
70
- // 2. RECORDING
71
- try {
72
- const responseRec = await digest.request({
73
- method: 'POST',
74
- url: recordUrl,
75
- data: recordXml,
76
- headers: { 'Content-Type': 'application/xml' },
77
- httpsAgent: node.protocol === 'https' ? httpsAgent : undefined,
78
- timeout: 5000
192
+ // 2. SCARICAMENTO VIDEO
193
+ const startVideoSearch = referenceTime.toISOString().split('T')[0] + "T00:00:00Z";
194
+ const endVideoSearch = referenceTime.toISOString().split('T')[0] + "T23:59:59Z";
195
+
196
+ const payloadVideoSearch = `<?xml version="1.0" encoding="utf-8"?>
197
+ <CMSearchDescription>
198
+ <searchID>CEC13F0F-1AEA-4474-A2DA-CFFF332C7C5B</searchID>
199
+ <trackList><trackID>${videoTrackID}</trackID></trackList>
200
+ <timeSpanList>
201
+ <timeSpan>
202
+ <startTime>${startVideoSearch}</startTime>
203
+ <endTime>${endVideoSearch}</endTime>
204
+ </timeSpan>
205
+ </timeSpanList>
206
+ <maxResults>100</maxResults>
207
+ <searchResultPostion>0</searchResultPostion>
208
+ <metadataList><metadataDescriptor>//recordType.meta.std-cgi.com</metadataDescriptor></metadataList>
209
+ </CMSearchDescription>`;
210
+
211
+ const resVideoSearch = await nvrAuth.request({
212
+ method: 'POST',
213
+ url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`,
214
+ data: payloadVideoSearch,
215
+ headers: { "Content-Type": "application/xml" },
216
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
217
+ });
218
+
219
+ const xmlString = resVideoSearch.data.toString();
220
+ const uriMatch = xmlString.match(/<playbackURI>([\s\S]*?)<\/playbackURI>/i);
221
+
222
+ if (uriMatch && uriMatch[1]) {
223
+ const playbackURIGrezzo = uriMatch[1].trim();
224
+
225
+ const payloadDownload = `<?xml version="1.0" encoding="UTF-8"?>
226
+ <downloadRequest>
227
+ <playbackURI>${playbackURIGrezzo}</playbackURI>
228
+ </downloadRequest>`;
229
+
230
+ const resDownVideo = await nvrAuth.request({
231
+ method: 'GET',
232
+ url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download`,
233
+ data: payloadDownload,
234
+ headers: { "Content-Type": "application/xml" },
235
+ responseType: 'arraybuffer',
236
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
237
+ });
238
+
239
+ let bufferVideo = Buffer.from(resDownVideo.data);
240
+ if (bufferVideo.slice(0, 4).toString() === 'IMKH') bufferVideo = bufferVideo.slice(40);
241
+
242
+ const rawPath = path.join(baseStorage, `raw_${timestamp}_ch${channelID}.mp4`);
243
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
244
+ fs.writeFileSync(rawPath, bufferVideo);
245
+
246
+ await new Promise((resolve) => {
247
+ exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
248
+ if (!err && fs.existsSync(fixedPath)) {
249
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
250
+ fileDaCancellare.push(fixedPath);
251
+ } else {
252
+ output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
253
+ }
254
+ fileDaCancellare.push(rawPath);
255
+ resolve();
256
+ });
79
257
  });
80
- const xmlOutput = responseRec.data.toString();
81
- const regex = new RegExp(`<id>${day}</id>[^]*?<record>true</record>`);
82
- resCanale.isRecording = regex.test(xmlOutput);
83
- } catch (err) {
84
- resCanale.recError = err.message;
85
258
  }
86
259
 
87
- snapshotResults.push(resCanale);
88
- await new Promise(resolve => setTimeout(resolve, 200));
260
+ if (output.foto_base64 || output.video_base64) {
261
+ node.send({ payload: output });
262
+
263
+ setTimeout(() => {
264
+ for (let file of fileDaCancellare) {
265
+ try {
266
+ if (fs.existsSync(file)) fs.unlinkSync(file);
267
+ } catch (err) {}
268
+ }
269
+ }, 120000);
270
+ }
271
+
272
+ } catch (e) {
273
+ node.error(`Errore Download Cam ${channelID}: ${e.message}`);
89
274
  }
275
+ updateNodeStatus();
276
+ }
90
277
 
91
- msg.payload = snapshotResults;
92
- node.send(msg);
278
+ // --- ALERT STREAM ---
279
+ function startAlertStream() {
280
+ if (isClosing) return;
281
+ const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
282
+ const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
283
+ nvrAuth.request({ method: 'GET', url: url, responseType: 'stream', httpsAgent: node.protocol === "https" ? httpsAgent : undefined })
284
+ .then(response => {
285
+ streamRequest = response;
286
+ if (!nvrOnline) { node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", ip: node.host, msg: "NVR Online", nome_cliente: node.name } }); nvrOnline = true; }
287
+ updateNodeStatus();
288
+ response.data.on('data', (chunk) => {
289
+ const data = chunk.toString().toLowerCase();
290
+ if (data.includes("active")) {
291
+ const chMatch = data.match(/<channelid>(\d+)<\/channelid>/i);
292
+ if (chMatch) {
293
+ let ev = "Unknown";
294
+ for (let e of EventList) { if (data.includes(e.toLowerCase())) { ev = e; break; } }
295
+ downloadMedia(ev, chMatch[1]);
296
+ }
297
+ }
298
+ });
299
+ response.data.on('error', () => handleNvrError());
300
+ response.data.on('end', () => !isClosing && setTimeout(startAlertStream, 5000));
301
+ }).catch(() => handleNvrError());
302
+ }
93
303
 
94
- // Conteggi per lo stato del nodo
95
- const snapCount = snapshotResults.filter(v => v.snapOk).length;
96
- const recCount = snapshotResults.filter(v => v.isRecording).length;
97
-
98
- node.status({
99
- fill: (snapCount === node.maxChannels && recCount === node.maxChannels) ? "green" : "yellow",
100
- shape: "dot",
101
- text: `Snap: ${snapCount}/${node.maxChannels} | Rec: ${recCount}/${node.maxChannels}`
102
- });
304
+ function handleNvrError() {
305
+ if (nvrOnline) { node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", ip: node.host, msg: "NVR Offline", nome_cliente: node.name } }); nvrOnline = false; }
306
+ updateNodeStatus();
307
+ if (!isClosing) setTimeout(startAlertStream, 10000);
308
+ }
309
+
310
+ startAlertStream();
311
+ setTimeout(checkCameras, 2000);
312
+ node.on('close', (done) => {
313
+ isClosing = true;
314
+ clearInterval(heartbeatInterval);
315
+ if (streamRequest) streamRequest.data.destroy();
316
+ done();
103
317
  });
104
318
  }
105
- RED.nodes.registerType("hik-snapshot", HikSnapshotNode);
319
+ RED.nodes.registerType("hik-media-buffer", HikMediaBufferNode);
106
320
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.113",
3
+ "version": "1.1.115",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",