zigbee-herdsman-converters 15.0.43 → 15.0.44
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/converters/fromZigbee.js +25 -0
- package/devices/essentialb.js +51 -2
- package/devices/ewelink.js +1 -1
- package/devices/livolo.js +37 -0
- package/devices/lonsonho.js +1 -1
- package/devices/namron.js +1 -0
- package/devices/third_reality.js +1 -0
- package/devices/tuya.js +4 -4
- package/devices/xiaomi.js +27 -4
- package/lib/tuya.js +47 -15
- package/lib/xiaomi.d.ts +11 -0
- package/lib/xiaomi.js +306 -0
- package/package.json +1 -1
package/converters/fromZigbee.js
CHANGED
|
@@ -3068,6 +3068,21 @@ const converters = {
|
|
|
3068
3068
|
}
|
|
3069
3069
|
},
|
|
3070
3070
|
},
|
|
3071
|
+
livolo_pir_state: {
|
|
3072
|
+
cluster: 'genPowerCfg',
|
|
3073
|
+
type: ['raw'],
|
|
3074
|
+
convert: (model, msg, publish, options, meta) => {
|
|
3075
|
+
const stateHeader = Buffer.from([122, 209]);
|
|
3076
|
+
if (msg.data.indexOf(stateHeader) === 0) {
|
|
3077
|
+
if (msg.data[10] === 7) {
|
|
3078
|
+
const status = msg.data[14];
|
|
3079
|
+
return {
|
|
3080
|
+
occupancy: status & 1 ? true : false,
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
},
|
|
3085
|
+
},
|
|
3071
3086
|
easycode_action: {
|
|
3072
3087
|
cluster: 'closuresDoorLock',
|
|
3073
3088
|
type: 'raw',
|
|
@@ -3126,6 +3141,11 @@ const converters = {
|
|
|
3126
3141
|
[122,209, 110,74,116,33,0,75,18,0, 5,1,5,0,2,11] left: 0, right: 0 (off)
|
|
3127
3142
|
[122,209, 110,74,116,33,0,75,18,0, 5,1,5,0,1,11] left: 1, right: 0 (left on)
|
|
3128
3143
|
[122,209, 110,74,116,33,0,75,18,0, 5,1,5,0,0,11] left: 0, right: 1 (right on)
|
|
3144
|
+
|
|
3145
|
+
pir sensor
|
|
3146
|
+
[124,210,21,216,128, 225,52,225,34,0,75,18,0, 19,13,0] after interview
|
|
3147
|
+
[122,209, 245,94,225,34,0,75,18,0, 7,1,7,1,1,11] occupancy: true
|
|
3148
|
+
[122,209, 245,94,225,34,0,75,18,0, 7,1,7,1,0,11] occupancy: false
|
|
3129
3149
|
*/
|
|
3130
3150
|
const malformedHeader = Buffer.from([0x7c, 0xd2, 0x15, 0xd8, 0x00]);
|
|
3131
3151
|
const infoHeader = Buffer.from([0x7c, 0xd2, 0x15, 0xd8, 0x80]);
|
|
@@ -3170,6 +3190,11 @@ const converters = {
|
|
|
3170
3190
|
meta.device.modelID = 'TI0001-cover';
|
|
3171
3191
|
meta.device.save();
|
|
3172
3192
|
}
|
|
3193
|
+
if (msg.data.includes(Buffer.from([19, 13, 0]), 13)) {
|
|
3194
|
+
if (meta.logger) meta.logger.debug('Detected Livolo Pir Sensor');
|
|
3195
|
+
meta.device.modelID = 'TI0001-pir';
|
|
3196
|
+
meta.device.save();
|
|
3197
|
+
}
|
|
3173
3198
|
}
|
|
3174
3199
|
},
|
|
3175
3200
|
},
|
package/devices/essentialb.js
CHANGED
|
@@ -8,11 +8,60 @@ module.exports = [
|
|
|
8
8
|
description: 'Smart LED bulb',
|
|
9
9
|
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 370]}),
|
|
10
10
|
},
|
|
11
|
+
{
|
|
12
|
+
zigbeeModel: ['EB-E14-FLA-CCT'],
|
|
13
|
+
model: 'EB-E14-FLA-CCT',
|
|
14
|
+
vendor: 'EssentielB',
|
|
15
|
+
description: 'E14 flame CCT light bulb',
|
|
16
|
+
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 454]}),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
zigbeeModel: ['EB-E27-A60-CCT-FC'],
|
|
20
|
+
model: 'EB-E27-A60-CCT-FC',
|
|
21
|
+
vendor: 'EssentielB',
|
|
22
|
+
description: 'E27 A60 CCT filament clear light bulb',
|
|
23
|
+
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 454]}),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
zigbeeModel: ['EB-E27-A60-CCT'],
|
|
27
|
+
model: 'EB-E27-A60-CCT',
|
|
28
|
+
vendor: 'EssentielB',
|
|
29
|
+
description: 'E27 A60 CCT light bulb',
|
|
30
|
+
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 454]}),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
zigbeeModel: ['EB-E27-A60-RGBW'],
|
|
34
|
+
model: 'EB-E27-A60-RGBW',
|
|
35
|
+
vendor: 'EssentielB',
|
|
36
|
+
description: 'E27 A60 RGBW light bulb',
|
|
37
|
+
extend: extend.light_onoff_brightness_colortemp_color({colorTempRange: [153, 370]}),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
zigbeeModel: ['EB-E27-G95-CCT-FV'],
|
|
41
|
+
model: 'EB-E27-G95-CCT-FV',
|
|
42
|
+
vendor: 'EssentielB',
|
|
43
|
+
description: 'Filament vintage globe light bulb',
|
|
44
|
+
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 454]}),
|
|
45
|
+
},
|
|
11
46
|
{
|
|
12
47
|
zigbeeModel: ['EB-E27-ST64-CCT-FV'],
|
|
13
48
|
model: 'EB-E27-ST64-CCT-FV',
|
|
14
49
|
vendor: 'EssentielB',
|
|
15
|
-
description: 'Filament vintage light bulb',
|
|
16
|
-
extend: extend.
|
|
50
|
+
description: 'Filament vintage edison light bulb',
|
|
51
|
+
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 454]}),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
zigbeeModel: ['EB-GU10-MR16-CCT'],
|
|
55
|
+
model: 'EB-GU10-MR16-CCT',
|
|
56
|
+
vendor: 'EssentielB',
|
|
57
|
+
description: 'GU10 MR16 CCT light bulb',
|
|
58
|
+
extend: extend.light_onoff_brightness_colortemp({colorTempRange: [153, 454]}),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
zigbeeModel: ['EB-GU10-MR16-RGBW'],
|
|
62
|
+
model: 'EB-GU10-MR16-RGBW',
|
|
63
|
+
vendor: 'EssentielB',
|
|
64
|
+
description: 'GU10 MR16 RGBW light bulb',
|
|
65
|
+
extend: extend.light_onoff_brightness_colortemp_color({colorTempRange: [153, 370]}),
|
|
17
66
|
},
|
|
18
67
|
];
|
package/devices/ewelink.js
CHANGED
|
@@ -22,7 +22,7 @@ module.exports = [
|
|
|
22
22
|
model: 'SA-003-Zigbee',
|
|
23
23
|
vendor: 'eWeLink',
|
|
24
24
|
description: 'Zigbee smart plug',
|
|
25
|
-
extend: extend.switch(),
|
|
25
|
+
extend: extend.switch({disablePowerOnBehavior: true}),
|
|
26
26
|
fromZigbee: [fz.on_off_skip_duplicate_transaction],
|
|
27
27
|
configure: async (device, coordinatorEndpoint, logger) => {
|
|
28
28
|
const endpoint = device.getEndpoint(1);
|
package/devices/livolo.js
CHANGED
|
@@ -246,4 +246,41 @@ module.exports = [
|
|
|
246
246
|
}
|
|
247
247
|
},
|
|
248
248
|
},
|
|
249
|
+
{
|
|
250
|
+
zigbeeModel: ['TI0001-pir'],
|
|
251
|
+
model: 'TI0001-pir',
|
|
252
|
+
description: 'Zigbee motion Sensor',
|
|
253
|
+
vendor: 'Livolo',
|
|
254
|
+
exposes: [
|
|
255
|
+
e.occupancy(),
|
|
256
|
+
],
|
|
257
|
+
fromZigbee: [fz.livolo_pir_state],
|
|
258
|
+
toZigbee: [],
|
|
259
|
+
configure: poll,
|
|
260
|
+
onEvent: async (type, data, device) => {
|
|
261
|
+
if (type === 'stop') {
|
|
262
|
+
clearInterval(globalStore.getValue(device, 'interval'));
|
|
263
|
+
globalStore.clearValue(device, 'interval');
|
|
264
|
+
}
|
|
265
|
+
if (['start', 'deviceAnnounce'].includes(type)) {
|
|
266
|
+
await poll(device);
|
|
267
|
+
if (!globalStore.hasValue(device, 'interval')) {
|
|
268
|
+
const interval = setInterval(async () => await poll(device), 10*1000);
|
|
269
|
+
globalStore.putValue(device, 'interval', interval);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (data.cluster === 'genPowerCfg' && data.type === 'raw') {
|
|
273
|
+
const dp = data.data[10];
|
|
274
|
+
if (data.data[0] === 0x7a && data.data[1] === 0xd1) {
|
|
275
|
+
const endpoint = device.getEndpoint(6);
|
|
276
|
+
if (dp === 0x01) {
|
|
277
|
+
const options = {manufacturerCode: 0x1ad2, disableDefaultResponse: true, disableResponse: true,
|
|
278
|
+
reservedBits: 3, direction: 1, writeUndiv: true};
|
|
279
|
+
const payload = {0x2002: {value: [0, 0, 0, 0, 0, 0, 0], type: 0x0e}};
|
|
280
|
+
await endpoint.readResponse('genPowerCfg', 0xe9, payload, options);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
},
|
|
249
286
|
];
|
package/devices/lonsonho.js
CHANGED
|
@@ -27,7 +27,7 @@ module.exports = [
|
|
|
27
27
|
description: 'Curtain switch',
|
|
28
28
|
fromZigbee: [fz.cover_position_tilt, tuya.fz.backlight_mode_low_medium_high, fz.tuya_cover_options],
|
|
29
29
|
toZigbee: [tz.cover_state, tz.cover_position_tilt, tz.tuya_cover_calibration, tz.tuya_cover_reversal,
|
|
30
|
-
tuya.tz.
|
|
30
|
+
tuya.tz.backlight_indicator_mode_1],
|
|
31
31
|
meta: {coverInverted: true},
|
|
32
32
|
exposes: [e.cover_position(), exposes.enum('moving', ea.STATE, ['UP', 'STOP', 'DOWN']),
|
|
33
33
|
exposes.binary('calibration', ea.ALL, 'ON', 'OFF'), exposes.binary('motor_reversal', ea.ALL, 'ON', 'OFF'),
|
package/devices/namron.js
CHANGED
|
@@ -116,6 +116,7 @@ module.exports = [
|
|
|
116
116
|
vendor: 'Namron',
|
|
117
117
|
description: 'ZigBee dimmer 400W',
|
|
118
118
|
extend: extend.light_onoff_brightness({noConfigure: true}),
|
|
119
|
+
ota: ota.zigbeeOTA,
|
|
119
120
|
configure: async (device, coordinatorEndpoint, logger) => {
|
|
120
121
|
await extend.light_onoff_brightness().configure(device, coordinatorEndpoint, logger);
|
|
121
122
|
const endpoint = device.getEndpoint(1);
|
package/devices/third_reality.js
CHANGED
|
@@ -165,6 +165,7 @@ module.exports = [
|
|
|
165
165
|
description: 'Zigbee / BLE smart plug with power',
|
|
166
166
|
fromZigbee: [fz.on_off, fz.electrical_measurement],
|
|
167
167
|
toZigbee: [tz.on_off],
|
|
168
|
+
ota: ota.zigbeeOTA,
|
|
168
169
|
exposes: [e.switch(), e.power(), e.current(), e.voltage()],
|
|
169
170
|
configure: async (device, coordinatorEndpoint, logger) => {
|
|
170
171
|
const endpoint = device.getEndpoint(1);
|
package/devices/tuya.js
CHANGED
|
@@ -1447,7 +1447,7 @@ module.exports = [
|
|
|
1447
1447
|
description: 'Curtain/blind switch',
|
|
1448
1448
|
fromZigbee: [fz.cover_position_tilt, tuya.fz.backlight_mode_low_medium_high, fz.tuya_cover_options],
|
|
1449
1449
|
toZigbee: [tz.cover_state, tz.cover_position_tilt, tz.tuya_cover_calibration, tz.tuya_cover_reversal,
|
|
1450
|
-
tuya.tz.
|
|
1450
|
+
tuya.tz.backlight_indicator_mode_1],
|
|
1451
1451
|
meta: {coverInverted: true},
|
|
1452
1452
|
whiteLabel: [{vendor: 'LoraTap', model: 'SC400'}],
|
|
1453
1453
|
exposes: [e.cover_position(), exposes.enum('moving', ea.STATE, ['UP', 'STOP', 'DOWN']),
|
|
@@ -2041,7 +2041,7 @@ module.exports = [
|
|
|
2041
2041
|
vendor: 'TuYa',
|
|
2042
2042
|
description: '3 gang switch module',
|
|
2043
2043
|
whiteLabel: [{vendor: 'OXT', model: 'SWTZ23'}],
|
|
2044
|
-
extend: tuya.extend.switch({switchType: true, endpoints: ['l1', 'l2', 'l3']}),
|
|
2044
|
+
extend: tuya.extend.switch({switchType: true, backlightModeOffOn: true, endpoints: ['l1', 'l2', 'l3']}),
|
|
2045
2045
|
endpoint: (device) => {
|
|
2046
2046
|
return {'l1': 1, 'l2': 2, 'l3': 3};
|
|
2047
2047
|
},
|
|
@@ -2517,7 +2517,7 @@ module.exports = [
|
|
|
2517
2517
|
vendor: 'TuYa',
|
|
2518
2518
|
fromZigbee: [fz.on_off, fz.electrical_measurement, fz.metering, fz.ignore_basic_report, tuya.fz.power_outage_memory,
|
|
2519
2519
|
tuya.fz.indicator_mode],
|
|
2520
|
-
toZigbee: [tz.on_off, tuya.tz.power_on_behavior, tuya.tz.
|
|
2520
|
+
toZigbee: [tz.on_off, tuya.tz.power_on_behavior, tuya.tz.backlight_indicator_mode_1],
|
|
2521
2521
|
configure: async (device, coordinatorEndpoint, logger) => {
|
|
2522
2522
|
const endpoint = device.getEndpoint(1);
|
|
2523
2523
|
await reporting.bind(endpoint, coordinatorEndpoint, ['genOnOff', 'haElectricalMeasurement', 'seMetering']);
|
|
@@ -3441,7 +3441,7 @@ module.exports = [
|
|
|
3441
3441
|
tuyaDatapoints: [
|
|
3442
3442
|
[1, 'presence', tuya.valueConverter.trueFalse1],
|
|
3443
3443
|
[2, 'radar_sensitivity', tuya.valueConverter.raw],
|
|
3444
|
-
[102, 'occupancy', tuya.
|
|
3444
|
+
[102, 'occupancy', tuya.valueConverter.trueFalse2],
|
|
3445
3445
|
[103, 'illuminance_lux', tuya.valueConverter.raw],
|
|
3446
3446
|
[105, 'tumble_switch', tuya.valueConverter.plus1],
|
|
3447
3447
|
[106, 'tumble_alarm_time', tuya.valueConverter.raw],
|
package/devices/xiaomi.js
CHANGED
|
@@ -134,11 +134,17 @@ const fzLocal = {
|
|
|
134
134
|
Object.assign(result, heartbeat);
|
|
135
135
|
break;
|
|
136
136
|
}
|
|
137
|
+
case 0x027d:
|
|
138
|
+
result['schedule'] = {1: 'ON', 0: 'OFF'}[value];
|
|
139
|
+
break;
|
|
140
|
+
case 0x0276: {
|
|
141
|
+
const schedule = trv.decodeSchedule(value);
|
|
142
|
+
result['schedule_settings'] = trv.stringifySchedule(schedule);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
137
145
|
case 0xfff2:
|
|
138
146
|
case 0x00ff: // 4e:27:49:bb:24:b6:30:dd:74:de:53:76:89:44:c4:81
|
|
139
|
-
case 0x0276: // 04:3e:01:e0:00:00:09:60:04:38:00:00:06:a4:05:64:00:00:08:98:81:e0:00:00:08:98
|
|
140
147
|
case 0x027c: // 0x00
|
|
141
|
-
case 0x027d: // 0x00
|
|
142
148
|
case 0x0280: // 0x00/0x01
|
|
143
149
|
meta.logger.debug(`zigbee-herdsman-converters:aqara_trv: Unhandled key ${key} = ${value}`);
|
|
144
150
|
break;
|
|
@@ -345,7 +351,7 @@ const fzLocal = {
|
|
|
345
351
|
const tzLocal = {
|
|
346
352
|
aqara_trv: {
|
|
347
353
|
key: ['system_mode', 'preset', 'window_detection', 'valve_detection', 'child_lock', 'away_preset_temperature',
|
|
348
|
-
'calibrate', 'sensor', 'sensor_temp', 'identify'],
|
|
354
|
+
'calibrate', 'sensor', 'sensor_temp', 'identify', 'schedule', 'schedule_settings'],
|
|
349
355
|
convertSet: async (entity, key, value, meta) => {
|
|
350
356
|
const aqaraHeader = (counter, params, action) => {
|
|
351
357
|
const header = [0xaa, 0x71, params.length + 3, 0x44, counter];
|
|
@@ -453,13 +459,25 @@ const tzLocal = {
|
|
|
453
459
|
case 'identify':
|
|
454
460
|
await entity.command('genIdentify', 'identify', {identifytime: 5}, {});
|
|
455
461
|
break;
|
|
462
|
+
case 'schedule':
|
|
463
|
+
await entity.write('aqaraOpple', {0x027d: {value: {'OFF': 0, 'ON': 1}[value], type: 0x20}},
|
|
464
|
+
{manufacturerCode: 0x115f});
|
|
465
|
+
break;
|
|
466
|
+
case 'schedule_settings': {
|
|
467
|
+
const schedule = trv.parseSchedule(value);
|
|
468
|
+
trv.validateSchedule(schedule);
|
|
469
|
+
const buffer = trv.encodeSchedule(schedule);
|
|
470
|
+
await entity.write('aqaraOpple', {0x0276: {value: buffer, type: 0x41}}, {manufacturerCode: 0x115f});
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
456
473
|
default: // Unknown key
|
|
457
474
|
meta.logger.warn(`zigbee-herdsman-converters:aqara_trv: Unhandled key ${key}`);
|
|
458
475
|
}
|
|
459
476
|
},
|
|
460
477
|
convertGet: async (entity, key, meta) => {
|
|
461
478
|
const dict = {'system_mode': 0x0271, 'preset': 0x0272, 'window_detection': 0x0273, 'valve_detection': 0x0274,
|
|
462
|
-
'child_lock': 0x0277, 'away_preset_temperature': 0x0279, 'calibrated': 0x027b, 'sensor': 0x027e
|
|
479
|
+
'child_lock': 0x0277, 'away_preset_temperature': 0x0279, 'calibrated': 0x027b, 'sensor': 0x027e,
|
|
480
|
+
'schedule': 0x027d, 'schedule_settings': 0x0276};
|
|
463
481
|
|
|
464
482
|
if (dict.hasOwnProperty(key)) {
|
|
465
483
|
await entity.read('aqaraOpple', [dict[key]], {manufacturerCode: 0x115F});
|
|
@@ -2993,6 +3011,11 @@ module.exports = [
|
|
|
2993
3011
|
e.battery(),
|
|
2994
3012
|
e.power_outage_count(),
|
|
2995
3013
|
e.device_temperature(),
|
|
3014
|
+
exposes.switch().withState('schedule', true,
|
|
3015
|
+
'When being ON, the thermostat will change its state based on your settings',
|
|
3016
|
+
ea.ALL, 'ON', 'OFF'),
|
|
3017
|
+
exposes.text('schedule_settings', ea.ALL)
|
|
3018
|
+
.withDescription('Smart schedule configuration (default: mon,tue,wed,thu,fri|8:00,24.0|18:00,17.0|23:00,22.0|8:00,22.0)'),
|
|
2996
3019
|
],
|
|
2997
3020
|
configure: async (device, coordinatorEndpoint, logger) => {
|
|
2998
3021
|
const endpoint = device.getEndpoint(1);
|
package/lib/tuya.js
CHANGED
|
@@ -1162,6 +1162,7 @@ const tuyaExposes = {
|
|
|
1162
1162
|
.withDescription('Intensity of the backlight'),
|
|
1163
1163
|
backlightModeOffNormalInverted: () => exposes.enum('backlight_mode', ea.ALL, ['off', 'normal', 'inverted'])
|
|
1164
1164
|
.withDescription('Mode of the backlight'),
|
|
1165
|
+
backlightModeOffOn: () => exposes.binary('backlight_mode', ea.ALL, 'ON', 'OFF').withDescription(`Mode of the backlight`),
|
|
1165
1166
|
indicatorMode: () => exposes.enum('indicator_mode', ea.ALL, ['off', 'off/on', 'on/off', 'on']).withDescription('LED indicator mode'),
|
|
1166
1167
|
indicatorModeNoneRelayPos: () => exposes.enum('indicator_mode', ea.ALL, ['none', 'relay', 'pos'])
|
|
1167
1168
|
.withDescription('Mode of the indicator light'),
|
|
@@ -1560,7 +1561,7 @@ const tuyaTz = {
|
|
|
1560
1561
|
await entity.read('manuSpecificTuya_3', ['switchType']);
|
|
1561
1562
|
},
|
|
1562
1563
|
},
|
|
1563
|
-
|
|
1564
|
+
backlight_indicator_mode_1: {
|
|
1564
1565
|
key: ['backlight_mode', 'indicator_mode'],
|
|
1565
1566
|
convertSet: async (entity, key, value, meta) => {
|
|
1566
1567
|
const lookup = key === 'backlight_mode' ? {'low': 0, 'medium': 1, 'high': 2, 'off': 0, 'normal': 1, 'inverted': 2} :
|
|
@@ -1574,6 +1575,19 @@ const tuyaTz = {
|
|
|
1574
1575
|
await entity.read('genOnOff', ['tuyaBacklightMode']);
|
|
1575
1576
|
},
|
|
1576
1577
|
},
|
|
1578
|
+
backlight_indicator_mode_2: {
|
|
1579
|
+
key: ['backlight_mode'],
|
|
1580
|
+
convertSet: async (entity, key, value, meta) => {
|
|
1581
|
+
const lookup = {'off': 0, 'on': 1};
|
|
1582
|
+
value = value.toLowerCase();
|
|
1583
|
+
utils.validateValue(value, Object.keys(lookup));
|
|
1584
|
+
await entity.write('genOnOff', {tuyaBacklightSwitch: lookup[value]});
|
|
1585
|
+
return {state: {[key]: value}};
|
|
1586
|
+
},
|
|
1587
|
+
convertGet: async (entity, key, meta) => {
|
|
1588
|
+
await entity.read('genOnOff', ['tuyaBacklightSwitch']);
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1577
1591
|
child_lock: {
|
|
1578
1592
|
key: ['child_lock'],
|
|
1579
1593
|
convertSet: async (entity, key, value, meta) => {
|
|
@@ -1727,6 +1741,17 @@ const tuyaFz = {
|
|
|
1727
1741
|
}
|
|
1728
1742
|
},
|
|
1729
1743
|
},
|
|
1744
|
+
backlight_mode_off_on: {
|
|
1745
|
+
cluster: 'genOnOff',
|
|
1746
|
+
type: ['attributeReport', 'readResponse'],
|
|
1747
|
+
convert: (model, msg, publish, options, meta) => {
|
|
1748
|
+
if (msg.data.hasOwnProperty('tuyaBacklightSwitch')) {
|
|
1749
|
+
const value = msg.data['tuyaBacklightSwitch'];
|
|
1750
|
+
const backlightLookup = {0: 'off', 1: 'on'};
|
|
1751
|
+
return {backlight_mode: backlightLookup[value]};
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
},
|
|
1730
1755
|
indicator_mode: {
|
|
1731
1756
|
cluster: 'genOnOff',
|
|
1732
1757
|
type: ['attributeReport', 'readResponse'],
|
|
@@ -1830,21 +1855,28 @@ const tuyaExtend = {
|
|
|
1830
1855
|
toZigbee.push(tuyaTz.switch_type);
|
|
1831
1856
|
exposes.push(tuyaExposes.switchType());
|
|
1832
1857
|
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1858
|
+
|
|
1859
|
+
if (options.backlightModeLowMediumHigh) {
|
|
1860
|
+
fromZigbee.push(tuyaFz.backlight_mode_low_medium_high);
|
|
1861
|
+
exposes.push(tuyaExposes.backlightModeLowMediumHigh());
|
|
1862
|
+
toZigbee.push(tuyaTz.backlight_indicator_mode_1);
|
|
1863
|
+
}
|
|
1864
|
+
if (options.backlightModeOffNormalInverted) {
|
|
1865
|
+
fromZigbee.push(tuyaFz.backlight_mode_off_normal_inverted);
|
|
1866
|
+
exposes.push(tuyaExposes.backlightModeOffNormalInverted());
|
|
1867
|
+
toZigbee.push(tuyaTz.backlight_indicator_mode_1);
|
|
1868
|
+
}
|
|
1869
|
+
if (options.indicatorMode) {
|
|
1870
|
+
fromZigbee.push(tuyaFz.indicator_mode);
|
|
1871
|
+
exposes.push(tuyaExposes.indicatorMode());
|
|
1872
|
+
toZigbee.push(tuyaTz.backlight_indicator_mode_1);
|
|
1847
1873
|
}
|
|
1874
|
+
if (options.backlightModeOffOn) {
|
|
1875
|
+
fromZigbee.push(tuyaFz.backlight_mode_off_on);
|
|
1876
|
+
exposes.push(tuyaExposes.backlightModeOffOn());
|
|
1877
|
+
toZigbee.push(tuyaTz.backlight_indicator_mode_2);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1848
1880
|
if (options.electricalMeasurements) {
|
|
1849
1881
|
fromZigbee.push(fz.electrical_measurement, fz.metering);
|
|
1850
1882
|
exposes.push(e.power(), e.current(), e.voltage().withAccess(ea.STATE), e.energy());
|
package/lib/xiaomi.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
declare type Day = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
|
|
2
|
+
|
|
3
|
+
export interface TrvScheduleConfigEvent {
|
|
4
|
+
time: number;
|
|
5
|
+
temperature: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TrvScheduleConfig {
|
|
9
|
+
days: Day[];
|
|
10
|
+
events: TrvScheduleConfigEvent[];
|
|
11
|
+
}
|
package/lib/xiaomi.js
CHANGED
|
@@ -891,6 +891,151 @@ const fp1 = {
|
|
|
891
891
|
},
|
|
892
892
|
};
|
|
893
893
|
|
|
894
|
+
/**
|
|
895
|
+
* @param {Buffer} buffer
|
|
896
|
+
* @param {number} offset
|
|
897
|
+
* @return {number}
|
|
898
|
+
*/
|
|
899
|
+
function readTemperature(buffer, offset) {
|
|
900
|
+
return buffer.readUint16BE(offset) / 100;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* @param {Buffer} buffer
|
|
905
|
+
* @param {number} offset
|
|
906
|
+
* @param {number} temperature
|
|
907
|
+
* @return {void}
|
|
908
|
+
*/
|
|
909
|
+
function writeTemperature(buffer, offset, temperature) {
|
|
910
|
+
buffer.writeUint16BE(temperature * 100, offset);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* @type {Day[]}
|
|
915
|
+
*/
|
|
916
|
+
const dayNames = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* @param {Buffer} buffer
|
|
920
|
+
* @param {number} offset
|
|
921
|
+
* @return {Day[]}
|
|
922
|
+
*/
|
|
923
|
+
function readDaySelection(buffer, offset) {
|
|
924
|
+
const selectedDays = [];
|
|
925
|
+
|
|
926
|
+
dayNames.forEach((day, index) => {
|
|
927
|
+
if ((buffer[offset] >> index + 1) % 2 !== 0) {
|
|
928
|
+
selectedDays.push(day);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
return selectedDays;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* @param {Day[]} selectedDays
|
|
937
|
+
*/
|
|
938
|
+
function validateDaySelection(selectedDays) {
|
|
939
|
+
selectedDays.filter((selectedDay) => !dayNames.includes(selectedDay)).forEach((invalidValue) => {
|
|
940
|
+
throw new Error(`The value "${invalidValue}" is not a valid day (available values: ${dayNames.join(', ')})`);
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* @param {Buffer} buffer
|
|
946
|
+
* @param {number} offset
|
|
947
|
+
* @param {Day[]} selectedDays
|
|
948
|
+
*/
|
|
949
|
+
function writeDaySelection(buffer, offset, selectedDays) {
|
|
950
|
+
validateDaySelection(selectedDays);
|
|
951
|
+
|
|
952
|
+
const bitMap = dayNames.reduce((repeat, dayName, index) => {
|
|
953
|
+
const isDaySelected = selectedDays.includes(dayName);
|
|
954
|
+
return repeat | isDaySelected << index + 1;
|
|
955
|
+
}, 0);
|
|
956
|
+
|
|
957
|
+
buffer.writeUInt8(bitMap, offset);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const timeNextDayFlag = 1 << 15;
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* @param {Buffer} buffer
|
|
964
|
+
* @param {number} offset
|
|
965
|
+
* @return {number}
|
|
966
|
+
*/
|
|
967
|
+
function readTime(buffer, offset) {
|
|
968
|
+
const minutesWithDayFlag = buffer.readUint16BE(offset);
|
|
969
|
+
return minutesWithDayFlag & ~timeNextDayFlag;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* @param {number} time
|
|
974
|
+
* @return {void}
|
|
975
|
+
*/
|
|
976
|
+
function validateTime(time) {
|
|
977
|
+
const isPositiveInteger = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
978
|
+
|
|
979
|
+
if (!isPositiveInteger(time)) {
|
|
980
|
+
throw new Error(`Time must be a positive integer number`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (time >= 24 * 60) {
|
|
984
|
+
throw new Error(`Time must be between 00:00 and 23:59`);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* @param {Buffer} buffer
|
|
990
|
+
* @param {number} offset
|
|
991
|
+
* @param {number} time
|
|
992
|
+
* @param {boolean} isNextDay
|
|
993
|
+
* @return {void}
|
|
994
|
+
*/
|
|
995
|
+
function writeTime(buffer, offset, time, isNextDay) {
|
|
996
|
+
validateTime(time);
|
|
997
|
+
|
|
998
|
+
let minutesWithDayFlag = time;
|
|
999
|
+
|
|
1000
|
+
if (isNextDay) {
|
|
1001
|
+
minutesWithDayFlag = minutesWithDayFlag | timeNextDayFlag;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
buffer.writeUint16BE(minutesWithDayFlag, offset);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Formats a number of minutes into a user-readable 24-hour time notation in the form hh:mm.
|
|
1009
|
+
* @param {number} timeMinutes
|
|
1010
|
+
* @return {string}
|
|
1011
|
+
*/
|
|
1012
|
+
function formatTime(timeMinutes) {
|
|
1013
|
+
const hours = Math.floor(timeMinutes / 60);
|
|
1014
|
+
const minutes = timeMinutes % 60;
|
|
1015
|
+
return `${hours}:${String(minutes).padStart(2, '0')}`;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Parses a 24-hour time notation string in the form hh:mm into a number of minutes.
|
|
1020
|
+
* @param {string} timeString
|
|
1021
|
+
* @return {number}
|
|
1022
|
+
*/
|
|
1023
|
+
function parseTime(timeString) {
|
|
1024
|
+
const parts = timeString.split(':');
|
|
1025
|
+
|
|
1026
|
+
if (parts.length !== 2) {
|
|
1027
|
+
throw new Error(`Cannot parse time string ${timeString}`);
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const hours = parseInt(parts[0]);
|
|
1031
|
+
const minutes = parseInt(parts[1]);
|
|
1032
|
+
|
|
1033
|
+
return hours * 60 + minutes;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const stringifiedScheduleFragmentSeparator = '|';
|
|
1037
|
+
const stringifiedScheduleValueSeparator = ',';
|
|
1038
|
+
|
|
894
1039
|
const trv = {
|
|
895
1040
|
decodeFirmwareVersionString(value) {
|
|
896
1041
|
// Add prefix to follow Aqara's versioning schema: https://www.aqara.com/en/version/radiator-thermostat-e1
|
|
@@ -964,6 +1109,167 @@ const trv = {
|
|
|
964
1109
|
|
|
965
1110
|
return payload;
|
|
966
1111
|
},
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Decode a Zigbee schedule configuration message into a schedule configuration object.
|
|
1115
|
+
* @param {Buffer} buffer
|
|
1116
|
+
* @return {TrvScheduleConfig}
|
|
1117
|
+
*/
|
|
1118
|
+
decodeSchedule(buffer) {
|
|
1119
|
+
return {
|
|
1120
|
+
days: readDaySelection(buffer, 1),
|
|
1121
|
+
events: [
|
|
1122
|
+
{time: readTime(buffer, 2), temperature: readTemperature(buffer, 6)},
|
|
1123
|
+
{time: readTime(buffer, 8), temperature: readTemperature(buffer, 12)},
|
|
1124
|
+
{time: readTime(buffer, 14), temperature: readTemperature(buffer, 18)},
|
|
1125
|
+
{time: readTime(buffer, 20), temperature: readTemperature(buffer, 24)},
|
|
1126
|
+
],
|
|
1127
|
+
};
|
|
1128
|
+
},
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* @param {TrvScheduleConfig} schedule
|
|
1132
|
+
* @return {void}
|
|
1133
|
+
*/
|
|
1134
|
+
validateSchedule(schedule) {
|
|
1135
|
+
const eventCount = 4;
|
|
1136
|
+
|
|
1137
|
+
if (typeof schedule !== 'object') {
|
|
1138
|
+
throw new Error('The provided value must be a schedule object');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (schedule.days == null || !Array.isArray(schedule.days) || schedule.days.length === 0) {
|
|
1142
|
+
throw new Error(`The schedule object must contain an array of days with at least one entry`);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
validateDaySelection(schedule.days);
|
|
1146
|
+
|
|
1147
|
+
if (schedule.events == null || !Array.isArray(schedule.events) || schedule.events.length !== eventCount) {
|
|
1148
|
+
throw new Error(`The schedule object must contain an array of ${eventCount} time/temperature events`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
schedule.events.forEach((event) => {
|
|
1152
|
+
if (typeof event !== 'object') {
|
|
1153
|
+
throw new Error('The provided time/temperature event must be an object');
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
validateTime(event.time);
|
|
1157
|
+
|
|
1158
|
+
if (typeof event.temperature !== 'number') {
|
|
1159
|
+
throw new Error(`The provided time/temperature entry must contain a numeric temperature`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (event.temperature < 5 || event.temperature > 30) {
|
|
1163
|
+
throw new Error(`The temperature must be between 5 and 30 °C`);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
// Calculate time durations between events
|
|
1168
|
+
const durations = schedule.events
|
|
1169
|
+
.map((entry, index, entries) => {
|
|
1170
|
+
if (index === 0) {
|
|
1171
|
+
return 0;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const time = entry.time;
|
|
1175
|
+
const fullDay = 24 * 60;
|
|
1176
|
+
const previousTime = entries[index - 1].time;
|
|
1177
|
+
const isNextDay = time < previousTime;
|
|
1178
|
+
|
|
1179
|
+
if (isNextDay) {
|
|
1180
|
+
return (fullDay - previousTime) + time;
|
|
1181
|
+
} else {
|
|
1182
|
+
return time - previousTime;
|
|
1183
|
+
}
|
|
1184
|
+
})
|
|
1185
|
+
// Remove first entry which is not a duration
|
|
1186
|
+
.slice(1);
|
|
1187
|
+
|
|
1188
|
+
const minDuration = 60;
|
|
1189
|
+
const hasInvalidDurations = durations.some((duration) => duration < minDuration);
|
|
1190
|
+
|
|
1191
|
+
if (hasInvalidDurations) {
|
|
1192
|
+
throw new Error(`The individual times must be at least 1 hour apart`);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const maxTotalDuration = 24 * 60;
|
|
1196
|
+
const totalDuration = durations.reduce((total, duration) => total + duration, 0);
|
|
1197
|
+
|
|
1198
|
+
if (totalDuration > maxTotalDuration) {
|
|
1199
|
+
// this implicitly also makes sure that there is at most one "next day" switch
|
|
1200
|
+
throw new Error(`The start and end times must be at most 24 hours apart`);
|
|
1201
|
+
}
|
|
1202
|
+
},
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Encodes a schedule object into Zigbee message format.
|
|
1206
|
+
* @param {TrvScheduleConfig} schedule
|
|
1207
|
+
* @return {Buffer}
|
|
1208
|
+
*/
|
|
1209
|
+
encodeSchedule(schedule) {
|
|
1210
|
+
const buffer = Buffer.alloc(26);
|
|
1211
|
+
buffer.writeUInt8(0x04);
|
|
1212
|
+
|
|
1213
|
+
writeDaySelection(buffer, 1, schedule.days);
|
|
1214
|
+
|
|
1215
|
+
schedule.events.forEach((event, index, events) => {
|
|
1216
|
+
const offset = 2 + index * 6;
|
|
1217
|
+
const isNextDay = index > 0 && event.time < events[index - 1].time;
|
|
1218
|
+
|
|
1219
|
+
writeTime(buffer, offset, event.time, isNextDay);
|
|
1220
|
+
writeTemperature(buffer, offset + 4, event.temperature);
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
return buffer;
|
|
1224
|
+
},
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Converts a schedule config object into a configuration string.
|
|
1228
|
+
* @param {TrvScheduleConfig} schedule
|
|
1229
|
+
* @return {string}
|
|
1230
|
+
*/
|
|
1231
|
+
stringifySchedule(schedule) {
|
|
1232
|
+
const stringifiedScheduleFragments = [schedule.days.join(stringifiedScheduleValueSeparator)];
|
|
1233
|
+
|
|
1234
|
+
for (const event of schedule.events) {
|
|
1235
|
+
const formattedTemperature = Number.isInteger(event.temperature) ?
|
|
1236
|
+
event.temperature.toFixed(1) : // add ".0" for usability to signal that floats can be used
|
|
1237
|
+
String(event.temperature);
|
|
1238
|
+
|
|
1239
|
+
const entryFragments = [formatTime(event.time), formattedTemperature];
|
|
1240
|
+
|
|
1241
|
+
stringifiedScheduleFragments.push(entryFragments.join(stringifiedScheduleValueSeparator));
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
return stringifiedScheduleFragments.join(stringifiedScheduleFragmentSeparator);
|
|
1245
|
+
},
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Parses a schedule configuration string into a configuration object.
|
|
1249
|
+
* @param {string} stringifiedSchedule
|
|
1250
|
+
* @return {TrvScheduleConfig}
|
|
1251
|
+
*/
|
|
1252
|
+
parseSchedule(stringifiedSchedule) {
|
|
1253
|
+
const schedule = {days: [], events: []};
|
|
1254
|
+
|
|
1255
|
+
if (!stringifiedSchedule) {
|
|
1256
|
+
return schedule;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const stringifiedScheduleFragments = stringifiedSchedule.split(stringifiedScheduleFragmentSeparator);
|
|
1260
|
+
|
|
1261
|
+
stringifiedScheduleFragments.forEach((fragment, index) => {
|
|
1262
|
+
if (index === 0) {
|
|
1263
|
+
schedule.days.push(...fragment.split(stringifiedScheduleValueSeparator));
|
|
1264
|
+
} else {
|
|
1265
|
+
const entryFragments = fragment.split(stringifiedScheduleValueSeparator);
|
|
1266
|
+
const entry = {time: parseTime(entryFragments[0]), temperature: parseFloat(entryFragments[1])};
|
|
1267
|
+
schedule.events.push(entry);
|
|
1268
|
+
}
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
return schedule;
|
|
1272
|
+
},
|
|
967
1273
|
};
|
|
968
1274
|
|
|
969
1275
|
module.exports = {
|