homebridge-tuya-plus 3.14.0-dev.47 → 3.14.0-dev.48

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.
@@ -120,6 +120,11 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
120
120
  ? Characteristic.TargetDoorState.OPEN
121
121
  : Characteristic.TargetDoorState.CLOSED;
122
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;
123
128
 
124
129
  this.characteristicTargetDoorState = service.getCharacteristic(Characteristic.TargetDoorState)
125
130
  .updateValue(initialTarget)
@@ -201,11 +206,24 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
201
206
  // (=OPEN), which would fight the close we're about to send. Ignore
202
207
  // status reports until the close has gone out.
203
208
  if (this.pendingCloseTimer) return;
204
- 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);
205
211
  if (isOpen === null) {
206
212
  this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: ignoring unknown state DP value ${JSON.stringify(changes[this.dpState])}`);
207
213
  return;
208
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
+ }
209
227
  this._applyReportedState(isOpen);
210
228
  }
211
229
 
@@ -259,16 +277,17 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
259
277
  this._cancelPartialStop();
260
278
 
261
279
  // The opener is level-triggered: act only when the requested target
262
- // differs from the one already committed. HomeKit re-sends the same
263
- // target after a tap — a second controller echoing it, or its own retry
264
- // a few seconds later — and acting on that repeat would fire another
280
+ // differs from the one we've already committed to. HomeKit re-sends the
281
+ // same target after a tap — a second controller echoing it, or its own
282
+ // retry a few seconds later — and acting on that repeat would fire another
265
283
  // open/close. For a close that means a second stop-before-close into the
266
- // already-closing gate, halting it mid-travel and restarting it. A real
267
- // request always flips the target (the Home app toggles to the opposite
268
- // state), and the reported state keeps cachedTargetDoorState honest a
269
- // command the controller drops is reverted by the next status read — so
270
- // this never swallows a genuine request, only the redundant echoes.
271
- if (value === this.accessory.context.cachedTargetDoorState) {
284
+ // already-closing gate, halting it mid-travel and restarting it (the
285
+ // reported stutter). A genuine request always flips the target (the Home
286
+ // app toggles to the opposite state). The committed target is reconciled
287
+ // with the gate's real position in _onDeviceChange so external operation
288
+ // still works but only from a definitive report, never from the
289
+ // "stopped" transient our own stop produces, so the repeat stays caught.
290
+ if (value === this._committedTarget) {
272
291
  this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: target unchanged — ignoring repeat request`);
273
292
  return;
274
293
  }
@@ -282,6 +301,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
282
301
  _applyTarget(value) {
283
302
  const {Characteristic} = this.hap;
284
303
  const open = value === Characteristic.TargetDoorState.OPEN;
304
+ this._committedTarget = value;
285
305
  this.accessory.context.cachedTargetDoorState = value;
286
306
  if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(value);
287
307
  if (open) this._sendOpen();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.47",
3
+ "version": "3.14.0-dev.48",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -68,6 +68,7 @@ function makeSimpleGarage(initialContext = {}) {
68
68
  updateValue: jest.fn().mockImplementation(function(v) { this.value = v; return this; }),
69
69
  };
70
70
  accessory.context.cachedTargetDoorState = TDS.CLOSED;
71
+ instance._committedTarget = TDS.CLOSED;
71
72
 
72
73
  // Mirror the persistent change listener registered in production.
73
74
  device.on('change', changes => instance._onDeviceChange(changes));
@@ -233,6 +234,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
233
234
  function openGate(instance, device, state = STATE_OPENING_OR_OPEN) {
234
235
  if (state !== undefined) device.state['105'] = state;
235
236
  instance.accessory.context.cachedTargetDoorState = TDS.OPEN;
237
+ instance._committedTarget = TDS.OPEN;
236
238
  }
237
239
 
238
240
  test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
@@ -330,6 +332,25 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
330
332
  expect(device.update).toHaveBeenCalledTimes(2); // swallowed — no second stop/close
331
333
  });
332
334
 
335
+ test('A stopped (11) report after the close does not re-enable the repeat (LAN stutter)', () => {
336
+ // Over the LAN the stop pulse makes the controller report 11 (=OPEN) just
337
+ // after the close has gone out — outside the stop-before-close window. That
338
+ // 11 must NOT move the committed target back to OPEN, or HomeKit's repeat
339
+ // close would fire a second stop-before-close into the already-closing gate.
340
+ const { instance, device } = makeSimpleGarage();
341
+ instance.stopBeforeCloseMs = 1500;
342
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
343
+
344
+ instance.setTargetDoorState(TDS.CLOSED);
345
+ jest.advanceTimersByTime(1500);
346
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
347
+
348
+ emitState(device, STATE_STOPPED); // the stop's late 11 report
349
+ expect(instance._committedTarget).toBe(TDS.CLOSED); // not bounced to OPEN
350
+ instance.setTargetDoorState(TDS.CLOSED); // HomeKit's repeat
351
+ expect(device.update).toHaveBeenCalledTimes(2); // swallowed
352
+ });
353
+
333
354
  test('A close runs again once the gate is reported open (committed target tracks reports)', () => {
334
355
  const { instance, device } = makeSimpleGarage();
335
356
  instance.stopBeforeCloseMs = 1500;
@@ -631,7 +652,7 @@ describe('SimpleGarageDoorAccessory — force switches', () => {
631
652
  test('Force Close fires the close action (immediately when already stopped)', () => {
632
653
  const { instance, device, accessory } = makeSimpleGarage();
633
654
  device.state['105'] = STATE_STOPPED;
634
- accessory.context.cachedTargetDoorState = TDS.OPEN; // gate open → close is a real transition
655
+ instance._committedTarget = TDS.OPEN; // gate open → close is a real transition
635
656
 
636
657
  instance.setTargetDoorState(TDS.CLOSED);
637
658