homebridge-tuya-plus 3.14.0-dev.22 → 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.
@@ -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.22",
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
  });
@@ -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. |