homebridge-tuya-plus 3.14.0-dev.30 → 3.14.0-dev.32

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
@@ -228,12 +228,7 @@ class TuyaLan {
228
228
  (config) => {
229
229
  if (!config || !config.id) return;
230
230
  const tuyaDevice = this.tuyaDevices.get(config.id);
231
- if (!tuyaDevice)
232
- return this.log.warn(
233
- 'Discovered a device that has not been configured yet (%s@%s).',
234
- config.id,
235
- config.ip,
236
- );
231
+ if (!tuyaDevice) return this._warnUnconfiguredDevice(config);
237
232
  if (connectedDevices.includes(config.id)) return;
238
233
 
239
234
  connectedDevices.push(config.id);
@@ -284,6 +279,49 @@ class TuyaLan {
284
279
  }, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
285
280
  }
286
281
 
282
+ // A device showed up on the LAN that isn't in the user's config. When the
283
+ // optional cloud session is available we look the device up there first, so
284
+ // the warning can name it (and hint at its product/category) and the user
285
+ // can tell which physical device still needs configuring — far more useful
286
+ // than a bare id@ip. If there's no cloud session, or the lookup fails (the
287
+ // device belongs to another account, the device-management API isn't
288
+ // authorised, the network is down, …), fall back to the plain warning.
289
+ // Never throws: enrichment is best-effort and must not disrupt discovery.
290
+ async _warnUnconfiguredDevice(config) {
291
+ const bareWarning = () =>
292
+ this.log.warn(
293
+ 'Discovered a device that has not been configured yet (%s@%s).',
294
+ config.id,
295
+ config.ip,
296
+ );
297
+
298
+ if (!this.cloudApi) return bareWarning();
299
+
300
+ let info;
301
+ try {
302
+ info = await this.cloudApi.getDeviceInfo(config.id);
303
+ } catch (ex) {
304
+ this.log.debug(
305
+ 'Tuya Cloud lookup for unconfigured device %s failed: %s',
306
+ config.id,
307
+ ex.message,
308
+ );
309
+ }
310
+ if (!info || !info.name) return bareWarning();
311
+
312
+ const details = [];
313
+ if (info.product_name) details.push(info.product_name);
314
+ if (info.category) details.push(`category ${info.category}`);
315
+
316
+ this.log.warn(
317
+ 'Discovered a device that has not been configured yet: %s%s (%s@%s).',
318
+ `"${info.name}"`,
319
+ details.length ? ` — ${details.join(', ')}` : '',
320
+ config.id,
321
+ config.ip,
322
+ );
323
+ }
324
+
287
325
  /* ------------------------------------------------------------------ *
288
326
  * Tuya Cloud — a single, global fallback session.
289
327
  *
@@ -24,7 +24,7 @@ class BaseAccessory {
24
24
  });
25
25
 
26
26
  this.device.once('change', () => {
27
- this.log(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
27
+ this.log.debug(`Ready to handle ${this.device.context.name} (${this.device.context.type}:${this.device.context.version}) with signature ${JSON.stringify(this.device.state)}`);
28
28
 
29
29
  this._registerCharacteristics(this.device.state);
30
30
  });
@@ -328,8 +328,8 @@ class TuyaAccessory extends EventEmitter {
328
328
  data = JSON.parse(decryptedMsg);
329
329
  } catch (ex) {
330
330
  data = decryptedMsg;
331
- this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
332
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
331
+ this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, data);
332
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
333
333
  break;
334
334
  }
335
335
 
@@ -351,8 +351,8 @@ class TuyaAccessory extends EventEmitter {
351
351
  try {
352
352
  data = JSON.parse(data);
353
353
  } catch (ex) {
354
- this.log.info(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
355
- this.log.info(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
354
+ this.log.debug(`Malformed update from ${this.context.name} with command ${cmd}:`, data);
355
+ this.log.debug(`Raw update from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
356
356
  break;
357
357
  }
358
358
 
@@ -361,8 +361,8 @@ class TuyaAccessory extends EventEmitter {
361
361
  break;
362
362
 
363
363
  default:
364
- this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, data);
365
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
364
+ this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, data);
365
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
366
366
  }
367
367
 
368
368
  callback();
@@ -416,8 +416,8 @@ class TuyaAccessory extends EventEmitter {
416
416
  try {
417
417
  data = JSON.parse(decryptedMsg);
418
418
  } catch(ex) {
419
- this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
420
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
419
+ this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
420
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
421
421
  return callback();
422
422
  }
423
423
 
@@ -429,15 +429,15 @@ class TuyaAccessory extends EventEmitter {
429
429
  //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
430
430
  this._change(data.dps);
431
431
  } else {
432
- this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
433
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
432
+ this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
433
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
434
434
  }
435
435
  }
436
436
  break;
437
437
 
438
438
  default:
439
- this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
440
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
439
+ this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
440
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
441
441
  }
442
442
 
443
443
  callback();
@@ -550,15 +550,15 @@ class TuyaAccessory extends EventEmitter {
550
550
  //this.log.info(`Heard back from ${this.context.name} with command ${cmd}`);
551
551
  this._change(parsedPayload.dps);
552
552
  } else {
553
- this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
554
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
553
+ this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
554
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
555
555
  }
556
556
  }
557
557
  break;
558
558
 
559
559
  default:
560
- this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
561
- this.log.info(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
560
+ this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, decryptedMsg);
561
+ this.log.debug(`Raw message from ${this.context.name} (${this.context.version}) with command ${cmd}:`, task.msg.toString('hex'));
562
562
  }
563
563
 
564
564
  callback();
@@ -674,11 +674,11 @@ class TuyaAccessory extends EventEmitter {
674
674
  if (parsedPayload && parsedPayload.dps) {
675
675
  this._change(parsedPayload.dps);
676
676
  } else {
677
- this.log.info(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
677
+ this.log.debug(`Malformed message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
678
678
  }
679
679
  break;
680
680
  default:
681
- this.log.info(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
681
+ this.log.debug(`Odd message from ${this.context.name} with command ${cmd}:`, payload.toString('utf8'));
682
682
  }
683
683
 
684
684
  callback();
@@ -39,12 +39,19 @@ class TuyaCloudDevice extends EventEmitter {
39
39
  this.api = props.cloudApi;
40
40
  this.messaging = props.messaging || null; // shared realtime stream
41
41
 
42
+ // Lets the cloud backend ask its parent TuyaDevice whether the SAME device
43
+ // is currently reachable over the LAN. A cloud-fallback failure is harmless
44
+ // when the LAN is up and worth surfacing when it isn't, so the failure log
45
+ // adapts to it. Null when used standalone (no LAN sibling).
46
+ this.isLanConnected = typeof props.isLanConnected === 'function' ? props.isLanConnected : null;
47
+
42
48
  // Keep a plain config object on `context`, mirroring TuyaAccessory, but
43
49
  // without the live helper objects we were handed.
44
50
  this.context = {...props};
45
51
  delete this.context.cloudApi;
46
52
  delete this.context.messaging;
47
53
  delete this.context.log;
54
+ delete this.context.isLanConnected;
48
55
 
49
56
  this.state = {};
50
57
  this.connected = false;
@@ -118,22 +125,20 @@ class TuyaCloudDevice extends EventEmitter {
118
125
  // won't clear without the user changing their Tuya project. Since PR #71 the
119
126
  // cloud backs *every* device as a fallback, so a LAN-only device whose cloud
120
127
  // project lacks permission would otherwise log an error every 30s forever.
121
- // Two things keep that out of the log: a given error is surfaced once and then
128
+ // Two things keep that out of the log: a given message is surfaced once and then
122
129
  // suppressed to debug until it changes (or the device connects), and the retry
123
130
  // interval backs off (30s → 30m cap) instead of hammering the OpenAPI.
124
131
  _onConnectFailure(ex) {
125
132
  const message = ex && ex.message ? ex.message : String(ex);
126
-
127
- if (message !== this._lastConnectError) {
128
- // The cloud is only a fallback for a LAN-capable device (one with a
129
- // local key); for a cloud-only device it is the sole path, so the same
130
- // failure is more serious. Reflect that in the level and the hint.
131
- const lanFallback = !!this.context.key;
132
- const hint = lanFallback
133
- ? ' The cloud is only a fallback here, so this is harmless if the device is reachable over the LAN; otherwise check that it is linked to the cloud project (matching account/home and datacenter region).'
134
- : ' This device is cloud-only, so it stays unreachable until this clears — check that it is linked to the cloud project (matching account/home and datacenter region).';
135
- this.log[lanFallback ? 'warn' : 'error'](`${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`);
136
- this._lastConnectError = message;
133
+ const {level, text} = this._describeConnectFailure(message);
134
+
135
+ // The full (level + wording) is what the dedup tracks, not just the raw
136
+ // message: it means a device that flips from "reachable over the LAN"
137
+ // (harmless, debug) to "not reachable over the LAN either" (warn) re-surfaces
138
+ // the change, while an unchanged situation stays quiet.
139
+ if (text !== this._lastConnectError) {
140
+ this.log[level](text);
141
+ this._lastConnectError = text;
137
142
  } else {
138
143
  this.log.debug(`${this.context.name}: Tuya Cloud connection still failing: ${message}`);
139
144
  }
@@ -145,6 +150,41 @@ class TuyaCloudDevice extends EventEmitter {
145
150
  if (this._retryTimer && this._retryTimer.unref) this._retryTimer.unref();
146
151
  }
147
152
 
153
+ // Pick the log level and wording for a connect failure from what we know about
154
+ // the device: whether the cloud is merely a fallback (the device has a local
155
+ // key) and, if so, whether that LAN path is reachable right now.
156
+ _describeConnectFailure(message) {
157
+ const lanFallback = !!this.context.key;
158
+ const lanReachable = lanFallback && !!(this.isLanConnected && this.isLanConnected());
159
+
160
+ // "permission deny" (1106) is specifically a visibility problem: the cloud
161
+ // project can't see this device. A device that has been offline for a long
162
+ // time is a prime candidate for having been removed/unbound/reset, which
163
+ // produces exactly this — so point at that, but only for this error (a
164
+ // timeout or token failure is unrelated).
165
+ const unbound = /permission deny|\b1106\b/i.test(message)
166
+ ? ' Note: a device left offline for a long time can be removed or unbound from the account (e.g. reset or re-paired), which also produces this "permission deny".'
167
+ : '';
168
+
169
+ let level, hint;
170
+ if (lanReachable) {
171
+ // Confirmed reachable over the LAN, so the cloud fallback we couldn't
172
+ // reach isn't needed. Keep it out of the way at debug.
173
+ level = 'debug';
174
+ hint = ' The device is reachable over the LAN, so only the (unused) cloud fallback is affected — harmless.';
175
+ } else if (lanFallback) {
176
+ // LAN-capable but not connected right now, so the cloud is currently the
177
+ // only path and the device may be unresponsive in HomeKit.
178
+ level = 'warn';
179
+ hint = ` The cloud is only a fallback, but the device isn't reachable over the LAN right now either, so it may show as unresponsive in HomeKit. Check that it is linked to this cloud project (matching account/home and datacenter region).${unbound}`;
180
+ } else {
181
+ level = 'error';
182
+ hint = ` This device is cloud-only, so it stays unreachable until this clears — check that it is linked to the cloud project (matching account/home and datacenter region).${unbound}`;
183
+ }
184
+
185
+ return {level, text: `${this.context.name}: Tuya Cloud connection failed: ${message}.${hint}`};
186
+ }
187
+
148
188
  /* ------------------------------------------------------------------ *
149
189
  * Realtime (shared MQTT stream)
150
190
  * ------------------------------------------------------------------ */
@@ -259,7 +299,7 @@ class TuyaCloudDevice extends EventEmitter {
259
299
  const dp = i.dp_id != null ? i.dp_id : (i.dpId != null ? i.dpId : this.dpIdByCode[i.code]);
260
300
  return `${i.code}${dp != null ? `(dp ${dp})` : ''}=${JSON.stringify(i.value)}`;
261
301
  }).join(', ');
262
- this.log.info(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
302
+ this.log.debug(`${this.context.name}: Tuya Cloud data-points → ${list || '(none reported)'}`);
263
303
  } catch (_) { /* logging must never throw */ }
264
304
  }
265
305
 
package/lib/TuyaDevice.js CHANGED
@@ -79,6 +79,9 @@ class TuyaDevice extends EventEmitter {
79
79
  cloudApi: props.cloudApi,
80
80
  messaging: props.messaging,
81
81
  log: this.log,
82
+ // Let the cloud backend tell a harmless fallback failure (the device
83
+ // is reachable over the LAN) apart from one that matters (it isn't).
84
+ isLanConnected: () => !!(this.lan && this.lan.connected),
82
85
  connect: false
83
86
  });
84
87
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.30",
3
+ "version": "3.14.0-dev.32",
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": {
@@ -246,6 +246,59 @@ describe('TuyaCloudDevice', () => {
246
246
  }
247
247
  });
248
248
 
249
+ test('a device reachable over the LAN logs the fallback failure as harmless (debug, not warn)', () => {
250
+ jest.useFakeTimers();
251
+ try {
252
+ const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => true});
253
+ const warn = jest.spyOn(log, 'warn');
254
+ const debug = jest.spyOn(log, 'debug');
255
+
256
+ dev._onConnectFailure(new Error('permission deny (code 1106)'));
257
+
258
+ expect(warn).not.toHaveBeenCalled();
259
+ expect(debug).toHaveBeenCalledTimes(1);
260
+ expect(debug.mock.calls[0][0]).toContain('reachable over the LAN');
261
+ } finally {
262
+ jest.useRealTimers();
263
+ }
264
+ });
265
+
266
+ test('the same failure re-surfaces (debug → warn) when the LAN path drops', () => {
267
+ jest.useFakeTimers();
268
+ try {
269
+ let lanUp = true;
270
+ const dev = makeDevice(makeApi(), null, {key: 'abc', isLanConnected: () => lanUp});
271
+ const warn = jest.spyOn(log, 'warn');
272
+ const err = new Error('permission deny (code 1106)');
273
+
274
+ dev._onConnectFailure(err); // LAN up → harmless, debug only
275
+ expect(warn).not.toHaveBeenCalled();
276
+
277
+ lanUp = false;
278
+ dev._onConnectFailure(err); // identical error, but LAN now down → surfaced
279
+ expect(warn).toHaveBeenCalledTimes(1);
280
+ expect(warn.mock.calls[0][0]).toContain("isn't reachable over the LAN");
281
+ } finally {
282
+ jest.useRealTimers();
283
+ }
284
+ });
285
+
286
+ test('permission-deny adds the offline-unbinding hint; an unrelated error does not', () => {
287
+ jest.useFakeTimers();
288
+ try {
289
+ const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable, LAN down
290
+ const warn = jest.spyOn(log, 'warn');
291
+
292
+ dev._onConnectFailure(new Error('permission deny (code 1106)'));
293
+ expect(warn.mock.calls[0][0]).toContain('offline for a long time');
294
+
295
+ dev._onConnectFailure(new Error('request timed out'));
296
+ expect(warn.mock.calls[1][0]).not.toContain('offline for a long time');
297
+ } finally {
298
+ jest.useRealTimers();
299
+ }
300
+ });
301
+
249
302
  test('a different error message is surfaced again, not suppressed', () => {
250
303
  jest.useFakeTimers();
251
304
  try {
@@ -232,6 +232,20 @@ describe('TuyaDevice — composition', () => {
232
232
  expect(dev.cloud).toBeNull();
233
233
  });
234
234
 
235
+ test('the cloud backend can see whether the LAN path is currently up', () => {
236
+ const api = {
237
+ isConfigured: () => true,
238
+ getStatus: jest.fn().mockResolvedValue([]),
239
+ getDeviceInfo: jest.fn().mockResolvedValue({online: true}),
240
+ sendCommands: jest.fn()
241
+ };
242
+ const dev = makeDevice({cloudApi: api});
243
+
244
+ expect(dev.cloud.isLanConnected()).toBe(false); // no LAN attached yet
245
+ dev.lan = {connected: true};
246
+ expect(dev.cloud.isLanConnected()).toBe(true); // reflects live LAN state
247
+ });
248
+
235
249
  test('attachLan builds the LAN backend with the discovered version (forceVersion still wins)', () => {
236
250
  const dev = makeDevice({ip: '10.0.0.5'});
237
251
  dev.attachLan({ip: '10.0.0.9', version: '3.3'});
@@ -16,6 +16,7 @@ jest.mock('../lib/TuyaAccessory', () => jest.fn().mockImplementation(function(pr
16
16
  jest.mock('../lib/TuyaCloudApi', () => jest.fn().mockImplementation(function(cfg) {
17
17
  this.endpoint = 'https://openapi.example.com';
18
18
  this.cfg = cfg;
19
+ this.getDeviceInfo = jest.fn();
19
20
  }));
20
21
  jest.mock('../lib/TuyaCloudMessaging', () => jest.fn().mockImplementation(function() {}));
21
22
  jest.mock('../lib/TuyaDiscovery', () => ({
@@ -135,6 +136,63 @@ describe('TuyaLan — discovery routing', () => {
135
136
  });
136
137
  });
137
138
 
139
+ describe('TuyaLan — unconfigured device discovery', () => {
140
+ const UNKNOWN = {id: 'bf99999999999999', ip: '10.0.0.9'};
141
+ const BARE = 'Discovered a device that has not been configured yet (%s@%s).';
142
+ const ENRICHED = 'Discovered a device that has not been configured yet: %s%s (%s@%s).';
143
+
144
+ test('without a cloud session, the bare id@ip warning is logged', async () => {
145
+ const platform = run({devices: [SW()]});
146
+ await platform._warnUnconfiguredDevice(UNKNOWN);
147
+ expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
148
+ });
149
+
150
+ test('with a cloud session, the warning is enriched with the device name and product', async () => {
151
+ const platform = run({cloud: CLOUD, devices: [SW()]});
152
+ platform.cloudApi.getDeviceInfo.mockResolvedValue({name: 'Living Room Light', product_name: 'Smart Bulb', category: 'dj', online: true});
153
+
154
+ await platform._warnUnconfiguredDevice(UNKNOWN);
155
+
156
+ expect(platform.cloudApi.getDeviceInfo).toHaveBeenCalledWith(UNKNOWN.id);
157
+ const [format, ...args] = platform.log.warn.mock.calls[0];
158
+ expect(format).toBe(ENRICHED);
159
+ const text = args.join(' ');
160
+ expect(text).toContain('"Living Room Light"');
161
+ expect(text).toContain('Smart Bulb');
162
+ expect(text).toContain('category dj');
163
+ expect(text).toContain(UNKNOWN.id);
164
+ });
165
+
166
+ test('a cloud lookup that fails falls back to the bare warning', async () => {
167
+ const platform = run({cloud: CLOUD, devices: [SW()]});
168
+ platform.cloudApi.getDeviceInfo.mockRejectedValue(new Error('device not found'));
169
+
170
+ await platform._warnUnconfiguredDevice(UNKNOWN);
171
+
172
+ expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
173
+ expect(platform.log.debug).toHaveBeenCalled();
174
+ });
175
+
176
+ test('a cloud record without a name falls back to the bare warning', async () => {
177
+ const platform = run({cloud: CLOUD, devices: [SW()]});
178
+ platform.cloudApi.getDeviceInfo.mockResolvedValue({online: true});
179
+
180
+ await platform._warnUnconfiguredDevice(UNKNOWN);
181
+
182
+ expect(platform.log.warn).toHaveBeenCalledWith(BARE, UNKNOWN.id, UNKNOWN.ip);
183
+ });
184
+
185
+ test('the discover handler routes an unconfigured device through the cloud lookup', () => {
186
+ const platform = run({cloud: CLOUD, devices: [SW()]});
187
+ platform.cloudApi.getDeviceInfo.mockResolvedValue(null);
188
+
189
+ const onDiscover = TuyaDiscovery.on.mock.calls.find(c => c[0] === 'discover')[1];
190
+ onDiscover(UNKNOWN);
191
+
192
+ expect(platform.cloudApi.getDeviceInfo).toHaveBeenCalledWith(UNKNOWN.id);
193
+ });
194
+ });
195
+
138
196
  describe('TuyaLan — duplicate device ids', () => {
139
197
  // Two entries sharing an id resolve to one accessory UUID; configuring it twice
140
198
  // used to crash the child bridge (addAccessory read back its own wrapper and