node-red-contrib-hik-media-buffer 1.1.109 → 1.1.111

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.
Files changed (2) hide show
  1. package/hik-media-buffer.js +159 -165
  2. package/package.json +2 -1
@@ -1,5 +1,6 @@
1
1
  const axios = require('axios');
2
2
  const AxiosDigestAuth = require('@mhoc/axios-digest-auth').default;
3
+ const https = require('https');
3
4
  const fs = require('fs');
4
5
  const path = require('path');
5
6
  const os = require('os');
@@ -16,6 +17,8 @@ module.exports = function(RED) {
16
17
  node.protocol = config.protocol || "http";
17
18
  node.user = config.user;
18
19
  node.pass = config.pass;
20
+ node.camPass = config.camPass || config.pass;
21
+ node.cameras = config.cameras || [];
19
22
 
20
23
  let streamRequest = null;
21
24
  let isClosing = false;
@@ -23,232 +26,227 @@ module.exports = function(RED) {
23
26
  let nvrOnline = true;
24
27
  let statoCamera = {};
25
28
 
29
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
30
+ const EventList = ["FieldDetection", "LineDetection"];
31
+
32
+ // CARTELLA TEMPORANEA
26
33
  const baseStorage = path.join(os.tmpdir(), "hik_temp_media");
27
34
  if (!fs.existsSync(baseStorage)) fs.mkdirSync(baseStorage, { recursive: true });
28
35
 
29
36
  node.status({fill:"grey", shape:"ring", text:"Inizializzazione..."});
30
37
 
31
- const nvrAuthAxios = new AxiosDigestAuth({ username: node.user, password: node.pass });
38
+ function toHikDate(d) { return d.toISOString().split('.')[0] + "Z"; }
32
39
 
33
- async function getCameraName(channelID) {
34
- try {
35
- const res = await nvrAuthAxios.request({
36
- method: 'GET',
37
- url: `${node.protocol}://${node.host}:${node.port}/ISAPI/System/Video/inputs/channels/${channelID}/overlays/channelNameOverlay`,
38
- timeout: 5000
39
- });
40
- const match = res.data.toString().match(/<name>([^<]+)<\/name>/i);
41
- if (match && match[1]) return match[1].trim();
42
- return `Canale_${channelID}`;
43
- } catch (e) { return `Camera_${channelID}`; }
44
- }
40
+ // --- PRENDE IL NOME DELLA TELECAMERA ---
41
+ async function getCameraName(cam) {
42
+ const camAuth = new AxiosDigestAuth({
43
+ username: node.user,
44
+ password: node.camPass
45
+ });
45
46
 
46
- async function checkCameras() {
47
- if (isClosing || !nvrOnline) return;
48
47
  try {
49
- const res = await nvrAuthAxios.request({
48
+ const res = await camAuth.request({
50
49
  method: 'GET',
51
- url: `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/InputProxy/channels/status`,
52
- timeout: 8000
50
+ url: `${node.protocol}://${cam.ip}:${node.port}/ISAPI/System/Video/inputs/channels/${cam.channel}/overlays/channelNameOverlay`,
51
+ timeout: 5000,
52
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
53
53
  });
54
- const xml = res.data.toString();
55
- const statusBlocks = xml.match(/<InputProxyChannelStatus>[\s\S]*?<\/InputProxyChannelStatus>/gi) || [];
56
54
 
57
- for (let block of statusBlocks) {
58
- const idMatch = block.match(/<id>(\d+)<\/id>/i);
59
- const statusMatch = block.match(/<online>([^<]+)<\/online>/i);
55
+ const data = res.data.toString();
56
+ const match = data.match(/<name>([^<]+)<\/name>/i);
57
+
58
+ if (match && match[1]) {
59
+ return match[1].trim();
60
+ } else {
61
+ return `Canale_${cam.channel}`;
62
+ }
60
63
 
61
- if (idMatch && statusMatch) {
62
- const ch = idMatch[1].trim();
63
- const isOnlineNow = statusMatch[1].trim().toLowerCase() === 'true';
64
+ } catch (e) {
65
+ return `Camera_${cam.channel}`;
66
+ }
67
+ }
64
68
 
65
- if (isOnlineNow && statoCamera[ch] === false) {
66
- const nomeOnline = await getCameraName(ch);
67
- node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", nome_cliente: node.name, nome_telecamera: nomeOnline, channel: ch, msg: "Camera ripristinata" } });
68
- statoCamera[ch] = true;
69
- } else if (!isOnlineNow && statoCamera[ch] !== false && statoCamera[ch] !== undefined) {
70
- node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", nome_cliente: node.name, nome_telecamera: `Camera_${ch}`, channel: ch, msg: "Camera scollegata dall'NVR" } });
71
- statoCamera[ch] = false;
72
- } else if (statoCamera[ch] === undefined) { statoCamera[ch] = isOnlineNow; }
69
+ // --- CONTROLLO STATUS CAMERE ---
70
+ async function checkCameras() {
71
+ if (isClosing) return;
72
+ for (let cam of node.cameras) {
73
+ const camAuth = new AxiosDigestAuth({ username: node.user, password: node.camPass });
74
+ try {
75
+ await camAuth.request({
76
+ method: 'GET',
77
+ url: `${node.protocol}://${cam.ip}:${node.port}/ISAPI/System/deviceInfo`,
78
+ timeout: 5000,
79
+ httpsAgent: node.protocol === "https" ? httpsAgent : undefined
80
+ });
81
+ if (statoCamera[cam.ip] === false) {
82
+ const nomeOnline = await getCameraName(cam);
83
+ node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", nome_cliente: node.name, nome_telecamera: nomeOnline, ip_telecamera: cam.ip, channel: cam.channel, msg: "Camera ripristinata" } });
84
+ statoCamera[cam.ip] = true;
85
+ } else if (statoCamera[cam.ip] === undefined) {
86
+ statoCamera[cam.ip] = true;
87
+ }
88
+ } catch (e) {
89
+ if (statoCamera[cam.ip] !== false) {
90
+ node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", nome_cliente: node.name, nome_telecamera: `Camera_${cam.channel}`, ip_telecamera: cam.ip, channel: cam.channel, msg: "Camera non raggiungibile" } });
91
+ statoCamera[cam.ip] = false;
73
92
  }
74
93
  }
75
- } catch (e) {}
94
+ }
76
95
  updateNodeStatus();
77
96
  }
78
97
 
79
98
  function updateNodeStatus() {
80
99
  const offlineCams = Object.values(statoCamera).filter(v => v === false).length;
81
- if (!nvrOnline) { node.status({fill:"red", shape:"ring", text:"NVR Offline"}); }
82
- else if (offlineCams > 0) { node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`}); }
83
- else { node.status({fill:"green", shape:"ring", text:"In ascolto"}); }
100
+ if (!nvrOnline) {
101
+ node.status({fill:"red", shape:"ring", text:"NVR Offline"});
102
+ } else if (offlineCams > 0) {
103
+ node.status({fill:"yellow", shape:"dot", text: `${offlineCams} Cam Offline`});
104
+ } else {
105
+ node.status({fill:"green", shape:"ring", text:"In ascolto"});
106
+ }
84
107
  }
85
108
 
86
109
  const heartbeatInterval = setInterval(checkCameras, 30000);
87
110
 
88
- // --- NUOVA STRATEGIA INDISTRUTTIBILE: DIRETTA SU STREAMING RTSP STORICO ---
111
+ // --- DOWNLOAD, SALVATAGGIO, CONVERSIONE E RIMOZIONE DOPO 2 MINUTI ---
89
112
  async function downloadMedia(evento, channelID) {
90
- const chStr = channelID.toString();
113
+ const camera = node.cameras.find(c => c.channel == channelID);
114
+ if (!camera) return;
115
+
116
+ const nomeCamera = await getCameraName(camera);
117
+
91
118
  const nowTime = Date.now();
92
- if (!lastTriggerTime[chStr]) lastTriggerTime[chStr] = 0;
93
- if (nowTime - lastTriggerTime[chStr] < 5000) return;
94
- lastTriggerTime[chStr] = nowTime;
119
+ if(!lastTriggerTime[camera.ip]){
120
+ lastTriggerTime[camera.ip] = 0;
121
+ }
122
+ if (nowTime - lastTriggerTime[camera.ip] < 5000) return;
123
+ lastTriggerTime[camera.ip] = nowTime;
95
124
 
96
- const nomeCamera = await getCameraName(channelID);
125
+ const camAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
97
126
  const referenceTime = new Date();
98
127
  const timestamp = Math.floor(referenceTime.getTime() / 1000);
128
+ const startTime = toHikDate(new Date(referenceTime.getTime() - (10 * 1000)));
129
+ const endTime = toHikDate(new Date(referenceTime.getTime() + (10 * 1000)));
99
130
 
100
- // Costruiamo le date giornaliere identiche a quelle viste su Chrome
101
- const pad = (num) => String(num).padStart(2, '0');
102
- const year = referenceTime.getFullYear();
103
- const month = pad(referenceTime.getMonth() + 1);
104
- const day = pad(referenceTime.getDate());
131
+ node.status({fill:"yellow", shape:"dot", text:`Download Cam ${channelID}...`});
132
+ await new Promise(resolve => setTimeout(resolve, 6000));
105
133
 
106
- const startTime = `${year}-${month}-${day}T00:00:00 01:00`;
107
- const endTime = `${year}-${month}-${day}T23:59:59 01:00`;
108
-
109
- node.status({fill:"yellow", shape:"dot", text:`Invio query protetta...`});
134
+ const baseUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt`;
110
135
 
136
+ // struttura del payload per Python
111
137
  let output = {
112
- tipo_messaggio: "evento", nome_cliente: node.name, nome_telecamera: nomeCamera,
113
- ip_telecamera: null, tipo_evento: evento, timestamp_epoch: timestamp,
114
- stato_telecamera: "ONLINE", channel: chStr, foto_base64: null, video_base64: null
138
+ tipo_messaggio: "evento",
139
+ nome_cliente: node.name,
140
+ nome_telecamera: nomeCamera,
141
+ ip_telecamera: camera.ip,
142
+ tipo_evento: evento,
143
+ timestamp_epoch: timestamp,
144
+ stato_telecamera: "ONLINE",
145
+ channel: channelID.toString(),
146
+ foto_base64: null,
147
+ video_base64: null
115
148
  };
116
149
 
150
+
117
151
  let fileDaCancellare = [];
118
152
 
119
153
  try {
120
- // --- GENERAZIONE HASH DI SESSIONE LOCALE ---
121
- // Generiamo un MD5 casuale per bypassare il controllo di uguaglianza tra Cookie e Sessiontag
122
- const fakeSessionToken = require('crypto').createHash('md5').update(String(Math.random() + Date.now())).digest('hex');
123
- const cookieKeyName = `WebSession_${require('crypto').createHash('md5').update(node.host).digest('hex').substring(0, 10)}`;
124
-
125
- node.warn(`[DEBUG SESSION] Simulazione Header -> ${cookieKeyName}=${fakeSessionToken}`);
154
+ const tracks = [{ id: "203" }, { id: "201" }];
155
+ for (let t of tracks) {
156
+ const searchXml = `<?xml version="1.0" encoding="utf-8"?><CMSearchDescription><searchID>LAST_EVENT</searchID><trackIDList><trackID>${t.id}</trackID></trackIDList><timeSpanList><timeSpan><startTime>2026-06-25T00:00:00Z</startTime><endTime>2026-06-26T23:59:59Z</endTime></timeSpan></timeSpanList><contentTypeList><contentType>metadata</contentType></contentTypeList><maxResults>30</maxResults><searchResultPostion>0</searchResultPostion><metadataList><metadataDescriptor>recordType.meta.hikvision.com/LineDetection</metadataDescriptor></metadataList></CMSearchDescription>`;
126
157
 
127
- const dynamicSearchId = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
128
- var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
129
- return v.toString(16).toUpperCase();
130
- });
131
-
132
- const searchPayload = {
133
- "EventSearchDescription": {
134
- "searchID": dynamicSearchId,
135
- "searchResultPosition": 0,
136
- "maxResults": 30,
137
- "timeSpanList": [{ "startTime": startTime, "endTime": endTime }],
138
- "type": "all",
139
- "channels": [ parseInt(channelID) ],
140
- "eventType": "behavior",
141
- "behavior": { "behaviorEventType": evento.toLowerCase() }
142
- }
143
- };
144
-
145
- const tempJsonPath = path.join(baseStorage, `search_${channelID}_${timestamp}.json`);
146
- fs.writeFileSync(tempJsonPath, JSON.stringify(searchPayload), 'utf8');
147
-
148
- const targetUrl = `${node.protocol}://${node.host}:${node.port}/ISAPI/ContentMgmt/eventRecordSearch?format=json`;
149
-
150
- // Compiliamo il comando cURL inserendo gli header emersi dall'analisi di Chrome
151
- // Usiamo sia il content-type form-urlencoded sia la coppia speculare cookie/sessiontag
152
- const curlCommand = `curl -s -X POST --digest -u "${node.user}:${node.pass}" ` +
153
- `-H "Content-Type: application/x-www-form-urlencoded; charset=UTF-8" ` +
154
- `-H "X-Requested-With: XMLHttpRequest" ` +
155
- `-H "sessiontag: ${fakeSessionToken}" ` +
156
- `-b "${cookieKeyName}=${fakeSessionToken}; updatePlugin=false" ` +
157
- `-d "@${tempJsonPath}" "${targetUrl}"`;
158
-
159
- let jsonResponseRaw = await new Promise((resolve) => { exec(curlCommand, (err, stdout) => resolve(err ? null : stdout)); });
160
-
161
- try { if (fs.existsSync(tempJsonPath)) fs.unlinkSync(tempJsonPath); } catch(e){}
162
-
163
- if (!jsonResponseRaw) {
164
- node.error("[DEBUG JSON] Nessuna risposta dall'NVR.");
165
- updateNodeStatus();
166
- return;
167
- }
168
-
169
- const searchResult = JSON.parse(jsonResponseRaw);
170
- const matches = searchResult?.EventSearchResult?.matchList || [];
171
-
172
- if (matches.length === 0) {
173
- node.warn(`[DEBUG JSON] Zero match trovati nell'archivio protetto.`);
174
- updateNodeStatus();
175
- return;
176
- }
177
-
178
- // Se superiamo il blocco, estraiamo i file storici
179
- const matchItem = matches[0];
180
- const playbackUri = matchItem?.mediaSegmentDescriptor?.playbackURI;
181
-
182
- if (playbackUri) {
183
- node.warn(`[DEBUG JSON] File individuato: ${playbackUri}`);
158
+ const resSearch = await camAuth.request({
159
+ method: 'POST', url: `${baseUrl}/search`, data: searchXml, headers: { "Content-Type": "application/xml" }
160
+ });
184
161
 
185
- // --- DOWNLOAD FOTO REGISTRATA ---
186
- let pictureUrlPath = null;
187
- const urlParamsMatch = playbackUri.match(/\?(.*)/);
188
- if (urlParamsMatch) {
189
- const trackI = (channelID * 100 + 3).toString();
190
- pictureUrlPath = `/picture/Streaming/tracks/${trackI}/?${urlParamsMatch[1]}`;
191
- }
192
-
193
- if (pictureUrlPath) {
194
- const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
195
- const curlImgCmd = `curl -s -X GET --digest -u "${node.user}:${node.pass}" -b "${cookieKeyName}=${fakeSessionToken}" -H "sessiontag: ${fakeSessionToken}" "${node.protocol}://${node.host}:${node.port}${pictureUrlPath}" -o "${fullImgPath}"`;
196
- await new Promise((resolve) => { exec(curlImgCmd, () => resolve()); });
197
- if (fs.existsSync(fullImgPath) && fs.statSync(fullImgPath).size > 100) {
162
+ let xml = resSearch.data.replace(/<(\/?)\w+:/g, "<$1");
163
+ const uriMatch = xml.match(/<playbackURI>([^<]+)</);
164
+
165
+ if (uriMatch) {
166
+ const rawUri = uriMatch[1].replace(/&amp;/g, '&');
167
+ const resDown = await camAuth.request({
168
+ method: 'GET',
169
+ url: `${baseUrl}/download`,
170
+ data: `<?xml version="1.0" encoding="UTF-8"?><downloadRequest><playbackURI>${rawUri.replace(/&/g, '&amp;')}</playbackURI></downloadRequest>`,
171
+ responseType: 'arraybuffer'
172
+ });
173
+
174
+ let buffer = Buffer.from(resDown.data);
175
+ if (t.id === "203") {
176
+ // SALVA FOTO IN LOCALE
177
+ const fullImgPath = path.join(baseStorage, `img_${timestamp}.jpg`);
178
+ fs.writeFileSync(fullImgPath, buffer);
179
+
180
+ // La convertiamo subito in testo per il payload
198
181
  output.foto_base64 = fs.readFileSync(fullImgPath, { encoding: 'base64' });
182
+
183
+ // Registriamo il file per la distruzione futura
199
184
  fileDaCancellare.push(fullImgPath);
185
+ } else {
186
+ // SALVA VIDEO IN LOCALE-
187
+ if (buffer.slice(0, 4).toString() === 'IMKH') buffer = buffer.slice(40);
188
+ const rawPath = path.join(baseStorage, `raw_${timestamp}.mp4`);
189
+ const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
190
+ fs.writeFileSync(rawPath, buffer);
191
+
192
+ // Eseguiamo ffmpeg localmente
193
+ await new Promise((resolve) => {
194
+ exec(`ffmpeg -y -i "${rawPath}" -c copy -movflags +faststart "${fixedPath}"`, (err) => {
195
+ if (!err && fs.existsSync(fixedPath)) {
196
+ output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
197
+ fileDaCancellare.push(fixedPath);
198
+ } else {
199
+ output.video_base64 = fs.readFileSync(rawPath, { encoding: 'base64' });
200
+ }
201
+ fileDaCancellare.push(rawPath);
202
+ resolve();
203
+ });
204
+ });
200
205
  }
201
206
  }
202
-
203
- // --- DOWNLOAD VIDEO REGISTRATO VIA FFMPEG ---
204
- const fixedPath = path.join(baseStorage, `hik_v_${channelID}_${timestamp}.mp4`);
205
- const authenticatedRtspUrl = playbackUri.replace("rtsp://", `rtsp://${node.user}:${node.pass}@`);
206
- const ffmpegRtspCmd = `ffmpeg -y -rtsp_transport tcp -i "${authenticatedRtspUrl}" -t 15 -c:v copy -c:a aac -movflags +faststart "${fixedPath}"`;
207
-
208
- await new Promise((resolve) => { exec(ffmpegRtspCmd, { timeout: 25000 }, () => resolve()); });
209
-
210
- if (fs.existsSync(fixedPath) && fs.statSync(fixedPath).size > 1000) {
211
- output.video_base64 = fs.readFileSync(fixedPath, { encoding: 'base64' });
212
- fileDaCancellare.push(fixedPath);
213
- }
214
207
  }
215
208
 
209
+ // Spediamo il pacchetto completo verso l'HTTP Request tramite Node-RED
216
210
  if (output.foto_base64 || output.video_base64) {
217
211
  node.send({ payload: output });
212
+
213
+ // TIMER A 2 MINUTI PER LA PULIZIA DEL DISCO
218
214
  setTimeout(() => {
219
215
  for (let file of fileDaCancellare) {
220
- try { if (fs.existsSync(file)) fs.unlinkSync(file); } catch (err) {}
216
+ try {
217
+ if (fs.existsSync(file)) {
218
+ fs.unlinkSync(file);
219
+ }
220
+ } catch (err) {
221
+ node.error(`Errore durante la pulizia del file temporaneo ${file}: ${err.message}`);
222
+ }
221
223
  }
222
- }, 60000);
224
+ }, 120000);
223
225
  }
224
226
 
225
227
  } catch (e) {
226
- node.error(`Errore query speculare: ${e.message}`);
228
+ node.error(`Errore Download Cam ${channelID}: ${e.message}`);
227
229
  }
228
230
  updateNodeStatus();
229
231
  }
230
232
 
231
- // --- ASCOLTO LIVE STREAM DEGLI ALLARMI ---
233
+ // --- ALERT STREAM ---
232
234
  function startAlertStream() {
233
235
  if (isClosing) return;
236
+ const nvrAuth = new AxiosDigestAuth({ username: node.user, password: node.pass });
234
237
  const url = `${node.protocol}://${node.host}:${node.port}/ISAPI/Event/notification/alertStream`;
235
-
236
- nvrAuthAxios.request({ method: 'GET', url: url, responseType: 'stream' })
238
+ nvrAuth.request({ method: 'GET', url: url, responseType: 'stream', httpsAgent: node.protocol === "https" ? httpsAgent : undefined })
237
239
  .then(response => {
238
240
  streamRequest = response;
239
- if (!nvrOnline) {
240
- node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", ip: node.host, msg: "NVR Online", nome_cliente: node.name } });
241
- nvrOnline = true;
242
- }
241
+ if (!nvrOnline) { node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "online", ip: node.host, msg: "NVR Online", nome_cliente: node.name } }); nvrOnline = true; }
243
242
  updateNodeStatus();
244
-
245
243
  response.data.on('data', (chunk) => {
246
- const data = chunk.toString();
247
- if (data.toLowerCase().includes("active")) {
248
- const chMatch = data.match(/<channelID>(\d+)<\/channelID>/i);
244
+ const data = chunk.toString().toLowerCase();
245
+ if (data.includes("active")) {
246
+ const chMatch = data.match(/<channelid>(\d+)<\/channelid>/i);
249
247
  if (chMatch) {
250
- let ev = "LineDetection";
251
- if (data.toLowerCase().includes("fielddetection")) ev = "FieldDetection";
248
+ let ev = "Unknown";
249
+ for (let e of EventList) { if (data.includes(e.toLowerCase())) { ev = e; break; } }
252
250
  downloadMedia(ev, chMatch[1]);
253
251
  }
254
252
  }
@@ -259,17 +257,13 @@ module.exports = function(RED) {
259
257
  }
260
258
 
261
259
  function handleNvrError() {
262
- if (nvrOnline) {
263
- node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", ip: node.host, msg: "NVR Offline", nome_cliente: node.name } });
264
- nvrOnline = false;
265
- }
260
+ if (nvrOnline) { node.send({ payload: { tipo_messaggio: "status", stato_telecamera: "offline", ip: node.host, msg: "NVR Offline", nome_cliente: node.name } }); nvrOnline = false; }
266
261
  updateNodeStatus();
267
262
  if (!isClosing) setTimeout(startAlertStream, 10000);
268
263
  }
269
264
 
270
265
  startAlertStream();
271
266
  setTimeout(checkCameras, 2000);
272
-
273
267
  node.on('close', (done) => {
274
268
  isClosing = true;
275
269
  clearInterval(heartbeatInterval);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-hik-media-buffer",
3
- "version": "1.1.109",
3
+ "version": "1.1.111",
4
4
  "description": "Ottiene buffer video e immagine da camere Hikvision via ISAPI",
5
5
  "keywords": [
6
6
  "node-red",
@@ -22,6 +22,7 @@
22
22
  "dependencies": {
23
23
  "@mhoc/axios-digest-auth": "^0.8.0",
24
24
  "axios": "^1.6.0",
25
+ "node-red-contrib-hik-media-buffer": "^1.1.109",
25
26
  "xml2js": "^0.6.2"
26
27
  }
27
28
  }