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.
@@ -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');
@@ -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
- const status = await this.api.getStatus(this.context.id);
228
- this._applyStatus(status);
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
- return this._dispatch(sendable);
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.info(`${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).`);
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.41",
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