@mega-yfue/eufy-sdk 0.2.0-beta.8 → 0.2.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 (53) hide show
  1. package/dist/client/device-registry.d.ts +14 -28
  2. package/dist/client/eufy-mega.d.ts +11 -6
  3. package/dist/client/types.d.ts +6 -2
  4. package/dist/core/contracts.d.ts +23 -27
  5. package/dist/core/crypto.d.ts +10 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/solix-types.d.ts +121 -0
  8. package/dist/core/store.d.ts +38 -10
  9. package/dist/index.js +2709 -503
  10. package/dist/index.js.map +4 -4
  11. package/dist/model/capabilities/access.d.ts +22 -3
  12. package/dist/model/capabilities/arming.d.ts +33 -27
  13. package/dist/model/capabilities/battery.d.ts +32 -4
  14. package/dist/model/capabilities/contact.d.ts +4 -0
  15. package/dist/model/capabilities/doorbell.d.ts +24 -14
  16. package/dist/model/capabilities/index.d.ts +14 -3
  17. package/dist/model/capabilities/lock.d.ts +15 -12
  18. package/dist/model/capabilities/ptz.d.ts +6 -2
  19. package/dist/model/capabilities/solix.d.ts +173 -0
  20. package/dist/model/capabilities/types.d.ts +60 -10
  21. package/dist/model/capabilities/vacuum-clean.d.ts +59 -0
  22. package/dist/model/classify.d.ts +3 -1
  23. package/dist/model/device-family.d.ts +2 -1
  24. package/dist/model/device-types.d.ts +1 -0
  25. package/dist/model/device.d.ts +15 -0
  26. package/dist/model/index.d.ts +5 -0
  27. package/dist/model/solix-catalog.d.ts +25 -0
  28. package/dist/model/solix-device.d.ts +137 -0
  29. package/dist/model/solix-family.d.ts +31 -0
  30. package/dist/model/solix-site.d.ts +70 -0
  31. package/dist/transport/ff09.d.ts +7 -0
  32. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  33. package/dist/transport/http/index.d.ts +1 -0
  34. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  35. package/dist/transport/http/media-download.d.ts +3 -0
  36. package/dist/transport/http/mega-client.d.ts +89 -9
  37. package/dist/transport/http/solix-client.d.ts +270 -0
  38. package/dist/transport/http/solix-constants.d.ts +56 -0
  39. package/dist/transport/media-failure.d.ts +48 -0
  40. package/dist/transport/mqtt/index.d.ts +3 -0
  41. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  42. package/dist/transport/mqtt/solix-mqtt.d.ts +360 -0
  43. package/dist/transport/mqtt/topics.d.ts +30 -0
  44. package/dist/transport/p2p/command-router.d.ts +131 -19
  45. package/dist/transport/p2p/live-stream.d.ts +5 -4
  46. package/dist/transport/p2p/live-trace.d.ts +32 -5
  47. package/dist/transport/p2p/media.d.ts +2 -2
  48. package/dist/transport/p2p/p2p-session.d.ts +9 -0
  49. package/dist/transport/p2p/session-manager.d.ts +57 -23
  50. package/dist/transport/p2p/shared-live-source.d.ts +10 -1
  51. package/dist/transport/p2p/station-channels.d.ts +54 -0
  52. package/dist/transport/stored-image-cache.d.ts +7 -1
  53. package/package.json +5 -5
@@ -32,6 +32,14 @@ export interface MegaClientConfig {
32
32
  * an explicit value pins a fixed one. Not the account identity — that's `phoneModel`.
33
33
  */
34
34
  mediaUserAgent?: string;
35
+ /**
36
+ * Acting name written into the commands that carry an actor field — guard mode and HomeBase alarm
37
+ * output (`user_name`), a lock's acting username. Trimmed, and blank counts as unset: the default
38
+ * is the login email's local-part (the whole string when it has no `@`). Attribution only — the
39
+ * device stores it for its own activity record, and no captured frame shows it being validated
40
+ * against the account.
41
+ */
42
+ accountName?: string;
35
43
  /** Persist + reuse the session (token + session key) across runs. Default: in-memory. */
36
44
  store?: SessionStore;
37
45
  /** Diagnostics sink. Omit for silence; pass a `Logger` (or `new ConsoleLogger()`) to see logs. */
@@ -63,9 +71,34 @@ export declare class MegaApiError extends Error {
63
71
  * device-list params carry the same `{param_type, param_value, update_time}` and are not owner-gated.
64
72
  */
65
73
  export declare const OWNER_ONLY_CODE = 20004;
66
- /** Thrown when a persisted/expired session is rejected (401). Re-login to recover. */
74
+ /**
75
+ * Thrown when a persisted/expired session is rejected (401). Re-login to recover.
76
+ *
77
+ * It carries the rate the client has already worked out for replacing a rejected token, because the
78
+ * rejection is where that rate stops being the client's alone: a login driven from here spends the same
79
+ * session the client's own recovery would have, and repeated logins are what makes an account start
80
+ * demanding captchas. {@link retryAfterMs} is how long the next replacement is barred for, and
81
+ * {@link contended} whether this rejection landed inside that bar — the shape repeated displacement has.
82
+ */
67
83
  export declare class SessionExpiredError extends Error {
68
- constructor(message: string);
84
+ /**
85
+ * How long the next session replacement is barred for, in milliseconds; `0` when nothing bars one now.
86
+ *
87
+ * The remainder of the client's own hold-off, which doubles per consecutive replacement and is capped —
88
+ * and which every replacement extends, whether the client spent it or a login made on this error did.
89
+ */
90
+ readonly retryAfterMs: number;
91
+ /**
92
+ * Whether this rejection landed inside that bar — a token replaced recently and rejected again since.
93
+ *
94
+ * It says the session is being DISPLACED rather than expiring: something else is signing in on this
95
+ * account, and replacing the token again only trades one login for another.
96
+ */
97
+ readonly contended: boolean;
98
+ constructor(message: string, opts?: {
99
+ retryAfterMs?: number;
100
+ contended?: boolean;
101
+ });
69
102
  }
70
103
  /**
71
104
  * eufy cloud gateway error codes — the numeric `code` carried in a response envelope alongside the
@@ -179,6 +212,13 @@ export declare class MegaHttpClient {
179
212
  private sessionKey?;
180
213
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
181
214
  private readonly sessionKeys;
215
+ /**
216
+ * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
217
+ * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
218
+ *
219
+ * The `gtoken` header is hashed from `accountUserId`: that is the id the gateway recomputes the header
220
+ * from, rejecting a disagreement with `"gtoken not equal userid error"`.
221
+ */
182
222
  private auth_?;
183
223
  /** captcha_id of an in-flight challenge, held between login() and solveCaptcha(). */
184
224
  private pendingCaptchaId?;
@@ -208,9 +248,11 @@ export declare class MegaHttpClient {
208
248
  private loggingIn;
209
249
  /** The one in-flight re-login every call rejected on the same dead token waits on. */
210
250
  private reauthAttempt?;
211
- /** Replacements since the held session last proved stable, and when the last one ran — see {@link recoveryDue}. */
251
+ /** Replacements since the held session last proved stable, and when the last one ran — see {@link holdOffRemainingMs}. */
212
252
  private recoveries;
213
253
  private lastRecoveryAt;
254
+ /** A token of ours has been rejected and not yet replaced — see {@link noteTokenReplacement}. */
255
+ private rejectedTokenPending;
214
256
  constructor(cfg: MegaClientConfig);
215
257
  /**
216
258
  * Install the session the store holds, if it holds a usable one: the token + its bound ECDH key, skipping
@@ -231,10 +273,15 @@ export declare class MegaHttpClient {
231
273
  /** The active region shard (e.g. `"eu-pr"`, `"us-pr"`), set after {@link login} or a region override. */
232
274
  get regionShard(): RegionShard;
233
275
  /**
234
- * The logged-in account's display name — the login email's local-part (e.g. `someone+tag` for
235
- * `someone+tag@example.com`). This is the string the app writes into the ff09 command's acting
236
- * "username" field (verified against a captured T8531 unlock frame). Falls back to the whole email
237
- * if it has no `@`.
276
+ * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
277
+ * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
278
+ * is the login email's local-part (e.g. `someone+tag` for `someone+tag@example.com`) and falls back
279
+ * to the whole email if it has no `@`.
280
+ *
281
+ * The local-part is the string the app writes into the ff09 command's acting "username" field
282
+ * (verified against a captured T8531 unlock frame), so it is the faithful default. An override is a
283
+ * different LABEL for the same account, not a different identity: the session authenticates on the
284
+ * token and the device record's member ids, neither of which this touches.
238
285
  */
239
286
  get accountName(): string;
240
287
  /**
@@ -246,7 +293,14 @@ export declare class MegaHttpClient {
246
293
  */
247
294
  private baseHeaders;
248
295
  /**
249
- * The account-credential headers every authed call carries — `x-auth-token` + `gtoken` (`md5(userId)`).
296
+ * The id `gtoken` is hashed from — the account's own `user_id`, which is what the gateway recomputes the
297
+ * header from. One place so the two header paths cannot drift on which of the session's ids that is.
298
+ *
299
+ * Call only where `auth_` is already established; every header path guards it.
300
+ */
301
+ private gtokenUserId;
302
+ /**
303
+ * The account-credential headers every authed call carries — `x-auth-token` + `gtoken`.
250
304
  * One place so the signed path, the key-exchange and the bearer path can't drift on what "authed" means.
251
305
  */
252
306
  private authTokenHeaders;
@@ -353,7 +407,13 @@ export declare class MegaHttpClient {
353
407
  registerPushToken(token: string): Promise<void>;
354
408
  /** Download raw bytes from a push-media URL using the active account session. */
355
409
  downloadMedia(url: string): Promise<Buffer>;
356
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
410
+ /**
411
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
412
+ *
413
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
414
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
415
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
416
+ */
357
417
  downloadImage(url: string, p2pDid?: string): Promise<Buffer>;
358
418
  /** The security-app data host for this region (face recognition, media, etc.). */
359
419
  private securityAppHost;
@@ -467,6 +527,26 @@ export declare class MegaHttpClient {
467
527
  * and a caller is told the honest reason instead of being served a fight.
468
528
  */
469
529
  private recoveryDue;
530
+ /**
531
+ * How much longer a token replacement must wait, in milliseconds; `0` when one may run now.
532
+ *
533
+ * The wait doubles per consecutive replacement and is capped, and it is what {@link recoveryDue} gates
534
+ * this client's own recovery on — and what {@link SessionExpiredError.retryAfterMs} hands a host that
535
+ * drives its own. One function so the two cannot disagree about the rate, which they would have to for
536
+ * a host to be told it may retry while this client is still holding off.
537
+ */
538
+ private holdOffRemainingMs;
539
+ /**
540
+ * Count one token replacement against the hold-off, and clear the rejection it answered.
541
+ *
542
+ * Every replacement passes through here, wherever it was spent from: {@link recoverRejectedSession}, and
543
+ * a {@link login} that follows a rejection this client surfaced. A hold-off that counted only its own
544
+ * would be no bound at all — the wait would sit at its first value however many sessions had been spent,
545
+ * and {@link SessionExpiredError.retryAfterMs} would report a minute while logins ran every few seconds.
546
+ * Which of the two counted a given replacement is the flag: the recovery path clears it before logging
547
+ * in, so the login cannot count the same one again.
548
+ */
549
+ private noteTokenReplacement;
470
550
  /**
471
551
  * Note that the held session is working. A replacement that keeps serving calls for long enough is not
472
552
  * contention, so the hold-off is forgotten and the next genuine expiry recovers immediately.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * A minimal client for the Anker Solix power-station cloud, driven by the SAME account login the
3
+ * eufy client uses.
4
+ *
5
+ * Why this is separate from the eufy device client: Solix shares Anker's `algo_ecdh` passport (so
6
+ * {@link prepareKeyExchange} / {@link encryptLoginPassword} / {@link signRequest} are reused verbatim
7
+ * for the login handshake) but exposes a different device backend — its own `app-name`, host, and
8
+ * bootstrap key (`SOLIX_APP_NAME`, `SOLIX_DEFAULT_API_HOST`, {@link SOLIX_LOCAL_KEY_HEX}) —
9
+ * and its authenticated resource reads are PLAIN JSON, carrying only the auth token and a
10
+ * `gtoken = md5(user_id)`, with no per-request encryption or signature. This client therefore does
11
+ * the encrypted passport handshake to obtain a token, then makes plain authenticated reads.
12
+ *
13
+ * This is the wire client (transport layer): it returns the vendor's typed JSON as received. Building
14
+ * those records into capability-driven `SolixDevice` models is the model layer's job — see
15
+ * `discoverSolixDevices()` — so the two stay decorrelated (transport never imports model).
16
+ */
17
+ import { type SessionStore, type SolixDeviceRecord, type SolixPowerCutoffOption, type SolixProductCategory, type SolixSiteRecord, type SolixSiteScene, type SolixSocParams } from "../../core/index.js";
18
+ import type { SecureMqttCredentials } from "../mqtt/secure-mqtt.js";
19
+ /** An authenticated Solix session — the token + the derived `gtoken` + the resolved API host. */
20
+ export interface SolixSession {
21
+ authToken: string;
22
+ userId: string;
23
+ /** `md5(user_id)` — sent as the `gtoken` header on every authenticated read. */
24
+ gtoken: string;
25
+ /** The regional API host the account resolved to (e.g. the EU shard). */
26
+ apiHost: string;
27
+ /** Unix seconds; 0 when the server did not supply one. */
28
+ tokenExpiresAt: number;
29
+ }
30
+ /**
31
+ * Outcome of {@link SolixClient.login}. `2fa` mirrors the eufy passport: the server sent a code and
32
+ * the client holds a limited token — call {@link SolixClient.submitVerifyCode} to finish.
33
+ */
34
+ export type SolixLoginResult = {
35
+ status: "ok";
36
+ session: SolixSession;
37
+ } | {
38
+ status: "2fa";
39
+ method: string;
40
+ };
41
+ /** Options for {@link SolixClient}. */
42
+ export interface SolixClientOptions {
43
+ email: string;
44
+ password: string;
45
+ /** ISO-3166 alpha-2; defaults to "US". Sent as `country` and `ab`. */
46
+ countryCode?: string;
47
+ /** Override the API host (skips domain-estimate). Defaults to estimate → `SOLIX_DEFAULT_API_HOST`. */
48
+ apiHost?: string;
49
+ /** App version reported to the cloud. */
50
+ appVersion?: string;
51
+ /**
52
+ * Stable per-install device id (UUID). The auth token is bound to it, and a shifting id looks like
53
+ * a new device each run and re-triggers 2FA. Defaults to a deterministic id derived from the email
54
+ * (stable across runs); a store's saved id wins over this.
55
+ */
56
+ openudid?: string;
57
+ /** Persist the token + device id so a dedicated account logs in once and reuses it until expiry. */
58
+ store?: SolixSessionStore;
59
+ /** Injected fetch (for tests). Defaults to the global `fetch`. */
60
+ fetchImpl?: typeof fetch;
61
+ }
62
+ /** What {@link SolixSessionStore} holds: the stable device id and (once logged in) the session. */
63
+ export interface SolixPersisted {
64
+ openudid: string;
65
+ session?: SolixSession;
66
+ }
67
+ /**
68
+ * A place to persist a Solix session across process runs — the core {@link SessionStore} parameterised on
69
+ * the Solix record shape, so `FileSessionStore` serves it as-is. The device id survives token expiry (so
70
+ * the account keeps seeing the same device and does not re-prompt 2FA), and a live session is reused
71
+ * until it expires.
72
+ */
73
+ export type SolixSessionStore = SessionStore<SolixPersisted>;
74
+ /**
75
+ * Login + read client for one Anker account's Solix devices. Construct with the account
76
+ * credentials, `await login()`, then read {@link getDevices} / {@link getSites} / {@link
77
+ * getUserMqttInfo}. Not tied to any host runtime.
78
+ */
79
+ export declare class SolixClient {
80
+ private readonly email;
81
+ private readonly password;
82
+ private readonly country;
83
+ private readonly appVersion;
84
+ private readonly doFetch;
85
+ private readonly store?;
86
+ private readonly openudid;
87
+ private apiHost;
88
+ private session_?;
89
+ /** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
90
+ private pending2fa?;
91
+ /**
92
+ * Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
93
+ * not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
94
+ * handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
95
+ * override that also skips domain-estimate, and every read goes through `this.apiHost`.
96
+ */
97
+ constructor(opts: SolixClientOptions);
98
+ /** Persist the current device id (+ session, if any) when a store is configured. */
99
+ private persist;
100
+ /** The authenticated session, once {@link login} has resolved to `ok`. */
101
+ get session(): SolixSession | undefined;
102
+ /**
103
+ * Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
104
+ * must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
105
+ * (`401 token error`) — so those use {@link baseHeaders} directly.
106
+ */
107
+ private authHeaders;
108
+ /** Base headers common to every Solix request. */
109
+ private baseHeaders;
110
+ /** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
111
+ private send;
112
+ /** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
113
+ private post;
114
+ /** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
115
+ private estimateHost;
116
+ /** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
117
+ private keyExchange;
118
+ /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
119
+ private postLogin;
120
+ /**
121
+ * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
122
+ * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
123
+ * has been satisfied.
124
+ *
125
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
126
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
127
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
128
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
129
+ */
130
+ private classifyLogin;
131
+ /** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
132
+ private decryptLogin;
133
+ /**
134
+ * Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
135
+ * when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
136
+ * (adopted from a store) is answered without a handshake.
137
+ */
138
+ login(): Promise<SolixLoginResult>;
139
+ /**
140
+ * On a rejected `/passport/login` (non-zero code, so `data` is an error envelope not the encrypted
141
+ * payload), throw a diagnostic that names WHY the passport refused — the throttle (`26161`, "too
142
+ * frequent") vs a challenge it wants the client to satisfy. The passport marks a required captcha with
143
+ * a `captcha_id`/`item`; our headless client cannot answer one, so surfacing it distinguishes "wait
144
+ * out the rate-limit" from "a captcha is required — clear it in the app". No secrets are logged, only
145
+ * the code, message, and which challenge fields are present.
146
+ */
147
+ private assertLoginAccepted;
148
+ /** Complete a `2fa` login with the code the passport sent. */
149
+ submitVerifyCode(code: string): Promise<SolixLoginResult>;
150
+ /**
151
+ * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
152
+ * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
153
+ *
154
+ * Self-heals a **displaced session**: Anker allows ~one session per account, so another login (the app,
155
+ * or a second client) invalidates this token and reads then fail with {@link SOLIX_TOKEN_KICKED_CODE}
156
+ * ("token does not exist because it was kicked out"). On that code this re-logs in once and retries, so
157
+ * a running client recovers on its own instead of failing every read until its session store is cleared.
158
+ */
159
+ private authed;
160
+ /**
161
+ * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
162
+ * gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
163
+ * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
164
+ */
165
+ getDevices(): Promise<SolixDeviceRecord[]>;
166
+ /**
167
+ * The account's sites (systems); devices are grouped under a site. Each record carries its
168
+ * `site_device_list` (the member devices), which {@link discoverSolixSites} resolves into a
169
+ * capability-driven `SolixSite`. Asserted to {@link SolixSiteRecord} at this trust boundary — and
170
+ * `site_id` (the one field the model layer keys a `SolixSite` on) is validated here, so a record the
171
+ * cloud returns without a usable id is dropped rather than surfacing a `SolixSite` with `id ===
172
+ * undefined`; every other field is optional and read defensively.
173
+ */
174
+ getSites(): Promise<SolixSiteRecord[]>;
175
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
176
+ getUserMqttInfo(): Promise<SecureMqttCredentials>;
177
+ /**
178
+ * Read a site's "scene" snapshot — the app's dashboard read for a system, a plain authed read. Its
179
+ * battery detail (`solarbank_info.solarbank_list[]`) carries clean, correctly-named fields including
180
+ * `bat_temperature`, which the realtime `ff09` MQTT push does NOT reliably carry (the fast frame's BMS
181
+ * blob is empty, so the decoder withholds temperature). This is therefore a low-rate BACKSTOP for those
182
+ * gap fields — NOT the realtime source: live power/SOC still come from the MQTT push (which is what the
183
+ * app itself refreshes from every ~5 s; there is no clean-JSON scene PUSH). Verified live against the
184
+ * `ff09` floats — the two agree to the watt at the same instant.
185
+ */
186
+ getSiteScene(siteId: string): Promise<SolixSiteScene>;
187
+ /**
188
+ * The pairable-product catalog (categories → products). This is Anker's product registry, not the
189
+ * account's devices — fetch it to label a discovered device's model code with a marketing name and
190
+ * category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
191
+ * baked-in table.
192
+ */
193
+ getProductCatalog(): Promise<SolixProductCategory[]>;
194
+ /**
195
+ * Write device attributes — a CONTROL write, e.g. the Solarbank ambient light
196
+ * `{ ambient_light_switch: 0 | 1 }` (0 = on, 1 = off). Unlike the plain authenticated reads, a write
197
+ * must be **encrypted + signed** with a freshly negotiated `algo_ecdh` key: the gateway accepts an
198
+ * unsigned write with `code 0` but the device never applies it. The token-bearing request also must
199
+ * NOT carry the device id (`openudid`), or the gateway answers `401 token error`. Both verified live
200
+ * on an AE103 (the LED-enable bit in the `ba` telemetry flips exactly as commanded).
201
+ */
202
+ setDeviceAttrs(deviceSn: string, attributes: Record<string, unknown>): Promise<void>;
203
+ /**
204
+ * A CONTROL write: `algo_ecdh`-encrypted + signed, token-bearing but WITHOUT `openudid`. Every device
205
+ * control the account performs (set_device_attrs, set_power_cutoff, …) goes through this — the gateway
206
+ * accepts an unsigned/plain write with `code 0` but the device never applies it, and adding `openudid`
207
+ * to the token-bearing request returns `401 token error`. Both verified live on an AE103.
208
+ */
209
+ private encryptedWrite;
210
+ /** Turn the Solarbank's ambient LED on/off — a confirmed `set_device_attrs` write. */
211
+ setAmbientLight(deviceSn: string, on: boolean): Promise<void>;
212
+ /**
213
+ * Read device attributes — a plain authenticated read (unlike the encrypted write). `attributes`
214
+ * names the keys to fetch (e.g. `["screen_off_time"]`); an empty list asks for the device's default
215
+ * set. Returns the gateway's attribute map as-is (values are device-typed — numbers, strings). Used
216
+ * to reflect a control's live state, e.g. the display/light off-timeout.
217
+ */
218
+ getDeviceAttrs(deviceSn: string, attributes?: string[]): Promise<Record<string, unknown>>;
219
+ /**
220
+ * Set the Solarbank display's screen-off timeout, in SECONDS (`screen_off_time`). The app's picker
221
+ * offers 10/20/30 s and 1/5/30 min; the LCD backlight — and with it the ambient LED that the screen
222
+ * gates — turns off after this idle period. This is the raw-seconds write; the caller maps its own UI
223
+ * options to seconds. The "Never" (always-on) sentinel is device-defined and NOT assumed here — pass
224
+ * the exact integer read back from {@link getDeviceAttrs} while the device is in that mode.
225
+ */
226
+ setScreenOffTime(deviceSn: string, seconds: number): Promise<void>;
227
+ /**
228
+ * Read the Solarbank's battery discharge-cutoff (minimum-SOC) options — a plain authed read.
229
+ * The gateway returns a preset list (`power_cutoff_data`): each entry is a selectable minimum
230
+ * state-of-charge `output_cutoff_data` (percent) with its `id` and `is_selected` flag. The caller
231
+ * presents these options and writes the chosen `id` back via {@link setPowerCutoff} — the values and
232
+ * ids come from the device, never assumed. `siteId` is optional (the device knows its own cutoff).
233
+ */
234
+ getPowerCutoff(deviceSn: string, siteId?: string): Promise<SolixPowerCutoffOption[]>;
235
+ /**
236
+ * Select the Solarbank's battery discharge-cutoff (minimum SOC) by option id — a control write.
237
+ * `cutoffDataId` MUST be an `id` returned by {@link getPowerCutoff} for this device (the preset the
238
+ * user picked), never a raw percentage; the gateway maps the id to its cutoff percent.
239
+ */
240
+ setPowerCutoff(deviceSn: string, cutoffDataId: number): Promise<void>;
241
+ /** The `param_type` under which the Solarbank's SOC-limit block lives (verified live on an AE103). */
242
+ private static readonly SOC_PARAM_TYPE;
243
+ /** `cmd` value that scopes the `site/*_site_device_param` family (from the app's request builder). */
244
+ private static readonly SITE_DEVICE_PARAM_CMD;
245
+ /**
246
+ * Read one of a site's "device param" blocks by `param_type` — a plain authenticated read whose
247
+ * `data.param_data` is itself a JSON STRING (the vendor double-encodes it). Returns the parsed inner
248
+ * object, or `{}` when the block is empty (the gateway answers `code 0` with an empty `param_data`
249
+ * for a `param_type` that does not apply to the site's hardware). The caller owns the inner shape.
250
+ */
251
+ private getSiteDeviceParam;
252
+ /**
253
+ * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
254
+ * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
255
+ * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
256
+ * app-synced source for `chargeUpperLimit`. `backupReserve` (with its enable switch) also has a second
257
+ * source under the same name, the realtime MQTT `b5` frame; whether the two agree while the switch is
258
+ * OFF is not yet verified. Verified live against a known AE103 setting (discharge 20 / charge 80).
259
+ */
260
+ getSafetySocParams(siteId: string): Promise<SolixSocParams | undefined>;
261
+ /**
262
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
263
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
264
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
265
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
266
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
267
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
268
+ */
269
+ setSafetySocParams(siteId: string, changes: Partial<SolixSocParams>): Promise<SolixSocParams>;
270
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Anker "Solix" cloud endpoints + app-line constants for {@link SolixClient}.
3
+ *
4
+ * Solix runs the SAME `algo_ecdh` passport as the eufy_mega account stack, re-skinned under a
5
+ * different `app-name` with its own API host and key-exchange bootstrap key (the bootstrap key lives
6
+ * in `core` beside its siblings — {@link SOLIX_LOCAL_KEY_HEX}). One Anker/eufy account logs in here
7
+ * with the exact login handshake the eufy client uses; only these constants differ. Authenticated
8
+ * resource reads, by contrast, are PLAIN JSON carrying just the auth token + `gtoken`.
9
+ */
10
+ /** The `app-name` header value that scopes the passport + API to the Solix product. */
11
+ export declare const SOLIX_APP_NAME = "anker_power";
12
+ /** Domain-estimate bootstrap host. `POST /passport/estimate_domain {ab,mode:1}` answers the shard host. */
13
+ export declare const SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
14
+ /** EU-shard API host — the estimate result, and the fallback when estimate is skipped. */
15
+ export declare const SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
16
+ /** Solix cloud paths used by {@link SolixClient}. */
17
+ export declare const SOLIX_ENDPOINTS: {
18
+ readonly estimateDomain: "/passport/estimate_domain";
19
+ readonly keyExchange: "/openapi/oauth/key/exchange";
20
+ readonly login: "/passport/login";
21
+ /** Bound devices for the account (flat list). */
22
+ readonly getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices";
23
+ /** Sites (systems) the account owns; devices are grouped under a site. */
24
+ readonly getSiteList: "/power_service/v1/site/get_site_list";
25
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26
+ readonly getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info";
27
+ /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
28
+ readonly productCategories: "/power_service/v1/product_categories";
29
+ /** POST (encrypted+signed): write device attributes, e.g. `{ambient_light_switch: 0|1}`. */
30
+ readonly setDeviceAttrs: "/power_service/v1/app/device/set_device_attrs";
31
+ /** POST (plain authed): read device attributes, e.g. the display `screen_off_time` (seconds). */
32
+ readonly getDeviceAttrs: "/power_service/v1/app/device/get_device_attrs";
33
+ /** POST (plain authed): the battery discharge-cutoff (minimum-SOC) preset options. */
34
+ readonly getPowerCutoff: "/power_service/v1/app/compatible/get_power_cutoff";
35
+ /** POST (encrypted+signed): select the discharge-cutoff preset by `cutoff_data_id`. */
36
+ readonly setPowerCutoff: "/power_service/v1/app/compatible/set_power_cutoff";
37
+ /**
38
+ * POST (plain authed): the site "scene" snapshot — the same clean Solarbank/grid telemetry the app
39
+ * reads on load/refresh. Used as a low-rate BACKSTOP for the fields the realtime `ff09` push doesn't
40
+ * carry reliably (notably `bat_temperature`), NOT as the realtime source (that is the MQTT push).
41
+ */
42
+ readonly getSiteScene: "/power_service/v2/site/platform_get_site_scene";
43
+ /**
44
+ * POST (plain authed): read a site "device param" block by `param_type`. Body is
45
+ * `{ site_id, param_type, cmd: 246 }`; the response's `data.param_data` is a JSON STRING the caller
46
+ * parses. The Solarbank's SOC-limit settings live under `param_type "27"` (charge/discharge limits,
47
+ * backup reserve) — verified live on an AE103 (`"18"` returns empty for this device).
48
+ */
49
+ readonly getSiteDeviceParam: "/power_service/v1/site/get_site_device_param";
50
+ /**
51
+ * POST (encrypted+signed): write a site "device param" block. Body is
52
+ * `{ site_id, cmd: 246, param_type, param_data: <JSON string> }`. Used for the SOC-limit write
53
+ * (`param_type "27"`, `param_data` = the SocSettingParam map) — see {@link SolixClient.setSafetySocParams}.
54
+ */
55
+ readonly setSiteDeviceParam: "/power_service/v1/site/set_site_device_param";
56
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Why acquiring a push thumbnail produced no bytes.
3
+ *
4
+ * Transport vocabulary, and it stays inside `transport/`: the modules that raise it and the cache that
5
+ * reads it are both here, and neither the device model nor the client names it. Nothing crosses the
6
+ * capability boundary, so nothing belongs in the shared floor.
7
+ *
8
+ * @module transport/media-failure
9
+ */
10
+ /**
11
+ * The terms an acquisition failure is reported in — a CLOSED vocabulary, and that is the point.
12
+ *
13
+ * `download-failed` says a candidate did not become an image; it does not say whether the URL was
14
+ * refused before a packet moved, the host answered 404, the transfer timed out, or the bytes arrived
15
+ * and would not decode. Those need entirely different fixes, so the distinction has to survive to
16
+ * whatever is reading the log.
17
+ *
18
+ * It is a fixed vocabulary rather than an error message because the thing that fails holds a signed
19
+ * media URL and a response body, and neither may reach a log line. Every member here is a term this
20
+ * file defines; a downloader's own wording never is.
21
+ *
22
+ * - `url-not-allowed` — the candidate URL failed the media allowlist (scheme, credentials, port, a
23
+ * literal address, or a host the SDK does not download from). Nothing was requested.
24
+ * - `address-not-public` — the host resolved to nothing, or to an address that is not public.
25
+ * - `redirect-not-allowed` — the media host redirected somewhere the object-store allowlist refuses,
26
+ * redirected without a target, or redirected twice.
27
+ * - `http-status` — the host answered, with a status other than 200. Carried alongside as `status`.
28
+ * - `too-large` — the body exceeded the download bound, declared or observed.
29
+ * - `timeout` — the whole attempt, DNS included, outlived its window.
30
+ * - `network` — the request itself failed: connect, TLS, a reset mid-body.
31
+ * - `decode-failed` — the bytes arrived and the push-image decoder refused them. A property of the
32
+ * image, not of the network.
33
+ */
34
+ export type MediaFailureReason = "url-not-allowed" | "address-not-public" | "redirect-not-allowed" | "http-status" | "too-large" | "timeout" | "network" | "decode-failed";
35
+ /** Every {@link MediaFailureReason}, so a tag that crossed an injected boundary can be checked against it. */
36
+ export declare const MEDIA_FAILURE_REASONS: readonly MediaFailureReason[];
37
+ /**
38
+ * The tag a media-acquisition failure carries for diagnostics.
39
+ *
40
+ * A property rather than a base class: the cache that logs it takes its downloader by injection and
41
+ * must not import the transport that throws — so it reads this shape off an unknown error and checks
42
+ * the term against {@link MEDIA_FAILURE_REASONS} before it goes anywhere near a log.
43
+ */
44
+ export interface MediaFailure {
45
+ readonly mediaFailure: MediaFailureReason;
46
+ /** The HTTP status, when {@link mediaFailure} is `http-status`. */
47
+ readonly status?: number;
48
+ }
@@ -3,3 +3,6 @@ export * from "./topics.js";
3
3
  export * from "./app-client-id.js";
4
4
  export * from "./broker-discovery.js";
5
5
  export * from "./biz-stream.js";
6
+ export { SolixMqtt } from "./solix-mqtt.js";
7
+ export type { SolixMqttOptions, SolixMqttDevice, SolixReading, SolixParamFrame, SolixChannel } from "./solix-mqtt.js";
8
+ export { SOLIX_MODBUS_EMS_MODES } from "./solix-mqtt.js";
@@ -93,12 +93,25 @@ export declare class SecureMqtt extends EventEmitter implements RealtimeTranspor
93
93
  * four topics for `eufy_life`).
94
94
  *
95
95
  * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
96
- * `SUBACK_FAILURE` (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
96
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
97
97
  * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
98
98
  * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
99
99
  * line that grants its state channel but refuses (say) the OTA leg still works.
100
100
  */
101
101
  subscribeDevice(device: EufyDevice): Promise<void>;
102
+ /**
103
+ * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
104
+ * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
105
+ * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
106
+ * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
107
+ */
108
+ subscribe(topics: string[]): Promise<string[]>;
109
+ /**
110
+ * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
111
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
112
+ * this split and layer their own policy (drop vs report) on top.
113
+ */
114
+ private partitionGrants;
102
115
  /**
103
116
  * Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
104
117
  * is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the