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

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.
@@ -0,0 +1,290 @@
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
+ // Issue one or more commands: [{code, value}, …].
218
+ async sendCommands(deviceId, commands) {
219
+ if (!Array.isArray(commands) || !commands.length) return true;
220
+ const res = await this.request('POST', `/v1.0/devices/${encodeURIComponent(deviceId)}/commands`, {commands});
221
+ return !!(res && res.result);
222
+ }
223
+
224
+ // Fetch broker credentials for the realtime MQTT message service. Returns
225
+ // the raw `result` object (url, client_id, username, password, source_topic,
226
+ // expire_time, …) or throws. Requires the account uid (from the token call)
227
+ // and that the project has the "Message Service" API authorized.
228
+ async getMqttConfig(linkId) {
229
+ await this._ensureToken();
230
+ if (!this.uid) throw new Error('no account uid available for MQTT config');
231
+ const body = {
232
+ uid: this.uid,
233
+ link_id: linkId,
234
+ link_type: 'mqtt',
235
+ topics: 'device',
236
+ msg_encrypted_version: '2.0'
237
+ };
238
+ const res = await this.request('POST', '/v1.0/iot-03/open-hub/access-config', body);
239
+ if (!res || res.success !== true || !res.result) {
240
+ throw new Error(`MQTT config request failed: ${this._describeError(res)}`);
241
+ }
242
+ return res.result;
243
+ }
244
+
245
+ _describeError(res) {
246
+ if (!res) return 'no/empty response';
247
+ if (res.msg || res.code) return `${res.msg || ''} (code ${res.code})`.trim();
248
+ return 'unexpected response';
249
+ }
250
+
251
+ /* ------------------------- transport ------------------------------- */
252
+
253
+ _httpsRequest(method, urlPath, headers, bodyStr) {
254
+ return new Promise((resolve, reject) => {
255
+ let url;
256
+ try {
257
+ url = new URL(this.endpoint + urlPath);
258
+ } catch (ex) {
259
+ return reject(ex);
260
+ }
261
+
262
+ const req = https.request({
263
+ hostname: url.hostname,
264
+ port: url.port || 443,
265
+ path: url.pathname + url.search,
266
+ method,
267
+ headers
268
+ }, resp => {
269
+ let data = '';
270
+ resp.setEncoding('utf8');
271
+ resp.on('data', chunk => { data += chunk; });
272
+ resp.on('end', () => {
273
+ try {
274
+ resolve(JSON.parse(data));
275
+ } catch (ex) {
276
+ reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
277
+ }
278
+ });
279
+ });
280
+
281
+ req.on('error', reject);
282
+ req.setTimeout(20000, () => req.destroy(new Error('request timed out')));
283
+ if (bodyStr) req.write(bodyStr);
284
+ req.end();
285
+ });
286
+ }
287
+ }
288
+
289
+ TuyaCloudApi.REGION_ENDPOINTS = REGION_ENDPOINTS;
290
+ module.exports = TuyaCloudApi;
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const EventEmitter = require('events');
4
+
5
+ /*
6
+ * TuyaCloudDevice
7
+ * ---------------
8
+ * A drop-in replacement for TuyaAccessory (the LAN device class) that talks to
9
+ * a device through the Tuya Cloud instead of the local network. It deliberately
10
+ * exposes the exact same minimal contract every accessory relies on, so the
11
+ * existing accessories (IrrigationSystemAccessory, …) work unchanged on top:
12
+ *
13
+ * - `context` device config (name, id, type, …)
14
+ * - `state` current data-points { <code>: value, … }
15
+ * - `connected` boolean
16
+ * - `_connect()` start talking to the device
17
+ * - `update(dps)` write data-points
18
+ * - 'connect' / 'change' events (change → (changes, state))
19
+ *
20
+ * The one meaningful difference from the LAN class is the data-point KEY. The
21
+ * LAN protocol addresses data-points by numeric id (1, 2, …); the cloud speaks
22
+ * string "codes" (e.g. `switch_1`, `battery_percentage`). So `state` here is
23
+ * keyed by code, and accessories used over the cloud are configured with codes.
24
+ * The device logs its codes on connect to make that configuration obvious.
25
+ *
26
+ * Updates are delivered by Tuya's realtime MQTT message service (see
27
+ * TuyaCloudMessaging) — there is no periodic polling. Two REST calls are still
28
+ * inherent to the cloud, because MQTT only carries *changes* and is
29
+ * *receive-only*:
30
+ * - one `GET /status` to learn the initial state on connect (and again as a
31
+ * catch-up whenever the realtime stream (re)connects), and
32
+ * - `POST /commands` to actually control the device.
33
+ */
34
+
35
+ class TuyaCloudDevice extends EventEmitter {
36
+ constructor(props = {}) {
37
+ super();
38
+ this.log = props.log || console;
39
+ this.api = props.cloudApi;
40
+ this.messaging = props.messaging || null; // shared realtime stream
41
+
42
+ // Keep a plain config object on `context`, mirroring TuyaAccessory, but
43
+ // without the live helper objects we were handed.
44
+ this.context = {...props};
45
+ delete this.context.cloudApi;
46
+ delete this.context.messaging;
47
+ delete this.context.log;
48
+
49
+ this.state = {};
50
+ this.connected = false;
51
+ this._stopped = false;
52
+ this._retryTimer = null;
53
+
54
+ if (props.connect !== false) this._connect();
55
+ }
56
+
57
+ /* ------------------------------------------------------------------ *
58
+ * Connect: read initial state, announce, then let realtime keep it fresh.
59
+ * ------------------------------------------------------------------ */
60
+
61
+ async _connect() {
62
+ if (!this.api || !this.api.isConfigured()) {
63
+ return this.log.error(`${this.context.name}: Tuya Cloud is not configured (missing accessId/accessKey).`);
64
+ }
65
+
66
+ try {
67
+ const status = await this.api.getStatus(this.context.id);
68
+ this.state = this._statusToState(status);
69
+ this.connected = true;
70
+
71
+ this._logDiscoveredCodes(status);
72
+
73
+ this.emit('connect');
74
+ // Accessories register their characteristics off the first 'change'.
75
+ this.emit('change', {...this.state}, this.state);
76
+
77
+ this._subscribeRealtime();
78
+ } catch (ex) {
79
+ this.log.error(`${this.context.name}: failed to connect to Tuya Cloud: ${ex.message}`);
80
+ // Retry later — credentials/permissions/network may recover.
81
+ if (!this._stopped) {
82
+ this._retryTimer = setTimeout(() => this._connect(), 30000);
83
+ }
84
+ }
85
+ }
86
+
87
+ /* ------------------------------------------------------------------ *
88
+ * Realtime (shared MQTT stream)
89
+ * ------------------------------------------------------------------ */
90
+
91
+ _subscribeRealtime() {
92
+ if (!this.messaging) {
93
+ return this.log.warn(`${this.context.name}: realtime updates are unavailable (no MQTT). Control still works, but external changes (physical buttons, the device's own timers) won't be reflected until restart. Ensure the "mqtt" package is installed and realtime isn't disabled in the cloud config.`);
94
+ }
95
+ // Register the reconnect catch-up handler before starting, so the very
96
+ // first 'online' is caught. On any (re)connect we re-read the full state
97
+ // to recover anything missed while the stream was down.
98
+ this.messaging.on('online', () => this._refreshState());
99
+ this.messaging.subscribeDevice(this.context.id, status => this._applyStatus(status));
100
+ }
101
+
102
+ async _refreshState() {
103
+ if (this._stopped) return;
104
+ try {
105
+ const status = await this.api.getStatus(this.context.id);
106
+ this._applyStatus(status);
107
+ } catch (ex) {
108
+ this.log.debug(`${this.context.name}: cloud state refresh failed: ${ex.message}`);
109
+ }
110
+ }
111
+
112
+ /* ------------------------------------------------------------------ *
113
+ * State helpers
114
+ * ------------------------------------------------------------------ */
115
+
116
+ // Convert a Tuya `[{code, value}]` status array into a flat { code: value }.
117
+ _statusToState(status) {
118
+ const state = {};
119
+ (status || []).forEach(item => {
120
+ if (item && typeof item.code === 'string') state[item.code] = item.value;
121
+ });
122
+ return state;
123
+ }
124
+
125
+ // Apply a fresh snapshot (initial / catch-up) or a realtime delta, diff it
126
+ // against what we hold, and emit only what changed — exactly mirroring
127
+ // TuyaAccessory._change so accessories behave identically.
128
+ _applyStatus(status) {
129
+ const incoming = this._statusToState(status);
130
+ const changes = {};
131
+ Object.keys(incoming).forEach(code => {
132
+ if (incoming[code] !== this.state[code]) changes[code] = incoming[code];
133
+ });
134
+ if (Object.keys(changes).length) {
135
+ this.state = {...this.state, ...incoming};
136
+ this.emit('change', changes, this.state);
137
+ }
138
+ return changes;
139
+ }
140
+
141
+ _logDiscoveredCodes(status) {
142
+ try {
143
+ const list = (status || []).map(i => `${i.code}=${JSON.stringify(i.value)}`).join(', ');
144
+ this.log.info(`${this.context.name}: Tuya Cloud data-point codes → ${list || '(none reported)'}`);
145
+ } catch (_) { /* logging must never throw */ }
146
+ }
147
+
148
+ /* ------------------------------------------------------------------ *
149
+ * Writes
150
+ * ------------------------------------------------------------------ */
151
+
152
+ // Write data-points. `dps` is keyed by code → value (the accessory was
153
+ // configured with codes). We deliberately do NOT optimistically mutate
154
+ // `this.state`: the accessory already reflects the user's intent in HomeKit
155
+ // immediately, and leaving `state` untouched lets the realtime stream
156
+ // confirm the real device state without a spurious "revert" while a sleepy
157
+ // device catches up. Returns truthy like TuyaAccessory.update().
158
+ update(dps) {
159
+ if (!dps || typeof dps !== 'object') return true;
160
+
161
+ const commands = Object.keys(dps).map(code => ({code, value: dps[code]}));
162
+ if (!commands.length) return true;
163
+
164
+ if (!this.connected) {
165
+ this.log.debug(`${this.context.name}: skipping cloud write, not connected`);
166
+ return false;
167
+ }
168
+
169
+ this.api.sendCommands(this.context.id, commands)
170
+ .then(ok => {
171
+ if (!ok) this.log.warn(`${this.context.name}: Tuya Cloud rejected command ${JSON.stringify(commands)}`);
172
+ })
173
+ .catch(ex => this.log.error(`${this.context.name}: cloud command failed: ${ex.message}`));
174
+
175
+ return true;
176
+ }
177
+
178
+ /* ------------------------------------------------------------------ *
179
+ * Teardown (tidy; Homebridge doesn't strictly require it)
180
+ * ------------------------------------------------------------------ */
181
+
182
+ stop() {
183
+ this._stopped = true;
184
+ if (this._retryTimer) { clearTimeout(this._retryTimer); this._retryTimer = null; }
185
+ }
186
+ }
187
+
188
+ module.exports = TuyaCloudDevice;