homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.52

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.
Files changed (53) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +162 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +16 -3
  5. package/Readme.MD +8 -32
  6. package/config.schema.json +79 -79
  7. package/index.js +599 -358
  8. package/lib/AirPurifierAccessory.js +1 -1
  9. package/lib/BaseAccessory.js +54 -17
  10. package/lib/ConvectorAccessory.js +1 -1
  11. package/lib/CustomMultiOutletAccessory.js +2 -1
  12. package/lib/DoorbellAccessory.js +9 -16
  13. package/lib/GarageDoorAccessory.js +1 -1
  14. package/lib/IrrigationSystemAccessory.js +260 -120
  15. package/lib/MultiOutletAccessory.js +3 -2
  16. package/lib/OilDiffuserAccessory.js +10 -8
  17. package/lib/RGBTWLightAccessory.js +13 -11
  18. package/lib/RGBTWOutletAccessory.js +11 -9
  19. package/lib/SimpleBlindsAccessory.js +5 -5
  20. package/lib/SimpleDimmer2Accessory.js +1 -1
  21. package/lib/SimpleDimmerAccessory.js +10 -6
  22. package/lib/SimpleGarageDoorAccessory.js +122 -26
  23. package/lib/SimpleHeaterAccessory.js +4 -4
  24. package/lib/SimpleLightAccessory.js +1 -1
  25. package/lib/SwitchAccessory.js +3 -2
  26. package/lib/TWLightAccessory.js +2 -2
  27. package/lib/TuyaAccessory.js +75 -42
  28. package/lib/TuyaCloudApi.js +121 -8
  29. package/lib/TuyaCloudDevice.js +434 -31
  30. package/lib/TuyaCloudMessaging.js +34 -41
  31. package/lib/TuyaDevice.js +269 -0
  32. package/lib/TuyaDiscovery.js +25 -9
  33. package/lib/ValveAccessory.js +16 -12
  34. package/lib/VerticalBlindsWithTilt.js +27 -25
  35. package/lib/WledDimmerAccessory.js +46 -81
  36. package/package.json +5 -6
  37. package/scripts/cleanup-pr-tags.js +122 -0
  38. package/test/BaseAccessory.test.js +94 -15
  39. package/test/IrrigationSystemAccessory.test.js +293 -67
  40. package/test/MultiOutletAccessory.test.js +18 -3
  41. package/test/RGBTWLightAccessory.test.js +3 -3
  42. package/test/SimpleFanLightAccessory.test.js +3 -3
  43. package/test/SimpleGarageDoorAccessory.test.js +193 -19
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +564 -2
  47. package/test/TuyaCloudMessaging.test.js +24 -5
  48. package/test/TuyaDevice.test.js +266 -0
  49. package/test/getCategory.test.js +1 -0
  50. package/test/index.test.js +271 -0
  51. package/test/support/mocks.js +13 -0
  52. package/wiki/Supported-Device-Types.md +64 -34
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -80,7 +80,7 @@ class AirPurifierAccessory extends BaseAccessory {
80
80
  _addAirQualityService() {
81
81
  const {Service} = this.hap;
82
82
  const nameAirQuality = this.device.context.nameAirQuality || 'Air Quality';
83
- this.log.info('Adding air quality sensor: %s', nameAirQuality);
83
+ this.log.debug('Adding air quality sensor: %s', nameAirQuality);
84
84
  this.accessory.addService(Service.AirQualitySensor, nameAirQuality);
85
85
  }
86
86
 
@@ -24,7 +24,7 @@ class BaseAccessory {
24
24
  });
25
25
 
26
26
  this.device.once('change', () => {
27
- this.log(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
27
+ this.log.debug(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
28
28
 
29
29
  this._registerCharacteristics(this.device.state);
30
30
  });
@@ -65,8 +65,18 @@ class BaseAccessory {
65
65
  return typeof b === 'boolean' ? b : (typeof b === 'string' ? b.toLowerCase().trim() === 'true' : (typeof b === 'number' ? b !== 0 : df));
66
66
  }
67
67
 
68
+ // A HapStatusError to throw from a get/set handler when the value can't be
69
+ // served (device unreachable, or an unusable reading). HomeKit shows the
70
+ // accessory as "No Response". Preferred over a plain `throw new Error(...)`,
71
+ // which newer Homebridge logs a characteristic warning for before falling
72
+ // back to this same status code (SERVICE_COMMUNICATION_FAILURE, -70402).
73
+ _commError() {
74
+ const {HapStatusError, HAPStatus} = this.hap;
75
+ return new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
76
+ }
77
+
68
78
  getState(dp, callback) {
69
- if (!this.device.connected) return callback(true);
79
+ if (!this.device.connected) return callback(this._commError());
70
80
  const _callback = () => {
71
81
  if (Array.isArray(dp)) {
72
82
  const ret = {};
@@ -91,36 +101,37 @@ class BaseAccessory {
91
101
  //For devices like the DETA Smart Fan Controller Switch that by default set the speed as 3 the new code in the setMultiState function causes issues.
92
102
  if (!this.device.connected) {
93
103
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
94
- return callback && callback();
104
+ return callback && callback(this._commError());
95
105
  }
96
106
  const ret = this.device.update(dps);
97
- callback && callback(!ret);
107
+ callback && callback(ret === false ? this._commError() : null);
98
108
  }
99
109
 
100
110
  setMultiState(dps, callback) {
101
111
  if (!this.device.connected) {
102
112
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
103
- return callback && callback();
113
+ return callback && callback(this._commError());
104
114
  }
115
+ let failed = false;
105
116
  for (const dp in dps) {
106
117
  if (dps.hasOwnProperty(dp) && dps[dp] !== this.device.state[dp]){
107
- this.__ret = this.device.update({[dp.toString()] : dps[dp]});
118
+ if (this.device.update({[dp.toString()] : dps[dp]}) === false) failed = true;
108
119
  }
109
120
  }
110
- callback && callback(!this.__ret);
121
+ callback && callback(failed ? this._commError() : null);
111
122
  }
112
123
 
113
124
  getDividedState(dp, divisor, callback) {
114
125
  this.getState(dp, (err, data) => {
115
126
  if (err) return callback(err);
116
- if (!isFinite(data)) return callback(true);
127
+ if (!isFinite(data)) return callback(this._commError());
117
128
 
118
129
  callback(null, this._getDividedState(data, divisor));
119
130
  });
120
131
  }
121
132
 
122
133
  getStateAsync(dp) {
123
- if (!this.device.connected) throw new Error('Not connected');
134
+ if (!this.device.connected) throw this._commError();
124
135
  if (Array.isArray(dp)) {
125
136
  const ret = {};
126
137
  dp.forEach(p => { ret[p] = this.device.state[p]; });
@@ -133,29 +144,55 @@ class BaseAccessory {
133
144
  return this.setMultiStateAsync({[dp.toString()]: value});
134
145
  }
135
146
 
136
- setMultiStateAsync(dps) {
147
+ // Throws a "No Response" HapStatusError when the device is unreachable or the
148
+ // write is rejected, so a get/set handler that returns this promise surfaces
149
+ // the failure to HomeKit instead of silently pretending the write succeeded.
150
+ // `device.update` returns a boolean for LAN devices and an awaitable boolean
151
+ // for cloud devices (false == the command did not go through).
152
+ async setMultiStateAsync(dps) {
137
153
  if (!this.device.connected) {
138
154
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
139
- return;
155
+ throw this._commError();
140
156
  }
157
+ const writes = [];
141
158
  for (const dp in dps) {
142
159
  if (dps.hasOwnProperty(dp) && dps[dp] !== this.device.state[dp]) {
143
- this.device.update({[dp.toString()]: dps[dp]});
160
+ writes.push(this.device.update({[dp.toString()]: dps[dp]}));
144
161
  }
145
162
  }
163
+ const results = await Promise.all(writes);
164
+ if (results.some(ok => ok === false)) throw this._commError();
146
165
  }
147
166
 
148
- setMultiStateLegacyAsync(dps) {
167
+ async setMultiStateLegacyAsync(dps) {
149
168
  if (!this.device.connected) {
150
169
  this.log.debug(`${this.device.context.name}: skipping write, device not connected`);
151
- return;
170
+ throw this._commError();
152
171
  }
153
- this.device.update(dps);
172
+ if (await this.device.update(dps) === false) throw this._commError();
173
+ }
174
+
175
+ // Non-throwing write helpers for callers that are NOT inside a HomeKit
176
+ // get/set handler — setTimeout callbacks, multi-step command sequences, etc.
177
+ // The async helpers above reject on a disconnected/failed write so HomeKit
178
+ // can show "No Response"; in a detached/background context there is no
179
+ // HomeKit request to fail and an unhandled rejection would crash Homebridge,
180
+ // so these swallow the error (the helper has already logged it).
181
+ setStateInBackground(dp, value) {
182
+ this.setStateAsync(dp, value).catch(() => {});
183
+ }
184
+
185
+ setMultiStateInBackground(dps) {
186
+ this.setMultiStateAsync(dps).catch(() => {});
187
+ }
188
+
189
+ setMultiStateLegacyInBackground(dps) {
190
+ this.setMultiStateLegacyAsync(dps).catch(() => {});
154
191
  }
155
192
 
156
193
  getDividedStateAsync(dp, divisor) {
157
194
  const data = this.getStateAsync(dp);
158
- if (!isFinite(data)) throw new Error('Invalid state');
195
+ if (!isFinite(data)) throw this._commError();
159
196
  return this._getDividedState(data, divisor);
160
197
  }
161
198
 
@@ -167,7 +204,7 @@ class BaseAccessory {
167
204
  this.colorFunction = this.device.context.colorFunction && {HSB: 'HSB', HEXHSB: 'HEXHSB'}[this.device.context.colorFunction.toUpperCase()];
168
205
  if (!this.colorFunction && value) {
169
206
  this.colorFunction = {12: 'HSB', 14: 'HEXHSB'}[value.length] || 'Unknown';
170
- if (this.colorFunction) this.log.info(`Color format for ${this.device.context.name} (${this.device.context.version}) identified as ${this.colorFunction} (length: ${value.length}).`);
207
+ if (this.colorFunction) this.log.debug(`Color format for ${this.device.context.name} (${this.device.context.version}) identified as ${this.colorFunction} (length: ${value.length}).`);
171
208
  }
172
209
  if (!this.colorFunction) {
173
210
  this.colorFunction = 'Unknown';
@@ -55,7 +55,7 @@ class ConvectorAccessory extends BaseAccessory {
55
55
  service.getCharacteristic(Characteristic.TargetHeaterCoolerState)
56
56
  .setProps({ minValue: 1, maxValue: 1, validValues: [Characteristic.TargetHeaterCoolerState.HEAT] })
57
57
  .updateValue(this._getTargetHeaterCoolerState())
58
- .onGet(() => this._getTargetHeaterCoolerState())
58
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this._getTargetHeaterCoolerState(); })
59
59
  .onSet(() => this.setStateAsync(this.dpActive, true));
60
60
 
61
61
  const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature)
@@ -74,6 +74,7 @@ class CustomMultiOutletAccessory extends BaseAccessory {
74
74
  }
75
75
 
76
76
  setPower(dp, value) {
77
+ if (!this.device.connected) throw this._commError();
77
78
  if (!this._pendingPower) {
78
79
  this._pendingPower = {props: {}, resolvers: []};
79
80
  }
@@ -87,7 +88,7 @@ class CustomMultiOutletAccessory extends BaseAccessory {
87
88
  this._pendingPower.timer = setTimeout(() => {
88
89
  const {props, resolvers} = this._pendingPower;
89
90
  this._pendingPower = null;
90
- this.setMultiStateAsync(props);
91
+ this.setMultiStateInBackground(props);
91
92
  resolvers.forEach(r => r());
92
93
  }, 500);
93
94
  });
@@ -11,7 +11,8 @@ const LOCK_TIMEOUT = 5000;
11
11
 
12
12
  class DoorbellDelegate /*extends CameraStreamingDelegate*/ {
13
13
 
14
- constructor() {
14
+ constructor(log) {
15
+ this.log = log || console;
15
16
  this.cameraImage = undefined;
16
17
  }
17
18
 
@@ -27,7 +28,7 @@ class DoorbellDelegate /*extends CameraStreamingDelegate*/ {
27
28
  var body=Buffer.alloc(0);
28
29
 
29
30
  if (res.statusCode!==200) {
30
- return console.error('HTTP '+res.statusCode);
31
+ return me.log.error('Doorbell image fetch failed: HTTP %s', res.statusCode);
31
32
  }
32
33
 
33
34
  res.on('data', function(chunk) {
@@ -36,13 +37,12 @@ class DoorbellDelegate /*extends CameraStreamingDelegate*/ {
36
37
 
37
38
  res.on('end', function() {
38
39
  me.setCameraImage(body);
39
- console.log("Image stored");
40
+ me.log.debug('Doorbell image stored.');
40
41
  callback();
41
- console.log("Callback after image store complete");
42
42
  });
43
43
 
44
44
  res.on('error', function(err) {
45
- console.error(err);
45
+ me.log.error('Doorbell image fetch error:', err);
46
46
  });
47
47
  }
48
48
  );
@@ -96,12 +96,9 @@ class DoorbellAccessory extends BaseAccessory {
96
96
 
97
97
 
98
98
  this.device.on('change', (changes, state) => {
99
- //console.log(`Changes: ${JSON.stringify(changes)}, State: ${JSON.stringify(state)}`);
100
-
101
99
  if (changes.hasOwnProperty(DP_DOORBELL)) {
102
100
  const urlBase64 = changes[DP_DOORBELL];
103
101
  const strUrl = Buffer.from(urlBase64, 'base64');
104
- //console.log(`Image URL: ${strUrl}`);
105
102
 
106
103
  this.streamingDelegate.storeImage(strUrl.toString(), () => {
107
104
  service.updateCharacteristic(Characteristic.ProgrammableSwitchEvent, Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS);
@@ -109,18 +106,18 @@ class DoorbellAccessory extends BaseAccessory {
109
106
  }
110
107
 
111
108
  if (changes.hasOwnProperty(DP_DOOR)) {
112
- console.log("Door button pressed");
109
+ this.log.debug('Door button pressed');
113
110
  }
114
111
 
115
112
  if (changes.hasOwnProperty(DP_GATE)) {
116
- console.log("Gate button pressed");
113
+ this.log.debug('Gate button pressed');
117
114
  }
118
115
  });
119
116
  }
120
117
 
121
118
  configureCamera() {
122
- this.streamingDelegate = new DoorbellDelegate();
123
- console.log("created streaming delegate");
119
+ this.streamingDelegate = new DoorbellDelegate(this.log);
120
+ this.log.debug('Created camera streaming delegate.');
124
121
  const options = {
125
122
  cameraStreamCount: 1,
126
123
  delegate: this.streamingDelegate,
@@ -157,12 +154,8 @@ class DoorbellAccessory extends BaseAccessory {
157
154
  },
158
155
  };
159
156
 
160
- //console.log("created options");
161
157
  const cameraController = new this.hap.CameraController(options);
162
- //console.log("created controller");
163
-
164
158
  this.accessory.configureController(cameraController);
165
- //console.log("configured controller with accessory");
166
159
  }
167
160
 
168
161
  pushDoor(callback) {
@@ -105,7 +105,7 @@ class GarageDoorAccessory extends BaseAccessory {
105
105
  .onGet(() => this.getCurrentDoorState());
106
106
 
107
107
  this.device.on('change', changes => {
108
- this._alwaysLog('changed:' + JSON.stringify(changes));
108
+ this._debugLog('changed:' + JSON.stringify(changes));
109
109
 
110
110
  if (changes.hasOwnProperty(this.dpStatus)) {
111
111
  const newCurrentDoorState = this._getCurrentDoorState(changes[this.dpStatus]);