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,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
|
+
});
|
package/lib/motionApi.js
ADDED
|
@@ -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
|
+
};
|