node-red-contrib-hik-media-buffer 1.1.86 → 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 +53 -131
  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,110 +29,15 @@ module.exports = function(RED) {
30
29
 
31
30
  node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
32
31
 
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}`;
32
+ // Funzione data ISO standard con la Z che Chrome usa nell'NVR
33
+ function toHikDate(d) {
34
+ return d.toISOString().split('.')[0] + "Z";
46
35
  }
47
36
 
48
- // 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)
49
38
  const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
50
39
 
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
-
136
- // --- PRENDE IL NOME DELLA TELECAMERA DALL'NVR ---
40
+ // --- RECUPERA IL NOME DELLA TELECAMERA DALL'NVR ---
137
41
  async function getCameraName(channelID) {
138
42
  try {
139
43
  const res = await nvrAuthAxios.request({
@@ -149,7 +53,7 @@ module.exports = function(RED) {
149
53
  }
150
54
  }
151
55
 
152
- // --- CONTROLLO STATUS CAMERE AUTOMATICO DALL'NVR ---
56
+ // --- CONTROLLO STATUS CANERE DIRETTAMENTE DALL'NVR ---
153
57
  async function checkCameras() {
154
58
  if (isClosing || !nvrOnline) return;
155
59
  try {
@@ -200,7 +104,7 @@ module.exports = function(RED) {
200
104
 
201
105
  const heartbeatInterval = setInterval(checkCameras, 30000);
202
106
 
203
- // --- DOWNLOAD MEDIA RIGENERATO CON MOTORE NATIVO ---
107
+ // --- DOWNLOAD MEDIA SBLOCCATO VIA cURL ---
204
108
  async function downloadMedia(evento, channelID) {
205
109
  const chStr = channelID.toString();
206
110
  const nowTime = Date.now();
@@ -211,10 +115,12 @@ module.exports = function(RED) {
211
115
  const nomeCamera = await getCameraName(channelID);
212
116
  const referenceTime = new Date();
213
117
  const timestamp = Math.floor(referenceTime.getTime() / 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)));
118
+ const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
119
+ const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
216
120
 
217
121
  node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
122
+
123
+ // Aspettiamo che l'NVR scriva il file fisicamente sul disco
218
124
  await new Promise(resolve => setTimeout(resolve, 6000));
219
125
 
220
126
  let output = {
@@ -226,49 +132,63 @@ module.exports = function(RED) {
226
132
  let fileDaCancellare = [];
227
133
 
228
134
  try {
229
- const trackV = (channelID * 100 + 1).toString();
230
- 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
231
137
  const tracks = [{ id: trackV }, { id: trackI }];
232
138
 
233
139
  for (let t of tracks) {
234
140
  let searchXml = ``;
235
141
  if (t.id === trackV) {
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>`;
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();
237
143
  } else {
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>`;
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();
239
145
  }
240
146
 
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}`);
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")) {
246
159
  continue;
247
160
  }
248
161
 
249
- let xml = resSearch.data.toString().replace(/<(\/?)\w+:/g, "<$1");
162
+ let xml = xmlResponse.replace(/<(\/?)\w+:/g, "<$1");
250
163
  const uriMatch = xml.match(/<playbackURI>([^<]+)</);
251
164
 
252
165
  if (uriMatch) {
253
166
  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>`;
167
+ const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`.trim();
255
168
 
256
- // Anche il download lo facciamo con il motore nativo per evitare sballi binari
257
- 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;
258
172
 
259
- if (resDown.statusCode === 200) {
260
- let buffer = resDown.data;
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
+ });
180
+
181
+ if (fs.existsSync(targetFilePath) && fs.statSync(targetFilePath).size > 0) {
261
182
  if (t.id === trackI) {
262
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
263
- fs.writeFileSync(fullImgPath, buffer);
264
183
  output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
265
184
  fileDaCancellare.push(fullImgPath);
266
185
  } else {
267
- if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
268
- const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
269
186
  const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
270
- fs.writeFileSync(rawPath, buffer);
271
-
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
+
272
192
  await new Promise((resolve) => {
273
193
  exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
274
194
  if (!err && fs.existsSync(fixedPath)) {
@@ -301,7 +221,7 @@ module.exports = function(RED) {
301
221
  updateNodeStatus();
302
222
  }
303
223
 
304
- // --- ALERT STREAM CON AXIOS (Super Stabile) ---
224
+ // --- ALERT STREAM DI VEDETTA (Super Stabile con Axios) ---
305
225
  function startAlertStream() {
306
226
  if (isClosing) return;
307
227
  const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
@@ -316,12 +236,14 @@ module.exports = function(RED) {
316
236
  updateNodeStatus();
317
237
 
318
238
  response.data.on('data', (chunk) => {
319
- const data = chunk.toString().toLowerCase();
320
- if (data.includes("active")) {
321
- 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);
322
242
  if (chMatch) {
323
243
  let ev = "Unknown";
324
- 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
+ }
325
247
  downloadMedia(ev, chMatch[1]);
326
248
  }
327
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.86",
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",