homebridge-tuya-plus 3.14.0-dev.4 → 3.14.0-dev.41

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 +63 -78
  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 +93 -114
  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 +78 -24
  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 +115 -7
  29. package/lib/TuyaCloudDevice.js +351 -28
  30. package/lib/TuyaCloudMessaging.js +28 -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 +122 -68
  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 +63 -9
  44. package/test/TuyaAccessory.protocol.test.js +76 -3
  45. package/test/TuyaCloudApi.test.js +110 -1
  46. package/test/TuyaCloudDevice.test.js +438 -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 +48 -30
  53. package/wiki/Tuya-Cloud-Setup.md +31 -108
@@ -203,8 +203,8 @@ class OilDiffuserAccessory extends BaseAccessory {
203
203
  }
204
204
 
205
205
  getBrightness() {
206
- if (this.device.state[this.dpMode] === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
207
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
206
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
207
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
208
208
  }
209
209
 
210
210
  setBrightness(value) {
@@ -218,8 +218,8 @@ class OilDiffuserAccessory extends BaseAccessory {
218
218
  }
219
219
 
220
220
  getColorTemperature() {
221
- if (this.device.state[this.dpMode] !== this.cmdWhite) return 0;
222
- return this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature]);
221
+ if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return 0;
222
+ return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
223
223
  }
224
224
 
225
225
  setColorTemperature(value) {
@@ -234,8 +234,8 @@ class OilDiffuserAccessory extends BaseAccessory {
234
234
  }
235
235
 
236
236
  getHue() {
237
- if (this.device.state[this.dpMode] === this.cmdWhite) return 0;
238
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h;
237
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return 0;
238
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
239
239
  }
240
240
 
241
241
  setHue(value) {
@@ -243,7 +243,7 @@ class OilDiffuserAccessory extends BaseAccessory {
243
243
  }
244
244
 
245
245
  getSaturation() {
246
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s;
246
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
247
247
  }
248
248
 
249
249
  setSaturation(value) {
@@ -251,6 +251,8 @@ class OilDiffuserAccessory extends BaseAccessory {
251
251
  }
252
252
 
253
253
  _setHueSaturation(prop) {
254
+ if (!this.device.connected) throw this._commError();
255
+
254
256
  if (!this._pendingHueSaturation) {
255
257
  this._pendingHueSaturation = {props: {}, resolvers: []};
256
258
  }
@@ -266,7 +268,7 @@ class OilDiffuserAccessory extends BaseAccessory {
266
268
  this._pendingHueSaturation = null;
267
269
 
268
270
  const newValue = this.convertColorFromHomeKitToTuya(props);
269
- this.setMultiStateAsync({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
271
+ this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
270
272
 
271
273
  resolvers.forEach(r => r());
272
274
  }, 500);
@@ -134,8 +134,8 @@ class RGBTWLightAccessory extends BaseAccessory {
134
134
  }
135
135
 
136
136
  getBrightness() {
137
- if (this.device.state[this.dpMode] === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
138
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
137
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
138
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
139
139
  }
140
140
 
141
141
  setBrightness(value) {
@@ -145,8 +145,8 @@ class RGBTWLightAccessory extends BaseAccessory {
145
145
 
146
146
  getColorTemperature() {
147
147
  this.log.debug(`getColorTemperature`);
148
- if (this.device.state[this.dpMode] !== this.cmdWhite) return this.minWhiteColor;
149
- return this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature]);
148
+ if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return this.minWhiteColor;
149
+ return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
150
150
  }
151
151
 
152
152
  setColorTemperature(value) {
@@ -165,10 +165,10 @@ class RGBTWLightAccessory extends BaseAccessory {
165
165
  }
166
166
 
167
167
  getHue() {
168
- if (this.device.state[this.dpMode] === this.cmdWhite) {
169
- return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])).h;
168
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) {
169
+ return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature))).h;
170
170
  }
171
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h;
171
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
172
172
  }
173
173
 
174
174
  setHue(value) {
@@ -176,10 +176,10 @@ class RGBTWLightAccessory extends BaseAccessory {
176
176
  }
177
177
 
178
178
  getSaturation() {
179
- if (this.device.state[this.dpMode] === this.cmdWhite) {
180
- return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature])).s;
179
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) {
180
+ return this.convertHomeKitColorTemperatureToHomeKitColor(this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature))).s;
181
181
  }
182
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s;
182
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
183
183
  }
184
184
 
185
185
  setSaturation(value) {
@@ -187,6 +187,8 @@ class RGBTWLightAccessory extends BaseAccessory {
187
187
  }
188
188
 
189
189
  _setHueSaturation(prop) {
190
+ if (!this.device.connected) throw this._commError();
191
+
190
192
  if (!this._pendingHueSaturation) {
191
193
  this._pendingHueSaturation = {props: {}, resolvers: []};
192
194
  }
@@ -208,7 +210,7 @@ class RGBTWLightAccessory extends BaseAccessory {
208
210
  const newValue = this.convertColorFromHomeKitToTuya(props);
209
211
 
210
212
  if (!(this.device.state[this.dpMode] === this.cmdWhite && isSham)) {
211
- this.setMultiStateAsync({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
213
+ this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
212
214
  }
213
215
  this.characteristicColorTemperature.updateValue(this.minWhiteColor);
214
216
 
@@ -196,8 +196,8 @@ class RGBTWOutletAccessory extends BaseAccessory {
196
196
  }
197
197
 
198
198
  getBrightness() {
199
- if (this.device.state[this.dpMode] === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
200
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).b;
199
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
200
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).b;
201
201
  }
202
202
 
203
203
  setBrightness(value) {
@@ -206,8 +206,8 @@ class RGBTWOutletAccessory extends BaseAccessory {
206
206
  }
207
207
 
208
208
  getColorTemperature() {
209
- if (this.device.state[this.dpMode] !== this.cmdWhite) return 0;
210
- return this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature]);
209
+ if (this.getStateAsync(this.dpMode) !== this.cmdWhite) return 0;
210
+ return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
211
211
  }
212
212
 
213
213
  setColorTemperature(value) {
@@ -221,8 +221,8 @@ class RGBTWOutletAccessory extends BaseAccessory {
221
221
  }
222
222
 
223
223
  getHue() {
224
- if (this.device.state[this.dpMode] === this.cmdWhite) return 0;
225
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).h;
224
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return 0;
225
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).h;
226
226
  }
227
227
 
228
228
  setHue(value) {
@@ -230,8 +230,8 @@ class RGBTWOutletAccessory extends BaseAccessory {
230
230
  }
231
231
 
232
232
  getSaturation() {
233
- if (this.device.state[this.dpMode] === this.cmdWhite) return 0;
234
- return this.convertColorFromTuyaToHomeKit(this.device.state[this.dpColor]).s;
233
+ if (this.getStateAsync(this.dpMode) === this.cmdWhite) return 0;
234
+ return this.convertColorFromTuyaToHomeKit(this.getStateAsync(this.dpColor)).s;
235
235
  }
236
236
 
237
237
  setSaturation(value) {
@@ -239,6 +239,8 @@ class RGBTWOutletAccessory extends BaseAccessory {
239
239
  }
240
240
 
241
241
  _setHueSaturation(prop) {
242
+ if (!this.device.connected) throw this._commError();
243
+
242
244
  if (!this._pendingHueSaturation) {
243
245
  this._pendingHueSaturation = {props: {}, resolvers: []};
244
246
  }
@@ -257,7 +259,7 @@ class RGBTWOutletAccessory extends BaseAccessory {
257
259
  const newValue = this.convertColorFromHomeKitToTuya(props);
258
260
 
259
261
  if (!(this.device.state[this.dpMode] === this.cmdWhite && isSham)) {
260
- this.setMultiStateAsync({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
262
+ this.setMultiStateInBackground({[this.dpMode]: this.cmdColor, [this.dpColor]: newValue});
261
263
  }
262
264
  this.characteristicColorTemperature.updateValue(0);
263
265
 
@@ -92,7 +92,7 @@ class SimpleBlindsAccessory extends BaseAccessory {
92
92
 
93
93
  const characteristicPositionState = service.getCharacteristic(Characteristic.PositionState)
94
94
  .updateValue(this._getPositionState())
95
- .onGet(() => this._getPositionState());
95
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this._getPositionState(); });
96
96
 
97
97
  this.device.on('change', changes => {
98
98
  this.log.debug('[TuyaAccessory] Blinds saw change to ' + changes[this.dpAction]);
@@ -176,7 +176,7 @@ class SimpleBlindsAccessory extends BaseAccessory {
176
176
  }
177
177
 
178
178
  getCurrentPosition() {
179
- return this._getCurrentPosition(this.device.state[this.dpAction]);
179
+ return this._getCurrentPosition(this.getStateAsync(this.dpAction));
180
180
  }
181
181
 
182
182
  _getCurrentPosition(dp) {
@@ -191,7 +191,7 @@ class SimpleBlindsAccessory extends BaseAccessory {
191
191
  }
192
192
 
193
193
  getTargetPosition() {
194
- return this._getTargetPosition(this.device.state[this.dpAction]);
194
+ return this._getTargetPosition(this.getStateAsync(this.dpAction));
195
195
  }
196
196
 
197
197
  _getTargetPosition(dp) {
@@ -234,7 +234,7 @@ class SimpleBlindsAccessory extends BaseAccessory {
234
234
  this.log.debug('[TuyaAccessory] Blinds will stop in ' + duration + 'ms');
235
235
  this.changeTimeout = setTimeout(() => {
236
236
  this.log.debug('[TuyaAccessory] Blinds asked to stop');
237
- this.setStateAsync(this.dpAction, this.cmdStop);
237
+ this.setStateInBackground(this.dpAction, this.cmdStop);
238
238
  }, duration);
239
239
  }
240
240
  return result;
@@ -246,7 +246,7 @@ class SimpleBlindsAccessory extends BaseAccessory {
246
246
  this.log.debug('[TuyaAccessory] Blinds will stop in ' + duration + 'ms');
247
247
  this.changeTimeout = setTimeout(() => {
248
248
  this.log.debug('[TuyaAccessory] Blinds asked to stop');
249
- this.setStateAsync(this.dpAction, this.cmdStop);
249
+ this.setStateInBackground(this.dpAction, this.cmdStop);
250
250
  }, duration);
251
251
  }
252
252
  return result;
@@ -43,7 +43,7 @@ class SimpleDimmer2Accessory extends BaseAccessory {
43
43
  }
44
44
 
45
45
  getBrightness() {
46
- return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
46
+ return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
47
47
  }
48
48
 
49
49
  setBrightness(value) {
@@ -25,25 +25,29 @@ class SimpleDimmerAccessory extends BaseAccessory {
25
25
  this.dpPower = this._getCustomDP(this.device.context.dpPower) || '1';
26
26
  this.dpBrightness = this._getCustomDP(this.device.context.dpBrightness) || this._getCustomDP(this.device.context.dp) || '2';
27
27
 
28
- const characteristicOn = service.getCharacteristic(Characteristic.On)
28
+ this._characteristicOn = service.getCharacteristic(Characteristic.On)
29
29
  .updateValue(dps[this.dpPower])
30
30
  .onGet(() => this.getStateAsync(this.dpPower))
31
31
  .onSet(value => this.setStateAsync(this.dpPower, value));
32
32
 
33
- const characteristicBrightness = service.getCharacteristic(Characteristic.Brightness)
33
+ this._characteristicBrightness = service.getCharacteristic(Characteristic.Brightness)
34
34
  .updateValue(this.convertBrightnessFromTuyaToHomeKit(dps[this.dpBrightness]))
35
35
  .onGet(() => this.getBrightness())
36
36
  .onSet(value => this.setBrightness(value));
37
37
 
38
+ this._registerChangeListener();
39
+ }
40
+
41
+ _registerChangeListener() {
38
42
  this.device.on('change', changes => {
39
- if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]);
40
- if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(characteristicBrightness.value) !== changes[this.dpBrightness])
41
- characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
43
+ if (changes.hasOwnProperty(this.dpPower) && this._characteristicOn.value !== changes[this.dpPower]) this._characteristicOn.updateValue(changes[this.dpPower]);
44
+ if (changes.hasOwnProperty(this.dpBrightness) && this.convertBrightnessFromHomeKitToTuya(this._characteristicBrightness.value) !== changes[this.dpBrightness])
45
+ this._characteristicBrightness.updateValue(this.convertBrightnessFromTuyaToHomeKit(changes[this.dpBrightness]));
42
46
  });
43
47
  }
44
48
 
45
49
  getBrightness() {
46
- return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
50
+ return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
47
51
  }
48
52
 
49
53
  setBrightness(value) {
@@ -98,6 +98,12 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
98
98
  // and the close that trails a stop in the stop-before-close path.
99
99
  this.partialStopTimer = null;
100
100
  this.pendingCloseTimer = null;
101
+ // A partial open that has fired its open but is still waiting for the
102
+ // controller to report it's moving before arming the auto-stop.
103
+ this.partialPending = false;
104
+ // Bumped whenever a partial open starts or is superseded/cancelled, so a
105
+ // stale auto-stop (and its re-sends) from an earlier flow bails out.
106
+ this.partialGeneration = 0;
101
107
 
102
108
  // Seed the initial state from whatever the device has already reported.
103
109
  // If it hasn't reported yet, fall back to the persisted target, then to
@@ -117,25 +123,25 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
117
123
 
118
124
  this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
119
125
  .updateValue(initialTarget)
120
- .onGet(() => this.accessory.context.cachedTargetDoorState)
126
+ .onGet(() => this._reportDoorState(this.accessory.context.cachedTargetDoorState))
121
127
  .onSet(value => this.setTargetDoorState(value));
122
128
 
123
129
  this.characteristicCurrentDoorState = service.getCharacteristic(Characteristic.CurrentDoorState)
124
130
  .updateValue(this.currentDoorState)
125
- .onGet(() => this.currentDoorState);
131
+ .onGet(() => this._reportDoorState(this.currentDoorState));
126
132
 
127
133
  // The controller exposes limit switches (l_open/l_close) but they don't
128
134
  // work in practice, and there's no obstruction sensor wired up, so this
129
135
  // is always reported clear.
130
136
  service.getCharacteristic(Characteristic.ObstructionDetected)
131
137
  .updateValue(false)
132
- .onGet(() => false);
138
+ .onGet(() => this._reportDoorState(false));
133
139
 
134
140
  const partialSwitch = this.accessory.getServiceById(Service.Switch, 'partialOpen');
135
141
  if (partialSwitch) {
136
142
  const onChar = partialSwitch.getCharacteristic(Characteristic.On)
137
143
  .updateValue(isOpen)
138
- .onGet(() => this.currentDoorState === Characteristic.CurrentDoorState.OPEN)
144
+ .onGet(() => this._reportDoorState(this.currentDoorState === Characteristic.CurrentDoorState.OPEN))
139
145
  .onSet(value => {
140
146
  // Stateful: the switch mirrors CurrentDoorState (ON while
141
147
  // the gate is open in HomeKit's view — see
@@ -197,7 +203,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
197
203
  if (this.pendingCloseTimer) return;
198
204
  const isOpen = this._mapDpState(changes[this.dpState]);
199
205
  if (isOpen === null) {
200
- this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
206
+ this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
201
207
  return;
202
208
  }
203
209
  this._applyReportedState(isOpen);
@@ -227,9 +233,26 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
227
233
  if (this.characteristicPartialOpen) {
228
234
  this.characteristicPartialOpen.updateValue(isOpen);
229
235
  }
236
+
237
+ // A partial open fires its open and then waits for the controller to
238
+ // confirm the gate is actually moving before starting the auto-stop
239
+ // countdown — see _handlePartialOpen.
240
+ if (this.partialPending && isOpen) this._armPartialStop();
241
+ }
242
+
243
+ // onGet helper: a cached/optimistic door value is fine to report while the
244
+ // gate is reachable, but when it's offline HomeKit must show "No Response"
245
+ // instead of a stale state that makes the gate look online and accept taps.
246
+ _reportDoorState(value) {
247
+ if (!this.device.connected) throw this._commError();
248
+ return value;
230
249
  }
231
250
 
232
251
  setTargetDoorState(value) {
252
+ // Surface "No Response" for a tap on an unreachable gate instead of
253
+ // silently dropping the command (the write helpers would otherwise just
254
+ // log and skip, leaving HomeKit to believe the open/close succeeded).
255
+ if (!this.device.connected) throw this._commError();
233
256
  // A direct open/close (the GarageDoorOpener target, a Force switch, or
234
257
  // the partial switch tapped OFF) is manual control: cancel any pending
235
258
  // partial-open auto-stop so it can't halt this movement part-way.
@@ -254,8 +277,8 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
254
277
  // own, even mid-close. Abandon any pending stop-before-close.
255
278
  _sendOpen() {
256
279
  this._cancelPendingClose();
257
- this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
258
- this.setMultiStateLegacyAsync({[this.dpOpen]: true});
280
+ this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
281
+ this.setMultiStateLegacyInBackground({[this.dpOpen]: true});
259
282
  }
260
283
 
261
284
  // Close is ignored while the gate is actively moving, so fire it directly
@@ -266,20 +289,20 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
266
289
  if (this.pendingCloseTimer) {
267
290
  // A stop-before-close is already running — don't restart it (and
268
291
  // push the close out) on a duplicate or retransmitted request.
269
- this.log.info(`[SimpleGarageDoor] ${name}: close ignored — a stop-before-close is already running`);
292
+ this.log.debug(`[SimpleGarageDoor] ${name}: close ignored — a stop-before-close is already running`);
270
293
  return;
271
294
  }
272
295
  if (this._isStopped()) {
273
- this.log.info(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) — gate already stopped`);
274
- this.setMultiStateLegacyAsync({[this.dpClose]: true});
296
+ this.log.debug(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) — gate already stopped`);
297
+ this.setMultiStateLegacyInBackground({[this.dpClose]: true});
275
298
  return;
276
299
  }
277
- this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — stop (dp${this.dpStop}) now, close (dp${this.dpClose}) in ${this.stopBeforeCloseMs}ms`);
278
- this.setMultiStateLegacyAsync({[this.dpStop]: true});
300
+ this.log.debug(`[SimpleGarageDoor] ${name}: stop-before-close — stop (dp${this.dpStop}) now, close (dp${this.dpClose}) in ${this.stopBeforeCloseMs}ms`);
301
+ this.setMultiStateLegacyInBackground({[this.dpStop]: true});
279
302
  this.pendingCloseTimer = setTimeout(() => {
280
303
  this.pendingCloseTimer = null;
281
- this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
282
- this.setMultiStateLegacyAsync({[this.dpClose]: true});
304
+ this.log.debug(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
305
+ this.setMultiStateLegacyInBackground({[this.dpClose]: true});
283
306
  }, this.stopBeforeCloseMs);
284
307
  }
285
308
 
@@ -298,32 +321,63 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
298
321
  }
299
322
  }
300
323
 
301
- // Partial open: fire the open action, then after partialOpenMs fire a stop
302
- // so the gate ends up parked part-way. Anchored to the button press (the
303
- // controller starts moving and reports state within ~1s), which is all the
304
- // user asked for.
324
+ // Partial open: fire the open action, wait partialOpenMs, then fire a stop
325
+ // so the gate ends up parked part-way.
326
+ //
327
+ // The auto-stop is anchored to the controller *reporting the gate is moving*
328
+ // (the status DP flipping to OPEN), not to the button press: the open
329
+ // command takes time to reach the gate, and a stop fired before the gate is
330
+ // actually moving lands as a no-op the controller drops — letting the gate
331
+ // run all the way open. If the gate already reads open/opening, no fresh
332
+ // report is coming, so we arm straight away.
305
333
  _handlePartialOpen() {
306
334
  const {Characteristic} = this.hap;
307
335
  const name = this.device.context.name;
336
+ if (!this.device.connected) throw this._commError();
308
337
  if (!this.partialOpenMs) return;
309
- if (this.partialStopTimer) {
310
- // Re-entrant press while a partial is already armed (e.g. a
338
+ if (this.partialPending || this.partialStopTimer) {
339
+ // Re-entrant press while a partial is already in progress (e.g. a
311
340
  // HomeKit/iOS WRITE retransmit) — ignore so the stop isn't pushed
312
341
  // out, which would let the gate run further than intended.
313
- this.log.info(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
342
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
314
343
  return;
315
344
  }
316
345
 
317
- this.log.info(`[SimpleGarageDoor] ${name}: partial open — opening, will stop in ${this.partialOpenMs}ms`);
346
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial open — opening, will stop ${this.partialOpenMs}ms after the gate starts moving`);
347
+ this.partialGeneration++;
348
+ this.partialPending = true;
318
349
  this._applyTarget(Characteristic.TargetDoorState.OPEN);
350
+
351
+ const reported = this.device.state ? this.device.state[this.dpState] : undefined;
352
+ if (this._mapDpState(reported) === true) this._armPartialStop();
353
+ }
354
+
355
+ _armPartialStop() {
356
+ if (!this.partialPending || this.partialStopTimer) return;
357
+ this.partialPending = false;
358
+ const name = this.device.context.name;
359
+ const generation = this.partialGeneration;
319
360
  this.partialStopTimer = setTimeout(() => {
320
361
  this.partialStopTimer = null;
321
- this.log.info(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
322
- this.setMultiStateLegacyAsync({[this.dpStop]: true});
362
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
363
+ this._sendPartialStop(generation, 1);
323
364
  }, this.partialOpenMs);
324
365
  }
325
366
 
367
+ // The controller occasionally drops a lone write (its command queue can
368
+ // coalesce back-to-back writes, and brief Wi-Fi blips lose individual
369
+ // packets), and a dropped stop here is exactly what lets a partial open run
370
+ // all the way. Re-send it a few times; a stop on an already-parked gate is a
371
+ // harmless no-op. Bails out if the flow was superseded mid-way.
372
+ _sendPartialStop(generation, attempt) {
373
+ if (generation !== this.partialGeneration) return;
374
+ this.setMultiStateLegacyInBackground({[this.dpStop]: true});
375
+ if (attempt < 3) setTimeout(() => this._sendPartialStop(generation, attempt + 1), 300);
376
+ }
377
+
326
378
  _cancelPartialStop() {
379
+ this.partialPending = false;
380
+ this.partialGeneration++;
327
381
  if (this.partialStopTimer) {
328
382
  clearTimeout(this.partialStopTimer);
329
383
  this.partialStopTimer = null;
@@ -45,7 +45,7 @@ class SimpleHeaterAccessory extends BaseAccessory {
45
45
 
46
46
  const characteristicCurrentHeaterCoolerState = service.getCharacteristic(Characteristic.CurrentHeaterCoolerState)
47
47
  .updateValue(this._getCurrentHeaterCoolerState(dps))
48
- .onGet(() => this.currentHeaterCoolerState);
48
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this.currentHeaterCoolerState; });
49
49
 
50
50
  service.getCharacteristic(Characteristic.TargetHeaterCoolerState)
51
51
  .setProps({
@@ -54,7 +54,7 @@ class SimpleHeaterAccessory extends BaseAccessory {
54
54
  validValues: [Characteristic.TargetHeaterCoolerState.HEAT]
55
55
  })
56
56
  .updateValue(this._getTargetHeaterCoolerState())
57
- .onGet(() => this._getTargetHeaterCoolerState())
57
+ .onGet(() => { if (!this.device.connected) throw this._commError(); return this._getTargetHeaterCoolerState(); })
58
58
  .onSet(() => this.setStateAsync(this.dpActive, true));
59
59
 
60
60
  const characteristicCurrentTemperature = service.getCharacteristic(Characteristic.CurrentTemperature)
@@ -90,7 +90,7 @@ class SimpleHeaterAccessory extends BaseAccessory {
90
90
  characteristicCurrentTemperature.updateValue(this._getDividedState(changes[this.dpCurrentTemperature], this.temperatureDivisor, this.currentTemperatureOffset));
91
91
 
92
92
  characteristicCurrentHeaterCoolerState.updateValue(this._getCurrentHeaterCoolerState(state));
93
- this.log.info('SimpleHeater changed: ' + JSON.stringify(state));
93
+ this.log.debug('SimpleHeater changed: ' + JSON.stringify(state));
94
94
  });
95
95
  }
96
96
 
@@ -147,7 +147,7 @@ class SimpleHeaterAccessory extends BaseAccessory {
147
147
 
148
148
  _getCurrentTempAsync() {
149
149
  const data = this.getStateAsync(this.dpCurrentTemperature);
150
- if (!isFinite(data)) throw new Error('Invalid state');
150
+ if (!isFinite(data)) throw this._commError();
151
151
  return this._getDividedState(data, this.temperatureDivisor, this.currentTemperatureOffset);
152
152
  }
153
153
  }
@@ -31,7 +31,7 @@ class SimpleLightAccessory extends BaseAccessory {
31
31
 
32
32
  this.device.on('change', (changes, state) => {
33
33
  if (changes.hasOwnProperty(this.dpPower) && characteristicOn.value !== changes[this.dpPower]) characteristicOn.updateValue(changes[this.dpPower]);
34
- this.log.info('SimpleLight changed: ' + JSON.stringify(state));
34
+ this.log.debug('SimpleLight changed: ' + JSON.stringify(state));
35
35
  });
36
36
  }
37
37
  }
@@ -34,7 +34,7 @@ class SwitchAccessory extends BaseAccessory {
34
34
  this.accessory.services
35
35
  .filter(service => service.UUID === Service.Switch.UUID && !_validServices.includes(service))
36
36
  .forEach(service => {
37
- this.log.info('Removing', service.displayName);
37
+ this.log.debug('Removing', service.displayName);
38
38
  this.accessory.removeService(service);
39
39
  });
40
40
  }
@@ -69,6 +69,7 @@ class SwitchAccessory extends BaseAccessory {
69
69
  }
70
70
 
71
71
  setPower(dp, value) {
72
+ if (!this.device.connected) throw this._commError();
72
73
  if (!this._pendingPower) {
73
74
  this._pendingPower = {props: {}, resolvers: []};
74
75
  }
@@ -82,7 +83,7 @@ class SwitchAccessory extends BaseAccessory {
82
83
  this._pendingPower.timer = setTimeout(() => {
83
84
  const {props, resolvers} = this._pendingPower;
84
85
  this._pendingPower = null;
85
- this.setMultiStateAsync(props);
86
+ this.setMultiStateInBackground(props);
86
87
  resolvers.forEach(r => r());
87
88
  }, 500);
88
89
  });
@@ -72,7 +72,7 @@ class TWLightAccessory extends BaseAccessory {
72
72
  }
73
73
 
74
74
  getBrightness() {
75
- return this.convertBrightnessFromTuyaToHomeKit(this.device.state[this.dpBrightness]);
75
+ return this.convertBrightnessFromTuyaToHomeKit(this.getStateAsync(this.dpBrightness));
76
76
  }
77
77
 
78
78
  setBrightness(value) {
@@ -80,7 +80,7 @@ class TWLightAccessory extends BaseAccessory {
80
80
  }
81
81
 
82
82
  getColorTemperature() {
83
- return this.convertColorTemperatureFromTuyaToHomeKit(this.device.state[this.dpColorTemperature]);
83
+ return this.convertColorTemperatureFromTuyaToHomeKit(this.getStateAsync(this.dpColorTemperature));
84
84
  }
85
85
 
86
86
  setColorTemperature(value) {