homebridge-tuya-plus 3.14.0-dev.21 → 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.21",
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": {
@@ -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
+ });