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.
- package/.github/workflows/publish-dev.yml +298 -31
- package/AGENTS.md +120 -0
- package/CLAUDE.md +1 -0
- package/Changelog.md +21 -0
- package/Readme.MD +8 -24
- package/config.schema.json +122 -68
- package/index.js +536 -269
- package/lib/BaseAccessory.js +53 -16
- package/lib/ConvectorAccessory.js +1 -1
- package/lib/CustomMultiOutletAccessory.js +2 -1
- package/lib/IrrigationSystemAccessory.js +98 -95
- package/lib/MultiOutletAccessory.js +2 -1
- package/lib/OilDiffuserAccessory.js +10 -8
- package/lib/RGBTWLightAccessory.js +13 -11
- package/lib/RGBTWOutletAccessory.js +11 -9
- package/lib/SimpleBlindsAccessory.js +5 -5
- package/lib/SimpleDimmer2Accessory.js +1 -1
- package/lib/SimpleDimmerAccessory.js +10 -6
- package/lib/SimpleGarageDoorAccessory.js +78 -24
- package/lib/SimpleHeaterAccessory.js +3 -3
- package/lib/SwitchAccessory.js +2 -1
- package/lib/TWLightAccessory.js +2 -2
- package/lib/TuyaAccessory.js +0 -1
- package/lib/TuyaCloudApi.js +320 -0
- package/lib/TuyaCloudDevice.js +367 -0
- package/lib/TuyaCloudMessaging.js +219 -0
- package/lib/TuyaDevice.js +269 -0
- package/lib/ValveAccessory.js +16 -12
- package/lib/VerticalBlindsWithTilt.js +5 -3
- package/lib/WledDimmerAccessory.js +46 -81
- package/package.json +5 -3
- package/scripts/cleanup-pr-tags.js +122 -0
- package/test/BaseAccessory.test.js +94 -15
- package/test/IrrigationSystemAccessory.test.js +134 -31
- package/test/MultiOutletAccessory.test.js +18 -3
- package/test/RGBTWLightAccessory.test.js +3 -3
- package/test/SimpleFanLightAccessory.test.js +3 -3
- package/test/SimpleGarageDoorAccessory.test.js +63 -9
- package/test/TuyaCloudApi.test.js +221 -0
- package/test/TuyaCloudDevice.test.js +357 -0
- package/test/TuyaCloudMessaging.test.js +113 -0
- package/test/TuyaDevice.test.js +266 -0
- package/test/getCategory.test.js +1 -0
- package/test/index.test.js +241 -0
- package/test/support/mocks.js +13 -0
- package/wiki/Supported-Device-Types.md +60 -28
- 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;
|