iobroker.zigbee2mqtt 3.0.10 → 3.0.14
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 +1 -1
- package/README.md +17 -245
- package/io-package.json +54 -42
- package/lib/check.js +6 -0
- package/lib/colors.js +26 -6
- package/lib/deviceController.js +66 -12
- package/lib/exposes.js +61 -50
- package/lib/imageController.js +52 -3
- package/lib/messages.js +10 -0
- package/lib/mqttServerController.js +20 -6
- package/lib/nonGenericDevicesExtension.js +6 -2
- package/lib/rgb.js +75 -30
- package/lib/statesController.js +53 -0
- package/lib/utils.js +54 -7
- package/lib/websocketController.js +29 -0
- package/lib/z2mController.js +19 -0
- package/main.js +8 -10
- package/package.json +3 -7
package/lib/exposes.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/* eslint-disable no-prototype-builtins */
|
|
2
|
-
// @ts-nocheck
|
|
3
1
|
'use strict';
|
|
4
2
|
|
|
5
3
|
const statesDefs = require('./states').states;
|
|
@@ -11,28 +9,28 @@ const getNonGenDevStatesDefs = require('./nonGenericDevicesExtension').getStateD
|
|
|
11
9
|
// https://www.zigbee2mqtt.io/guide/usage/exposes.html#access
|
|
12
10
|
const z2mAccess = {
|
|
13
11
|
/**
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
* Bit 0: The property can be found in the published state of this device
|
|
13
|
+
*/
|
|
16
14
|
STATE: 1,
|
|
17
15
|
/**
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
* Bit 1: The property can be set with a /set command
|
|
17
|
+
*/
|
|
20
18
|
SET: 2,
|
|
21
19
|
/**
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
* Bit 2: The property can be retrieved with a /get command
|
|
21
|
+
*/
|
|
24
22
|
GET: 4,
|
|
25
23
|
/**
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
* Bitwise inclusive OR of STATE and SET : 0b001 | 0b010
|
|
25
|
+
*/
|
|
28
26
|
STATE_SET: 3,
|
|
29
27
|
/**
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
* Bitwise inclusive OR of STATE and GET : 0b001 | 0b100
|
|
29
|
+
*/
|
|
32
30
|
STATE_GET: 5,
|
|
33
31
|
/**
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
* Bitwise inclusive OR of STATE and GET and SET : 0b001 | 0b100 | 0b010
|
|
33
|
+
*/
|
|
36
34
|
ALL: 7,
|
|
37
35
|
};
|
|
38
36
|
|
|
@@ -174,6 +172,11 @@ function genState(expose, role, name, desc) {
|
|
|
174
172
|
return state;
|
|
175
173
|
}
|
|
176
174
|
|
|
175
|
+
/**
|
|
176
|
+
*
|
|
177
|
+
* @param devicesMessag
|
|
178
|
+
* @param adapter
|
|
179
|
+
*/
|
|
177
180
|
async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
178
181
|
const states = [];
|
|
179
182
|
let scenes = [];
|
|
@@ -189,7 +192,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
189
192
|
if (state === undefined) {
|
|
190
193
|
return 0;
|
|
191
194
|
}
|
|
192
|
-
if (access === undefined)
|
|
195
|
+
if (access === undefined) {
|
|
196
|
+
access = z2mAccess.ALL;
|
|
197
|
+
}
|
|
193
198
|
state.readable = (access & z2mAccess.STATE) > 0;
|
|
194
199
|
state.writable = (access & z2mAccess.SET) > 0;
|
|
195
200
|
const stateExists = states.findIndex((x, _index, _array) => x.id === state.id);
|
|
@@ -214,7 +219,7 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
return states.push(state);
|
|
217
|
-
}
|
|
222
|
+
}
|
|
218
223
|
if (state.readable && !states[stateExists].readable) {
|
|
219
224
|
states[stateExists].read = state.read;
|
|
220
225
|
// as state is readable, it can't be button or event
|
|
@@ -294,7 +299,7 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
294
299
|
}
|
|
295
300
|
|
|
296
301
|
return states.length;
|
|
297
|
-
|
|
302
|
+
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
// search for scenes in the endpoints and build them into an array
|
|
@@ -461,9 +466,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
461
466
|
}
|
|
462
467
|
if (config.useKelvin == true) {
|
|
463
468
|
return utils.miredKelvinConversion(payload[propName]);
|
|
464
|
-
}
|
|
469
|
+
}
|
|
465
470
|
return payload[propName];
|
|
466
|
-
|
|
471
|
+
|
|
467
472
|
},
|
|
468
473
|
},
|
|
469
474
|
prop.access
|
|
@@ -519,9 +524,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
519
524
|
//}
|
|
520
525
|
if (config.useKelvin == true) {
|
|
521
526
|
return utils.miredKelvinConversion(payload[propName]);
|
|
522
|
-
}
|
|
527
|
+
}
|
|
523
528
|
return payload[propName];
|
|
524
|
-
|
|
529
|
+
|
|
525
530
|
},
|
|
526
531
|
},
|
|
527
532
|
prop.access
|
|
@@ -566,14 +571,14 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
566
571
|
payload[stateName].y
|
|
567
572
|
);
|
|
568
573
|
return (
|
|
569
|
-
|
|
570
|
-
utils.decimalToHex(colorval[0])
|
|
571
|
-
utils.decimalToHex(colorval[1])
|
|
572
|
-
utils.decimalToHex(colorval[2])
|
|
574
|
+
`#${
|
|
575
|
+
utils.decimalToHex(colorval[0])
|
|
576
|
+
}${utils.decimalToHex(colorval[1])
|
|
577
|
+
}${utils.decimalToHex(colorval[2])}`
|
|
573
578
|
);
|
|
574
|
-
}
|
|
579
|
+
}
|
|
575
580
|
return undefined;
|
|
576
|
-
|
|
581
|
+
|
|
577
582
|
},
|
|
578
583
|
epname: expose.endpoint,
|
|
579
584
|
},
|
|
@@ -635,10 +640,10 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
635
640
|
payload[stateName].y
|
|
636
641
|
);
|
|
637
642
|
return (
|
|
638
|
-
|
|
639
|
-
utils.decimalToHex(colorval[0])
|
|
640
|
-
utils.decimalToHex(colorval[1])
|
|
641
|
-
utils.decimalToHex(colorval[2])
|
|
643
|
+
`#${
|
|
644
|
+
utils.decimalToHex(colorval[0])
|
|
645
|
+
}${utils.decimalToHex(colorval[1])
|
|
646
|
+
}${utils.decimalToHex(colorval[2])}`
|
|
642
647
|
);
|
|
643
648
|
}
|
|
644
649
|
return undefined;
|
|
@@ -749,7 +754,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
749
754
|
break;
|
|
750
755
|
}
|
|
751
756
|
}
|
|
752
|
-
if (state)
|
|
757
|
+
if (state) {
|
|
758
|
+
pushToStates(state, expose.access);
|
|
759
|
+
}
|
|
753
760
|
break;
|
|
754
761
|
|
|
755
762
|
case 'enum':
|
|
@@ -934,9 +941,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
934
941
|
return utils.miredKelvinConversion(
|
|
935
942
|
payload.action_color_temperature
|
|
936
943
|
);
|
|
937
|
-
}
|
|
944
|
+
}
|
|
938
945
|
return payload.action_color_temperature;
|
|
939
|
-
|
|
946
|
+
|
|
940
947
|
}
|
|
941
948
|
},
|
|
942
949
|
},
|
|
@@ -970,14 +977,14 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
970
977
|
payload.action_color.y
|
|
971
978
|
);
|
|
972
979
|
return (
|
|
973
|
-
|
|
974
|
-
utils.decimalToHex(colorval[0])
|
|
975
|
-
utils.decimalToHex(colorval[1])
|
|
976
|
-
utils.decimalToHex(colorval[2])
|
|
980
|
+
`#${
|
|
981
|
+
utils.decimalToHex(colorval[0])
|
|
982
|
+
}${utils.decimalToHex(colorval[1])
|
|
983
|
+
}${utils.decimalToHex(colorval[2])}`
|
|
977
984
|
);
|
|
978
|
-
}
|
|
985
|
+
}
|
|
979
986
|
return undefined;
|
|
980
|
-
|
|
987
|
+
|
|
981
988
|
},
|
|
982
989
|
},
|
|
983
990
|
expose.access
|
|
@@ -1004,9 +1011,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
1004
1011
|
|
|
1005
1012
|
if (payload.action_level) {
|
|
1006
1013
|
return utils.bulbLevelToAdapterLevel(payload.action_level);
|
|
1007
|
-
}
|
|
1014
|
+
}
|
|
1008
1015
|
return undefined;
|
|
1009
|
-
|
|
1016
|
+
|
|
1010
1017
|
},
|
|
1011
1018
|
},
|
|
1012
1019
|
expose.access
|
|
@@ -1032,9 +1039,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
1032
1039
|
|
|
1033
1040
|
if (payload.action_level) {
|
|
1034
1041
|
return payload.action_saturation;
|
|
1035
|
-
}
|
|
1042
|
+
}
|
|
1036
1043
|
return undefined;
|
|
1037
|
-
|
|
1044
|
+
|
|
1038
1045
|
},
|
|
1039
1046
|
},
|
|
1040
1047
|
expose.access
|
|
@@ -1061,9 +1068,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
1061
1068
|
|
|
1062
1069
|
if (payload.action_enhanced_hue) {
|
|
1063
1070
|
return payload.action_enhanced_hue;
|
|
1064
|
-
}
|
|
1071
|
+
}
|
|
1065
1072
|
return undefined;
|
|
1066
|
-
|
|
1073
|
+
|
|
1067
1074
|
},
|
|
1068
1075
|
},
|
|
1069
1076
|
expose.access
|
|
@@ -1128,7 +1135,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
1128
1135
|
state = genState(expose);
|
|
1129
1136
|
break;
|
|
1130
1137
|
}
|
|
1131
|
-
if (state)
|
|
1138
|
+
if (state) {
|
|
1139
|
+
pushToStates(state, expose.access);
|
|
1140
|
+
}
|
|
1132
1141
|
break;
|
|
1133
1142
|
|
|
1134
1143
|
case 'binary':
|
|
@@ -1166,7 +1175,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
1166
1175
|
break;
|
|
1167
1176
|
}
|
|
1168
1177
|
}
|
|
1169
|
-
if (state)
|
|
1178
|
+
if (state) {
|
|
1179
|
+
pushToStates(state, expose.access);
|
|
1180
|
+
}
|
|
1170
1181
|
break;
|
|
1171
1182
|
|
|
1172
1183
|
case 'text':
|
|
@@ -1261,9 +1272,9 @@ async function createDeviceFromExposes(devicesMessag, adapter) {
|
|
|
1261
1272
|
return !isNaN(payload[expose.property][prop.property])
|
|
1262
1273
|
? payload[expose.property][prop.property]
|
|
1263
1274
|
: undefined;
|
|
1264
|
-
}
|
|
1275
|
+
}
|
|
1265
1276
|
return undefined;
|
|
1266
|
-
|
|
1277
|
+
|
|
1267
1278
|
};
|
|
1268
1279
|
} else {
|
|
1269
1280
|
state.getter = (payload) => {
|
package/lib/imageController.js
CHANGED
|
@@ -1,23 +1,42 @@
|
|
|
1
1
|
const axios = require('axios').default;
|
|
2
2
|
const sharp = require('sharp');
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
*
|
|
6
|
+
*/
|
|
4
7
|
class ImageController {
|
|
8
|
+
/**
|
|
9
|
+
*
|
|
10
|
+
* @param adapter
|
|
11
|
+
*/
|
|
5
12
|
constructor(adapter) {
|
|
6
13
|
this.adapter = adapter;
|
|
7
14
|
}
|
|
8
15
|
|
|
16
|
+
/**
|
|
17
|
+
*
|
|
18
|
+
* @param modelName
|
|
19
|
+
*/
|
|
9
20
|
sanitizeModelIDForImageUrl(modelName) {
|
|
10
21
|
const modelNameString = modelName.replace('/', '_');
|
|
11
22
|
// eslint-disable-next-line no-control-regex
|
|
12
23
|
return modelNameString.replace(/\u0000/g, '');
|
|
13
24
|
}
|
|
14
25
|
|
|
26
|
+
/**
|
|
27
|
+
*
|
|
28
|
+
* @param deviceName
|
|
29
|
+
*/
|
|
15
30
|
sanitizeZ2MDeviceName(deviceName) {
|
|
16
31
|
const deviceNameString = deviceName.replace(/:|\s|\//g, '-');
|
|
17
32
|
// eslint-disable-next-line no-control-regex
|
|
18
33
|
return deviceName ? deviceNameString.replace(/\u0000/g, '') : 'NA';
|
|
19
34
|
}
|
|
20
35
|
|
|
36
|
+
/**
|
|
37
|
+
*
|
|
38
|
+
* @param device
|
|
39
|
+
*/
|
|
21
40
|
getZ2mDeviceImageModelJPG(device) {
|
|
22
41
|
if (device && device.definition && device.definition.model) {
|
|
23
42
|
const icoString = `https://www.zigbee2mqtt.io/images/devices/${this.sanitizeZ2MDeviceName(device.definition.model)}.jpg`;
|
|
@@ -26,6 +45,10 @@ class ImageController {
|
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
|
|
48
|
+
/**
|
|
49
|
+
*
|
|
50
|
+
* @param device
|
|
51
|
+
*/
|
|
29
52
|
getZ2mDeviceImageModelPNG(device) {
|
|
30
53
|
if (device && device.definition && device.definition.model) {
|
|
31
54
|
const icoString = `https://www.zigbee2mqtt.io/images/devices/${this.sanitizeZ2MDeviceName(device.definition.model)}.png`;
|
|
@@ -35,6 +58,10 @@ class ImageController {
|
|
|
35
58
|
}
|
|
36
59
|
|
|
37
60
|
|
|
61
|
+
/**
|
|
62
|
+
*
|
|
63
|
+
* @param device
|
|
64
|
+
*/
|
|
38
65
|
getSlsDeviceImage(device) {
|
|
39
66
|
if (device && device.model_id) {
|
|
40
67
|
const icoString = `https://www.zigbee2mqtt.io/images/devices/${this.sanitizeModelIDForImageUrl(device.model_id)}.png`;
|
|
@@ -43,8 +70,14 @@ class ImageController {
|
|
|
43
70
|
}
|
|
44
71
|
}
|
|
45
72
|
|
|
73
|
+
/**
|
|
74
|
+
*
|
|
75
|
+
* @param device
|
|
76
|
+
*/
|
|
46
77
|
async getDeviceIcon(device) {
|
|
47
|
-
if (!this.adapter.config.useDeviceIcons)
|
|
78
|
+
if (!this.adapter.config.useDeviceIcons) {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
48
81
|
|
|
49
82
|
const imageSize = this.adapter.config.deviceIconsSize;
|
|
50
83
|
|
|
@@ -78,7 +111,7 @@ class ImageController {
|
|
|
78
111
|
if (!iconFound) {
|
|
79
112
|
this.adapter.log.warn(`Failed to download image for device model: ${device.definition.model} - ${device.definition.description}`);
|
|
80
113
|
return '';
|
|
81
|
-
}
|
|
114
|
+
}
|
|
82
115
|
// Load image from the Meta-Store
|
|
83
116
|
const icon = await this.adapter.readFileAsync(this.adapter.namespace, iconFileName);
|
|
84
117
|
// Load Image Metadata
|
|
@@ -106,8 +139,12 @@ class ImageController {
|
|
|
106
139
|
|
|
107
140
|
// Create and output Base64
|
|
108
141
|
return `data:image/png;base64,${icon.file.toString('base64')}`;
|
|
109
|
-
|
|
142
|
+
|
|
110
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
*
|
|
146
|
+
* @param url
|
|
147
|
+
*/
|
|
111
148
|
getFileNameWithExtension(url) {
|
|
112
149
|
const path = new URL(url).pathname;
|
|
113
150
|
const filename = path.split('/').pop();
|
|
@@ -115,6 +152,12 @@ class ImageController {
|
|
|
115
152
|
return filename.replace(/\u0000/g, '');
|
|
116
153
|
}
|
|
117
154
|
|
|
155
|
+
/**
|
|
156
|
+
*
|
|
157
|
+
* @param adapter
|
|
158
|
+
* @param url
|
|
159
|
+
* @param namespace
|
|
160
|
+
*/
|
|
118
161
|
async downloadIcon(adapter, url, namespace) {
|
|
119
162
|
try {
|
|
120
163
|
const res = await axios.get(url, { responseType: 'arraybuffer' });
|
|
@@ -125,6 +168,12 @@ class ImageController {
|
|
|
125
168
|
return false;
|
|
126
169
|
}
|
|
127
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
*
|
|
173
|
+
* @param z2mIconFileNameJPG
|
|
174
|
+
* @param z2mIconFileNamePNG
|
|
175
|
+
* @param slsIconFileName
|
|
176
|
+
*/
|
|
128
177
|
async getExistingIconFileName(z2mIconFileNameJPG, z2mIconFileNamePNG, slsIconFileName) {
|
|
129
178
|
if (await this.adapter.fileExistsAsync(this.adapter.namespace, z2mIconFileNameJPG)) {
|
|
130
179
|
return z2mIconFileNameJPG;
|
package/lib/messages.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*
|
|
3
|
+
* @param config
|
|
4
|
+
* @param log
|
|
5
|
+
*/
|
|
1
6
|
async function adapterInfo(config, log) {
|
|
2
7
|
log.info('================================= Adapter Config =================================');
|
|
3
8
|
log.info(`|| Zigbee2MQTT Frontend Scheme: ${config.webUIScheme}`);
|
|
@@ -41,6 +46,11 @@ async function adapterInfo(config, log) {
|
|
|
41
46
|
log.info('==================================================================================');
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
/**
|
|
50
|
+
*
|
|
51
|
+
* @param payload
|
|
52
|
+
* @param log
|
|
53
|
+
*/
|
|
44
54
|
async function zigbee2mqttInfo(payload, log) {
|
|
45
55
|
log.info('============================ Zigbee2MQTT Information =============================');
|
|
46
56
|
log.info(`|| Zigbee2MQTT Version: ${payload.version} `);
|
|
@@ -3,24 +3,33 @@ const Aedes = require('aedes');
|
|
|
3
3
|
const net = require('net');
|
|
4
4
|
let mqttServer;
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
6
9
|
class MqttServerController {
|
|
10
|
+
/**
|
|
11
|
+
*
|
|
12
|
+
* @param adapter
|
|
13
|
+
*/
|
|
7
14
|
constructor(adapter) {
|
|
8
15
|
this.adapter = adapter;
|
|
9
16
|
}
|
|
10
17
|
|
|
18
|
+
/**
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
11
21
|
async createMQTTServer() {
|
|
12
22
|
try {
|
|
13
23
|
const NedbPersistence = require('aedes-persistence-nedb');
|
|
14
24
|
const db = new NedbPersistence({
|
|
15
25
|
path: `${core.getAbsoluteInstanceDataDir(this.adapter)}/mqttData`,
|
|
16
26
|
prefix: '',
|
|
17
|
-
});
|
|
18
|
-
// @ts-ignore
|
|
27
|
+
});
|
|
19
28
|
const aedes = Aedes({ persistence: db });
|
|
20
29
|
mqttServer = net.createServer(aedes.handle);
|
|
21
30
|
mqttServer.listen(this.adapter.config.mqttServerPort, this.adapter.config.mqttServerIPBind, () => {
|
|
22
31
|
this.adapter.log.info(
|
|
23
|
-
`
|
|
32
|
+
`Starting MQTT-Server on IP ${this.adapter.config.mqttServerIPBind} and Port ${this.adapter.config.mqttServerPort}`
|
|
24
33
|
);
|
|
25
34
|
});
|
|
26
35
|
} catch (err) {
|
|
@@ -28,14 +37,16 @@ class MqttServerController {
|
|
|
28
37
|
}
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
/**
|
|
41
|
+
*
|
|
42
|
+
*/
|
|
31
43
|
async createDummyMQTTServer() {
|
|
32
44
|
try {
|
|
33
|
-
// @ts-ignore
|
|
34
45
|
const aedes = Aedes();
|
|
35
46
|
mqttServer = net.createServer(aedes.handle);
|
|
36
47
|
mqttServer.listen(this.adapter.config.mqttServerPort, this.adapter.config.mqttServerIPBind, () => {
|
|
37
48
|
this.adapter.log.info(
|
|
38
|
-
`
|
|
49
|
+
`Starting DummyMQTT-Server on IP ${this.adapter.config.mqttServerIPBind} and Port ${this.adapter.config.mqttServerPort}`
|
|
39
50
|
);
|
|
40
51
|
});
|
|
41
52
|
} catch (err) {
|
|
@@ -43,8 +54,11 @@ class MqttServerController {
|
|
|
43
54
|
}
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
/**
|
|
58
|
+
*
|
|
59
|
+
*/
|
|
46
60
|
closeServer() {
|
|
47
|
-
if (mqttServer && !mqttServer.
|
|
61
|
+
if (mqttServer && !mqttServer.close()) {
|
|
48
62
|
mqttServer.close();
|
|
49
63
|
}
|
|
50
64
|
}
|
|
@@ -32,13 +32,17 @@ const nonGenDevStatesDefs = {
|
|
|
32
32
|
],
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
*
|
|
37
|
+
* @param model
|
|
38
|
+
*/
|
|
35
39
|
function getStateDefinition(model) {
|
|
36
40
|
const stateDef = nonGenDevStatesDefs[model];
|
|
37
41
|
if (stateDef) {
|
|
38
42
|
return stateDef;
|
|
39
|
-
}
|
|
43
|
+
}
|
|
40
44
|
return [];
|
|
41
|
-
|
|
45
|
+
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
module.exports = {
|