node-red-contrib-hik-media-buffer 1.1.92 → 1.1.93
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 +122 -131
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -23,21 +23,33 @@ module.exports = function(RED) {
|
|
|
23
23
|
let nvrOnline = true;
|
|
24
24
|
let statoCamera = {};
|
|
25
25
|
|
|
26
|
-
const EventList = ["FieldDetection", "LineDetection"];
|
|
27
26
|
const baseStorage = path.join(os.tmpdir(), "hik_temp_media");
|
|
28
27
|
if (!fs.existsSync(baseStorage)) fs.mkdirSync(baseStorage, { recursive: true });
|
|
29
28
|
|
|
30
29
|
node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
|
|
31
30
|
|
|
32
|
-
//
|
|
33
|
-
function
|
|
34
|
-
|
|
31
|
+
// Formatta la data nel formato ISO locale richiesto dal JSON dell'NVR (Es: 2026-06-22T14:41:00+02:00)
|
|
32
|
+
function toJsonDate(d) {
|
|
33
|
+
const pad = (num) => String(num).padStart(2, '0');
|
|
34
|
+
const year = d.getFullYear();
|
|
35
|
+
const month = pad(d.getMonth() + 1);
|
|
36
|
+
const day = pad(d.getDate());
|
|
37
|
+
const hours = pad(d.getHours());
|
|
38
|
+
const minutes = pad(d.getMinutes());
|
|
39
|
+
const seconds = pad(d.getSeconds());
|
|
40
|
+
|
|
41
|
+
const offsetMinutes = d.getTimezoneOffset();
|
|
42
|
+
const offsetSign = offsetMinutes <= 0 ? '+' : '-';
|
|
43
|
+
const absOffsetMinutes = Math.abs(offsetMinutes);
|
|
44
|
+
const offsetHours = pad(Math.floor(absOffsetMinutes / 60));
|
|
45
|
+
const offsetMins = pad(absOffsetMinutes % 60);
|
|
46
|
+
|
|
47
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMins}`;
|
|
35
48
|
}
|
|
36
49
|
|
|
37
|
-
// Axios Digest usato solo per le GET leggere (Stream eventi e nomi canali)
|
|
38
50
|
const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
39
51
|
|
|
40
|
-
// --- RECUPERA IL NOME DELLA TELECAMERA
|
|
52
|
+
// --- RECUPERA IL NOME DELLA TELECAMERA ---
|
|
41
53
|
async function getCameraName(channelID) {
|
|
42
54
|
try {
|
|
43
55
|
const res = await nvrAuthAxios.request({
|
|
@@ -48,12 +60,10 @@ module.exports = function(RED) {
|
|
|
48
60
|
const match = res.data.toString().match(/<name>([^<]+)<\/name>/i);
|
|
49
61
|
if (match && match[1]) return match[1].trim();
|
|
50
62
|
return `Canale_${channelID}`;
|
|
51
|
-
} catch (e) {
|
|
52
|
-
return `Camera_${channelID}`;
|
|
53
|
-
}
|
|
63
|
+
} catch (e) { return `Camera_${channelID}`; }
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
// --- CONTROLLO STATUS
|
|
66
|
+
// --- CONTROLLO STATUS TELECAMERE CONNESSE ALL'NVR ---
|
|
57
67
|
async function checkCameras() {
|
|
58
68
|
if (isClosing || !nvrOnline) return;
|
|
59
69
|
try {
|
|
@@ -80,31 +90,23 @@ module.exports = function(RED) {
|
|
|
80
90
|
} else if (!isOnlineNow && statoCamera[ch] !== false && statoCamera[ch] !== undefined) {
|
|
81
91
|
node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", nome_cliente: node.name, nome_telecamera: `Camera_${ch}`, channel: ch, msg: "Camera scollegata dall'NVR" } });
|
|
82
92
|
statoCamera[ch] = false;
|
|
83
|
-
} else if (statoCamera[ch] === undefined) {
|
|
84
|
-
statoCamera[ch] = isOnlineNow;
|
|
85
|
-
}
|
|
93
|
+
} else if (statoCamera[ch] === undefined) { statoCamera[ch] = isOnlineNow; }
|
|
86
94
|
}
|
|
87
95
|
}
|
|
88
|
-
} catch (e) {
|
|
89
|
-
// Silenzioso
|
|
90
|
-
}
|
|
96
|
+
} catch (e) {}
|
|
91
97
|
updateNodeStatus();
|
|
92
98
|
}
|
|
93
99
|
|
|
94
100
|
function updateNodeStatus() {
|
|
95
101
|
const offlineCams = Object.values(statoCamera).filter(v => v === false).length;
|
|
96
|
-
if (!nvrOnline) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`});
|
|
100
|
-
} else {
|
|
101
|
-
node.status({fill:"green", shape:"ring", text:"In ascolto"});
|
|
102
|
-
}
|
|
102
|
+
if (!nvrOnline) { node.status({fill:"red", shape:"ring", text:"NVR Offline"}); }
|
|
103
|
+
else if (offlineCams > 0) { node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`}); }
|
|
104
|
+
else { node.status({fill:"green", shape:"ring", text:"In ascolto"}); }
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
106
108
|
|
|
107
|
-
// ---
|
|
109
|
+
// --- CORE RESCUE: RICERCA EVENTO JSON, CATTURA FOTO HTTP E VIDEO RTSP ---
|
|
108
110
|
async function downloadMedia(evento, channelID) {
|
|
109
111
|
const chStr = channelID.toString();
|
|
110
112
|
const nowTime = Date.now();
|
|
@@ -115,13 +117,15 @@ module.exports = function(RED) {
|
|
|
115
117
|
const nomeCamera = await getCameraName(channelID);
|
|
116
118
|
const referenceTime = new Date();
|
|
117
119
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
+
|
|
121
|
+
// Finestra temporale di ricerca: 15 secondi prima e 15 secondi dopo l'evento
|
|
122
|
+
const startTime = toJsonDate(new Date(referenceTime.getTime() - (15 * 1000)));
|
|
123
|
+
const endTime = toJsonDate(new Date(referenceTime.getTime() + (15 * 1000)));
|
|
120
124
|
|
|
121
|
-
node.status({fill:"yellow", shape:"dot", text:`
|
|
125
|
+
node.status({fill:"yellow", shape:"dot", text:`Ricerca Evento Cam ${channelID}...`});
|
|
122
126
|
|
|
123
|
-
// Aspettiamo
|
|
124
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
127
|
+
// Aspettiamo 3 secondi per dare tempo all'NVR di indicizzare l'evento sul log
|
|
128
|
+
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
125
129
|
|
|
126
130
|
let output = {
|
|
127
131
|
tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
|
|
@@ -132,131 +136,120 @@ module.exports = function(RED) {
|
|
|
132
136
|
let fileDaCancellare = [];
|
|
133
137
|
|
|
134
138
|
try {
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
139
|
+
// Generazione del Payload JSON per interrogare il registro eventi dell'NVR
|
|
140
|
+
const searchPayload = {
|
|
141
|
+
"EventSearchDescription": {
|
|
142
|
+
"searchID": "D57A6C81-7FCB-4015-8156-71A54B486925",
|
|
143
|
+
"searchResultPosition": 0,
|
|
144
|
+
"maxResults": 10,
|
|
145
|
+
"timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
|
|
146
|
+
"type": "all",
|
|
147
|
+
"channels": [ parseInt(channelID) ],
|
|
148
|
+
"eventType": "behavior",
|
|
149
|
+
"behavior": { "behaviorEventType": evento.toLowerCase() }
|
|
145
150
|
}
|
|
146
|
-
|
|
147
|
-
// Salviamo l'XML su un file temporaneo per non far incazzare la shell con i caratteri < e >
|
|
148
|
-
const tempXmlPath = path.join(baseStorage, `search_${t.id}_${timestamp}.xml`);
|
|
149
|
-
fs.writeFileSync(tempXmlPath, searchXml, 'utf8');
|
|
150
|
-
|
|
151
|
-
node.warn(`[DEBUG] Eseguo ricerca traccia ${t.id} tramite cURL via file temporaneo...`);
|
|
152
|
-
|
|
153
|
-
// Usiamo -d "@path" così cURL si legge il file da solo senza passare i simboli alla shell
|
|
154
|
-
const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/search`;
|
|
155
|
-
const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/xml; charset=UTF-8" -d "@${tempXmlPath}" "${targetUrl}"`;
|
|
156
|
-
|
|
157
|
-
let xmlResponse = await new Promise((resolve) => {
|
|
158
|
-
exec(curlCommand, (err, stdout, stderr) => {
|
|
159
|
-
if (err) {
|
|
160
|
-
node.error(`[CRASH cURL]: ${err.message} | STDERR: ${stderr}`);
|
|
161
|
-
resolve(null);
|
|
162
|
-
} else {
|
|
163
|
-
resolve(stdout);
|
|
164
|
-
}
|
|
165
|
-
});
|
|
166
|
-
});
|
|
151
|
+
};
|
|
167
152
|
|
|
168
|
-
|
|
169
|
-
|
|
153
|
+
const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
|
|
154
|
+
fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
|
|
170
155
|
|
|
171
|
-
|
|
172
|
-
|
|
156
|
+
node.warn(`[DEBUG JSON] Interrogo il log eventi dell'NVR per canale ${channelID}...`);
|
|
157
|
+
const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/eventRecordSearch?format=json`;
|
|
158
|
+
const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/json" -d "@${tempJsonPath}" "${targetUrl}"`;
|
|
173
159
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
160
|
+
let jsonResponseRaw = await new Promise((resolve) => {
|
|
161
|
+
exec(curlCommand, (err, stdout) => { resolve(err ? null : stdout); });
|
|
162
|
+
});
|
|
178
163
|
|
|
179
|
-
|
|
180
|
-
const uriMatch = xml.match(/<playbackURI>([^<]+)</);
|
|
164
|
+
try { if (fs.existsSync(tempJsonPath)) fs.unlinkSync(tempJsonPath); } catch(e){}
|
|
181
165
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
fs.writeFileSync(tempDownXmlPath, downXml, 'utf8');
|
|
166
|
+
if (!jsonResponseRaw) {
|
|
167
|
+
node.error("[DEBUG JSON] Nessuna risposta ricevuta dall'NVR.");
|
|
168
|
+
updateNodeStatus();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
188
171
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const targetFilePath = (t.id === trackI) ? fullImgPath : rawPath;
|
|
172
|
+
const searchResult = JSON.parse(jsonResponseRaw);
|
|
173
|
+
const matches = searchResult?.EventSearchResult?.matchList || [];
|
|
192
174
|
|
|
193
|
-
|
|
194
|
-
|
|
175
|
+
if (matches.length === 0) {
|
|
176
|
+
node.warn(`[DEBUG JSON] Nessun match trovato nel database per la Cam ${channelID} in questa finestra temporale.`);
|
|
177
|
+
updateNodeStatus();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Estraiamo il record dell'evento
|
|
182
|
+
const matchItem = matches[0];
|
|
183
|
+
const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
|
|
184
|
+
|
|
185
|
+
// --- 1. CATTURA DELLA FOTO ORIGINALE (URL /picture/ intercettato da Chrome) ---
|
|
186
|
+
let pictureUrlPath = null;
|
|
187
|
+
if (playbackUri) {
|
|
188
|
+
const urlParamsMatch = playbackUri.match(/\?(.*)/);
|
|
189
|
+
if (urlParamsMatch) {
|
|
190
|
+
const trackI = (channelID * 100 + 3).toString(); // Es: Canale 2 -> traccia foto 203
|
|
191
|
+
pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
195
194
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
if (pictureUrlPath) {
|
|
196
|
+
node.warn(`[DEBUG FOTO] Scarico la foto nativa dell'evento via HTTP GET...`);
|
|
197
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
198
|
+
const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
|
|
199
|
+
|
|
200
|
+
await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
|
|
201
|
+
|
|
202
|
+
if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
|
|
203
|
+
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
204
|
+
fileDaCancellare.push(fullImgPath);
|
|
205
|
+
node.warn(`[DEBUG FOTO] Foto catturata con successo (${fs.statSync(fullImgPath).size} byte).`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- 2. CATTURA DELLO SPEZZONE VIDEO (Via RTSP con Ffmpeg per tagliare a 15 secondi) ---
|
|
210
|
+
if (playbackUri) {
|
|
211
|
+
node.warn(`[DEBUG VIDEO] Aggancio il flusso RTSP dell'evento registrato...`);
|
|
212
|
+
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
213
|
+
|
|
214
|
+
// Iniettiamo le credenziali admin:pass all'interno dell'URL RTSP dell'NVR
|
|
215
|
+
const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
|
|
216
|
+
|
|
217
|
+
// -t 15 costringe Ffmpeg a registrare solo 15 secondi dall'orario di inizio dell'evento, poi chiude il rubinetto!
|
|
218
|
+
const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${authenticatedRtspUrl}" -t 15 -c copy -movflags +faststart "${fixedPath}"`;
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve) => {
|
|
221
|
+
exec(ffmpegRtspCmd, { timeout: 25000 }, (err) => {
|
|
222
|
+
if (err) node.error(`[DEBUG FFMPEG ERRORE]: ${err.message}`);
|
|
223
|
+
resolve();
|
|
199
224
|
});
|
|
225
|
+
});
|
|
200
226
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
} else {
|
|
208
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
209
|
-
|
|
210
|
-
// Tagliamo via l'header proprietario IMKH se presente
|
|
211
|
-
let fileStats = fs.statSync(rawPath);
|
|
212
|
-
if (fileStats.size > 40) {
|
|
213
|
-
let fd = fs.openSync(rawPath, 'r');
|
|
214
|
-
let magicBuffer = Buffer.alloc(4);
|
|
215
|
-
fs.readSync(fd, magicBuffer, 0, 4, 0);
|
|
216
|
-
fs.closeSync(fd);
|
|
217
|
-
|
|
218
|
-
if (magicBuffer.toString() === 'IMKH') {
|
|
219
|
-
// Taglio dell'header senza leggere tutto in memoria (evita crash su file giganti)
|
|
220
|
-
exec(`ffmpeg -y -ss 00:00:00 -i "${rawPath}" -c copy -map 0 -an "${fixedPath}"`, () => {});
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
node.warn(`[DEBUG] Ritaglio il video dell'evento a 10 secondi per non saturare la memoria...`);
|
|
225
|
-
// USIAMO FFMPEG PER PRENDERE SOLO GLI ULTIMI 10 SECONDI ED EVITARE IL FILE DA 1GB IN BASE64
|
|
226
|
-
await new Promise((resolve) => {
|
|
227
|
-
exec(`ffmpeg -y -sseof -10 -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
228
|
-
if (!err && fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 0) {
|
|
229
|
-
// Leggiamo il file tagliato che ora peserà pochissimo!
|
|
230
|
-
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
231
|
-
fileDaCancellare.push(fixedPath);
|
|
232
|
-
} else {
|
|
233
|
-
node.error(`[DEBUG FFMPEG] Impossibile tagliare il video, traccia troppo pesante.`);
|
|
234
|
-
}
|
|
235
|
-
fileDaCancellare.push(rawPath);
|
|
236
|
-
resolve();
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
}
|
|
227
|
+
if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
|
|
228
|
+
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
229
|
+
fileDaCancellare.push(fixedPath);
|
|
230
|
+
node.warn(`[DEBUG VIDEO] Spezzone video catturato con successo (${fs.statSync(fixedPath).size} byte).`);
|
|
231
|
+
} else {
|
|
232
|
+
node.warn(`[DEBUG VIDEO] Flusso RTSP non catturato o spezzone non pronto.`);
|
|
241
233
|
}
|
|
242
234
|
}
|
|
243
235
|
|
|
236
|
+
// --- INVIO PAYLOAD FINALE A NODE-RED ---
|
|
244
237
|
if (output.foto_base64 || output.video_base64) {
|
|
245
238
|
node.send({ payload: output });
|
|
246
239
|
setTimeout(() => {
|
|
247
240
|
for (let file of fileDaCancellare) {
|
|
248
241
|
try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
|
|
249
242
|
}
|
|
250
|
-
},
|
|
243
|
+
}, 60000); // Pulisce i file locali dopo 1 minuto
|
|
251
244
|
}
|
|
252
245
|
|
|
253
246
|
} catch (e) {
|
|
254
|
-
node.error(`Errore Download Cam ${channelID}: ${e.message}`);
|
|
247
|
+
node.error(`Errore Critico nel Download Cam ${channelID}: ${e.message}`);
|
|
255
248
|
}
|
|
256
249
|
updateNodeStatus();
|
|
257
250
|
}
|
|
258
251
|
|
|
259
|
-
// ---
|
|
252
|
+
// --- ASCOLTO ALLARMI IN LIVE STREAM (Axios Stream) ---
|
|
260
253
|
function startAlertStream() {
|
|
261
254
|
if (isClosing) return;
|
|
262
255
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|
|
@@ -275,10 +268,8 @@ module.exports = function(RED) {
|
|
|
275
268
|
if (data.toLowerCase().includes("active")) {
|
|
276
269
|
const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
|
|
277
270
|
if (chMatch) {
|
|
278
|
-
let ev = "
|
|
279
|
-
|
|
280
|
-
if (data.toLowerCase().includes(e.toLowerCase())) { ev = e; break; }
|
|
281
|
-
}
|
|
271
|
+
let ev = "LineDetection";
|
|
272
|
+
if (data.toLowerCase().includes("fielddetection")) ev = "FieldDetection";
|
|
282
273
|
downloadMedia(ev, chMatch[1]);
|
|
283
274
|
}
|
|
284
275
|
}
|