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

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.
@@ -143,7 +143,18 @@ export declare const CATEGORY_CAPABILITIES: Readonly<Record<string, readonly Sol
143
143
  export declare const SOLIX_METER_MODELS: readonly string[];
144
144
  /**
145
145
  * Product-code prefixes for the grid-tie Solarbank / home-battery family (detects `battery` +
146
- * `solarInput` regardless of category): A1790 = Solarbank E1600 gen-1, A17C* = Solarbank 2 / 3.
146
+ * `solarInput` regardless of category, so a caller that builds a device without the catalog still gets
147
+ * them): `A1790` = Solarbank E1600 gen-1, `A17C*` = Solarbank 2 / 3, `AE10*` = Solarbank 4 E5000 Pro /
148
+ * SOLIX Power Dock.
149
+ *
150
+ * Grounded in the live `product_categories` catalog: `A17C0`–`A17C5` and `AE100` all list under
151
+ * category `"Plug-in Home Battery "`, and `AE103` by the device spec. `AE1X0`/`AE1R0` meters start
152
+ * `AE1X`/`AE1R`, so `AE10` does not catch them.
153
+ *
154
+ * The speculative `A17E` ("Solarbank Max AC") and `AE11` ("Solarbank Max") were dropped: neither is in
155
+ * the catalog, and the only `AE11x` product there — `AE113` "XE 6/8kW" — is a Residential Storage
156
+ * System, a different family whose telemetry is unverified, so granting it `battery`/`solarInput` would
157
+ * be an unevidenced false positive (exactly what this detection is otherwise careful to avoid).
147
158
  */
148
159
  export declare const SOLARBANK_MODELS: readonly string[];
149
160
  /** The minimum device shape {@link detectSolixCapabilities} reads. */
@@ -247,7 +247,11 @@ export interface CommandContext extends AvailabilityContext {
247
247
  adminUserId?: string;
248
248
  /** The acting member's short id (`member.short_user_id`, hex, e.g. `"0003"`) — the lock cmd `A5` field. */
249
249
  shortUserId?: string;
250
- /** The logged-in account's display name (email local-part) — the lock cmd acting-username `A4` field. */
250
+ /**
251
+ * The acting name a command attributes itself to — the lock cmd acting-username `A4` field, and the
252
+ * `user_name` of the guard-mode and HomeBase-alarm writes. The logged-in account's display name
253
+ * (email local-part) unless the client pins a different label for it.
254
+ */
251
255
  accountName?: string;
252
256
  /**
253
257
  * Whether the device has a usable P2P endpoint (a non-empty `p2p_did`). A HomeBase-attached lock
@@ -30,6 +30,8 @@ export * from "./capabilities/index.js";
30
30
  */
31
31
  export type { FamilyContext } from "./device-family.js";
32
32
  export { CusPushEvent, CusPushAlarmType, CusPushMode, DoorbellPushEvent, IndoorPushEvent, HB3PairedDevicePushEvent, LockPushEvent, SmartDropPushEvent, NotificationStyle, detectionName, } from "./push-events.js";
33
- export { SolixDevice, discoverSolixDevices, type SolixDeviceReader, type SolixDeviceRecord, type SolixIdentity, type SolixConnectivity, type SolixEnergyMeter, type SolixDeviceOptions, } from "./solix-device.js";
33
+ export { SolixDevice, discoverSolixDevices, solarbankSceneReadings, type SolixDeviceReader, type SolixDeviceRecord, type SolixIdentity, type SolixConnectivity, type SolixEnergyMeter, type SolixDeviceOptions, } from "./solix-device.js";
34
34
  export { SOLIX_ENERGY_METER_MEMBERS, type SolixCapability, type SolixEnergyMeterReads } from "./capabilities/solix.js";
35
35
  export { buildModelIndex, type SolixProduct, type SolixProductCategory } from "./solix-catalog.js";
36
+ export { solixProductFamily, isSolixPowerStation, isSolixSolarbank, isSolixSmartMeter, type SolixProductFamily, type SolixFamilyInput, } from "./solix-family.js";
37
+ export { SolixSite, discoverSolixSites, type SolixSiteReader, type SolixSiteOptions } from "./solix-site.js";
@@ -13,6 +13,11 @@ import type { SolixProductCategory } from "../core/solix-types.js";
13
13
  * Flatten a product catalog into a `product_code → { name, category }` lookup for labelling
14
14
  * discovered devices. Every variant code in `p_codes` maps to its parent product too, so a device
15
15
  * reporting a sub-model resolves to the same marketing name.
16
+ *
17
+ * The category name is trimmed here, at ingest — the live catalog returns some names with trailing
18
+ * whitespace (e.g. `"Plug-in Home Battery "`), and normalising once at the source means every consumer
19
+ * (a device's `identity().category`, `detectSolixCapabilities`, and any exact-match category test) sees
20
+ * the clean name, rather than each call site having to remember to trim.
16
21
  */
17
22
  export declare function buildModelIndex(categories: SolixProductCategory[]): Map<string, {
18
23
  name: string;
@@ -1,5 +1,6 @@
1
1
  import { type SolixCapability, type SolixEnergyMeterReads } from "./capabilities/solix.js";
2
- import type { SolixDeviceRecord, SolixProductCategory } from "../core/solix-types.js";
2
+ import { type SolixProductFamily } from "./solix-family.js";
3
+ import type { SolixDeviceRecord, SolixProductCategory, SolixSiteScene } from "../core/solix-types.js";
3
4
  /**
4
5
  * The device record shape, re-exported from the model surface. It lives in `core/solix-types` so the
5
6
  * transport client can return it without crossing the transport↔model line.
@@ -12,6 +13,11 @@ export interface SolixIdentity {
12
13
  name: string;
13
14
  /** Anker catalog category (e.g. "Accessory", "Portable Power Station"), if resolvable. */
14
15
  category?: string;
16
+ /**
17
+ * Normalized product family — the "what kind of thing is this" answer (power station, Solarbank,
18
+ * smart meter, …), decorrelated from the marketing `category` string. See {@link SolixDevice.family}.
19
+ */
20
+ family: SolixProductFamily;
15
21
  }
16
22
  export interface SolixConnectivity {
17
23
  online: boolean;
@@ -42,6 +48,12 @@ export declare class SolixDevice {
42
48
  private readonly identity_;
43
49
  private values;
44
50
  constructor(record: SolixDeviceRecord, opts?: SolixDeviceOptions);
51
+ /**
52
+ * The device's {@link SolixProductFamily} — the classification a caller branches on to SORT devices
53
+ * (a site's power stations vs its meters), the Solix analogue of eufy's `isHomeBase()`. Distinct from
54
+ * {@link has}, which answers what the device can DO; family answers what KIND of device it is.
55
+ */
56
+ get family(): SolixProductFamily;
45
57
  /** All capabilities this device carries. */
46
58
  get capabilities(): SolixCapability[];
47
59
  /** Whether the device carries a capability — the only correct way to branch on behaviour. */
@@ -90,6 +102,15 @@ export interface SolixDeviceReader {
90
102
  getDevices(): Promise<SolixDeviceRecord[]>;
91
103
  getProductCatalog(): Promise<SolixProductCategory[]>;
92
104
  }
105
+ /**
106
+ * Resolve the product catalog for a discovery: a caller-supplied `opts.catalog` short-circuits the wire
107
+ * read, and a failed `getProductCatalog()` degrades to no categories (names/families just go unresolved,
108
+ * never a thrown discovery). Shared by {@link discoverSolixDevices} and `discoverSolixSites` so that
109
+ * "a failed catalog is non-fatal" decision lives in exactly one place.
110
+ */
111
+ export declare function resolveSolixCatalog(client: SolixDeviceReader, opts: {
112
+ catalog?: SolixProductCategory[];
113
+ }): Promise<SolixProductCategory[]>;
93
114
  /**
94
115
  * Discover an account's Solix devices as capability-driven {@link SolixDevice} objects — the wire+model
95
116
  * composition (a transport read + the product catalog) that used to be `SolixClient.discoverDevices()`.
@@ -100,3 +121,16 @@ export interface SolixDeviceReader {
100
121
  export declare function discoverSolixDevices(client: SolixDeviceReader, opts?: {
101
122
  catalog?: SolixProductCategory[];
102
123
  }): Promise<SolixDevice[]>;
124
+ /**
125
+ * Reduce a site "scene" snapshot to per-device telemetry readings — the BACKSTOP counterpart to the
126
+ * realtime `ff09` decode. It emits only the fields the scene reliably carries that the fast MQTT frame
127
+ * does NOT: `batteryTemperature` (the realtime frame's BMS blob is empty, so `solixReadings` withholds
128
+ * it) and `batterySoc` (a cross-check/seed for the `0xa3` SOC). Each reading is shaped exactly like a
129
+ * `SolixMqtt` `reading` event — `{ deviceSn, values }` — so a caller can feed it straight into
130
+ * {@link SolixDevice.applyReading} and broadcast it on the same path as a live frame. Entries with no
131
+ * usable value are dropped, so a poll during a gap emits nothing rather than clobbering live values.
132
+ */
133
+ export declare function solarbankSceneReadings(scene: SolixSiteScene): {
134
+ deviceSn: string;
135
+ values: Record<string, number>;
136
+ }[];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A Solix device's product family — the normalized "what kind of thing is this" answer, decorrelated
3
+ * from the Anker catalog's marketing category strings. `unknown` when neither the product code nor the
4
+ * category identifies a family (never a guess).
5
+ */
6
+ export type SolixProductFamily = "powerStation" | "solarbank" | "smartMeter" | "powerBank" | "cooler" | "evCharger" | "charger" | "unknown";
7
+ /** The pure evidence a family decision reads: the product code, and the catalog category when resolved. */
8
+ export interface SolixFamilyInput {
9
+ /** SKU / model code, e.g. `A1782`, `AE103`, `AE1X0`. */
10
+ product_code: string;
11
+ /** Anker catalog category, when a catalog resolved one (e.g. `"Portable Power Station"`). */
12
+ category?: string;
13
+ }
14
+ /** Grid-tie Solarbank / plug-in home battery family (by product code, catalog-independent). */
15
+ export declare const isSolixSolarbank: (input: SolixFamilyInput) => boolean;
16
+ /** Smart energy meter / grid-CT family (by product code, catalog-independent). */
17
+ export declare const isSolixSmartMeter: (input: SolixFamilyInput) => boolean;
18
+ /** Portable Power Station (SOLIX F-series) family — resolved from the catalog category. */
19
+ export declare const isSolixPowerStation: (input: SolixFamilyInput) => boolean;
20
+ /**
21
+ * Resolve a Solix device's {@link SolixProductFamily} by consulting the family predicates first (they are
22
+ * catalog-independent, so a device classifies even before a catalog is fetched), then the catalog
23
+ * category for the families that have no prefix set yet. Returns `"unknown"` when neither identifies one —
24
+ * never a guess, mirroring `device-family.ts` returning `false` on an unknown device type.
25
+ *
26
+ * The meter is checked before the Solarbank: both can present a battery-ish catalog category, but the
27
+ * meter's `AE1X0` prefix is unambiguous and a meter is never a Solarbank. The Solarbank case reuses
28
+ * {@link isSolixSolarbank} (prefix OR the home-battery category) rather than re-testing the prefix here,
29
+ * so the two never drift.
30
+ */
31
+ export declare function solixProductFamily(input: SolixFamilyInput): SolixProductFamily;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A capability-driven model for an Anker Solix **site** — the account's home energy system, the "My
3
+ * Home" the app shows. A site is the Solix analogue of a eufy HomeBase in its GROUPING role: it is the
4
+ * system that member devices belong to, so it is what a caller reaches for to ask "what's in this
5
+ * system" and to sort the members by family ({@link SolixSite.powerStations} / {@link solarbanks} /
6
+ * {@link smartMeters}) or by capability ({@link SolixSite.withCapability}).
7
+ *
8
+ * A site is a *grouping*, not a device: it carries no telemetry of its own. Its members are full
9
+ * {@link SolixDevice} models (each with its own capabilities + live telemetry), resolved by
10
+ * {@link discoverSolixSites} from the site record's member list joined to the account's device records.
11
+ * The realtime SYSTEM aggregate the app draws (instantaneous battery SoC + solar + grid flow) is not
12
+ * modelled here: it has no confirmed, stable read surface for the current device generation, and this
13
+ * layer does not fabricate a getter for a value it cannot ground — a caller reads each member device's
14
+ * telemetry instead.
15
+ */
16
+ import { SolixDevice, type SolixDeviceReader } from "./solix-device.js";
17
+ import type { SolixProductFamily } from "./solix-family.js";
18
+ import type { SolixCapability } from "./capabilities/solix.js";
19
+ import type { SolixProductCategory, SolixSiteRecord } from "../core/solix-types.js";
20
+ /**
21
+ * The minimum a client must offer to discover an account's SITES against — {@link SolixDeviceReader}
22
+ * (the device + catalog reads {@link discoverSolixDevices} already composes) plus the site read.
23
+ * Structural (not `SolixClient`) so the model layer never imports the transport client: the hard
24
+ * `transport ⊥ model` rule forbids it, and a structural shape needs no import.
25
+ */
26
+ export interface SolixSiteReader extends SolixDeviceReader {
27
+ getSites(): Promise<SolixSiteRecord[]>;
28
+ }
29
+ /** Options for {@link SolixSite} / {@link discoverSolixSites} — the same catalog the device model takes. */
30
+ export interface SolixSiteOptions {
31
+ catalog?: SolixProductCategory[];
32
+ }
33
+ /**
34
+ * A discovered Solix site with its member devices resolved to {@link SolixDevice} models. Build one
35
+ * directly from a record + members, or discover an account's sites with {@link discoverSolixSites}.
36
+ */
37
+ export declare class SolixSite {
38
+ readonly id: string;
39
+ /** Friendly site name (e.g. "My Home"), or the site id when the record carries none. */
40
+ readonly name: string;
41
+ /** Anker's site-type discriminator (e.g. 20 for a Solarbank-anchored home system), when present. */
42
+ readonly powerSiteType?: number;
43
+ readonly record: SolixSiteRecord;
44
+ readonly devices: SolixDevice[];
45
+ constructor(record: SolixSiteRecord, devices: SolixDevice[]);
46
+ /** The member device with this serial, if it belongs to the site. */
47
+ device(serial: string): SolixDevice | undefined;
48
+ /** Member devices of a given product {@link SolixProductFamily} — the grouping accessor. */
49
+ withFamily(family: SolixProductFamily): SolixDevice[];
50
+ /** Member devices that carry a given capability (e.g. every `battery` in the system). */
51
+ withCapability(capability: SolixCapability): SolixDevice[];
52
+ /** The Solarbank / plug-in home-battery members of the system. */
53
+ solarbanks(): SolixDevice[];
54
+ /** The smart-meter (grid-CT) members of the system. */
55
+ smartMeters(): SolixDevice[];
56
+ /** The portable power-station members of the system. */
57
+ powerStations(): SolixDevice[];
58
+ }
59
+ /**
60
+ * Discover an account's Solix sites as {@link SolixSite} groupings of capability-driven
61
+ * {@link SolixDevice} members — the site analogue of {@link discoverSolixDevices}. Composes three wire
62
+ * reads (the sites, the account's device records, the product catalog) entirely model-side, so the
63
+ * transport client is passed structurally and the layers stay decorrelated.
64
+ *
65
+ * A site member is resolved to its FULL device record from `getDevices()` when the account lists one
66
+ * (so the member carries firmware/connectivity + telemetry identity); a member the flat device list
67
+ * omits is built from the site entry alone (serial + product code + name), so the system is never
68
+ * missing a device it declares.
69
+ */
70
+ export declare function discoverSolixSites(client: SolixSiteReader, opts?: SolixSiteOptions): Promise<SolixSite[]>;
@@ -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. */
@@ -238,10 +246,15 @@ export declare class MegaHttpClient {
238
246
  /** The active region shard (e.g. `"eu-pr"`, `"us-pr"`), set after {@link login} or a region override. */
239
247
  get regionShard(): RegionShard;
240
248
  /**
241
- * The logged-in account's display name — the login email's local-part (e.g. `someone+tag` for
242
- * `someone+tag@example.com`). This is the string the app writes into the ff09 command's acting
243
- * "username" field (verified against a captured T8531 unlock frame). Falls back to the whole email
244
- * if it has no `@`.
249
+ * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
250
+ * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
251
+ * is the login email's local-part (e.g. `someone+tag` for `someone+tag@example.com`) and falls back
252
+ * to the whole email if it has no `@`.
253
+ *
254
+ * The local-part is the string the app writes into the ff09 command's acting "username" field
255
+ * (verified against a captured T8531 unlock frame), so it is the faithful default. An override is a
256
+ * different LABEL for the same account, not a different identity: the session authenticates on the
257
+ * token and the device record's member ids, neither of which this touches.
245
258
  */
246
259
  get accountName(): string;
247
260
  /**
@@ -14,7 +14,7 @@
14
14
  * those records into capability-driven `SolixDevice` models is the model layer's job — see
15
15
  * `discoverSolixDevices()` — so the two stay decorrelated (transport never imports model).
16
16
  */
17
- import { type SessionStore, type SolixDeviceRecord, type SolixProductCategory } from "../../core/index.js";
17
+ import { type SessionStore, type SolixDeviceRecord, type SolixPowerCutoffOption, type SolixProductCategory, type SolixSiteRecord, type SolixSiteScene, type SolixSocParams } from "../../core/index.js";
18
18
  import type { SecureMqttCredentials } from "../mqtt/secure-mqtt.js";
19
19
  /** An authenticated Solix session — the token + the derived `gtoken` + the resolved API host. */
20
20
  export interface SolixSession {
@@ -136,11 +136,25 @@ export declare class SolixClient {
136
136
  * (adopted from a store) is answered without a handshake.
137
137
  */
138
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;
139
148
  /** Complete a `2fa` login with the code the passport sent. */
140
149
  submitVerifyCode(code: string): Promise<SolixLoginResult>;
141
150
  /**
142
151
  * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
143
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.
144
158
  */
145
159
  private authed;
146
160
  /**
@@ -149,10 +163,27 @@ export declare class SolixClient {
149
163
  * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
150
164
  */
151
165
  getDevices(): Promise<SolixDeviceRecord[]>;
152
- /** The account's sites (systems); devices are typically grouped under a site. */
153
- getSites(): Promise<unknown[]>;
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[]>;
154
175
  /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
155
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>;
156
187
  /**
157
188
  * The pairable-product catalog (categories → products). This is Anker's product registry, not the
158
189
  * account's devices — fetch it to label a discovered device's model code with a marketing name and
@@ -160,4 +191,79 @@ export declare class SolixClient {
160
191
  * baked-in table.
161
192
  */
162
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 (and the only source for `chargeUpperLimit` / `backupReserve`). Verified live
257
+ * against a known AE103 setting (discharge 20 / charge 80).
258
+ */
259
+ getSafetySocParams(siteId: string): Promise<SolixSocParams | undefined>;
260
+ /**
261
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
262
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
263
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
264
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
265
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
266
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
267
+ */
268
+ setSafetySocParams(siteId: string, changes: Partial<SolixSocParams>): Promise<SolixSocParams>;
163
269
  }
@@ -26,4 +26,31 @@ export declare const SOLIX_ENDPOINTS: {
26
26
  readonly getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info";
27
27
  /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
28
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";
29
56
  };
@@ -71,6 +71,51 @@ export declare const SOLIX_METER_FIELD_NAMES: Readonly<Record<number, string>>;
71
71
  * transport). Add a meter prefix to both.
72
72
  */
73
73
  export declare const SOLIX_METER_PRODUCT_PREFIXES: readonly string[];
74
+ /**
75
+ * Product-code prefix of the gen-4 Solarbank (the `ats_ax170` family, e.g. `AE103` Solarbank 4 E5000
76
+ * Pro) whose ff09 tag layout {@link SOLIX_SOLARBANK_FIELD_NAMES} + the SOC/temperature extraction
77
+ * describe. Like the meter table this is family-specific — the SAME tag carries a different quantity on
78
+ * the meter (`0xac` is line voltage there, battery power here), so the Solarbank names are applied ONLY
79
+ * to a frame from this family. A product-code prefix used to pick a decode table, not a model import.
80
+ * `AE10` covers the AE10x gen-4 Solarbanks and does NOT match the meter (`AE1X0`, whose 4th char is `X`).
81
+ */
82
+ export declare const SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
83
+ /**
84
+ * Confirmed ff09 tag → field bindings for the gen-4 Solarbank (`ats_ax170`), correlated live against the
85
+ * app UI. Power values in watts; signed fields note their sign convention:
86
+ * - `0xac` battery power, SIGNED (+ charging / − discharging) — the measured net pack power.
87
+ * - `0xbc` charge power (0 unless charging); `0xad` discharge power (0 unless discharging).
88
+ * - `0xae` AC plug power, SIGNED (+ feeding the home / − drawing in to charge).
89
+ * - `0xaf` socket power — the unit's own on-board AC outlet (an appliance plugged into the Solarbank).
90
+ * - `0xc4` grid input power; `0xc5` home load power.
91
+ * SOC and temperature are NOT float channels — see {@link solixReadings}, which reads SOC from tag `0xa3`
92
+ * (a uint8) and temperature from the `0xa4` BMS status blob. The 4 PV-string channels (`0xc6`–`0xc9`),
93
+ * the AC currents (`0xb2`/`0xb3`) and export energy (`0xb4`) are not yet confirmed, so they stay raw
94
+ * `channel_<hex>` until a capture pins them.
95
+ */
96
+ export declare const SOLIX_SOLARBANK_FIELD_NAMES: Readonly<Record<number, string>>;
97
+ /**
98
+ * Confirmed `state_info` tag → field bindings for the gen-4 Solarbank. `state_info` is a SEPARATE push
99
+ * topic from `param_info` and, though it shares the ff09 framing, its tags carry SETTINGS/targets, NOT
100
+ * live measurements — so the SAME tag byte means something different here than in
101
+ * {@link SOLIX_SOLARBANK_FIELD_NAMES} (e.g. `0xab` is live PV power in param_info, the mode's AC-socket
102
+ * export limit here). Mapped by live observation against the app's SOC-setting screen; everything else
103
+ * stays raw `state_<hex>` until confirmed the same way.
104
+ */
105
+ export declare const SOLIX_STATE_FIELD_NAMES: Readonly<Record<number, string>>;
106
+ /**
107
+ * Decode a `state_info` ff09 frame to named + raw settings values. Skips the header tags (`< 0xa5`:
108
+ * request marker, serial, timestamps). Each settings tag is emitted under `state_<hex>` (a plain number
109
+ * so it's watchable in a consumer while more tags get mapped) AND, when confirmed, under its name from
110
+ * {@link SOLIX_STATE_FIELD_NAMES}. Value is read type-aware: `0x05` float32, `0x02` u16, `0x01` u8, and
111
+ * `0x03` the whole-number settings byte (`payload[1]`).
112
+ *
113
+ * The header cutoff is `0xa5` here, deliberately one lower than {@link solixReadings}' `0xa6` for
114
+ * `param_info`: the two frames are different layouts under the same ff09 framing — `state_info` carries
115
+ * a settings value at `0xa5` (SOC), where `param_info` has a header tag. The cutoffs are not meant to
116
+ * match; the spec pins `0xa5`'s treatment in each so they can't silently drift together.
117
+ */
118
+ export declare function solixStateReadings(frame: SolixParamFrame): Record<string, number>;
74
119
  /** Interpret one TLV value as a telemetry channel (leading type byte + payload). */
75
120
  export declare function readSolixChannel(value: Buffer | undefined): SolixChannel | undefined;
76
121
  /**
@@ -86,10 +131,11 @@ export declare function decodeSolixParamFrame(buf: Buffer): SolixParamFrame | nu
86
131
  * carry the field count, the serial and the status, not measurements. A measurement channel is one whose
87
132
  * leading type byte is `0x05` (float32 LE over a 4-byte payload); any other type is a non-measurement
88
133
  * param and contributes nothing. Each measurement is emitted under `channel_<hex tag>`, and additionally
89
- * under its name when the tag has a confirmed one in {@link SOLIX_METER_FIELD_NAMES} AND `productCode` is
90
- * from the meter family (see {@link SOLIX_METER_PRODUCT_PREFIXES}) — so a non-meter device's tags stay
91
- * raw `channel_<hex>` rather than borrowing the meter's tag→name table. `productCode` is required (it
134
+ * under its name when the tag has a confirmed one AND `productCode` is from a known family — pass the
135
+ * telemetry topic's product code so a device outside the meter/Solarbank families keeps raw
136
+ * `channel_<hex>` rather than borrowing another family's tag→name table. `productCode` is required (it
92
137
  * comes straight from the telemetry topic); pass `""` for a frame of unknown origin and no names apply.
138
+ * Meter family: {@link SOLIX_METER_PRODUCT_PREFIXES}; Solarbank: {@link SOLIX_SOLARBANK_PRODUCT_PREFIX}.
93
139
  */
94
140
  export declare function solixReadings(frame: SolixParamFrame, productCode: string): Record<string, number>;
95
141
  /** A live telemetry sample emitted by {@link SolixMqtt} as a `reading` event. */
@@ -176,9 +222,14 @@ export declare class SolixMqtt extends EventEmitter {
176
222
  * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
177
223
  * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
178
224
  *
179
- * Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
180
- * channels — never the `…/req` channels, which are the app→device request side this arms on, and would
181
- * echo its own publishes back.
225
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
226
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
227
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
228
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
229
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
230
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
231
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
232
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
182
233
  *
183
234
  * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
184
235
  * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
@@ -189,6 +240,14 @@ export declare class SolixMqtt extends EventEmitter {
189
240
  watch(device: SolixMqttDevice): Promise<void>;
190
241
  /** Tear down the connection and stop the re-arm timer. */
191
242
  close(): Promise<void>;
243
+ /**
244
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
245
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
246
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
247
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
248
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
249
+ */
250
+ setDisplayTimeout(device: SolixMqttDevice, index: number): Promise<void>;
192
251
  /**
193
252
  * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
194
253
  * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
@@ -216,8 +275,25 @@ export declare class SolixMqtt extends EventEmitter {
216
275
  * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
217
276
  * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
218
277
  * own `a2` field wins for the serial when it carries one.
278
+ *
279
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
280
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
281
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
282
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
283
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
219
284
  */
220
285
  private onMessage;
286
+ /**
287
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
288
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
289
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
290
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
291
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
292
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
293
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
294
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
295
+ */
296
+ private handleCommand;
221
297
  }
222
298
  /**
223
299
  * Pull the ff09 binary frame out of a received message. Solix telemetry arrives as a `{head, payload}`
@@ -235,4 +311,11 @@ export declare function extractFf09Payload(raw: unknown): Buffer | null;
235
311
  * `fe` carries a fresh unix-timestamp nonce; the trailing byte is XOR of every preceding byte (the same
236
312
  * checksum the meter's telemetry frames use — verified to reproduce the captured frames exactly).
237
313
  */
314
+ /**
315
+ * Build the display screen-off-timeout command frame (ff09 msgtype `0x68`, tag `a5 = [01, index]`),
316
+ * captured live from the app on `cmd/anker_power/<pc>/<sn>/req` (cmd 17). `index` is the 1-based
317
+ * position in the app dropdown `[10s,20s,30s,1m,5m,30m]` — live-confirmed 10s=1, 30s=3, 1m=4. "Never"
318
+ * is a separate command (not this one). Byte-identical to the captured frames modulo the index byte.
319
+ */
320
+ export declare function buildDisplayTimeoutFrame(index: number): Buffer;
238
321
  export declare function buildFf09Request(variant: "info" | "realtime", atUnixSec?: number): Buffer;
@@ -80,14 +80,24 @@ export interface ParsedTopic {
80
80
  export declare function parseSecureTopic(topic: string): ParsedTopic | undefined;
81
81
  /** The per-device Solix topics for `{appName, productCode, deviceSn}`. */
82
82
  export interface SolixDeviceTopics {
83
- /** Telemetry the device pushes (SUBSCRIBE) — ff09 `param_info` frames. */
83
+ /** Telemetry the device pushes (SUBSCRIBE) — ff09 `param_info` frames (live measurements). */
84
84
  paramInfo: string;
85
+ /**
86
+ * Settings/state the device pushes (SUBSCRIBE) — ff09 `state_info` frames. Same ff09 framing as
87
+ * `param_info` but the TAGS carry SETTINGS/targets (mode export limit, SOC limits, max_load, toggles),
88
+ * NOT live measurements — so it needs its own tag→name table, not the param_info one.
89
+ */
90
+ stateInfo: string;
85
91
  /** This device's command replies (SUBSCRIBE). */
86
92
  cmdRes: string;
87
- /** The device's requestDeviceInfo channel (PUBLISH only — the app arms reporting here). */
93
+ /**
94
+ * The device's requestDeviceInfo channel (cmd 17). PUBLISH to arm reporting; also SUBSCRIBE — the
95
+ * broker copies the APP's publishes here to any co-subscriber, which is the only way to observe a
96
+ * control the app changed that `param_info` does not reflect (ambient light, display timeout).
97
+ */
88
98
  req: string;
89
99
  }
90
- /** Build the per-device Solix topics. `param_info` is the telemetry we decode; `req` is publish-only. */
100
+ /** Build the per-device Solix topics. `param_info` is the telemetry we decode; `req` is arm + read-back. */
91
101
  export declare function solixDeviceTopics(appName: string, productCode: string, deviceSn: string): SolixDeviceTopics;
92
102
  /** The per-account Solix topics keyed by `user_id`. */
93
103
  export interface SolixUserTopics {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mega-yfue/eufy-sdk",
3
- "version": "0.2.0-beta.17",
3
+ "version": "0.2.0-beta.19",
4
4
  "description": "One typed TypeScript client for the Anker eufy v6 cloud — capability-driven devices, realtime events over P2P/MQTT/push, and live media",
5
5
  "license": "Apache-2.0",
6
6
  "author": "mega-yfue",