iobroker.motioneye 0.1.2 → 0.2.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,76 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const {
5
+ extractMediaFolderFromMotionEyeConfig,
6
+ mapMotionEyeCamera,
7
+ mergeMotionEyeCameras,
8
+ parseLoadCamerasMessage,
9
+ } = require('./cameraDiscovery');
10
+
11
+ describe('cameraDiscovery', () => {
12
+ it('extractMediaFolderFromMotionEyeConfig should read custom folder names', () => {
13
+ expect(
14
+ extractMediaFolderFromMotionEyeConfig({
15
+ root_directory: '/var/lib/motioneye/Bambu',
16
+ }),
17
+ ).to.equal('Bambu');
18
+ expect(
19
+ extractMediaFolderFromMotionEyeConfig({
20
+ root_directory: '/var/lib/motioneye/Camera8',
21
+ }),
22
+ ).to.equal('');
23
+ });
24
+
25
+ it('mapMotionEyeCamera should build admin row defaults', () => {
26
+ expect(
27
+ mapMotionEyeCamera(
28
+ {
29
+ id: 3,
30
+ name: 'Auffahrt',
31
+ root_directory: '/var/lib/motioneye/Auffahrt',
32
+ },
33
+ 'still',
34
+ ),
35
+ ).to.deep.equal({
36
+ name: 'Auffahrt',
37
+ motionEyeId: 3,
38
+ id: '',
39
+ mediaFolder: 'Auffahrt',
40
+ defaultMode: 'still',
41
+ enabled: true,
42
+ });
43
+ });
44
+
45
+ it('parseLoadCamerasMessage should decode camerasPayload from admin jsonData', () => {
46
+ const cameras = [{ name: 'A', motionEyeId: 1 }];
47
+ const payload = parseLoadCamerasMessage({
48
+ motionHost: '192.168.1.1',
49
+ camerasPayload: encodeURIComponent(JSON.stringify(cameras)),
50
+ });
51
+
52
+ expect(payload.motionHost).to.equal('192.168.1.1');
53
+ expect(payload.cameras).to.deep.equal(cameras);
54
+ });
55
+
56
+ it('mergeMotionEyeCameras should keep existing rows and append missing ids', () => {
57
+ const result = mergeMotionEyeCameras(
58
+ [{ name: 'Carport', motionEyeId: 1, enabled: true, defaultMode: 'off' }],
59
+ [
60
+ { id: 1, name: 'Carport ME' },
61
+ { id: 2, name: 'Garten' },
62
+ ],
63
+ 'sharp',
64
+ );
65
+
66
+ expect(result.added).to.equal(1);
67
+ expect(result.cameras).to.have.length(2);
68
+ expect(result.cameras[0].name).to.equal('Carport');
69
+ expect(result.cameras[1]).to.deep.include({
70
+ name: 'Garten',
71
+ motionEyeId: 2,
72
+ defaultMode: 'sharp',
73
+ enabled: true,
74
+ });
75
+ });
76
+ });
@@ -31,6 +31,7 @@ function slugifyId(value) {
31
31
  * @property {number} motionEyeId
32
32
  * @property {boolean} [enabled]
33
33
  * @property {string} [defaultMode]
34
+ * @property {string} [mediaFolder] Folder name under /var/lib/motioneye
34
35
  */
35
36
 
36
37
  /**
@@ -41,6 +42,7 @@ function slugifyId(value) {
41
42
  * @property {number} motionEyeId
42
43
  * @property {boolean} enabled
43
44
  * @property {string} defaultMode
45
+ * @property {string} mediaFolder Sanitized folder name or empty
44
46
  */
45
47
 
46
48
  /**
@@ -98,6 +100,7 @@ function resolveCameras(cameras, fallbackDefaultMode = 'off') {
98
100
  motionEyeId: Number(entry.motionEyeId),
99
101
  enabled: entry.enabled !== false,
100
102
  defaultMode: entry.defaultMode || fallbackDefaultMode,
103
+ mediaFolder: String(entry.mediaFolder || '').trim(),
101
104
  });
102
105
  }
103
106
 
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ /** Default MotionEye media base path (see motioneye.conf media_path). */
4
+ const MOTIONEYE_MEDIA_BASE = '/var/lib/motioneye';
5
+
6
+ /**
7
+ * Sanitize a single folder name segment (no path separators).
8
+ * @param {unknown} value
9
+ * @returns {string}
10
+ */
11
+ function sanitizeMediaFolderName(value) {
12
+ const trimmed = String(value == null ? '' : value).trim();
13
+ if (!trimmed) {
14
+ return '';
15
+ }
16
+
17
+ if (/[/\\]/.test(trimmed) || trimmed.includes('..')) {
18
+ return '';
19
+ }
20
+
21
+ return trimmed
22
+ .replace(/[^\wäöüÄÖÜß .-]/g, '_')
23
+ .replace(/\s+/g, ' ')
24
+ .trim();
25
+ }
26
+
27
+ /**
28
+ * @param {unknown} folderName
29
+ * @returns {string} Full root_directory path or empty when invalid/unset
30
+ */
31
+ function buildMediaRootDirectory(folderName) {
32
+ const folder = sanitizeMediaFolderName(folderName);
33
+ if (!folder) {
34
+ return '';
35
+ }
36
+
37
+ return `${MOTIONEYE_MEDIA_BASE}/${folder}`;
38
+ }
39
+
40
+ /**
41
+ * MotionEye config patch for custom file storage path.
42
+ * @param {unknown} folderName
43
+ * @returns {Record<string, unknown>}
44
+ */
45
+ function buildStoragePatch(folderName) {
46
+ const rootDirectory = buildMediaRootDirectory(folderName);
47
+ if (!rootDirectory) {
48
+ return {};
49
+ }
50
+
51
+ return {
52
+ storage_device: 'custom-path',
53
+ root_directory: rootDirectory,
54
+ };
55
+ }
56
+
57
+ module.exports = {
58
+ MOTIONEYE_MEDIA_BASE,
59
+ sanitizeMediaFolderName,
60
+ buildMediaRootDirectory,
61
+ buildStoragePatch,
62
+ };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const {
5
+ MOTIONEYE_MEDIA_BASE,
6
+ sanitizeMediaFolderName,
7
+ buildMediaRootDirectory,
8
+ buildStoragePatch,
9
+ } = require('./mediaStorage');
10
+
11
+ describe('mediaStorage', () => {
12
+ it('sanitizeMediaFolderName should reject path separators', () => {
13
+ expect(sanitizeMediaFolderName('Bambu')).to.equal('Bambu');
14
+ expect(sanitizeMediaFolderName('Innenhof I')).to.equal('Innenhof I');
15
+ expect(sanitizeMediaFolderName('/var/lib/foo')).to.equal('');
16
+ expect(sanitizeMediaFolderName('..')).to.equal('');
17
+ });
18
+
19
+ it('buildMediaRootDirectory should prepend MotionEye base path', () => {
20
+ expect(buildMediaRootDirectory('Bambu')).to.equal(`${MOTIONEYE_MEDIA_BASE}/Bambu`);
21
+ expect(buildMediaRootDirectory('')).to.equal('');
22
+ });
23
+
24
+ it('buildStoragePatch should return custom-path config', () => {
25
+ expect(buildStoragePatch('Carport')).to.deep.equal({
26
+ storage_device: 'custom-path',
27
+ root_directory: `${MOTIONEYE_MEDIA_BASE}/Carport`,
28
+ });
29
+ expect(buildStoragePatch('')).to.deep.equal({});
30
+ });
31
+ });
@@ -9,7 +9,7 @@ const SIGNATURE_REGEX = new RegExp('[^a-zA-Z0-9/?_.=&{}\\[\\]":, -]', 'g');
9
9
  const LIST_CACHE_MS = 15000;
10
10
 
11
11
  /**
12
- * @param {string|undefined} serverHeader
12
+ * @param {string | string[] | undefined} serverHeader
13
13
  * @returns {string}
14
14
  */
15
15
  function parseMotionEyeServerHeader(serverHeader) {
@@ -17,7 +17,8 @@ function parseMotionEyeServerHeader(serverHeader) {
17
17
  return '';
18
18
  }
19
19
 
20
- const match = String(serverHeader).match(/^motionEye\/(.+)$/i);
20
+ const header = Array.isArray(serverHeader) ? serverHeader[0] : serverHeader;
21
+ const match = String(header).match(/^motionEye\/(.+)$/i);
21
22
  return match ? match[1].trim() : '';
22
23
  }
23
24
 
@@ -152,6 +153,7 @@ function buildAuthPath(path, method, body, username, signKey) {
152
153
  * @property {number} status
153
154
  * @property {unknown} data
154
155
  * @property {string} body
156
+ * @property {import('node:http').IncomingHttpHeaders} [headers]
155
157
  */
156
158
 
157
159
  /**
@@ -174,7 +176,7 @@ function createMotionEyeApi(options) {
174
176
  * @param {string} path
175
177
  * @param {string} [method]
176
178
  * @param {string|null} [body]
177
- * @returns {Promise<{ status: number, body: string }>}
179
+ * @returns {Promise<{ status: number, body: string, headers: import('node:http').IncomingHttpHeaders }>}
178
180
  */
179
181
  function httpRequest(path, method = 'GET', body = null) {
180
182
  return new Promise((resolve, reject) => {
@@ -84,10 +84,6 @@ function checkStreamPort(motionHost, port) {
84
84
  });
85
85
  }
86
86
 
87
- function sleep(ms) {
88
- return new Promise(resolve => setTimeout(resolve, ms));
89
- }
90
-
91
87
  /**
92
88
  * @typedef {object} StreamManagerDeps
93
89
  * @property {string} motionHost
@@ -104,6 +100,7 @@ function sleep(ms) {
104
100
  * @property {(level: string, message: string) => void} log
105
101
  * @property {(fn: () => void, ms: number) => unknown} setTimeoutFn
106
102
  * @property {(id: unknown) => void} clearTimeoutFn
103
+ * @property {(ms: number) => Promise<void>} delayFn
107
104
  * @property {() => Map<string, import('./cameraRegistry').ResolvedCamera>} getCamerasByChannel
108
105
  * @property {() => boolean} isUnloading
109
106
  */
@@ -209,7 +206,7 @@ function createStreamManager(deps) {
209
206
  */
210
207
  async function publishStreamHtmlWhenReady(camera) {
211
208
  const started = Date.now();
212
- await sleep(deps.streamStartDelayMs);
209
+ await deps.delayFn(deps.streamStartDelayMs);
213
210
 
214
211
  while (Date.now() - started < deps.streamReadyTimeoutMs) {
215
212
  if (deps.isUnloading() || !(await isStreamEnabled(camera))) {
@@ -221,7 +218,7 @@ function createStreamManager(deps) {
221
218
  uiConfig = await getStreamUiConfig(camera);
222
219
  } catch (error) {
223
220
  deps.log('debug', `Stream port query ${camera.name}: ${error.message}`);
224
- await sleep(deps.streamRetryMs);
221
+ await deps.delayFn(deps.streamRetryMs);
225
222
  continue;
226
223
  }
227
224
 
@@ -234,7 +231,7 @@ function createStreamManager(deps) {
234
231
  }
235
232
 
236
233
  await setStreamLoadingHtml(camera);
237
- await sleep(deps.streamRetryMs);
234
+ await deps.delayFn(deps.streamRetryMs);
238
235
  }
239
236
 
240
237
  if (await isStreamEnabled(camera)) {
@@ -289,7 +286,7 @@ function createStreamManager(deps) {
289
286
  /** @type {Record<string, boolean>} */
290
287
  const relinked = {};
291
288
 
292
- await sleep(deps.streamStartDelayMs);
289
+ await deps.delayFn(deps.streamStartDelayMs);
293
290
 
294
291
  while (Date.now() - started < deps.streamSiblingRelinkTimeoutMs) {
295
292
  if (deps.isUnloading() || relinkRunIds[runKey] !== runId) {
@@ -328,7 +325,7 @@ function createStreamManager(deps) {
328
325
  return;
329
326
  }
330
327
 
331
- await sleep(deps.streamRetryMs);
328
+ await deps.delayFn(deps.streamRetryMs);
332
329
  }
333
330
  }
334
331
 
package/main.js CHANGED
@@ -7,7 +7,9 @@
7
7
  const utils = require('@iobroker/adapter-core');
8
8
  const { createMotionEyeApi } = require('./lib/motionEyeApi');
9
9
  const { createMotionApi } = require('./lib/motionApi');
10
+ const { buildStoragePatch } = require('./lib/mediaStorage');
10
11
  const { INFO_STATE_LABELS } = require('./lib/infoLabels');
12
+ const { mergeMotionEyeCameras, parseLoadCamerasMessage } = require('./lib/cameraDiscovery');
11
13
  const { resolveCameras, buildWebhookUrl } = require('./lib/cameraRegistry');
12
14
  const {
13
15
  normalizeMode,
@@ -35,6 +37,12 @@ class Motioneye extends utils.Adapter {
35
37
  });
36
38
  this.on('ready', this.onReady.bind(this));
37
39
  this.on('stateChange', this.onStateChange.bind(this));
40
+ this.on('message', obj => {
41
+ this.onMessage(obj).catch(error => {
42
+ this.log.error(`onMessage failed: ${error.stack || error.message || error}`);
43
+ this.replyToMessage(obj, { error: String(error.message || error) });
44
+ });
45
+ });
38
46
  this.on('unload', this.onUnload.bind(this));
39
47
 
40
48
  this.motionEyeApi = undefined;
@@ -93,6 +101,7 @@ class Motioneye extends utils.Adapter {
93
101
  setState: (id, val, ack) => this.setStateAsync(id, val, ack),
94
102
  log: (level, message) => this.log[level](message),
95
103
  setTimeoutFn: (fn, ms) => this.setTimeout(fn, ms),
104
+ delayFn: ms => this.delay(ms),
96
105
  clearTimeoutFn: id => {
97
106
  // @ts-expect-error adapter-core branded Timeout id from setTimeout
98
107
  this.clearTimeout(id);
@@ -209,7 +218,7 @@ class Motioneye extends utils.Adapter {
209
218
  }
210
219
 
211
220
  async ensureInfoStates() {
212
- for (const [stateId, name] of Object.entries(INFO_STATE_LABELS)) {
221
+ for (const [stateId, labels] of Object.entries(INFO_STATE_LABELS)) {
213
222
  const type = stateId === 'camerasOnline' ? 'number' : 'string';
214
223
  const role =
215
224
  stateId === 'connection' ? 'indicator.connected' : stateId === 'camerasOnline' ? 'value' : 'text';
@@ -217,7 +226,7 @@ class Motioneye extends utils.Adapter {
217
226
  await this.setObjectNotExistsAsync(`${INFO_PREFIX}.${stateId}`, {
218
227
  type: 'state',
219
228
  common: {
220
- name,
229
+ name: /** @type {ioBroker.StringOrTranslated} */ (labels),
221
230
  type: stateId === 'connection' ? 'boolean' : type,
222
231
  role,
223
232
  read: true,
@@ -411,7 +420,7 @@ class Motioneye extends utils.Adapter {
411
420
  {
412
421
  id: 'streamUrl',
413
422
  common: {
414
- name: `${camera.name} stream HTML (inventwo)`,
423
+ name: `${camera.name} stream HTML`,
415
424
  type: 'string',
416
425
  role: 'text',
417
426
  read: true,
@@ -462,6 +471,16 @@ class Motioneye extends utils.Adapter {
462
471
  });
463
472
  }
464
473
 
474
+ const streamUrlId = `${channelId}.streamUrl`;
475
+ const streamUrlName = `${camera.name} stream HTML`;
476
+ const streamUrlObject = await this.getObjectAsync(streamUrlId);
477
+ const currentStreamUrlName = streamUrlObject?.common?.name ? String(streamUrlObject.common.name) : '';
478
+ if (currentStreamUrlName && /\(inventwo\)|inventwo HTML/i.test(currentStreamUrlName)) {
479
+ await this.extendObjectAsync(streamUrlId, {
480
+ common: { name: streamUrlName },
481
+ });
482
+ }
483
+
465
484
  const webhookUrl = this.getWebhookUrl(camera);
466
485
  await this.setStateAsync(`${channelId}.webhookUrl`, webhookUrl, true);
467
486
  await this.setStateAsync(`${channelId}.motionEyeId`, camera.motionEyeId, true);
@@ -534,6 +553,15 @@ class Motioneye extends utils.Adapter {
534
553
  patch.video_streaming = false;
535
554
  }
536
555
 
556
+ const storagePatch = buildStoragePatch(camera.mediaFolder);
557
+ if (camera.mediaFolder && !storagePatch.root_directory) {
558
+ this.log.warn(
559
+ `Invalid media folder for ${camera.name}: "${camera.mediaFolder}" — skipped (use a single folder name without slashes)`,
560
+ );
561
+ } else {
562
+ Object.assign(patch, storagePatch);
563
+ }
564
+
537
565
  const result = await this.motionEyeApi.saveCameraConfig(camera.motionEyeId, patch);
538
566
  await this.setStateAsync(`${channelId}.mode`, mode, true);
539
567
  await this.setStateAsync(`${channelId}.status`, `Mode=${MODE_LABELS[mode]}`, true);
@@ -754,6 +782,74 @@ class Motioneye extends utils.Adapter {
754
782
  }
755
783
  }
756
784
 
785
+ /**
786
+ * @param {ioBroker.Message} obj
787
+ * @param {Record<string, unknown>} response
788
+ */
789
+ replyToMessage(obj, response) {
790
+ if (!obj?.callback || !obj.from || !obj.command) {
791
+ return;
792
+ }
793
+
794
+ this.sendTo(obj.from, obj.command, response, obj.callback);
795
+ }
796
+
797
+ /**
798
+ * @param {ioBroker.Message} obj
799
+ */
800
+ async onMessage(obj) {
801
+ if (!obj || typeof obj.command !== 'string') {
802
+ return;
803
+ }
804
+
805
+ if (obj.command === 'loadCameras') {
806
+ await this.handleLoadCameras(obj);
807
+ }
808
+ }
809
+
810
+ /**
811
+ * @param {ioBroker.Message} obj
812
+ */
813
+ async handleLoadCameras(obj) {
814
+ const payload = parseLoadCamerasMessage(obj.message);
815
+
816
+ const motionHost = String(payload.motionHost || this.config.motionHost || '').trim();
817
+ const motionEyePort = Number(payload.motionEyePort ?? this.config.motionEyePort) || 8765;
818
+ const motionEyeUser = String(payload.motionEyeUser ?? this.config.motionEyeUser ?? 'admin');
819
+ const motionEyePassword = String(this.config.motionEyePassword ?? '');
820
+ const requestTimeoutMs = Number(payload.requestTimeoutMs ?? this.config.requestTimeoutMs) || 45000;
821
+ const defaultMode = String(payload.defaultMode || this.config.defaultMode || 'off');
822
+ const existingCameras = Array.isArray(payload.cameras) ? payload.cameras : this.config.cameras || [];
823
+
824
+ try {
825
+ if (!motionHost) {
826
+ throw new Error('MotionEye host is required');
827
+ }
828
+
829
+ const api = createMotionEyeApi({
830
+ host: motionHost,
831
+ motionEyePort,
832
+ username: motionEyeUser,
833
+ password: motionEyePassword,
834
+ requestTimeoutMs,
835
+ listCacheMs: 0,
836
+ });
837
+
838
+ const motionEyeList = await api.getCameraList();
839
+ const { cameras, added } = mergeMotionEyeCameras(existingCameras, motionEyeList, defaultMode);
840
+
841
+ this.log.info(`Loaded ${motionEyeList.length} camera(s) from MotionEye, added ${added} new row(s)`);
842
+
843
+ this.replyToMessage(obj, {
844
+ native: { cameras },
845
+ result: added > 0 ? 'added' : 'none',
846
+ });
847
+ } catch (error) {
848
+ this.log.error(`loadCameras failed: ${error.message}`);
849
+ this.replyToMessage(obj, { error: error.message });
850
+ }
851
+ }
852
+
757
853
  /**
758
854
  * @param {() => void} callback
759
855
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.motioneye",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Connect MotionEye cameras to ioBroker for motion detection, snapshots, and live streams",
5
5
  "author": {
6
6
  "name": "skvarel",
@@ -29,7 +29,7 @@
29
29
  "node": ">= 22"
30
30
  },
31
31
  "dependencies": {
32
- "@iobroker/adapter-core": "^3.3.2"
32
+ "@iobroker/adapter-core": "^3.4.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@alcalzone/release-script": "^5.2.1",
@@ -74,6 +74,6 @@
74
74
  },
75
75
  "readmeFilename": "README.md",
76
76
  "overrides": {
77
- "@iobroker/adapter-core": "^3.3.2"
77
+ "@iobroker/adapter-core": "^3.4.1"
78
78
  }
79
79
  }