node-red-contrib-hik-media-buffer 1.1.92 → 1.1.94

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.
Files changed (2) hide show
  1. package/hik-media-buffer.js +122 -131
  2. package/package.json +1 -1
@@ -23,21 +23,33 @@ module.exports = function(RED) {
23
23
  let nvrOnline = true;
24
24
  let statoCamera = {};
25
25
 
26
- const EventList = ["FieldDetection", "LineDetection"];
27
26
  const baseStorage = path.join(os.tmpdir(), "hik_temp_media");
28
27
  if (!fs.existsSync(baseStorage)) fs.mkdirSync(baseStorage, { recursive: true });
29
28
 
30
29
  node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
31
30
 
32
- // Funzione data ISO standard con la Z che Chrome usa nell'NVR
33
- function toHikDate(d) {
34
- return d.toISOString().split('.')[0] + "Z";
31
+ // Formatta la data nel formato ISO locale richiesto dal JSON dell'NVR (Es: 2026-06-22T14:41:00+02:00)
32
+ function toJsonDate(d) {
33
+ const pad = (num) => String(num).padStart(2, '0');
34
+ const year = d.getFullYear();
35
+ const month = pad(d.getMonth() + 1);
36
+ const day = pad(d.getDate());
37
+ const hours = pad(d.getHours());
38
+ const minutes = pad(d.getMinutes());
39
+ const seconds = pad(d.getSeconds());
40
+
41
+ const offsetMinutes = d.getTimezoneOffset();
42
+ const offsetSign = offsetMinutes <= 0 ? '+' : '-';
43
+ const absOffsetMinutes = Math.abs(offsetMinutes);
44
+ const offsetHours = pad(Math.floor(absOffsetMinutes / 60));
45
+ const offsetMins = pad(absOffsetMinutes % 60);
46
+
47
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMins}`;
35
48
  }
36
49
 
37
- // Axios Digest usato solo per le GET leggere (Stream eventi e nomi canali)
38
50
  const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
39
51
 
40
- // --- RECUPERA IL NOME DELLA TELECAMERA DALL'NVR ---
52
+ // --- RECUPERA IL NOME DELLA TELECAMERA ---
41
53
  async function getCameraName(channelID) {
42
54
  try {
43
55
  const res = await nvrAuthAxios.request({
@@ -48,12 +60,10 @@ module.exports = function(RED) {
48
60
  const match = res.data.toString().match(/<name>([^<]+)<\/name>/i);
49
61
  if (match && match[1]) return match[1].trim();
50
62
  return `Canale_${channelID}`;
51
- } catch (e) {
52
- return `Camera_${channelID}`;
53
- }
63
+ } catch (e) { return `Camera_${channelID}`; }
54
64
  }
55
65
 
56
- // --- CONTROLLO STATUS CANERE DIRETTAMENTE DALL'NVR ---
66
+ // --- CONTROLLO STATUS TELECAMERE CONNESSE ALL'NVR ---
57
67
  async function checkCameras() {
58
68
  if (isClosing || !nvrOnline) return;
59
69
  try {
@@ -80,31 +90,23 @@ module.exports = function(RED) {
80
90
  } else if (!isOnlineNow && statoCamera[ch] !== false && statoCamera[ch] !== undefined) {
81
91
  node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", nome_cliente: node.name, nome_telecamera: `Camera_${ch}`, channel: ch, msg: "Camera scollegata dall'NVR" } });
82
92
  statoCamera[ch] = false;
83
- } else if (statoCamera[ch] === undefined) {
84
- statoCamera[ch] = isOnlineNow;
85
- }
93
+ } else if (statoCamera[ch] === undefined) { statoCamera[ch] = isOnlineNow; }
86
94
  }
87
95
  }
88
- } catch (e) {
89
- // Silenzioso
90
- }
96
+ } catch (e) {}
91
97
  updateNodeStatus();
92
98
  }
93
99
 
94
100
  function updateNodeStatus() {
95
101
  const offlineCams = Object.values(statoCamera).filter(v => v === false).length;
96
- if (!nvrOnline) {
97
- node.status({fill:"red", shape:"ring", text:"NVR Offline"});
98
- } else if (offlineCams > 0) {
99
- node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`});
100
- } else {
101
- node.status({fill:"green", shape:"ring", text:"In ascolto"});
102
- }
102
+ if (!nvrOnline) { node.status({fill:"red", shape:"ring", text:"NVR Offline"}); }
103
+ else if (offlineCams > 0) { node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`}); }
104
+ else { node.status({fill:"green", shape:"ring", text:"In ascolto"}); }
103
105
  }
104
106
 
105
107
  const heartbeatInterval = setInterval(checkCameras, 30000);
106
108
 
107
- // --- DOWNLOAD MEDIA SBLOCCATO VIA cURL ---
109
+ // --- CORE RESCUE: RICERCA EVENTO JSON, CATTURA FOTO HTTP E VIDEO RTSP ---
108
110
  async function downloadMedia(evento, channelID) {
109
111
  const chStr = channelID.toString();
110
112
  const nowTime = Date.now();
@@ -115,13 +117,15 @@ module.exports = function(RED) {
115
117
  const nomeCamera = await getCameraName(channelID);
116
118
  const referenceTime = new Date();
117
119
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
118
- const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
119
- const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
120
+
121
+ // Nuova finestra temporale larga: cerca da 10 minuti fa a 2 minuti nel futuro per compensare i disallineamenti di orario
122
+ const startTime = toJsonDate(new Date(referenceTime.getTime() - (10 * 60 * 1000)));
123
+ const endTime = toJsonDate(new Date(referenceTime.getTime() + (2 * 60 * 1000)));
120
124
 
121
- node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
125
+ node.status({fill:"yellow", shape:"dot", text:`Ricerca Evento Cam ${channelID}...`});
122
126
 
123
- // Aspettiamo che l'NVR scriva il file fisicamente sul disco
124
- await new Promise(resolve => setTimeout(resolve, 6000));
127
+ // Aspettiamo 3 secondi per dare tempo all'NVR di indicizzare l'evento sul log
128
+ await new Promise(resolve => setTimeout(resolve, 3000));
125
129
 
126
130
  let output = {
127
131
  tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
@@ -132,131 +136,120 @@ module.exports = function(RED) {
132
136
  let fileDaCancellare = [];
133
137
 
134
138
  try {
135
- const trackV = (channelID * 100 + 1).toString(); // Es: Canale 2 -> 201
136
- const trackI = (channelID * 100 + 3).toString(); // Es: Canale 2 -> 203
137
- const tracks = [{ id: trackV }, { id: trackI }];
138
-
139
- for (let t of tracks) {
140
- let searchXml = ``;
141
- if (t.id === trackV) {
142
- searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>23D1C3CB-8C68-41C0-8A89-2BCF5765B5D2</searchID><trackList><trackID>${t.id}</trackID></trackList><timeSpanList><timeSpan><startTime>${startTime}</startTime><endTime>${endTime}</endTime></timeSpan></timeSpanList><maxResults>100</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>//recordType.meta.std-cgi.com</metadataDescriptor></metadataList></CMSearchDescription>`.trim();
143
- } else {
144
- searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>23D1C3CB-8C68-41C0-8A89-2BCF5765B5D2</searchID><trackIDList><trackID>${t.id}</trackID></trackIDList><timeSpanList><timeSpan><startTime>${startTime}</startTime><endTime>${endTime}</endTime></timeSpan></timeSpanList><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>recordType.meta.hikvision.com/${evento}</metadataDescriptor></metadataList></CMSearchDescription>`.trim();
139
+ // Generazione del Payload JSON per interrogare il registro eventi dell'NVR
140
+ const searchPayload = {
141
+ "EventSearchDescription": {
142
+ "searchID": "D57A6C81-7FCB-4015-8156-71A54B486925",
143
+ "searchResultPosition": 0,
144
+ "maxResults": 10,
145
+ "timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
146
+ "type": "all",
147
+ "channels": [ parseInt(channelID) ],
148
+ "eventType": "behavior",
149
+ "behavior": { "behaviorEventType": evento.toLowerCase() }
145
150
  }
146
-
147
- // Salviamo l'XML su un file temporaneo per non far incazzare la shell con i caratteri < e >
148
- const tempXmlPath = path.join(baseStorage, `search_${t.id}_${timestamp}.xml`);
149
- fs.writeFileSync(tempXmlPath, searchXml, 'utf8');
150
-
151
- node.warn(`[DEBUG] Eseguo ricerca traccia ${t.id} tramite cURL via file temporaneo...`);
152
-
153
- // Usiamo -d "@path" così cURL si legge il file da solo senza passare i simboli alla shell
154
- const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`;
155
- const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d "@${tempXmlPath}" "${targetUrl}"`;
156
-
157
- let xmlResponse = await new Promise((resolve) => {
158
- exec(curlCommand, (err, stdout, stderr) => {
159
- if (err) {
160
- node.error(`[CRASH cURL]: ${err.message} | STDERR: ${stderr}`);
161
- resolve(null);
162
- } else {
163
- resolve(stdout);
164
- }
165
- });
166
- });
151
+ };
167
152
 
168
- // Cancelliamo subito il file XML temporaneo di ricerca
169
- try { if (fs.existsSync(tempXmlPath)) fs.unlinkSync(tempXmlPath); } catch(e){}
153
+ const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
154
+ fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
170
155
 
171
- node.warn(`[DEBUG NVR RESP] Risposta grezza per traccia ${t.id}:`);
172
- node.warn(xmlResponse);
156
+ node.warn(`[DEBUG JSON] Interrogo il log eventi dell'NVR per canale ${channelID}...`);
157
+ const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/eventRecordSearch?format=json`;
158
+ const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/json" -d "@${tempJsonPath}" "${targetUrl}"`;
173
159
 
174
- if (!xmlResponse || xmlResponse.includes("400 Bad Request") || xmlResponse.includes("401 Unauthorized")) {
175
- node.error(`[DEBUG NVR ERRORE] Risposta KO da curl su traccia ${t.id}`);
176
- continue;
177
- }
160
+ let jsonResponseRaw = await new Promise((resolve) => {
161
+ exec(curlCommand, (err, stdout) => { resolve(err ? null : stdout); });
162
+ });
178
163
 
179
- let xml = xmlResponse.replace(/<(\/?)\w+:/g, "<$1");
180
- const uriMatch = xml.match(/<playbackURI>([^<]+)</);
164
+ try { if (fs.existsSync(tempJsonPath)) fs.unlinkSync(tempJsonPath); } catch(e){}
181
165
 
182
- if (uriMatch) {
183
- const rawUri = uriMatch[1].replace(/&amp;/g, '&');
184
- const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`.trim();
185
-
186
- const tempDownXmlPath = path.join(baseStorage, `down_${t.id}_${timestamp}.xml`);
187
- fs.writeFileSync(tempDownXmlPath, downXml, 'utf8');
166
+ if (!jsonResponseRaw) {
167
+ node.error("[DEBUG JSON] Nessuna risposta ricevuta dall'NVR.");
168
+ updateNodeStatus();
169
+ return;
170
+ }
188
171
 
189
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
190
- const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
191
- const targetFilePath = (t.id === trackI) ? fullImgPath : rawPath;
172
+ const searchResult = JSON.parse(jsonResponseRaw);
173
+ const matches = searchResult?.EventSearchResult?.matchList || [];
192
174
 
193
- const downUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download`;
194
- const curlDownCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d "@${tempDownXmlPath}" "${downUrl}" -o "${targetFilePath}"`;
175
+ if (matches.length === 0) {
176
+ node.warn(`[DEBUG JSON] Nessun match trovato nel database per la Cam ${channelID} in questa finestra temporale.`);
177
+ updateNodeStatus();
178
+ return;
179
+ }
180
+
181
+ // Estraiamo il record dell'evento
182
+ const matchItem = matches[0];
183
+ const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
184
+
185
+ // --- 1. CATTURA DELLA FOTO ORIGINALE (URL /picture/ intercettato da Chrome) ---
186
+ let pictureUrlPath = null;
187
+ if (playbackUri) {
188
+ const urlParamsMatch = playbackUri.match(/\?(.*)/);
189
+ if (urlParamsMatch) {
190
+ const trackI = (channelID * 100 + 3).toString(); // Es: Canale 2 -> traccia foto 203
191
+ pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
192
+ }
193
+ }
195
194
 
196
- node.warn(`[DEBUG] Eseguo download traccia ${t.id} tramite cURL...`);
197
- await new Promise((resolve) => {
198
- exec(curlDownCommand, () => resolve());
195
+ if (pictureUrlPath) {
196
+ node.warn(`[DEBUG FOTO] Scarico la foto nativa dell'evento via HTTP GET...`);
197
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
198
+ const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
199
+
200
+ await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
201
+
202
+ if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
203
+ output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
204
+ fileDaCancellare.push(fullImgPath);
205
+ node.warn(`[DEBUG FOTO] Foto catturata con successo (${fs.statSync(fullImgPath).size} byte).`);
206
+ }
207
+ }
208
+
209
+ // --- 2. CATTURA DELLO SPEZZONE VIDEO (Via RTSP con Ffmpeg per tagliare a 15 secondi) ---
210
+ if (playbackUri) {
211
+ node.warn(`[DEBUG VIDEO] Aggancio il flusso RTSP dell'evento registrato...`);
212
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
213
+
214
+ // Iniettiamo le credenziali admin:pass all'interno dell'URL RTSP dell'NVR
215
+ const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
216
+
217
+ // -t 15 costringe Ffmpeg a registrare solo 15 secondi dall'orario di inizio dell'evento, poi chiude il rubinetto!
218
+ const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${authenticatedRtspUrl}" -t 15 -c copy -movflags +faststart "${fixedPath}"`;
219
+
220
+ await new Promise((resolve) => {
221
+ exec(ffmpegRtspCmd, { timeout: 25000 }, (err) => {
222
+ if (err) node.error(`[DEBUG FFMPEG ERRORE]: ${err.message}`);
223
+ resolve();
199
224
  });
225
+ });
200
226
 
201
- try { if (fs.existsSync(tempDownXmlPath)) fs.unlinkSync(tempDownXmlPath); } catch(e){}
202
-
203
- if (fs.existsSync(targetFilePath) && fs.statSync(targetFilePath).size > 0) {
204
- if (t.id === trackI) {
205
- output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
206
- fileDaCancellare.push(fullImgPath);
207
- } else {
208
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
209
-
210
- // Tagliamo via l'header proprietario IMKH se presente
211
- let fileStats = fs.statSync(rawPath);
212
- if (fileStats.size > 40) {
213
- let fd = fs.openSync(rawPath, 'r');
214
- let magicBuffer = Buffer.alloc(4);
215
- fs.readSync(fd, magicBuffer, 0, 4, 0);
216
- fs.closeSync(fd);
217
-
218
- if (magicBuffer.toString() === 'IMKH') {
219
- // Taglio dell'header senza leggere tutto in memoria (evita crash su file giganti)
220
- exec(`ffmpeg -y -ss 00:00:00 -i "${rawPath}" -c copy -map 0 -an "${fixedPath}"`, () => {});
221
- }
222
- }
223
-
224
- node.warn(`[DEBUG] Ritaglio il video dell'evento a 10 secondi per non saturare la memoria...`);
225
- // USIAMO FFMPEG PER PRENDERE SOLO GLI ULTIMI 10 SECONDI ED EVITARE IL FILE DA 1GB IN BASE64
226
- await new Promise((resolve) => {
227
- exec(`ffmpeg -y -sseof -10 -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
228
- if (!err && fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 0) {
229
- // Leggiamo il file tagliato che ora peserà pochissimo!
230
- output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
231
- fileDaCancellare.push(fixedPath);
232
- } else {
233
- node.error(`[DEBUG FFMPEG] Impossibile tagliare il video, traccia troppo pesante.`);
234
- }
235
- fileDaCancellare.push(rawPath);
236
- resolve();
237
- });
238
- });
239
- }
240
- }
227
+ if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
228
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
229
+ fileDaCancellare.push(fixedPath);
230
+ node.warn(`[DEBUG VIDEO] Spezzone video catturato con successo (${fs.statSync(fixedPath).size} byte).`);
231
+ } else {
232
+ node.warn(`[DEBUG VIDEO] Flusso RTSP non catturato o spezzone non pronto.`);
241
233
  }
242
234
  }
243
235
 
236
+ // --- INVIO PAYLOAD FINALE A NODE-RED ---
244
237
  if (output.foto_base64 || output.video_base64) {
245
238
  node.send({ payload: output });
246
239
  setTimeout(() => {
247
240
  for (let file of fileDaCancellare) {
248
241
  try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
249
242
  }
250
- }, 120000);
243
+ }, 60000); // Pulisce i file locali dopo 1 minuto
251
244
  }
252
245
 
253
246
  } catch (e) {
254
- node.error(`Errore Download Cam ${channelID}: ${e.message}`);
247
+ node.error(`Errore Critico nel Download Cam ${channelID}: ${e.message}`);
255
248
  }
256
249
  updateNodeStatus();
257
250
  }
258
251
 
259
- // --- ALERT STREAM DI VEDETTA (Super Stabile con Axios) ---
252
+ // --- ASCOLTO ALLARMI IN LIVE STREAM (Axios Stream) ---
260
253
  function startAlertStream() {
261
254
  if (isClosing) return;
262
255
  const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
@@ -275,10 +268,8 @@ module.exports = function(RED) {
275
268
  if (data.toLowerCase().includes("active")) {
276
269
  const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
277
270
  if (chMatch) {
278
- let ev = "Unknown";
279
- for (let e of EventList) {
280
- if (data.toLowerCase().includes(e.toLowerCase())) { ev = e; break; }
281
- }
271
+ let ev = "LineDetection";
272
+ if (data.toLowerCase().includes("fielddetection")) ev = "FieldDetection";
282
273
  downloadMedia(ev, chMatch[1]);
283
274
  }
284
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.92",
3
+ "version": "1.1.94",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",