node-red-contrib-hik-media-buffer 1.1.83 → 1.1.85

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 +128 -47
  2. package/package.json +1 -1
@@ -1,5 +1,6 @@
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
3
4
  const fs = require('fs');
4
5
  const path = require('path');
5
6
  const os = require('os');
@@ -31,9 +32,94 @@ module.exports = function(RED) {
31
32
 
32
33
  function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
33
34
 
34
- // Unica libreria di autenticazione usata per tutto il flusso
35
+ // Manteniamo AxiosDigest solo per lo stream degli allarmi e lo stato dei canali (dove va da dio)
35
36
  const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
36
37
 
38
+ // --- FUNZIONE AUSILIARIA PER FARE LA POST DIGEST CON HTTP NATIVO (ELIMINA IL 400) ---
39
+ function nativeDigestPost(urlPath, xmlBody) {
40
+ return new Promise((resolve, reject) => {
41
+ // Primo invio a vuoto per scatenare il 401 dell'handshake Digest
42
+ const options = {
43
+ hostname: node.host,
44
+ port: node.port,
45
+ path: urlPath,
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/xml', 'Content-Length': Buffer.byteLength(xmlBody) }
48
+ };
49
+
50
+ const req = http.request(options, (res) => {
51
+ let chunks = [];
52
+ res.on('data', (chunk) => chunks.push(chunk));
53
+ res.on('end', () => {
54
+ const body = Buffer.concat(chunks).toString();
55
+
56
+ // Se l'NVR risponde 401 (normale al primo colpo), estraiamo i parametri Digest
57
+ if (res.statusCode === 401) {
58
+ const authHeader = res.headers['www-authenticate'];
59
+ if (!authHeader) return reject(new Error("Manca header WWW-Authenticate"));
60
+
61
+ // Estrazione parametri con regex
62
+ const realm = authHeader.match(/realm="([^"]+)"/)[1];
63
+ const nonce = authHeader.match(/nonce="([^"]+)"/)[1];
64
+ const qopMatch = authHeader.match(/qop="([^"]+)"/);
65
+ const qop = qopMatch ? qopMatch[1] : null;
66
+
67
+ // Calcolo degli Hash MD5 standard dell'handshake (Come fa Chrome!)
68
+ const crypto = require('crypto');
69
+ const md5 = (str) => crypto.createHash('md5').update(str).digest('hex');
70
+
71
+ const ha1 = md5(`${node.user}:${realm}:${node.pass}`);
72
+ const ha2 = md5(`POST:${urlPath}`);
73
+
74
+ let authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", response=""`;
75
+
76
+ if (qop) {
77
+ const cnonce = crypto.randomBytes(8).toString('hex');
78
+ const nc = "00000001";
79
+ const response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
80
+ authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${response}"`;
81
+ } else {
82
+ const response = md5(`${ha1}:${nonce}:${ha2}`);
83
+ authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", response="${response}"`;
84
+ }
85
+
86
+ // Secondo invio definitivo con l'autenticazione calcolata al millimetro
87
+ const finalOptions = {
88
+ hostname: node.host,
89
+ port: node.port,
90
+ path: urlPath,
91
+ method: 'POST',
92
+ headers: {
93
+ 'Content-Type': 'application/xml',
94
+ 'Authorization': authString,
95
+ 'Content-Length': Buffer.byteLength(xmlBody)
96
+ }
97
+ };
98
+
99
+ const finalReq = http.request(finalOptions, (finalRes) => {
100
+ let finalChunks = [];
101
+ finalRes.on('data', (c) => finalChunks.push(c));
102
+ finalRes.on('end', () => {
103
+ resolve({
104
+ statusCode: finalRes.statusCode,
105
+ data: Buffer.concat(finalChunks)
106
+ });
107
+ });
108
+ });
109
+ finalReq.on('error', (err) => reject(err));
110
+ finalReq.write(xmlBody);
111
+ finalReq.end();
112
+ } else {
113
+ resolve({ statusCode: res.statusCode, data: body });
114
+ }
115
+ });
116
+ });
117
+ req.on('error', (err) => reject(err));
118
+ req.write(xmlBody);
119
+ req.end();
120
+ });
121
+ }
122
+
37
123
  // --- PRENDE IL NOME DELLA TELECAMERA DALL'NVR ---
38
124
  async function getCameraName(channelID) {
39
125
  try {
@@ -101,7 +187,7 @@ module.exports = function(RED) {
101
187
 
102
188
  const heartbeatInterval = setInterval(checkCameras, 30000);
103
189
 
104
- // --- DOWNLOAD MEDIA UTILIZZANDO SOLO AXIOS ---
190
+ // --- DOWNLOAD MEDIA RIGENERATO CON MOTORE NATIVO ---
105
191
  async function downloadMedia(evento, channelID) {
106
192
  const chStr = channelID.toString();
107
193
  const nowTime = Date.now();
@@ -112,8 +198,8 @@ module.exports = function(RED) {
112
198
  const nomeCamera = await getCameraName(channelID);
113
199
  const referenceTime = new Date();
114
200
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
115
- const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
116
- const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
201
+ const startTime = toHikDate(new Date(referenceTime.getTime() - (15 * 1000))); // Allargato a 15s per sicurezza
202
+ const endTime = toHikDate(new Date(referenceTime.getTime() + (15 * 1000)));
117
203
 
118
204
  node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
119
205
  await new Promise(resolve => setTimeout(resolve, 6000));
@@ -139,55 +225,50 @@ module.exports = function(RED) {
139
225
  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>`;
140
226
  }
141
227
 
142
- // Forza l'XML in un buffer per evitare che Axios sballi la lunghezza della stringa HTTP
143
- const xmlBuffer = Buffer.from(searchXml, 'utf-8');
144
-
145
- const resSearch = await nvrAuthAxios.request({
146
- method: 'POST',
147
- url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`,
148
- data: xmlBuffer,
149
- headers: {
150
- "Content-Type": "application/xml; charset=UTF-8",
151
- "Content-Length": xmlBuffer.length
152
- }
153
- });
228
+ // USIAMO IL MOTORE NATIVO: Ritorna la risposta dell'NVR pulita senza subire il bug di Axios!
229
+ const resSearch = await nativeDigestPost('/ISAPI/ContentMgmt/search', searchXml);
154
230
 
155
- let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
231
+ if (resSearch.statusCode !== 200) {
232
+ node.error(`[NVR REJECT] La ricerca ha risposto con codice errore: ${resSearch.statusCode}`);
233
+ continue;
234
+ }
235
+
236
+ let xml = resSearch.data.toString().replace(/<(\/?)\w+:/g, "<$1");
156
237
  const uriMatch = xml.match(/<playbackURI>([^<]+)</);
157
238
 
158
239
  if (uriMatch) {
159
240
  const rawUri = uriMatch[1].replace(/&amp;/g, '&');
241
+ const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`;
160
242
 
161
- const resDown = await nvrAuthAxios.request({
162
- method: 'GET',
163
- url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download?playbackURI=${encodeURIComponent(rawUri)}`,
164
- responseType: 'arraybuffer'
165
- });
166
-
167
- let buffer = Buffer.from(resDown.data);
168
- if (t.id === trackI) {
169
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
170
- fs.writeFileSync(fullImgPath, buffer);
171
- output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
172
- fileDaCancellare.push(fullImgPath);
173
- } else {
174
- if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
175
- const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
176
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
177
- fs.writeFileSync(rawPath, buffer);
178
-
179
- await new Promise((resolve) => {
180
- exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
181
- if (!err && fs.existsSync(fixedPath)) {
182
- output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
183
- fileDaCancellare.push(fixedPath);
184
- } else {
185
- output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
186
- }
187
- fileDaCancellare.push(rawPath);
188
- resolve();
243
+ // Anche il download lo facciamo con il motore nativo per evitare sballi binari
244
+ const resDown = await nativeDigestPost('/ISAPI/ContentMgmt/download', downXml);
245
+
246
+ if (resDown.statusCode === 200) {
247
+ let buffer = resDown.data;
248
+ if (t.id === trackI) {
249
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
250
+ fs.writeFileSync(fullImgPath, buffer);
251
+ output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
252
+ fileDaCancellare.push(fullImgPath);
253
+ } else {
254
+ if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
255
+ const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
256
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
257
+ fs.writeFileSync(rawPath, buffer);
258
+
259
+ await new Promise((resolve) => {
260
+ exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
261
+ if (!err && fs.existsSync(fixedPath)) {
262
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
263
+ fileDaCancellare.push(fixedPath);
264
+ } else {
265
+ output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
266
+ }
267
+ fileDaCancellare.push(rawPath);
268
+ resolve();
269
+ });
189
270
  });
190
- });
271
+ }
191
272
  }
192
273
  }
193
274
  }
@@ -207,7 +288,7 @@ module.exports = function(RED) {
207
288
  updateNodeStatus();
208
289
  }
209
290
 
210
- // --- ALERT STREAM CON AXIOS ---
291
+ // --- ALERT STREAM CON AXIOS (Super Stabile) ---
211
292
  function startAlertStream() {
212
293
  if (isClosing) return;
213
294
  const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.83",
3
+ "version": "1.1.85",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",