homebridge-tuya-plus 3.14.0-dev.3 → 3.14.0-dev.31

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.
Files changed (47) hide show
  1. package/.github/workflows/publish-dev.yml +298 -31
  2. package/AGENTS.md +120 -0
  3. package/CLAUDE.md +1 -0
  4. package/Changelog.md +21 -0
  5. package/Readme.MD +8 -24
  6. package/config.schema.json +122 -68
  7. package/index.js +536 -269
  8. package/lib/BaseAccessory.js +53 -16
  9. package/lib/ConvectorAccessory.js +1 -1
  10. package/lib/CustomMultiOutletAccessory.js +2 -1
  11. package/lib/IrrigationSystemAccessory.js +98 -95
  12. package/lib/MultiOutletAccessory.js +2 -1
  13. package/lib/OilDiffuserAccessory.js +10 -8
  14. package/lib/RGBTWLightAccessory.js +13 -11
  15. package/lib/RGBTWOutletAccessory.js +11 -9
  16. package/lib/SimpleBlindsAccessory.js +5 -5
  17. package/lib/SimpleDimmer2Accessory.js +1 -1
  18. package/lib/SimpleDimmerAccessory.js +10 -6
  19. package/lib/SimpleGarageDoorAccessory.js +78 -24
  20. package/lib/SimpleHeaterAccessory.js +3 -3
  21. package/lib/SwitchAccessory.js +2 -1
  22. package/lib/TWLightAccessory.js +2 -2
  23. package/lib/TuyaAccessory.js +0 -1
  24. package/lib/TuyaCloudApi.js +320 -0
  25. package/lib/TuyaCloudDevice.js +367 -0
  26. package/lib/TuyaCloudMessaging.js +219 -0
  27. package/lib/TuyaDevice.js +269 -0
  28. package/lib/ValveAccessory.js +16 -12
  29. package/lib/VerticalBlindsWithTilt.js +5 -3
  30. package/lib/WledDimmerAccessory.js +46 -81
  31. package/package.json +5 -3
  32. package/scripts/cleanup-pr-tags.js +122 -0
  33. package/test/BaseAccessory.test.js +94 -15
  34. package/test/IrrigationSystemAccessory.test.js +134 -31
  35. package/test/MultiOutletAccessory.test.js +18 -3
  36. package/test/RGBTWLightAccessory.test.js +3 -3
  37. package/test/SimpleFanLightAccessory.test.js +3 -3
  38. package/test/SimpleGarageDoorAccessory.test.js +63 -9
  39. package/test/TuyaCloudApi.test.js +221 -0
  40. package/test/TuyaCloudDevice.test.js +357 -0
  41. package/test/TuyaCloudMessaging.test.js +113 -0
  42. package/test/TuyaDevice.test.js +266 -0
  43. package/test/getCategory.test.js +1 -0
  44. package/test/index.test.js +241 -0
  45. package/test/support/mocks.js +13 -0
  46. package/wiki/Supported-Device-Types.md +60 -28
  47. package/wiki/Tuya-Cloud-Setup.md +71 -0
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * TuyaCloudApi
5
+ * ------------
6
+ * A tiny, dependency-free client for the Tuya Cloud OpenAPI.
7
+ *
8
+ * This plugin is, and remains, a LAN-first plugin: almost every Tuya device is
9
+ * controlled locally over the network. A small class of devices, however,
10
+ * simply cannot be reached that way — most notably battery-powered "sleepy"
11
+ * devices (irrigation/faucet timers, door sensors, …). To save battery they
12
+ * spend nearly all their time asleep and only ever talk to Tuya's cloud (over
13
+ * MQTT) for a brief moment when they wake. They never keep the local TCP port
14
+ * (6668) open and never answer LAN discovery, so the cloud is the ONLY way to
15
+ * reach them. This client exists exclusively for those devices.
16
+ *
17
+ * Only the handful of endpoints the plugin needs are implemented:
18
+ * - GET /v1.0/token?grant_type=1 (auth — also yields the account uid)
19
+ * - GET /v1.0/devices/{id}/status (read all data-points at once)
20
+ * - POST /v1.0/devices/{id}/commands (write data-points)
21
+ * - POST /v1.0/iot-03/open-hub/access-config (optional realtime MQTT config)
22
+ *
23
+ * Everything is signed with the Tuya "new" HMAC-SHA256 signature scheme, using
24
+ * only Node's built-in `https` + `crypto`, so the plugin gains no new runtime
25
+ * dependency for the core cloud path.
26
+ *
27
+ * Docs:
28
+ * Signature : https://developer.tuya.com/en/docs/iot/new-singnature
29
+ * Control : https://developer.tuya.com/en/docs/cloud/device-control
30
+ * Regions : https://developer.tuya.com/en/docs/iot/api-request
31
+ */
32
+
33
+ const https = require('https');
34
+ const crypto = require('crypto');
35
+
36
+ // SHA-256 of an empty string — the Content-SHA256 used for GET / empty-body requests.
37
+ const EMPTY_BODY_SHA256 = crypto.createHash('sha256').update('').digest('hex');
38
+
39
+ // Data-center base URLs. The region MUST match the one the Tuya / Smart Life
40
+ // app account is registered in (App → Me → Settings → Account → Region);
41
+ // cross-region calls are rejected by Tuya.
42
+ const REGION_ENDPOINTS = {
43
+ cn: 'https://openapi.tuyacn.com', // China
44
+ us: 'https://openapi.tuyaus.com', // Western America (default)
45
+ 'us-e': 'https://openapi-ueaz.tuyaus.com', // Eastern America (Azure)
46
+ eu: 'https://openapi.tuyaeu.com', // Central Europe
47
+ 'eu-w': 'https://openapi-weaz.tuyaeu.com', // Western Europe (Azure)
48
+ in: 'https://openapi.tuyain.com', // India
49
+ sg: 'https://openapi-sg.iotbing.com' // Singapore
50
+ };
51
+
52
+ // Tuya error codes that mean "the access token is no longer usable"; when we
53
+ // see one we drop the cached token and retry the call once.
54
+ const TOKEN_INVALID_CODES = new Set([1010, 1011, 1004]);
55
+
56
+ class TuyaCloudApi {
57
+ constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log} = {}) {
58
+ this.log = log || console;
59
+ this.accessId = ('' + (accessId || '')).trim();
60
+ this.accessKey = ('' + (accessKey || '')).trim();
61
+
62
+ // Optional "Smart Home" project credentials. When a username/password
63
+ // are supplied we authenticate as that app account (so the cloud sees
64
+ // the devices linked to it); otherwise we use the simpler "Custom"
65
+ // project token flow (grant_type=1).
66
+ this.username = ('' + (username || '')).trim();
67
+ this.password = ('' + (password || '')).trim();
68
+ this.countryCode = ('' + (countryCode || '')).trim();
69
+ this.schema = (('' + (schema || '')).trim()) || 'tuyaSmart';
70
+
71
+ const r = ('' + (region || 'us')).trim().toLowerCase();
72
+ this.region = r;
73
+ this.endpoint = (('' + (endpoint || '')).trim()) || REGION_ENDPOINTS[r] || REGION_ENDPOINTS.us;
74
+ // Strip a trailing slash so path concatenation is predictable.
75
+ this.endpoint = this.endpoint.replace(/\/+$/, '');
76
+
77
+ this._token = null; // current access_token
78
+ this.uid = null; // linked app-account uid (needed for realtime MQTT)
79
+ this._tokenExpiry = 0; // ms epoch when the token must be refreshed
80
+ this._tokenPromise = null; // in-flight token request (de-dupes concurrent callers)
81
+ }
82
+
83
+ isConfigured() {
84
+ return !!(this.accessId && this.accessKey);
85
+ }
86
+
87
+ // A stable key identifying this credential+region pair, so several devices
88
+ // that share the same cloud project can share one client (one token, one
89
+ // realtime connection).
90
+ static keyFor({accessId, region, endpoint, username} = {}) {
91
+ const r = ('' + (region || 'us')).trim().toLowerCase();
92
+ const e = (('' + (endpoint || '')).trim()) || REGION_ENDPOINTS[r] || REGION_ENDPOINTS.us;
93
+ return `${('' + (accessId || '')).trim()}:${('' + (username || '')).trim()}@${e.replace(/\/+$/, '')}`;
94
+ }
95
+
96
+ /* ----------------------------- signing ----------------------------- */
97
+
98
+ _hmac(str) {
99
+ return crypto.createHmac('sha256', this.accessKey).update(str, 'utf8').digest('hex').toUpperCase();
100
+ }
101
+
102
+ // Build the signed headers for a request. When `withToken` is true the
103
+ // current access_token is folded into the signature (business calls);
104
+ // otherwise the token-request variant is used (the token call itself).
105
+ // `t` and `nonce` are injectable for deterministic testing.
106
+ _signedHeaders({method, urlPath, bodyStr, withToken, t, nonce} = {}) {
107
+ t = t || Date.now().toString();
108
+ nonce = nonce || crypto.randomUUID();
109
+
110
+ const contentSha = bodyStr
111
+ ? crypto.createHash('sha256').update(bodyStr, 'utf8').digest('hex')
112
+ : EMPTY_BODY_SHA256;
113
+
114
+ // stringToSign = METHOD \n Content-SHA256 \n Signature-Headers \n Url
115
+ // Signature-Headers is intentionally empty (we fold none into the sign).
116
+ const stringToSign = [method.toUpperCase(), contentSha, '', urlPath].join('\n');
117
+ const str = (withToken
118
+ ? this.accessId + this._token + t + nonce
119
+ : this.accessId + t + nonce) + stringToSign;
120
+
121
+ const headers = {
122
+ 'client_id': this.accessId,
123
+ 'sign': this._hmac(str),
124
+ 't': t,
125
+ 'sign_method': 'HMAC-SHA256',
126
+ 'nonce': nonce,
127
+ 'Content-Type': 'application/json'
128
+ };
129
+ if (withToken) headers['access_token'] = this._token;
130
+ return headers;
131
+ }
132
+
133
+ /* ------------------------------ token ------------------------------ */
134
+
135
+ async _ensureToken() {
136
+ if (this._token && Date.now() < this._tokenExpiry) return this._token;
137
+ if (this._tokenPromise) return this._tokenPromise;
138
+
139
+ this._tokenPromise = (async () => {
140
+ const res = this._useSmartHomeLogin()
141
+ ? await this._loginSmartHome()
142
+ : await this._loginCustom();
143
+ if (!res || res.success !== true || !res.result || !res.result.access_token) {
144
+ throw new Error(`token request failed: ${this._describeError(res)}`);
145
+ }
146
+ this._token = res.result.access_token;
147
+ this.uid = res.result.uid || this.uid;
148
+ // Refresh a minute early so a token never expires mid-request.
149
+ const ttl = (parseInt(res.result.expire_time) || 7200) * 1000;
150
+ this._tokenExpiry = Date.now() + Math.max(60000, ttl - 60000);
151
+ return this._token;
152
+ })();
153
+
154
+ try {
155
+ return await this._tokenPromise;
156
+ } finally {
157
+ this._tokenPromise = null;
158
+ }
159
+ }
160
+
161
+ _useSmartHomeLogin() {
162
+ return !!(this.username && this.password);
163
+ }
164
+
165
+ // "Custom" project: GET /v1.0/token?grant_type=1 (token-variant signature).
166
+ _loginCustom() {
167
+ const urlPath = '/v1.0/token?grant_type=1';
168
+ const headers = this._signedHeaders({method: 'GET', urlPath, bodyStr: '', withToken: false});
169
+ return this._httpsRequest('GET', urlPath, headers, null);
170
+ }
171
+
172
+ // "Smart Home" project: authenticate as the linked app account. The password
173
+ // is sent as a lowercase hex MD5; the request is signed with the token
174
+ // (no-access-token) variant since we don't hold a token yet.
175
+ _loginSmartHome() {
176
+ const urlPath = '/v1.0/iot-01/associated-users/actions/authorized-login';
177
+ const body = {
178
+ country_code: this.countryCode,
179
+ username: this.username,
180
+ password: crypto.createHash('md5').update('' + this.password).digest('hex'),
181
+ schema: this.schema
182
+ };
183
+ const bodyStr = JSON.stringify(body);
184
+ const headers = this._signedHeaders({method: 'POST', urlPath, bodyStr, withToken: false});
185
+ return this._httpsRequest('POST', urlPath, headers, bodyStr);
186
+ }
187
+
188
+ _invalidateToken() {
189
+ this._token = null;
190
+ this._tokenExpiry = 0;
191
+ }
192
+
193
+ /* --------------------------- requests ------------------------------ */
194
+
195
+ async request(method, urlPath, body, _retried) {
196
+ await this._ensureToken();
197
+ const bodyStr = body ? JSON.stringify(body) : '';
198
+ const headers = this._signedHeaders({method, urlPath, bodyStr, withToken: true});
199
+ const res = await this._httpsRequest(method, urlPath, headers, bodyStr || null);
200
+
201
+ if (res && res.success === false) {
202
+ if (!_retried && TOKEN_INVALID_CODES.has(res.code)) {
203
+ this._invalidateToken();
204
+ return this.request(method, urlPath, body, true);
205
+ }
206
+ throw new Error(`${method} ${urlPath} failed: ${this._describeError(res)}`);
207
+ }
208
+ return res;
209
+ }
210
+
211
+ // Read every reported data-point in one call → array of {code, value}.
212
+ async getStatus(deviceId) {
213
+ const res = await this.request('GET', `/v1.0/devices/${encodeURIComponent(deviceId)}/status`);
214
+ return (res && Array.isArray(res.result)) ? res.result : [];
215
+ }
216
+
217
+ // Read the device's "thing shadow" properties. Like getStatus, but each item
218
+ // ALSO carries its numeric `dp_id` — which is exactly the id the LAN protocol
219
+ // uses to address that data-point. That mapping is what lets the plugin bridge
220
+ // a LAN-style (numeric DP) configuration to the cloud (string codes), and back,
221
+ // transparently — so the same accessory works over either transport.
222
+ //
223
+ // Returns an array of {code, dp_id, value} on success, or null when the
224
+ // endpoint isn't available (older projects, or the device-shadow API isn't
225
+ // authorised). Callers fall back to getStatus() in that case (code-only, no
226
+ // numeric mapping). Never throws.
227
+ // Docs: https://developer.tuya.com/en/docs/cloud/116cc8bf6f?id=Kcp2kwfrpe719
228
+ async getShadowProperties(deviceId) {
229
+ try {
230
+ const res = await this.request('GET', `/v2.0/cloud/thing/${encodeURIComponent(deviceId)}/shadow/properties`);
231
+ return (res && res.result && Array.isArray(res.result.properties)) ? res.result.properties : null;
232
+ } catch (ex) {
233
+ this.log.debug(`Tuya Cloud shadow/properties unavailable for ${deviceId} (${ex.message}); falling back to /status.`);
234
+ return null;
235
+ }
236
+ }
237
+
238
+ // Read the device record (name, product, and crucially `online`). Used to
239
+ // learn whether the device is currently reachable. Returns the raw `result`
240
+ // object or null. Requires the project to have the device-management API
241
+ // authorized; callers treat a failure as "online unknown".
242
+ async getDeviceInfo(deviceId) {
243
+ const res = await this.request('GET', `/v1.0/devices/${encodeURIComponent(deviceId)}`);
244
+ return (res && res.result && typeof res.result === 'object') ? res.result : null;
245
+ }
246
+
247
+ // Issue one or more commands: [{code, value}, …].
248
+ async sendCommands(deviceId, commands) {
249
+ if (!Array.isArray(commands) || !commands.length) return true;
250
+ const res = await this.request('POST', `/v1.0/devices/${encodeURIComponent(deviceId)}/commands`, {commands});
251
+ return !!(res && res.result);
252
+ }
253
+
254
+ // Fetch broker credentials for the realtime MQTT message service. Returns
255
+ // the raw `result` object (url, client_id, username, password, source_topic,
256
+ // expire_time, …) or throws. Requires the account uid (from the token call)
257
+ // and that the project has the "Message Service" API authorized.
258
+ async getMqttConfig(linkId) {
259
+ await this._ensureToken();
260
+ if (!this.uid) throw new Error('no account uid available for MQTT config');
261
+ const body = {
262
+ uid: this.uid,
263
+ link_id: linkId,
264
+ link_type: 'mqtt',
265
+ topics: 'device',
266
+ msg_encrypted_version: '2.0'
267
+ };
268
+ const res = await this.request('POST', '/v1.0/iot-03/open-hub/access-config', body);
269
+ if (!res || res.success !== true || !res.result) {
270
+ throw new Error(`MQTT config request failed: ${this._describeError(res)}`);
271
+ }
272
+ return res.result;
273
+ }
274
+
275
+ _describeError(res) {
276
+ if (!res) return 'no/empty response';
277
+ if (res.msg || res.code) return `${res.msg || ''} (code ${res.code})`.trim();
278
+ return 'unexpected response';
279
+ }
280
+
281
+ /* ------------------------- transport ------------------------------- */
282
+
283
+ _httpsRequest(method, urlPath, headers, bodyStr) {
284
+ return new Promise((resolve, reject) => {
285
+ let url;
286
+ try {
287
+ url = new URL(this.endpoint + urlPath);
288
+ } catch (ex) {
289
+ return reject(ex);
290
+ }
291
+
292
+ const req = https.request({
293
+ hostname: url.hostname,
294
+ port: url.port || 443,
295
+ path: url.pathname + url.search,
296
+ method,
297
+ headers
298
+ }, resp => {
299
+ let data = '';
300
+ resp.setEncoding('utf8');
301
+ resp.on('data', chunk => { data += chunk; });
302
+ resp.on('end', () => {
303
+ try {
304
+ resolve(JSON.parse(data));
305
+ } catch (ex) {
306
+ reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
307
+ }
308
+ });
309
+ });
310
+
311
+ req.on('error', reject);
312
+ req.setTimeout(20000, () => req.destroy(new Error('request timed out')));
313
+ if (bodyStr) req.write(bodyStr);
314
+ req.end();
315
+ });
316
+ }
317
+ }
318
+
319
+ TuyaCloudApi.REGION_ENDPOINTS = REGION_ENDPOINTS;
320
+ module.exports = TuyaCloudApi;