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.
- package/LICENSE +21 -0
- package/README.md +131 -0
- package/admin/i18n/de.json +49 -0
- package/admin/i18n/en.json +49 -0
- package/admin/i18n/es.json +49 -0
- package/admin/i18n/fr.json +49 -0
- package/admin/i18n/it.json +49 -0
- package/admin/i18n/nl.json +49 -0
- package/admin/i18n/pl.json +49 -0
- package/admin/i18n/pt.json +49 -0
- package/admin/i18n/ru.json +49 -0
- package/admin/i18n/uk.json +49 -0
- package/admin/i18n/zh-cn.json +49 -0
- package/admin/img/inventwo.svg +2 -0
- package/admin/jsonConfig.json +302 -0
- package/admin/motioneye-logo.svg +13 -0
- package/admin/motioneye.svg +1 -0
- package/io-package.json +153 -0
- package/lib/adapter-config.d.ts +19 -0
- package/lib/cameraRegistry.js +160 -0
- package/lib/cameraRegistry.test.js +56 -0
- package/lib/modeProfiles.js +102 -0
- package/lib/modeProfiles.test.js +36 -0
- package/lib/motionApi.js +73 -0
- package/lib/motionEyeApi.js +285 -0
- package/lib/motionEyeApi.test.js +62 -0
- package/lib/streamManager.js +487 -0
- package/lib/streamManager.test.js +19 -0
- package/lib/webhookServer.js +98 -0
- package/main.js +758 -0
- package/package.json +79 -0
|
@@ -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
|
+
};
|