node-red-contrib-hik-media-buffer 1.1.99 → 1.1.101
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 +59 -61
- package/package.json +1 -1
package/hik-media-buffer.js
CHANGED
|
@@ -28,7 +28,7 @@ module.exports = function(RED) {
|
|
|
28
28
|
|
|
29
29
|
node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
|
|
30
30
|
|
|
31
|
-
// Formatta la data
|
|
31
|
+
// Formatta la data simulando il fuso orario senza il segno '+'
|
|
32
32
|
function toJsonDate(d) {
|
|
33
33
|
const pad = (num) => String(num).padStart(2, '0');
|
|
34
34
|
const year = d.getFullYear();
|
|
@@ -39,17 +39,16 @@ module.exports = function(RED) {
|
|
|
39
39
|
const seconds = pad(d.getSeconds());
|
|
40
40
|
|
|
41
41
|
const offsetMinutes = d.getTimezoneOffset();
|
|
42
|
-
const offsetSign = offsetMinutes <= 0 ? '
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
42
|
+
const offsetSign = offsetMinutes <= 0 ? '' : '-';
|
|
43
|
+
const offsetHours = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
44
|
+
const offsetMins = pad(Math.abs(offsetMinutes) % 60);
|
|
45
|
+
const fusoOrario = `${offsetSign}${offsetHours}:${offsetMins}`;
|
|
46
46
|
|
|
47
|
-
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${
|
|
47
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds} ${fusoOrario}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
|
|
51
51
|
|
|
52
|
-
// --- RECUPERA IL NOME DELLA TELECAMERA ---
|
|
53
52
|
async function getCameraName(channelID) {
|
|
54
53
|
try {
|
|
55
54
|
const res = await nvrAuthAxios.request({
|
|
@@ -63,7 +62,6 @@ module.exports = function(RED) {
|
|
|
63
62
|
} catch (e) { return `Camera_${channelID}`; }
|
|
64
63
|
}
|
|
65
64
|
|
|
66
|
-
// --- CONTROLLO STATUS TELECAMERE CONNESSE ALL'NVR ---
|
|
67
65
|
async function checkCameras() {
|
|
68
66
|
if (isClosing || !nvrOnline) return;
|
|
69
67
|
try {
|
|
@@ -106,7 +104,7 @@ module.exports = function(RED) {
|
|
|
106
104
|
|
|
107
105
|
const heartbeatInterval = setInterval(checkCameras, 30000);
|
|
108
106
|
|
|
109
|
-
// --- CORE
|
|
107
|
+
// --- CORE: RICERCA ARCHIVIO GIORNALIERO SIMULANDO CHROME ---
|
|
110
108
|
async function downloadMedia(evento, channelID) {
|
|
111
109
|
const chStr = channelID.toString();
|
|
112
110
|
const nowTime = Date.now();
|
|
@@ -117,23 +115,26 @@ module.exports = function(RED) {
|
|
|
117
115
|
const nomeCamera = await getCameraName(channelID);
|
|
118
116
|
const referenceTime = new Date();
|
|
119
117
|
const timestamp = Math.floor(referenceTime.getTime() / 1000);
|
|
120
|
-
|
|
121
|
-
// Forziamo l'azzeramento totale dei secondi (:00 e :59) come vuole il database Hikvision
|
|
122
|
-
const dStart = new Date(referenceTime.getTime() - (5 * 60 * 1000)); // 5 minuti fa
|
|
123
|
-
dStart.setSeconds(0, 0); // Forza :00 secondi e 0 millisecondi
|
|
124
|
-
|
|
125
|
-
const dEnd = new Date(referenceTime.getTime() + (2 * 60 * 1000)); // 2 minuti nel futuro
|
|
126
|
-
dEnd.setSeconds(59, 999); // Forza :59 secondi
|
|
127
118
|
|
|
128
|
-
|
|
129
|
-
const
|
|
119
|
+
// Generazione dell'intervallo per l'intera giornata corrente
|
|
120
|
+
const pad = (num) => String(num).padStart(2, '0');
|
|
121
|
+
const year = referenceTime.getFullYear();
|
|
122
|
+
const month = pad(referenceTime.getMonth() + 1);
|
|
123
|
+
const day = pad(referenceTime.getDate());
|
|
124
|
+
|
|
125
|
+
const offsetMinutes = referenceTime.getTimezoneOffset();
|
|
126
|
+
const offsetSign = offsetMinutes <= 0 ? '' : '-';
|
|
127
|
+
const offsetHours = pad(Math.floor(Math.abs(offsetMinutes) / 60));
|
|
128
|
+
const offsetMins = pad(Math.abs(offsetMinutes) % 60);
|
|
129
|
+
const fusoOrario = `${offsetSign}${offsetHours}:${offsetMins}`;
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
const startTime = `${year}-${month}-${day}T00:00:00 ${fusoOrario}`;
|
|
132
|
+
const endTime = `${year}-${month}-${day}T23:59:59 ${fusoOrario}`;
|
|
132
133
|
|
|
133
|
-
node.status({fill:"yellow", shape:"dot", text:`Ricerca
|
|
134
|
+
node.status({fill:"yellow", shape:"dot", text:`Ricerca archivio Cam ${channelID}...`});
|
|
134
135
|
|
|
135
|
-
// Aspettiamo
|
|
136
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
136
|
+
// Aspettiamo 4 secondi per dare tempo all'NVR di indicizzare il log su disco
|
|
137
|
+
await new Promise(resolve => setTimeout(resolve, 4000));
|
|
137
138
|
|
|
138
139
|
let output = {
|
|
139
140
|
tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
|
|
@@ -144,13 +145,13 @@ module.exports = function(RED) {
|
|
|
144
145
|
let fileDaCancellare = [];
|
|
145
146
|
|
|
146
147
|
try {
|
|
147
|
-
//
|
|
148
|
+
// SearchID UUID Dinamico ad ogni sessione
|
|
148
149
|
const dynamicSearchId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
149
150
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
150
151
|
return v.toString(16).toUpperCase();
|
|
151
152
|
});
|
|
152
153
|
|
|
153
|
-
//
|
|
154
|
+
// Payload clonato dal Form Data di Chrome
|
|
154
155
|
const searchPayload = {
|
|
155
156
|
"EventSearchDescription": {
|
|
156
157
|
"searchID": dynamicSearchId,
|
|
@@ -158,19 +159,20 @@ module.exports = function(RED) {
|
|
|
158
159
|
"maxResults": 30,
|
|
159
160
|
"timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
|
|
160
161
|
"type": "all",
|
|
161
|
-
"channels": [ parseInt(channelID) ],
|
|
162
|
+
"channels": [ parseInt(channelID) ],
|
|
162
163
|
"eventType": "behavior",
|
|
163
|
-
"behavior": {
|
|
164
|
-
"behaviorEventType": evento.toLowerCase() // Spara "linedetection" o "fielddetection"
|
|
165
|
-
}
|
|
164
|
+
"behavior": { "behaviorEventType": evento.toLowerCase() }
|
|
166
165
|
}
|
|
167
166
|
};
|
|
167
|
+
|
|
168
168
|
const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
|
|
169
169
|
fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
|
|
170
170
|
|
|
171
|
-
node.warn(`[DEBUG JSON]
|
|
171
|
+
node.warn(`[DEBUG JSON] Invio query giornaliera con data stile Chrome...`);
|
|
172
172
|
const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/eventRecordSearch?format=json`;
|
|
173
|
-
|
|
173
|
+
|
|
174
|
+
// Forziamo gli header completi ricavati dall'analisi del client web
|
|
175
|
+
const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" -H "Content-Type: application/json; charset=UTF-8" -H "X-Requested-With: XMLHttpRequest" -d "@${tempJsonPath}" "${targetUrl}"`;
|
|
174
176
|
|
|
175
177
|
let jsonResponseRaw = await new Promise((resolve) => {
|
|
176
178
|
exec(curlCommand, (err, stdout) => { resolve(err ? null : stdout); });
|
|
@@ -179,7 +181,7 @@ module.exports = function(RED) {
|
|
|
179
181
|
try { if (fs.existsSync(tempJsonPath)) fs.unlinkSync(tempJsonPath); } catch(e){}
|
|
180
182
|
|
|
181
183
|
if (!jsonResponseRaw) {
|
|
182
|
-
node.error("[DEBUG JSON] Nessuna risposta
|
|
184
|
+
node.error("[DEBUG JSON] Nessuna risposta dall'NVR.");
|
|
183
185
|
updateNodeStatus();
|
|
184
186
|
return;
|
|
185
187
|
}
|
|
@@ -188,48 +190,46 @@ module.exports = function(RED) {
|
|
|
188
190
|
const matches = searchResult?.EventSearchResult?.matchList || [];
|
|
189
191
|
|
|
190
192
|
if (matches.length === 0) {
|
|
191
|
-
node.warn(`[DEBUG JSON]
|
|
193
|
+
node.warn(`[DEBUG JSON] Zero match trovati nell'archivio giornaliero.`);
|
|
192
194
|
updateNodeStatus();
|
|
193
195
|
return;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
//
|
|
198
|
+
// Prendiamo il record d'archivio più fresco (l'ultimo registrato)
|
|
197
199
|
const matchItem = matches[0];
|
|
198
200
|
const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
|
|
199
201
|
|
|
200
|
-
// --- 1. CATTURA DELLA FOTO ORIGINALE (URL /picture/ intercettato da Chrome) ---
|
|
201
|
-
let pictureUrlPath = null;
|
|
202
202
|
if (playbackUri) {
|
|
203
|
+
node.warn(`[DEBUG JSON] Trovato PlaybackURI: ${playbackUri}`);
|
|
204
|
+
|
|
205
|
+
// --- 1. DOWNLOAD FOTO HISTORIC (GET diretta sulla traccia foto 203) ---
|
|
206
|
+
let pictureUrlPath = null;
|
|
203
207
|
const urlParamsMatch = playbackUri.match(/\?(.*)/);
|
|
204
208
|
if (urlParamsMatch) {
|
|
205
|
-
const trackI = (channelID * 100 + 3).toString();
|
|
209
|
+
const trackI = (channelID * 100 + 3).toString();
|
|
206
210
|
pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
|
|
207
211
|
}
|
|
208
|
-
}
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
213
|
+
if (pictureUrlPath) {
|
|
214
|
+
node.warn(`[DEBUG FOTO] Scarico lo snapshot nativo d'archivio via GET...`);
|
|
215
|
+
const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
|
|
216
|
+
const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
|
|
217
|
+
|
|
218
|
+
await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
|
|
219
|
+
|
|
220
|
+
if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
|
|
221
|
+
output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
|
|
222
|
+
fileDaCancellare.push(fullImgPath);
|
|
223
|
+
node.warn(`[DEBUG FOTO] Foto scaricata con successo.`);
|
|
224
|
+
}
|
|
221
225
|
}
|
|
222
|
-
}
|
|
223
226
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
node.warn(`[DEBUG VIDEO] Aggancio il flusso RTSP dell'evento registrato...`);
|
|
227
|
+
// --- 2. DOWNLOAD VIDEO HISTORIC VIA RTSP (Taglio a 15 secondi) ---
|
|
228
|
+
node.warn(`[DEBUG VIDEO] Recupero lo spezzone video registrato via RTSP...`);
|
|
227
229
|
const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
|
|
228
|
-
|
|
229
|
-
// Iniettiamo le credenziali admin:pass all'interno dell'URL RTSP dell'NVR
|
|
230
230
|
const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
|
|
231
231
|
|
|
232
|
-
//
|
|
232
|
+
// Connessione all'RTSP storico e salvataggio locale dei primi 15 secondi dell'evento
|
|
233
233
|
const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${authenticatedRtspUrl}" -t 15 -c copy -movflags +faststart "${fixedPath}"`;
|
|
234
234
|
|
|
235
235
|
await new Promise((resolve) => {
|
|
@@ -242,20 +242,18 @@ module.exports = function(RED) {
|
|
|
242
242
|
if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
|
|
243
243
|
output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
|
|
244
244
|
fileDaCancellare.push(fixedPath);
|
|
245
|
-
node.warn(`[DEBUG VIDEO] Spezzone video
|
|
246
|
-
} else {
|
|
247
|
-
node.warn(`[DEBUG VIDEO] Flusso RTSP non catturato o spezzone non pronto.`);
|
|
245
|
+
node.warn(`[DEBUG VIDEO] Spezzone video generato con successo.`);
|
|
248
246
|
}
|
|
249
247
|
}
|
|
250
248
|
|
|
251
|
-
// ---
|
|
249
|
+
// --- SPEDIZIONE MESSAGGIO FINALE ---
|
|
252
250
|
if (output.foto_base64 || output.video_base64) {
|
|
253
251
|
node.send({ payload: output });
|
|
254
252
|
setTimeout(() => {
|
|
255
253
|
for (let file of fileDaCancellare) {
|
|
256
254
|
try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
|
|
257
255
|
}
|
|
258
|
-
}, 60000);
|
|
256
|
+
}, 60000);
|
|
259
257
|
}
|
|
260
258
|
|
|
261
259
|
} catch (e) {
|
|
@@ -264,7 +262,7 @@ module.exports = function(RED) {
|
|
|
264
262
|
updateNodeStatus();
|
|
265
263
|
}
|
|
266
264
|
|
|
267
|
-
// --- ASCOLTO
|
|
265
|
+
// --- ASCOLTO SULL'ALERT STREAM DELL'NVR ---
|
|
268
266
|
function startAlertStream() {
|
|
269
267
|
if (isClosing) return;
|
|
270
268
|
const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
|