@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.
- package/README.md +17 -41
- package/dist/client/eufy-mega.d.ts +33 -6
- package/dist/core/contracts.d.ts +58 -3
- package/dist/core/crypto.d.ts +10 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/logger.d.ts +5 -3
- package/dist/core/solix-types.d.ts +36 -0
- package/dist/core/store.d.ts +20 -9
- package/dist/index.js +1229 -102
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/arming.d.ts +58 -28
- package/dist/model/capabilities/display.d.ts +85 -0
- package/dist/model/capabilities/index.d.ts +11 -5
- package/dist/model/capabilities/solix.d.ts +75 -0
- package/dist/model/capabilities/types.d.ts +16 -4
- package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
- package/dist/model/index.d.ts +3 -0
- package/dist/model/param-dictionary.d.ts +24 -0
- package/dist/model/param-namespace.d.ts +1 -1
- package/dist/model/solix-catalog.d.ts +20 -0
- package/dist/model/solix-device.d.ts +102 -0
- package/dist/model/types.d.ts +5 -5
- package/dist/transport/ff09.d.ts +7 -0
- package/dist/transport/http/index.d.ts +1 -0
- package/dist/transport/http/mega-client.d.ts +10 -2
- package/dist/transport/http/solix-client.d.ts +158 -0
- package/dist/transport/http/solix-constants.d.ts +29 -0
- package/dist/transport/mqtt/app-client-id.d.ts +9 -3
- package/dist/transport/mqtt/command-router.d.ts +0 -3
- package/dist/transport/mqtt/index.d.ts +2 -0
- package/dist/transport/mqtt/secure-mqtt.d.ts +24 -1
- package/dist/transport/mqtt/solix-mqtt.d.ts +214 -0
- package/dist/transport/mqtt/topics.d.ts +20 -0
- package/dist/transport/p2p/command-router.d.ts +23 -0
- package/dist/transport/p2p/index.d.ts +1 -0
- package/dist/transport/p2p/live-trace.d.ts +100 -6
- package/dist/transport/p2p/media.d.ts +11 -0
- package/dist/transport/p2p/p2p-session.d.ts +4 -0
- 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[]>;
|
package/dist/model/types.d.ts
CHANGED
|
@@ -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).
|
|
32
|
-
*
|
|
33
|
-
* targets it
|
|
34
|
-
*
|
|
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
|
/**
|
package/dist/transport/ff09.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
190
|
-
|
|
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
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
|
|
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
|
-
*
|
|
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
|