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.
@@ -0,0 +1,19 @@
1
+ // This file extends the AdapterConfig type from "@iobroker/types"
2
+ // using the actual properties present in io-package.json
3
+ // in order to provide typings for adapter.config properties
4
+
5
+ import { native } from '../io-package.json';
6
+
7
+ type _AdapterConfig = typeof native;
8
+
9
+ // Augment the globally declared type ioBroker.AdapterConfig
10
+ declare global {
11
+ namespace ioBroker {
12
+ interface AdapterConfig extends _AdapterConfig {
13
+ // Do not enter anything here!
14
+ }
15
+ }
16
+ }
17
+
18
+ // this is required so the above AdapterConfig is found by TypeScript / type checking
19
+ export {};
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Sanitize a user-chosen camera name for ioBroker channel IDs.
5
+ * @param {string} name
6
+ * @returns {string}
7
+ */
8
+ function safeChannelName(name) {
9
+ return String(name)
10
+ .trim()
11
+ .replace(/\s+/g, '_')
12
+ .replace(/[^a-zA-Z0-9_äöüÄÖÜß-]/g, '_');
13
+ }
14
+
15
+ /**
16
+ * @param {string} value
17
+ * @returns {string}
18
+ */
19
+ function slugifyId(value) {
20
+ return safeChannelName(value)
21
+ .toLowerCase()
22
+ .replace(/[^a-z0-9_-]/g, '_')
23
+ .replace(/_+/g, '_')
24
+ .replace(/^_|_$/g, '');
25
+ }
26
+
27
+ /**
28
+ * @typedef {object} NativeCameraConfig
29
+ * @property {string} [id]
30
+ * @property {string} name
31
+ * @property {number} motionEyeId
32
+ * @property {boolean} [enabled]
33
+ * @property {string} [defaultMode]
34
+ */
35
+
36
+ /**
37
+ * @typedef {object} ResolvedCamera
38
+ * @property {string} id Internal webhook key
39
+ * @property {string} name Display name
40
+ * @property {string} channel Channel ID under adapter namespace
41
+ * @property {number} motionEyeId
42
+ * @property {boolean} enabled
43
+ * @property {string} defaultMode
44
+ */
45
+
46
+ /**
47
+ * @param {NativeCameraConfig[]} cameras
48
+ * @param {string} fallbackDefaultMode
49
+ * @returns {ResolvedCamera[]}
50
+ */
51
+ function resolveCameras(cameras, fallbackDefaultMode = 'off') {
52
+ if (!Array.isArray(cameras)) {
53
+ return [];
54
+ }
55
+
56
+ /** @type {ResolvedCamera[]} */
57
+ const resolved = [];
58
+ const usedIds = new Set();
59
+ const usedChannels = new Set();
60
+
61
+ for (const entry of cameras) {
62
+ if (!entry || !entry.name || entry.motionEyeId == null) {
63
+ continue;
64
+ }
65
+
66
+ const name = String(entry.name).trim();
67
+ const channel = safeChannelName(name);
68
+ if (!channel) {
69
+ continue;
70
+ }
71
+
72
+ let id = entry.id ? slugifyId(entry.id) : slugifyId(name);
73
+ if (!id) {
74
+ id = `cam_${entry.motionEyeId}`;
75
+ }
76
+
77
+ let uniqueId = id;
78
+ let suffix = 2;
79
+ while (usedIds.has(uniqueId)) {
80
+ uniqueId = `${id}_${suffix}`;
81
+ suffix += 1;
82
+ }
83
+
84
+ let uniqueChannel = channel;
85
+ suffix = 2;
86
+ while (usedChannels.has(uniqueChannel)) {
87
+ uniqueChannel = `${channel}_${suffix}`;
88
+ suffix += 1;
89
+ }
90
+
91
+ usedIds.add(uniqueId);
92
+ usedChannels.add(uniqueChannel);
93
+
94
+ resolved.push({
95
+ id: uniqueId,
96
+ name,
97
+ channel: uniqueChannel,
98
+ motionEyeId: Number(entry.motionEyeId),
99
+ enabled: entry.enabled !== false,
100
+ defaultMode: entry.defaultMode || fallbackDefaultMode,
101
+ });
102
+ }
103
+
104
+ return resolved;
105
+ }
106
+
107
+ /**
108
+ * @param {string} namespace Adapter namespace (e.g. motioneye.0)
109
+ * @param {string} webhookHost ioBroker host reachable from MotionEye
110
+ * @param {number} webhookPort
111
+ * @param {string} cameraId Internal camera key
112
+ * @returns {string}
113
+ */
114
+ function buildWebhookUrl(namespace, webhookHost, webhookPort, cameraId) {
115
+ const host = String(webhookHost).trim();
116
+ const port = Number(webhookPort);
117
+ return `http://${host}:${port}/${namespace}/webhook/${encodeURIComponent(cameraId)}?value=true`;
118
+ }
119
+
120
+ /**
121
+ * @param {string} path Request path including query string
122
+ * @param {string} namespace Adapter namespace (e.g. motioneye.0)
123
+ * @returns {{ cameraId: string, value: boolean } | null}
124
+ */
125
+ function parseWebhookRequest(path, namespace) {
126
+ const pathOnly = path.split('?')[0];
127
+ const prefix = `/${namespace}/webhook/`;
128
+ if (!pathOnly.startsWith(prefix)) {
129
+ return null;
130
+ }
131
+
132
+ const cameraId = decodeURIComponent(pathOnly.slice(prefix.length).replace(/\/+$/, ''));
133
+ if (!cameraId) {
134
+ return null;
135
+ }
136
+
137
+ const query = path.includes('?') ? path.slice(path.indexOf('?') + 1) : '';
138
+ let value = true;
139
+ for (const part of query.split('&')) {
140
+ if (!part) {
141
+ continue;
142
+ }
143
+ const eq = part.indexOf('=');
144
+ const key = eq >= 0 ? decodeURIComponent(part.slice(0, eq)) : decodeURIComponent(part);
145
+ if (key === 'value') {
146
+ const raw = eq >= 0 ? decodeURIComponent(part.slice(eq + 1)) : '';
147
+ value = !['0', 'false', 'off', 'no'].includes(String(raw).trim().toLowerCase());
148
+ }
149
+ }
150
+
151
+ return { cameraId, value };
152
+ }
153
+
154
+ module.exports = {
155
+ safeChannelName,
156
+ slugifyId,
157
+ resolveCameras,
158
+ buildWebhookUrl,
159
+ parseWebhookRequest,
160
+ };
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const {
5
+ safeChannelName,
6
+ slugifyId,
7
+ resolveCameras,
8
+ buildWebhookUrl,
9
+ parseWebhookRequest,
10
+ } = require('./cameraRegistry');
11
+
12
+ describe('cameraRegistry', () => {
13
+ it('safeChannelName should replace spaces and strip unsafe characters', () => {
14
+ expect(safeChannelName('Innenhof II')).to.equal('Innenhof_II');
15
+ expect(safeChannelName(' Auffahrt ')).to.equal('Auffahrt');
16
+ });
17
+
18
+ it('slugifyId should produce lowercase stable ids', () => {
19
+ expect(slugifyId('Innenhof II')).to.equal('innenhof_ii');
20
+ });
21
+
22
+ it('resolveCameras should skip invalid entries and deduplicate ids', () => {
23
+ const cameras = resolveCameras(
24
+ [
25
+ { name: 'Auffahrt', motionEyeId: 1 },
26
+ { name: 'Auffahrt', motionEyeId: 2, id: 'auffahrt' },
27
+ { name: '', motionEyeId: 3 },
28
+ { name: 'Carport', motionEyeId: 2, enabled: false },
29
+ ],
30
+ 'still',
31
+ );
32
+
33
+ expect(cameras).to.have.length(3);
34
+ expect(cameras[0].channel).to.equal('Auffahrt');
35
+ expect(cameras[0].defaultMode).to.equal('still');
36
+ expect(cameras[1].id).to.equal('auffahrt_2');
37
+ expect(cameras[2].enabled).to.equal(false);
38
+ });
39
+
40
+ it('buildWebhookUrl should include namespace and camera id', () => {
41
+ const url = buildWebhookUrl('motioneye.0', '192.168.1.10', 8090, 'auffahrt');
42
+ expect(url).to.equal('http://192.168.1.10:8090/motioneye.0/webhook/auffahrt?value=true');
43
+ });
44
+
45
+ it('parseWebhookRequest should parse value query parameter', () => {
46
+ expect(parseWebhookRequest('/motioneye.0/webhook/auffahrt?value=true', 'motioneye.0')).to.deep.equal({
47
+ cameraId: 'auffahrt',
48
+ value: true,
49
+ });
50
+ expect(parseWebhookRequest('/motioneye.0/webhook/carport?value=false', 'motioneye.0')).to.deep.equal({
51
+ cameraId: 'carport',
52
+ value: false,
53
+ });
54
+ expect(parseWebhookRequest('/other/path', 'motioneye.0')).to.equal(null);
55
+ });
56
+ });
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ /** @type {Record<string, string>} */
4
+ const MODE_ALIASES = {
5
+ off: 'off',
6
+ aus: 'off',
7
+ 0: 'off',
8
+ false: 'off',
9
+ still: 'still',
10
+ ruhig: 'still',
11
+ trigger: 'still',
12
+ sharp: 'sharp',
13
+ scharf: 'sharp',
14
+ armed: 'sharp',
15
+ 1: 'sharp',
16
+ true: 'sharp',
17
+ };
18
+
19
+ /** @type {Record<string, Record<string, unknown>>} */
20
+ const MODE_PROFILES = {
21
+ off: {
22
+ motion_detection: false,
23
+ movies: false,
24
+ web_hook_notifications_enabled: false,
25
+ },
26
+ still: {
27
+ motion_detection: true,
28
+ movies: false,
29
+ web_hook_notifications_enabled: true,
30
+ web_hook_notifications_http_method: 'GET',
31
+ },
32
+ sharp: {
33
+ motion_detection: true,
34
+ movies: true,
35
+ recording_mode: 'motion-triggered',
36
+ movie_format: 'mp4',
37
+ web_hook_notifications_enabled: true,
38
+ web_hook_notifications_http_method: 'GET',
39
+ },
40
+ };
41
+
42
+ /** @type {Record<string, string>} */
43
+ const MODE_LABELS = {
44
+ off: 'Off',
45
+ still: 'Still',
46
+ sharp: 'Sharp',
47
+ };
48
+
49
+ /** Snapshot media defaults applied on adapter start when enabled. */
50
+ const MEDIA_SETTINGS = {
51
+ still_images: true,
52
+ capture_mode: 'manual',
53
+ manual_snapshots: true,
54
+ };
55
+
56
+ /**
57
+ * @param {unknown} value
58
+ * @returns {'off'|'still'|'sharp'|null}
59
+ */
60
+ function normalizeMode(value) {
61
+ const key = String(value == null ? '' : value)
62
+ .trim()
63
+ .toLowerCase();
64
+ return /** @type {'off'|'still'|'sharp'|null} */ (MODE_ALIASES[key] || null);
65
+ }
66
+
67
+ /**
68
+ * @param {Record<string, unknown>} uiConfig MotionEye camera config
69
+ * @returns {'off'|'still'|'sharp'}
70
+ */
71
+ function inferModeFromConfig(uiConfig) {
72
+ if (!uiConfig.motion_detection) {
73
+ return 'off';
74
+ }
75
+ if (uiConfig.movies) {
76
+ return 'sharp';
77
+ }
78
+ return 'still';
79
+ }
80
+
81
+ /**
82
+ * @param {'off'|'still'|'sharp'} mode
83
+ * @param {string} [webhookUrl]
84
+ * @returns {Record<string, unknown>}
85
+ */
86
+ function buildModePatch(mode, webhookUrl) {
87
+ const patch = { ...MODE_PROFILES[mode] };
88
+ if ((mode === 'still' || mode === 'sharp') && webhookUrl) {
89
+ patch.web_hook_notifications_url = webhookUrl;
90
+ }
91
+ return patch;
92
+ }
93
+
94
+ module.exports = {
95
+ MODE_ALIASES,
96
+ MODE_PROFILES,
97
+ MODE_LABELS,
98
+ MEDIA_SETTINGS,
99
+ normalizeMode,
100
+ inferModeFromConfig,
101
+ buildModePatch,
102
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const { expect } = require('chai');
4
+ const { normalizeMode, inferModeFromConfig, buildModePatch } = require('./modeProfiles');
5
+
6
+ describe('modeProfiles', () => {
7
+ it('normalizeMode should accept aliases', () => {
8
+ expect(normalizeMode('scharf')).to.equal('sharp');
9
+ expect(normalizeMode('aus')).to.equal('off');
10
+ expect(normalizeMode('trigger')).to.equal('still');
11
+ expect(normalizeMode('unknown')).to.equal(null);
12
+ });
13
+
14
+ it('inferModeFromConfig should derive mode from MotionEye flags', () => {
15
+ expect(inferModeFromConfig({ motion_detection: false, movies: false })).to.equal('off');
16
+ expect(inferModeFromConfig({ motion_detection: true, movies: false })).to.equal('still');
17
+ expect(inferModeFromConfig({ motion_detection: true, movies: true })).to.equal('sharp');
18
+ });
19
+
20
+ it('buildModePatch should include webhook URL for still and sharp', () => {
21
+ const still = buildModePatch('still', 'http://iobroker:8090/motioneye.0/webhook/cam?value=true');
22
+ expect(still.motion_detection).to.equal(true);
23
+ expect(still.movies).to.equal(false);
24
+ expect(still.web_hook_notifications_url).to.include('webhook/cam');
25
+
26
+ const sharp = buildModePatch('sharp', 'http://iobroker/webhook');
27
+ expect(sharp.movie_format).to.equal('mp4');
28
+ expect(sharp.recording_mode).to.equal('motion-triggered');
29
+ });
30
+
31
+ it('buildModePatch off should disable webhook', () => {
32
+ const off = buildModePatch('off');
33
+ expect(off.web_hook_notifications_enabled).to.equal(false);
34
+ expect(off).to.not.have.property('web_hook_notifications_url');
35
+ });
36
+ });
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+
5
+ /**
6
+ * @typedef {object} MotionApiOptions
7
+ * @property {string} host MotionEye / Motion host
8
+ * @property {number} [motionPort=7999]
9
+ * @property {number} [requestTimeoutMs=45000]
10
+ */
11
+
12
+ /**
13
+ * Motion HTTP API client (snapshots on motionPort).
14
+ * @param {MotionApiOptions} options
15
+ */
16
+ function createMotionApi(options) {
17
+ const host = options.host;
18
+ const motionPort = options.motionPort ?? 7999;
19
+ const requestTimeoutMs = options.requestTimeoutMs ?? 45000;
20
+
21
+ /**
22
+ * @param {string} path
23
+ * @param {string} [method]
24
+ * @returns {Promise<{ status: number, body: string }>}
25
+ */
26
+ function request(path, method = 'GET') {
27
+ return new Promise((resolve, reject) => {
28
+ const req = http.request(
29
+ {
30
+ hostname: host,
31
+ port: motionPort,
32
+ path,
33
+ method,
34
+ timeout: requestTimeoutMs,
35
+ },
36
+ res => {
37
+ let body = '';
38
+ res.setEncoding('utf8');
39
+ res.on('data', chunk => {
40
+ body += chunk;
41
+ });
42
+ res.on('end', () => {
43
+ if ((res.statusCode || 0) >= 400) {
44
+ reject(new Error(`Motion HTTP ${res.statusCode}: ${body.trim() || path}`));
45
+ return;
46
+ }
47
+ resolve({ status: res.statusCode || 0, body: body.trim() });
48
+ });
49
+ },
50
+ );
51
+
52
+ req.on('timeout', () => {
53
+ req.destroy();
54
+ reject(new Error(`Timeout after ${requestTimeoutMs} ms: ${path}`));
55
+ });
56
+ req.on('error', reject);
57
+ req.end();
58
+ });
59
+ }
60
+
61
+ return {
62
+ /**
63
+ * @param {number} cameraId MotionEye camera ID
64
+ */
65
+ takeSnapshot(cameraId) {
66
+ return request(`/${cameraId}/action/snapshot`, 'GET');
67
+ },
68
+ };
69
+ }
70
+
71
+ module.exports = {
72
+ createMotionApi,
73
+ };