homebridge-tuya-plus 3.14.0-dev.41 → 3.14.0-dev.43
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/TuyaCloudApi.js
CHANGED
|
@@ -87,6 +87,10 @@ class TuyaCloudApi {
|
|
|
87
87
|
this.uid = null; // linked app-account uid (needed for realtime MQTT)
|
|
88
88
|
this._tokenExpiry = 0; // ms epoch when the token must be refreshed
|
|
89
89
|
this._tokenPromise = null; // in-flight token request (de-dupes concurrent callers)
|
|
90
|
+
|
|
91
|
+
// Reuse TLS connections across requests instead of a fresh handshake each
|
|
92
|
+
// time — noticeably cuts per-command latency and the startup read burst.
|
|
93
|
+
this._agent = new https.Agent({keepAlive: true, maxSockets: 8});
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
isConfigured() {
|
|
@@ -365,7 +369,8 @@ class TuyaCloudApi {
|
|
|
365
369
|
port: url.port || 443,
|
|
366
370
|
path: url.pathname + url.search,
|
|
367
371
|
method,
|
|
368
|
-
headers
|
|
372
|
+
headers,
|
|
373
|
+
agent: this._agent
|
|
369
374
|
}, resp => {
|
|
370
375
|
let data = '';
|
|
371
376
|
resp.setEncoding('utf8');
|
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -75,6 +75,8 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
75
75
|
this._codeMethod = {};
|
|
76
76
|
this._propertiesTried = new Set();
|
|
77
77
|
this._announcedProperties = false;
|
|
78
|
+
this._catchupTimers = null; // post-write state re-reads (see _scheduleStateCatchup)
|
|
79
|
+
this._writeChain = null; // serializes cloud writes to this device (see update)
|
|
78
80
|
|
|
79
81
|
// Bidirectional data-point maps, learned from the device's thing-shadow on
|
|
80
82
|
// connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
|
|
@@ -224,8 +226,11 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
224
226
|
this.connected = online;
|
|
225
227
|
this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
|
|
226
228
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
+
// Read via the same shadow-preferred path as the initial connect, not
|
|
230
|
+
// the plain /status endpoint: a thing-model-only device (e.g. a custom
|
|
231
|
+
// gate controller) returns an EMPTY /status result, so /status would
|
|
232
|
+
// silently wipe nothing in and never reflect a state change.
|
|
233
|
+
this._applyStatus(await this._readInitialProperties());
|
|
229
234
|
} catch (ex) {
|
|
230
235
|
this.log.debug(`${this.context.name}: cloud state refresh failed: ${ex.message}`);
|
|
231
236
|
}
|
|
@@ -362,7 +367,16 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
362
367
|
if (undeliverable.length) this._reportUndeliverableWrite(undeliverable);
|
|
363
368
|
if (!sendable.length) return false;
|
|
364
369
|
|
|
365
|
-
|
|
370
|
+
// Serialize writes to this device. HomeKit frequently double-sends a
|
|
371
|
+
// characteristic write, and rapid changes (dragging a thermostat) fire a
|
|
372
|
+
// burst; running them concurrently both races the per-code endpoint
|
|
373
|
+
// learning (a duplicate could see a code "tried" but not yet learned and
|
|
374
|
+
// fail spuriously) and bursts the cloud's QPS limit (uneven latency). One
|
|
375
|
+
// at a time keeps learning coherent and the request rate smooth. _dispatch
|
|
376
|
+
// never rejects, so the chain can't break.
|
|
377
|
+
const run = () => this._dispatch(sendable);
|
|
378
|
+
this._writeChain = this._writeChain ? this._writeChain.then(run, run) : run();
|
|
379
|
+
return this._writeChain;
|
|
366
380
|
}
|
|
367
381
|
|
|
368
382
|
// Send a write, routing each data-point to the cloud endpoint it accepts. Codes
|
|
@@ -382,6 +396,7 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
382
396
|
|
|
383
397
|
if (byCommands.ok && byProperties.ok) {
|
|
384
398
|
this._lastWriteError = null;
|
|
399
|
+
this._scheduleStateCatchup();
|
|
385
400
|
return true;
|
|
386
401
|
}
|
|
387
402
|
this._reportWriteFailure(byCommands.error || byProperties.error);
|
|
@@ -413,11 +428,36 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
413
428
|
}
|
|
414
429
|
if (!this._announcedProperties) {
|
|
415
430
|
this._announcedProperties = true;
|
|
416
|
-
this.log.
|
|
431
|
+
this.log.debug(`${this.context.name}: controlling some of this device's data-points over the Tuya thing-model property endpoint (they aren't in its standard instruction set).`);
|
|
417
432
|
}
|
|
418
433
|
return this._learn(fresh, 'properties');
|
|
419
434
|
}
|
|
420
435
|
|
|
436
|
+
// After a cloud write the resulting state (e.g. a gate's return_state flipping
|
|
437
|
+
// to "opening") shows up in the cloud within ~a second, but the realtime stream
|
|
438
|
+
// doesn't always carry it — and an accessory that mirrors a status DP (the
|
|
439
|
+
// garage door) would otherwise sit on the old value forever. So re-read the
|
|
440
|
+
// device a couple of times after a command to catch the transition. Each new
|
|
441
|
+
// command re-arms it, so a burst of writes only triggers one catch-up; it only
|
|
442
|
+
// runs on the cloud path, so a LAN-reachable device pays nothing.
|
|
443
|
+
_scheduleStateCatchup() {
|
|
444
|
+
if (this._catchupTimers) this._catchupTimers.forEach(t => clearTimeout(t));
|
|
445
|
+
this._catchupTimers = [2000, 8000].map(delay => {
|
|
446
|
+
const t = setTimeout(() => this._catchupOnce(), delay);
|
|
447
|
+
if (t && t.unref) t.unref();
|
|
448
|
+
return t;
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async _catchupOnce() {
|
|
453
|
+
if (this._stopped) return;
|
|
454
|
+
try {
|
|
455
|
+
this._applyStatus(await this._readInitialProperties());
|
|
456
|
+
} catch (ex) {
|
|
457
|
+
this.log.debug(`${this.context.name}: post-write state catch-up failed: ${ex.message}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
421
461
|
// Invoke one control endpoint; resolves to {ok, error} and never rejects, so a
|
|
422
462
|
// missing-method mock or a network error can't escape the dispatch.
|
|
423
463
|
_send(method, commands) {
|
|
@@ -505,6 +545,7 @@ class TuyaCloudDevice extends EventEmitter {
|
|
|
505
545
|
stop() {
|
|
506
546
|
this._stopped = true;
|
|
507
547
|
if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; }
|
|
548
|
+
if (this._catchupTimers) { this._catchupTimers.forEach(t => clearTimeout(t)); this._catchupTimers = null; }
|
|
508
549
|
}
|
|
509
550
|
}
|
|
510
551
|
|
|
@@ -152,6 +152,12 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
152
152
|
if (!devId || !Array.isArray(status)) return;
|
|
153
153
|
|
|
154
154
|
const handlers = this._handlers.get('' + devId);
|
|
155
|
+
// Trace what the realtime stream actually delivers (debug only). A device
|
|
156
|
+
// whose status never arrives here — while it clearly changed in the cloud —
|
|
157
|
+
// is the signature of the message service not pushing that device's reports.
|
|
158
|
+
try {
|
|
159
|
+
this.log.debug(`Tuya Cloud realtime update for ${devId}${handlers && handlers.length ? '' : ' (no subscriber)'}: ${JSON.stringify(status)}`);
|
|
160
|
+
} catch (_) { /* logging must never throw */ }
|
|
155
161
|
if (!handlers || !handlers.length) return;
|
|
156
162
|
handlers.forEach(fn => {
|
|
157
163
|
try { fn(status); } catch (ex) { this.log.debug(`Tuya Cloud realtime handler error: ${ex.message}`); }
|
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.43",
|
|
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": {
|
|
@@ -240,6 +240,29 @@ describe('TuyaCloudDevice', () => {
|
|
|
240
240
|
expect(api.sendCommands).toHaveBeenCalledWith('dev1', [{code: 'switch_1', value: false}]);
|
|
241
241
|
expect(api.sendProperties).toHaveBeenCalledWith('dev1', [{code: 'wfh_open', value: true}]);
|
|
242
242
|
});
|
|
243
|
+
|
|
244
|
+
// HomeKit often double-sends a write; running the two concurrently used to
|
|
245
|
+
// race the learning (the 2nd saw the code "tried" but not yet learned and
|
|
246
|
+
// failed). Serializing writes per device fixes it.
|
|
247
|
+
test('concurrent duplicate writes of an unknown code are serialized, not raced', async () => {
|
|
248
|
+
const api = makeApi();
|
|
249
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'Power', dp_id: 1, value: false}]);
|
|
250
|
+
api.sendCommands.mockImplementation((id, cmds) =>
|
|
251
|
+
cmds.some(c => c.code === 'Power')
|
|
252
|
+
? Promise.reject(new Error('command or value not support (code 2008)'))
|
|
253
|
+
: Promise.resolve(true));
|
|
254
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
255
|
+
await dev._connect();
|
|
256
|
+
const warn = jest.spyOn(log, 'warn');
|
|
257
|
+
|
|
258
|
+
const [a, b] = await Promise.all([dev.update({'1': true}), dev.update({'1': true})]);
|
|
259
|
+
|
|
260
|
+
expect(a).toBe(true);
|
|
261
|
+
expect(b).toBe(true);
|
|
262
|
+
expect(warn).not.toHaveBeenCalled(); // the 2nd write didn't spuriously fail
|
|
263
|
+
expect(api.sendCommands).toHaveBeenCalledTimes(1); // only the 1st probed commands
|
|
264
|
+
jest.restoreAllMocks();
|
|
265
|
+
});
|
|
243
266
|
});
|
|
244
267
|
|
|
245
268
|
test('realtime code-only deltas are mirrored to numeric ids via the learned map', async () => {
|
|
@@ -293,6 +316,51 @@ describe('TuyaCloudDevice', () => {
|
|
|
293
316
|
expect(api.getStatus).toHaveBeenCalledWith('dev1');
|
|
294
317
|
});
|
|
295
318
|
|
|
319
|
+
describe('cloud state sync', () => {
|
|
320
|
+
test('the post-write catch-up re-reads the shadow and emits the resulting change', async () => {
|
|
321
|
+
const api = makeApi();
|
|
322
|
+
let rs = 13;
|
|
323
|
+
api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
|
|
324
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
325
|
+
await dev._connect(); // state: return_state=13, '105'=13
|
|
326
|
+
|
|
327
|
+
const changes = [];
|
|
328
|
+
dev.on('change', c => changes.push(c));
|
|
329
|
+
rs = 12; // the gate is now "opening" in the cloud
|
|
330
|
+
await dev._catchupOnce();
|
|
331
|
+
expect(changes[0]).toEqual({return_state: 12, '105': 12});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('a successful cloud write arms a state catch-up', async () => {
|
|
335
|
+
const api = makeApi();
|
|
336
|
+
api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
|
|
337
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
338
|
+
await dev._connect();
|
|
339
|
+
expect(dev._catchupTimers).toBeNull();
|
|
340
|
+
await dev.update({'1': true});
|
|
341
|
+
expect(Array.isArray(dev._catchupTimers)).toBe(true);
|
|
342
|
+
dev.stop();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('a refresh reads via the shadow, so a thing-model device whose /status is empty still updates', async () => {
|
|
346
|
+
const api = makeApi();
|
|
347
|
+
let rs = 13;
|
|
348
|
+
api.getStatus = jest.fn().mockResolvedValue([]); // thing-model-only device: /status comes back empty
|
|
349
|
+
api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
|
|
350
|
+
const dev = makeDevice(api, null, {key: 'abc'});
|
|
351
|
+
await dev._connect();
|
|
352
|
+
|
|
353
|
+
const changes = [];
|
|
354
|
+
dev.on('change', c => changes.push(c));
|
|
355
|
+
rs = 12;
|
|
356
|
+
await dev._refreshState();
|
|
357
|
+
|
|
358
|
+
expect(api.getShadowProperties).toHaveBeenCalled();
|
|
359
|
+
expect(changes.some(c => c['105'] === 12)).toBe(true);
|
|
360
|
+
expect(dev.state['105']).toBe(12); // not wiped by the empty /status
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
296
364
|
describe('connect-failure handling (no log spam)', () => {
|
|
297
365
|
afterEach(() => jest.restoreAllMocks());
|
|
298
366
|
|