node-red-contrib-hik-media-buffer 1.1.54 → 1.1.56
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 +143 -109
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -12,7 +12,7 @@ module.exports = function(RED) {
|
|
|
12
12
|
const node = this;
|
|
13
13
|
|
|
14
14
|
node.name = config.name || "TEST";
|
|
15
|
-
node.host = config.host;
|
|
15
|
+
node.host = config.host; // IP dell'NVR centrale
|
|
16
16
|
node.port = config.port || "80";
|
|
17
17
|
node.protocol = config.protocol || "http";
|
|
18
18
|
node.user = config.user;
|
|
@@ -25,7 +25,11 @@ module.exports = function(RED) {
|
|
|
25
25
|
let statoCanale = {};
|
|
26
26
|
|
|
27
27
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
28
|
+
|
|
29
|
+
// EventList per l'alertStream
|
|
28
30
|
const EventList = ["FieldDetection", "LineDetection"];
|
|
31
|
+
|
|
32
|
+
// CARTELLA TEMPORANEA
|
|
29
33
|
const baseStorage = path.join(os.tmpdir(), "hik_temp_media");
|
|
30
34
|
if (!fs.existsSync(baseStorage)) fs.mkdirSync(baseStorage, { recursive: true });
|
|
31
35
|
|
|
@@ -33,6 +37,7 @@ module.exports = function(RED) {
|
|
|
33
37
|
|
|
34
38
|
function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
|
|
35
39
|
|
|
40
|
+
// --- PRENDE IL NOME DEL CANALE DIRETTAMENTE DALL'NVR ---
|
|
36
41
|
async function getCameraName(channelID) {
|
|
37
42
|
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
38
43
|
try {
|
|
@@ -42,6 +47,7 @@ module.exports = function(RED) {
|
|
|
42
47
|
timeout: 5000,
|
|
43
48
|
httpsAgent: node.protocol === "https" ? httpsAgent : undefined
|
|
44
49
|
});
|
|
50
|
+
|
|
45
51
|
const data = res.data.toString();
|
|
46
52
|
const match = data.match(/<name>([^<]+)<\/name>/i);
|
|
47
53
|
return match && match[1] ? match[1].trim() : `Canale_${channelID}`;
|
|
@@ -50,9 +56,11 @@ module.exports = function(RED) {
|
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
// --- CONTROLLO STATUS DINAMICO DEI CANALI DIGITALI DALL'NVR ---
|
|
53
60
|
async function checkCameras() {
|
|
54
61
|
if (isClosing) return;
|
|
55
62
|
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
63
|
+
|
|
56
64
|
try {
|
|
57
65
|
const res = await nvrAuth.request({
|
|
58
66
|
method: 'GET',
|
|
@@ -60,19 +68,23 @@ module.exports = function(RED) {
|
|
|
60
68
|
timeout: 5000,
|
|
61
69
|
httpsAgent: node.protocol === "https" ? httpsAgent : undefined
|
|
62
70
|
});
|
|
71
|
+
|
|
63
72
|
nvrOnline = true;
|
|
64
73
|
const xmlData = res.data.toString();
|
|
74
|
+
|
|
65
75
|
const bloccoRegex = /<InputProxyChannelStatus[\s\\S]*?>([\s\S]*?)<\/InputProxyChannelStatus>/gi;
|
|
66
76
|
let matchBlocco;
|
|
67
77
|
|
|
68
78
|
while ((matchBlocco = bloccoRegex.exec(xmlData)) !== null) {
|
|
69
79
|
const bloccoContenuto = matchBlocco[1];
|
|
80
|
+
|
|
70
81
|
const idMatch = bloccoContenuto.match(/<id>(\d+)<\/id>/i);
|
|
71
82
|
const statusMatch = bloccoContenuto.match(/<status>([^<]+)</i);
|
|
72
83
|
|
|
73
84
|
if (idMatch && statusMatch) {
|
|
74
85
|
const ch = idMatch[1];
|
|
75
86
|
const statusStr = statusMatch[1].toLowerCase();
|
|
87
|
+
|
|
76
88
|
const isOnline = statusStr.includes("online") || statusStr.includes("recording");
|
|
77
89
|
|
|
78
90
|
if (isOnline && statoCanale[ch] === false) {
|
|
@@ -106,42 +118,42 @@ module.exports = function(RED) {
|
|
|
106
118
|
|
|
107
119
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
108
120
|
|
|
109
|
-
// --- DOWNLOAD
|
|
121
|
+
// --- DOWNLOAD MULTIMEDIALE (XML PER FOTO + JSON PER VIDEO) ---
|
|
110
122
|
async function downloadMedia(evento, channelID) {
|
|
111
123
|
node.warn(`[STEP 1] Inizio downloadMedia. Evento: ${evento}, Canale originario: ${channelID}`);
|
|
112
124
|
|
|
113
125
|
const nowTime = Date.now();
|
|
114
126
|
if (!lastTriggerTime[channelID]) lastTriggerTime[channelID] = 0;
|
|
115
|
-
if (nowTime - lastTriggerTime[channelID] < 5000)
|
|
116
|
-
node.warn(`[STEP 1] Richiesta bloccata dall'antirimbalzo (meno di 5s dall'ultimo trigger)`);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
127
|
+
if (nowTime - lastTriggerTime[channelID] < 5000) return;
|
|
119
128
|
lastTriggerTime[channelID] = nowTime;
|
|
120
129
|
|
|
121
130
|
const camAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
122
131
|
const referenceTime = new Date();
|
|
123
132
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
124
133
|
|
|
125
|
-
//
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
|
|
134
|
+
// Finestre temporali standard UTC
|
|
135
|
+
const startTimeZ = toHikDate(new Date(referenceTime.getTime() - (20 * 1000)));
|
|
136
|
+
const endTimeZ = toHikDate(new Date(referenceTime.getTime() + (20 * 1000)));
|
|
137
|
+
|
|
138
|
+
// Finestre temporali per il JSON dell'NVR (con specifica dell'offset locale, es. "+02:00")
|
|
139
|
+
const formatJsonDate = (d) => {
|
|
140
|
+
const pad = (num) => String(num).padStart(2, '0');
|
|
141
|
+
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} 02:00`;
|
|
142
|
+
};
|
|
143
|
+
const startTimeJson = formatJsonDate(new Date(referenceTime.getTime() - (20 * 1000)));
|
|
144
|
+
const endTimeJson = formatJsonDate(new Date(referenceTime.getTime() + (20 * 1000)));
|
|
129
145
|
|
|
130
146
|
node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
|
|
131
147
|
const nomeCamera = await getCameraName(channelID);
|
|
132
148
|
|
|
133
|
-
node.warn(`[STEP
|
|
149
|
+
node.warn(`[STEP 2] Attesa di 6 secondi per la scrittura su disco dell'NVR...`);
|
|
134
150
|
await new Promise(resolve => setTimeout(resolve, 6000));
|
|
135
151
|
|
|
136
152
|
const baseUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt`;
|
|
137
153
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
eventoNVR = e;
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
154
|
+
const eventoMinuscolo = evento.toLowerCase();
|
|
155
|
+
let eventoNVR = "FieldDetection";
|
|
156
|
+
if (eventoMinuscolo.includes("linedetection")) eventoNVR = "LineDetection";
|
|
145
157
|
|
|
146
158
|
let output = {
|
|
147
159
|
tipo_messaggio: "evento",
|
|
@@ -157,112 +169,134 @@ module.exports = function(RED) {
|
|
|
157
169
|
};
|
|
158
170
|
|
|
159
171
|
const parsedChannel = parseInt(channelID, 10);
|
|
160
|
-
const
|
|
161
|
-
const trackFoto = (parsedChannel * 100) + 3;
|
|
162
|
-
|
|
163
|
-
const tracks = [
|
|
164
|
-
{ id: trackVideo.toString(), isFoto: false, label: "VIDEO" },
|
|
165
|
-
{ id: trackFoto.toString(), isFoto: true, label: "FOTO" }
|
|
166
|
-
];
|
|
172
|
+
const trackFoto = (parsedChannel * 100) + 3; // Es. Canale 2 -> 203
|
|
167
173
|
let fileDaCancellare = [];
|
|
168
174
|
|
|
175
|
+
// --- PARTE 1: RICERCA E SCARICO VIDEO VIA JSON ---
|
|
176
|
+
node.warn(`[STEP 3 - VIDEO] Invio query JSON a eventRecordSearch per canale ${parsedChannel}...`);
|
|
177
|
+
const searchJsonPayload = {
|
|
178
|
+
"EventSearchDescription": {
|
|
179
|
+
"searchID": "BA80CAF2-A1E5-42E3-8624-529C3D6665D7",
|
|
180
|
+
"searchResultPosition": 0,
|
|
181
|
+
"maxResults": 30,
|
|
182
|
+
"timeSpanList": [{
|
|
183
|
+
"startTime": startTimeJson,
|
|
184
|
+
"endTime": endTimeJson
|
|
185
|
+
}],
|
|
186
|
+
"type": "all",
|
|
187
|
+
"channels": [parsedChannel],
|
|
188
|
+
"eventType": "behavior",
|
|
189
|
+
"behavior": {
|
|
190
|
+
"behaviorEventType": eventoMinuscolo
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
169
195
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
196
|
+
const resSearchVideo = await camAuth.request({
|
|
197
|
+
method: 'POST',
|
|
198
|
+
url: `${baseUrl}/eventRecordSearch?format=json`,
|
|
199
|
+
data: searchJsonPayload,
|
|
200
|
+
headers: { "Content-Type": "application/json" }
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const resDataStr = JSON.stringify(resSearchVideo.data);
|
|
204
|
+
const videoUriMatch = resDataStr.match(/rtsp:\/\/[^"]+/i) || resDataStr.match(/\/ISAPI\/ContentMgmt\/download[^"]+/i);
|
|
205
|
+
|
|
206
|
+
if (videoUriMatch) {
|
|
207
|
+
let rawVideoUri = videoUriMatch[0].replace(/\\/g, '');
|
|
208
|
+
node.warn(`[STEP 3.1 - VIDEO] Trovato URI video nel JSON: ${rawVideoUri}`);
|
|
172
209
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const rawUri = uriMatch[1].replace(/&/g, '&');
|
|
194
|
-
node.warn(`[STEP 5] Trovato playbackURI per traccia ${t.id}: ${rawUri}`);
|
|
195
|
-
|
|
196
|
-
node.warn(`[STEP 5.1] Invio richiesta GET /download per traccia ${t.id}`);
|
|
197
|
-
const resDown = await camAuth.request({
|
|
198
|
-
method: 'GET',
|
|
199
|
-
url: `${baseUrl}/download`,
|
|
200
|
-
data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`,
|
|
201
|
-
responseType: 'arraybuffer'
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
node.warn(`[STEP 5.2] Scaricati ${resDown.data.byteLength} byte per traccia ${t.id}`);
|
|
205
|
-
let buffer = Buffer.from(resDown.data);
|
|
206
|
-
|
|
207
|
-
if (t.isFoto) {
|
|
208
|
-
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
209
|
-
fs.writeFileSync(fullImgPath, buffer);
|
|
210
|
-
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
211
|
-
fileDaCancellare.push(fullImgPath);
|
|
212
|
-
node.warn(`[STEP 5.3] Immagine salvata e convertita in Base64.`);
|
|
210
|
+
const resDownVideo = await camAuth.request({
|
|
211
|
+
method: 'GET',
|
|
212
|
+
url: `${baseUrl}/download`,
|
|
213
|
+
data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawVideoUri.replace(/&/g, '&')}</playbackURI></downloadRequest>`,
|
|
214
|
+
responseType: 'arraybuffer'
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
let videoBuffer = Buffer.from(resDownVideo.data);
|
|
218
|
+
if (videoBuffer.slice(0, 4).toString() === 'IMKH') videoBuffer = videoBuffer.slice(40);
|
|
219
|
+
|
|
220
|
+
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
221
|
+
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
222
|
+
fs.writeFileSync(rawPath, videoBuffer);
|
|
223
|
+
|
|
224
|
+
await new Promise((resolve) => {
|
|
225
|
+
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
226
|
+
if (!err && fs.existsSync(fixedPath)) {
|
|
227
|
+
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
228
|
+
fileDaCancellare.push(fixedPath);
|
|
229
|
+
node.warn(`[STEP 3.2 - VIDEO] Convertito video con successo.`);
|
|
213
230
|
} else {
|
|
214
|
-
|
|
215
|
-
const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
|
|
216
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
217
|
-
fs.writeFileSync(rawPath, buffer);
|
|
218
|
-
|
|
219
|
-
node.warn(`[STEP 5.3] Video grezzo salvato. Avvio conversione FFMPEG...`);
|
|
220
|
-
await new Promise((resolve) => {
|
|
221
|
-
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
222
|
-
if (!err && fs.existsSync(fixedPath)) {
|
|
223
|
-
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
224
|
-
fileDaCancellare.push(fixedPath);
|
|
225
|
-
node.warn(`[STEP 5.4] Conversione FFMPEG completata con successo.`);
|
|
226
|
-
} else {
|
|
227
|
-
node.warn(`[STEP 5.4] Errore FFMPEG (uso il video grezzo di backup): ${err ? err.message : 'File mancante'}`);
|
|
228
|
-
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
229
|
-
}
|
|
230
|
-
fileDaCancellare.push(rawPath);
|
|
231
|
-
resolve();
|
|
232
|
-
});
|
|
233
|
-
});
|
|
231
|
+
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
234
232
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
errorDetails += ` | Status: ${searchError.response.status} | Dati Risposta: ${JSON.stringify(searchError.response.data)}`;
|
|
242
|
-
}
|
|
243
|
-
node.error(`[CATCH TRACCIA ${t.id}] Errore durante la POST di ricerca: ${errorDetails}`);
|
|
244
|
-
}
|
|
233
|
+
fileDaCancellare.push(rawPath);
|
|
234
|
+
resolve();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
} else {
|
|
238
|
+
node.warn(`[STEP 3.3 - VIDEO] Nessun file video indicizzato per questa finestra temporale.`);
|
|
245
239
|
}
|
|
240
|
+
} catch (videoErr) {
|
|
241
|
+
node.error(`[VIDEO ERR] Errore durante la ricerca JSON: ${videoErr.message}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- PARTE 2: RICERCA E SCARICO FOTO VIA XML (CORRETTO) ---
|
|
245
|
+
node.warn(`[STEP 4 - FOTO] Invio query XML a /search per traccia foto ${trackFoto}...`);
|
|
246
|
+
|
|
247
|
+
const searchXmlFoto = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>LAST_EVENT</searchID><trackIDList><trackID>${trackFoto}</trackID></trackIDList><timeSpanList><timeSpan><startTime>${startTimeZ}</startTime><endTime>${endTimeZ}</endTime></timeSpan></timeSpanList><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPosition>0</searchResultPosition><metadataList><metadataDescriptor>recordType.meta.hikvision.com/timing</metadataDescriptor></metadataList></CMSearchDescription>`;
|
|
246
248
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
249
|
+
try {
|
|
250
|
+
const resSearchFoto = await camAuth.request({
|
|
251
|
+
method: 'POST',
|
|
252
|
+
url: `${baseUrl}/search`,
|
|
253
|
+
data: searchXmlFoto,
|
|
254
|
+
headers: { "Content-Type": "application/xml" }
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
let xml = resSearchFoto.data.replace(/<(\/?)\w+:/g, "<$1");
|
|
258
|
+
const uriMatch = xml.match(/<playbackURI>([^<]+)</);
|
|
259
|
+
|
|
260
|
+
if (uriMatch) {
|
|
261
|
+
const rawUriFoto = uriMatch[1].replace(/&/g, '&');
|
|
262
|
+
node.warn(`[STEP 4.1 - FOTO] Trovato URI foto: ${rawUriFoto}`);
|
|
250
263
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
264
|
+
const resDownFoto = await camAuth.request({
|
|
265
|
+
method: 'GET',
|
|
266
|
+
url: `${baseUrl}/download`,
|
|
267
|
+
data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUriFoto.replace(/&/g, '&')}</playbackURI></downloadRequest>`,
|
|
268
|
+
responseType: 'arraybuffer'
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
272
|
+
fs.writeFileSync(fullImgPath, Buffer.from(resDownFoto.data));
|
|
273
|
+
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
274
|
+
fileDaCancellare.push(fullImgPath);
|
|
275
|
+
node.warn(`[STEP 4.2 - FOTO] Foto salvata e convertita correttamente.`);
|
|
256
276
|
} else {
|
|
257
|
-
node.warn(`[STEP
|
|
277
|
+
node.warn(`[STEP 4.3 - FOTO] Nessuna foto trovata per questa finestra temporale.`);
|
|
258
278
|
}
|
|
279
|
+
} catch (fotoErr) {
|
|
280
|
+
node.error(`[FOTO ERR] Errore durante la ricerca XML: ${fotoErr.message}`);
|
|
281
|
+
}
|
|
259
282
|
|
|
260
|
-
|
|
261
|
-
|
|
283
|
+
// --- PARTE 3: SPEDIZIONE VERSO PYTHON ---
|
|
284
|
+
if (output.foto_base64 || output.video_base64) {
|
|
285
|
+
node.warn(`[STEP 5] Spedizione payload multimediale completata!`);
|
|
286
|
+
node.send({ payload: output });
|
|
287
|
+
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
for (let file of fileDaCancellare) {
|
|
290
|
+
try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
|
|
291
|
+
}
|
|
292
|
+
}, 120000);
|
|
293
|
+
} else {
|
|
294
|
+
node.warn(`[STEP 5] Nessun file multimediale scaricato, nessun pacchetto inviato.`);
|
|
262
295
|
}
|
|
263
|
-
|
|
296
|
+
updateNodeStatus();
|
|
264
297
|
}
|
|
265
298
|
|
|
299
|
+
// --- ALERT STREAM ---
|
|
266
300
|
function startAlertStream() {
|
|
267
301
|
if (isClosing) return;
|
|
268
302
|
const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
|