homebridge-tuya-plus 3.14.0-dev.46 → 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
 
@@ -257,6 +275,22 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
257
275
  // the partial switch tapped OFF) is manual control: cancel any pending
258
276
  // partial-open auto-stop so it can't halt this movement part-way.
259
277
  this._cancelPartialStop();
278
+
279
+ // The opener is level-triggered: act only when the requested target
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
283
+ // open/close. For a close that means a second stop-before-close into the
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) {
291
+ this.log.debug(`[SimpleGarageDoor] ${this.device.context.name}: target unchanged — ignoring repeat request`);
292
+ return;
293
+ }
260
294
  this._applyTarget(value);
261
295
  }
262
296
 
@@ -267,6 +301,7 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
267
301
  _applyTarget(value) {
268
302
  const {Characteristic} = this.hap;
269
303
  const open = value === Characteristic.TargetDoorState.OPEN;
304
+ this._committedTarget = value;
270
305
  this.accessory.context.cachedTargetDoorState = value;
271
306
  if (this.characteristicTargetDoorState) this.characteristicTargetDoorState.updateValue(value);
272
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.46",
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));
@@ -226,9 +227,19 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
226
227
  beforeEach(() => jest.useFakeTimers());
227
228
  afterEach(() => jest.useRealTimers());
228
229
 
230
+ // A close is only ever issued from an open gate, so the committed target
231
+ // (cachedTargetDoorState) starts OPEN — the state HomeKit shows before the
232
+ // tap. Without it the very first close would match the helper's default
233
+ // committed target and be (correctly) treated as a redundant repeat.
234
+ function openGate(instance, device, state = STATE_OPENING_OR_OPEN) {
235
+ if (state !== undefined) device.state['105'] = state;
236
+ instance.accessory.context.cachedTargetDoorState = TDS.OPEN;
237
+ instance._committedTarget = TDS.OPEN;
238
+ }
239
+
229
240
  test('CLOSE fires immediately when the gate is already stopped (state 11)', () => {
230
241
  const { instance, device, accessory } = makeSimpleGarage();
231
- device.state['105'] = STATE_STOPPED;
242
+ openGate(instance, device, STATE_STOPPED);
232
243
 
233
244
  instance.setTargetDoorState(TDS.CLOSED);
234
245
 
@@ -242,7 +253,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
242
253
  test('CLOSE while opening (state 12) fires stop, then close after stopBeforeCloseMs', () => {
243
254
  const { instance, device } = makeSimpleGarage();
244
255
  instance.stopBeforeCloseMs = 1500;
245
- device.state['105'] = STATE_OPENING_OR_OPEN;
256
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
246
257
 
247
258
  instance.setTargetDoorState(TDS.CLOSED);
248
259
 
@@ -259,10 +270,10 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
259
270
  expect(instance.pendingCloseTimer).toBeNull();
260
271
  });
261
272
 
262
- test('CLOSE while closing (state 13) also uses stop-before-close', () => {
273
+ test('CLOSE that reads state 13 still uses stop-before-close (13 is not "stopped")', () => {
263
274
  const { instance, device } = makeSimpleGarage();
264
275
  instance.stopBeforeCloseMs = 1500;
265
- device.state['105'] = STATE_CLOSING_OR_CLOSED;
276
+ openGate(instance, device, STATE_CLOSING_OR_CLOSED);
266
277
 
267
278
  instance.setTargetDoorState(TDS.CLOSED);
268
279
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
@@ -274,7 +285,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
274
285
  test('CLOSE with no reported state yet uses stop-before-close', () => {
275
286
  const { instance, device } = makeSimpleGarage();
276
287
  instance.stopBeforeCloseMs = 1500;
277
- // device.state['105'] intentionally left undefined
288
+ openGate(instance, device, undefined); // device.state['105'] left undefined
278
289
 
279
290
  instance.setTargetDoorState(TDS.CLOSED);
280
291
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
@@ -286,7 +297,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
286
297
  test('A duplicate CLOSE during the wait does not restart or double-fire', () => {
287
298
  const { instance, device } = makeSimpleGarage();
288
299
  instance.stopBeforeCloseMs = 1500;
289
- device.state['105'] = STATE_OPENING_OR_OPEN;
300
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
290
301
 
291
302
  instance.setTargetDoorState(TDS.CLOSED);
292
303
  expect(device.update).toHaveBeenCalledTimes(1); // stop
@@ -300,10 +311,71 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
300
311
  expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
301
312
  });
302
313
 
314
+ test('A repeat close once committed is swallowed — no second stop-before-close into the moving gate', () => {
315
+ // HomeKit re-sends the same target seconds after a tap (a second
316
+ // controller echoing it, or its own retry). Acting on it fired another
317
+ // stop-before-close that halted the closing gate and restarted it — the
318
+ // reported stutter. Level-triggered dispatch makes the repeat a no-op.
319
+ const { instance, device } = makeSimpleGarage();
320
+ instance.stopBeforeCloseMs = 1500;
321
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
322
+
323
+ instance.setTargetDoorState(TDS.CLOSED);
324
+ jest.advanceTimersByTime(1500);
325
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
326
+ expect(device.update).toHaveBeenNthCalledWith(2, { '102': true });
327
+
328
+ // The gate is now committed closed and still travelling (reports 13).
329
+ emitState(device, STATE_CLOSING_OR_CLOSED);
330
+ jest.advanceTimersByTime(3000);
331
+ instance.setTargetDoorState(TDS.CLOSED); // HomeKit's repeat close
332
+ expect(device.update).toHaveBeenCalledTimes(2); // swallowed — no second stop/close
333
+ });
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
+
354
+ test('A close runs again once the gate is reported open (committed target tracks reports)', () => {
355
+ const { instance, device } = makeSimpleGarage();
356
+ instance.stopBeforeCloseMs = 1500;
357
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
358
+
359
+ instance.setTargetDoorState(TDS.CLOSED);
360
+ jest.advanceTimersByTime(1500);
361
+ expect(device.update).toHaveBeenCalledTimes(2); // stop + close
362
+ emitState(device, STATE_CLOSING_OR_CLOSED); // committed = closed
363
+
364
+ instance.setTargetDoorState(TDS.CLOSED); // repeat — swallowed
365
+ expect(device.update).toHaveBeenCalledTimes(2);
366
+
367
+ // The gate is opened again (here, externally) — a close is a real
368
+ // transition once more and runs.
369
+ emitState(device, STATE_OPENING_OR_OPEN); // committed = open
370
+ instance.setTargetDoorState(TDS.CLOSED);
371
+ expect(device.update).toHaveBeenCalledTimes(3);
372
+ expect(device.update).toHaveBeenNthCalledWith(3, { '103': true });
373
+ });
374
+
303
375
  test('OPEN during the wait cancels the pending close and opens instead', () => {
304
376
  const { instance, device } = makeSimpleGarage();
305
377
  instance.stopBeforeCloseMs = 1500;
306
- device.state['105'] = STATE_OPENING_OR_OPEN;
378
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
307
379
 
308
380
  instance.setTargetDoorState(TDS.CLOSED);
309
381
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true }); // stop
@@ -321,7 +393,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
321
393
  test('Status reports are ignored during the stop-before-close window', () => {
322
394
  const { instance, device, accessory } = makeSimpleGarage();
323
395
  instance.stopBeforeCloseMs = 1500;
324
- device.state['105'] = STATE_OPENING_OR_OPEN;
396
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
325
397
  instance.currentDoorState = CDS.OPEN;
326
398
  instance.characteristicCurrentDoorState.value = CDS.OPEN;
327
399
 
@@ -345,7 +417,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
345
417
  test('stopBeforeCloseMs is configurable', () => {
346
418
  const { instance, device } = makeSimpleGarage();
347
419
  instance.stopBeforeCloseMs = 300;
348
- device.state['105'] = STATE_OPENING_OR_OPEN;
420
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
349
421
 
350
422
  instance.setTargetDoorState(TDS.CLOSED);
351
423
  expect(device.update).toHaveBeenNthCalledWith(1, { '103': true });
@@ -361,7 +433,7 @@ describe('SimpleGarageDoorAccessory.setTargetDoorState — close (stop-before-cl
361
433
  instance.dpClose = '2';
362
434
  instance.dpStop = '4';
363
435
  instance.stopBeforeCloseMs = 1000;
364
- device.state['105'] = STATE_OPENING_OR_OPEN;
436
+ openGate(instance, device, STATE_OPENING_OR_OPEN);
365
437
 
366
438
  instance.setTargetDoorState(TDS.CLOSED);
367
439
  expect(device.update).toHaveBeenNthCalledWith(1, { '4': true });
@@ -580,6 +652,7 @@ describe('SimpleGarageDoorAccessory — force switches', () => {
580
652
  test('Force Close fires the close action (immediately when already stopped)', () => {
581
653
  const { instance, device, accessory } = makeSimpleGarage();
582
654
  device.state['105'] = STATE_STOPPED;
655
+ instance._committedTarget = TDS.OPEN; // gate open → close is a real transition
583
656
 
584
657
  instance.setTargetDoorState(TDS.CLOSED);
585
658