node-red-contrib-hik-media-buffer 1.1.91 → 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 -120
- 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,120 +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
|
-
const tempDownXmlPath = path.join(baseStorage, `down_${t.id}_${timestamp}.xml`);
|
|
188
|
-
fs.writeFileSync(tempDownXmlPath, downXml, 'utf8');
|
|
166
|
+
if (!jsonResponseRaw) {
|
|
167
|
+
node.error("[DEBUG JSON] Nessuna risposta ricevuta dall'NVR.");
|
|
168
|
+
updateNodeStatus();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
189
171
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const targetFilePath = (t.id === trackI) ? fullImgPath : rawPath;
|
|
172
|
+
const searchResult = JSON.parse(jsonResponseRaw);
|
|
173
|
+
const matches = searchResult?.EventSearchResult?.matchList || [];
|
|
193
174
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}
|
|
196
194
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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();
|
|
200
224
|
});
|
|
225
|
+
});
|
|
201
226
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
fileDaCancellare.push(fullImgPath);
|
|
209
|
-
} else {
|
|
210
|
-
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
211
|
-
let videoBuffer = fs.readFileSync(rawPath);
|
|
212
|
-
if (videoBuffer.slice(0, 4).toString() === 'IMKH') {
|
|
213
|
-
try { fs.writeFileSync(rawPath, videoBuffer.slice(40)); } catch(e){}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
await new Promise((resolve) => {
|
|
217
|
-
exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
|
|
218
|
-
if (!err && fs.existsSync(fixedPath)) {
|
|
219
|
-
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
220
|
-
fileDaCancellare.push(fixedPath);
|
|
221
|
-
} else {
|
|
222
|
-
output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
|
|
223
|
-
}
|
|
224
|
-
fileDaCancellare.push(rawPath);
|
|
225
|
-
resolve();
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
}
|
|
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.`);
|
|
230
233
|
}
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
// --- INVIO PAYLOAD FINALE A NODE-RED ---
|
|
233
237
|
if (output.foto_base64 || output.video_base64) {
|
|
234
238
|
node.send({ payload: output });
|
|
235
239
|
setTimeout(() => {
|
|
236
240
|
for (let file of fileDaCancellare) {
|
|
237
241
|
try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
|
|
238
242
|
}
|
|
239
|
-
},
|
|
243
|
+
}, 60000); // Pulisce i file locali dopo 1 minuto
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
} catch (e) {
|
|
243
|
-
node.error(`Errore Download Cam ${channelID}: ${e.message}`);
|
|
247
|
+
node.error(`Errore Critico nel Download Cam ${channelID}: ${e.message}`);
|
|
244
248
|
}
|
|
245
249
|
updateNodeStatus();
|
|
246
250
|
}
|
|
247
251
|
|
|
248
|
-
// ---
|
|
252
|
+
// --- ASCOLTO ALLARMI IN LIVE STREAM (Axios Stream) ---
|
|
249
253
|
function startAlertStream() {
|
|
250
254
|
if (isClosing) return;
|
|
251
255
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|
|
@@ -264,10 +268,8 @@ module.exports = function(RED) {
|
|
|
264
268
|
if (data.toLowerCase().includes("active")) {
|
|
265
269
|
const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
|
|
266
270
|
if (chMatch) {
|
|
267
|
-
let ev = "
|
|
268
|
-
|
|
269
|
-
if (data.toLowerCase().includes(e.toLowerCase())) { ev = e; break; }
|
|
270
|
-
}
|
|
271
|
+
let ev = "LineDetection";
|
|
272
|
+
if (data.toLowerCase().includes("fielddetection")) ev = "FieldDetection";
|
|
271
273
|
downloadMedia(ev, chMatch[1]);
|
|
272
274
|
}
|
|
273
275
|
}
|