@mega-yfue/eufy-sdk 0.2.0-beta.16 → 0.2.0-beta.18

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.
@@ -34,22 +34,88 @@ export interface SolixParamFrame {
34
34
  }
35
35
  /**
36
36
  * Telemetry field tags for the Smart Meter (AE1X0) that we emit under a stable NAME, keyed by ff09 tag
37
- * byte. Only tags whose tag→name binding is CONFIRMED against a live frame live here:
37
+ * byte. These twelve are the meter fields the vendor app itself names, and their tag→name bindings are
38
+ * confirmed:
38
39
  *
39
- * - `0xac` = `meterVoltageL1` — confirmed against live single-phase data (a nominal mains voltage).
40
+ * - The app's field vocabulary is exactly these twelve — voltage, current and power per line
41
+ * (L1/L2/L3), a power total, and cumulative import/export energy — with no current total, no
42
+ * frequency and no power-factor field.
43
+ * - A live single-phase frame confirms the tag→field magnitudes: `0xac` a nominal mains voltage,
44
+ * `0xa8` == `0xab` an equal power pair (line power equals total on one phase, one of them going
45
+ * negative on export), `0xaf` the line current, `0xb3` a slowly-cumulative import counter; the L2/L3
46
+ * slots read 0 on a single-CT install.
40
47
  *
41
- * Every other measurement tag still surfaces as `channel_<hex tag>` (see {@link solixReadings}), so
42
- * nothing on the wire is lost — a caller reads unconfirmed tags there. The names are deliberately NOT
43
- * asserted for the rest: the app exposes the field *list*, but the tag→name *binding* below is a
44
- * structural inference until a known-load capture pins it, and a mislabelled live float is worse than an
45
- * honest `channel_<tag>`. The recovered candidates, to re-add one line each (moving the tag from this
46
- * comment to the map above) as a known-load capture confirms each binding:
48
+ * The frame carries sixteen float slots (`0xa8`..`0xb7`). The four that name no field — `0xb2`, `0xb5`,
49
+ * `0xb6`, `0xb7` — stay raw `channel_<hex tag>` (see {@link solixReadings}). `0xb2` in particular is NOT
50
+ * a current total: under a 1.371 A line current it reads 0.009, three orders of magnitude off. Both
51
+ * `0xb2` and `0xb7` read zero at idle and non-zero under load, so they carry *something* load-related;
52
+ * what, is not established.
47
53
  *
48
- * 0xa8 meterPowerL1 0xa9 meterPowerL2 0xaa meterPowerL3 0xab meterPowerTotal
49
- * 0xad meterVoltageL2 0xae meterVoltageL3 0xaf meterCurrentL1 0xb0 meterCurrentL2
50
- * 0xb1 meterCurrentL3 0xb2 meterCurrentTotal 0xb3 meterImportEnergy 0xb4 meterExportEnergy
54
+ * This table is **meter-family-specific**: the same tag carries a different quantity on another Solix
55
+ * device (a Solarbank's `0xac` reads a power value, not a voltage), so {@link solixReadings} applies
56
+ * these names ONLY to a frame from the meter family — see {@link SOLIX_METER_PRODUCT_PREFIXES}. Every
57
+ * measurement tag still surfaces as `channel_<hex tag>` regardless of device, so nothing on the wire is
58
+ * lost; the model layer names non-meter tags per capability.
51
59
  */
52
60
  export declare const SOLIX_METER_FIELD_NAMES: Readonly<Record<number, string>>;
61
+ /**
62
+ * Product-code prefixes of the Smart Meter family that {@link SOLIX_METER_FIELD_NAMES} decodes. The table
63
+ * is meter-specific, so {@link solixReadings} applies its named fields ONLY to a frame whose product code
64
+ * starts with one of these; a Solarbank (`AE103`) reporting the same `0xac` tag would otherwise be
65
+ * mislabelled `meterVoltageL1` with a nonsensical (negative-power) value. These are product-code prefixes
66
+ * used to select a decode table — not a model import — so the `transport ⊥ model` rule is untouched.
67
+ *
68
+ * Keep this in lockstep with `SOLIX_METER_MODELS` in `model/capabilities/solix.ts` (the same meter
69
+ * prefixes, model-side): a prefix added there but not here grants `energyMeter` to a device whose frames
70
+ * this decoder then refuses to name, and no guard can catch the split (the model layer can't import
71
+ * transport). Add a meter prefix to both.
72
+ */
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>;
53
119
  /** Interpret one TLV value as a telemetry channel (leading type byte + payload). */
54
120
  export declare function readSolixChannel(value: Buffer | undefined): SolixChannel | undefined;
55
121
  /**
@@ -65,9 +131,13 @@ export declare function decodeSolixParamFrame(buf: Buffer): SolixParamFrame | nu
65
131
  * carry the field count, the serial and the status, not measurements. A measurement channel is one whose
66
132
  * leading type byte is `0x05` (float32 LE over a 4-byte payload); any other type is a non-measurement
67
133
  * param and contributes nothing. Each measurement is emitted under `channel_<hex tag>`, and additionally
68
- * under its name when the tag has a confirmed one in {@link SOLIX_METER_FIELD_NAMES}.
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
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}.
69
139
  */
70
- export declare function solixReadings(frame: SolixParamFrame): Record<string, number>;
140
+ export declare function solixReadings(frame: SolixParamFrame, productCode: string): Record<string, number>;
71
141
  /** A live telemetry sample emitted by {@link SolixMqtt} as a `reading` event. */
72
142
  export interface SolixReading {
73
143
  deviceSn: string;
@@ -152,9 +222,14 @@ export declare class SolixMqtt extends EventEmitter {
152
222
  * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
153
223
  * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
154
224
  *
155
- * Subscribes ONLY to what the device sends — `param_info` plus the device and account command-reply
156
- * channels — never the `…/req` channels, which are the app→device request side this arms on, and would
157
- * 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.
158
233
  *
159
234
  * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
160
235
  * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
@@ -165,6 +240,14 @@ export declare class SolixMqtt extends EventEmitter {
165
240
  watch(device: SolixMqttDevice): Promise<void>;
166
241
  /** Tear down the connection and stop the re-arm timer. */
167
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>;
168
251
  /**
169
252
  * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
170
253
  * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
@@ -192,8 +275,25 @@ export declare class SolixMqtt extends EventEmitter {
192
275
  * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
193
276
  * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
194
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.
195
284
  */
196
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;
197
297
  }
198
298
  /**
199
299
  * Pull the ff09 binary frame out of a received message. Solix telemetry arrives as a `{head, payload}`
@@ -211,4 +311,11 @@ export declare function extractFf09Payload(raw: unknown): Buffer | null;
211
311
  * `fe` carries a fresh unix-timestamp nonce; the trailing byte is XOR of every preceding byte (the same
212
312
  * checksum the meter's telemetry frames use — verified to reproduce the captured frames exactly).
213
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;
214
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.16",
3
+ "version": "0.2.0-beta.18",
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",