node-red-contrib-hik-media-buffer 1.1.87 → 1.1.88

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 +54 -135
  2. package/package.json +1 -1
@@ -1,6 +1,5 @@
1
1
  const axios = require('axios');
2
2
  const AxiosDigestAuth = require('@mhoc/axios-digest-auth').default;
3
- const http = require('http'); // Modulo nativo di Node.js per sconfiggere il 400
4
3
  const fs = require('fs');
5
4
  const path = require('path');
6
5
  const os = require('os');
@@ -30,113 +29,15 @@ module.exports = function(RED) {
30
29
 
31
30
  node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
32
31
 
33
- function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
32
+ // Funzione data ISO standard con la Z che Chrome usa nell'NVR
33
+ function toHikDate(d) {
34
+ return d.toISOString().split('.')[0] + "Z";
35
+ }
34
36
 
35
- // Manteniamo AxiosDigest solo per lo stream degli allarmi e lo stato dei canali (dove va da dio)
37
+ // Axios Digest usato solo per le GET leggere (Stream eventi e nomi canali)
36
38
  const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
37
39
 
38
- // --- FUNZIONE AUSILIARIA PER FARE LA POST DIGEST CON HTTP NATIVO (ELIMINA IL 400) ---
39
- // --- FUNZIONE DEFINITIVA PER LA POST DIGEST CON CALCOLO INTEGRITÀ DEL BODY ---
40
- function nativeDigestPost(urlPath, xmlBody) {
41
- return new Promise((resolve, reject) => {
42
- const options = {
43
- hostname: node.host,
44
- port: node.port,
45
- path: urlPath,
46
- method: 'POST',
47
- headers: {
48
- 'Content-Type': 'application/xml; charset=UTF-8',
49
- 'Content-Length': Buffer.byteLength(xmlBody, 'utf8')
50
- }
51
- };
52
-
53
- // Primo invio a vuoto per scatenare il 401 dell'NVR
54
- const req = http.request(options, (res) => {
55
- let chunks = [];
56
- res.on('data', (chunk) => chunks.push(chunk));
57
- res.on('end', () => {
58
- const body = Buffer.concat(chunks).toString();
59
-
60
- if (res.statusCode === 401) {
61
- const authHeader = res.headers['www-authenticate'];
62
- if (!authHeader) return reject(new Error("Manca header WWW-Authenticate"));
63
-
64
- // Estrazione parametri di sicurezza
65
- const realm = authHeader.match(/realm="([^"]+)"/)[1];
66
- const nonce = authHeader.match(/nonce="([^"]+)"/)[1];
67
- const qopMatch = authHeader.match(/qop="([^"]+)"/);
68
- const qop = qopMatch ? qopMatch[1] : null;
69
-
70
- const crypto = require('crypto');
71
- const md5 = (str) => crypto.createHash('md5').update(str, 'utf8').digest('hex');
72
-
73
- // 1. Calcolo HA1 base
74
- const ha1 = md5(`${node.user}:${realm}:${node.pass}`);
75
-
76
- // 2. Calcolo HA2: SE L'NVR CHIEDE AUTH-INT, INSERIAMO L'HASH DEL BODY XML!
77
- let ha2;
78
- if (qop && qop.includes('auth-int')) {
79
- const bodyHash = md5(xmlBody);
80
- ha2 = md5(`POST:${urlPath}:${bodyHash}`);
81
- } else {
82
- ha2 = md5(`POST:${urlPath}`);
83
- }
84
-
85
- // 3. Generazione parametri finali dell'handshake
86
- const cnonce = crypto.randomBytes(8).toString('hex');
87
- const nc = "00000001";
88
- let response;
89
- let authString;
90
-
91
- if (qop) {
92
- // Se c'è il qop (auth o auth-int), usiamo la formula estesa
93
- const actualQop = qop.includes('auth-int') ? 'auth-int' : 'auth';
94
- response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${actualQop}:${ha2}`);
95
- authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", qop="${actualQop}", nc=${nc}, cnonce="${cnonce}", response="${response}"`;
96
- } else {
97
- // Altrimenti andiamo di Digest classico
98
- response = md5(`${ha1}:${nonce}:${ha2}`);
99
- authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", response="${response}"`;
100
- }
101
-
102
- // Secondo invio definitivo con l'header Authorization corretto al millesimo
103
- const finalOptions = {
104
- hostname: node.host,
105
- port: node.port,
106
- path: urlPath,
107
- method: 'POST',
108
- headers: {
109
- 'Content-Type': 'application/xml; charset=UTF-8',
110
- 'Authorization': authString,
111
- 'Content-Length': Buffer.byteLength(xmlBody, 'utf8')
112
- }
113
- };
114
-
115
- const finalReq = http.request(finalOptions, (finalRes) => {
116
- let finalChunks = [];
117
- finalRes.on('data', (c) => finalChunks.push(c));
118
- finalRes.on('end', () => {
119
- resolve({
120
- statusCode: finalRes.statusCode,
121
- data: Buffer.concat(finalChunks)
122
- });
123
- });
124
- });
125
- finalReq.on('error', (err) => reject(err));
126
- finalReq.write(xmlBody, 'utf8');
127
- finalReq.end();
128
- } else {
129
- resolve({ statusCode: res.statusCode, data: body });
130
- }
131
- });
132
- });
133
- req.on('error', (err) => reject(err));
134
- req.write(xmlBody, 'utf8');
135
- req.end();
136
- });
137
- }
138
-
139
- // --- PRENDE IL NOME DELLA TELECAMERA DALL'NVR ---
40
+ // --- RECUPERA IL NOME DELLA TELECAMERA DALL'NVR ---
140
41
  async function getCameraName(channelID) {
141
42
  try {
142
43
  const res = await nvrAuthAxios.request({
@@ -152,7 +53,7 @@ module.exports = function(RED) {
152
53
  }
153
54
  }
154
55
 
155
- // --- CONTROLLO STATUS CAMERE AUTOMATICO DALL'NVR ---
56
+ // --- CONTROLLO STATUS CANERE DIRETTAMENTE DALL'NVR ---
156
57
  async function checkCameras() {
157
58
  if (isClosing || !nvrOnline) return;
158
59
  try {
@@ -203,7 +104,7 @@ module.exports = function(RED) {
203
104
 
204
105
  const heartbeatInterval = setInterval(checkCameras, 30000);
205
106
 
206
- // --- DOWNLOAD MEDIA RIGENERATO CON MOTORE NATIVO ---
107
+ // --- DOWNLOAD MEDIA SBLOCCATO VIA cURL ---
207
108
  async function downloadMedia(evento, channelID) {
208
109
  const chStr = channelID.toString();
209
110
  const nowTime = Date.now();
@@ -214,10 +115,12 @@ module.exports = function(RED) {
214
115
  const nomeCamera = await getCameraName(channelID);
215
116
  const referenceTime = new Date();
216
117
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
217
- const startTime = toHikDate(new Date(referenceTime.getTime() - (15 * 1000))); // Allargato a 15s per sicurezza
218
- const endTime = toHikDate(new Date(referenceTime.getTime() + (15 * 1000)));
118
+ const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
119
+ const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
219
120
 
220
121
  node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
122
+
123
+ // Aspettiamo che l'NVR scriva il file fisicamente sul disco
221
124
  await new Promise(resolve => setTimeout(resolve, 6000));
222
125
 
223
126
  let output = {
@@ -229,49 +132,63 @@ module.exports = function(RED) {
229
132
  let fileDaCancellare = [];
230
133
 
231
134
  try {
232
- const trackV = (channelID * 100 + 1).toString();
233
- const trackI = (channelID * 100 + 3).toString();
135
+ const trackV = (channelID * 100 + 1).toString(); // Es: Canale 2 -> 201
136
+ const trackI = (channelID * 100 + 3).toString(); // Es: Canale 2 -> 203
234
137
  const tracks = [{ id: trackV }, { id: trackI }];
235
138
 
236
139
  for (let t of tracks) {
237
140
  let searchXml = ``;
238
141
  if (t.id === trackV) {
239
- searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>LAST_EVENT</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>`;
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();
240
143
  } else {
241
- 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><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>recordType.meta.hikvision.com/${evento}</metadataDescriptor></metadataList></CMSearchDescription>`;
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();
242
145
  }
243
146
 
244
- // USIAMO IL MOTORE NATIVO: Ritorna la risposta dell'NVR pulita senza subire il bug di Axios!
245
- const resSearch = await nativeDigestPost('/ISAPI/ContentMgmt/search', searchXml);
246
-
247
- if (resSearch.statusCode !== 200) {
248
- node.error(`[NVR REJECT] La ricerca ha risposto con codice errore: ${resSearch.statusCode}`);
147
+ // Chiamata cURL nativa del sistema operativo: se lo fa Chrome, lo fa anche cURL senza fallire sul Digest
148
+ const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`;
149
+ const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d '${searchXml}' "${targetUrl}"`;
150
+
151
+ let xmlResponse = await new Promise((resolve) => {
152
+ exec(curlCommand, (err, stdout, stderr) => {
153
+ if (err) resolve(null);
154
+ else resolve(stdout);
155
+ });
156
+ });
157
+
158
+ if (!xmlResponse || xmlResponse.includes("400 Bad Request") || xmlResponse.includes("401 Unauthorized")) {
249
159
  continue;
250
160
  }
251
161
 
252
- let xml = resSearch.data.toString().replace(/<(\/?)\w+:/g, "<$1");
162
+ let xml = xmlResponse.replace(/<(\/?)\w+:/g, "<$1");
253
163
  const uriMatch = xml.match(/<playbackURI>([^<]+)</);
254
164
 
255
165
  if (uriMatch) {
256
166
  const rawUri = uriMatch[1].replace(/&amp;/g, '&');
257
- const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`;
167
+ const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`.trim();
258
168
 
259
- // Anche il download lo facciamo con il motore nativo per evitare sballi binari
260
- const resDown = await nativeDigestPost('/ISAPI/ContentMgmt/download', downXml);
169
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
170
+ const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
171
+ const targetFilePath = (t.id === trackI) ? fullImgPath : rawPath;
172
+
173
+ // cURL gestisce anche il download binario immenso salvando direttamente su file (-o)
174
+ const downUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download`;
175
+ const curlDownCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d '${downXml}' "${downUrl}" -o "${targetFilePath}"`;
176
+
177
+ await new Promise((resolve) => {
178
+ exec(curlDownCommand, () => resolve());
179
+ });
261
180
 
262
- if (resDown.statusCode === 200) {
263
- let buffer = resDown.data;
181
+ if (fs.existsSync(targetFilePath) && fs.statSync(targetFilePath).size > 0) {
264
182
  if (t.id === trackI) {
265
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
266
- fs.writeFileSync(fullImgPath, buffer);
267
183
  output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
268
184
  fileDaCancellare.push(fullImgPath);
269
185
  } else {
270
- if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
271
- const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
272
186
  const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
273
- fs.writeFileSync(rawPath, buffer);
274
-
187
+ let videoBuffer = fs.readFileSync(rawPath);
188
+ if (videoBuffer.slice(0, 4).toString() === 'IMKH') {
189
+ try { fs.writeFileSync(rawPath, videoBuffer.slice(40)); } catch(e){}
190
+ }
191
+
275
192
  await new Promise((resolve) => {
276
193
  exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
277
194
  if (!err && fs.existsSync(fixedPath)) {
@@ -304,7 +221,7 @@ module.exports = function(RED) {
304
221
  updateNodeStatus();
305
222
  }
306
223
 
307
- // --- ALERT STREAM CON AXIOS (Super Stabile) ---
224
+ // --- ALERT STREAM DI VEDETTA (Super Stabile con Axios) ---
308
225
  function startAlertStream() {
309
226
  if (isClosing) return;
310
227
  const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
@@ -319,12 +236,14 @@ module.exports = function(RED) {
319
236
  updateNodeStatus();
320
237
 
321
238
  response.data.on('data', (chunk) => {
322
- const data = chunk.toString().toLowerCase();
323
- if (data.includes("active")) {
324
- const chMatch = data.match(/<channelid>(\d+)<\/channelid>/i);
239
+ const data = chunk.toString();
240
+ if (data.toLowerCase().includes("active")) {
241
+ const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
325
242
  if (chMatch) {
326
243
  let ev = "Unknown";
327
- for (let e of EventList) { if (data.includes(e.toLowerCase())) { ev = e; break; } }
244
+ for (let e of EventList) {
245
+ if (data.toLowerCase().includes(e.toLowerCase())) { ev = e; break; }
246
+ }
328
247
  downloadMedia(ev, chMatch[1]);
329
248
  }
330
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.87",
3
+ "version": "1.1.88",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",