@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.
- package/dist/core/store.d.ts +18 -1
- package/dist/index.js +138 -18
- package/dist/index.js.map +2 -2
- package/dist/model/capabilities/solix.d.ts +102 -15
- package/dist/transport/http/mega-client.d.ts +15 -1
- package/dist/transport/http/solix-client.d.ts +5 -0
- package/dist/transport/mqtt/solix-mqtt.d.ts +37 -13
- package/package.json +1 -1
package/dist/core/store.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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_ = {
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
}
|