homebridge-tuya-plus 3.14.0-dev.20 → 3.14.0-dev.22

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.
package/index.js CHANGED
@@ -123,6 +123,7 @@ class TuyaLan {
123
123
  const lanDeviceIds = []; // ids we still want to find on the LAN
124
124
  const connectedDevices = []; // ids discovered on the LAN
125
125
  const fakeDevices = [];
126
+ const seenUUIDs = new Set(); // accessory UUIDs already configured this run
126
127
 
127
128
  this.config.devices.forEach(device => {
128
129
  try {
@@ -137,6 +138,15 @@ class TuyaLan {
137
138
  if (!device.type) return this.log.error('%s (%s) doesn\'t have a type defined.', device.name || 'Unnamed device', device.id);
138
139
  if (!CLASS_DEF[device.type.toLowerCase()]) return this.log.error('%s (%s) doesn\'t have a valid type defined.', device.name || 'Unnamed device', device.id);
139
140
 
141
+ // Two config entries that resolve to the same accessory UUID (the same
142
+ // Tuya id, real or fake) make addAccessory process that UUID twice. The
143
+ // second pass reads back the accessory wrapper the first pass cached
144
+ // instead of a PlatformAccessory and crashes the child bridge while
145
+ // trying to unregister it. Keep the first entry; skip the rest.
146
+ const uuid = UUID.generate(UUID_SEED + (device.fake ? ':fake:' : ':') + device.id);
147
+ if (seenUUIDs.has(uuid)) return this.log.warn('%s (%s) is configured more than once; ignoring the duplicate entry.', device.name || 'Unnamed device', device.id);
148
+ seenUUIDs.add(uuid);
149
+
140
150
  if (device.fake) {
141
151
  fakeDevices.push({name: device.id.slice(8), ...device});
142
152
  return;
@@ -337,9 +347,17 @@ class TuyaLan {
337
347
  removeAccessory(homebridgeAccessory) {
338
348
  if (!homebridgeAccessory) return;
339
349
 
350
+ // Only real PlatformAccessory instances can be unregistered. cachedAccessories
351
+ // also holds accessory wrappers (set by addAccessory), and Homebridge throws a
352
+ // fatal TypeError when handed anything else, taking down the whole child bridge.
353
+ // Guard against it rather than trust every caller.
354
+ if (!(homebridgeAccessory instanceof PlatformAccessory)) {
355
+ return this.log.warn('Skipped unregistering %s: not a PlatformAccessory.', homebridgeAccessory.displayName);
356
+ }
357
+
340
358
  this.log.warn('Unregistering', homebridgeAccessory.displayName);
341
359
 
342
- delete this.cachedAccessories[homebridgeAccessory.UUID];
360
+ this.cachedAccessories.delete(homebridgeAccessory.UUID);
343
361
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [homebridgeAccessory]);
344
362
  }
345
363
 
@@ -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
@@ -227,6 +233,11 @@ 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();
230
241
  }
231
242
 
232
243
  // onGet helper: a cached/optimistic door value is fine to report while the
@@ -310,33 +321,63 @@ class SimpleGarageDoorAccessory extends BaseAccessory {
310
321
  }
311
322
  }
312
323
 
313
- // Partial open: fire the open action, then after partialOpenMs fire a stop
314
- // so the gate ends up parked part-way. Anchored to the button press (the
315
- // controller starts moving and reports state within ~1s), which is all the
316
- // 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.
317
333
  _handlePartialOpen() {
318
334
  const {Characteristic} = this.hap;
319
335
  const name = this.device.context.name;
320
336
  if (!this.device.connected) throw this._commError();
321
337
  if (!this.partialOpenMs) return;
322
- if (this.partialStopTimer) {
323
- // 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
324
340
  // HomeKit/iOS WRITE retransmit) — ignore so the stop isn't pushed
325
341
  // out, which would let the gate run further than intended.
326
342
  this.log.debug(`[SimpleGarageDoor] ${name}: partial press ignored — a partial open is already running`);
327
343
  return;
328
344
  }
329
345
 
330
- this.log.debug(`[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;
331
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;
332
360
  this.partialStopTimer = setTimeout(() => {
333
361
  this.partialStopTimer = null;
334
362
  this.log.debug(`[SimpleGarageDoor] ${name}: partial open — firing stop (dp${this.dpStop})`);
335
- this.setMultiStateLegacyInBackground({[this.dpStop]: true});
363
+ this._sendPartialStop(generation, 1);
336
364
  }, this.partialOpenMs);
337
365
  }
338
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
+
339
378
  _cancelPartialStop() {
379
+ this.partialPending = false;
380
+ this.partialGeneration++;
340
381
  if (this.partialStopTimer) {
341
382
  clearTimeout(this.partialStopTimer);
342
383
  this.partialStopTimer = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.20",
3
+ "version": "3.14.0-dev.22",
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": {
@@ -52,6 +52,8 @@ function makeSimpleGarage(initialContext = {}) {
52
52
  instance.stopBeforeCloseMs = 1500;
53
53
  instance.partialStopTimer = null;
54
54
  instance.pendingCloseTimer = null;
55
+ instance.partialPending = false;
56
+ instance.partialGeneration = 0;
55
57
  instance.currentDoorState = CDS.CLOSED;
56
58
  instance.characteristicCurrentDoorState = {
57
59
  value: CDS.CLOSED,
@@ -409,35 +411,71 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
409
411
  beforeEach(() => jest.useFakeTimers());
410
412
  afterEach(() => jest.useRealTimers());
411
413
 
412
- test('Fires open, then fires stop after partialOpenMs', () => {
414
+ test('Stop is anchored to the gate reporting it is moving, not the button press', () => {
413
415
  const { instance, device } = makeSimpleGarage();
414
416
  instance.partialOpenMs = 2000;
417
+ // Gate starts closed, so the open command needs time to reach it and
418
+ // get the gate moving before a stop can do anything.
419
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
415
420
 
416
421
  instance._handlePartialOpen();
417
422
 
418
- // Open goes out immediately.
423
+ // Open goes out immediately, but the auto-stop is NOT armed yet.
419
424
  expect(device.update).toHaveBeenCalledTimes(1);
420
425
  expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
421
426
  expect(instance.characteristicTargetDoorState.value).toBe(TDS.OPEN);
427
+ expect(instance.partialStopTimer).toBeNull();
428
+ expect(instance.partialPending).toBe(true);
429
+
430
+ // No amount of waiting fires a stop until the gate reports it's moving —
431
+ // this is what stops a too-early stop from being a dropped no-op.
432
+ jest.advanceTimersByTime(10_000);
433
+ expect(device.update).toHaveBeenCalledTimes(1);
434
+
435
+ // Controller reports it has started opening: now the countdown arms.
436
+ emitState(device, STATE_OPENING_OR_OPEN);
437
+ expect(instance.partialStopTimer).not.toBeNull();
438
+ expect(instance.partialPending).toBe(false);
422
439
 
423
- // Nothing happens until the timer elapses.
424
440
  jest.advanceTimersByTime(2000 - 1);
425
441
  expect(device.update).toHaveBeenCalledTimes(1);
426
442
 
427
- // Then a single stop is fired.
443
+ // The stop fires, and is re-sent a couple of times to survive a dropped
444
+ // write (the controller occasionally drops a lone command).
428
445
  jest.advanceTimersByTime(1);
429
446
  expect(device.update).toHaveBeenCalledTimes(2);
430
447
  expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
448
+ jest.advanceTimersByTime(300);
449
+ expect(device.update).toHaveBeenNthCalledWith(3, { '103': true });
450
+ jest.advanceTimersByTime(300);
451
+ expect(device.update).toHaveBeenNthCalledWith(4, { '103': true });
431
452
 
432
- // And nothing more after that.
453
+ // And nothing more after the re-sends.
433
454
  jest.advanceTimersByTime(10_000);
434
- expect(device.update).toHaveBeenCalledTimes(2);
455
+ expect(device.update).toHaveBeenCalledTimes(4);
435
456
  expect(instance.partialStopTimer).toBeNull();
436
457
  });
437
458
 
438
- test('Re-entrant press while armed is ignored (idempotent)', () => {
459
+ test('Arms straight away when the gate already reports open/opening', () => {
439
460
  const { instance, device } = makeSimpleGarage();
440
461
  instance.partialOpenMs = 2000;
462
+ device.state['105'] = STATE_OPENING_OR_OPEN;
463
+
464
+ instance._handlePartialOpen();
465
+
466
+ // Open fired and the countdown armed without waiting for a fresh report.
467
+ expect(device.update).toHaveBeenNthCalledWith(1, { '101': true });
468
+ expect(instance.partialStopTimer).not.toBeNull();
469
+ expect(instance.partialPending).toBe(false);
470
+
471
+ jest.advanceTimersByTime(2000);
472
+ expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
473
+ });
474
+
475
+ test('Re-entrant press while in progress is ignored (idempotent)', () => {
476
+ const { instance, device } = makeSimpleGarage();
477
+ instance.partialOpenMs = 2000;
478
+ device.state['105'] = STATE_OPENING_OR_OPEN; // arms immediately
441
479
 
442
480
  instance._handlePartialOpen();
443
481
  expect(device.update).toHaveBeenCalledTimes(1);
@@ -454,6 +492,20 @@ describe('SimpleGarageDoorAccessory._handlePartialOpen', () => {
454
492
  expect(device.update).toHaveBeenNthCalledWith(2, { '103': true });
455
493
  });
456
494
 
495
+ test('Re-entrant press while waiting for movement is ignored', () => {
496
+ const { instance, device } = makeSimpleGarage();
497
+ instance.partialOpenMs = 2000;
498
+ device.state['105'] = STATE_CLOSING_OR_CLOSED;
499
+
500
+ instance._handlePartialOpen();
501
+ expect(device.update).toHaveBeenCalledTimes(1); // open
502
+ expect(instance.partialPending).toBe(true);
503
+
504
+ // A second press before the gate reports moving must not fire another open.
505
+ instance._handlePartialOpen();
506
+ expect(device.update).toHaveBeenCalledTimes(1);
507
+ });
508
+
457
509
  test('A direct close cancels the partial auto-stop and runs stop-before-close', () => {
458
510
  const { instance, device } = makeSimpleGarage();
459
511
  instance.partialOpenMs = 2000;
@@ -24,6 +24,7 @@ jest.mock('../lib/TuyaDiscovery', () => ({
24
24
  }));
25
25
 
26
26
  const TuyaDevice = require('../lib/TuyaDevice');
27
+ const TuyaAccessory = require('../lib/TuyaAccessory');
27
28
  const TuyaCloudApi = require('../lib/TuyaCloudApi');
28
29
  const TuyaCloudMessaging = require('../lib/TuyaCloudMessaging');
29
30
  const TuyaDiscovery = require('../lib/TuyaDiscovery');
@@ -56,7 +57,7 @@ function makeLog() {
56
57
  }
57
58
 
58
59
  function makeApi() {
59
- return {hap: makeHap(), on: jest.fn(), registerPlatformAccessories: jest.fn()};
60
+ return {hap: makeHap(), on: jest.fn(), registerPlatformAccessories: jest.fn(), unregisterPlatformAccessories: jest.fn()};
60
61
  }
61
62
 
62
63
  function run(config) {
@@ -139,3 +140,50 @@ describe('TuyaLan — discovery routing', () => {
139
140
  expect(TuyaDiscovery.start).not.toHaveBeenCalled();
140
141
  });
141
142
  });
143
+
144
+ describe('TuyaLan — duplicate device ids', () => {
145
+ // Two entries sharing an id resolve to one accessory UUID; configuring it twice
146
+ // used to crash the child bridge (addAccessory read back its own wrapper and
147
+ // tried to unregister it). The repeat must be dropped with a warning instead.
148
+ test('a repeated id is configured once and warns', () => {
149
+ const platform = run({devices: [SW(), SW()]});
150
+ expect(TuyaDevice).toHaveBeenCalledTimes(1);
151
+ expect(platform.addAccessory).toHaveBeenCalledTimes(1);
152
+ expect(platform.log.warn).toHaveBeenCalled();
153
+ });
154
+
155
+ test('distinct ids are all configured', () => {
156
+ run({devices: [SW(), SLEEPY()]});
157
+ expect(TuyaDevice).toHaveBeenCalledTimes(2);
158
+ });
159
+
160
+ test('a repeated fake id is configured once', () => {
161
+ run({devices: [SW({fake: true}), SW({fake: true})]});
162
+ expect(TuyaAccessory).toHaveBeenCalledTimes(1);
163
+ });
164
+ });
165
+
166
+ describe('TuyaLan — removeAccessory hardening', () => {
167
+ function platformWith(PlatformAccessory) {
168
+ let cls;
169
+ factory({platformAccessory: PlatformAccessory, hap: makeHap(), registerPlatform: (n, p, c) => { cls = c; }});
170
+ return new cls(makeLog(), {devices: [SW()]}, makeApi());
171
+ }
172
+ const PlatformAccessoryStub = function(name, uuid) { this.displayName = name; this.UUID = uuid; };
173
+
174
+ test('a non-PlatformAccessory (e.g. an accessory wrapper) is never unregistered', () => {
175
+ const platform = platformWith(PlatformAccessoryStub);
176
+ expect(() => platform.removeAccessory({accessory: {}})).not.toThrow();
177
+ expect(platform.api.unregisterPlatformAccessories).not.toHaveBeenCalled();
178
+ expect(platform.log.warn).toHaveBeenCalled();
179
+ });
180
+
181
+ test('a real PlatformAccessory is unregistered and dropped from the cache', () => {
182
+ const platform = platformWith(PlatformAccessoryStub);
183
+ const accessory = new PlatformAccessoryStub('Real', 'uuid:x');
184
+ platform.cachedAccessories.set('uuid:x', accessory);
185
+ platform.removeAccessory(accessory);
186
+ expect(platform.api.unregisterPlatformAccessories).toHaveBeenCalledTimes(1);
187
+ expect(platform.cachedAccessories.has('uuid:x')).toBe(false);
188
+ });
189
+ });
@@ -500,9 +500,10 @@ external stop, where close is accepted immediately), a close is sent as
500
500
  /* Optional. If set, exposes an extra stateful switch that mirrors
501
501
  whether the gate is currently open in HomeKit's view. Tapping it
502
502
  ON triggers a partial-open: the gate opens and then stops itself
503
- this many milliseconds later, leaving the gate partially open.
504
- Tapping it OFF triggers a standard full close. Useful for letting
505
- someone pass through briefly. Leave unset to skip the switch. */
503
+ this many milliseconds after it actually starts moving, leaving the
504
+ gate partially open. Tapping it OFF triggers a standard full close.
505
+ Useful for letting someone pass through briefly. Leave unset to skip
506
+ the switch. */
506
507
  "partialOpenMs": 2000,
507
508
 
508
509
  /* Optional. Exposes extra Force Open and Force Close momentary