@mega-yfue/eufy-sdk 0.5.0-beta.50 → 0.5.0-beta.52

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.
@@ -454,8 +454,9 @@ export declare class EufyMega extends EventEmitter {
454
454
  *
455
455
  * Side-effect: registers `eufy_home_tuya` devices with the Tuya command router so
456
456
  * the command dispatcher can resolve a eufy SN → Tuya devId without a separate lookup.
457
- * The Tuya id is extracted from the device's raw cloud record (`tuya_uuid`, `tuya_virtual_id`,
458
- * `tuya_device_id`, or `virtualId` fields — whichever is non-empty).
457
+ * The Tuya id is the first non-empty `tuya_uuid`, `tuya_virtual_id`, `tuya_device_id` or
458
+ * `virtualId` on the raw cloud record, else the device's serial: the app keys its Tuya device by
459
+ * the eufy device id, and a record that carries none of those fields is read under its serial.
459
460
  *
460
461
  * A partial cloud outage still resolves, with the devices that answered plus the ones already known — but a
461
462
  * session the cloud has rejected REJECTS, with {@link SessionExpiredError}. An empty list would be
@@ -790,6 +791,10 @@ export declare class EufyMega extends EventEmitter {
790
791
  /**
791
792
  * Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
792
793
  * unreachable device must not stop the rest of the roster from coming up).
794
+ *
795
+ * A Tuya clean-line device's DPs are also read from the Tuya cloud, whether or not its subscribe
796
+ * succeeded: the cloud read is a separate plane, and the broker can refuse a subscribe for a device
797
+ * whose DPs the Tuya cloud still serves.
793
798
  */
794
799
  private subscribeMqttDevices;
795
800
  /**
package/dist/index.js CHANGED
@@ -25210,7 +25210,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
25210
25210
 
25211
25211
  // dist/transport/tuya/account.js
25212
25212
  import { createCipheriv as createCipheriv5 } from "node:crypto";
25213
- var TUYA_USERNAME_PREFIX = "eufyhome-";
25213
+ var TUYA_USERNAME_PREFIX = "eh-";
25214
25214
  var TUYA_PASSWORD_KEY = Buffer.from([
25215
25215
  36,
25216
25216
  78,
@@ -25398,10 +25398,13 @@ var StubSigner = class {
25398
25398
  }
25399
25399
  };
25400
25400
  var TUYA_SIGN_K = "com.oceanwing.battery.cam_16:6C:23:45:57:B7:76:CA:D8:AC:94:C9:79:37:9E:48:DF:38:7D:4D:8F:96:A3:43:DF:40:FC:D9:05:BF:F6:86_dn9erpyp7nmeuvah8ktghqsgpay87maa_pt585qhmt75hwcynchnps9dnxh9suhwd";
25401
+ function resolveSignKey(override) {
25402
+ return override ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
25403
+ }
25401
25404
  var HmacSigner = class {
25402
25405
  key;
25403
25406
  constructor(key) {
25404
- this.key = key ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
25407
+ this.key = resolveSignKey(key);
25405
25408
  }
25406
25409
  sign(preimage) {
25407
25410
  return createHmac4("sha256", this.key).update(preimage, "utf-8").digest("hex");
@@ -25410,7 +25413,37 @@ var HmacSigner = class {
25410
25413
  var TUYA_CHKEY = "7cbfe6d8";
25411
25414
 
25412
25415
  // dist/transport/tuya/client.js
25413
- import { randomBytes as randomBytes4, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
25416
+ import { randomBytes as randomBytes5, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
25417
+
25418
+ // dist/transport/tuya/et3.js
25419
+ import { createCipheriv as createCipheriv6, createDecipheriv as createDecipheriv8, createHmac as createHmac5, randomBytes as randomBytes4 } from "node:crypto";
25420
+ import { gunzipSync } from "node:zlib";
25421
+ function deriveBodyKey(requestId, ecode) {
25422
+ const k = resolveSignKey();
25423
+ const msg = ecode === null ? k : `${k}_${ecode}`;
25424
+ const hex = createHmac5("sha256", requestId).update(msg, "utf-8").digest("hex");
25425
+ return Buffer.from(hex.slice(0, 16), "ascii");
25426
+ }
25427
+ function encryptBody2(key, json) {
25428
+ const nonce = randomBytes4(12);
25429
+ const cipher = createCipheriv6("aes-128-gcm", key, nonce);
25430
+ const ciphertext = Buffer.concat([cipher.update(Buffer.from(json, "utf-8")), cipher.final()]);
25431
+ return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64");
25432
+ }
25433
+ function decryptReply(key, resultB64) {
25434
+ const blob = Buffer.from(resultB64, "base64");
25435
+ if (blob.length < 12 + 16) {
25436
+ throw new Error("tuya et=3 reply too short to hold a 12-byte nonce + 16-byte tag");
25437
+ }
25438
+ const nonce = blob.subarray(0, 12);
25439
+ const tag2 = blob.subarray(blob.length - 16);
25440
+ const ciphertext = blob.subarray(12, blob.length - 16);
25441
+ const decipher = createDecipheriv8("aes-128-gcm", key, nonce);
25442
+ decipher.setAuthTag(tag2);
25443
+ const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
25444
+ const text2 = plain.length >= 2 && plain[0] === 31 && plain[1] === 139 ? gunzipSync(plain) : plain;
25445
+ return text2.toString("utf-8");
25446
+ }
25414
25447
 
25415
25448
  // dist/transport/tuya/request.js
25416
25449
  import { randomUUID as randomUUID4 } from "node:crypto";
@@ -25465,8 +25498,9 @@ function buildApiParams(action, opts) {
25465
25498
  nd: env.nd,
25466
25499
  deviceCoreVersion: env.deviceCoreVersion
25467
25500
  };
25468
- if (action.postData !== void 0)
25469
- params.postData = action.postData;
25501
+ if (action.postData !== void 0) {
25502
+ params.postData = env.et === "3" ? encryptBody2(deriveBodyKey(params.requestId, opts.session.ecode ?? null), action.postData) : action.postData;
25503
+ }
25470
25504
  const sign = opts.signer.sign(buildSignPreimage(params));
25471
25505
  return { ...params, sign };
25472
25506
  }
@@ -25486,16 +25520,16 @@ async function sendApiRequest(params, opts = {}) {
25486
25520
  }
25487
25521
  function buildPublishDpsAction(devId, gwId, dps) {
25488
25522
  return {
25489
- a: "thing.m.device.dp.publish",
25523
+ a: "smartlife.m.device.dp.publish",
25490
25524
  v: "2.0",
25491
25525
  postData: JSON.stringify({ gwId, devId, dps: JSON.stringify(dps) })
25492
25526
  };
25493
25527
  }
25494
- function buildGetDeviceDpsAction(devId, dpCacheType = 1) {
25528
+ function buildGetDeviceDpsAction(devId) {
25495
25529
  return {
25496
- a: "thing.m.device.cache.dp.get",
25497
- v: "2.0",
25498
- postData: JSON.stringify({ devId, dpCacheType })
25530
+ a: "smartlife.m.device.dp.get",
25531
+ v: "1.0",
25532
+ postData: JSON.stringify({ devId })
25499
25533
  };
25500
25534
  }
25501
25535
  function buildUsernameTokenGetAction(countryCode, username) {
@@ -25509,7 +25543,15 @@ function buildPasswordLoginRegAction(countryCode, uid, passwd, token) {
25509
25543
  return {
25510
25544
  a: "smartlife.m.user.uid.password.login.reg",
25511
25545
  v: "1.0",
25512
- postData: JSON.stringify({ countryCode, uid, passwd, token, ifencrypt: 1, createGroup: true })
25546
+ postData: JSON.stringify({
25547
+ countryCode,
25548
+ uid,
25549
+ passwd,
25550
+ token,
25551
+ ifencrypt: 1,
25552
+ createGroup: true,
25553
+ options: '{"group": 1}'
25554
+ })
25513
25555
  };
25514
25556
  }
25515
25557
  function buildUidTokenCreateAction(countryCode, uid) {
@@ -25522,7 +25564,7 @@ function buildUidTokenCreateAction(countryCode, uid) {
25522
25564
 
25523
25565
  // dist/transport/tuya/client.js
25524
25566
  function genDeviceId() {
25525
- return randomBytes4(22).toString("hex");
25567
+ return randomBytes5(22).toString("hex");
25526
25568
  }
25527
25569
  function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
25528
25570
  const bigintToBuffer = (n) => {
@@ -25572,9 +25614,29 @@ var TuyaClient = class {
25572
25614
  buildRequest(action) {
25573
25615
  return buildApiParams(action, { session: this.session, signer: this.signer, env: this.env });
25574
25616
  }
25575
- /** Build + POST an action, returning the parsed envelope. Requires a working signer. */
25617
+ /**
25618
+ * Build + POST an action, returning the parsed envelope. Requires a working signer.
25619
+ *
25620
+ * On an `et=3` reply the envelope's `result` is a base64 AES-128-GCM blob, not an object; this
25621
+ * decrypts it (keyed off the request's `requestId` and the session's `ecode`) and returns the
25622
+ * decrypted inner envelope, so callers read `result` as an object regardless. A non-string `result`
25623
+ * (an error, or an already-plain reply) passes through untouched.
25624
+ */
25576
25625
  async call(action) {
25577
- return sendApiRequest(this.buildRequest(action), { endpoint: this.endpoint, http: this.http });
25626
+ const params = this.buildRequest(action);
25627
+ const env = await sendApiRequest(params, { endpoint: this.endpoint, http: this.http });
25628
+ return this.decodeReply(env, params.requestId);
25629
+ }
25630
+ /**
25631
+ * Decode an et=3 reply. When `result` is a non-empty string it is the encrypted blob: decrypt and
25632
+ * parse it (a wrong key or tampered blob fails the GCM tag in {@link decryptReply} and throws). The
25633
+ * plaintext is itself the standard `{success, t, result}` envelope, which becomes the returned one.
25634
+ */
25635
+ decodeReply(env, requestId) {
25636
+ if (typeof env.result !== "string" || env.result === "")
25637
+ return env;
25638
+ const key = deriveBodyKey(requestId, this.session.ecode ?? null);
25639
+ return JSON.parse(decryptReply(key, env.result));
25578
25640
  }
25579
25641
  /**
25580
25642
  * Log into the Tuya cloud from a eufy user id (wire-confirmed from the eufy Security app).
@@ -25607,7 +25669,7 @@ var TuyaClient = class {
25607
25669
  const encPasswd = rsaEncryptPassword(password, publicKey, exponent);
25608
25670
  const loginRes = await this.call(buildPasswordLoginRegAction(account.countryCode, account.username, encPasswd, token));
25609
25671
  if (loginRes.success && loginRes.result?.sid && loginRes.result?.uid) {
25610
- return { sid: loginRes.result.sid, uid: loginRes.result.uid };
25672
+ return { sid: loginRes.result.sid, uid: loginRes.result.uid, ecode: loginRes.result.ecode };
25611
25673
  }
25612
25674
  if ((loginRes.errorCode ?? loginRes.errorMsg) === "USER_PASSWD_WRONG")
25613
25675
  return "PASSWD_WRONG";
@@ -25619,18 +25681,19 @@ var TuyaClient = class {
25619
25681
  if (result === "PASSWD_WRONG") {
25620
25682
  throw new Error("tuya login: USER_PASSWD_WRONG on both derived password and fallback '12345678'");
25621
25683
  }
25622
- this.session = { ...this.session, sid: result.sid };
25623
- return result;
25684
+ this.session = { ...this.session, sid: result.sid, ecode: result.ecode };
25685
+ return { sid: result.sid, uid: result.uid };
25624
25686
  }
25625
25687
  /**
25626
- * READ/dump a device's cached data-points (`thing.m.device.cache.dp.get`).
25627
- * Builds the request without needing a working signer; the signer is only exercised on send.
25688
+ * READ a device's current data-points (`smartlife.m.device.dp.get`). The envelope's `result` is a
25689
+ * flat `{ "<dpId>": value }` map. Builds the request without needing a working signer; the signer is
25690
+ * only exercised on send.
25628
25691
  */
25629
- async getDeviceDps(devId, dpCacheType) {
25630
- return this.call(buildGetDeviceDpsAction(devId, dpCacheType));
25692
+ async getDeviceDps(devId) {
25693
+ return this.call(buildGetDeviceDpsAction(devId));
25631
25694
  }
25632
25695
  /**
25633
- * CONTROL: publish data-points to a device (`thing.m.device.dp.publish`). `dps` is
25696
+ * CONTROL: publish data-points to a device (`smartlife.m.device.dp.publish`). `dps` is
25634
25697
  * `{ "<dpId>": <value> }`. `gwId` is the gateway/parent id (equals `devId` for a standalone gw).
25635
25698
  *
25636
25699
  * ⚠️ UNVERIFIED write — refuses to send by default. The `dp.publish` param shape
@@ -25718,9 +25781,9 @@ var TuyaCommandRouter = class {
25718
25781
  /** Promise that resolves once Tuya login has completed. Reset by {@link bind}. */
25719
25782
  loginOnce = null;
25720
25783
  /**
25721
- * eufy SN → Tuya device ids, populated by the facade via {@link registerDevice}.
25722
- * The facade extracts the Tuya id from the `raw` device record fields (`tuya_uuid`,
25723
- * `tuya_virtual_id`, `tuya_device_id`, `virtualId`) and registers it once the device list loads.
25784
+ * eufy SN → Tuya device ids, populated via {@link registerDevice} once the device list loads.
25785
+ * The Tuya id is the record's first non-empty `tuya_uuid` / `tuya_virtual_id` / `tuya_device_id` /
25786
+ * `virtualId`, or the device's serial when the record carries none of them.
25724
25787
  */
25725
25788
  snMap = /* @__PURE__ */ new Map();
25726
25789
  constructor(config = {}) {
@@ -25742,10 +25805,10 @@ var TuyaCommandRouter = class {
25742
25805
  this.loginOnce = null;
25743
25806
  }
25744
25807
  /**
25745
- * Register a eufy SN → Tuya devId mapping. Called by the facade for each `eufy_home_tuya`
25746
- * device after the cloud device list loads. The facade extracts the Tuya id from the device's
25747
- * raw record (`tuya_uuid` / `tuya_virtual_id` / `tuya_device_id` / `virtualId` fields).
25748
- * `gwId` defaults to `devId` — standalone devices share the two.
25808
+ * Register a eufy SN → Tuya devId mapping for an `eufy_home_tuya` device. `devId` is the record's
25809
+ * first non-empty `tuya_uuid` / `tuya_virtual_id` / `tuya_device_id` / `virtualId`, or the device's
25810
+ * serial when the record carries none of them. `gwId` defaults to `devId` — standalone devices
25811
+ * share the two.
25749
25812
  */
25750
25813
  registerDevice(sn, devId, gwId = devId) {
25751
25814
  this.snMap.set(sn, { devId, gwId });
@@ -25769,13 +25832,9 @@ var TuyaCommandRouter = class {
25769
25832
  return this.loginOnce;
25770
25833
  }
25771
25834
  /**
25772
- * Fetch a device's cached DPs from the Tuya cloud (`thing.m.device.cache.dp.get`) and deliver
25773
- * the raw DP map to the caller. Used for initial state hydration after MQTT subscribe — gets the
25774
- * last-known state without waiting for the first realtime push.
25775
- *
25776
- * The response shape from `getDeviceDps` is not yet pinned from a live capture. The defensive
25777
- * extraction tries both `result.dps` (a nested map) and bare `result` (a flat map), and returns
25778
- * `null` when neither yields a non-empty record.
25835
+ * Read a device's current DPs from the Tuya cloud (`smartlife.m.device.dp.get`) and answer the DP
25836
+ * map: the reply's flat `result`, kept to integer DP keys with scalar values. Answers `null` for an
25837
+ * unregistered device, a failed reply, or a reply with no DPs.
25779
25838
  */
25780
25839
  async fetchDps(sn) {
25781
25840
  const ids = this.snMap.get(sn);
@@ -25785,12 +25844,8 @@ var TuyaCommandRouter = class {
25785
25844
  const res = await this.client.getDeviceDps(ids.devId);
25786
25845
  if (!res.success || !res.result)
25787
25846
  return null;
25788
- const nested = res.result.dps;
25789
- if (nested && typeof nested === "object" && !Array.isArray(nested) && Object.keys(nested).length > 0) {
25790
- return nested;
25791
- }
25792
- const flat = parseTuyaDpEvent(res.result);
25793
- return flat ? flat : null;
25847
+ const dps = parseTuyaDpEvent(res.result);
25848
+ return dps ? dps : null;
25794
25849
  }
25795
25850
  /**
25796
25851
  * Route an `aiot-dp` {@link Command} to the Tuya REST API.
@@ -25809,7 +25864,7 @@ var TuyaCommandRouter = class {
25809
25864
  await this.ensureLoggedIn();
25810
25865
  const ids = this.snMap.get(sn);
25811
25866
  if (!ids) {
25812
- throw new Error(`TuyaCommandRouter: no Tuya device id registered for ${sn}. Ensure getDevices() was called and the cloud record includes a tuya_uuid / tuya_virtual_id field.`);
25867
+ throw new Error(`TuyaCommandRouter: no Tuya device id registered for ${sn}. Ensure getDevices() was called; it registers every eufy_home_tuya device under its Tuya id or its serial.`);
25813
25868
  }
25814
25869
  await this.client.publishDps(ids.devId, ids.gwId, { [cmd.dp]: cmd.value }, { allowUnverified: this.allowUnverified });
25815
25870
  }
@@ -26647,7 +26702,7 @@ var PushClient = class _PushClient extends EventEmitter12 {
26647
26702
  };
26648
26703
 
26649
26704
  // dist/transport/push/fcm.js
26650
- import { randomBytes as randomBytes5 } from "node:crypto";
26705
+ import { randomBytes as randomBytes6 } from "node:crypto";
26651
26706
  import { setTimeout as sleep3 } from "node:timers/promises";
26652
26707
  var FCM = {
26653
26708
  PROJECT_ID: "batterycam-3250a",
@@ -26660,7 +26715,7 @@ var FCM = {
26660
26715
  SDK_VERSION: "a:16.3.1"
26661
26716
  };
26662
26717
  function generateFid() {
26663
- const b = randomBytes5(17);
26718
+ const b = randomBytes6(17);
26664
26719
  b[0] = 112 + b[0] % 16;
26665
26720
  return b.toString("base64url").slice(0, 22);
26666
26721
  }
@@ -28617,8 +28672,9 @@ var EufyMega = class extends EventEmitter13 {
28617
28672
  *
28618
28673
  * Side-effect: registers `eufy_home_tuya` devices with the Tuya command router so
28619
28674
  * the command dispatcher can resolve a eufy SN → Tuya devId without a separate lookup.
28620
- * The Tuya id is extracted from the device's raw cloud record (`tuya_uuid`, `tuya_virtual_id`,
28621
- * `tuya_device_id`, or `virtualId` fields — whichever is non-empty).
28675
+ * The Tuya id is the first non-empty `tuya_uuid`, `tuya_virtual_id`, `tuya_device_id` or
28676
+ * `virtualId` on the raw cloud record, else the device's serial: the app keys its Tuya device by
28677
+ * the eufy device id, and a record that carries none of those fields is read under its serial.
28622
28678
  *
28623
28679
  * A partial cloud outage still resolves, with the devices that answered plus the ones already known — but a
28624
28680
  * session the cloud has rejected REJECTS, with {@link SessionExpiredError}. An empty list would be
@@ -28630,9 +28686,7 @@ var EufyMega = class extends EventEmitter13 {
28630
28686
  if (dev.category !== "eufy_home_tuya")
28631
28687
  continue;
28632
28688
  const raw = dev.raw ?? {};
28633
- const devId = tuyaDevIdFrom(raw);
28634
- if (devId)
28635
- this.tuya.registerDevice(dev.sn, devId);
28689
+ this.tuya.registerDevice(dev.sn, tuyaDevIdFrom(raw) ?? dev.sn);
28636
28690
  }
28637
28691
  return devices;
28638
28692
  }
@@ -29300,21 +29354,25 @@ var EufyMega = class extends EventEmitter13 {
29300
29354
  /**
29301
29355
  * Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
29302
29356
  * unreachable device must not stop the rest of the roster from coming up).
29357
+ *
29358
+ * A Tuya clean-line device's DPs are also read from the Tuya cloud, whether or not its subscribe
29359
+ * succeeded: the cloud read is a separate plane, and the broker can refuse a subscribe for a device
29360
+ * whose DPs the Tuya cloud still serves.
29303
29361
  */
29304
29362
  async subscribeMqttDevices(scope, transport) {
29305
29363
  for (const d of this.getMqttDevices().filter((dev) => mqttScopeFor(dev) === scope)) {
29306
29364
  try {
29307
29365
  await transport.subscribeDevice(d);
29308
29366
  await this.sendRealtimeInit(d.sn);
29309
- if (d.category === "eufy_home_tuya") {
29310
- this.tuya.fetchDps(d.sn).then((dps) => {
29311
- if (dps)
29312
- this.tuyaDpRouter.deliver(d.sn, dps);
29313
- }).catch((e) => this.reportError(e));
29314
- }
29315
29367
  } catch (e) {
29316
29368
  this.reportError(e);
29317
29369
  }
29370
+ if (d.category === "eufy_home_tuya") {
29371
+ this.tuya.fetchDps(d.sn).then((dps) => {
29372
+ if (dps)
29373
+ this.tuyaDpRouter.deliver(d.sn, dps);
29374
+ }).catch((e) => this.reportError(e));
29375
+ }
29318
29376
  }
29319
29377
  }
29320
29378
  /**
@@ -30787,6 +30845,7 @@ __export(tuya_exports, {
30787
30845
  genDeviceId: () => genDeviceId,
30788
30846
  isoToDialCode: () => isoToDialCode,
30789
30847
  resolveCountryCode: () => resolveCountryCode,
30848
+ resolveSignKey: () => resolveSignKey,
30790
30849
  sendApiRequest: () => sendApiRequest,
30791
30850
  swapMd5: () => swapMd5,
30792
30851
  tuyaUsername: () => tuyaUsername