iobroker.agent-dvr 0.0.4 → 0.0.5

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/lib/web.js ADDED
@@ -0,0 +1,296 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const net = require('node:net');
5
+ const path = require('node:path');
6
+ const http = require('node:http');
7
+ const https = require('node:https');
8
+
9
+ const MIME = {
10
+ '.html': 'text/html; charset=utf-8',
11
+ '.js': 'application/javascript',
12
+ '.css': 'text/css',
13
+ '.png': 'image/png',
14
+ '.jpg': 'image/jpeg',
15
+ '.svg': 'image/svg+xml',
16
+ '.ico': 'image/x-icon',
17
+ '.json': 'application/json',
18
+ };
19
+
20
+ const WWW = path.join(__dirname, '..', 'www');
21
+ const ROUTE = 'agent-dvr';
22
+
23
+ class AgentDvrWeb {
24
+ constructor(server, webSettings, adapter, instanceSettings, app) {
25
+ this.app = app;
26
+ this.adapter = adapter;
27
+
28
+ const ns = instanceSettings ? instanceSettings._id.replace('system.adapter.', '') : 'agent-dvr.0';
29
+ const native = (instanceSettings && instanceSettings.native) ? instanceSettings.native : {};
30
+
31
+ // ── REST API ─────────────────────────────────────────────────────────
32
+ // Must be registered before the static file handler (Express matches in order)
33
+ app.get('/' + ROUTE + '/api/cameras', (req, res) => {
34
+ const base = (native.ip && native.port) ? 'http://' + native.ip + ':' + native.port : '';
35
+
36
+ Promise.all([
37
+ adapter.getForeignStatesAsync(ns + '.cam_*.name'),
38
+ adapter.getForeignStatesAsync(ns + '.cam_*.urls.mjpeg'),
39
+ adapter.getForeignStatesAsync(ns + '.cam_*.urls.snapshot'),
40
+ adapter.getForeignStatesAsync(ns + '.cam_*.data.online'),
41
+ adapter.getForeignStatesAsync(ns + '.cam_*.control.ptz.up'), // PTZ detection
42
+ adapter.getForeignStatesAsync(ns + '.cam_*.status.recording'),
43
+ adapter.getForeignStatesAsync(ns + '.cam_*.status.detected'),
44
+ adapter.getForeignStatesAsync(ns + '.cam_*.status.alerted'),
45
+ ]).then(([nameStates, mjpegStates, snapStates, onlineStates, ptzStates, recStates, detStates, alertStates]) => {
46
+ const cameras = {};
47
+
48
+ Object.entries(nameStates || {}).forEach(([id, state]) => {
49
+ const rel = id.slice(ns.length + 1);
50
+ const camKey = rel.split('.')[0];
51
+ const m = camKey.match(/^cam_(\d+)_/);
52
+ cameras[camKey] = {
53
+ name: (state && state.val != null) ? String(state.val) : camKey,
54
+ oid: m ? m[1] : null,
55
+ mjpegUrl: null,
56
+ snapshotUrl: null,
57
+ online: false,
58
+ hasPtz: false,
59
+ recording: false,
60
+ detected: false,
61
+ alerted: false,
62
+ };
63
+ });
64
+
65
+ Object.entries(mjpegStates || {}).forEach(([id, state]) => {
66
+ const camKey = id.slice(ns.length + 1).split('.')[0];
67
+ if (cameras[camKey] && state && state.val) cameras[camKey].mjpegUrl = String(state.val);
68
+ });
69
+
70
+ Object.entries(snapStates || {}).forEach(([id, state]) => {
71
+ const camKey = id.slice(ns.length + 1).split('.')[0];
72
+ if (cameras[camKey] && state && state.val) cameras[camKey].snapshotUrl = String(state.val);
73
+ });
74
+
75
+ Object.entries(onlineStates || {}).forEach(([id, state]) => {
76
+ const camKey = id.slice(ns.length + 1).split('.')[0];
77
+ if (cameras[camKey]) cameras[camKey].online = !!(state && state.val);
78
+ });
79
+
80
+ // PTZ: state exists → camera has PTZ
81
+ Object.keys(ptzStates || {}).forEach(id => {
82
+ const camKey = id.slice(ns.length + 1).split('.')[0];
83
+ if (cameras[camKey]) cameras[camKey].hasPtz = true;
84
+ });
85
+
86
+ Object.entries(recStates || {}).forEach(([id, state]) => {
87
+ const camKey = id.slice(ns.length + 1).split('.')[0];
88
+ if (cameras[camKey]) cameras[camKey].recording = !!(state && state.val);
89
+ });
90
+
91
+ Object.entries(detStates || {}).forEach(([id, state]) => {
92
+ const camKey = id.slice(ns.length + 1).split('.')[0];
93
+ if (cameras[camKey]) cameras[camKey].detected = !!(state && state.val);
94
+ });
95
+
96
+ Object.entries(alertStates || {}).forEach(([id, state]) => {
97
+ const camKey = id.slice(ns.length + 1).split('.')[0];
98
+ if (cameras[camKey]) cameras[camKey].alerted = !!(state && state.val);
99
+ });
100
+
101
+ // Fallback URLs if enableUrls was off
102
+ if (base) {
103
+ Object.values(cameras).forEach(cam => {
104
+ if (!cam.mjpegUrl && cam.oid) cam.mjpegUrl = base + '/video.mjpg?oid=' + cam.oid;
105
+ if (!cam.snapshotUrl && cam.oid) cam.snapshotUrl = base + '/grab.jpg?oid=' + cam.oid;
106
+ });
107
+ }
108
+
109
+ res.setHeader('Content-Type', 'application/json');
110
+ res.end(JSON.stringify({ instance: ns, base, cameras }));
111
+ }).catch(err => {
112
+ adapter.log.error('[agent-dvr] /api/cameras error: ' + err.message);
113
+ res.status(500).json({ error: err.message });
114
+ });
115
+ });
116
+
117
+ // ── Dashboard config endpoint ─────────────────────────────────────────
118
+ app.get('/' + ROUTE + '/api/config', (req, res) => {
119
+ res.setHeader('Content-Type', 'application/json');
120
+ res.end(JSON.stringify({
121
+ dashDefaultView: native.dashDefaultView || 'live',
122
+ dashShowOffline: native.dashShowOffline !== false,
123
+ dashGridCols: native.dashGridCols || 0,
124
+ dashBtnsVisible: !!native.dashBtnsVisible,
125
+ dashRefreshSec: native.dashRefreshSec || 60,
126
+ dashStreamReconnect: native.dashStreamReconnect !== false,
127
+ dashColorBg: native.dashColorBg || '#080b0f',
128
+ dashColorSurface: native.dashColorSurface || '#0d1117',
129
+ dashColorAccent: native.dashColorAccent || '#2563eb',
130
+ dashColorText: native.dashColorText || '#dde4ef',
131
+ dashColorBorder: native.dashColorBorder || '#1e2a38',
132
+ dashColorOnline: native.dashColorOnline || '#22c55e',
133
+ dashColorOffline: native.dashColorOffline || '#ef4444',
134
+ dashTagPosition: native.dashTagPosition || 'top-right',
135
+ go2rtcEnabled: !!native.go2rtcEnabled,
136
+ go2rtcUrl: native.go2rtcUrl || '',
137
+ go2rtcMapping: Array.isArray(native.go2rtcMapping) ? native.go2rtcMapping : [],
138
+ }));
139
+ });
140
+
141
+ // ── go2rtc streams proxy ─────────────────────────────────────────────
142
+ app.get('/' + ROUTE + '/api/go2rtc/streams', (req, res) => {
143
+ if (!native.go2rtcEnabled || !native.go2rtcUrl) {
144
+ res.setHeader('Content-Type', 'application/json');
145
+ res.end('{"streams":[]}');
146
+ return;
147
+ }
148
+ let target;
149
+ try { target = new URL('/api/streams', native.go2rtcUrl); }
150
+ catch (_) { res.setHeader('Content-Type', 'application/json'); res.end('{"streams":[]}'); return; }
151
+ const mod = target.protocol === 'https:' ? https : http;
152
+ const req2 = mod.get(target.toString(), r2 => {
153
+ let body = '';
154
+ r2.on('data', c => { body += c; });
155
+ r2.on('end', () => {
156
+ try {
157
+ const streams = Object.keys(JSON.parse(body) || {});
158
+ res.setHeader('Content-Type', 'application/json');
159
+ res.end(JSON.stringify({ streams }));
160
+ } catch (_) {
161
+ res.setHeader('Content-Type', 'application/json');
162
+ res.end('{"streams":[]}');
163
+ }
164
+ });
165
+ });
166
+ req2.setTimeout(4000, () => { req2.destroy(); });
167
+ req2.on('error', () => {
168
+ if (!res.headersSent) {
169
+ res.setHeader('Content-Type', 'application/json');
170
+ res.end('{"streams":[]}');
171
+ }
172
+ });
173
+ });
174
+
175
+ // ── PTZ endpoint ─────────────────────────────────────────────────────
176
+ app.get('/' + ROUTE + '/api/ptz', (req, res) => {
177
+ const camKey = String(req.query.camKey || '');
178
+ const dir = String(req.query.dir || '');
179
+ const val = req.query.val === '1';
180
+ const DIRS = new Set(['left','upLeft','up','upRight','right','downRight','down','downLeft','zoomIn','zoomOut','stop','center']);
181
+ if (!camKey || !/^cam_[a-zA-Z0-9_-]+$/.test(camKey) || !DIRS.has(dir)) {
182
+ res.status(400).json({ error: 'invalid params' }); return;
183
+ }
184
+ adapter.setForeignStateAsync(ns + '.' + camKey + '.control.ptz.' + dir, { val, ack: false })
185
+ .then(() => { res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}'); })
186
+ .catch(err => { adapter.log.warn('[agent-dvr] ptz error: ' + err.message); res.status(500).json({ error: err.message }); });
187
+ });
188
+
189
+ // ── Record control endpoint ───────────────────────────────────────────
190
+ app.get('/' + ROUTE + '/api/record', (req, res) => {
191
+ const camKey = String(req.query.camKey || '');
192
+ const action = String(req.query.action || '');
193
+ if (!camKey || !/^cam_[a-zA-Z0-9_-]+$/.test(camKey) || !['start','stop'].includes(action)) {
194
+ res.status(400).json({ error: 'invalid params' }); return;
195
+ }
196
+ const cmd = action === 'start' ? 'record' : 'recordStop';
197
+ adapter.setForeignStateAsync(ns + '.' + camKey + '.control.' + cmd, { val: true, ack: false })
198
+ .then(() => { res.setHeader('Content-Type', 'application/json'); res.end('{"ok":true}'); })
199
+ .catch(err => { adapter.log.warn('[agent-dvr] record error: ' + err.message); res.status(500).json({ error: err.message }); });
200
+ });
201
+
202
+
203
+ // ── Recordings endpoint — reads events.json state, no AgentDVR call ─
204
+ app.get('/' + ROUTE + '/api/recordings', (req, res) => {
205
+ const camKey = String(req.query.camKey || '');
206
+ if (!camKey || !/^cam_[a-zA-Z0-9_-]+$/.test(camKey)) {
207
+ res.status(400).json({ error: 'invalid camKey' });
208
+ return;
209
+ }
210
+ adapter.getForeignStateAsync(ns + '.' + camKey + '.events.json')
211
+ .then(state => {
212
+ const json = (state && state.val) ? String(state.val) : '[]';
213
+ res.setHeader('Content-Type', 'application/json');
214
+ res.end(json);
215
+ })
216
+ .catch(err => {
217
+ adapter.log.warn('[agent-dvr] /api/recordings error: ' + err.message);
218
+ res.status(500).json({ error: err.message });
219
+ });
220
+ });
221
+
222
+ // ── Static file handler ───────────────────────────────────────────────
223
+ app.use('/' + ROUTE, (req, res) => {
224
+ let rel = (req.url || '/').split('?')[0];
225
+ if (rel === '/' || rel === '') rel = '/index.html';
226
+
227
+ const file = path.resolve(path.join(WWW, rel));
228
+
229
+ if (!file.startsWith(WWW + path.sep) && file !== path.join(WWW, 'index.html')) {
230
+ res.status(403).end();
231
+ return;
232
+ }
233
+
234
+ fs.readFile(file, (err, data) => {
235
+ if (err) { res.status(404).end('Not found'); return; }
236
+ const ext = path.extname(file).toLowerCase();
237
+ res.setHeader('Content-Type', MIME[ext] || 'application/octet-stream');
238
+ res.end(data);
239
+ });
240
+ });
241
+
242
+ adapter.log.info('[agent-dvr] Web UI: /' + ROUTE + '/');
243
+
244
+ // ── WebSocket proxy for go2rtc (bypasses browser cross-origin block) ─
245
+ server.on('upgrade', (req, socket, head) => {
246
+ if (!(req.url || '').startsWith('/' + ROUTE + '/api/ws')) return;
247
+ if (!native.go2rtcEnabled || !native.go2rtcUrl) {
248
+ socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n');
249
+ socket.destroy();
250
+ return;
251
+ }
252
+ const qs = (req.url.split('?')[1] || '');
253
+ const src = new URLSearchParams(qs).get('src') || '';
254
+ let target;
255
+ try { target = new URL('/api/ws?src=' + encodeURIComponent(src), native.go2rtcUrl.replace(/\/+$/, '')); }
256
+ catch (_) { socket.write('HTTP/1.1 400 Bad Request\r\n\r\n'); socket.destroy(); return; }
257
+
258
+ const port = parseInt(target.port) || (target.protocol === 'https:' ? 443 : 80);
259
+ const upstream = net.connect(port, target.hostname, () => {
260
+ const lines = [
261
+ 'GET ' + target.pathname + target.search + ' HTTP/1.1',
262
+ 'Host: ' + target.hostname + ':' + port,
263
+ 'Upgrade: websocket',
264
+ 'Connection: Upgrade',
265
+ 'Sec-WebSocket-Key: ' + (req.headers['sec-websocket-key'] || ''),
266
+ 'Sec-WebSocket-Version: 13',
267
+ '', '',
268
+ ];
269
+ upstream.write(lines.join('\r\n'));
270
+ if (head && head.length) upstream.write(head);
271
+ upstream.pipe(socket);
272
+ socket.pipe(upstream);
273
+ });
274
+ upstream.on('error', err => {
275
+ adapter.log.warn('[agent-dvr] go2rtc WS proxy error: ' + err.message);
276
+ if (!socket.destroyed) { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); }
277
+ });
278
+ socket.on('error', () => { if (!upstream.destroyed) upstream.destroy(); });
279
+ socket.on('close', () => { if (!upstream.destroyed) upstream.destroy(); });
280
+ });
281
+ }
282
+
283
+ unload() { return Promise.resolve(); }
284
+
285
+ welcomePage() {
286
+ return {
287
+ link: ROUTE + '/',
288
+ name: 'AgentDVR',
289
+ img: 'adapter/agent-dvr/agent-dvr.png',
290
+ color: '#2196f3',
291
+ order: 10,
292
+ };
293
+ }
294
+ }
295
+
296
+ module.exports = AgentDvrWeb;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.agent-dvr",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Connects ioBroker to AgentDVR: auto-discovers cameras, mirrors all device values as data points, delivers real-time triggers on new recordings, and generates a responsive HTML gallery with optional search and tag filtering.",
5
5
  "author": {
6
6
  "name": "ipod86",
@@ -46,6 +46,7 @@
46
46
  "admin{,/!(src)/**}/!(tsconfig|tsconfig.*|.eslintrc).{json,json5}",
47
47
  "admin{,/!(src)/**}/*.{html,css,png,svg,jpg,js}",
48
48
  "build/",
49
+ "lib/",
49
50
  "www/",
50
51
  "io-package.json",
51
52
  "LICENSE"