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/LICENSE +21 -0
- package/README.md +131 -0
- package/admin/i18n/de.json +49 -0
- package/admin/i18n/en.json +49 -0
- package/admin/i18n/es.json +49 -0
- package/admin/i18n/fr.json +49 -0
- package/admin/i18n/it.json +49 -0
- package/admin/i18n/nl.json +49 -0
- package/admin/i18n/pl.json +49 -0
- package/admin/i18n/pt.json +49 -0
- package/admin/i18n/ru.json +49 -0
- package/admin/i18n/uk.json +49 -0
- package/admin/i18n/zh-cn.json +49 -0
- package/admin/img/inventwo.svg +2 -0
- package/admin/jsonConfig.json +302 -0
- package/admin/motioneye-logo.svg +13 -0
- package/admin/motioneye.svg +1 -0
- package/io-package.json +153 -0
- package/lib/adapter-config.d.ts +19 -0
- package/lib/cameraRegistry.js +160 -0
- package/lib/cameraRegistry.test.js +56 -0
- package/lib/modeProfiles.js +102 -0
- package/lib/modeProfiles.test.js +36 -0
- package/lib/motionApi.js +73 -0
- package/lib/motionEyeApi.js +285 -0
- package/lib/motionEyeApi.test.js +62 -0
- package/lib/streamManager.js +487 -0
- package/lib/streamManager.test.js +19 -0
- package/lib/webhookServer.js +98 -0
- package/main.js +758 -0
- package/package.json +79 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
const SIGNATURE_REGEX = new RegExp('[^a-zA-Z0-9/?_.=&{}\\[\\]":, -]', 'g');
|
|
7
|
+
|
|
8
|
+
/** Default cache TTL for camera list (ms). */
|
|
9
|
+
const LIST_CACHE_MS = 15000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} value
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function quoteParam(value) {
|
|
16
|
+
return encodeURIComponent(value).replace(/[!'()*~]/g, c => c);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute MotionEye API signature (SHA1).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} method
|
|
23
|
+
* @param {string} requestPath
|
|
24
|
+
* @param {string} body
|
|
25
|
+
* @param {string} password Sign key (empty string when no password)
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function computeSignature(method, requestPath, body, password) {
|
|
29
|
+
const qIndex = requestPath.indexOf('?');
|
|
30
|
+
const pathname = qIndex >= 0 ? requestPath.slice(0, qIndex) : requestPath;
|
|
31
|
+
const queryString = qIndex >= 0 ? requestPath.slice(qIndex + 1) : '';
|
|
32
|
+
const params = [];
|
|
33
|
+
|
|
34
|
+
if (queryString) {
|
|
35
|
+
for (const part of queryString.split('&')) {
|
|
36
|
+
if (!part) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const eq = part.indexOf('=');
|
|
40
|
+
const name = eq >= 0 ? decodeURIComponent(part.slice(0, eq)) : decodeURIComponent(part);
|
|
41
|
+
const value = eq >= 0 ? decodeURIComponent(part.slice(eq + 1)) : '';
|
|
42
|
+
if (name !== '_signature') {
|
|
43
|
+
params.push([name, value]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
params.sort((a, b) => a[0].localeCompare(b[0]));
|
|
49
|
+
const query = params.map(([name, value]) => `${name}=${quoteParam(value)}`).join('&');
|
|
50
|
+
let path = pathname + (query ? `?${query}` : '');
|
|
51
|
+
path = path.replace(SIGNATURE_REGEX, '-');
|
|
52
|
+
|
|
53
|
+
const key = String(password).replace(SIGNATURE_REGEX, '-');
|
|
54
|
+
let bodyStr = body || '';
|
|
55
|
+
if (bodyStr.startsWith('---')) {
|
|
56
|
+
bodyStr = '';
|
|
57
|
+
} else if (bodyStr) {
|
|
58
|
+
bodyStr = bodyStr.replace(SIGNATURE_REGEX, '-');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const signing = `${method}:${path}:${bodyStr}:${key}`;
|
|
62
|
+
return crypto.createHash('sha1').update(signing, 'utf8').digest('hex').toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {string} password Plain-text MotionEye password
|
|
67
|
+
* @returns {string} Sign key (empty when password is empty)
|
|
68
|
+
*/
|
|
69
|
+
function motionEyeSignKey(password) {
|
|
70
|
+
if (!password) {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return crypto.createHash('sha1').update(String(password), 'utf8').digest('hex').toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} path
|
|
79
|
+
* @param {string} method
|
|
80
|
+
* @param {string|null|undefined} body
|
|
81
|
+
* @param {string} username
|
|
82
|
+
* @param {string} signKey
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
function buildAuthPath(path, method, body, username, signKey) {
|
|
86
|
+
const joiner = path.includes('?') ? '&' : '?';
|
|
87
|
+
const unsignedPath = `${path}${joiner}_username=${quoteParam(username)}`;
|
|
88
|
+
const signature = computeSignature(method, unsignedPath, body || '', signKey);
|
|
89
|
+
return `${unsignedPath}&_signature=${signature}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @typedef {object} MotionEyeApiOptions
|
|
94
|
+
* @property {string} host MotionEye host
|
|
95
|
+
* @property {number} [motionEyePort=8765]
|
|
96
|
+
* @property {string} [username='admin']
|
|
97
|
+
* @property {string} [password='']
|
|
98
|
+
* @property {number} [requestTimeoutMs=45000]
|
|
99
|
+
* @property {number} [listCacheMs=15000]
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {object} MotionEyeApiResult
|
|
104
|
+
* @property {number} status
|
|
105
|
+
* @property {unknown} data
|
|
106
|
+
* @property {string} body
|
|
107
|
+
*/
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create a MotionEye Config API client.
|
|
111
|
+
* @param {MotionEyeApiOptions} options
|
|
112
|
+
*/
|
|
113
|
+
function createMotionEyeApi(options) {
|
|
114
|
+
const host = options.host;
|
|
115
|
+
const motionEyePort = options.motionEyePort ?? 8765;
|
|
116
|
+
const username = options.username || 'admin';
|
|
117
|
+
const password = options.password || '';
|
|
118
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 45000;
|
|
119
|
+
const listCacheMs = options.listCacheMs ?? LIST_CACHE_MS;
|
|
120
|
+
const signKey = motionEyeSignKey(password);
|
|
121
|
+
|
|
122
|
+
let listCache = null;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} path
|
|
126
|
+
* @param {string} [method]
|
|
127
|
+
* @param {string|null} [body]
|
|
128
|
+
* @returns {Promise<{ status: number, body: string }>}
|
|
129
|
+
*/
|
|
130
|
+
function httpRequest(path, method = 'GET', body = null) {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const requestOptions = {
|
|
133
|
+
hostname: host,
|
|
134
|
+
port: motionEyePort,
|
|
135
|
+
path,
|
|
136
|
+
method,
|
|
137
|
+
timeout: requestTimeoutMs,
|
|
138
|
+
headers: {},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (body) {
|
|
142
|
+
requestOptions.headers = {
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
'Content-Length': Buffer.byteLength(body, 'utf8'),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const req = http.request(requestOptions, res => {
|
|
149
|
+
let responseBody = '';
|
|
150
|
+
res.setEncoding('utf8');
|
|
151
|
+
res.on('data', chunk => {
|
|
152
|
+
responseBody += chunk;
|
|
153
|
+
});
|
|
154
|
+
res.on('end', () => {
|
|
155
|
+
resolve({
|
|
156
|
+
status: res.statusCode || 0,
|
|
157
|
+
body: responseBody.trim(),
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
req.on('timeout', () => {
|
|
163
|
+
req.destroy();
|
|
164
|
+
reject(new Error(`Timeout after ${requestTimeoutMs} ms: ${path}`));
|
|
165
|
+
});
|
|
166
|
+
req.on('error', reject);
|
|
167
|
+
|
|
168
|
+
if (body) {
|
|
169
|
+
req.write(body);
|
|
170
|
+
}
|
|
171
|
+
req.end();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param {string} path
|
|
177
|
+
* @param {string} [method]
|
|
178
|
+
* @param {Record<string, unknown>|null} [bodyObj]
|
|
179
|
+
* @returns {Promise<MotionEyeApiResult>}
|
|
180
|
+
*/
|
|
181
|
+
async function call(path, method = 'GET', bodyObj = null) {
|
|
182
|
+
const body = bodyObj ? JSON.stringify(bodyObj) : null;
|
|
183
|
+
const authPath = buildAuthPath(path, method, body, username, signKey);
|
|
184
|
+
const result = await httpRequest(authPath, method, body);
|
|
185
|
+
|
|
186
|
+
let data = null;
|
|
187
|
+
if (result.body) {
|
|
188
|
+
try {
|
|
189
|
+
data = JSON.parse(result.body);
|
|
190
|
+
} catch {
|
|
191
|
+
data = result.body;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
result.status >= 400 ||
|
|
197
|
+
(data && typeof data === 'object' && data !== null && 'error' in data && data.error)
|
|
198
|
+
) {
|
|
199
|
+
const message =
|
|
200
|
+
(data && typeof data === 'object' && data !== null && 'error' in data && data.error) ||
|
|
201
|
+
result.body ||
|
|
202
|
+
`HTTP ${result.status}`;
|
|
203
|
+
throw new Error(String(message));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { status: result.status, data, body: result.body };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @returns {Promise<Record<string, unknown>[]>}
|
|
211
|
+
*/
|
|
212
|
+
async function getCameraList() {
|
|
213
|
+
const now = Date.now();
|
|
214
|
+
if (listCache && listCache.expires > now) {
|
|
215
|
+
return listCache.data;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const result = await call('/config/list', 'GET');
|
|
219
|
+
if (!result.data || typeof result.data !== 'object' || result.data === null || !('cameras' in result.data)) {
|
|
220
|
+
throw new Error('config/list: no cameras found');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const cameras = /** @type {Record<string, unknown>[]} */ (result.data.cameras);
|
|
224
|
+
listCache = { data: cameras, expires: now + listCacheMs };
|
|
225
|
+
return cameras;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {number} cameraId
|
|
230
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
231
|
+
*/
|
|
232
|
+
async function getCameraConfig(cameraId) {
|
|
233
|
+
const cameras = await getCameraList();
|
|
234
|
+
const camera = cameras.find(entry => entry.id === cameraId);
|
|
235
|
+
if (!camera) {
|
|
236
|
+
throw new Error(`Camera ID ${cameraId} not found in MotionEye`);
|
|
237
|
+
}
|
|
238
|
+
return camera;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {number} cameraId
|
|
243
|
+
* @param {Record<string, unknown>} patch
|
|
244
|
+
* @returns {Promise<{ changed: boolean, data: unknown }>}
|
|
245
|
+
*/
|
|
246
|
+
async function saveCameraConfig(cameraId, patch) {
|
|
247
|
+
const uiConfig = await getCameraConfig(cameraId);
|
|
248
|
+
let changed = false;
|
|
249
|
+
|
|
250
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
251
|
+
if (uiConfig[key] !== value) {
|
|
252
|
+
uiConfig[key] = value;
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!changed) {
|
|
258
|
+
return { changed: false, data: null };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
listCache = null;
|
|
262
|
+
const result = await call(`/config/${cameraId}/set/`, 'POST', uiConfig);
|
|
263
|
+
return { changed: true, data: result.data };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
call,
|
|
268
|
+
getCameraList,
|
|
269
|
+
getCameraConfig,
|
|
270
|
+
saveCameraConfig,
|
|
271
|
+
invalidateCache() {
|
|
272
|
+
listCache = null;
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
module.exports = {
|
|
278
|
+
SIGNATURE_REGEX,
|
|
279
|
+
quoteParam,
|
|
280
|
+
computeSignature,
|
|
281
|
+
motionEyeSignKey,
|
|
282
|
+
buildAuthPath,
|
|
283
|
+
createMotionEyeApi,
|
|
284
|
+
LIST_CACHE_MS,
|
|
285
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { expect } = require('chai');
|
|
5
|
+
const { quoteParam, computeSignature, motionEyeSignKey, buildAuthPath } = require('./motionEyeApi');
|
|
6
|
+
|
|
7
|
+
describe('motionEyeApi signature', () => {
|
|
8
|
+
it('quoteParam should encode special characters', () => {
|
|
9
|
+
expect(quoteParam('hello world')).to.equal('hello%20world');
|
|
10
|
+
expect(quoteParam('a!b*c')).to.equal('a!b*c');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('motionEyeSignKey should return empty string for empty password', () => {
|
|
14
|
+
expect(motionEyeSignKey('')).to.equal('');
|
|
15
|
+
expect(motionEyeSignKey(null)).to.equal('');
|
|
16
|
+
expect(motionEyeSignKey(undefined)).to.equal('');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('motionEyeSignKey should return SHA1 hex of password', () => {
|
|
20
|
+
const expected = crypto.createHash('sha1').update('testpass', 'utf8').digest('hex').toLowerCase();
|
|
21
|
+
expect(motionEyeSignKey('testpass')).to.equal(expected);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('computeSignature should be deterministic for GET /config/list without password', () => {
|
|
25
|
+
const path = '/config/list?_username=admin';
|
|
26
|
+
const sig1 = computeSignature('GET', path, '', '');
|
|
27
|
+
const sig2 = computeSignature('GET', path, '', '');
|
|
28
|
+
expect(sig1).to.equal(sig2);
|
|
29
|
+
expect(sig1).to.match(/^[a-f0-9]{40}$/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('computeSignature should differ when password sign key is set', () => {
|
|
33
|
+
const path = '/config/list?_username=admin';
|
|
34
|
+
const signKey = motionEyeSignKey('secret');
|
|
35
|
+
const withoutPassword = computeSignature('GET', path, '', '');
|
|
36
|
+
const withPassword = computeSignature('GET', path, '', signKey);
|
|
37
|
+
expect(withoutPassword).to.not.equal(withPassword);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('computeSignature should sort query parameters before signing', () => {
|
|
41
|
+
const path = '/config/list?z=1&_username=admin&a=2';
|
|
42
|
+
const signature = computeSignature('GET', path, '', '');
|
|
43
|
+
const reordered = computeSignature('GET', '/config/list?a=2&_username=admin&z=1', '', '');
|
|
44
|
+
expect(signature).to.equal(reordered);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('computeSignature should ignore existing _signature parameter', () => {
|
|
48
|
+
const withSig = computeSignature('GET', '/config/list?_username=admin&_signature=old', '', '');
|
|
49
|
+
const withoutSig = computeSignature('GET', '/config/list?_username=admin', '', '');
|
|
50
|
+
expect(withSig).to.equal(withoutSig);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('buildAuthPath should append _username and _signature', () => {
|
|
54
|
+
const authPath = buildAuthPath('/config/list', 'GET', null, 'admin', '');
|
|
55
|
+
expect(authPath).to.match(/^\/config\/list\?_username=admin&_signature=[a-f0-9]{40}$/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('buildAuthPath should use & joiner when path already has query string', () => {
|
|
59
|
+
const authPath = buildAuthPath('/config/list?foo=bar', 'GET', null, 'admin', '');
|
|
60
|
+
expect(authPath).to.include('/config/list?foo=bar&_username=admin&_signature=');
|
|
61
|
+
});
|
|
62
|
+
});
|