node-red-contrib-hik-media-buffer 1.1.84 → 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 +131 -44
  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));
@@ -127,61 +213,62 @@ module.exports = function(RED) {
127
213
  let fileDaCancellare = [];
128
214
 
129
215
  try {
130
- try { await nvrAuthAxios.request({ method: 'POST', url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`, data: "" }); } catch(e) {}
131
216
  const trackV = (channelID * 100 + 1).toString();
132
217
  const trackI = (channelID * 100 + 3).toString();
133
218
  const tracks = [{ id: trackV }, { id: trackI }];
134
- let searchXml = ``;
135
- for (let t of tracks) {
136
219
 
220
+ for (let t of tracks) {
221
+ let searchXml = ``;
137
222
  if (t.id === trackV) {
138
223
  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>`;
139
224
  } else {
140
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>`;
141
226
  }
142
- const resSearch = await nvrAuthAxios.request({
143
- method: 'POST',
144
- url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`,
145
- data: searchXml,
146
- headers: { "Content-Type": "application/xml; charset=UTF-8" }
147
- });
148
227
 
149
- let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
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);
230
+
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");
150
237
  const uriMatch = xml.match(/<playbackURI>([^<]+)</);
151
238
 
152
239
  if (uriMatch) {
153
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>`;
154
242
 
155
- const resDown = await nvrAuthAxios.request({
156
- method: 'GET',
157
- url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download?playbackURI=${encodeURIComponent(rawUri)}`,
158
- responseType: 'arraybuffer'
159
- });
160
-
161
- let buffer = Buffer.from(resDown.data);
162
- if (t.id === trackI) {
163
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
164
- fs.writeFileSync(fullImgPath, buffer);
165
- output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
166
- fileDaCancellare.push(fullImgPath);
167
- } else {
168
- if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
169
- const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
170
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
171
- fs.writeFileSync(rawPath, buffer);
172
-
173
- await new Promise((resolve) => {
174
- exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
175
- if (!err && fs.existsSync(fixedPath)) {
176
- output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
177
- fileDaCancellare.push(fixedPath);
178
- } else {
179
- output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
180
- }
181
- fileDaCancellare.push(rawPath);
182
- 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
+ });
183
270
  });
184
- });
271
+ }
185
272
  }
186
273
  }
187
274
  }
@@ -201,7 +288,7 @@ module.exports = function(RED) {
201
288
  updateNodeStatus();
202
289
  }
203
290
 
204
- // --- ALERT STREAM CON AXIOS ---
291
+ // --- ALERT STREAM CON AXIOS (Super Stabile) ---
205
292
  function startAlertStream() {
206
293
  if (isClosing) return;
207
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.84",
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",