homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-pr.59.7
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/Changelog.md
CHANGED
|
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file. This projec
|
|
|
9
9
|
* Works with both **Custom** and **Smart Home** Cloud projects (the latter via app-account login).
|
|
10
10
|
* The existing **IrrigationSystem** accessory works unchanged over the cloud — its data-points are simply addressed by Tuya "code" (e.g. `switch_1`, `battery_percentage`) instead of a numeric id; the device logs its codes on startup. Battery-only controllers with no rain sensor: set `"noRainSensor": true`.
|
|
11
11
|
* See the wiki: **[Tuya Cloud Setup](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)**.
|
|
12
|
+
* [*] **Fix realtime (MQTT) cloud updates being silently dropped** — external changes (physical buttons, the Tuya app, the device's own timers) now show up in HomeKit within a second or two. The decryptor was verifying the AES-GCM auth tag (`decipher.final()`), but Tuya's real status frames don't carry a tag that verifies against the documented AAD, so every realtime message was being thrown away. Now decrypts with `update()` only, matching the official `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` implementations.
|
|
13
|
+
* [*] **Fix cloud irrigation valves that could be turned on but not off** — the per-zone write coalescer was dropping any command that matched the last-known `device.state`. Cloud devices never optimistically advance `state` (it only moves once the realtime stream confirms the device), so an "off" issued before the "on" was echoed matched the stale "off" and was discarded — HomeKit showed the zone closed while it kept running. Queued commands are now sent as-is (callers already queue only genuine changes).
|
|
12
14
|
|
|
13
15
|
## 2.0.1 (2021-03-25)
|
|
14
16
|
This update includes the following changes:
|
|
@@ -614,16 +614,19 @@ class IrrigationSystemAccessory extends BaseAccessory {
|
|
|
614
614
|
return;
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
617
|
+
// Send exactly what was queued. We deliberately do NOT drop data-points
|
|
618
|
+
// that appear to already match `device.state`: cloud devices never
|
|
619
|
+
// optimistically advance `state` (it only moves when the realtime/refresh
|
|
620
|
+
// stream confirms the device), so comparing against it would silently
|
|
621
|
+
// swallow a genuine command. The most visible symptom was a valve that
|
|
622
|
+
// could be turned on but never off — the "off" matched the stale "off"
|
|
623
|
+
// still in `state` (the "on" hadn't been echoed back yet) and was
|
|
624
|
+
// discarded, so HomeKit showed the zone closed while it kept running.
|
|
625
|
+
// Callers (_setValveActive, _setSystemActive, the auto-shutoff timers)
|
|
626
|
+
// already queue only real changes, judged against the HomeKit state the
|
|
627
|
+
// user sees, so there is nothing left to de-dupe here.
|
|
628
|
+
const dps = pending.dps;
|
|
629
|
+
if (Object.keys(dps).length) this.device.update(dps);
|
|
627
630
|
}
|
|
628
631
|
|
|
629
632
|
_valveService(cfg) {
|
|
@@ -145,12 +145,12 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
145
145
|
envelope = JSON.parse(payload.toString());
|
|
146
146
|
} catch (_) { return; }
|
|
147
147
|
|
|
148
|
-
const {protocol, data
|
|
148
|
+
const {protocol, data} = envelope;
|
|
149
149
|
if (!data) return;
|
|
150
150
|
|
|
151
151
|
let plaintext;
|
|
152
152
|
try {
|
|
153
|
-
plaintext = this._decrypt(data, this.config && this.config.password
|
|
153
|
+
plaintext = this._decrypt(data, this.config && this.config.password);
|
|
154
154
|
} catch (ex) {
|
|
155
155
|
this.log.debug(`Tuya Cloud realtime decrypt failed: ${ex.message}`);
|
|
156
156
|
return;
|
|
@@ -179,26 +179,31 @@ class TuyaCloudMessaging extends EventEmitter {
|
|
|
179
179
|
// Decrypt the MQTT `data` field. msg_encrypted_version 2.0 → AES-128-GCM,
|
|
180
180
|
// 1.0 → AES-128-ECB. The AES key is the middle 16 chars of the broker
|
|
181
181
|
// password (NOT the project Access Secret).
|
|
182
|
-
|
|
182
|
+
//
|
|
183
|
+
// The GCM auth tag is intentionally NOT verified: Tuya's real status frames
|
|
184
|
+
// do not carry a tag that verifies against the documented AAD, so calling
|
|
185
|
+
// `decipher.final()` would throw on every genuine message and silently drop
|
|
186
|
+
// all realtime updates. AES-GCM is a stream cipher, so `update()` alone
|
|
187
|
+
// recovers the plaintext correctly regardless of the tag; a wrong key just
|
|
188
|
+
// yields garbage that the subsequent JSON.parse rejects. Both the official
|
|
189
|
+
// `tuya/tuya-homebridge` and `0x5e/homebridge-tuya-platform` decrypt with
|
|
190
|
+
// `update()` only, for exactly this reason — `final()` here was the bug that
|
|
191
|
+
// stopped external changes (physical buttons, the Tuya app) from showing up.
|
|
192
|
+
_decrypt(data, password) {
|
|
183
193
|
const key = Buffer.from(('' + (password || '')).substring(8, 24), 'utf8');
|
|
184
194
|
const buf = Buffer.from(data, 'base64');
|
|
185
195
|
|
|
186
196
|
// GCM (v2.0) layout: [ivLen(4) BE][iv(ivLen)][ciphertext][tag(16)].
|
|
187
|
-
// Try it first; if the header doesn't look like a sane IV length,
|
|
188
|
-
//
|
|
197
|
+
// Try it first; if the header doesn't look like a sane IV length, fall
|
|
198
|
+
// back to ECB (v1.0).
|
|
189
199
|
if (buf.length > 4 + GCM_TAG_LENGTH) {
|
|
190
200
|
const ivLen = buf.readUIntBE(0, 4);
|
|
191
201
|
if (ivLen >= 12 && ivLen <= 16 && (4 + ivLen + GCM_TAG_LENGTH) <= buf.length) {
|
|
192
202
|
try {
|
|
193
203
|
const iv = buf.slice(4, 4 + ivLen);
|
|
194
204
|
const ciphertext = buf.slice(4 + ivLen, buf.length - GCM_TAG_LENGTH);
|
|
195
|
-
const tag = buf.slice(buf.length - GCM_TAG_LENGTH);
|
|
196
205
|
const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);
|
|
197
|
-
decipher.
|
|
198
|
-
const aad = Buffer.allocUnsafe(6);
|
|
199
|
-
aad.writeUIntBE(Number(t) || 0, 0, 6);
|
|
200
|
-
decipher.setAAD(aad);
|
|
201
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
206
|
+
return decipher.update(ciphertext).toString('utf8');
|
|
202
207
|
} catch (gcmEx) {
|
|
203
208
|
// fall through to ECB
|
|
204
209
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tuya-plus",
|
|
3
|
-
"version": "3.14.0-
|
|
3
|
+
"version": "3.14.0-pr.59.7",
|
|
4
4
|
"description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -492,4 +492,36 @@ describe('IrrigationSystemAccessory — cloud mode (data-points keyed by code)',
|
|
|
492
492
|
jest.advanceTimersByTime(500);
|
|
493
493
|
expect(device.update).toHaveBeenCalledWith({ zone_b: true });
|
|
494
494
|
});
|
|
495
|
+
|
|
496
|
+
test('turning a zone off still writes false when the cloud never echoed the "on"', () => {
|
|
497
|
+
// The real cloud device never optimistically advances `state`; it only
|
|
498
|
+
// moves when the realtime stream confirms the device. Emulate that by
|
|
499
|
+
// making update() a no-op on state. A follow-up "off" must STILL be sent
|
|
500
|
+
// — otherwise the valve stays open while HomeKit shows it closed (the
|
|
501
|
+
// exact "can turn on but not off" report).
|
|
502
|
+
const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
|
|
503
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
504
|
+
const v = valve(accessory, 'switch_1');
|
|
505
|
+
|
|
506
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
507
|
+
jest.advanceTimersByTime(500);
|
|
508
|
+
expect(device.update).toHaveBeenLastCalledWith({ switch_1: true });
|
|
509
|
+
|
|
510
|
+
v.getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
511
|
+
jest.advanceTimersByTime(500);
|
|
512
|
+
expect(device.update).toHaveBeenLastCalledWith({ switch_1: false });
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test('master OFF closes zones the cloud has not echoed as open', () => {
|
|
516
|
+
const { accessory, device } = makeHarness(cloudState(), { ...cloudCtx, defaultDuration: 0 });
|
|
517
|
+
device.update.mockImplementation(() => true); // writes never touch state
|
|
518
|
+
|
|
519
|
+
valve(accessory, 'switch_1').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
520
|
+
valve(accessory, 'switch_2').getCharacteristic(Characteristic.Active).triggerSet(1);
|
|
521
|
+
jest.advanceTimersByTime(500);
|
|
522
|
+
|
|
523
|
+
irrigation(accessory).getCharacteristic(Characteristic.Active).triggerSet(0);
|
|
524
|
+
jest.advanceTimersByTime(500);
|
|
525
|
+
expect(device.update).toHaveBeenLastCalledWith({ switch_1: false, switch_2: false });
|
|
526
|
+
});
|
|
495
527
|
});
|
|
@@ -56,6 +56,30 @@ describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
|
56
56
|
expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test('decrypts a GCM frame even when the auth tag does not verify (Tuya quirk)', () => {
|
|
60
|
+
// Real Tuya status frames carry a GCM tag that does NOT verify against
|
|
61
|
+
// the documented AAD. We must decrypt with update() only (no final(),
|
|
62
|
+
// no tag check) or every realtime update is silently dropped — which is
|
|
63
|
+
// exactly what stopped external changes from reaching HomeKit. Simulate
|
|
64
|
+
// it by clobbering the trailing 16-byte tag: the plaintext must still
|
|
65
|
+
// come through.
|
|
66
|
+
const mq = makeIdle();
|
|
67
|
+
const received = [];
|
|
68
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
69
|
+
|
|
70
|
+
const t = Date.now();
|
|
71
|
+
const raw = Buffer.from(
|
|
72
|
+
encryptGCM(JSON.stringify({devId: 'DEV1', status: [{code: 'switch_1', value: true}]}), mq.config.password, t),
|
|
73
|
+
'base64'
|
|
74
|
+
);
|
|
75
|
+
crypto.randomBytes(16).copy(raw, raw.length - 16); // corrupt the auth tag
|
|
76
|
+
|
|
77
|
+
mq._onMessage('topic', Buffer.from(JSON.stringify({protocol: 4, data: raw.toString('base64'), t})));
|
|
78
|
+
|
|
79
|
+
expect(received).toHaveLength(1);
|
|
80
|
+
expect(received[0]).toEqual([{code: 'switch_1', value: true}]);
|
|
81
|
+
});
|
|
82
|
+
|
|
59
83
|
test('decrypts a legacy ECB (v1.0) frame too', () => {
|
|
60
84
|
const mq = makeIdle();
|
|
61
85
|
const received = [];
|