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.
package/lib/TuyaCloudDevice.js
CHANGED
|
@@ -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
|
-
|
|
228
|
-
|
|
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.
|
|
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
|
|