homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.5
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 +8 -0
- package/Readme.MD +8 -0
- package/config.schema.json +82 -13
- package/index.js +92 -3
- package/lib/IrrigationSystemAccessory.js +31 -7
- package/lib/TuyaCloudApi.js +290 -0
- package/lib/TuyaCloudDevice.js +188 -0
- package/lib/TuyaCloudMessaging.js +232 -0
- package/package.json +4 -1
- package/test/IrrigationSystemAccessory.test.js +49 -0
- package/test/TuyaCloudApi.test.js +196 -0
- package/test/TuyaCloudDevice.test.js +105 -0
- package/test/TuyaCloudMessaging.test.js +94 -0
- package/wiki/Supported-Device-Types.md +14 -0
- package/wiki/Tuya-Cloud-Setup.md +160 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const TuyaCloudMessaging = require('../lib/TuyaCloudMessaging');
|
|
5
|
+
|
|
6
|
+
const log = {info: () => {}, warn: () => {}, error: () => {}, debug: () => {}};
|
|
7
|
+
|
|
8
|
+
// Encrypt exactly like Tuya's msg_encrypted_version 2.0 (AES-128-GCM):
|
|
9
|
+
// [ivLen(4 BE)][iv][ciphertext][tag(16)], key = password[8,24), AAD = 6-byte BE t.
|
|
10
|
+
function encryptGCM(plaintext, password, t) {
|
|
11
|
+
const key = Buffer.from(('' + password).substring(8, 24), 'utf8');
|
|
12
|
+
const iv = crypto.randomBytes(12);
|
|
13
|
+
const c = crypto.createCipheriv('aes-128-gcm', key, iv);
|
|
14
|
+
const aad = Buffer.allocUnsafe(6); aad.writeUIntBE(Number(t), 0, 6); c.setAAD(aad);
|
|
15
|
+
const enc = Buffer.concat([c.update(Buffer.from(plaintext, 'utf8')), c.final()]);
|
|
16
|
+
const tag = c.getAuthTag();
|
|
17
|
+
const ivLen = Buffer.allocUnsafe(4); ivLen.writeUIntBE(iv.length, 0, 4);
|
|
18
|
+
return Buffer.concat([ivLen, iv, enc, tag]).toString('base64');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// AES-128-ECB / PKCS7 (msg_encrypted_version 1.0).
|
|
22
|
+
function encryptECB(plaintext, password) {
|
|
23
|
+
const key = Buffer.from(('' + password).substring(8, 24), 'utf8');
|
|
24
|
+
const c = crypto.createCipheriv('aes-128-ecb', key, null);
|
|
25
|
+
return Buffer.concat([c.update(Buffer.from(plaintext, 'utf8')), c.final()]).toString('base64');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A messaging instance that won't try to open a real connection.
|
|
29
|
+
function makeIdle(password = 'abcdefgh0123456789WXYZ!!') {
|
|
30
|
+
const mq = new TuyaCloudMessaging({api: {}, log});
|
|
31
|
+
mq._started = true; // prevent start()
|
|
32
|
+
mq.config = {password};
|
|
33
|
+
return mq;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function envelope(payloadObj, password, {protocol = 4, t = Date.now(), mode = 'gcm'} = {}) {
|
|
37
|
+
const data = mode === 'ecb'
|
|
38
|
+
? encryptECB(JSON.stringify(payloadObj), password)
|
|
39
|
+
: encryptGCM(JSON.stringify(payloadObj), password, t);
|
|
40
|
+
return Buffer.from(JSON.stringify({protocol, data, t}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('TuyaCloudMessaging — decryption + dispatch', () => {
|
|
44
|
+
test('decrypts a GCM status frame and delivers it to the subscribed device', () => {
|
|
45
|
+
const mq = makeIdle();
|
|
46
|
+
const received = [];
|
|
47
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
48
|
+
|
|
49
|
+
const t = Date.now();
|
|
50
|
+
mq._onMessage('topic', envelope(
|
|
51
|
+
{devId: 'DEV1', status: [{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]},
|
|
52
|
+
mq.config.password, {t}
|
|
53
|
+
));
|
|
54
|
+
|
|
55
|
+
expect(received).toHaveLength(1);
|
|
56
|
+
expect(received[0]).toEqual([{code: 'switch_1', value: true, t}, {code: 'battery_percentage', value: 80, t}]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('decrypts a legacy ECB (v1.0) frame too', () => {
|
|
60
|
+
const mq = makeIdle();
|
|
61
|
+
const received = [];
|
|
62
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
63
|
+
mq._onMessage('topic', envelope({devId: 'DEV1', status: [{code: 'switch_2', value: true}]}, mq.config.password, {mode: 'ecb'}));
|
|
64
|
+
expect(received[0]).toEqual([{code: 'switch_2', value: true}]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('ignores messages for devices we did not subscribe', () => {
|
|
68
|
+
const mq = makeIdle();
|
|
69
|
+
const received = [];
|
|
70
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
71
|
+
mq._onMessage('topic', envelope({devId: 'OTHER', status: [{code: 'switch_1', value: true}]}, mq.config.password, {}));
|
|
72
|
+
expect(received).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a frame that cannot be decrypted is dropped without throwing', () => {
|
|
76
|
+
const mq = makeIdle();
|
|
77
|
+
const received = [];
|
|
78
|
+
mq.subscribeDevice('DEV1', status => received.push(status));
|
|
79
|
+
// Encrypted with the wrong password → auth/decrypt fails.
|
|
80
|
+
expect(() => mq._onMessage('topic', envelope({devId: 'DEV1', status: [{code: 'x', value: 1}]}, 'ZZZZZZZZwrongkeywrongkey', {}))).not.toThrow();
|
|
81
|
+
expect(received).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('malformed JSON envelope is ignored', () => {
|
|
85
|
+
const mq = makeIdle();
|
|
86
|
+
mq.subscribeDevice('DEV1', () => { throw new Error('should not be called'); });
|
|
87
|
+
expect(() => mq._onMessage('topic', Buffer.from('not json'))).not.toThrow();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('reports whether realtime is available (mqtt installed)', () => {
|
|
91
|
+
// mqtt is an (optional) dependency of this project, so it should load.
|
|
92
|
+
expect(typeof TuyaCloudMessaging.isAvailable()).toBe('boolean');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -732,6 +732,20 @@ Multi-valve Tuya irrigation/sprinkler controllers (the battery-powered Wi-Fi "fa
|
|
|
732
732
|
|
|
733
733
|
Because these devices are slow to respond, all zone changes that happen close together — turning the whole system on/off, or running a scene that toggles several zones — are merged into a **single** Tuya command instead of a burst of them.
|
|
734
734
|
|
|
735
|
+
> **⚠️ Can't connect locally?** Most of these are battery-powered **"sleepy"** devices that **cannot be controlled over the LAN at all** — they sleep to save battery and only ever reach Tuya's cloud. If discovery never finds yours (or it won't connect), control it over the **[Tuya Cloud](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Tuya-Cloud-Setup.md)** instead: add cloud credentials once and set `"cloud": true` on the device. Everything below works the same — data-points are just addressed by their Tuya *code* (e.g. `switch_1`), which the plugin logs on startup.
|
|
736
|
+
|
|
737
|
+
```json5
|
|
738
|
+
// Cloud example (also needs a top-level "cloud" credentials block — see the Tuya Cloud Setup guide)
|
|
739
|
+
{
|
|
740
|
+
"name": "Garden Irrigation",
|
|
741
|
+
"type": "IrrigationSystem",
|
|
742
|
+
"id": "bfae6739xxxxxxxxxxxxxx", // the cloud Device ID
|
|
743
|
+
"cloud": true,
|
|
744
|
+
"valveCount": 4,
|
|
745
|
+
"noRainSensor": true // many battery timers have no rain sensor
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
735
749
|
#### Minimal Configuration
|
|
736
750
|
|
|
737
751
|
The defaults match the common 4-zone layout (valves A–D on data-points `1`–`4`, battery on `46`, rain on `49`):
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Tuya Cloud Setup
|
|
2
|
+
|
|
3
|
+
This plugin is **LAN-first** — almost every Tuya device is controlled locally. But a few devices **cannot** be reached over the LAN at all:
|
|
4
|
+
|
|
5
|
+
> Battery-powered **"sleepy"** devices — most notably multi-zone **irrigation / faucet timers** — sleep almost all the time to save battery and only ever connect *outbound* to Tuya's cloud (over MQTT) for a brief moment when they wake. They never keep the local port open and never answer LAN discovery, so the local protocol can't reach them. (Tuya's own developer docs state LAN control is unavailable in low-power mode.)
|
|
6
|
+
|
|
7
|
+
For these devices the plugin can talk to the **Tuya Cloud** instead. It is **opt-in, per device** — your other devices stay 100% local.
|
|
8
|
+
|
|
9
|
+
* Initial state + control go through the Tuya OpenAPI (signed HTTPS).
|
|
10
|
+
* Live updates (including physical button presses) arrive over Tuya's **MQTT** message service — no polling.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Create a Tuya Cloud project
|
|
15
|
+
|
|
16
|
+
1. Sign up / log in at **[iot.tuya.com](https://iot.tuya.com/)** (the Tuya IoT Development Platform).
|
|
17
|
+
2. **Cloud → Development → Create Cloud Project.**
|
|
18
|
+
* **Development Method:** choose **Smart Home**.
|
|
19
|
+
* **Data Center:** pick the region your **Tuya Smart / Smart Life app account** is in (App → *Me → Settings → Account and Security → Region*). This matters — cross-region API calls are rejected.
|
|
20
|
+
3. After it's created, open the project — the **Overview** tab shows your **Access ID / Client ID** and **Access Secret / Client Secret**. You'll need both.
|
|
21
|
+
|
|
22
|
+
## 2. Authorize the required API services
|
|
23
|
+
|
|
24
|
+
In the project, go to **Service API → Go to Authorize** and make sure these are subscribed (all free):
|
|
25
|
+
|
|
26
|
+
* **IoT Core**
|
|
27
|
+
* **Authorization**
|
|
28
|
+
* **Smart Home Basic Service**
|
|
29
|
+
* **Device Status Notification** ← needed for realtime MQTT updates
|
|
30
|
+
|
|
31
|
+
> ⚠️ The free trial of *IoT Core* lasts ~6 months, after which you must click **Extend Trial Period** (Cloud → My Services). If it lapses you'll see *"No permissions. Your subscription … has expired."*
|
|
32
|
+
|
|
33
|
+
## 3. Link your app account (so the project can see your devices)
|
|
34
|
+
|
|
35
|
+
Still in the project: **Devices → Link App Account → Add App Account**, then in the **Tuya Smart / Smart Life** app go to **Me → ⊞ (scan icon, top-right)** and scan the QR code. Your irrigation timer should now appear under **Devices → All Devices**.
|
|
36
|
+
|
|
37
|
+
## 4. Find the device ID
|
|
38
|
+
|
|
39
|
+
**Devices → All Devices** → click your device → copy its **Device ID** (a string like `bfae6739…tfx`). (You can also find it in the Smart Life app: device → ✎/⚙ → *Device Information*.)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## 5. Configure the plugin
|
|
44
|
+
|
|
45
|
+
Add a **top-level `cloud` block** with your credentials, and set **`"cloud": true`** on the device. There are two project styles:
|
|
46
|
+
|
|
47
|
+
### Smart Home project (recommended — what most people have)
|
|
48
|
+
|
|
49
|
+
Authenticates as your app account (username/password), so it sees exactly the devices linked in step 3.
|
|
50
|
+
|
|
51
|
+
```json5
|
|
52
|
+
{
|
|
53
|
+
"platform": "TuyaLan",
|
|
54
|
+
"cloud": {
|
|
55
|
+
"accessId": "your-access-id",
|
|
56
|
+
"accessKey": "your-access-secret",
|
|
57
|
+
"region": "eu", // eu / us / cn / in / sg / eu-w / us-e
|
|
58
|
+
"username": "you@example.com", // your Tuya/Smart Life app login
|
|
59
|
+
"password": "your-app-password",
|
|
60
|
+
"countryCode": "48", // your phone country code (e.g. 1, 44, 48)
|
|
61
|
+
"schema": "tuyaSmart" // or "smartlife" if you use the Smart Life app
|
|
62
|
+
},
|
|
63
|
+
"devices": [
|
|
64
|
+
{
|
|
65
|
+
"name": "Garden Irrigation",
|
|
66
|
+
"type": "IrrigationSystem",
|
|
67
|
+
"id": "bfae6739xxxxxxxxxxxxxx", // the cloud Device ID
|
|
68
|
+
"cloud": true,
|
|
69
|
+
"valveCount": 4,
|
|
70
|
+
"noRainSensor": true // most battery timers have no rain DP
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Custom project
|
|
77
|
+
|
|
78
|
+
If you created a **Custom** project (devices linked by QR to the project's asset) just omit `username`/`password`/`countryCode`/`schema`:
|
|
79
|
+
|
|
80
|
+
```json5
|
|
81
|
+
"cloud": {
|
|
82
|
+
"accessId": "your-access-id",
|
|
83
|
+
"accessKey": "your-access-secret",
|
|
84
|
+
"region": "eu"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Per-device credentials (optional)
|
|
89
|
+
|
|
90
|
+
Instead of (or in addition to) the platform block, a device can carry its own credentials — handy if different devices live in different Tuya projects:
|
|
91
|
+
|
|
92
|
+
```json5
|
|
93
|
+
{
|
|
94
|
+
"name": "Garden Irrigation",
|
|
95
|
+
"type": "IrrigationSystem",
|
|
96
|
+
"id": "bfae6739xxxxxxxxxxxxxx",
|
|
97
|
+
"cloud": {
|
|
98
|
+
"accessId": "…",
|
|
99
|
+
"accessKey": "…",
|
|
100
|
+
"region": "eu",
|
|
101
|
+
"username": "…",
|
|
102
|
+
"password": "…",
|
|
103
|
+
"countryCode": "48"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
> No local `key` is needed for cloud devices — the cloud authenticates with your project credentials.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Data-points are addressed by "code" on the cloud
|
|
113
|
+
|
|
114
|
+
Over the LAN, data-points are numbered (1, 2, …). Over the **cloud** they're named **codes** (e.g. `switch_1`, `battery_percentage`). When a cloud device connects, the plugin **logs the exact codes** it reports, e.g.:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
Garden Irrigation: Tuya Cloud data-point codes → switch_1=false, switch_2=false, switch_3=false, switch_4=false, countdown_1=0, …, battery_percentage=99
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `IrrigationSystem` defaults already match the common 4-zone layout (`switch_1`…`switch_4`, battery `battery_percentage`). If your device differs, use the logged codes:
|
|
121
|
+
|
|
122
|
+
```json5
|
|
123
|
+
"valves": [
|
|
124
|
+
{ "name": "Front Lawn", "dp": "switch_1", "defaultDuration": 900 },
|
|
125
|
+
{ "name": "Back Lawn", "dp": "switch_2", "defaultDuration": 900 }
|
|
126
|
+
],
|
|
127
|
+
"dpBattery": "battery_percentage",
|
|
128
|
+
"noRainSensor": true // or: "dpRain": "rain_sensor_state"
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
See **[Irrigation Systems / Sprinklers](https://github.com/adrianjagielak/homebridge-tuya-plus/blob/main/wiki/Supported-Device-Types.md#irrigation-systems--sprinklers)** for all options (per-zone durations, master switch, etc.).
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Realtime updates (MQTT)
|
|
136
|
+
|
|
137
|
+
Live updates use Tuya's MQTT message service via the **`mqtt`** package, which is an *optional dependency* installed automatically. If it's missing (or `"realtime": false`), cloud devices still work and stay controllable, but external changes (a physical button, the device's own timer) won't show up until Homebridge restarts. To force-install it:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
sudo npm install -g mqtt
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The realtime stream connects out to Tuya's broker on **port 8883** — make sure your firewall allows that outbound (it's open on normal home networks).
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Troubleshooting
|
|
148
|
+
|
|
149
|
+
| Symptom | Cause / fix |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `failed to connect to Tuya Cloud: token request failed … (code 1004)` | Wrong **Access ID/Secret**, or host clock skew — the signature includes a timestamp, so keep the machine **NTP-synced**. |
|
|
152
|
+
| `… (code 1106) permission deny` / `No permissions` | API service not authorized or **trial expired** (step 2), or **wrong region**, or you logged in with the developer account instead of the **app** account. |
|
|
153
|
+
| Device connects but shows nothing / `0` devices | **Region mismatch**, or the device isn't linked to the project (step 3), or the linked account isn't the device's **owner** (shared/guest access hides devices). |
|
|
154
|
+
| `realtime disabled: the optional "mqtt" package is not installed` | Install `mqtt` (above), or ignore if you don't need live external updates. |
|
|
155
|
+
| Realtime never connects (control works, external changes don't) | Outbound **port 8883** blocked by a firewall. |
|
|
156
|
+
| Logs show different codes than expected | Use the codes from the startup log line shown above. |
|
|
157
|
+
|
|
158
|
+
### Security note
|
|
159
|
+
|
|
160
|
+
Your **Access Secret** and app password are sensitive. Keep them only in your Homebridge `config.json`. If you ever share a config for support, redact them — and you can always reset the Access Secret on the Tuya IoT platform.
|