node-red-contrib-hik-media-buffer 1.1.87 → 1.1.89
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.
- package/hik-media-buffer.js +59 -135
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
// ---
|
|
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
|
|
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
|
|
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() - (
|
|
218
|
-
const endTime = toHikDate(new Date(referenceTime.getTime() + (
|
|
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,68 @@ 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>
|
|
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>
|
|
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
|
-
//
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
+
// >>> AGGIUNGI QUESTE RIGHE DI LOG QUI PER VEDERE COSA COZZO RISPONDE <<<
|
|
159
|
+
node.warn(`[DEBUG NVR RESP] Risposta grezza per traccia ${t.id}:`);
|
|
160
|
+
node.warn(xmlResponse);
|
|
161
|
+
|
|
162
|
+
if (!xmlResponse || xmlResponse.includes("400 Bad Request") || xmlResponse.includes("401 Unauthorized")) {
|
|
163
|
+
node.error(`[DEBUG NVR ERRORE] Risposta KO da curl su traccia ${t.id}`);
|
|
249
164
|
continue;
|
|
250
165
|
}
|
|
251
166
|
|
|
252
|
-
let xml =
|
|
167
|
+
let xml = xmlResponse.replace(/<(\/?)\w+:/g, "<$1");
|
|
253
168
|
const uriMatch = xml.match(/<playbackURI>([^<]+)</);
|
|
254
169
|
|
|
255
170
|
if (uriMatch) {
|
|
256
171
|
const rawUri = uriMatch[1].replace(/&/g, '&');
|
|
257
|
-
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest
|
|
172
|
+
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`.trim();
|
|
258
173
|
|
|
259
|
-
|
|
260
|
-
const
|
|
174
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
175
|
+
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
176
|
+
const targetFilePath = (t.id === trackI) ? fullImgPath : rawPath;
|
|
177
|
+
|
|
178
|
+
// cURL gestisce anche il download binario immenso salvando direttamente su file (-o)
|
|
179
|
+
const downUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/download`;
|
|
180
|
+
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}"`;
|
|
181
|
+
|
|
182
|
+
await new Promise((resolve) => {
|
|
183
|
+
exec(curlDownCommand, () => resolve());
|
|
184
|
+
});
|
|
261
185
|
|
|
262
|
-
if (
|
|
263
|
-
let buffer = resDown.data;
|
|
186
|
+
if (fs.existsSync(targetFilePath) && fs.statSync(targetFilePath).size > 0) {
|
|
264
187
|
if (t.id === trackI) {
|
|
265
|
-
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
266
|
-
fs.writeFileSync(fullImgPath, buffer);
|
|
267
188
|
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
268
189
|
fileDaCancellare.push(fullImgPath);
|
|
269
190
|
} else {
|
|
270
|
-
if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
|
|
271
|
-
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
272
191
|
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
273
|
-
fs.
|
|
274
|
-
|
|
192
|
+
let videoBuffer = fs.readFileSync(rawPath);
|
|
193
|
+
if (videoBuffer.slice(0, 4).toString() === 'IMKH') {
|
|
194
|
+
try { fs.writeFileSync(rawPath, videoBuffer.slice(40)); } catch(e){}
|
|
195
|
+
}
|
|
196
|
+
|
|
275
197
|
await new Promise((resolve) => {
|
|
276
198
|
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
277
199
|
if (!err && fs.existsSync(fixedPath)) {
|
|
@@ -304,7 +226,7 @@ module.exports = function(RED) {
|
|
|
304
226
|
updateNodeStatus();
|
|
305
227
|
}
|
|
306
228
|
|
|
307
|
-
// --- ALERT STREAM
|
|
229
|
+
// --- ALERT STREAM DI VEDETTA (Super Stabile con Axios) ---
|
|
308
230
|
function startAlertStream() {
|
|
309
231
|
if (isClosing) return;
|
|
310
232
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|
|
@@ -319,12 +241,14 @@ module.exports = function(RED) {
|
|
|
319
241
|
updateNodeStatus();
|
|
320
242
|
|
|
321
243
|
response.data.on('data', (chunk) => {
|
|
322
|
-
const data = chunk.toString()
|
|
323
|
-
if (data.includes("active")) {
|
|
324
|
-
const chMatch = data.match(/<
|
|
244
|
+
const data = chunk.toString();
|
|
245
|
+
if (data.toLowerCase().includes("active")) {
|
|
246
|
+
const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
|
|
325
247
|
if (chMatch) {
|
|
326
248
|
let ev = "Unknown";
|
|
327
|
-
for (let e of EventList) {
|
|
249
|
+
for (let e of EventList) {
|
|
250
|
+
if (data.toLowerCase().includes(e.toLowerCase())) { ev = e; break; }
|
|
251
|
+
}
|
|
328
252
|
downloadMedia(ev, chMatch[1]);
|
|
329
253
|
}
|
|
330
254
|
}
|