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.
- package/README.md +11 -5
- package/admin/i18n/de.json +22 -0
- package/admin/i18n/en.json +22 -0
- package/admin/i18n/es.json +22 -0
- package/admin/i18n/fr.json +22 -0
- package/admin/i18n/it.json +22 -0
- package/admin/i18n/nl.json +22 -0
- package/admin/i18n/pl.json +22 -0
- package/admin/i18n/pt.json +22 -0
- package/admin/i18n/ru.json +22 -0
- package/admin/i18n/uk.json +22 -0
- package/admin/i18n/zh-cn.json +22 -0
- package/admin/jsonConfig.json +195 -4
- package/io-package.json +16 -1
- package/lib/cameraDiscovery.js +130 -0
- package/lib/cameraDiscovery.test.js +76 -0
- package/lib/cameraRegistry.js +3 -0
- package/lib/mediaStorage.js +62 -0
- package/lib/mediaStorage.test.js +31 -0
- package/lib/motionEyeApi.js +5 -3
- package/main.js +88 -3
- package/package.json +1 -1
|
@@ -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
|
+
});
|
package/lib/cameraRegistry.js
CHANGED
|
@@ -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
|
+
});
|
package/lib/motionEyeApi.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
*/
|