homebridge-tuya-plus 3.14.0-dev.21 → 3.14.0-dev.23

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
 
@@ -50,6 +50,8 @@ class TuyaCloudDevice extends EventEmitter {
50
50
  this.connected = false;
51
51
  this._stopped = false;
52
52
  this._retryTimer = null;
53
+ this._retryDelay = 0; // current connect-retry backoff (ms); grows on repeated failure
54
+ this._lastConnectError = null; // last surfaced connect error, so identical repeats stay quiet
53
55
 
54
56
  // Bidirectional data-point maps, learned from the device's thing-shadow on
55
57
  // connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
@@ -92,18 +94,55 @@ class TuyaCloudDevice extends EventEmitter {
92
94
 
93
95
  this._logDiscoveredCodes(props);
94
96
 
97
+ // Connected: announce recovery from any earlier failure once, and
98
+ // reset the retry backoff.
99
+ if (this._lastConnectError) {
100
+ this.log.info(`${this.context.name}: Tuya Cloud connection restored.`);
101
+ this._lastConnectError = null;
102
+ }
103
+ this._retryDelay = 0;
104
+
95
105
  this.emit('connect');
96
106
  // Accessories register their characteristics off the first 'change'.
97
107
  this.emit('change', {...this.state}, this.state);
98
108
 
99
109
  this._subscribeRealtime();
100
110
  } catch (ex) {
101
- this.log.error(`${this.context.name}: failed to connect to Tuya Cloud: ${ex.message}`);
102
- // Retry later — credentials/permissions/network may recover.
103
- if (!this._stopped) {
104
- this._retryTimer = setTimeout(() => this._connect(), 30000);
105
- }
111
+ this._onConnectFailure(ex);
112
+ }
113
+ }
114
+
115
+ // A failed connect is retried, but the cause is frequently permanent — the
116
+ // cloud project can't see this device (permission denied / "no space"), the
117
+ // datacenter region is wrong, or the device isn't linked to the account — and
118
+ // won't clear without the user changing their Tuya project. Since PR #71 the
119
+ // cloud backs *every* device as a fallback, so a LAN-only device whose cloud
120
+ // 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
122
+ // suppressed to debug until it changes (or the device connects), and the retry
123
+ // interval backs off (30s → 30m cap) instead of hammering the OpenAPI.
124
+ _onConnectFailure(ex) {
125
+ 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;
137
+ } else {
138
+ this.log.debug(`${this.context.name}: Tuya Cloud connection still failing: ${message}`);
106
139
  }
140
+
141
+ if (this._stopped) return;
142
+ // Exponential backoff: 30s after the first failure, doubling to a 30m cap.
143
+ this._retryDelay = Math.min(this._retryDelay ? this._retryDelay * 2 : 30000, 1800000);
144
+ this._retryTimer = setTimeout(() => this._connect(), this._retryDelay);
145
+ if (this._retryTimer && this._retryTimer.unref) this._retryTimer.unref();
107
146
  }
108
147
 
109
148
  /* ------------------------------------------------------------------ *
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.23",
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": {
@@ -201,4 +201,104 @@ describe('TuyaCloudDevice', () => {
201
201
  await new Promise(r => setImmediate(r));
202
202
  expect(api.getStatus).toHaveBeenCalledWith('dev1');
203
203
  });
204
+
205
+ describe('connect-failure handling (no log spam)', () => {
206
+ afterEach(() => jest.restoreAllMocks());
207
+
208
+ test('a repeated failure is surfaced once, suppressed after, and backs off', () => {
209
+ jest.useFakeTimers();
210
+ try {
211
+ const dev = makeDevice(makeApi(), null, {key: 'abc'}); // LAN-capable → fallback
212
+ const warn = jest.spyOn(log, 'warn');
213
+ const debug = jest.spyOn(log, 'debug');
214
+ const err = new Error('GET /v1.0/devices/dev1/status failed: permission deny (code 1106)');
215
+
216
+ dev._onConnectFailure(err);
217
+ expect(warn).toHaveBeenCalledTimes(1);
218
+ expect(warn.mock.calls[0][0]).toContain('permission deny (code 1106)');
219
+ expect(dev._retryDelay).toBe(30000);
220
+
221
+ dev._onConnectFailure(err); // identical → not surfaced again, just debug + backoff
222
+ expect(warn).toHaveBeenCalledTimes(1);
223
+ expect(debug).toHaveBeenCalledWith(expect.stringContaining('still failing'));
224
+ expect(dev._retryDelay).toBe(60000);
225
+
226
+ dev._onConnectFailure(err);
227
+ expect(dev._retryDelay).toBe(120000);
228
+ } finally {
229
+ jest.useRealTimers();
230
+ }
231
+ });
232
+
233
+ test('a cloud-only device (no key) surfaces the failure at error level', () => {
234
+ jest.useFakeTimers();
235
+ try {
236
+ const dev = makeDevice(makeApi()); // no key → cloud is the only path
237
+ const error = jest.spyOn(log, 'error');
238
+ const warn = jest.spyOn(log, 'warn');
239
+
240
+ dev._onConnectFailure(new Error('permission deny (code 1106)'));
241
+ expect(warn).not.toHaveBeenCalled();
242
+ expect(error).toHaveBeenCalledTimes(1);
243
+ expect(error.mock.calls[0][0]).toContain('cloud-only');
244
+ } finally {
245
+ jest.useRealTimers();
246
+ }
247
+ });
248
+
249
+ test('a different error message is surfaced again, not suppressed', () => {
250
+ jest.useFakeTimers();
251
+ try {
252
+ const dev = makeDevice(makeApi(), null, {key: 'abc'});
253
+ const warn = jest.spyOn(log, 'warn');
254
+
255
+ dev._onConnectFailure(new Error('permission deny (code 1106)'));
256
+ dev._onConnectFailure(new Error('request timed out'));
257
+ expect(warn).toHaveBeenCalledTimes(2);
258
+ } finally {
259
+ jest.useRealTimers();
260
+ }
261
+ });
262
+
263
+ test('backoff is capped at 30 minutes', () => {
264
+ jest.useFakeTimers();
265
+ try {
266
+ const dev = makeDevice(makeApi(), null, {key: 'abc'});
267
+ jest.spyOn(log, 'warn');
268
+ jest.spyOn(log, 'debug');
269
+ const err = new Error('permission deny (code 1106)');
270
+
271
+ for (let i = 0; i < 20; i++) dev._onConnectFailure(err);
272
+ expect(dev._retryDelay).toBe(1800000);
273
+ } finally {
274
+ jest.useRealTimers();
275
+ }
276
+ });
277
+
278
+ test('reconnecting after a failure logs recovery once and resets backoff', async () => {
279
+ const dev = makeDevice(makeApi(), null, {key: 'abc'});
280
+ dev._lastConnectError = 'permission deny (code 1106)';
281
+ dev._retryDelay = 240000;
282
+ const info = jest.spyOn(log, 'info');
283
+
284
+ await dev._connect(); // mocked api resolves → success path runs
285
+
286
+ expect(dev._lastConnectError).toBeNull();
287
+ expect(dev._retryDelay).toBe(0);
288
+ expect(info).toHaveBeenCalledWith(expect.stringContaining('connection restored'));
289
+ });
290
+
291
+ test('a stopped device does not schedule another retry', () => {
292
+ jest.useFakeTimers();
293
+ try {
294
+ const dev = makeDevice(makeApi(), null, {key: 'abc'});
295
+ jest.spyOn(log, 'warn');
296
+ dev.stop();
297
+ dev._onConnectFailure(new Error('permission deny (code 1106)'));
298
+ expect(dev._retryTimer).toBeNull();
299
+ } finally {
300
+ jest.useRealTimers();
301
+ }
302
+ });
303
+ });
204
304
  });
@@ -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
+ });
@@ -132,8 +132,8 @@ The realtime stream connects out to Tuya's broker on **port 8883** — make sure
132
132
 
133
133
  | Symptom | Cause / fix |
134
134
  |---|---|
135
- | `failed to connect to Tuya Cloud: token request failed … (code 1004)` | Wrong **Access ID/Secret**, or host clock skew — the signature includes a timestamp, so keep the machine **NTP-synced**. |
136
- | `… (code 1106) permission deny` / `No permissions` | API service not authorized or **trial expired** (step 2), or **wrong region**, or you logged in with the developer account instead of the **app** account. |
135
+ | `Tuya Cloud connection failed: token request failed … (code 1004)` | Wrong **Access ID/Secret**, or host clock skew — the signature includes a timestamp, so keep the machine **NTP-synced**. |
136
+ | `… (code 1106) permission deny` / `(code 40001900) No space permission` | API service not authorized or **trial expired** (step 2), or **wrong region**, or you logged in with the developer account instead of the **app** account, or this specific device isn't linked to the project (step 3). The cloud backs every device as a fallback, so a single LAN-only device that isn't in the project logs this once — harmless if that device already works over the LAN. |
137
137
  | Device connects but shows nothing / `0` devices | **Region mismatch**, or the device isn't linked to the project (step 3), or the linked account isn't the device's **owner** (shared/guest access hides devices). |
138
138
  | `realtime disabled: the optional "mqtt" package is not installed` | Install `mqtt` (above), or ignore if you don't need live external updates. |
139
139
  | Realtime never connects (control works, external changes don't) | Outbound **port 8883** blocked by a firewall. |