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.
- package/hik-media-buffer.js +145 -45
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -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) {
|
|
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
|
-
//
|
|
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
|
|
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() - (
|
|
116
|
-
const endTime = toHikDate(new Date(referenceTime.getTime() + (
|
|
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
|
-
|
|
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(/&/g, '&');
|
|
254
|
+
const downXml = `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`;
|
|
154
255
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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`;
|