@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.
- package/dist/core/solix-types.d.ts +79 -0
- package/dist/index.js +662 -35
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/solix.d.ts +114 -16
- package/dist/model/index.d.ts +3 -1
- package/dist/model/solix-catalog.d.ts +5 -0
- package/dist/model/solix-device.d.ts +35 -1
- package/dist/model/solix-family.d.ts +31 -0
- package/dist/model/solix-site.d.ts +70 -0
- package/dist/transport/http/solix-client.d.ts +109 -3
- package/dist/transport/http/solix-constants.d.ts +27 -0
- package/dist/transport/mqtt/solix-mqtt.d.ts +123 -16
- package/dist/transport/mqtt/topics.d.ts +13 -3
- package/package.json +1 -1
|
@@ -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.
|
|
37
|
+
* byte. These twelve are the meter fields the vendor app itself names, and their tag→name bindings are
|
|
38
|
+
* confirmed:
|
|
38
39
|
*
|
|
39
|
-
* -
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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",
|