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/main.js ADDED
@@ -0,0 +1,758 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Created with @iobroker/create-adapter (inventwo scaffold)
5
+ */
6
+
7
+ const utils = require('@iobroker/adapter-core');
8
+ const { createMotionEyeApi } = require('./lib/motionEyeApi');
9
+ const { createMotionApi } = require('./lib/motionApi');
10
+ const { resolveCameras, buildWebhookUrl } = require('./lib/cameraRegistry');
11
+ const {
12
+ normalizeMode,
13
+ inferModeFromConfig,
14
+ buildModePatch,
15
+ MODE_LABELS,
16
+ MEDIA_SETTINGS,
17
+ } = require('./lib/modeProfiles');
18
+ const { createWebhookServer } = require('./lib/webhookServer');
19
+ const { createStreamManager } = require('./lib/streamManager');
20
+
21
+ class Motioneye extends utils.Adapter {
22
+ /**
23
+ * @param {Partial<utils.AdapterOptions>} [options] - Adapter options
24
+ */
25
+ constructor(options) {
26
+ super({
27
+ ...options,
28
+ name: 'motioneye',
29
+ });
30
+ this.on('ready', this.onReady.bind(this));
31
+ this.on('stateChange', this.onStateChange.bind(this));
32
+ this.on('unload', this.onUnload.bind(this));
33
+
34
+ this.motionEyeApi = undefined;
35
+ this.motionApi = undefined;
36
+ this.streamManager = undefined;
37
+ this.webhookServer = undefined;
38
+ this.pollInterval = undefined;
39
+ this.motionResetTimers = {};
40
+ /** @type {Map<string, import('./lib/cameraRegistry').ResolvedCamera>} */
41
+ this.camerasById = new Map();
42
+ /** @type {Map<string, import('./lib/cameraRegistry').ResolvedCamera>} */
43
+ this.camerasByChannel = new Map();
44
+ this.webhookHost = '';
45
+ this._unloading = false;
46
+ }
47
+
48
+ /**
49
+ * Is called when databases are connected and adapter received configuration.
50
+ */
51
+ async onReady() {
52
+ this.log.info('MotionEye adapter starting...');
53
+
54
+ await this.ensureAdapterRootMeta();
55
+
56
+ if (!this.config.motionHost) {
57
+ this.log.error('No MotionEye host configured. Please set the host in instance settings.');
58
+ return;
59
+ }
60
+
61
+ this.motionEyeApi = createMotionEyeApi({
62
+ host: this.config.motionHost,
63
+ motionEyePort: this.config.motionEyePort,
64
+ username: this.config.motionEyeUser,
65
+ password: this.config.motionEyePassword,
66
+ requestTimeoutMs: this.config.requestTimeoutMs,
67
+ });
68
+
69
+ this.motionApi = createMotionApi({
70
+ host: this.config.motionHost,
71
+ motionPort: this.config.motionPort,
72
+ requestTimeoutMs: this.config.requestTimeoutMs,
73
+ });
74
+
75
+ this.streamManager = createStreamManager({
76
+ motionHost: this.config.motionHost,
77
+ motionEyeApi: this.motionEyeApi,
78
+ useMotionEyeConfig: this.config.useMotionEyeConfig !== false,
79
+ disableStreamOnStart: this.config.disableStreamOnStart !== false,
80
+ streamAutoOffMs: Number(this.config.streamAutoOffMs) || 0,
81
+ streamStartDelayMs: Number(this.config.streamStartDelayMs) || 3000,
82
+ streamReadyTimeoutMs: Number(this.config.streamReadyTimeoutMs) || 45000,
83
+ streamRetryMs: Number(this.config.streamRetryMs) || 2000,
84
+ streamSiblingRelinkTimeoutMs: Number(this.config.streamSiblingRelinkTimeoutMs) || 60000,
85
+ getState: id => this.getStateAsync(id),
86
+ setState: (id, val, ack) => this.setStateAsync(id, val, ack),
87
+ log: (level, message) => this.log[level](message),
88
+ setTimeoutFn: (fn, ms) => this.setTimeout(fn, ms),
89
+ clearTimeoutFn: id => {
90
+ // @ts-expect-error adapter-core branded Timeout id from setTimeout
91
+ this.clearTimeout(id);
92
+ },
93
+ getCamerasByChannel: () => this.camerasByChannel,
94
+ isUnloading: () => this._unloading,
95
+ });
96
+
97
+ this.webhookHost = await this.resolveWebhookHost();
98
+ if (!this.webhookHost) {
99
+ this.log.warn(
100
+ 'webhookHost is not configured and could not be detected — set it in instance settings so MotionEye can reach webhooks',
101
+ );
102
+ }
103
+
104
+ await this.ensureInfoStates();
105
+ await this.syncCameraRegistry();
106
+
107
+ try {
108
+ await this.startWebhookServer();
109
+ } catch (error) {
110
+ this.log.error(`Webhook server failed to start: ${error.message}`);
111
+ }
112
+
113
+ await this.initializeCameras();
114
+
115
+ this.subscribeStates('*.mode');
116
+ this.subscribeStates('*.motion');
117
+ this.subscribeStates('*.snapshot');
118
+ this.subscribeStates('*.stream');
119
+ this.subscribeStates('*.streamPulse');
120
+
121
+ const pollSec = Math.max(30, Number(this.config.statusPollIntervalSec) || 300);
122
+ this.pollInterval = this.setInterval(() => {
123
+ this.pollMotionEye().catch(error => {
124
+ this.log.warn(`Status poll failed: ${error.message}`);
125
+ });
126
+ }, pollSec * 1000);
127
+
128
+ this.log.info('MotionEye adapter ready');
129
+ }
130
+
131
+ /**
132
+ * Ensure adapter root (e.g. motioneye) is typed as meta.
133
+ * instanceObjects handles motioneye.0; objects with _id "" fails on adapter update (Invalid ID).
134
+ */
135
+ async ensureAdapterRootMeta() {
136
+ const rootId = this.name;
137
+ const titleLang = this.ioPack?.common?.titleLang;
138
+ const name =
139
+ typeof titleLang === 'object' && titleLang !== null && !Array.isArray(titleLang)
140
+ ? (titleLang[this.language] ?? titleLang.en ?? rootId)
141
+ : typeof titleLang === 'string'
142
+ ? titleLang
143
+ : rootId;
144
+
145
+ const existing = await this.getForeignObjectAsync(rootId);
146
+ if (!existing) {
147
+ await this.setForeignObjectAsync(rootId, {
148
+ type: 'meta',
149
+ common: {
150
+ name,
151
+ type: 'meta.folder',
152
+ },
153
+ native: {},
154
+ });
155
+ } else if (existing.type !== 'meta') {
156
+ await this.extendForeignObjectAsync(rootId, {
157
+ type: 'meta',
158
+ common: {
159
+ name,
160
+ type: 'meta.folder',
161
+ },
162
+ });
163
+ }
164
+ }
165
+
166
+ /**
167
+ * @returns {Promise<string>}
168
+ */
169
+ async resolveWebhookHost() {
170
+ const configured = String(this.config.webhookHost || '').trim();
171
+ if (configured) {
172
+ return configured;
173
+ }
174
+
175
+ try {
176
+ const hosts = await this.getForeignObjectsAsync('system.host.');
177
+ if (!hosts) {
178
+ return '';
179
+ }
180
+
181
+ for (const obj of Object.values(hosts)) {
182
+ const native = obj && obj.native;
183
+ if (!native || typeof native !== 'object') {
184
+ continue;
185
+ }
186
+
187
+ for (const entry of Object.values(native)) {
188
+ if (!entry || typeof entry !== 'object' || !('address' in entry)) {
189
+ continue;
190
+ }
191
+ const address = String(entry.address);
192
+ if (address && !address.startsWith('127.') && address !== '::1') {
193
+ return address;
194
+ }
195
+ }
196
+ }
197
+ } catch (error) {
198
+ this.log.debug(`Could not read system.host for webhookHost: ${error.message}`);
199
+ }
200
+
201
+ return '';
202
+ }
203
+
204
+ async ensureInfoStates() {
205
+ await this.setObjectNotExistsAsync('info.connection', {
206
+ type: 'state',
207
+ common: {
208
+ name: 'MotionEye reachable',
209
+ type: 'boolean',
210
+ role: 'indicator.connected',
211
+ read: true,
212
+ write: false,
213
+ def: false,
214
+ },
215
+ native: {},
216
+ });
217
+ await this.setObjectNotExistsAsync('info.camerasOnline', {
218
+ type: 'state',
219
+ common: {
220
+ name: 'Cameras online',
221
+ type: 'number',
222
+ role: 'value',
223
+ read: true,
224
+ write: false,
225
+ def: 0,
226
+ },
227
+ native: {},
228
+ });
229
+ await this.setObjectNotExistsAsync('info.lastSync', {
230
+ type: 'state',
231
+ common: {
232
+ name: 'Last MotionEye sync',
233
+ type: 'string',
234
+ role: 'text',
235
+ read: true,
236
+ write: false,
237
+ def: '',
238
+ },
239
+ native: {},
240
+ });
241
+ }
242
+
243
+ syncCameraRegistry() {
244
+ this.camerasById.clear();
245
+ this.camerasByChannel.clear();
246
+
247
+ const cameras = resolveCameras(this.config.cameras, this.config.defaultMode || 'off');
248
+ for (const camera of cameras) {
249
+ if (!camera.enabled) {
250
+ continue;
251
+ }
252
+ this.camerasById.set(camera.id, camera);
253
+ this.camerasByChannel.set(camera.channel, camera);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * @param {import('./lib/cameraRegistry').ResolvedCamera} camera
259
+ * @returns {string}
260
+ */
261
+ getWebhookUrl(camera) {
262
+ if (!this.webhookHost) {
263
+ return '';
264
+ }
265
+ return buildWebhookUrl(this.namespace, this.webhookHost, this.config.webhookPort, camera.id);
266
+ }
267
+
268
+ /**
269
+ * @param {import('./lib/cameraRegistry').ResolvedCamera} camera
270
+ */
271
+ async ensureCameraObjects(camera) {
272
+ const channelId = camera.channel;
273
+
274
+ await this.setObjectNotExistsAsync(channelId, {
275
+ type: 'channel',
276
+ common: { name: camera.name },
277
+ native: {
278
+ id: camera.id,
279
+ motionEyeId: camera.motionEyeId,
280
+ },
281
+ });
282
+
283
+ const states = [
284
+ {
285
+ id: 'mode',
286
+ common: {
287
+ name: `${camera.name} mode`,
288
+ type: 'string',
289
+ role: 'level.mode',
290
+ read: true,
291
+ write: true,
292
+ def: camera.defaultMode,
293
+ states: {
294
+ off: 'Off',
295
+ still: 'Still',
296
+ sharp: 'Sharp',
297
+ },
298
+ },
299
+ },
300
+ {
301
+ id: 'motion',
302
+ common: {
303
+ name: `${camera.name} motion`,
304
+ type: 'boolean',
305
+ role: 'sensor.motion',
306
+ read: true,
307
+ write: true,
308
+ def: false,
309
+ },
310
+ },
311
+ {
312
+ id: 'status',
313
+ common: {
314
+ name: `${camera.name} status`,
315
+ type: 'string',
316
+ role: 'text',
317
+ read: true,
318
+ write: false,
319
+ def: '',
320
+ },
321
+ },
322
+ {
323
+ id: 'lastAction',
324
+ common: {
325
+ name: `${camera.name} last action`,
326
+ type: 'string',
327
+ role: 'text',
328
+ read: true,
329
+ write: false,
330
+ def: '',
331
+ },
332
+ },
333
+ {
334
+ id: 'snapshot',
335
+ common: {
336
+ name: `${camera.name} snapshot trigger`,
337
+ type: 'boolean',
338
+ role: 'button',
339
+ read: true,
340
+ write: true,
341
+ def: false,
342
+ },
343
+ },
344
+ {
345
+ id: 'stream',
346
+ common: {
347
+ name: `${camera.name} video stream`,
348
+ type: 'boolean',
349
+ role: 'switch',
350
+ read: true,
351
+ write: true,
352
+ def: false,
353
+ },
354
+ },
355
+ {
356
+ id: 'streamPulse',
357
+ common: {
358
+ name: `${camera.name} stream pulse`,
359
+ type: 'boolean',
360
+ role: 'button',
361
+ read: true,
362
+ write: true,
363
+ def: false,
364
+ },
365
+ },
366
+ {
367
+ id: 'streamUrl',
368
+ common: {
369
+ name: `${camera.name} stream HTML (inventwo)`,
370
+ type: 'string',
371
+ role: 'text',
372
+ read: true,
373
+ write: false,
374
+ def: '',
375
+ },
376
+ },
377
+ {
378
+ id: 'webhookUrl',
379
+ common: {
380
+ name: `${camera.name} webhook URL`,
381
+ type: 'string',
382
+ role: 'url',
383
+ read: true,
384
+ write: false,
385
+ def: '',
386
+ },
387
+ },
388
+ {
389
+ id: 'motionEyeId',
390
+ common: {
391
+ name: `${camera.name} MotionEye ID`,
392
+ type: 'number',
393
+ role: 'value',
394
+ read: true,
395
+ write: false,
396
+ def: camera.motionEyeId,
397
+ },
398
+ },
399
+ {
400
+ id: 'motionEyeName',
401
+ common: {
402
+ name: `${camera.name} MotionEye name`,
403
+ type: 'string',
404
+ role: 'text',
405
+ read: true,
406
+ write: false,
407
+ def: '',
408
+ },
409
+ },
410
+ ];
411
+
412
+ for (const state of states) {
413
+ await this.setObjectNotExistsAsync(`${channelId}.${state.id}`, {
414
+ type: 'state',
415
+ common: /** @type {ioBroker.StateCommon} */ (state.common),
416
+ native: {},
417
+ });
418
+ }
419
+
420
+ const webhookUrl = this.getWebhookUrl(camera);
421
+ await this.setStateAsync(`${channelId}.webhookUrl`, webhookUrl, true);
422
+ await this.setStateAsync(`${channelId}.motionEyeId`, camera.motionEyeId, true);
423
+ await this.setStateAsync(`${channelId}.streamUrl`, '', true);
424
+ }
425
+
426
+ async initializeCameras() {
427
+ this.syncCameraRegistry();
428
+
429
+ if (!this.camerasById.size) {
430
+ this.log.warn('No enabled cameras configured — add cameras on the Cameras tab');
431
+ }
432
+
433
+ for (const camera of this.camerasById.values()) {
434
+ await this.ensureCameraObjects(camera);
435
+ }
436
+
437
+ let connected = false;
438
+ try {
439
+ await this.motionEyeApi.getCameraList();
440
+ connected = true;
441
+ } catch (error) {
442
+ this.log.warn(`MotionEye not reachable at startup: ${error.message}`);
443
+ }
444
+
445
+ await this.setStateAsync('info.connection', connected, true);
446
+
447
+ for (const camera of this.camerasById.values()) {
448
+ try {
449
+ await this.applyInitialCameraConfig(camera);
450
+ await this.streamManager.applyStreamOnStart(camera);
451
+ } catch (error) {
452
+ this.log.warn(`Initial setup failed for ${camera.name}: ${error.message}`);
453
+ await this.setStateAsync(`${camera.channel}.status`, `error: ${error.message}`, true);
454
+ }
455
+ }
456
+
457
+ if (connected) {
458
+ await this.pollMotionEye();
459
+ }
460
+ }
461
+
462
+ /**
463
+ * @param {import('./lib/cameraRegistry').ResolvedCamera} camera
464
+ */
465
+ async applyInitialCameraConfig(camera) {
466
+ const channelId = camera.channel;
467
+ const currentMode = await this.getStateAsync(`${channelId}.mode`);
468
+ const mode = /** @type {'off'|'still'|'sharp'} */ (
469
+ normalizeMode(currentMode && currentMode.val) || camera.defaultMode || 'off'
470
+ );
471
+
472
+ if (!this.config.useMotionEyeConfig) {
473
+ await this.setStateAsync(`${channelId}.mode`, mode, true);
474
+ await this.setStateAsync(`${channelId}.status`, 'MotionEye config sync disabled', true);
475
+ return;
476
+ }
477
+
478
+ /** @type {Record<string, unknown>} */
479
+ const patch = {};
480
+
481
+ if (this.config.applyMediaSettingsOnStart) {
482
+ Object.assign(patch, MEDIA_SETTINGS);
483
+ }
484
+
485
+ Object.assign(patch, buildModePatch(mode, this.getWebhookUrl(camera)));
486
+
487
+ if (this.config.disableStreamOnStart) {
488
+ patch.video_streaming = false;
489
+ }
490
+
491
+ const result = await this.motionEyeApi.saveCameraConfig(camera.motionEyeId, patch);
492
+ await this.setStateAsync(`${channelId}.mode`, mode, true);
493
+ await this.setStateAsync(`${channelId}.status`, `Mode=${MODE_LABELS[mode]}`, true);
494
+
495
+ if (result.changed) {
496
+ await this.setStateAsync(`${channelId}.lastAction`, 'config/set initial', true);
497
+ this.log.info(`Initial configuration applied for ${camera.name} (mode ${mode})`);
498
+ }
499
+ }
500
+
501
+ /**
502
+ * @param {import('./lib/cameraRegistry').ResolvedCamera} camera
503
+ * @param {'off'|'still'|'sharp'} mode
504
+ * @param {boolean} [fromPoll]
505
+ */
506
+ async setMode(camera, mode, fromPoll = false) {
507
+ const channelId = camera.channel;
508
+
509
+ if (!this.config.useMotionEyeConfig) {
510
+ if (!fromPoll) {
511
+ await this.setStateAsync(`${channelId}.mode`, mode, true);
512
+ }
513
+ await this.setStateAsync(`${channelId}.status`, 'useMotionEyeConfig is disabled', true);
514
+ return;
515
+ }
516
+
517
+ const patch = buildModePatch(mode, this.getWebhookUrl(camera));
518
+ const result = await this.motionEyeApi.saveCameraConfig(camera.motionEyeId, patch);
519
+
520
+ await this.setStateAsync(`${channelId}.status`, `Mode=${MODE_LABELS[mode]}`, true);
521
+
522
+ if (result.changed) {
523
+ await this.setStateAsync(`${channelId}.lastAction`, `config/set mode=${mode}`, true);
524
+ this.log.info(`Mode for ${camera.name}: ${MODE_LABELS[mode]}`);
525
+ }
526
+
527
+ if (!fromPoll) {
528
+ await this.setStateAsync(`${channelId}.mode`, mode, true);
529
+ }
530
+ }
531
+
532
+ async pollMotionEye() {
533
+ if (!this.motionEyeApi) {
534
+ return;
535
+ }
536
+
537
+ let cameras;
538
+ try {
539
+ cameras = await this.motionEyeApi.getCameraList();
540
+ await this.setStateAsync('info.connection', true, true);
541
+ } catch (error) {
542
+ await this.setStateAsync('info.connection', false, true);
543
+ throw error;
544
+ }
545
+
546
+ const byId = new Map(cameras.map(entry => [Number(entry.id), entry]));
547
+ let online = 0;
548
+
549
+ for (const camera of this.camerasById.values()) {
550
+ const uiConfig = byId.get(camera.motionEyeId);
551
+ if (!uiConfig) {
552
+ await this.setStateAsync(`${camera.channel}.status`, 'not found in MotionEye', true);
553
+ continue;
554
+ }
555
+
556
+ online += 1;
557
+ const mode = inferModeFromConfig(uiConfig);
558
+ const motionEyeName = String(uiConfig.name || uiConfig.id || camera.motionEyeId);
559
+
560
+ await this.setStateAsync(`${camera.channel}.motionEyeName`, motionEyeName, true);
561
+
562
+ const currentMode = await this.getStateAsync(`${camera.channel}.mode`);
563
+ const localMode = normalizeMode(currentMode && currentMode.val);
564
+
565
+ if (localMode !== mode) {
566
+ await this.setMode(camera, mode, true);
567
+ } else {
568
+ await this.setStateAsync(`${camera.channel}.status`, `Mode=${MODE_LABELS[mode]}`, true);
569
+ }
570
+
571
+ const streaming = !!uiConfig.video_streaming;
572
+ const streamState = await this.getStateAsync(`${camera.channel}.stream`);
573
+ const localStream = !!(streamState && streamState.val);
574
+ if (localStream !== streaming) {
575
+ await this.streamManager.setStream(camera, streaming, true);
576
+ }
577
+ }
578
+
579
+ await this.setStateAsync('info.camerasOnline', online, true);
580
+ await this.setStateAsync('info.lastSync', new Date().toISOString(), true);
581
+ }
582
+
583
+ async startWebhookServer() {
584
+ if (this.webhookServer) {
585
+ return;
586
+ }
587
+
588
+ this.webhookServer = createWebhookServer({
589
+ port: this.config.webhookPort,
590
+ bind: this.config.webhookBind || '0.0.0.0',
591
+ namespace: this.namespace,
592
+ onMotion: (cameraId, value) => this.handleWebhookMotion(cameraId, value),
593
+ log: (level, message) => this.log[level](message),
594
+ });
595
+
596
+ await this.webhookServer.start();
597
+ }
598
+
599
+ /**
600
+ * @param {string} cameraId
601
+ * @param {boolean} value
602
+ */
603
+ async handleWebhookMotion(cameraId, value) {
604
+ const camera = this.camerasById.get(cameraId);
605
+ if (!camera) {
606
+ this.log.warn(`Webhook for unknown camera id "${cameraId}"`);
607
+ return;
608
+ }
609
+
610
+ const stateId = `${this.namespace}.${camera.channel}.motion`;
611
+ await this.setStateAsync(stateId, value, true);
612
+
613
+ if (value) {
614
+ this.scheduleMotionReset(camera);
615
+ await this.setStateAsync(`${camera.channel}.lastAction`, 'motion webhook', true);
616
+ this.log.debug(`Motion webhook for ${camera.name}`);
617
+ }
618
+ }
619
+
620
+ /**
621
+ * @param {import('./lib/cameraRegistry').ResolvedCamera} camera
622
+ */
623
+ scheduleMotionReset(camera) {
624
+ const stateId = `${this.namespace}.${camera.channel}.motion`;
625
+ if (this.motionResetTimers[stateId]) {
626
+ this.clearTimeout(this.motionResetTimers[stateId]);
627
+ }
628
+
629
+ const resetMs = Math.max(1000, Number(this.config.motionResetMs) || 15000);
630
+ this.motionResetTimers[stateId] = this.setTimeout(async () => {
631
+ delete this.motionResetTimers[stateId];
632
+ await this.setStateAsync(stateId, false, true);
633
+ }, resetMs);
634
+ }
635
+
636
+ /**
637
+ * @param {string} id
638
+ * @param {ioBroker.State | null | undefined} state
639
+ */
640
+ async onStateChange(id, state) {
641
+ if (!state || state.ack || this._unloading) {
642
+ return;
643
+ }
644
+
645
+ const relativeId = id.startsWith(`${this.namespace}.`) ? id.slice(this.namespace.length + 1) : id;
646
+ const dot = relativeId.indexOf('.');
647
+ if (dot < 0) {
648
+ return;
649
+ }
650
+
651
+ const channel = relativeId.slice(0, dot);
652
+ const stateName = relativeId.slice(dot + 1);
653
+ const camera = this.camerasByChannel.get(channel);
654
+ if (!camera) {
655
+ return;
656
+ }
657
+
658
+ if (stateName === 'mode') {
659
+ const mode = normalizeMode(state.val);
660
+ if (!mode) {
661
+ this.log.warn(`Invalid mode "${state.val}" for ${camera.name}`);
662
+ return;
663
+ }
664
+
665
+ try {
666
+ await this.setMode(camera, mode);
667
+ } catch (error) {
668
+ this.log.error(`setMode failed for ${camera.name}: ${error.message}`);
669
+ await this.setStateAsync(`${camera.channel}.status`, `error: ${error.message}`, true);
670
+ }
671
+ return;
672
+ }
673
+
674
+ if (stateName === 'motion' && state.val === true) {
675
+ this.scheduleMotionReset(camera);
676
+ return;
677
+ }
678
+
679
+ if (stateName === 'snapshot' && state.val === true) {
680
+ try {
681
+ const result = await this.motionApi.takeSnapshot(camera.motionEyeId);
682
+ await this.setStateAsync(`${camera.channel}.lastAction`, `action/snapshot: ${result.body}`, true);
683
+ } catch (error) {
684
+ this.log.error(`Snapshot failed for ${camera.name}: ${error.message}`);
685
+ }
686
+ await this.setStateAsync(`${camera.channel}.snapshot`, false, true);
687
+ return;
688
+ }
689
+
690
+ if (stateName === 'stream') {
691
+ try {
692
+ await this.streamManager.setStream(camera, !!state.val);
693
+ } catch (error) {
694
+ this.log.error(`setStream failed for ${camera.name}: ${error.message}`);
695
+ await this.setStateAsync(`${camera.channel}.status`, `error: ${error.message}`, true);
696
+ }
697
+ return;
698
+ }
699
+
700
+ if (stateName === 'streamPulse' && state.val === true) {
701
+ try {
702
+ await this.streamManager.pulseStream(camera);
703
+ } catch (error) {
704
+ this.log.error(`streamPulse failed for ${camera.name}: ${error.message}`);
705
+ }
706
+ await this.setStateAsync(`${camera.channel}.streamPulse`, false, true);
707
+ }
708
+ }
709
+
710
+ /**
711
+ * @param {() => void} callback
712
+ */
713
+ onUnload(callback) {
714
+ this._unloading = true;
715
+
716
+ try {
717
+ if (this.pollInterval) {
718
+ this.clearInterval(this.pollInterval);
719
+ this.pollInterval = undefined;
720
+ }
721
+
722
+ for (const timerId of Object.keys(this.motionResetTimers)) {
723
+ this.clearTimeout(this.motionResetTimers[timerId]);
724
+ }
725
+ this.motionResetTimers = {};
726
+
727
+ if (this.streamManager) {
728
+ this.streamManager.destroy();
729
+ this.streamManager = undefined;
730
+ }
731
+
732
+ const stopWebhook = this.webhookServer ? this.webhookServer.stop() : Promise.resolve();
733
+ stopWebhook
734
+ .catch(error => {
735
+ this.log.warn(`Webhook server stop error: ${error.message}`);
736
+ })
737
+ .finally(() => {
738
+ this.webhookServer = undefined;
739
+ this.motionEyeApi = undefined;
740
+ this.motionApi = undefined;
741
+ callback();
742
+ });
743
+ } catch (error) {
744
+ this.log.error(`Error during unloading: ${error.message}`);
745
+ callback();
746
+ }
747
+ }
748
+ }
749
+
750
+ if (require.main !== module) {
751
+ /**
752
+ * @param {Partial<utils.AdapterOptions>} [options] - Adapter options
753
+ */
754
+ module.exports = options => new Motioneye(options);
755
+ module.exports.Motioneye = Motioneye;
756
+ } else {
757
+ new Motioneye();
758
+ }