homebridge-tuya-plus 3.14.0-dev.41 → 3.14.0-dev.42

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.
@@ -75,6 +75,7 @@ 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)
78
79
 
79
80
  // Bidirectional data-point maps, learned from the device's thing-shadow on
80
81
  // connect. They let this cloud transport (and the LAN+cloud TuyaDevice that
@@ -224,8 +225,11 @@ class TuyaCloudDevice extends EventEmitter {
224
225
  this.connected = online;
225
226
  this.log.info(`${this.context.name}: Tuya now reports the device ${online ? 'online' : 'offline'}.`);
226
227
  }
227
- const status = await this.api.getStatus(this.context.id);
228
- this._applyStatus(status);
228
+ // Read via the same shadow-preferred path as the initial connect, not
229
+ // the plain /status endpoint: a thing-model-only device (e.g. a custom
230
+ // gate controller) returns an EMPTY /status result, so /status would
231
+ // silently wipe nothing in and never reflect a state change.
232
+ this._applyStatus(await this._readInitialProperties());
229
233
  } catch (ex) {
230
234
  this.log.debug(`${this.context.name}: cloud state refresh failed: ${ex.message}`);
231
235
  }
@@ -382,6 +386,7 @@ class TuyaCloudDevice extends EventEmitter {
382
386
 
383
387
  if (byCommands.ok && byProperties.ok) {
384
388
  this._lastWriteError = null;
389
+ this._scheduleStateCatchup();
385
390
  return true;
386
391
  }
387
392
  this._reportWriteFailure(byCommands.error || byProperties.error);
@@ -418,6 +423,31 @@ class TuyaCloudDevice extends EventEmitter {
418
423
  return this._learn(fresh, 'properties');
419
424
  }
420
425
 
426
+ // After a cloud write the resulting state (e.g. a gate's return_state flipping
427
+ // to "opening") shows up in the cloud within ~a second, but the realtime stream
428
+ // doesn't always carry it — and an accessory that mirrors a status DP (the
429
+ // garage door) would otherwise sit on the old value forever. So re-read the
430
+ // device a couple of times after a command to catch the transition. Each new
431
+ // command re-arms it, so a burst of writes only triggers one catch-up; it only
432
+ // runs on the cloud path, so a LAN-reachable device pays nothing.
433
+ _scheduleStateCatchup() {
434
+ if (this._catchupTimers) this._catchupTimers.forEach(t => clearTimeout(t));
435
+ this._catchupTimers = [2000, 8000].map(delay => {
436
+ const t = setTimeout(() => this._catchupOnce(), delay);
437
+ if (t && t.unref) t.unref();
438
+ return t;
439
+ });
440
+ }
441
+
442
+ async _catchupOnce() {
443
+ if (this._stopped) return;
444
+ try {
445
+ this._applyStatus(await this._readInitialProperties());
446
+ } catch (ex) {
447
+ this.log.debug(`${this.context.name}: post-write state catch-up failed: ${ex.message}`);
448
+ }
449
+ }
450
+
421
451
  // Invoke one control endpoint; resolves to {ok, error} and never rejects, so a
422
452
  // missing-method mock or a network error can't escape the dispatch.
423
453
  _send(method, commands) {
@@ -505,6 +535,7 @@ class TuyaCloudDevice extends EventEmitter {
505
535
  stop() {
506
536
  this._stopped = true;
507
537
  if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; }
538
+ if (this._catchupTimers) { this._catchupTimers.forEach(t => clearTimeout(t)); this._catchupTimers = null; }
508
539
  }
509
540
  }
510
541
 
@@ -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.42",
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": {
@@ -293,6 +293,51 @@ describe('TuyaCloudDevice', () => {
293
293
  expect(api.getStatus).toHaveBeenCalledWith('dev1');
294
294
  });
295
295
 
296
+ describe('cloud state sync', () => {
297
+ test('the post-write catch-up re-reads the shadow and emits the resulting change', async () => {
298
+ const api = makeApi();
299
+ let rs = 13;
300
+ api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
301
+ const dev = makeDevice(api, null, {key: 'abc'});
302
+ await dev._connect(); // state: return_state=13, '105'=13
303
+
304
+ const changes = [];
305
+ dev.on('change', c => changes.push(c));
306
+ rs = 12; // the gate is now "opening" in the cloud
307
+ await dev._catchupOnce();
308
+ expect(changes[0]).toEqual({return_state: 12, '105': 12});
309
+ });
310
+
311
+ test('a successful cloud write arms a state catch-up', async () => {
312
+ const api = makeApi();
313
+ api.getShadowProperties = jest.fn().mockResolvedValue([{code: 'switch_1', dp_id: 1, value: false}]);
314
+ const dev = makeDevice(api, null, {key: 'abc'});
315
+ await dev._connect();
316
+ expect(dev._catchupTimers).toBeNull();
317
+ await dev.update({'1': true});
318
+ expect(Array.isArray(dev._catchupTimers)).toBe(true);
319
+ dev.stop();
320
+ });
321
+
322
+ test('a refresh reads via the shadow, so a thing-model device whose /status is empty still updates', async () => {
323
+ const api = makeApi();
324
+ let rs = 13;
325
+ api.getStatus = jest.fn().mockResolvedValue([]); // thing-model-only device: /status comes back empty
326
+ api.getShadowProperties = jest.fn(async () => [{code: 'return_state', dp_id: 105, value: rs}]);
327
+ const dev = makeDevice(api, null, {key: 'abc'});
328
+ await dev._connect();
329
+
330
+ const changes = [];
331
+ dev.on('change', c => changes.push(c));
332
+ rs = 12;
333
+ await dev._refreshState();
334
+
335
+ expect(api.getShadowProperties).toHaveBeenCalled();
336
+ expect(changes.some(c => c['105'] === 12)).toBe(true);
337
+ expect(dev.state['105']).toBe(12); // not wiped by the empty /status
338
+ });
339
+ });
340
+
296
341
  describe('connect-failure handling (no log spam)', () => {
297
342
  afterEach(() => jest.restoreAllMocks());
298
343