homebridge-rainpoint 1.0.0

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 (60) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +143 -0
  3. package/config.schema.json +148 -0
  4. package/dist/IrrigationSystemAccessory.d.ts +26 -0
  5. package/dist/IrrigationSystemAccessory.d.ts.map +1 -0
  6. package/dist/IrrigationSystemAccessory.js +168 -0
  7. package/dist/IrrigationSystemAccessory.js.map +1 -0
  8. package/dist/SensorAccessory.d.ts +22 -0
  9. package/dist/SensorAccessory.d.ts.map +1 -0
  10. package/dist/SensorAccessory.js +74 -0
  11. package/dist/SensorAccessory.js.map +1 -0
  12. package/dist/ValveAccessory.d.ts +25 -0
  13. package/dist/ValveAccessory.d.ts.map +1 -0
  14. package/dist/ValveAccessory.js +119 -0
  15. package/dist/ValveAccessory.js.map +1 -0
  16. package/dist/api/RainPointClientInterface.d.ts +65 -0
  17. package/dist/api/RainPointClientInterface.d.ts.map +1 -0
  18. package/dist/api/RainPointClientInterface.js +3 -0
  19. package/dist/api/RainPointClientInterface.js.map +1 -0
  20. package/dist/api/RainPointHomeClient.d.ts +36 -0
  21. package/dist/api/RainPointHomeClient.d.ts.map +1 -0
  22. package/dist/api/RainPointHomeClient.js +359 -0
  23. package/dist/api/RainPointHomeClient.js.map +1 -0
  24. package/dist/api/RainPointTyClient.d.ts +113 -0
  25. package/dist/api/RainPointTyClient.d.ts.map +1 -0
  26. package/dist/api/RainPointTyClient.js +947 -0
  27. package/dist/api/RainPointTyClient.js.map +1 -0
  28. package/dist/api/constants.d.ts +53 -0
  29. package/dist/api/constants.d.ts.map +1 -0
  30. package/dist/api/constants.js +105 -0
  31. package/dist/api/constants.js.map +1 -0
  32. package/dist/api/dp-parser.d.ts +14 -0
  33. package/dist/api/dp-parser.d.ts.map +1 -0
  34. package/dist/api/dp-parser.js +100 -0
  35. package/dist/api/dp-parser.js.map +1 -0
  36. package/dist/api/index.d.ts +7 -0
  37. package/dist/api/index.d.ts.map +1 -0
  38. package/dist/api/index.js +23 -0
  39. package/dist/api/index.js.map +1 -0
  40. package/dist/api/types.d.ts +330 -0
  41. package/dist/api/types.d.ts.map +1 -0
  42. package/dist/api/types.js +3 -0
  43. package/dist/api/types.js.map +1 -0
  44. package/dist/index.d.ts +4 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +7 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/naming.d.ts +14 -0
  49. package/dist/naming.d.ts.map +1 -0
  50. package/dist/naming.js +22 -0
  51. package/dist/naming.js.map +1 -0
  52. package/dist/platform.d.ts +52 -0
  53. package/dist/platform.d.ts.map +1 -0
  54. package/dist/platform.js +277 -0
  55. package/dist/platform.js.map +1 -0
  56. package/dist/settings.d.ts +3 -0
  57. package/dist/settings.d.ts.map +1 -0
  58. package/dist/settings.js +6 -0
  59. package/dist/settings.js.map +1 -0
  60. package/package.json +59 -0
@@ -0,0 +1,947 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.RainPointTyClient = void 0;
7
+ const https_1 = __importDefault(require("https"));
8
+ const crypto_1 = __importDefault(require("crypto"));
9
+ const url_1 = require("url");
10
+ const uuid_1 = require("uuid");
11
+ const constants_1 = require("./constants");
12
+ const APP_KEY = 'u9hs9dxd7cpcnj5ewcak';
13
+ const APP_SECRET = '5r4deyaxxdktnd4gxtn3vuatuvvgeprm';
14
+ const TTID = `sdk_international@${APP_KEY}`;
15
+ const CH_KEY = '03048afb';
16
+ // ET_VERSION is hardcoded "3" in ThingApiParams.smali (iput-object "3" -> ET_VERSION).
17
+ // initUrlParams() puts et=ET_VERSION into URL params, and when et=="3" also sets cp=gzip.
18
+ const API_ET_VERSION = '3';
19
+ // Per-API version strings extracted from the decompiled APK call sites
20
+ // (ApiParams.<init>(apiName, version) in pqdbppq.smali / dqdpbbd.smali / qqddbpb.smali /
21
+ // FamilyExtraBusiness.smali). The Tuya gateway validates the (a, v) pair server-side
22
+ // and returns API_OR_API_VERSION_WRONG if they don't match its registered schema.
23
+ const API_VERSIONS = {
24
+ // The app uses username.token.get (v2.0) with postData {countryCode, username, isUid},
25
+ // NOT email.token.create. Verified via Frida capture6: the live app's token request
26
+ // sends field "username" (not "email") and uses API thing.m.user.username.token.get.
27
+ // Using the wrong endpoint returns a publicKey that the login server rejects with
28
+ // USER_PASSWD_WRONG because the passwd was RSA-encrypted with the wrong key.
29
+ 'thing.m.user.username.token.get': '2.0',
30
+ 'thing.m.user.email.password.login': '3.0',
31
+ // location.extend.list (v1.0) returns an object {<homeId>: {defaultHome:"0"}}, NOT an
32
+ // array. Verified via the live app's response: {"result":{"157747002":{"defaultHome":"0"}}}.
33
+ // The app parses it as a HashMap (FamilyExtraBusiness.smali asyncHashMap).
34
+ 'thing.m.location.extend.list': '1.0',
35
+ // m.life.location.get (v3.4) fetches a single home's details by gid. Returns
36
+ // HomeResponseBean with name + rooms + devices. Used to resolve home names.
37
+ 'm.life.location.get': '3.4',
38
+ 'thing.m.my.group.device.list': '2.1',
39
+ 'thing.m.device.dp.get': '1.0',
40
+ 'thing.m.device.dp.publish': '1.0',
41
+ };
42
+ // HMAC key format confirmed via Frida memory scan of libthing_security.so:
43
+ // PACKAGE_NAME + "_" + CERT_SHA256_COLON + "_" + BMP_SECRET_ASCII + "_" + APP_SECRET
44
+ // Discovered by dumping the native lib's writable memory after initJNI and testing each
45
+ // printable run against a captured (signString, signature) pair. The earlier guess
46
+ // (CERT_SHA256_PLAIN + CERT_COLON + BMP_HEX + APP_SECRET) was wrong on two counts:
47
+ // - first segment is the PACKAGE NAME "com.baldr.rainpoint", NOT the plain-hex cert
48
+ // - BMP secret is the raw ASCII string, NOT hex-encoded bytes
49
+ const PACKAGE_NAME = 'com.baldr.rainpoint';
50
+ const CERT_SHA256_COLON = '2F:0B:FA:2B:F9:48:5B:D4:AC:29:11:EB:CE:32:D4:60:38:65:FE:9B:38:47:5F:AF:DF:E0:2C:D6:02:E3:93:B6';
51
+ const BMP_SECRET_ASCII = 'sv3cc3v445yqkggja8aexmjdgeqygsvv';
52
+ const HMAC_KEY = `${PACKAGE_NAME}_${CERT_SHA256_COLON}_${BMP_SECRET_ASCII}_${APP_SECRET}`;
53
+ const REGION_ENDPOINTS = {
54
+ EU: 'https://a1-eu.baldrgroup.net/api.json',
55
+ AZ: 'https://a1-us.baldrgroup.net/api.json',
56
+ IN: 'https://a1-in.baldrgroup.net/api.json',
57
+ RU: 'https://a1.iot334.com/api.json',
58
+ US: 'https://a1-us.baldrgroup.net/api.json',
59
+ };
60
+ function md5(data) {
61
+ return crypto_1.default.createHash('md5').update(data, 'utf8').digest('hex');
62
+ }
63
+ function mobileHash(data) {
64
+ const pre = md5(data);
65
+ return pre.slice(8, 16) + pre.slice(0, 8) + pre.slice(24, 32) + pre.slice(16, 24);
66
+ }
67
+ /**
68
+ * Faithful port of ThingApiParams.checkAPIName() (smali line 497-580).
69
+ *
70
+ * If the apiName starts with "thing", the SDK prepends "@xx2@" and then runs
71
+ * replaceAll("@xx2@thing", "smartlife"). Net effect: the "thing" prefix is
72
+ * rewritten to "smartlife". Non-thing names pass through unchanged.
73
+ *
74
+ * The rewritten name is what the Tuya cloud gateway receives in the `a` field
75
+ * and what participates in the HMAC sign string. The per-API version lookup
76
+ * still uses the ORIGINAL thing.m.* name (apiVersion is set in the constructor
77
+ * before checkAPIName runs).
78
+ */
79
+ function rewriteApiName(action) {
80
+ if (action.startsWith('thing')) {
81
+ return '@xx2@' + action;
82
+ }
83
+ return action;
84
+ }
85
+ function finalizeApiName(action) {
86
+ return rewriteApiName(action).replaceAll('@xx2@thing', 'smartlife');
87
+ }
88
+ function generateDeviceId() {
89
+ const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
90
+ let id = '';
91
+ for (let i = 0; i < 44; i++) {
92
+ id += chars[Math.floor(Math.random() * chars.length)];
93
+ }
94
+ return id;
95
+ }
96
+ function formUrlEncode(params) {
97
+ // application/x-www-form-urlencoded: spaces become +, same as OkHttp FormBody
98
+ return Object.entries(params)
99
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v).replace(/%20/g, '+')}`)
100
+ .join('&');
101
+ }
102
+ // ---------------------------------------------------------------------------
103
+ // Native crypto port: getEncryptoKey (AES-GCM key derivation)
104
+ // ---------------------------------------------------------------------------
105
+ // Reversed from libthing_security.so FUN_00115368 (getEncryptoKey JNI) +
106
+ // FUN_001179f8 (cipher dispatcher) + FUN_00117780 (HMAC init).
107
+ //
108
+ // The native function uses an HMAC-SHA256 cipher (cipher id=6, name "SHA256"
109
+ // at lib offset 0x1090fe). The "key string" global at 0x139070 (set during
110
+ // doCommandNative init) holds the full HMAC_KEY (verified via memory read).
111
+ //
112
+ // Algorithm (verified against 9 captured test vectors, all match):
113
+ // hash = HMAC-SHA256(key=requestId, msg=HMAC_KEY) // 32-byte digest
114
+ // key = ASCII hex of first 8 bytes // 16-byte string like "31786ab9ad78b501"
115
+ //
116
+ // When ecode is set (post-login sessions), the native code appends "_" + ecode
117
+ // to the key string before HMAC. For unauthenticated requests ecode is null,
118
+ // so the key string is just HMAC_KEY.
119
+ // ---------------------------------------------------------------------------
120
+ function deriveEncryptoKey(requestId, ecode) {
121
+ // The native code builds the key string as: HMAC_KEY (+ "_" + ecode if ecode set).
122
+ // When ecode is null/empty, it uses HMAC_KEY directly (the global at 0x139070).
123
+ const keyString = ecode ? `${HMAC_KEY}_${ecode}` : HMAC_KEY;
124
+ const hash = crypto_1.default.createHmac('sha256', requestId).update(keyString, 'utf8').digest('hex');
125
+ // The 16-byte AES key is the ASCII bytes of the first 16 hex characters.
126
+ return Buffer.from(hash.substring(0, 16), 'ascii');
127
+ }
128
+ // AES-GCM encrypt: returns base64(nonce[12] + ciphertext + tag[16])
129
+ // Matches the app's decryptBytesAppendedNonce2Bytes format (nonce prepended).
130
+ function aesGcmEncryptBase64(key, plaintext) {
131
+ const iv = crypto_1.default.randomBytes(12);
132
+ const cipher = crypto_1.default.createCipheriv('aes-128-gcm', key, iv);
133
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
134
+ const tag = cipher.getAuthTag();
135
+ return Buffer.concat([iv, ct, tag]).toString('base64');
136
+ }
137
+ // AES-GCM decrypt: input is base64(nonce[12] + ciphertext + tag[16])
138
+ // Matches the app's decryptBytesAppendedNonce2Bytes format.
139
+ // Note: decrypted data may be gzipped (et=3) — caller must check gzip magic (1f 8b).
140
+ function aesGcmDecryptBase64(key, b64) {
141
+ const raw = Buffer.from(b64, 'base64');
142
+ const iv = raw.subarray(0, 12);
143
+ const tag = raw.subarray(raw.length - 16);
144
+ const ct = raw.subarray(12, raw.length - 16);
145
+ const decipher = crypto_1.default.createDecipheriv('aes-128-gcm', key, iv);
146
+ decipher.setAuthTag(tag);
147
+ let decrypted = Buffer.concat([decipher.update(ct), decipher.final()]);
148
+ // et=3: check for gzip magic and decompress
149
+ if (decrypted.length >= 2 && decrypted[0] === 0x1f && decrypted[1] === 0x8b) {
150
+ decrypted = require('zlib').gunzipSync(decrypted);
151
+ }
152
+ return decrypted.toString('utf8');
153
+ }
154
+ class TuyaApiError extends Error {
155
+ constructor(message, code) {
156
+ super(message);
157
+ this.code = code;
158
+ }
159
+ }
160
+ class RainPointTyClient {
161
+ constructor(config, log) {
162
+ this.config = config;
163
+ this.log = log;
164
+ this.sid = '';
165
+ this.ecode = '';
166
+ this.homeId = '';
167
+ // Cache of deviceId -> NormalizedDevice, populated by getDevices(). Used by
168
+ // turnZoneOn/Off to resolve a 1-based port number to the device's per-zone
169
+ // switch DP (RainPoint TY uses 104/155/... rather than DP 1/2/...).
170
+ this.deviceCache = new Map();
171
+ this.sessionRestored = false;
172
+ const region = config.region || 'EU';
173
+ this.endpoint = REGION_ENDPOINTS[region] || REGION_ENDPOINTS.EU;
174
+ // countryCode defaults to "1" (US/Canada). The Tuya/Thingclips account is keyed by
175
+ // countryCode + email — a wrong countryCode yields USER_PASSWD_WRONG because the
176
+ // (code, email) pair doesn't match any account, even when the email exists elsewhere.
177
+ this.countryCode = config.countryCode || '1';
178
+ this.deviceId = RainPointTyClient.getOrCreateDeviceId();
179
+ // Persist the session (sid/ecode/endpoint) to disk so homebridge restarts don't
180
+ // trigger a fresh login every time. Tuya sessions last days/weeks (the app stays
181
+ // logged in across long gaps). The session is keyed by email so multiple accounts
182
+ // don't collide.
183
+ if (config.storageDir) {
184
+ const safeEmail = config.email.replace(/[^a-zA-Z0-9@._-]/g, '_');
185
+ this.sessionFile = `${config.storageDir}/rainpoint-ty-session-${safeEmail}.json`;
186
+ this.loadSession();
187
+ }
188
+ else {
189
+ this.sessionFile = null;
190
+ }
191
+ }
192
+ /** Load a saved session (sid/ecode/endpoint) from disk, if present. */
193
+ loadSession() {
194
+ if (!this.sessionFile) {
195
+ return;
196
+ }
197
+ try {
198
+ const fs = require('fs');
199
+ if (!fs.existsSync(this.sessionFile)) {
200
+ return;
201
+ }
202
+ const raw = fs.readFileSync(this.sessionFile, 'utf8');
203
+ const saved = JSON.parse(raw);
204
+ if (saved.sid) {
205
+ this.sid = saved.sid;
206
+ this.ecode = saved.ecode || '';
207
+ if (saved.endpoint) {
208
+ this.endpoint = saved.endpoint;
209
+ }
210
+ this.sessionRestored = true;
211
+ this.log.info('[TY] Restored saved session (sid=%s..., endpoint=%s)', this.sid.slice(0, 12), this.endpoint);
212
+ }
213
+ }
214
+ catch (e) {
215
+ this.log.warn('[TY] Failed to load saved session: %s', e);
216
+ }
217
+ }
218
+ /** Persist the current session (sid/ecode/endpoint) to disk. */
219
+ saveSession() {
220
+ if (!this.sessionFile) {
221
+ return;
222
+ }
223
+ try {
224
+ const fs = require('fs');
225
+ const data = JSON.stringify({
226
+ sid: this.sid,
227
+ ecode: this.ecode,
228
+ endpoint: this.endpoint,
229
+ });
230
+ fs.writeFileSync(this.sessionFile, data, 'utf8');
231
+ this.log.debug('[TY] Saved session to %s', this.sessionFile);
232
+ }
233
+ catch (e) {
234
+ this.log.warn('[TY] Failed to save session: %s', e);
235
+ }
236
+ }
237
+ /** Clear the saved session file (called when the server rejects the sid). */
238
+ clearSession() {
239
+ this.sid = '';
240
+ this.ecode = '';
241
+ this.sessionRestored = false;
242
+ if (!this.sessionFile) {
243
+ return;
244
+ }
245
+ try {
246
+ const fs = require('fs');
247
+ if (fs.existsSync(this.sessionFile)) {
248
+ fs.unlinkSync(this.sessionFile);
249
+ }
250
+ }
251
+ catch (e) {
252
+ this.log.warn('[TY] Failed to clear saved session: %s', e);
253
+ }
254
+ }
255
+ static getOrCreateDeviceId() {
256
+ if (RainPointTyClient.deviceIdStorage) {
257
+ return RainPointTyClient.deviceIdStorage;
258
+ }
259
+ const id = generateDeviceId();
260
+ RainPointTyClient.deviceIdStorage = id;
261
+ return id;
262
+ }
263
+ async login() {
264
+ // If a session was restored from disk (or is otherwise already present), skip
265
+ // the login flow entirely. The saved sid/ecode will be validated lazily on the
266
+ // first authenticated request; if the server rejects them, request() clears
267
+ // the session and re-logs in (see the SESSION_ERROR_CODES retry in request()).
268
+ // This avoids re-running token.get + password.login on every homebridge restart.
269
+ if (this.sid) {
270
+ this.log.debug('[TY] Session already present (sid=%s...) — skipping login', this.sid.slice(0, 12));
271
+ return;
272
+ }
273
+ // No fallback: a second request on failure would trigger anti-abuse blocking.
274
+ // The encrypted login flow (token.create → RSA-encrypt password → password.login)
275
+ // is the only flow the app uses.
276
+ await this.loginEx();
277
+ }
278
+ async loginEx() {
279
+ this.log.debug('[TY] Creating username token for encrypted login...');
280
+ // The app calls thing.m.user.username.token.get (v2.0) with postData
281
+ // {countryCode, username, isUid:false}. Verified via Frida capture6.
282
+ // Using email.token.create returns a publicKey bound to a different validation
283
+ // path, causing USER_PASSWD_WRONG at login despite a correct RSA passwd.
284
+ const tokenResult = await this.request('thing.m.user.username.token.get', {
285
+ countryCode: this.countryCode,
286
+ username: this.config.email,
287
+ isUid: false,
288
+ }, false);
289
+ this.log.debug('[TY] Token created, encrypting password with RSA...');
290
+ // MD5Util.md5AsBase64(password) returns the MD5 as LOWERCASE hex. Verified at runtime
291
+ // via Frida hook on the live app: MD5("UPY2256yu") = "d0f7595d08d85b58a5be27ea561881e1"
292
+ // (lowercase). Node's md5().digest('hex') is also lowercase, so no case conversion needed.
293
+ const passwdMd5 = md5(this.config.password);
294
+ this.log.debug('[TY] password MD5 (lowercase): %s', passwdMd5);
295
+ // RSAUtil.encrypt uses FixedSecureRandom — a deterministic SecureRandom whose
296
+ // nextBytes() repeats the same 20-byte seed forever. This means the PKCS1 v1.5
297
+ // padding string PS is IDENTICAL every time for the same key size. Tuya's server
298
+ // appears to compare the RSA ciphertext (not just the decrypted plaintext), so we
299
+ // MUST reproduce the exact same padding bytes. Node's crypto.publicEncrypt uses a
300
+ // real CSPRNG, so we build the PKCS1 block manually and do a raw RSA public-key
301
+ // operation (modexp) with the token's modulus + exponent.
302
+ const encryptedPass = this.rsaEncryptFixedPadding(tokenResult.publicKey, tokenResult.exponent, passwdMd5);
303
+ this.log.debug('[TY] RSA-encrypted passwd (len=%d): %s', encryptedPass.length, encryptedPass);
304
+ // LoginBusiness.smali email.password.login builds options as:
305
+ // "{\"group\": 1,\"mfaCode\": \"" + mfaCode + "\"}"
306
+ // where mfaCode is "" for a normal (non-MFA) login. The outer string is a raw JSON
307
+ // string (putPostData takes Object, fastjson serializes the String as-is).
308
+ const result = await this.request('thing.m.user.email.password.login', {
309
+ countryCode: this.countryCode,
310
+ email: this.config.email,
311
+ passwd: encryptedPass,
312
+ ifencrypt: 1,
313
+ options: '{"group": 1,"mfaCode": ""}',
314
+ token: tokenResult.token,
315
+ }, false);
316
+ this.handleLoginResult(result);
317
+ }
318
+ buildRsaPublicKeyDer(n, e) {
319
+ if (n[0] & 0x80) {
320
+ n = Buffer.concat([Buffer.from([0x00]), n]);
321
+ }
322
+ if (e[0] & 0x80) {
323
+ e = Buffer.concat([Buffer.from([0x00]), e]);
324
+ }
325
+ const nTag = Buffer.concat([Buffer.from([0x02]), this.derEncodeLength(n.length), n]);
326
+ const eTag = Buffer.concat([Buffer.from([0x02]), this.derEncodeLength(e.length), e]);
327
+ const innerLen = nTag.length + eTag.length;
328
+ return Buffer.concat([
329
+ Buffer.from([0x30]),
330
+ this.derEncodeLength(innerLen),
331
+ nTag,
332
+ eTag,
333
+ ]);
334
+ }
335
+ derEncodeLength(len) {
336
+ if (len < 128) {
337
+ return Buffer.from([len]);
338
+ }
339
+ if (len <= 0xFF) {
340
+ return Buffer.from([0x81, len]);
341
+ }
342
+ return Buffer.from([0x82, (len >> 8) & 0xFF, len & 0xFF]);
343
+ }
344
+ /**
345
+ * RSA-encrypt the password MD5 hex with PKCS1 v1.5 padding using Tuya's
346
+ * FixedSecureRandom — a deterministic 20-byte seed repeated to fill the
347
+ * padding string. Tuya's server compares the ciphertext, so the padding
348
+ * bytes must match exactly.
349
+ *
350
+ * @param publicKeyDecimal decimal modulus string from token.create
351
+ * @param exponentDecimal decimal exponent string (e.g. "3")
352
+ * @param plaintext the MD5 hex string (lowercase, 32 chars)
353
+ * @returns 256-char lowercase hex ciphertext
354
+ */
355
+ rsaEncryptFixedPadding(publicKeyDecimal, exponentDecimal, plaintext) {
356
+ // FixedSecureRandom.seed = 20 bytes (see FixedSecureRandom.smali array_0).
357
+ const FIXED_SEED = Buffer.from([
358
+ 0xAA, 0xFD, 0x12, 0xF6, 0x59, 0xCA, 0xE6, 0x34,
359
+ 0x89, 0xB4, 0x79, 0xE5, 0x07, 0x6D, 0xDE, 0xC2,
360
+ 0xF0, 0x6C, 0xB5, 0x8F,
361
+ ]);
362
+ const n = BigInt(publicKeyDecimal);
363
+ const e = BigInt(exponentDecimal || '3');
364
+ const msg = Buffer.from(plaintext, 'utf8');
365
+ const keyBytes = (n.toString(16).length + 1) >> 1; // modulus byte length (128 for RSA-1024)
366
+ const psLen = keyBytes - msg.length - 3; // PKCS1: 0x00 0x02 [PS] 0x00 [msg]
367
+ // Build PS by repeating the fixed seed (FixedSecureRandom.nextBytes behavior).
368
+ const ps = Buffer.alloc(psLen);
369
+ for (let i = 0; i < psLen; i++) {
370
+ ps[i] = FIXED_SEED[i % FIXED_SEED.length];
371
+ }
372
+ // Assemble the PKCS1 v1.5 block: 0x00 || 0x02 || PS || 0x00 || msg
373
+ const block = Buffer.concat([Buffer.from([0x00, 0x02]), ps, Buffer.from([0x00]), msg]);
374
+ if (block.length !== keyBytes) {
375
+ throw new Error(`PKCS1 block length ${block.length} != key length ${keyBytes}`);
376
+ }
377
+ // Raw RSA: ciphertext = block^e mod n. Interpret block as big-endian integer.
378
+ let m = BigInt(0);
379
+ for (let i = 0; i < block.length; i++) {
380
+ m = (m << 8n) | BigInt(block[i]);
381
+ }
382
+ let c = 1n;
383
+ // Square-and-multiply for c = m^e mod n
384
+ let exp = e;
385
+ while (exp > 0n) {
386
+ if (exp & 1n)
387
+ c = (c * m) % n;
388
+ m = (m * m) % n;
389
+ exp >>= 1n;
390
+ }
391
+ // Encode ciphertext as fixed-width keyBytes hex (lowercase).
392
+ let hex = c.toString(16);
393
+ if (hex.length < keyBytes * 2) {
394
+ hex = '0'.repeat(keyBytes * 2 - hex.length) + hex;
395
+ }
396
+ return hex;
397
+ }
398
+ handleLoginResult(result) {
399
+ this.sid = result.sid;
400
+ this.ecode = result.ecode;
401
+ if (result.domain?.mobileApiUrl) {
402
+ const newEndpoint = result.domain.mobileApiUrl + '/api.json';
403
+ if (newEndpoint !== this.endpoint) {
404
+ this.log.debug('Tuya endpoint redirected to: %s', result.domain.mobileApiUrl);
405
+ this.endpoint = newEndpoint;
406
+ }
407
+ }
408
+ this.sessionRestored = false;
409
+ this.saveSession();
410
+ this.log.info('Logged in to RainPoint TY API as %s', this.config.email);
411
+ }
412
+ async ensureAuthenticated() {
413
+ if (!this.sid) {
414
+ await this.login();
415
+ }
416
+ }
417
+ setHome(homeId) {
418
+ this.homeId = homeId;
419
+ }
420
+ async getHomes() {
421
+ // location.extend.list returns {<homeId>: {defaultHome:"0"}}, NOT an array.
422
+ // Verified via the live app: {"result":{"157747002":{"defaultHome":"0"}}}.
423
+ // The app parses it as a HashMap (FamilyExtraBusiness.smali asyncHashMap).
424
+ const result = await this.request('thing.m.location.extend.list', {});
425
+ const homeIds = Object.keys(result || {});
426
+ const homes = [];
427
+ for (const homeId of homeIds) {
428
+ // Resolve the home name via m.life.location.get (v3.4, postData {gid}).
429
+ // HomeResponseBean has {name, gid, id, ...}. Fall back to the homeId if the
430
+ // detail fetch fails (the name is only used for logging — setHome uses the id).
431
+ let name = homeId;
432
+ try {
433
+ const detail = await this.request('m.life.location.get', { gid: Number(homeId) });
434
+ if (detail?.name) {
435
+ name = detail.name;
436
+ }
437
+ }
438
+ catch (e) {
439
+ this.log.warn('[TY] Failed to fetch home detail for %s: %s', homeId, e);
440
+ }
441
+ homes.push({ id: homeId, name });
442
+ }
443
+ return homes;
444
+ }
445
+ async getDevices() {
446
+ const result = await this.request('thing.m.my.group.device.list', {}, this.homeId);
447
+ const devices = [];
448
+ for (const device of result) {
449
+ // TY sub-devices are individual zones (each deviceTopo.parentDevId sub-device
450
+ // is one irrigation zone). The gateway has no parent. So each non-gateway
451
+ // device is a single-port valve.
452
+ const isSubDevice = !!(device.deviceTopo?.parentDevId || device.parent_id);
453
+ const deviceType = this.classifyDevice(device);
454
+ // Skip the gateway — platform.ts's registerDevice filters it out anyway via
455
+ // DEVICE_TYPE_GATEWAY, but returning it as a 1-port valve would create a
456
+ // bogus accessory. The gateway record is only useful as a parentId for its
457
+ // children, which we capture below.
458
+ if (deviceType === constants_1.DEVICE_TYPE_GATEWAY) {
459
+ this.log.debug('[TY] Skipping gateway device: %s (%s)', device.name, device.devId);
460
+ continue;
461
+ }
462
+ // Detect per-zone valve switch DPs from the device's datapoints. The
463
+ // RainPoint TY irrigation controller exposes each valve as a distinct DP:
464
+ // zone 1 -> DP 104, zone 2 -> DP 155, zone 3 -> DP 206, ... (step 51)
465
+ // A "split" controller (e.g. Back Garden) has BOTH 104 and 155 => 2 zones.
466
+ // A single-valve controller has only 104 => 1 zone. We also honor dpName
467
+ // entries containing "Valve" as switch DPs (e.g. 155="Right Valve").
468
+ const { zoneSwitchDps, zoneNames } = this.detectValveZones(device);
469
+ devices.push({
470
+ id: device.devId || device.id || '',
471
+ name: device.name,
472
+ model: device.model || device.productVer || device.productId || '',
473
+ productId: device.productId || device.product_id || '',
474
+ online: device.cloudOnline ?? device.online ?? (device.connectionStatus === 1),
475
+ portNumber: zoneSwitchDps.length,
476
+ // Per-zone names: use dpName-derived names when available, else the device
477
+ // name for a single-zone device, else "Zone N".
478
+ portDescribe: zoneNames,
479
+ deviceType,
480
+ isSubDevice,
481
+ parentId: device.deviceTopo?.parentDevId || device.parent_id || undefined,
482
+ addr: 0,
483
+ zoneSwitchDps,
484
+ });
485
+ }
486
+ // Refresh the device cache so turnZoneOn/Off can resolve port -> switch DP.
487
+ this.deviceCache = new Map(devices.map(d => [d.id, d]));
488
+ return devices;
489
+ }
490
+ async getDeviceStatuses(deviceIds) {
491
+ const result = new Map();
492
+ const devices = await this.getDevices();
493
+ for (const deviceId of deviceIds) {
494
+ const device = devices.find(d => d.id === deviceId);
495
+ if (!device) {
496
+ continue;
497
+ }
498
+ try {
499
+ const statusResult = await this.request('thing.m.device.dp.get', { devId: deviceId });
500
+ const dpsMap = new Map();
501
+ if (Array.isArray(statusResult)) {
502
+ for (const item of statusResult) {
503
+ dpsMap.set(item.code, item.value);
504
+ }
505
+ }
506
+ else if (statusResult.dps) {
507
+ for (const [key, value] of Object.entries(statusResult.dps)) {
508
+ dpsMap.set(key, value);
509
+ }
510
+ }
511
+ const zones = [];
512
+ const switchDps = device.zoneSwitchDps;
513
+ for (let port = 1; port <= device.portNumber; port++) {
514
+ // Use the per-zone switch DP when available (RainPoint TY pattern:
515
+ // 104 for zone 1, 155 for zone 2, ...). Fall back to the legacy
516
+ // port-number DP scheme (DP 1, 2, ...) for devices without zoneSwitchDps.
517
+ const switchDp = switchDps?.[port - 1];
518
+ const isOn = switchDp !== undefined
519
+ ? dpsMap.get(String(switchDp)) === true
520
+ : (dpsMap.get(String(port)) === true || dpsMap.get(`switch_${port}`) === true);
521
+ // Countdown/remaining duration: best-effort. The RainPoint TY countdown
522
+ // DP isn't at a consistent offset from the switch DP across valve groups,
523
+ // so we don't try to read it here — the InUse characteristic falls back
524
+ // to 0 (not running), which is safe. A future capture of an active valve
525
+ // can pin down the countdown DP per zone.
526
+ const remaining = 0;
527
+ zones.push({
528
+ port,
529
+ name: device.portDescribe[port - 1] || `Zone ${port}`,
530
+ isOn,
531
+ remainingDuration: remaining,
532
+ });
533
+ }
534
+ const moisture = dpsMap.get('9') ?? dpsMap.get('14')
535
+ ?? dpsMap.get('humidity') ?? dpsMap.get('soil_humidity') ?? null;
536
+ const temperature = dpsMap.get('10') ?? dpsMap.get('15')
537
+ ?? dpsMap.get('temperature') ?? dpsMap.get('temp_current') ?? null;
538
+ const battery = dpsMap.get('11') ?? dpsMap.get('17')
539
+ ?? dpsMap.get('battery_percentage') ?? dpsMap.get('residual_electricity') ?? null;
540
+ result.set(deviceId, {
541
+ deviceId,
542
+ online: device.online,
543
+ zones,
544
+ moisture: moisture,
545
+ temperature: temperature,
546
+ battery: battery,
547
+ });
548
+ }
549
+ catch (error) {
550
+ this.log.error('Failed to get status for device %s: %s', deviceId, error);
551
+ }
552
+ }
553
+ return result;
554
+ }
555
+ async turnZoneOn(deviceId, port, durationSeconds) {
556
+ // qqddbpb.smali dp.publish: postData requires gwId + devId + dps (as JSON string).
557
+ // For non-sub-devices gwId == devId (the device is its own gateway).
558
+ // The DP key for the valve on/off is the per-zone switch DP (RainPoint TY:
559
+ // 104 for zone 1, 155 for zone 2, ...) when known; otherwise fall back to
560
+ // the legacy port-number DP scheme (DP 1, 2, ...).
561
+ const switchDp = this.resolveSwitchDp(deviceId, port);
562
+ const dps = { [String(switchDp)]: true };
563
+ if (durationSeconds) {
564
+ // Countdown DP is unknown per-zone for the TY scheme; omit rather than
565
+ // risk writing the wrong DP. The valve still turns on; only the auto-off
566
+ // timer isn't set server-side (HomeKit's SetDuration handles local timing).
567
+ }
568
+ await this.request('thing.m.device.dp.publish', {
569
+ gwId: deviceId,
570
+ devId: deviceId,
571
+ dps: JSON.stringify(dps),
572
+ });
573
+ this.log.debug('Turned ON zone %d (DP %s) on device %s', port, switchDp, deviceId);
574
+ }
575
+ async turnZoneOff(deviceId, port) {
576
+ const switchDp = this.resolveSwitchDp(deviceId, port);
577
+ await this.request('thing.m.device.dp.publish', {
578
+ gwId: deviceId,
579
+ devId: deviceId,
580
+ dps: JSON.stringify({ [String(switchDp)]: false }),
581
+ });
582
+ this.log.debug('Turned OFF zone %d (DP %s) on device %s', port, switchDp, deviceId);
583
+ }
584
+ /**
585
+ * Resolve a 1-based zone port number to the device's valve switch DP.
586
+ * Uses the cached device's zoneSwitchDps when available (populated by
587
+ * getDevices); falls back to the port number itself for legacy devices.
588
+ */
589
+ resolveSwitchDp(deviceId, port) {
590
+ const device = this.deviceCache.get(deviceId);
591
+ if (device?.zoneSwitchDps && device.zoneSwitchDps[port - 1] !== undefined) {
592
+ return device.zoneSwitchDps[port - 1];
593
+ }
594
+ return port;
595
+ }
596
+ /**
597
+ * Classify a TY device. The thing.m.my.group.device.list response has NO
598
+ * `category` field (unlike the classic Tuya device list), so we classify by
599
+ * structure + datapoints instead:
600
+ * - Gateway: deviceTopo is empty/missing AND it's the parent (other devices
601
+ * point at it via deviceTopo.parentDevId). Detected by absence of a
602
+ * parentDevId.
603
+ * - Sensor: a sub-device whose dps has no irrigation valve DP. (RainPoint
604
+ * sensors are rare in this OEM; we currently treat every sub-device as a
605
+ * valve unless its productId/dps indicates otherwise.)
606
+ * - Valve / irrigation: any sub-device with a parentDevId.
607
+ *
608
+ * Returns the HomGar-style device type strings (DEVICE_TYPE_GATEWAY / VALVE /
609
+ * SENSOR / IRRIGATION) so platform.ts's existing routing works unchanged.
610
+ */
611
+ classifyDevice(device) {
612
+ // Gateway: no parent. The gateway record also has localKey/mac but we don't
613
+ // require those — absence of deviceTopo.parentDevId is sufficient.
614
+ const hasParent = !!(device.deviceTopo?.parentDevId || device.parent_id);
615
+ if (!hasParent) {
616
+ return constants_1.DEVICE_TYPE_GATEWAY;
617
+ }
618
+ // Sub-device: treat as an irrigation valve/zone by default. The RainPoint
619
+ // irrigation sub-devices all expose valve-style dps (e.g. "155":true for
620
+ // "Right Valve"). There is no sensor variant in the captured account.
621
+ return constants_1.DEVICE_TYPE_VALVE;
622
+ }
623
+ /**
624
+ * Detect per-zone valve switch datapoints for a RainPoint TY irrigation device.
625
+ *
626
+ * The RainPoint TY controller exposes each valve as a distinct DP. Observed
627
+ * pattern (from a captured 3-device account):
628
+ * - Single-valve device (Front Garden R/L, productId ew946yrp3pgbaziu):
629
+ * DPs 101-128, valve on/off = DP 104 (boolean).
630
+ * - Split 2-valve device (Back Garden, productId pjnbcfv3bzwg4yyo):
631
+ * DPs 101-128 (zone 1, switch 104) AND DPs 150-167 (zone 2, switch 155,
632
+ * dpName "Right Valve"). The switch DPs pair by +51: 104 <-> 155.
633
+ *
634
+ * IMPORTANT: a valve group's switch DP may be ABSENT from the current dps
635
+ * snapshot when the device is offline (e.g. Back Garden, cloudOnline=false,
636
+ * omits DP 104 even though the valve exists). So zone COUNT is determined by
637
+ * DP-range presence (a group exists if ANY dps fall in its range), NOT by
638
+ * whether the specific switch DP is currently reported. The switch DP is then
639
+ * assigned by the +51 pattern regardless of snapshot presence — status
640
+ * polling reads it if present, else reports the zone as off.
641
+ *
642
+ * Group ranges (each spans ~28 DPs centered on its switch DP):
643
+ * group 1: DPs 90-149 (switch 104)
644
+ * group 2: DPs 141-200 (switch 155) — overlaps group 1, so we instead
645
+ * use non-overlapping buckets: group N owns DPs in [101+51*(N-1), 150+51*(N-1)]
646
+ *
647
+ * We also honor dpName entries containing "Valve" as explicit switch DPs
648
+ * (defensive for future firmware that may use a different pattern).
649
+ *
650
+ * Returns the switch DP IDs (ascending) and per-zone names (from dpName when
651
+ * available, else the device name for single-zone, else "Zone N").
652
+ */
653
+ detectValveZones(device) {
654
+ const dps = device.dataPointInfo?.dps ?? device.dps ?? {};
655
+ const dpName = device.dataPointInfo?.dpName ?? {};
656
+ const dpKeys = Object.keys(dps).map(k => Number(k)).filter(n => !Number.isNaN(n));
657
+ // Valve-group switch DPs follow the +51 pattern: 104, 155, 206, 257, ...
658
+ // A group N is considered present if the device reports ANY DP in its range
659
+ // [101 + 51*(N-1), 149 + 51*(N-1)] (covers the observed 101-128 and 150-167
660
+ // spans with margin). This catches a group even when its switch DP is
661
+ // temporarily absent (offline device).
662
+ const MAX_ZONES = 8;
663
+ const GROUP_SPAN = 51;
664
+ const GROUP_START = 101; // first DP of group 1's range
665
+ const SWITCH_BASE = 104; // switch DP of group 1
666
+ const GROUP_WIDTH = 49; // group N spans [101+51*(N-1), 149+51*(N-1)]
667
+ const switchDps = [];
668
+ for (let n = 0; n < MAX_ZONES; n++) {
669
+ const rangeLo = GROUP_START + GROUP_SPAN * n;
670
+ const rangeHi = rangeLo + GROUP_WIDTH;
671
+ const hasGroupDp = dpKeys.some(k => k >= rangeLo && k <= rangeHi);
672
+ if (hasGroupDp) {
673
+ switchDps.push(SWITCH_BASE + GROUP_SPAN * n);
674
+ }
675
+ }
676
+ // Also honor dpName entries containing "Valve" (case-insensitive). These are
677
+ // explicit valve on/off DPs the firmware named. Dedupe + sort.
678
+ for (const [dpStr, name] of Object.entries(dpName)) {
679
+ if (typeof name !== 'string' || !/valve/i.test(name)) {
680
+ continue;
681
+ }
682
+ const dp = Number(dpStr);
683
+ if (!Number.isNaN(dp) && !switchDps.includes(dp)) {
684
+ switchDps.push(dp);
685
+ }
686
+ }
687
+ switchDps.sort((a, b) => a - b);
688
+ // Fallback: if no valve groups detected at all (firmware without the standard
689
+ // pattern, or a completely empty dps snapshot), assume a single zone using
690
+ // the legacy DP 1 scheme so the accessory still appears.
691
+ if (switchDps.length === 0) {
692
+ this.log.warn('[TY] No valve groups detected for %s (productId=%s); '
693
+ + 'assuming single zone with legacy DP 1. dps keys: %s', device.name, device.productId, Object.keys(dps).join(','));
694
+ return {
695
+ zoneSwitchDps: [1],
696
+ zoneNames: [device.name],
697
+ };
698
+ }
699
+ // Build per-zone names. Prefer dpName; fall back to the device name for a
700
+ // single-zone device, else "Zone N" / "Left Valve" / "Right Valve" by index.
701
+ const DEFAULT_NAMES = { 0: 'Left Valve', 1: 'Right Valve' };
702
+ const zoneNames = switchDps.map((dp, i) => {
703
+ const named = dpName[String(dp)];
704
+ if (typeof named === 'string' && named.trim()) {
705
+ return named;
706
+ }
707
+ if (switchDps.length === 1) {
708
+ return device.name;
709
+ }
710
+ return DEFAULT_NAMES[i] ?? `Zone ${i + 1}`;
711
+ });
712
+ this.log.debug('[TY] %s: detected %d zone(s) switchDps=%s names=%s', device.name, switchDps.length, switchDps.join(','), JSON.stringify(zoneNames));
713
+ return { zoneSwitchDps: switchDps, zoneNames };
714
+ }
715
+ getDeviceType(category) {
716
+ // Legacy helper retained for the HomGar-style category strings; unused by
717
+ // the TY flow (which has no category field). Defensive: tolerate undefined.
718
+ switch ((category || '').toLowerCase()) {
719
+ case 'sf':
720
+ return constants_1.DEVICE_TYPE_IRRIGATION;
721
+ case 'wg2':
722
+ case 'sz':
723
+ return constants_1.DEVICE_TYPE_VALVE;
724
+ case 'sensor':
725
+ return constants_1.DEVICE_TYPE_SENSOR;
726
+ default:
727
+ return category || constants_1.DEVICE_TYPE_VALVE;
728
+ }
729
+ }
730
+ /**
731
+ * Make a Tuya mobile API request.
732
+ *
733
+ * Based on APK decompilation of ThingApiParams.getRequestBody():
734
+ * - HTTP POST to the bare endpoint URL (no query params)
735
+ * - Body is application/x-www-form-urlencoded
736
+ * - postData in the body is the RAW JSON string (not hashed)
737
+ * - postData in the sign string is mobileHash(postData)
738
+ * - The sign covers both URL params and postData (hashed)
739
+ */
740
+ async request(action, data, requireSid = true, retried = false) {
741
+ if (requireSid !== false) {
742
+ await this.ensureAuthenticated();
743
+ }
744
+ const gid = typeof requireSid === 'string' ? requireSid : undefined;
745
+ const needsSid = requireSid !== false;
746
+ const d = new Date();
747
+ const postDataStr = JSON.stringify(data);
748
+ const requestId = (0, uuid_1.v4)();
749
+ this.log.debug('[TY] plaintext postData for %s: %s', action, postDataStr);
750
+ // When et=3, the Business layer AES-GCM encrypts ALL postData before sending.
751
+ // The server decrypts postData using the key derived from requestId (via native
752
+ // getEncryptoKey). The sign covers the ENCRYPTED postData (base64), not the raw JSON.
753
+ //
754
+ // ecode handling: the ecode-suffixed key (HMAC_KEY + "_" + ecode) is only valid
755
+ // AFTER login. Pre-login calls (token.get + password.login, signaled by
756
+ // requireSid === false) must derive the key WITHOUT ecode — even if a saved
757
+ // session was restored (which would otherwise populate this.ecode). Using the
758
+ // ecode key for token.get causes the server's no-ecode-encrypted response to
759
+ // fail GCM auth ("unable to authenticate data").
760
+ const ecode = requireSid === false ? undefined : (this.ecode || undefined);
761
+ const encKey = deriveEncryptoKey(requestId, ecode);
762
+ const encryptedPostData = aesGcmEncryptBase64(encKey, postDataStr);
763
+ // Per-API version (Tuya gateway validates a+v server-side). Defaults to "*" for
764
+ // any API not in the map, matching ThingApiParams' default apiVersion field.
765
+ // Version lookup uses the ORIGINAL thing.m.* name (pre-rewrite), matching how
766
+ // the APK constructor sets apiVersion before checkAPIName() mutates apiName.
767
+ const apiVersion = API_VERSIONS[action] ?? '*';
768
+ // checkAPIName() rewrite: thing.m.* -> smartlife.m.* (via @xx2@ prepend + replaceAll).
769
+ // The rewritten name is what the gateway receives in `a` and what enters the sign string.
770
+ const apiName = finalizeApiName(action);
771
+ // --- Build the sign map (URL params + postData for signing) ---
772
+ // APK: getRequestBody() merges getUrlParams() + getPostBody() into one map for signing
773
+ // postData in the sign map is the ENCRYPTED base64 string (hashed via mobileHash)
774
+ const signMap = {
775
+ a: apiName,
776
+ deviceId: this.deviceId,
777
+ os: 'Android',
778
+ lang: 'en_US',
779
+ v: apiVersion,
780
+ clientId: APP_KEY,
781
+ time: Math.round(d.getTime() / 1000).toString(),
782
+ postData: mobileHash(encryptedPostData), // hashed encrypted postData for sign
783
+ et: API_ET_VERSION,
784
+ ttid: TTID,
785
+ appVersion: '1.2.5',
786
+ requestId: requestId,
787
+ chKey: CH_KEY,
788
+ };
789
+ if (gid) {
790
+ signMap.gid = gid;
791
+ }
792
+ if (needsSid && this.sid) {
793
+ signMap.sid = this.sid;
794
+ }
795
+ // Signed keys list from APK: ThingApiSignManager.bdpdqbp
796
+ const valuesToSign = new Set([
797
+ 'a', 'v', 'lat', 'lon', 'lang', 'deviceId', 'appVersion', 'ttid',
798
+ 'isH5', 'h5Token', 'os', 'clientId', 'postData', 'time', 'requestId',
799
+ 'et', 'n4h5', 'sid', 'sp', 'chKey',
800
+ ]);
801
+ const sortedKeys = Object.keys(signMap).sort();
802
+ let strToSign = '';
803
+ for (const key of sortedKeys) {
804
+ if (!valuesToSign.has(key) || !signMap[key]) {
805
+ continue;
806
+ }
807
+ if (strToSign) {
808
+ strToSign += '||';
809
+ }
810
+ strToSign += `${key}=${signMap[key]}`;
811
+ }
812
+ const sign = crypto_1.default.createHmac('sha256', HMAC_KEY).update(strToSign).digest('hex');
813
+ // --- Build the POST body map ---
814
+ // When et=3, postData is the AES-GCM encrypted base64 string (not raw JSON).
815
+ // The server decrypts it using the key derived from requestId.
816
+ const bodyParams = {
817
+ postData: encryptedPostData, // ENCRYPTED base64 in body
818
+ sign: sign,
819
+ a: apiName,
820
+ deviceId: this.deviceId,
821
+ os: 'Android',
822
+ lang: 'en_US',
823
+ v: apiVersion,
824
+ clientId: APP_KEY,
825
+ time: signMap.time,
826
+ et: API_ET_VERSION,
827
+ cp: 'gzip',
828
+ ttid: TTID,
829
+ appVersion: '1.2.5',
830
+ appRnVersion: '5.97',
831
+ sdkVersion: '6.8.0',
832
+ deviceCoreVersion: '6.8.0',
833
+ osSystem: '17',
834
+ platform: 'sdk_gphone16k_arm64',
835
+ timeZoneId: 'America/Los_Angeles',
836
+ channel: 'oem',
837
+ nd: '1',
838
+ customDomainSupport: '1',
839
+ requestId: signMap.requestId,
840
+ chKey: CH_KEY,
841
+ bizData: JSON.stringify({ brand: 'google', customDomainSupport: '1', nd: '1', sdkInt: '37' }),
842
+ };
843
+ if (gid) {
844
+ bodyParams.gid = gid;
845
+ }
846
+ if (needsSid && this.sid) {
847
+ bodyParams.sid = this.sid;
848
+ }
849
+ const bodyStr = formUrlEncode(bodyParams);
850
+ const urlObj = new url_1.URL(this.endpoint);
851
+ this.log.debug('[TY] POST %s', this.endpoint);
852
+ this.log.debug('[TY] sign string: %s', strToSign);
853
+ this.log.debug('[TY] body: %s', bodyStr);
854
+ // Error codes that indicate the saved sid is no longer valid and the client
855
+ // must re-login (then retry the request once). These are the typical Tuya
856
+ // session-expired signatures; the gateway returns them when the sid is
857
+ // missing/expired/revoked rather than re-issuing one silently.
858
+ const SESSION_ERROR_CODES = new Set([
859
+ 'SESSION_EXPIRED', 'SESSION_INVALID', 'INVALID_SESSION',
860
+ 'SID_INVALID', 'NOT_LOGIN', 'Frequently_Invoke',
861
+ ]);
862
+ const doRequest = () => new Promise((resolve, reject) => {
863
+ const options = {
864
+ hostname: urlObj.hostname,
865
+ port: urlObj.port || 443,
866
+ path: urlObj.pathname,
867
+ method: 'POST',
868
+ headers: {
869
+ 'Content-Type': 'application/x-www-form-urlencoded',
870
+ 'User-Agent': 'Thing-UA=APP/Android/1.2.5/SDK/6.8.0',
871
+ 'Connection': 'keep-alive',
872
+ 'channel_type': 'oem_app',
873
+ 'channel_key': APP_KEY,
874
+ 'x-client-trace-id': signMap.requestId,
875
+ },
876
+ rejectUnauthorized: false,
877
+ };
878
+ const req = https_1.default.request(options, (res) => {
879
+ let responseData = '';
880
+ res.on('data', (chunk) => {
881
+ responseData += chunk;
882
+ });
883
+ res.on('end', () => {
884
+ this.log.debug('[TY] Response: %s', responseData);
885
+ try {
886
+ const parsed = JSON.parse(responseData);
887
+ // The gateway returns two response shapes:
888
+ // (1) Encrypted: {t, sign, result:"<base64 AES-GCM ciphertext>"} — no "success" field.
889
+ // The real {success, result, ...} is inside the encrypted result.
890
+ // (2) Plain: {success, result, ...} — error responses or unencrypted APIs.
891
+ // We detect (1) by the presence of "sign" + "result" without "success",
892
+ // then decrypt with the same key derived from requestId (pure JS, no emulator).
893
+ let decoded = parsed;
894
+ if (parsed && typeof parsed === 'object' && 'sign' in parsed && 'result' in parsed && !('success' in parsed)) {
895
+ const requestId = signMap.requestId;
896
+ const resultB64 = parsed.result;
897
+ this.log.debug('[TY] Response is encrypted (sign=%s), decrypting (requestId=%s)...', parsed.sign, requestId);
898
+ const decKey = deriveEncryptoKey(requestId, ecode);
899
+ const decryptedJson = aesGcmDecryptBase64(decKey, resultB64);
900
+ this.log.debug('[TY] Decrypted response: %s', decryptedJson);
901
+ decoded = JSON.parse(decryptedJson);
902
+ }
903
+ const apiResp = decoded;
904
+ if (!apiResp.success) {
905
+ reject(new TuyaApiError(`Tuya API error: ${apiResp.errorMsg || 'Unknown'} (code: ${apiResp.errorCode})`, apiResp.errorCode));
906
+ return;
907
+ }
908
+ resolve(apiResp.result);
909
+ }
910
+ catch (e) {
911
+ reject(new Error(`Failed to parse Tuya API response: ${e}`));
912
+ }
913
+ });
914
+ });
915
+ req.on('error', (e) => {
916
+ reject(new Error(`HTTP request failed: ${e.message}`));
917
+ });
918
+ req.setTimeout(20000, () => {
919
+ req.destroy(new Error('Request timed out'));
920
+ });
921
+ req.write(bodyStr);
922
+ req.end();
923
+ });
924
+ // First attempt. If the server rejects with a session-expired code AND this
925
+ // request was made with a saved (restored) sid, clear the session, log back
926
+ // in, and retry exactly once. The retry reuses the SAME signMap (same
927
+ // requestId/time/postData), only the bodyParams.sid changes — which is fine
928
+ // because the gateway keys validation on the sid, not the requestId.
929
+ return doRequest().catch((err) => {
930
+ const code = err?.code;
931
+ const wasUsingRestoredSid = this.sessionRestored || (needsSid && this.sid);
932
+ if (code && SESSION_ERROR_CODES.has(code) && wasUsingRestoredSid && !retried) {
933
+ this.log.warn('[TY] Session rejected (code=%s) — clearing saved session and re-logging in...', code);
934
+ this.clearSession();
935
+ return this.login().then(() => {
936
+ // Re-issue the request with the fresh sid. The recursive call passes
937
+ // retried=true so a second session error surfaces instead of looping.
938
+ return this.request(action, data, requireSid, true);
939
+ });
940
+ }
941
+ throw err;
942
+ });
943
+ }
944
+ }
945
+ exports.RainPointTyClient = RainPointTyClient;
946
+ RainPointTyClient.deviceIdStorage = null;
947
+ //# sourceMappingURL=RainPointTyClient.js.map