zigbee-herdsman-converters 15.0.42 → 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.
@@ -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
  },
@@ -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.light_onoff_brightness_colortemp_color({colorTempRange: [153, 454]}),
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
  ];
@@ -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/lidl.js CHANGED
@@ -2,7 +2,6 @@ const exposes = require('../lib/exposes');
2
2
  const fz = {...require('../converters/fromZigbee'), legacy: require('../lib/legacy').fromZigbee};
3
3
  const tz = require('../converters/toZigbee');
4
4
  const reporting = require('../lib/reporting');
5
- const extend = require('../lib/extend');
6
5
  const e = exposes.presets;
7
6
  const ea = exposes.access;
8
7
  const tuya = require('../lib/tuya');
@@ -372,7 +371,7 @@ module.exports = [
372
371
  model: 'HG06337',
373
372
  vendor: 'Lidl',
374
373
  description: 'Silvercrest smart plug (EU, CH, FR, BS, DK)',
375
- extend: extend.switch(),
374
+ extend: tuya.extend.switch(),
376
375
  configure: async (device, coordinatorEndpoint, logger) => {
377
376
  const endpoint = device.getEndpoint(11);
378
377
  await reporting.bind(endpoint, coordinatorEndpoint, ['genOnOff']);
@@ -493,8 +492,7 @@ module.exports = [
493
492
  model: 'HG06338',
494
493
  vendor: 'Lidl',
495
494
  description: 'Silvercrest 3 gang switch, with 4 USB (EU, FR, CZ, BS)',
496
- exposes: [e.switch().withEndpoint('l1'), e.switch().withEndpoint('l2'), e.switch().withEndpoint('l3')],
497
- extend: extend.switch(),
495
+ extend: tuya.extend.switch({endpoints: ['l1', 'l2', 'l3']}),
498
496
  meta: {multiEndpoint: true},
499
497
  configure: async (device, coordinatorEndpoint, logger) => {
500
498
  await tuya.configureMagicPacket(device, coordinatorEndpoint, logger);
@@ -778,7 +776,7 @@ module.exports = [
778
776
  model: 'HG06620',
779
777
  vendor: 'Lidl',
780
778
  description: 'Silvercrest garden spike with 2 sockets',
781
- extend: extend.switch(),
779
+ extend: tuya.extend.switch(),
782
780
  configure: async (device, coordinatorEndpoint, logger) => {
783
781
  const endpoint = device.getEndpoint(1);
784
782
  await reporting.bind(endpoint, coordinatorEndpoint, ['genOnOff']);
@@ -801,7 +799,7 @@ module.exports = [
801
799
  model: 'HG06619',
802
800
  vendor: 'Lidl',
803
801
  description: 'Silvercrest outdoor plug',
804
- extend: extend.switch(),
802
+ extend: tuya.extend.switch(),
805
803
  configure: async (device, coordinatorEndpoint, logger) => {
806
804
  const endpoint = device.getEndpoint(1);
807
805
  await reporting.bind(endpoint, coordinatorEndpoint, ['genOnOff']);
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
  ];
@@ -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.backlight_indicator_mode],
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);
@@ -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.backlight_indicator_mode],
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.backlight_indicator_mode],
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.valueConverterBasic.trueFalse2],
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);
@@ -3067,7 +3090,6 @@ module.exports = [
3067
3090
  exposes: [
3068
3091
  e.battery(),
3069
3092
  e.battery_voltage(),
3070
- e.device_temperature(),
3071
3093
  e.power_outage_count(false),
3072
3094
  exposes
3073
3095
  .enum('operation_mode', ea.SET, ['action_mode', 'scene_mode'])
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
- backlight_indicator_mode: {
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
- if (options.backlightModeLowMediumHigh || options.backlightModeOffNormalInverted || options.indicatorMode) {
1834
- if (options.backlightModeLowMediumHigh) {
1835
- fromZigbee.push(tuyaFz.backlight_mode_low_medium_high);
1836
- exposes.push(tuyaExposes.backlightModeLowMediumHigh());
1837
- }
1838
- if (options.backlightModeOffNormalInverted) {
1839
- fromZigbee.push(tuyaFz.backlight_mode_off_normal_inverted);
1840
- exposes.push(tuyaExposes.backlightModeOffNormalInverted());
1841
- }
1842
- if (options.indicatorMode) {
1843
- fromZigbee.push(tuyaFz.indicator_mode);
1844
- exposes.push(tuyaExposes.indicatorMode());
1845
- }
1846
- toZigbee.push(tuyaTz.backlight_indicator_mode);
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());
@@ -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
@@ -163,7 +163,7 @@ const numericAttributes2Payload = async (msg, meta, model, options, dataObject)
163
163
  }
164
164
  break;
165
165
  case '3':
166
- if (['WXCJKG11LM', 'WXCJKG12LM', 'WXCJKG13LM', 'MCCGQ14LM', 'GZCGQ01LM', 'JY-GZ-01AQ'].includes(model.model)) {
166
+ if (['WXCJKG11LM', 'WXCJKG12LM', 'WXCJKG13LM', 'MCCGQ14LM', 'GZCGQ01LM', 'JY-GZ-01AQ', 'CTP-R01'].includes(model.model)) {
167
167
  // The temperature value is constant 25 °C and does not change, so we ignore it
168
168
  // https://github.com/Koenkk/zigbee2mqtt/issues/11126
169
169
  // https://github.com/Koenkk/zigbee-herdsman-converters/pull/3585
@@ -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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zigbee-herdsman-converters",
3
- "version": "15.0.42",
3
+ "version": "15.0.44",
4
4
  "description": "Collection of device converters to be used with zigbee-herdsman",
5
5
  "main": "index.js",
6
6
  "files": [