iobroker.motioneye 0.1.2 → 0.2.0

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) => {
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;
@@ -209,7 +217,7 @@ class Motioneye extends utils.Adapter {
209
217
  }
210
218
 
211
219
  async ensureInfoStates() {
212
- for (const [stateId, name] of Object.entries(INFO_STATE_LABELS)) {
220
+ for (const [stateId, labels] of Object.entries(INFO_STATE_LABELS)) {
213
221
  const type = stateId === 'camerasOnline' ? 'number' : 'string';
214
222
  const role =
215
223
  stateId === 'connection' ? 'indicator.connected' : stateId === 'camerasOnline' ? 'value' : 'text';
@@ -217,7 +225,7 @@ class Motioneye extends utils.Adapter {
217
225
  await this.setObjectNotExistsAsync(`${INFO_PREFIX}.${stateId}`, {
218
226
  type: 'state',
219
227
  common: {
220
- name,
228
+ name: /** @type {ioBroker.StringOrTranslated} */ (labels),
221
229
  type: stateId === 'connection' ? 'boolean' : type,
222
230
  role,
223
231
  read: true,
@@ -411,7 +419,7 @@ class Motioneye extends utils.Adapter {
411
419
  {
412
420
  id: 'streamUrl',
413
421
  common: {
414
- name: `${camera.name} stream HTML (inventwo)`,
422
+ name: `${camera.name} stream HTML`,
415
423
  type: 'string',
416
424
  role: 'text',
417
425
  read: true,
@@ -534,6 +542,15 @@ class Motioneye extends utils.Adapter {
534
542
  patch.video_streaming = false;
535
543
  }
536
544
 
545
+ const storagePatch = buildStoragePatch(camera.mediaFolder);
546
+ if (camera.mediaFolder && !storagePatch.root_directory) {
547
+ this.log.warn(
548
+ `Invalid media folder for ${camera.name}: "${camera.mediaFolder}" — skipped (use a single folder name without slashes)`,
549
+ );
550
+ } else {
551
+ Object.assign(patch, storagePatch);
552
+ }
553
+
537
554
  const result = await this.motionEyeApi.saveCameraConfig(camera.motionEyeId, patch);
538
555
  await this.setStateAsync(`${channelId}.mode`, mode, true);
539
556
  await this.setStateAsync(`${channelId}.status`, `Mode=${MODE_LABELS[mode]}`, true);
@@ -754,6 +771,74 @@ class Motioneye extends utils.Adapter {
754
771
  }
755
772
  }
756
773
 
774
+ /**
775
+ * @param {ioBroker.Message} obj
776
+ * @param {Record<string, unknown>} response
777
+ */
778
+ replyToMessage(obj, response) {
779
+ if (!obj?.callback || !obj.from || !obj.command) {
780
+ return;
781
+ }
782
+
783
+ this.sendTo(obj.from, obj.command, response, obj.callback);
784
+ }
785
+
786
+ /**
787
+ * @param {ioBroker.Message} obj
788
+ */
789
+ async onMessage(obj) {
790
+ if (!obj || typeof obj.command !== 'string') {
791
+ return;
792
+ }
793
+
794
+ if (obj.command === 'loadCameras') {
795
+ await this.handleLoadCameras(obj);
796
+ }
797
+ }
798
+
799
+ /**
800
+ * @param {ioBroker.Message} obj
801
+ */
802
+ async handleLoadCameras(obj) {
803
+ const payload = parseLoadCamerasMessage(obj.message);
804
+
805
+ const motionHost = String(payload.motionHost || this.config.motionHost || '').trim();
806
+ const motionEyePort = Number(payload.motionEyePort ?? this.config.motionEyePort) || 8765;
807
+ const motionEyeUser = String(payload.motionEyeUser ?? this.config.motionEyeUser ?? 'admin');
808
+ const motionEyePassword = String(this.config.motionEyePassword ?? '');
809
+ const requestTimeoutMs = Number(payload.requestTimeoutMs ?? this.config.requestTimeoutMs) || 45000;
810
+ const defaultMode = String(payload.defaultMode || this.config.defaultMode || 'off');
811
+ const existingCameras = Array.isArray(payload.cameras) ? payload.cameras : this.config.cameras || [];
812
+
813
+ try {
814
+ if (!motionHost) {
815
+ throw new Error('MotionEye host is required');
816
+ }
817
+
818
+ const api = createMotionEyeApi({
819
+ host: motionHost,
820
+ motionEyePort,
821
+ username: motionEyeUser,
822
+ password: motionEyePassword,
823
+ requestTimeoutMs,
824
+ listCacheMs: 0,
825
+ });
826
+
827
+ const motionEyeList = await api.getCameraList();
828
+ const { cameras, added } = mergeMotionEyeCameras(existingCameras, motionEyeList, defaultMode);
829
+
830
+ this.log.info(`Loaded ${motionEyeList.length} camera(s) from MotionEye, added ${added} new row(s)`);
831
+
832
+ this.replyToMessage(obj, {
833
+ native: { cameras },
834
+ result: added > 0 ? 'added' : 'none',
835
+ });
836
+ } catch (error) {
837
+ this.log.error(`loadCameras failed: ${error.message}`);
838
+ this.replyToMessage(obj, { error: error.message });
839
+ }
840
+ }
841
+
757
842
  /**
758
843
  * @param {() => void} callback
759
844
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.motioneye",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Connect MotionEye cameras to ioBroker for motion detection, snapshots, and live streams",
5
5
  "author": {
6
6
  "name": "skvarel",