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

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