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
|
-
|
|
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,
|
|
314
|
-
// so the gate ends up parked part-way.
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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('
|
|
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
|
-
//
|
|
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
|
|
453
|
+
// And nothing more after the re-sends.
|
|
433
454
|
jest.advanceTimersByTime(10_000);
|
|
434
|
-
expect(device.update).toHaveBeenCalledTimes(
|
|
455
|
+
expect(device.update).toHaveBeenCalledTimes(4);
|
|
435
456
|
expect(instance.partialStopTimer).toBeNull();
|
|
436
457
|
});
|
|
437
458
|
|
|
438
|
-
test('
|
|
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;
|
package/test/index.test.js
CHANGED
|
@@ -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
|
|
504
|
-
Tapping it OFF triggers a standard full close.
|
|
505
|
-
someone pass through briefly. Leave unset to skip
|
|
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
|