@mega-yfue/eufy-sdk 0.2.0-beta.0 → 0.2.0-beta.10

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 (39) hide show
  1. package/README.md +17 -41
  2. package/dist/client/eufy-mega.d.ts +33 -6
  3. package/dist/core/contracts.d.ts +58 -3
  4. package/dist/core/crypto.d.ts +10 -0
  5. package/dist/core/index.d.ts +1 -0
  6. package/dist/core/logger.d.ts +5 -3
  7. package/dist/core/solix-types.d.ts +36 -0
  8. package/dist/core/store.d.ts +20 -9
  9. package/dist/index.js +1229 -102
  10. package/dist/index.js.map +4 -4
  11. package/dist/model/capabilities/arming.d.ts +58 -28
  12. package/dist/model/capabilities/display.d.ts +85 -0
  13. package/dist/model/capabilities/index.d.ts +11 -5
  14. package/dist/model/capabilities/solix.d.ts +75 -0
  15. package/dist/model/capabilities/types.d.ts +16 -4
  16. package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
  17. package/dist/model/index.d.ts +3 -0
  18. package/dist/model/param-dictionary.d.ts +24 -0
  19. package/dist/model/param-namespace.d.ts +1 -1
  20. package/dist/model/solix-catalog.d.ts +20 -0
  21. package/dist/model/solix-device.d.ts +102 -0
  22. package/dist/model/types.d.ts +5 -5
  23. package/dist/transport/ff09.d.ts +7 -0
  24. package/dist/transport/http/index.d.ts +1 -0
  25. package/dist/transport/http/mega-client.d.ts +10 -2
  26. package/dist/transport/http/solix-client.d.ts +158 -0
  27. package/dist/transport/http/solix-constants.d.ts +29 -0
  28. package/dist/transport/mqtt/app-client-id.d.ts +9 -3
  29. package/dist/transport/mqtt/command-router.d.ts +0 -3
  30. package/dist/transport/mqtt/index.d.ts +2 -0
  31. package/dist/transport/mqtt/secure-mqtt.d.ts +24 -1
  32. package/dist/transport/mqtt/solix-mqtt.d.ts +214 -0
  33. package/dist/transport/mqtt/topics.d.ts +20 -0
  34. package/dist/transport/p2p/command-router.d.ts +23 -0
  35. package/dist/transport/p2p/index.d.ts +1 -0
  36. package/dist/transport/p2p/live-trace.d.ts +100 -6
  37. package/dist/transport/p2p/media.d.ts +11 -0
  38. package/dist/transport/p2p/p2p-session.d.ts +4 -0
  39. package/package.json +3 -2
@@ -0,0 +1,102 @@
1
+ import { type SolixCapability, type SolixEnergyMeterReads } from "./capabilities/solix.js";
2
+ import type { SolixDeviceRecord, SolixProductCategory } from "../core/solix-types.js";
3
+ /**
4
+ * The device record shape, re-exported from the model surface. It lives in `core/solix-types` so the
5
+ * transport client can return it without crossing the transport↔model line.
6
+ */
7
+ export type { SolixDeviceRecord } from "../core/solix-types.js";
8
+ export interface SolixIdentity {
9
+ serial: string;
10
+ productCode: string;
11
+ /** Friendly name — the catalog marketing name if resolvable, else the record's alias/name. */
12
+ name: string;
13
+ /** Anker catalog category (e.g. "Accessory", "Portable Power Station"), if resolvable. */
14
+ category?: string;
15
+ }
16
+ export interface SolixConnectivity {
17
+ online: boolean;
18
+ rssi?: number;
19
+ ssid?: string;
20
+ }
21
+ /**
22
+ * The `dev.energyMeter()` handle: the members-derived reads ({@link SolixEnergyMeterReads} —
23
+ * `meterVoltageL1` is present only once a frame carrying its tag has landed). Every not-yet-named meter
24
+ * quantity is read from {@link SolixDevice.telemetry} under its `channel_<hex tag>` key instead, which a
25
+ * static members table cannot enumerate.
26
+ */
27
+ export type SolixEnergyMeter = SolixEnergyMeterReads;
28
+ /** Options for {@link SolixDevice}. */
29
+ export interface SolixDeviceOptions {
30
+ /** Catalog categories (from `SolixClient.getProductCatalog()`) — used to resolve name + category. */
31
+ catalog?: SolixProductCategory[];
32
+ }
33
+ /**
34
+ * A discovered Solix device with resolved category + capabilities. Feed live telemetry with
35
+ * {@link applyReading} (from {@link SolixMqtt}'s `reading` events) to populate value accessors.
36
+ */
37
+ export declare class SolixDevice {
38
+ readonly serial: string;
39
+ readonly productCode: string;
40
+ readonly record: SolixDeviceRecord;
41
+ private readonly caps;
42
+ private readonly identity_;
43
+ private values;
44
+ constructor(record: SolixDeviceRecord, opts?: SolixDeviceOptions);
45
+ /** All capabilities this device carries. */
46
+ get capabilities(): SolixCapability[];
47
+ /** Whether the device carries a capability — the only correct way to branch on behaviour. */
48
+ has(capability: SolixCapability): boolean;
49
+ /**
50
+ * Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
51
+ * WHOLE reading, not just its values, and drops one addressed to a different device: the documented
52
+ * wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
53
+ * watched meter on the account — so without this filter two meters would cross-feed each other's floats.
54
+ * A reading with no `deviceSn` (a hand-built one) is accepted as-is.
55
+ */
56
+ applyReading(reading: {
57
+ deviceSn?: string;
58
+ values: Record<string, number>;
59
+ }): void;
60
+ /** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
61
+ telemetry(): Record<string, number>;
62
+ identity(): SolixIdentity;
63
+ firmware(): {
64
+ version: string;
65
+ } | undefined;
66
+ connectivity(): SolixConnectivity | undefined;
67
+ /**
68
+ * The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
69
+ * installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
70
+ * held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
71
+ * time of this call, so re-call it to pick up a tag first seen since.
72
+ */
73
+ energyMeter(): SolixEnergyMeter | undefined;
74
+ /**
75
+ * The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
76
+ * evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
77
+ * codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
78
+ * Solix telemetry is read-only, no member here dispatches a command.
79
+ */
80
+ private meterDeps;
81
+ /** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
82
+ private seenTags;
83
+ }
84
+ /**
85
+ * The minimum a client must offer to be discovered against — the two wire reads {@link discoverSolixDevices}
86
+ * composes. Typed STRUCTURALLY (not as `SolixClient`) so the model layer never imports the transport
87
+ * client: the hard `transport ⊥ model` rule forbids it, and a structural shape needs no import.
88
+ */
89
+ export interface SolixDeviceReader {
90
+ getDevices(): Promise<SolixDeviceRecord[]>;
91
+ getProductCatalog(): Promise<SolixProductCategory[]>;
92
+ }
93
+ /**
94
+ * Discover an account's Solix devices as capability-driven {@link SolixDevice} objects — the wire+model
95
+ * composition (a transport read + the product catalog) that used to be `SolixClient.discoverDevices()`.
96
+ * It lives in the model layer because it builds `SolixDevice`; the wire client (now `transport/http`)
97
+ * cannot, and passing it structurally keeps the layers decorrelated. Feed live telemetry to each result
98
+ * via `SolixDevice.applyReading`.
99
+ */
100
+ export declare function discoverSolixDevices(client: SolixDeviceReader, opts?: {
101
+ catalog?: SolixProductCategory[];
102
+ }): Promise<SolixDevice[]>;
@@ -28,10 +28,10 @@
28
28
  *
29
29
  * `display` is the T87Ax Smart Display line — its own codec because its `device_type` collides with
30
30
  * the security residual range (confirmed live, 2026-09-04: it connects over secure MQTT with no
31
- * `p2p_did`, never P2P). Its param namespace and product line are nonetheless grouped into `security`
32
- * by maintainer decision, not wire evidence — see {@link namespaceForCodec}. No capability module
33
- * targets it yet: no screen/audio/assistant param has been observed, only its own small cloud-param
34
- * namespace (ids 8001-8006).
31
+ * `p2p_did`, never P2P). It owns its own param namespace (ids 8001-8006) and its own product line, so
32
+ * no other line's capability can attach to it — see `namespaceForCodec` and `CODEC_LINE`. The `display`
33
+ * capability targets it and reads the charge; no screen/audio/assistant param has been observed, so
34
+ * those are absent rather than deferred.
35
35
  */
36
36
  export type Codec = "station" | "camera" | "sensor" | "lock" | "keypad" | "vacuum" | "mower" | "light" | "printer" | "display";
37
37
  /**
@@ -39,7 +39,7 @@ export type Codec = "station" | "camera" | "sensor" | "lock" | "keypad" | "vacuu
39
39
  * it maps to a {@link CapabilityModule} that owns its property schema. Extend this union as
40
40
  * new capabilities are modelled — adding one never requires a subclass.
41
41
  */
42
- export type Capability = "video" | "snapshot" | "motion" | "person_detection" | "battery" | "light" | "ptz" | "doorbell" | "contact" | "leak" | "smoke" | "co" | "siren" | "lock" | "keypad" | "arming" | "storage" | "rtsp" | "camera" | "audio" | "vacuum_clean" | "vacuum_dock" | "suction" | "locate" | "smart_light" | "info";
42
+ export type Capability = "video" | "snapshot" | "motion" | "person_detection" | "battery" | "light" | "ptz" | "doorbell" | "contact" | "leak" | "smoke" | "co" | "siren" | "lock" | "keypad" | "arming" | "storage" | "rtsp" | "camera" | "audio" | "vacuum_clean" | "vacuum_dock" | "suction" | "locate" | "smart_light" | "display" | "info";
43
43
  /** Value type of a property. */
44
44
  export type PropertyValueType = "bool" | "number" | "string" | "enum";
45
45
  /**
@@ -397,6 +397,13 @@ export interface Ff09SettingsResponse {
397
397
  * padding past the last real field.
398
398
  */
399
399
  export declare function parseFf09SettingsResponse(plain: Buffer): Ff09SettingsResponse;
400
+ /**
401
+ * Walk a bounded `tag|len|value` TLV region into a `tag → bytes` map. Stops at a `0x00` tag (only ever
402
+ * trailing zero-padding, never a real field — real tags start at `0xa1`) and refuses a field whose
403
+ * declared length would overrun `end`, so a corrupt length can't read past the region (e.g. into a
404
+ * trailing checksum). Shared by {@link parseFf09SettingsResponse} and the Solix param decoder.
405
+ */
406
+ export declare function walkFf09Tlv(buf: Buffer, start: number, end: number): Map<number, Buffer>;
400
407
  /** Read a little-endian u16 out of a TLV field buffer (throws on a missing/short field — a caller-side bug, not a wire ambiguity). */
401
408
  export declare function readFf09U16LE(field: Buffer | undefined, name: string): number;
402
409
  /** Read a single byte out of a TLV field buffer (throws on a missing field — same convention as {@link readFf09U16LE}). */
@@ -3,3 +3,4 @@ export { randomPhoneModel, randomUserAgent } from "./phone-model.js";
3
3
  export * from "./decodeImageV1.js";
4
4
  export * from "./decodeImageV2.js";
5
5
  export { listLightEffects, listAiSceneRecommendations, type LightEffectSummary } from "./light-catalog.js";
6
+ export { SolixClient, type SolixClientOptions, type SolixLoginResult, type SolixSession, type SolixPersisted, type SolixSessionStore, } from "./solix-client.js";
@@ -186,8 +186,16 @@ export declare class MegaHttpClient {
186
186
  * restored-session short-circuit must NOT treat it as a usable session. Cleared on Ok/reset. */
187
187
  private pending2fa;
188
188
  private tokenExpiresAt;
189
- /** Stable per-install device id — the auth token is bound to it. */
190
- private openudid;
189
+ /**
190
+ * Stable per-install device id: `openudid` as configured, as restored from the session store, or as
191
+ * derived from the ACCOUNT when neither supplied one — two clients that configure none therefore
192
+ * share it, and are one install as far as everything keyed on this is concerned.
193
+ *
194
+ * Two things are keyed on it, and both fail the same way when it is shared: the auth token is bound
195
+ * to it, so each login displaces the other's session, and the secure-MQTT client id is built from it,
196
+ * so each connection evicts the other's channel.
197
+ */
198
+ readonly openudid: string;
191
199
  /** The device model reported to the cloud (explicit `phoneModel`, else a stable random one). */
192
200
  private readonly phoneModel;
193
201
  /** The `user-agent` for the media-download path (explicit `mediaUserAgent`, else derived from the model). */
@@ -0,0 +1,158 @@
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 SolixProductCategory } 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
+ private classifyLogin;
126
+ /** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
127
+ private decryptLogin;
128
+ /**
129
+ * Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
130
+ * when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
131
+ * (adopted from a store) is answered without a handshake.
132
+ */
133
+ login(): Promise<SolixLoginResult>;
134
+ /** Complete a `2fa` login with the code the passport sent. */
135
+ submitVerifyCode(code: string): Promise<SolixLoginResult>;
136
+ /**
137
+ * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
138
+ * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
139
+ */
140
+ private authed;
141
+ /**
142
+ * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
143
+ * gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
144
+ * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
145
+ */
146
+ getDevices(): Promise<SolixDeviceRecord[]>;
147
+ /** The account's sites (systems); devices are typically grouped under a site. */
148
+ getSites(): Promise<unknown[]>;
149
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
150
+ getUserMqttInfo(): Promise<SecureMqttCredentials>;
151
+ /**
152
+ * The pairable-product catalog (categories → products). This is Anker's product registry, not the
153
+ * account's devices — fetch it to label a discovered device's model code with a marketing name and
154
+ * category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
155
+ * baked-in table.
156
+ */
157
+ getProductCatalog(): Promise<SolixProductCategory[]>;
158
+ }
@@ -0,0 +1,29 @@
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
+ };
@@ -11,6 +11,12 @@ export interface AppClientIdInput {
11
11
  }
12
12
  /** Build a client_id shaped like `android-{appName}-{uid}-{mqttUuid}-{timestamp}`. */
13
13
  export declare function buildAppShapedClientId(input: AppClientIdInput): string;
14
- /** A fresh stable-looking install UUID (16 hex chars) — generate ONCE per identity and persist it
15
- * (a new random value on every connect defeats the point of "stable"). */
16
- export declare function generateMqttUuid(): string;
14
+ /**
15
+ * The `mqttUuid` segment for a client bound to `installId`, hashed to the 16-hex shape
16
+ * {@link buildAppShapedClientId} expects. Deterministic, so a client keeps its id across restarts and
17
+ * takes its own stale session over rather than doubling up beside it; one-way, so the id it is derived
18
+ * from is not recoverable from a client_id that travels the wire in clear.
19
+ *
20
+ * Two clients are distinguished exactly as far as their `installId` is: equal ids in, equal ids out.
21
+ */
22
+ export declare function mqttUuidFrom(installId: string): string;
@@ -87,9 +87,6 @@ export interface MqttRouterDeps {
87
87
  export declare class MqttCommandRouter {
88
88
  private readonly deps;
89
89
  private readonly logger;
90
- /** Stable per-process install id for the app-shaped MQTT client_id (see {@link buildAppShapedClientId}) —
91
- * generated once, reused for every security-MQTT connect this router makes. */
92
- private mqttUuid?;
93
90
  constructor(deps: MqttRouterDeps);
94
91
  /**
95
92
  * Whether this transport stack drives `dev`'s `ff09-*` commands — a **eufy-cloud device**
@@ -3,3 +3,5 @@ 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";
@@ -14,6 +14,16 @@
14
14
  import { EventEmitter } from "node:events";
15
15
  import type { EufyDevice, RealtimeTransport } from "../../core/types.js";
16
16
  import { type Logger } from "../../core/logger.js";
17
+ /**
18
+ * Whether a connect failed because the broker REFUSED the client — a CONNACK return code the client
19
+ * cannot retry its way out of, as `mqtt.js` words it (`Connection refused: not authorized`). A socket
20
+ * that dies without an answer is not this: it is the same request, unanswered, and retrying it is the
21
+ * only way to learn which of the two happened.
22
+ *
23
+ * A refusal that arrives as a dropped connection instead of a CONNACK reads here as the transport
24
+ * failure it is indistinguishable from.
25
+ */
26
+ export declare function isNotAuthorized(err: unknown): boolean;
17
27
  /**
18
28
  * Per-user mTLS credentials as returned by get_user_mqtt_info.
19
29
  *
@@ -83,12 +93,25 @@ export declare class SecureMqtt extends EventEmitter implements RealtimeTranspor
83
93
  * four topics for `eufy_life`).
84
94
  *
85
95
  * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
86
- * `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
87
97
  * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
88
98
  * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
89
99
  * line that grants its state channel but refuses (say) the OTA leg still works.
90
100
  */
91
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;
92
115
  /**
93
116
  * Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
94
117
  * is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the