node-red-contrib-hik-media-buffer 1.1.84 → 1.1.86

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 +145 -45
  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');
@@ -29,11 +30,109 @@ module.exports = function(RED) {
29
30
 
30
31
  node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
31
32
 
32
- function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
33
+ function toHikDate(d) {
34
+ const pad = (num) => String(num).padStart(2, '0');
35
+
36
+ // Estraiamo i componenti dell'ora LOCALE della macchina su cui gira Node-RED
37
+ const year = d.getFullYear();
38
+ const month = pad(d.getMonth() + 1);
39
+ const day = pad(d.getDate());
40
+ const hours = pad(d.getHours());
41
+ const minutes = pad(d.getMinutes());
42
+ const seconds = pad(d.getSeconds());
43
+
44
+ // Formato finale richiesto dagli NVR Hikvision: YYYY-MM-DDTHH:mm:ss (SENZA "Z" E SENZA OFFSET!)
45
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
46
+ }
33
47
 
34
- // Unica libreria di autenticazione usata per tutto il flusso
48
+ // Manteniamo AxiosDigest solo per lo stream degli allarmi e lo stato dei canali (dove va da dio)
35
49
  const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
36
50
 
51
+ // --- FUNZIONE AUSILIARIA PER FARE LA POST DIGEST CON HTTP NATIVO (ELIMINA IL 400) ---
52
+ function nativeDigestPost(urlPath, xmlBody) {
53
+ return new Promise((resolve, reject) => {
54
+ // Primo invio a vuoto per scatenare il 401 dell'handshake Digest
55
+ const options = {
56
+ hostname: node.host,
57
+ port: node.port,
58
+ path: urlPath,
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/xml', 'Content-Length': Buffer.byteLength(xmlBody) }
61
+ };
62
+
63
+ const req = http.request(options, (res) => {
64
+ let chunks = [];
65
+ res.on('data', (chunk) => chunks.push(chunk));
66
+ res.on('end', () => {
67
+ const body = Buffer.concat(chunks).toString();
68
+
69
+ // Se l'NVR risponde 401 (normale al primo colpo), estraiamo i parametri Digest
70
+ if (res.statusCode === 401) {
71
+ const authHeader = res.headers['www-authenticate'];
72
+ if (!authHeader) return reject(new Error("Manca header WWW-Authenticate"));
73
+
74
+ // Estrazione parametri con regex
75
+ const realm = authHeader.match(/realm="([^"]+)"/)[1];
76
+ const nonce = authHeader.match(/nonce="([^"]+)"/)[1];
77
+ const qopMatch = authHeader.match(/qop="([^"]+)"/);
78
+ const qop = qopMatch ? qopMatch[1] : null;
79
+
80
+ // Calcolo degli Hash MD5 standard dell'handshake (Come fa Chrome!)
81
+ const crypto = require('crypto');
82
+ const md5 = (str) => crypto.createHash('md5').update(str).digest('hex');
83
+
84
+ const ha1 = md5(`${node.user}:${realm}:${node.pass}`);
85
+ const ha2 = md5(`POST:${urlPath}`);
86
+
87
+ let authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", response=""`;
88
+
89
+ if (qop) {
90
+ const cnonce = crypto.randomBytes(8).toString('hex');
91
+ const nc = "00000001";
92
+ const response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
93
+ authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${response}"`;
94
+ } else {
95
+ const response = md5(`${ha1}:${nonce}:${ha2}`);
96
+ authString = `Digest username="${node.user}", realm="${realm}", nonce="${nonce}", uri="${urlPath}", response="${response}"`;
97
+ }
98
+
99
+ // Secondo invio definitivo con l'autenticazione calcolata al millimetro
100
+ const finalOptions = {
101
+ hostname: node.host,
102
+ port: node.port,
103
+ path: urlPath,
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/xml',
107
+ 'Authorization': authString,
108
+ 'Content-Length': Buffer.byteLength(xmlBody)
109
+ }
110
+ };
111
+
112
+ const finalReq = http.request(finalOptions, (finalRes) => {
113
+ let finalChunks = [];
114
+ finalRes.on('data', (c) => finalChunks.push(c));
115
+ finalRes.on('end', () => {
116
+ resolve({
117
+ statusCode: finalRes.statusCode,
118
+ data: Buffer.concat(finalChunks)
119
+ });
120
+ });
121
+ });
122
+ finalReq.on('error', (err) => reject(err));
123
+ finalReq.write(xmlBody);
124
+ finalReq.end();
125
+ } else {
126
+ resolve({ statusCode: res.statusCode, data: body });
127
+ }
128
+ });
129
+ });
130
+ req.on('error', (err) => reject(err));
131
+ req.write(xmlBody);
132
+ req.end();
133
+ });
134
+ }
135
+
37
136
  // --- PRENDE IL NOME DELLA TELECAMERA DALL'NVR ---
38
137
  async function getCameraName(channelID) {
39
138
  try {
@@ -101,7 +200,7 @@ module.exports = function(RED) {
101
200
 
102
201
  const heartbeatInterval = setInterval(checkCameras, 30000);
103
202
 
104
- // --- DOWNLOAD MEDIA UTILIZZANDO SOLO AXIOS ---
203
+ // --- DOWNLOAD MEDIA RIGENERATO CON MOTORE NATIVO ---
105
204
  async function downloadMedia(evento, channelID) {
106
205
  const chStr = channelID.toString();
107
206
  const nowTime = Date.now();
@@ -112,8 +211,8 @@ module.exports = function(RED) {
112
211
  const nomeCamera = await getCameraName(channelID);
113
212
  const referenceTime = new Date();
114
213
  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)));
214
+ const startTime = toHikDate(new Date(referenceTime.getTime() - (15 * 1000))); // Allargato a 15s per sicurezza
215
+ const endTime = toHikDate(new Date(referenceTime.getTime() + (15 * 1000)));
117
216
 
118
217
  node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
119
218
  await new Promise(resolve => setTimeout(resolve, 6000));
@@ -127,61 +226,62 @@ module.exports = function(RED) {
127
226
  let fileDaCancellare = [];
128
227
 
129
228
  try {
130
- try { await nvrAuthAxios.request({ method: 'POST', url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`, data: "" }); } catch(e) {}
131
229
  const trackV = (channelID * 100 + 1).toString();
132
230
  const trackI = (channelID * 100 + 3).toString();
133
231
  const tracks = [{ id: trackV }, { id: trackI }];
134
- let searchXml = ``;
135
- for (let t of tracks) {
136
232
 
233
+ for (let t of tracks) {
234
+ let searchXml = ``;
137
235
  if (t.id === trackV) {
138
236
  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
237
  } else {
140
238
  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
239
  }
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
240
 
149
- let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
241
+ // USIAMO IL MOTORE NATIVO: Ritorna la risposta dell'NVR pulita senza subire il bug di Axios!
242
+ const resSearch = await nativeDigestPost('/ISAPI/ContentMgmt/search', searchXml);
243
+
244
+ if (resSearch.statusCode !== 200) {
245
+ node.error(`[NVR REJECT] La ricerca ha risposto con codice errore: ${resSearch.statusCode}`);
246
+ continue;
247
+ }
248
+
249
+ let xml = resSearch.data.toString().replace(/<(\/?)\w+:/g, "<$1");
150
250
  const uriMatch = xml.match(/<playbackURI>([^<]+)</);
151
251
 
152
252
  if (uriMatch) {
153
253
  const rawUri = uriMatch[1].replace(/&amp;/g, '&');
254
+ const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`;
154
255
 
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();
256
+ // Anche il download lo facciamo con il motore nativo per evitare sballi binari
257
+ const resDown = await nativeDigestPost('/ISAPI/ContentMgmt/download', downXml);
258
+
259
+ if (resDown.statusCode === 200) {
260
+ let buffer = resDown.data;
261
+ if (t.id === trackI) {
262
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
263
+ fs.writeFileSync(fullImgPath, buffer);
264
+ output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
265
+ fileDaCancellare.push(fullImgPath);
266
+ } else {
267
+ if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
268
+ const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
269
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
270
+ fs.writeFileSync(rawPath, buffer);
271
+
272
+ await new Promise((resolve) => {
273
+ exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
274
+ if (!err && fs.existsSync(fixedPath)) {
275
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
276
+ fileDaCancellare.push(fixedPath);
277
+ } else {
278
+ output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
279
+ }
280
+ fileDaCancellare.push(rawPath);
281
+ resolve();
282
+ });
183
283
  });
184
- });
284
+ }
185
285
  }
186
286
  }
187
287
  }
@@ -201,7 +301,7 @@ module.exports = function(RED) {
201
301
  updateNodeStatus();
202
302
  }
203
303
 
204
- // --- ALERT STREAM CON AXIOS ---
304
+ // --- ALERT STREAM CON AXIOS (Super Stabile) ---
205
305
  function startAlertStream() {
206
306
  if (isClosing) return;
207
307
  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.86",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",