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.
- package/lib/TuyaCloudDevice.js +44 -5
- package/package.json +1 -1
- package/test/TuyaCloudDevice.test.js +100 -0
- package/wiki/Tuya-Cloud-Setup.md +2 -2
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -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.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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
|
});
|
package/wiki/Tuya-Cloud-Setup.md
CHANGED
|
@@ -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
|
-
| `
|
|
136
|
-
| `… (code 1106) permission deny` / `No
|
|
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. |
|