iobroker.motioneye 0.0.1

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.
@@ -0,0 +1,487 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+
5
+ const STREAM_HTML_STYLE = 'width:100%; height:100%; object-fit:contain; display:block;';
6
+ const STREAM_LOADING_HTML = '<p style="margin:0;padding:2em;text-align:center;color:#aaa;">Stream starting…</p>';
7
+ const STREAM_PAUSED_HTML = '';
8
+ const STREAM_ERROR_HTML =
9
+ '<p style="margin:0;padding:2em;text-align:center;color:#f88;">Stream not reachable<br>' +
10
+ '<span style="color:#aaa;font-size:0.9em;">MotionEye port offline or VIS blocks HTTP images (HTTPS?)</span></p>';
11
+
12
+ /**
13
+ * @param {number} motionEyeId
14
+ * @param {Record<string, unknown>} [uiConfig]
15
+ * @returns {number}
16
+ */
17
+ function resolveStreamPort(motionEyeId, uiConfig) {
18
+ if (uiConfig && uiConfig.streaming_port != null) {
19
+ return Number(uiConfig.streaming_port);
20
+ }
21
+ return 9080 + motionEyeId;
22
+ }
23
+
24
+ /**
25
+ * @param {string} motionHost
26
+ * @param {number} motionEyeId
27
+ * @param {Record<string, unknown>} [uiConfig]
28
+ * @param {number} [cacheBust]
29
+ * @returns {string}
30
+ */
31
+ function buildStreamSrc(motionHost, motionEyeId, uiConfig, cacheBust) {
32
+ const streamPort = resolveStreamPort(motionEyeId, uiConfig);
33
+ const bust = cacheBust ? `?t=${cacheBust}` : '';
34
+ return `http://${motionHost}:${streamPort}/${bust}`;
35
+ }
36
+
37
+ /**
38
+ * @param {string} motionHost
39
+ * @param {number} motionEyeId
40
+ * @param {Record<string, unknown>} [uiConfig]
41
+ * @param {number} [cacheBust]
42
+ * @returns {string}
43
+ */
44
+ function buildStreamHtml(motionHost, motionEyeId, uiConfig, cacheBust) {
45
+ const src = buildStreamSrc(motionHost, motionEyeId, uiConfig, cacheBust);
46
+ const streamPort = resolveStreamPort(motionEyeId, uiConfig);
47
+ const baseSrc = `http://${motionHost}:${streamPort}/`;
48
+ const reconnect = `this.onerror=null;this.src='${baseSrc}?t='+Date.now()`;
49
+
50
+ return (
51
+ `<div style="width:100%;height:100%;overflow:hidden;background:#000;">` +
52
+ `<img src="${src}" style="${STREAM_HTML_STYLE}" alt="" onerror="${reconnect}">` +
53
+ `</div>`
54
+ );
55
+ }
56
+
57
+ /**
58
+ * @param {string} motionHost
59
+ * @param {number} port
60
+ * @returns {Promise<boolean>}
61
+ */
62
+ function checkStreamPort(motionHost, port) {
63
+ return new Promise(resolve => {
64
+ const req = http.request(
65
+ {
66
+ hostname: motionHost,
67
+ port,
68
+ path: '/',
69
+ method: 'GET',
70
+ timeout: 4000,
71
+ },
72
+ res => {
73
+ req.destroy();
74
+ resolve((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 400);
75
+ },
76
+ );
77
+
78
+ req.on('timeout', () => {
79
+ req.destroy();
80
+ resolve(false);
81
+ });
82
+ req.on('error', () => resolve(false));
83
+ req.end();
84
+ });
85
+ }
86
+
87
+ function sleep(ms) {
88
+ return new Promise(resolve => setTimeout(resolve, ms));
89
+ }
90
+
91
+ /**
92
+ * @typedef {object} StreamManagerDeps
93
+ * @property {string} motionHost
94
+ * @property {ReturnType<import('./motionEyeApi')['createMotionEyeApi']>} motionEyeApi
95
+ * @property {boolean} useMotionEyeConfig
96
+ * @property {boolean} disableStreamOnStart
97
+ * @property {number} streamAutoOffMs
98
+ * @property {number} streamStartDelayMs
99
+ * @property {number} streamReadyTimeoutMs
100
+ * @property {number} streamRetryMs
101
+ * @property {number} streamSiblingRelinkTimeoutMs
102
+ * @property {(channelId: string) => Promise<ioBroker.State | null | undefined>} getState
103
+ * @property {(id: string, val: ioBroker.StateValue, ack?: boolean) => Promise<unknown>} setState
104
+ * @property {(level: string, message: string) => void} log
105
+ * @property {(fn: () => void, ms: number) => unknown} setTimeoutFn
106
+ * @property {(id: unknown) => void} clearTimeoutFn
107
+ * @property {() => Map<string, import('./cameraRegistry').ResolvedCamera>} getCamerasByChannel
108
+ * @property {() => boolean} isUnloading
109
+ */
110
+
111
+ /**
112
+ * @param {StreamManagerDeps} deps
113
+ */
114
+ function createStreamManager(deps) {
115
+ /** @type {Record<string, unknown>} */
116
+ const timers = {};
117
+ /** @type {Record<string, number>} */
118
+ const relinkRunIds = {};
119
+
120
+ /**
121
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
122
+ */
123
+ function channelId(camera) {
124
+ return camera.channel;
125
+ }
126
+
127
+ /**
128
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
129
+ */
130
+ async function isStreamEnabled(camera) {
131
+ const state = await deps.getState(`${channelId(camera)}.stream`);
132
+ return !!(state && state.val);
133
+ }
134
+
135
+ /**
136
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
137
+ * @param {string} html
138
+ */
139
+ async function setStreamUrl(camera, html) {
140
+ await deps.setState(`${channelId(camera)}.streamUrl`, html, true);
141
+ }
142
+
143
+ /**
144
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
145
+ */
146
+ async function setStreamLoadingHtml(camera) {
147
+ await setStreamUrl(camera, STREAM_LOADING_HTML);
148
+ }
149
+
150
+ /**
151
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
152
+ */
153
+ async function setStreamPausedHtml(camera) {
154
+ await setStreamUrl(camera, STREAM_PAUSED_HTML);
155
+ }
156
+
157
+ /**
158
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
159
+ * @param {boolean} enabled
160
+ * @param {number} [cacheBust]
161
+ * @param {Record<string, unknown>} [uiConfig]
162
+ * @param {boolean} [force]
163
+ */
164
+ async function updateStreamHtml(camera, enabled, cacheBust, uiConfig, force = false) {
165
+ const html = enabled
166
+ ? buildStreamHtml(deps.motionHost, camera.motionEyeId, uiConfig, cacheBust)
167
+ : STREAM_PAUSED_HTML;
168
+ const current = await deps.getState(`${channelId(camera)}.streamUrl`);
169
+ if (!force && current && current.val === html) {
170
+ return;
171
+ }
172
+ await setStreamUrl(camera, html);
173
+ }
174
+
175
+ /**
176
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
177
+ */
178
+ function clearStreamHtmlTimer(camera) {
179
+ const key = `${channelId(camera)}.streamHtml`;
180
+ if (timers[key]) {
181
+ deps.clearTimeoutFn(timers[key]);
182
+ delete timers[key];
183
+ }
184
+ }
185
+
186
+ /**
187
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
188
+ */
189
+ function clearStreamAutoOffTimer(camera) {
190
+ const key = `${channelId(camera)}.streamAutoOff`;
191
+ if (timers[key]) {
192
+ deps.clearTimeoutFn(timers[key]);
193
+ delete timers[key];
194
+ }
195
+ }
196
+
197
+ /**
198
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
199
+ */
200
+ async function getStreamUiConfig(camera) {
201
+ const uiConfig = await deps.motionEyeApi.getCameraConfig(camera.motionEyeId);
202
+ return {
203
+ streaming_port: resolveStreamPort(camera.motionEyeId, uiConfig),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
209
+ */
210
+ async function publishStreamHtmlWhenReady(camera) {
211
+ const started = Date.now();
212
+ await sleep(deps.streamStartDelayMs);
213
+
214
+ while (Date.now() - started < deps.streamReadyTimeoutMs) {
215
+ if (deps.isUnloading() || !(await isStreamEnabled(camera))) {
216
+ return;
217
+ }
218
+
219
+ let uiConfig;
220
+ try {
221
+ uiConfig = await getStreamUiConfig(camera);
222
+ } catch (error) {
223
+ deps.log('debug', `Stream port query ${camera.name}: ${error.message}`);
224
+ await sleep(deps.streamRetryMs);
225
+ continue;
226
+ }
227
+
228
+ const port = Number(uiConfig.streaming_port);
229
+ const ready = await checkStreamPort(deps.motionHost, port);
230
+ if (ready) {
231
+ await updateStreamHtml(camera, true, Date.now(), uiConfig);
232
+ deps.log('info', `Stream HTML set for ${camera.name} (port ${port})`);
233
+ return;
234
+ }
235
+
236
+ await setStreamLoadingHtml(camera);
237
+ await sleep(deps.streamRetryMs);
238
+ }
239
+
240
+ if (await isStreamEnabled(camera)) {
241
+ await setStreamUrl(camera, STREAM_ERROR_HTML);
242
+ deps.log('warn', `Stream for ${camera.name} not reachable after ${deps.streamReadyTimeoutMs} ms`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
248
+ */
249
+ function scheduleStreamHtml(camera) {
250
+ clearStreamHtmlTimer(camera);
251
+ publishStreamHtmlWhenReady(camera).catch(error => {
252
+ deps.log('warn', `Stream HTML error for ${camera.name}: ${error.message}`);
253
+ });
254
+ }
255
+
256
+ /**
257
+ * @param {import('./cameraRegistry').ResolvedCamera} changedCamera
258
+ */
259
+ function getActiveSiblingCameras(changedCamera) {
260
+ const siblings = [];
261
+ for (const cam of deps.getCamerasByChannel().values()) {
262
+ if (cam.channel === changedCamera.channel) {
263
+ continue;
264
+ }
265
+ siblings.push(cam);
266
+ }
267
+ return siblings;
268
+ }
269
+
270
+ /**
271
+ * @param {import('./cameraRegistry').ResolvedCamera} changedCamera
272
+ */
273
+ async function publishSiblingStreamRelink(changedCamera) {
274
+ const siblings = [];
275
+ for (const cam of getActiveSiblingCameras(changedCamera)) {
276
+ if (await isStreamEnabled(cam)) {
277
+ siblings.push(cam);
278
+ }
279
+ }
280
+ if (!siblings.length) {
281
+ return;
282
+ }
283
+
284
+ const runId = Date.now();
285
+ const runKey = `${channelId(changedCamera)}.siblingRelinkRun`;
286
+ relinkRunIds[runKey] = runId;
287
+
288
+ const started = Date.now();
289
+ /** @type {Record<string, boolean>} */
290
+ const relinked = {};
291
+
292
+ await sleep(deps.streamStartDelayMs);
293
+
294
+ while (Date.now() - started < deps.streamSiblingRelinkTimeoutMs) {
295
+ if (deps.isUnloading() || relinkRunIds[runKey] !== runId) {
296
+ return;
297
+ }
298
+
299
+ for (const cam of siblings) {
300
+ if (!(await isStreamEnabled(cam))) {
301
+ continue;
302
+ }
303
+
304
+ try {
305
+ const uiConfig = await getStreamUiConfig(cam);
306
+ const port = Number(uiConfig.streaming_port);
307
+ if (!(await checkStreamPort(deps.motionHost, port))) {
308
+ continue;
309
+ }
310
+ await updateStreamHtml(cam, true, Date.now(), uiConfig, true);
311
+ if (!relinked[cam.channel]) {
312
+ deps.log('info', `Stream re-linked for ${cam.name}`);
313
+ relinked[cam.channel] = true;
314
+ }
315
+ } catch (error) {
316
+ deps.log('warn', `Stream re-link ${cam.name}: ${error.message}`);
317
+ }
318
+ }
319
+
320
+ let allDone = true;
321
+ for (const cam of siblings) {
322
+ if ((await isStreamEnabled(cam)) && !relinked[cam.channel]) {
323
+ allDone = false;
324
+ break;
325
+ }
326
+ }
327
+ if (allDone) {
328
+ return;
329
+ }
330
+
331
+ await sleep(deps.streamRetryMs);
332
+ }
333
+ }
334
+
335
+ /**
336
+ * @param {import('./cameraRegistry').ResolvedCamera} changedCamera
337
+ */
338
+ function scheduleSiblingStreamRelink(changedCamera) {
339
+ if (!deps.streamSiblingRelinkTimeoutMs) {
340
+ return;
341
+ }
342
+ delete relinkRunIds[`${channelId(changedCamera)}.siblingRelinkRun`];
343
+ publishSiblingStreamRelink(changedCamera).catch(error => {
344
+ deps.log('warn', `Stream sibling re-link error: ${error.message}`);
345
+ });
346
+ }
347
+
348
+ function scheduleStreamOff(setStreamFn) {
349
+ return (/** @type {import('./cameraRegistry').ResolvedCamera} */ camera) => {
350
+ if (!deps.streamAutoOffMs) {
351
+ return;
352
+ }
353
+ clearStreamAutoOffTimer(camera);
354
+ const key = `${channelId(camera)}.streamAutoOff`;
355
+ timers[key] = deps.setTimeoutFn(() => {
356
+ delete timers[key];
357
+ setStreamFn(camera, false).catch(error => {
358
+ deps.log('warn', `Stream auto-off for ${camera.name}: ${error.message}`);
359
+ });
360
+ }, deps.streamAutoOffMs);
361
+ };
362
+ }
363
+
364
+ const api = {
365
+ /**
366
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
367
+ * @param {boolean} enabled
368
+ * @param {boolean} [fromPoll]
369
+ * @param {boolean} [autoOff]
370
+ */
371
+ async setStream(camera, enabled, fromPoll = false, autoOff = false) {
372
+ if (!deps.useMotionEyeConfig) {
373
+ if (!fromPoll) {
374
+ await deps.setState(`${channelId(camera)}.stream`, !!enabled, true);
375
+ }
376
+ deps.log('warn', `Stream control for ${camera.name} requires useMotionEyeConfig=true`);
377
+ return;
378
+ }
379
+
380
+ if (!enabled) {
381
+ clearStreamHtmlTimer(camera);
382
+ }
383
+
384
+ const result = await deps.motionEyeApi.saveCameraConfig(camera.motionEyeId, {
385
+ video_streaming: !!enabled,
386
+ });
387
+ await deps.setState(`${channelId(camera)}.lastAction`, `config/set video_streaming=${!!enabled}`, true);
388
+
389
+ if (result.changed) {
390
+ deps.log('info', `Video stream for ${camera.name}: ${enabled ? 'on' : 'off'}`);
391
+ }
392
+
393
+ if (enabled) {
394
+ if (result.changed) {
395
+ await setStreamLoadingHtml(camera);
396
+ scheduleStreamHtml(camera);
397
+ } else {
398
+ try {
399
+ const uiConfig = await getStreamUiConfig(camera);
400
+ await updateStreamHtml(camera, true, Date.now(), uiConfig);
401
+ } catch {
402
+ await setStreamLoadingHtml(camera);
403
+ scheduleStreamHtml(camera);
404
+ }
405
+ }
406
+ if (autoOff) {
407
+ scheduleStreamOff(api.setStream)(camera);
408
+ } else {
409
+ clearStreamAutoOffTimer(camera);
410
+ }
411
+ } else {
412
+ clearStreamAutoOffTimer(camera);
413
+ await setStreamPausedHtml(camera);
414
+ }
415
+
416
+ if (!fromPoll) {
417
+ await deps.setState(`${channelId(camera)}.stream`, !!enabled, true);
418
+ scheduleSiblingStreamRelink(camera);
419
+ }
420
+ },
421
+
422
+ /**
423
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
424
+ */
425
+ async pulseStream(camera) {
426
+ await api.setStream(camera, true, false, true);
427
+ },
428
+
429
+ /**
430
+ * @param {import('./cameraRegistry').ResolvedCamera} camera
431
+ */
432
+ async applyStreamOnStart(camera) {
433
+ if (!deps.useMotionEyeConfig) {
434
+ await setStreamPausedHtml(camera);
435
+ return;
436
+ }
437
+
438
+ let uiConfig;
439
+ try {
440
+ uiConfig = await deps.motionEyeApi.getCameraConfig(camera.motionEyeId);
441
+ } catch {
442
+ await setStreamPausedHtml(camera);
443
+ return;
444
+ }
445
+
446
+ if (deps.disableStreamOnStart && uiConfig.video_streaming) {
447
+ await api.setStream(camera, false);
448
+ return;
449
+ }
450
+
451
+ const streaming = !!uiConfig.video_streaming;
452
+ await deps.setState(`${channelId(camera)}.stream`, streaming, true);
453
+ if (streaming) {
454
+ try {
455
+ await updateStreamHtml(camera, true, Date.now(), {
456
+ streaming_port: uiConfig.streaming_port || 9080 + camera.motionEyeId,
457
+ });
458
+ } catch {
459
+ scheduleStreamHtml(camera);
460
+ }
461
+ } else {
462
+ await setStreamPausedHtml(camera);
463
+ }
464
+ },
465
+
466
+ destroy() {
467
+ for (const key of Object.keys(timers)) {
468
+ deps.clearTimeoutFn(timers[key]);
469
+ delete timers[key];
470
+ }
471
+ for (const key of Object.keys(relinkRunIds)) {
472
+ delete relinkRunIds[key];
473
+ }
474
+ },
475
+ };
476
+
477
+ return api;
478
+ }
479
+
480
+ module.exports = {
481
+ STREAM_LOADING_HTML,
482
+ STREAM_PAUSED_HTML,
483
+ buildStreamSrc,
484
+ buildStreamHtml,
485
+ checkStreamPort,
486
+ createStreamManager,
487
+ };
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const { buildStreamHtml, buildStreamSrc } = require('./streamManager');
5
+
6
+ describe('streamManager HTML', () => {
7
+ it('buildStreamSrc should use streaming_port or fallback 9080+id', () => {
8
+ expect(buildStreamSrc('192.168.1.1', 2, { streaming_port: 9092 }, 123)).to.equal(
9
+ 'http://192.168.1.1:9092/?t=123',
10
+ );
11
+ expect(buildStreamSrc('192.168.1.1', 2, {}, undefined)).to.equal('http://192.168.1.1:9082/');
12
+ });
13
+
14
+ it('buildStreamHtml should return img tag with cache bust', () => {
15
+ const html = buildStreamHtml('192.168.1.1', 1, { streaming_port: 9081 }, 999);
16
+ expect(html).to.include('<img src="http://192.168.1.1:9081/?t=999"');
17
+ expect(html).to.include('onerror=');
18
+ });
19
+ });
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const { parseWebhookRequest } = require('./cameraRegistry');
5
+
6
+ /**
7
+ * @typedef {object} WebhookServerOptions
8
+ * @property {number} port
9
+ * @property {string} [bind='0.0.0.0']
10
+ * @property {string} namespace Adapter namespace (e.g. motioneye.0)
11
+ * @property {(cameraId: string, value: boolean) => void|Promise<void>} onMotion
12
+ * @property {(level: string, message: string) => void} [log]
13
+ */
14
+
15
+ /**
16
+ * @param {WebhookServerOptions} options
17
+ */
18
+ function createWebhookServer(options) {
19
+ const port = options.port;
20
+ const bind = options.bind || '0.0.0.0';
21
+ const namespace = options.namespace;
22
+ const onMotion = options.onMotion;
23
+ const log = options.log || (() => {});
24
+
25
+ /** @type {import('node:http').Server | undefined} */
26
+ let server;
27
+
28
+ const httpServer = http.createServer((req, res) => {
29
+ const method = req.method || 'GET';
30
+ if (method !== 'GET' && method !== 'POST' && method !== 'HEAD') {
31
+ res.writeHead(405);
32
+ res.end();
33
+ return;
34
+ }
35
+
36
+ const path = req.url || '/';
37
+ const parsed = parseWebhookRequest(path, namespace);
38
+ if (!parsed) {
39
+ res.writeHead(404);
40
+ res.end();
41
+ return;
42
+ }
43
+
44
+ Promise.resolve(onMotion(parsed.cameraId, parsed.value))
45
+ .then(() => {
46
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
47
+ res.end('OK');
48
+ })
49
+ .catch(error => {
50
+ log('error', `Webhook handler error for ${parsed.cameraId}: ${error.message}`);
51
+ res.writeHead(500);
52
+ res.end('Error');
53
+ });
54
+ });
55
+
56
+ return {
57
+ /**
58
+ * @returns {Promise<void>}
59
+ */
60
+ start() {
61
+ return new Promise((resolve, reject) => {
62
+ if (server) {
63
+ resolve();
64
+ return;
65
+ }
66
+
67
+ server = httpServer;
68
+ server.on('error', reject);
69
+ server.listen(port, bind, () => {
70
+ log('info', `Webhook server listening on ${bind}:${port}`);
71
+ resolve();
72
+ });
73
+ });
74
+ },
75
+
76
+ /**
77
+ * @returns {Promise<void>}
78
+ */
79
+ stop() {
80
+ return new Promise(resolve => {
81
+ if (!server) {
82
+ resolve();
83
+ return;
84
+ }
85
+
86
+ server.close(() => {
87
+ server = undefined;
88
+ resolve();
89
+ });
90
+ });
91
+ },
92
+ };
93
+ }
94
+
95
+ module.exports = {
96
+ createWebhookServer,
97
+ parseWebhookRequest,
98
+ };