@mega-yfue/eufy-sdk 0.2.0-beta.15 → 0.2.0-beta.17

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.
@@ -5,6 +5,14 @@ import type { RegionShard } from "../transport/http/mega-client.js";
5
5
  */
6
6
  export interface PersistedSession {
7
7
  userId: string;
8
+ /**
9
+ * The eufy account's own `user_id` — the id the `gtoken` header is hashed from, which is not always the
10
+ * `ap_cloud_user_id` that `userId` prefers.
11
+ *
12
+ * Always written, equal to `userId` when the login reply carried only the one id. Absent therefore means a
13
+ * record from before this was tracked, which {@link isSessionValid} refuses rather than restore.
14
+ */
15
+ accountUserId?: string;
8
16
  authToken: string;
9
17
  geoKey?: string;
10
18
  region: RegionShard;
@@ -50,5 +58,14 @@ export declare class FileSessionStore<T = PersistedSession> implements SessionSt
50
58
  * (e.g. Solix) whose session shape differs but whose freshness rule is identical.
51
59
  */
52
60
  export declare function tokenNotExpired(tokenExpiresAt: number | undefined, skewSec?: number): boolean;
53
- /** A persisted session is usable if it has a token that isn't (near-)expired. */
61
+ /**
62
+ * A persisted session is usable if it carries a complete credential — token, bound ECDH key, and the account
63
+ * id the `gtoken` header is hashed from — whose token isn't (near-)expired.
64
+ *
65
+ * `accountUserId` is part of the credential, not an optional extra: a record without it was written before
66
+ * that id was tracked, so it can only be restored as the other id, which is what the gateway rejects the
67
+ * header on. Nothing in a restored session can recover it either — the id arrives with a login reply. So such
68
+ * a record is refused and one login re-establishes it, rather than reinstating a session whose every
69
+ * authenticated call fails identically.
70
+ */
54
71
  export declare function isSessionValid(s: PersistedSession | null, skewSec?: number): boolean;
package/dist/index.js CHANGED
@@ -153,7 +153,7 @@ function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
153
153
  return true;
154
154
  }
155
155
  function isSessionValid(s, skewSec = 300) {
156
- if (!s?.authToken || !s.shareKey || !s.keyIdent)
156
+ if (!s?.authToken || !s.shareKey || !s.keyIdent || !s.accountUserId)
157
157
  return false;
158
158
  return tokenNotExpired(s.tokenExpiresAt, skewSec);
159
159
  }
@@ -1135,7 +1135,7 @@ var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
1135
1135
  var REAUTH_STABLE_MS = 10 * 6e4;
1136
1136
  var CONTENDED_SESSION_HINT = "another client may be signed in with the same account and device identity, and each login displaces the other's session; give each client its own openudid";
1137
1137
  function tokenRejected(code, msg) {
1138
- return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid.*token|token.*(expired|error|not exist)|kicked|(?:token|session).*does not exist|unauthor/i.test(msg ?? "");
1138
+ return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1139
1139
  }
1140
1140
  function withoutTokenEcho(text2) {
1141
1141
  return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
@@ -1147,6 +1147,13 @@ var MegaHttpClient = class {
1147
1147
  sessionKey;
1148
1148
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
1149
1149
  sessionKeys = /* @__PURE__ */ new Map();
1150
+ /**
1151
+ * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
1152
+ * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
1153
+ *
1154
+ * The `gtoken` header is hashed from `accountUserId`: that is the id the gateway recomputes the header
1155
+ * from, rejecting a disagreement with `"gtoken not equal userid error"`.
1156
+ */
1150
1157
  auth_;
1151
1158
  /** captcha_id of an in-flight challenge, held between login() and solveCaptcha(). */
1152
1159
  pendingCaptchaId;
@@ -1212,7 +1219,12 @@ var MegaHttpClient = class {
1212
1219
  if (!isSessionValid(saved) || !saved)
1213
1220
  return void 0;
1214
1221
  this.region = saved.region;
1215
- this.auth_ = { userId: saved.userId, authToken: saved.authToken, geoKey: saved.geoKey };
1222
+ this.auth_ = {
1223
+ userId: saved.userId,
1224
+ accountUserId: saved.accountUserId,
1225
+ authToken: saved.authToken,
1226
+ geoKey: saved.geoKey
1227
+ };
1216
1228
  this.tokenExpiresAt = saved.tokenExpiresAt;
1217
1229
  this.sessionKey = {
1218
1230
  keyIdent: saved.keyIdent,
@@ -1275,7 +1287,16 @@ var MegaHttpClient = class {
1275
1287
  };
1276
1288
  }
1277
1289
  /**
1278
- * The account-credential headers every authed call carries — `x-auth-token` + `gtoken` (`md5(userId)`).
1290
+ * The id `gtoken` is hashed from — the account's own `user_id`, which is what the gateway recomputes the
1291
+ * header from. One place so the two header paths cannot drift on which of the session's ids that is.
1292
+ *
1293
+ * Call only where `auth_` is already established; every header path guards it.
1294
+ */
1295
+ gtokenUserId() {
1296
+ return this.auth_.accountUserId ?? this.auth_.userId;
1297
+ }
1298
+ /**
1299
+ * The account-credential headers every authed call carries — `x-auth-token` + `gtoken`.
1279
1300
  * One place so the signed path, the key-exchange and the bearer path can't drift on what "authed" means.
1280
1301
  */
1281
1302
  authTokenHeaders() {
@@ -1284,7 +1305,7 @@ var MegaHttpClient = class {
1284
1305
  return {
1285
1306
  "x-auth-token": this.auth_.authToken,
1286
1307
  authorization: this.auth_.authToken,
1287
- gtoken: gtoken(this.auth_.userId)
1308
+ gtoken: gtoken(this.gtokenUserId())
1288
1309
  };
1289
1310
  }
1290
1311
  /**
@@ -1588,7 +1609,7 @@ var MegaHttpClient = class {
1588
1609
  try {
1589
1610
  return await downloadMediaResource(url, {
1590
1611
  "x-auth-token": this.auth_.authToken,
1591
- gtoken: gtoken(this.auth_.userId),
1612
+ gtoken: gtoken(this.gtokenUserId()),
1592
1613
  "app-name": "eufy_mega",
1593
1614
  "model-type": "PHONE",
1594
1615
  "user-agent": this.mediaUserAgent
@@ -1890,17 +1911,19 @@ var MegaHttpClient = class {
1890
1911
  {
1891
1912
  const cap = Object.keys(res).filter((k) => /captcha|answer|picture|image|fa_/i.test(k));
1892
1913
  this.logger.debug("[mega] login resp keys:", Object.keys(res).join(","));
1914
+ this.logger.debug("[mega] ids agree:", res.ap_cloud_user_id === res.user_id);
1893
1915
  if (cap.length)
1894
1916
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1895
1917
  }
1896
1918
  const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1919
+ const accountUserId = res.user_id ?? res.userId;
1897
1920
  const authToken = res.auth_token ?? res.token;
1898
1921
  if (!userId || !authToken)
1899
1922
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1900
1923
  const faInfo = res.fa_info ?? {};
1901
1924
  const needs2fa = !isVerify && !!faInfo.info;
1902
1925
  if (needs2fa) {
1903
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1926
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1904
1927
  this.pendingCaptchaId = void 0;
1905
1928
  this.pending2fa = true;
1906
1929
  await this.sendVerifyCode(messageType);
@@ -1908,7 +1931,7 @@ var MegaHttpClient = class {
1908
1931
  }
1909
1932
  this.pendingCaptchaId = void 0;
1910
1933
  this.pending2fa = false;
1911
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1934
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1912
1935
  this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
1913
1936
  this.sessionKey = void 0;
1914
1937
  await this.ensureSessionKey();
@@ -1921,6 +1944,7 @@ var MegaHttpClient = class {
1921
1944
  return;
1922
1945
  this.store.save({
1923
1946
  userId: this.auth_.userId,
1947
+ accountUserId: this.auth_.accountUserId ?? this.auth_.userId,
1924
1948
  authToken: this.auth_.authToken,
1925
1949
  geoKey: this.auth_.geoKey,
1926
1950
  region: this.region,
@@ -13607,11 +13631,7 @@ function inspectParams(rec, sn) {
13607
13631
 
13608
13632
  // dist/model/capabilities/solix.js
13609
13633
  var SOLIX_ENERGY_METER_MEMBERS = {
13610
- /**
13611
- * Line-1 voltage (V), ff09 tag `0xAC` — the ONE confirmed meter binding, matched against a live
13612
- * single-phase frame (a nominal mains voltage). Read-only; the evidence gate installs its getter only
13613
- * once a frame carrying `0xAC` has landed, so it is absent (not a fabricated `0`) until then.
13614
- */
13634
+ /** Line-1 voltage (V), ff09 tag `0xAC` — confirmed live (a nominal mains voltage). */
13615
13635
  meterVoltageL1: {
13616
13636
  param: 172,
13617
13637
  type: "number",
@@ -13619,6 +13639,87 @@ var SOLIX_ENERGY_METER_MEMBERS = {
13619
13639
  unit: "V",
13620
13640
  provenance: "verified",
13621
13641
  description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
13642
+ },
13643
+ /** Line-2 voltage (V), ff09 tag `0xAD` — the app's field; reads 0 until a multi-phase frame carries it. */
13644
+ meterVoltageL2: {
13645
+ param: 173,
13646
+ type: "number",
13647
+ kind: "scalar",
13648
+ unit: "V",
13649
+ provenance: "guessed",
13650
+ description: "Meter line-2 voltage (V) \u2014 ff09 tag 0xAD; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13651
+ },
13652
+ /** Line-3 voltage (V), ff09 tag `0xAE` — the app's field; reads 0 until a multi-phase frame carries it. */
13653
+ meterVoltageL3: {
13654
+ param: 174,
13655
+ type: "number",
13656
+ kind: "scalar",
13657
+ unit: "V",
13658
+ provenance: "guessed",
13659
+ description: "Meter line-3 voltage (V) \u2014 ff09 tag 0xAE; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13660
+ },
13661
+ /** Line-1 current (A), ff09 tag `0xAF` — confirmed live (the line's CT current). */
13662
+ meterCurrentL1: {
13663
+ param: 175,
13664
+ type: "number",
13665
+ kind: "scalar",
13666
+ unit: "A",
13667
+ provenance: "verified",
13668
+ description: "Meter line-1 current (A) \u2014 ff09 tag 0xAF, confirmed against a live single-phase frame."
13669
+ },
13670
+ /** Line-2 current (A), ff09 tag `0xB0` — the app's field; reads 0 until a multi-phase frame carries it. */
13671
+ meterCurrentL2: {
13672
+ param: 176,
13673
+ type: "number",
13674
+ kind: "scalar",
13675
+ unit: "A",
13676
+ provenance: "guessed",
13677
+ description: "Meter line-2 current (A) \u2014 ff09 tag 0xB0; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13678
+ },
13679
+ /** Line-3 current (A), ff09 tag `0xB1` — the app's field; reads 0 until a multi-phase frame carries it. */
13680
+ meterCurrentL3: {
13681
+ param: 177,
13682
+ type: "number",
13683
+ kind: "scalar",
13684
+ unit: "A",
13685
+ provenance: "guessed",
13686
+ description: "Meter line-3 current (A) \u2014 ff09 tag 0xB1; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13687
+ },
13688
+ /** Line-1 active power (W), ff09 tag `0xA8` — confirmed live; negative on export. */
13689
+ meterPowerL1: {
13690
+ param: 168,
13691
+ type: "number",
13692
+ kind: "scalar",
13693
+ unit: "W",
13694
+ provenance: "verified",
13695
+ description: "Meter line-1 active power (W) \u2014 ff09 tag 0xA8, confirmed live; negative on export."
13696
+ },
13697
+ /** Line-2 active power (W), ff09 tag `0xA9` — the app's field; reads 0 until a multi-phase frame carries it. */
13698
+ meterPowerL2: {
13699
+ param: 169,
13700
+ type: "number",
13701
+ kind: "scalar",
13702
+ unit: "W",
13703
+ provenance: "guessed",
13704
+ description: "Meter line-2 active power (W) \u2014 ff09 tag 0xA9; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13705
+ },
13706
+ /** Line-3 active power (W), ff09 tag `0xAA` — the app's field; reads 0 until a multi-phase frame carries it. */
13707
+ meterPowerL3: {
13708
+ param: 170,
13709
+ type: "number",
13710
+ kind: "scalar",
13711
+ unit: "W",
13712
+ provenance: "guessed",
13713
+ description: "Meter line-3 active power (W) \u2014 ff09 tag 0xAA; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13714
+ },
13715
+ /** Aggregate active power (W), ff09 tag `0xAB` — confirmed live; equals line-1 on a single phase. */
13716
+ meterPowerTotal: {
13717
+ param: 171,
13718
+ type: "number",
13719
+ kind: "scalar",
13720
+ unit: "W",
13721
+ provenance: "verified",
13722
+ description: "Meter total active power (W) \u2014 ff09 tag 0xAB, confirmed live; equals L1 on one phase."
13622
13723
  }
13623
13724
  };
13624
13725
  var CATEGORY_CAPABILITIES = {
@@ -26406,6 +26507,11 @@ var SolixClient = class {
26406
26507
  * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
26407
26508
  * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
26408
26509
  * has been satisfied.
26510
+ *
26511
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
26512
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
26513
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
26514
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
26409
26515
  */
26410
26516
  classifyLogin(data, isVerify) {
26411
26517
  const userId = data.ap_cloud_user_id ?? data.user_id;
@@ -26500,8 +26606,20 @@ var SolixClient = class {
26500
26606
  // dist/transport/mqtt/solix-mqtt.js
26501
26607
  import { EventEmitter as EventEmitter10 } from "node:events";
26502
26608
  var SOLIX_METER_FIELD_NAMES = {
26503
- 172: "meterVoltageL1"
26609
+ 168: "meterPowerL1",
26610
+ 169: "meterPowerL2",
26611
+ 170: "meterPowerL3",
26612
+ 171: "meterPowerTotal",
26613
+ 172: "meterVoltageL1",
26614
+ 173: "meterVoltageL2",
26615
+ 174: "meterVoltageL3",
26616
+ 175: "meterCurrentL1",
26617
+ 176: "meterCurrentL2",
26618
+ 177: "meterCurrentL3",
26619
+ 179: "meterImportEnergy",
26620
+ 180: "meterExportEnergy"
26504
26621
  };
26622
+ var SOLIX_METER_PRODUCT_PREFIXES = ["AE1X0"];
26505
26623
  function readSolixChannel(value) {
26506
26624
  if (!value || value.length < 1)
26507
26625
  return void 0;
@@ -26535,8 +26653,9 @@ function decodeSolixParamFrame(buf) {
26535
26653
  deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
26536
26654
  return { deviceSn, fields };
26537
26655
  }
26538
- function solixReadings(frame) {
26656
+ function solixReadings(frame, productCode) {
26539
26657
  const out = {};
26658
+ const named = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
26540
26659
  for (const [tag2, value] of frame.fields) {
26541
26660
  if (tag2 < 166)
26542
26661
  continue;
@@ -26544,7 +26663,7 @@ function solixReadings(frame) {
26544
26663
  if (ch?.type !== 5 || ch.float === void 0)
26545
26664
  continue;
26546
26665
  out[`channel_${tag2.toString(16)}`] = ch.float;
26547
- const name = SOLIX_METER_FIELD_NAMES[tag2];
26666
+ const name = named ? SOLIX_METER_FIELD_NAMES[tag2] : void 0;
26548
26667
  if (name)
26549
26668
  out[name] = ch.float;
26550
26669
  }
@@ -26726,12 +26845,13 @@ var SolixMqtt = class extends EventEmitter10 {
26726
26845
  if (!frame)
26727
26846
  return;
26728
26847
  const parts = topic.split("/");
26848
+ const productCode = parts[2] ?? "";
26729
26849
  const reading = {
26730
26850
  deviceSn: frame.deviceSn ?? parts[3] ?? "",
26731
- productCode: parts[2] ?? "",
26851
+ productCode,
26732
26852
  topic,
26733
26853
  frame,
26734
- values: solixReadings(frame)
26854
+ values: solixReadings(frame, productCode)
26735
26855
  };
26736
26856
  this.emit("reading", reading);
26737
26857
  }