node-red-contrib-hik-media-buffer 1.1.53 → 1.1.55

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 +124 -126
  2. package/package.json +1 -1
@@ -112,19 +112,25 @@ module.exports = function(RED) {
112
112
 
113
113
  const nowTime = Date.now();
114
114
  if (!lastTriggerTime[channelID]) lastTriggerTime[channelID] = 0;
115
- if (nowTime - lastTriggerTime[channelID] < 5000) {
116
- node.warn(`[STEP 1] Richiesta bloccata dall'antirimbalzo (meno di 5s dall'ultimo trigger)`);
117
- return;
118
- }
115
+ if (nowTime - lastTriggerTime[channelID] < 5000) return;
119
116
  lastTriggerTime[channelID] = nowTime;
120
117
 
121
118
  const camAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
122
119
  const referenceTime = new Date();
123
120
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
124
121
 
125
- const startTime = toHikDate(new Date(referenceTime.getTime() - (20 * 1000)));
126
- const endTime = toHikDate(new Date(referenceTime.getTime() + (20 * 1000)));
127
- node.warn(`[STEP 2] Finestra temporale impostata: ${startTime} -> ${endTime}`);
122
+ // Finestre temporali per i due endpoint
123
+ const startTimeZ = toHikDate(new Date(referenceTime.getTime() - (20 * 1000))); // Per XML (es. 2026-06-08T15:00:00Z)
124
+ const endTimeZ = toHikDate(new Date(referenceTime.getTime() + (20 * 1000)));
125
+
126
+ // Per il JSON creiamo il formato con l'offset del fuso orario italiano "+02:00" (o "+01:00" a seconda di come risponde il sistema)
127
+ // Sfruttiamo una formattazione pulita per l'orario del JSON dell'NVR
128
+ const formatJsonDate = (d) => {
129
+ const pad = (num) => String(num).padStart(2, '0');
130
+ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} 02:00`;
131
+ };
132
+ const startTimeJson = formatJsonDate(new Date(referenceTime.getTime() - (20 * 1000)));
133
+ const endTimeJson = formatJsonDate(new Date(referenceTime.getTime() + (20 * 1000)));
128
134
 
129
135
  node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
130
136
  const nomeCamera = await getCameraName(channelID);
@@ -134,13 +140,10 @@ module.exports = function(RED) {
134
140
 
135
141
  const baseUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt`;
136
142
 
137
- let eventoNVR = "FieldDetection";
138
- for (let e of EventList) {
139
- if (evento.toLowerCase() === e.toLowerCase()) {
140
- eventoNVR = e;
141
- break;
142
- }
143
- }
143
+ // Stringhe per i due formati
144
+ const eventoMinuscolo = evento.toLowerCase(); // "linedetection" o "fielddetection" per il JSON
145
+ let eventoNVR = "FieldDetection"; // "FieldDetection" o "LineDetection" per l'XML
146
+ if (eventoMinuscolo.includes("linedetection")) eventoNVR = "LineDetection";
144
147
 
145
148
  let output = {
146
149
  tipo_messaggio: "evento",
@@ -156,132 +159,127 @@ module.exports = function(RED) {
156
159
  };
157
160
 
158
161
  const parsedChannel = parseInt(channelID, 10);
159
- const trackVideo = (parsedChannel * 100) + 1;
160
- const trackFoto = (parsedChannel * 100) + 3;
161
-
162
- const tracks = [
163
- { id: trackVideo.toString(), isFoto: false, label: "VIDEO" },
164
- { id: trackFoto.toString(), isFoto: true, label: "FOTO" }
165
- ];
162
+ const trackFoto = (parsedChannel * 100) + 3; // Es. 203
166
163
  let fileDaCancellare = [];
167
164
 
168
- try {
169
- for (let t of tracks) {
170
- node.warn(`[STEP 4] Elaborazione traccia ${t.label} (ID: ${t.id})`);
171
-
172
- // COSTRUZIONE XML CON FORMATTAZIONE PULITA A LIVELLI (Risolve l'errore "two root tags")
173
- let searchXml = `<?xml version="1.0" encoding="utf-8"?>\n` +
174
- `<CMSearchDescription>\n` +
175
- ` <searchID>LAST_EVENT</searchID>\n` +
176
- ` <trackIDList>\n` +
177
- ` <trackID>${t.id}</trackID>\n` +
178
- ` </trackIDList>\n` +
179
- ` <timeSpanList>\n` +
180
- ` <timeSpan>\n` +
181
- ` <startTime>${startTime}</startTime>\n` +
182
- ` <endTime>${endTime}</endTime>\n` +
183
- ` </timeSpan>\n` +
184
- ` </timeSpanList>\n`;
185
-
186
- // Inseriamo il contentTypeList SOLO per la foto, per la ricerca video standard dell'NVR va rimosso!
187
- if (t.isFoto) {
188
- searchXml += ` <contentTypeList>\n` +
189
- ` <contentType>metadata</contentType>\n` +
190
- ` </contentTypeList>\n`;
165
+ // --- PARTE 1: SCARICHIAMO IL VIDEO USANDO IL JSON SUL NUOVO ENDPOINT ---
166
+ node.warn(`[STEP 4 - VIDEO] Invio POST a eventRecordSearch con JSON dell'ispeziona...`);
167
+ const searchJsonPayload = {
168
+ "EventSearchDescription": {
169
+ "searchID": "BA80CAF2-A1E5-42E3-8624-529C3D6665D7",
170
+ "searchResultPosition": 0,
171
+ "maxResults": 30,
172
+ "timeSpanList": [{
173
+ "startTime": startTimeJson,
174
+ "endTime": endTimeJson
175
+ }],
176
+ "type": "all",
177
+ "channels": [parsedChannel],
178
+ "eventType": "behavior",
179
+ "behavior": {
180
+ "behaviorEventType": eventoMinuscolo
191
181
  }
182
+ }
183
+ };
192
184
 
193
- searchXml += ` <maxResults>30</maxResults>\n` +
194
- ` <searchResultPostion>0</searchResultPostion>\n` +
195
- ` <metadataList>\n` +
196
- ` <metadataDescriptor>recordType.meta.hikvision.com/${eventoNVR}</metadataDescriptor>\n` +
197
- ` </metadataList>\n` +
198
- `</CMSearchDescription>`;
199
-
200
- node.warn(`[STEP 4.1] Invio POST /search per traccia ${t.id}. XML inviato:\n${searchXml}`);
201
-
202
- try {
203
- const resSearch = await camAuth.request({
204
- method: 'POST',
205
- url: `${baseUrl}/search`,
206
- data: searchXml,
207
- headers: { "Content-Type": "application/xml" }
208
- });
209
-
210
- node.warn(`[STEP 4.2] Risposta ricevuta da /search per traccia ${t.id}: Status ${resSearch.status}`);
211
-
212
- let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
213
- const uriMatch = xml.match(/<playbackURI>([^<]+)</);
185
+ try {
186
+ const resSearchVideo = await camAuth.request({
187
+ method: 'POST',
188
+ url: `${baseUrl}/eventRecordSearch?format=json`,
189
+ data: searchJsonPayload,
190
+ headers: { "Content-Type": "application/json" }
191
+ });
214
192
 
215
- if (uriMatch) {
216
- const rawUri = uriMatch[1].replace(/&amp;/g, '&');
217
- node.warn(`[STEP 5] Trovato playbackURI per traccia ${t.id}: ${rawUri}`);
218
-
219
- node.warn(`[STEP 5.1] Invio richiesta GET /download per traccia ${t.id}`);
220
- const resDown = await camAuth.request({
221
- method: 'GET',
222
- url: `${baseUrl}/download`,
223
- data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`,
224
- responseType: 'arraybuffer'
225
- });
193
+ // Il payload JSON restituisce una struttura differente, estraiamo l'url di download (playbackURI)
194
+ const resDataStr = JSON.stringify(resSearchVideo.data);
195
+ const videoUriMatch = resDataStr.match(/rtsp:\/\/[^"]+/i) || resDataStr.match(/\/ISAPI\/ContentMgmt\/download[^"]+/i);
196
+
197
+ if (videoUriMatch) {
198
+ let rawVideoUri = videoUriMatch[0].replace(/\\/g, '');
199
+ node.warn(`[STEP 4.1 - VIDEO] Trovato URI video nel JSON: ${rawVideoUri}`);
200
+
201
+ const resDownVideo = await camAuth.request({
202
+ method: 'GET',
203
+ url: `${baseUrl}/download`,
204
+ data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawVideoUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`,
205
+ responseType: 'arraybuffer'
206
+ });
226
207
 
227
- node.warn(`[STEP 5.2] Scaricati ${resDown.data.byteLength} byte per traccia ${t.id}`);
228
- let buffer = Buffer.from(resDown.data);
229
-
230
- if (t.isFoto) {
231
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
232
- fs.writeFileSync(fullImgPath, buffer);
233
- output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
234
- fileDaCancellare.push(fullImgPath);
235
- node.warn(`[STEP 5.3] Immagine salvata e convertita in Base64.`);
208
+ let videoBuffer = Buffer.from(resDownVideo.data);
209
+ if (videoBuffer.slice(0, 4).toString() === 'IMKH') videoBuffer = videoBuffer.slice(40);
210
+
211
+ const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
212
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
213
+ fs.writeFileSync(rawPath, videoBuffer);
214
+
215
+ await new Promise((resolve) => {
216
+ exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
217
+ if (!err && fs.existsSync(fixedPath)) {
218
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
219
+ fileDaCancellare.push(fixedPath);
236
220
  } else {
237
- if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
238
- const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
239
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
240
- fs.writeFileSync(rawPath, buffer);
241
-
242
- node.warn(`[STEP 5.3] Video grezzo salvato. Avvio conversione FFMPEG...`);
243
- await new Promise((resolve) => {
244
- exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
245
- if (!err && fs.existsSync(fixedPath)) {
246
- output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
247
- fileDaCancellare.push(fixedPath);
248
- node.warn(`[STEP 5.4] Conversione FFMPEG completata con successo.`);
249
- } else {
250
- node.warn(`[STEP 5.4] Errore FFMPEG (uso il video grezzo di backup): ${err ? err.message : 'File mancante'}`);
251
- output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
252
- }
253
- fileDaCancellare.push(rawPath);
254
- resolve();
255
- });
256
- });
221
+ output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
257
222
  }
258
- } else {
259
- node.warn(`[STEP 4.3] Nessun playbackURI trovato nel documento di risposta XML per traccia ${t.id}. XML di risposta:\n${resSearch.data}`);
260
- }
261
- } catch (searchError) {
262
- let errorDetails = searchError.message;
263
- if (searchError.response) {
264
- errorDetails += ` | Status: ${searchError.response.status} | Dati Risposta: ${JSON.stringify(searchError.response.data)}`;
265
- }
266
- node.error(`[CATCH TRACCIA ${t.id}] Errore durante la POST di ricerca: ${errorDetails}`);
267
- }
223
+ fileDaCancellare.push(rawPath);
224
+ resolve();
225
+ });
226
+ });
227
+ } else {
228
+ node.warn(`[STEP 4.2 - VIDEO] Nessun URI video trovato nella risposta JSON.`);
268
229
  }
230
+ } catch (videoErr) {
231
+ node.error(`[VIDEO ERR] Errore durante la ricerca video JSON: ${videoErr.message}`);
232
+ }
269
233
 
270
- if (output.foto_base64 || output.video_base64) {
271
- node.warn(`[STEP 6] Spedizione payload verso Node-RED effettuata con successo!`);
272
- node.send({ payload: output });
234
+ // --- PARTE 2: SCARICHIAMO LA FOTO USANDO L'XML SUL VECCHIO ENDPOINT ---
235
+ node.warn(`[STEP 5 - FOTO] Invio POST /search in XML per la traccia foto ${trackFoto}...`);
236
+ const searchXmlFoto = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>LAST_EVENT</searchID><trackIDList><trackID>${trackFoto}</trackID></trackIDList><timeSpanList><timeSpan><startTime>${startTimeZ}</startTime><endTime>${endTimeZ}</endTime></timeSpan></timeSpanList><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>recordType.meta.hikvision.com/${eventoNVR}</metadataDescriptor></metadataList></CMSearchDescription>`;
237
+
238
+ try {
239
+ const resSearchFoto = await camAuth.request({
240
+ method: 'POST',
241
+ url: `${baseUrl}/search`,
242
+ data: searchXmlFoto,
243
+ headers: { "Content-Type": "application/xml" }
244
+ });
245
+
246
+ let xml = resSearchFoto.data.replace(/<(\/?)\w+:/g, "<$1");
247
+ const uriMatch = xml.match(/<playbackURI>([^<]+)</);
248
+
249
+ if (uriMatch) {
250
+ const rawUriFoto = uriMatch[1].replace(/&amp;/g, '&');
251
+ node.warn(`[STEP 5.1 - FOTO] Trovato playbackURI foto: ${rawUriFoto}`);
273
252
 
274
- setTimeout(() => {
275
- for (let file of fileDaCancellare) {
276
- try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
277
- }
278
- }, 120000);
253
+ const resDownFoto = await camAuth.request({
254
+ method: 'GET',
255
+ url: `${baseUrl}/download`,
256
+ data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUriFoto.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`,
257
+ responseType: 'arraybuffer'
258
+ });
259
+
260
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
261
+ fs.writeFileSync(fullImgPath, Buffer.from(resDownFoto.data));
262
+ output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
263
+ fileDaCancellare.push(fullImgPath);
279
264
  } else {
280
- node.warn(`[STEP 6] Nessun media Base64 popolato, nessun pacchetto inviato a Node-RED.`);
265
+ node.warn(`[STEP 5.2 - FOTO] Nessun playbackURI foto trovato nell'XML.`);
281
266
  }
267
+ } catch (fotoErr) {
268
+ node.error(`[FOTO ERR] Errore durante la ricerca foto XML: ${fotoErr.message}`);
269
+ }
282
270
 
283
- } catch (e) {
284
- node.error(`[FATAL ERROR] Blocco principale downloadMedia fallito: ${e.message}`);
271
+ // --- FINE: INVIO DATI A NODE-RED ---
272
+ if (output.foto_base64 || output.video_base64) {
273
+ node.warn(`[STEP 6] Payload multimediale spedito correttamente!`);
274
+ node.send({ payload: output });
275
+
276
+ setTimeout(() => {
277
+ for (let file of fileDaCancellare) {
278
+ try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
279
+ }
280
+ }, 120000);
281
+ } else {
282
+ node.warn(`[STEP 6] Nessun file base64 generato.`);
285
283
  }
286
284
  node.status({fill:"green", shape:"ring", text:"In ascolto"});
287
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.53",
3
+ "version": "1.1.55",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",