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.
- package/hik-media-buffer.js +53 -131
- 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,110 +29,15 @@ module.exports = function(RED) {
|
|
|
30
29
|
|
|
31
30
|
node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
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
|
-
// ---
|
|
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
|
|
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
|
|
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() - (
|
|
215
|
-
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)));
|
|
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>
|
|
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>
|
|
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
|
-
//
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
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(/&/g, '&');
|
|
254
|
-
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest
|
|
167
|
+
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`.trim();
|
|
255
168
|
|
|
256
|
-
|
|
257
|
-
const
|
|
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
|
-
|
|
260
|
-
|
|
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.
|
|
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
|
|
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()
|
|
320
|
-
if (data.includes("active")) {
|
|
321
|
-
const chMatch = data.match(/<
|
|
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) {
|
|
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
|
}
|