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
@@ -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
@@ -114,28 +120,33 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
114
120
  ? Characteristic.TargetDoorState.OPEN
115
121
  : Characteristic.TargetDoorState.CLOSED;
116
122
  this.accessory.context.cachedTargetDoorState = initialTarget;
123
+ // The level-triggered dispatch baseline (see setTargetDoorState). Held
124
+ // separately from cachedTargetDoorState because that one mirrors every
125
+ // reported value for HomeKit's benefit — including the transient "stopped"
126
+ // our own stop-before-close produces — which must not move this baseline.
127
+ this._committedTarget = initialTarget;
117
128
 
118
129
  this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
119
130
  .updateValue(initialTarget)
120
- .onGet(() => this.accessory.context.cachedTargetDoorState)
131
+ .onGet(() => this._reportDoorState(this.accessory.context.cachedTargetDoorState))
121
132
  .onSet(value => this.setTargetDoorState(value));
122
133
 
123
134
  this.characteristicCurrentDoorState = service.getCharacteristic(Characteristic.CurrentDoorState)
124
135
  .updateValue(this.currentDoorState)
125
- .onGet(() => this.currentDoorState);
136
+ .onGet(() => this._reportDoorState(this.currentDoorState));
126
137
 
127
138
  // The controller exposes limit switches (l_open/l_close) but they don't
128
139
  // work in practice, and there's no obstruction sensor wired up, so this
129
140
  // is always reported clear.
130
141
  service.getCharacteristic(Characteristic.ObstructionDetected)
131
142
  .updateValue(false)
132
- .onGet(() => false);
143
+ .onGet(() => this._reportDoorState(false));
133
144
 
134
145
  const partialSwitch = this.accessory.getServiceById(Service.Switch, 'partialOpen');
135
146
  if (partialSwitch) {
136
147
  const onChar = partialSwitch.getCharacteristic(Characteristic.On)
137
148
  .updateValue(isOpen)
138
- .onGet(() => this.currentDoorState === Characteristic.CurrentDoorState.OPEN)
149
+ .onGet(() => this._reportDoorState(this.currentDoorState === Characteristic.CurrentDoorState.OPEN))
139
150
  .onSet(value => {
140
151
  // Stateful: the switch mirrors CurrentDoorState (ON while
141
152
  // the gate is open in HomeKit's view — see
@@ -195,11 +206,24 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
195
206
  // (=OPEN), which would fight the close we're about to send. Ignore
196
207
  // status reports until the close has gone out.
197
208
  if (this.pendingCloseTimer) return;
198
- const isOpen = this._mapDpState(changes[this.dpState]);
209
+ const raw = typeof changes[this.dpState] === 'string' ? parseInt(changes[this.dpState], 10) : changes[this.dpState];
210
+ const isOpen = this._mapDpState(raw);
199
211
  if (isOpen === null) {
200
- this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
212
+ this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
201
213
  return;
202
214
  }
215
+ // Keep the level-triggered dispatch baseline honest with the gate's real
216
+ // position, but only from a DEFINITIVE report — 12 (opening/open) or 13
217
+ // (closing/closed). A bare 11 (stopped) is skipped: it is exactly what our
218
+ // own stop-before-close pulse makes the controller report, and over the LAN
219
+ // that report can land just after the stop→close window. Letting it flip
220
+ // the committed target back to OPEN is what let HomeKit's repeat close fire
221
+ // a second stop-before-close into the already-closing gate. A genuine
222
+ // external open always passes through 12 first, so nothing is missed.
223
+ if (raw === STATE_OPENING_OR_OPEN || raw === STATE_CLOSING_OR_CLOSED) {
224
+ const {Characteristic} = this.hap;
225
+ this._committedTarget = isOpen ? Characteristic.TargetDoorState.OPEN : Characteristic.TargetDoorState.CLOSED;
226
+ }
203
227
  this._applyReportedState(isOpen);
204
228
  }
205
229
 
@@ -227,13 +251,53 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
227
251
  if (this.characteristicPartialOpen) {
228
252
  this.characteristicPartialOpen.updateValue(isOpen);
229
253
  }
254
+
255
+ // A partial open fires its open and then waits for the controller to
256
+ // confirm the gate is actually moving before starting the auto-stop
257
+ // countdown — see _handlePartialOpen.
258
+ if (this.partialPending && isOpen) this._armPartialStop();
259
+ }
260
+
261
+ // onGet helper: a cached/optimistic door value is fine to report while the
262
+ // gate is reachable, but when it's offline HomeKit must show "No Response"
263
+ // instead of a stale state that makes the gate look online and accept taps.
264
+ _reportDoorState(value) {
265
+ if (!this.device.connected) throw this._commError();
266
+ return value;
230
267
  }
231
268
 
232
269
  setTargetDoorState(value) {
270
+ // Surface "No Response" for a tap on an unreachable gate instead of
271
+ // silently dropping the command (the write helpers would otherwise just
272
+ // log and skip, leaving HomeKit to believe the open/close succeeded).
273
+ if (!this.device.connected) throw this._commError();
274
+
275
+ // The opener is level-triggered: act only when the requested target
276
+ // differs from the one we've already committed to. HomeKit re-sends the
277
+ // same target after a tap — a second controller echoing it, or its own
278
+ // retry a few seconds later — and acting on that repeat would fire another
279
+ // open/close. For a close that means a second stop-before-close into the
280
+ // already-closing gate, halting it mid-travel and restarting it (the
281
+ // reported stutter). A genuine request always flips the target (the Home
282
+ // app toggles to the opposite state). The committed target is reconciled
283
+ // with the gate's real position in _onDeviceChange so external operation
284
+ // still works — but only from a definitive report, never from the
285
+ // "stopped" transient our own stop produces, so the repeat stays caught.
286
+ if (value === this._committedTarget) {
287
+ this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: target unchanged — ignoring repeat request`);
288
+ return;
289
+ }
290
+
233
291
  // A direct open/close (the GarageDoorOpener target, a Force switch, or
234
292
  // the partial switch tapped OFF) is manual control: cancel any pending
235
- // partial-open auto-stop so it can't halt this movement part-way.
293
+ // partial-open auto-stop so it can't halt this movement part-way. This
294
+ // must run only for a genuine target change, AFTER the repeat check
295
+ // above: a partial open drives the committed target to OPEN itself, so
296
+ // HomeKit's repeat of that OPEN re-enters here, and cancelling on that
297
+ // swallowed repeat would tear down the auto-stop the partial just armed —
298
+ // letting the gate run all the way open instead of parking part-way.
236
299
  this._cancelPartialStop();
300
+
237
301
  this._applyTarget(value);
238
302
  }
239
303
 
@@ -244,6 +308,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
244
308
  _applyTarget(value) {
245
309
  const {Characteristic} = this.hap;
246
310
  const open = value === Characteristic.TargetDoorState.OPEN;
311
+ this._committedTarget = value;
247
312
  this.accessory.context.cachedTargetDoorState = value;
248
313
  if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(value);
249
314
  if (open) this._sendOpen();
@@ -254,8 +319,8 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
254
319
  // own, even mid-close. Abandon any pending stop-before-close.
255
320
  _sendOpen() {
256
321
  this._cancelPendingClose();
257
- this.log.info(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
258
- this.setMultiStateLegacyAsync({[this.dpOpen]: true});
322
+ this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: open (dp${this.dpOpen})`);
323
+ this.setMultiStateLegacyInBackground({[this.dpOpen]: true});
259
324
  }
260
325
 
261
326
  // Close is ignored while the gate is actively moving, so fire it directly
@@ -266,20 +331,20 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
266
331
  if (this.pendingCloseTimer) {
267
332
  // A stop-before-close is already running — don't restart it (and
268
333
  // 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`);
334
+ this.log.debug(`[SimpleGarageDoor] ${name}: close ignored — a stop-before-close is already running`);
270
335
  return;
271
336
  }
272
337
  if (this._isStopped()) {
273
- this.log.info(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) — gate already stopped`);
274
- this.setMultiStateLegacyAsync({[this.dpClose]: true});
338
+ this.log.debug(`[SimpleGarageDoor] ${name}: close (dp${this.dpClose}) — gate already stopped`);
339
+ this.setMultiStateLegacyInBackground({[this.dpClose]: true});
275
340
  return;
276
341
  }
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});
342
+ this.log.debug(`[SimpleGarageDoor] ${name}: stop-before-close — stop (dp${this.dpStop}) now, close (dp${this.dpClose}) in ${this.stopBeforeCloseMs}ms`);
343
+ this.setMultiStateLegacyInBackground({[this.dpStop]: true});
279
344
  this.pendingCloseTimer = setTimeout(() => {
280
345
  this.pendingCloseTimer = null;
281
- this.log.info(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
282
- this.setMultiStateLegacyAsync({[this.dpClose]: true});
346
+ this.log.debug(`[SimpleGarageDoor] ${name}: stop-before-close — firing close (dp${this.dpClose})`);
347
+ this.setMultiStateLegacyInBackground({[this.dpClose]: true});
283
348
  }, this.stopBeforeCloseMs);
284
349
  }
285
350
 
@@ -298,32 +363,63 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
298
363
  }
299
364
  }
300
365
 
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.
366
+ // Partial open: fire the open action, wait partialOpenMs, then fire a stop
367
+ // so the gate ends up parked part-way.
368
+ //
369
+ // The auto-stop is anchored to the controller *reporting the gate is moving*
370
+ // (the status DP flipping to OPEN), not to the button press: the open
371
+ // command takes time to reach the gate, and a stop fired before the gate is
372
+ // actually moving lands as a no-op the controller drops — letting the gate
373
+ // run all the way open. If the gate already reads open/opening, no fresh
374
+ // report is coming, so we arm straight away.
305
375
  _handlePartialOpen() {
306
376
  const {Characteristic} = this.hap;
307
377
  const name = this.device.context.name;
378
+ if (!this.device.connected) throw this._commError();
308
379
  if (!this.partialOpenMs) return;
309
- if (this.partialStopTimer) {
310
- // Re-entrant press while a partial is already armed (e.g. a
380
+ if (this.partialPending || this.partialStopTimer) {
381
+ // Re-entrant press while a partial is already in progress (e.g. a
311
382
  // HomeKit/iOS WRITE retransmit) — ignore so the stop isn't pushed
312
383
  // 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`);
384
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
314
385
  return;
315
386
  }
316
387
 
317
- this.log.info(`[SimpleGarageDoor] ${name}: partial open — opening, will stop in ${this.partialOpenMs}ms`);
388
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial open — opening, will stop ${this.partialOpenMs}ms after the gate starts moving`);
389
+ this.partialGeneration++;
390
+ this.partialPending = true;
318
391
  this._applyTarget(Characteristic.TargetDoorState.OPEN);
392
+
393
+ const reported = this.device.state ? this.device.state[this.dpState] : undefined;
394
+ if (this._mapDpState(reported) === true) this._armPartialStop();
395
+ }
396
+
397
+ _armPartialStop() {
398
+ if (!this.partialPending || this.partialStopTimer) return;
399
+ this.partialPending = false;
400
+ const name = this.device.context.name;
401
+ const generation = this.partialGeneration;
319
402
  this.partialStopTimer = setTimeout(() => {
320
403
  this.partialStopTimer = null;
321
- this.log.info(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
322
- this.setMultiStateLegacyAsync({[this.dpStop]: true});
404
+ this.log.debug(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
405
+ this._sendPartialStop(generation, 1);
323
406
  }, this.partialOpenMs);
324
407
  }
325
408
 
409
+ // The controller occasionally drops a lone write (its command queue can
410
+ // coalesce back-to-back writes, and brief Wi-Fi blips lose individual
411
+ // packets), and a dropped stop here is exactly what lets a partial open run
412
+ // all the way. Re-send it a few times; a stop on an already-parked gate is a
413
+ // harmless no-op. Bails out if the flow was superseded mid-way.
414
+ _sendPartialStop(generation, attempt) {
415
+ if (generation !== this.partialGeneration) return;
416
+ this.setMultiStateLegacyInBackground({[this.dpStop]: true});
417
+ if (attempt < 3) setTimeout(() => this._sendPartialStop(generation, attempt + 1), 300);
418
+ }
419
+
326
420
  _cancelPartialStop() {
421
+ this.partialPending = false;
422
+ this.partialGeneration++;
327
423
  if (this.partialStopTimer) {
328
424
  clearTimeout(this.partialStopTimer);
329
425
  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) {