@mega-yfue/eufy-sdk 0.2.0-beta.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/client/device-registry.d.ts +14 -28
  2. package/dist/client/eufy-mega.d.ts +11 -6
  3. package/dist/client/types.d.ts +6 -2
  4. package/dist/core/contracts.d.ts +23 -27
  5. package/dist/core/crypto.d.ts +10 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/solix-types.d.ts +121 -0
  8. package/dist/core/store.d.ts +38 -10
  9. package/dist/index.js +2690 -485
  10. package/dist/index.js.map +4 -4
  11. package/dist/model/capabilities/access.d.ts +22 -3
  12. package/dist/model/capabilities/arming.d.ts +4 -0
  13. package/dist/model/capabilities/battery.d.ts +32 -4
  14. package/dist/model/capabilities/contact.d.ts +4 -0
  15. package/dist/model/capabilities/doorbell.d.ts +24 -14
  16. package/dist/model/capabilities/index.d.ts +14 -3
  17. package/dist/model/capabilities/lock.d.ts +15 -12
  18. package/dist/model/capabilities/ptz.d.ts +6 -2
  19. package/dist/model/capabilities/solix.d.ts +173 -0
  20. package/dist/model/capabilities/types.d.ts +60 -10
  21. package/dist/model/capabilities/vacuum-clean.d.ts +59 -0
  22. package/dist/model/classify.d.ts +3 -1
  23. package/dist/model/device-family.d.ts +2 -1
  24. package/dist/model/device-types.d.ts +1 -0
  25. package/dist/model/device.d.ts +15 -0
  26. package/dist/model/index.d.ts +5 -0
  27. package/dist/model/solix-catalog.d.ts +25 -0
  28. package/dist/model/solix-device.d.ts +137 -0
  29. package/dist/model/solix-family.d.ts +31 -0
  30. package/dist/model/solix-site.d.ts +70 -0
  31. package/dist/transport/ff09.d.ts +7 -0
  32. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  33. package/dist/transport/http/index.d.ts +1 -0
  34. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  35. package/dist/transport/http/media-download.d.ts +3 -0
  36. package/dist/transport/http/mega-client.d.ts +89 -9
  37. package/dist/transport/http/solix-client.d.ts +270 -0
  38. package/dist/transport/http/solix-constants.d.ts +56 -0
  39. package/dist/transport/media-failure.d.ts +48 -0
  40. package/dist/transport/mqtt/index.d.ts +3 -0
  41. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  42. package/dist/transport/mqtt/solix-mqtt.d.ts +360 -0
  43. package/dist/transport/mqtt/topics.d.ts +30 -0
  44. package/dist/transport/p2p/command-router.d.ts +131 -19
  45. package/dist/transport/p2p/live-stream.d.ts +5 -4
  46. package/dist/transport/p2p/live-trace.d.ts +32 -5
  47. package/dist/transport/p2p/media.d.ts +2 -2
  48. package/dist/transport/p2p/p2p-session.d.ts +9 -0
  49. package/dist/transport/p2p/session-manager.d.ts +57 -23
  50. package/dist/transport/p2p/shared-live-source.d.ts +10 -1
  51. package/dist/transport/p2p/station-channels.d.ts +54 -0
  52. package/dist/transport/stored-image-cache.d.ts +7 -1
  53. package/package.json +5 -5
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Live telemetry for Anker Solix devices over the AWS-IoT MQTT plane.
3
+ *
4
+ * The transport is the shared `SecureMqtt` — the exact same anker AWS-IoT broker + per-user
5
+ * client-cert mutual TLS the eufy device path uses; a Solix account's `get_user_mqtt_info` result maps
6
+ * straight onto {@link SecureMqttCredentials}. Solix devices publish telemetry continuously on
7
+ * `dt/{app_name}/{product_code}/{device_sn}/param_info` as an **ff09 TLV frame** (the same framing
8
+ * family as {@link parseFf09SettingsResponse}), so this module only adds the Solix topic + a small
9
+ * ff09 param decoder on top of the reused transport.
10
+ *
11
+ * Frame layout (observed on a Smart Meter Gen 2 / AE1X0):
12
+ * ff09 | len(u16 LE, incl. trailing XOR checksum) | 5-byte header | TLV fields | xor
13
+ * each TLV field is `tag(1) | len(1) | value(len)`; measurement fields carry `type(1) | 4 bytes`,
14
+ * type `0x05` = float32 LE. Field `a2` is the device serial (ASCII after a leading type byte).
15
+ */
16
+ import { EventEmitter } from "node:events";
17
+ import { type SecureMqttCredentials } from "./secure-mqtt.js";
18
+ import { type Logger } from "../../core/index.js";
19
+ /** A decoded telemetry channel: the raw value plus float/uint interpretations of a 4-byte payload. */
20
+ export interface SolixChannel {
21
+ /** The leading type byte (`0x05` = float32 LE for the meter's measurement channels). */
22
+ type: number;
23
+ raw: Buffer;
24
+ /** Present when the payload is 4 bytes: little-endian float32. */
25
+ float?: number;
26
+ /** Present when the payload is 4 bytes: little-endian uint32. */
27
+ uint?: number;
28
+ }
29
+ /** A parsed ff09 param frame: the device serial (from `a2`) + the raw TLV field map keyed by tag. */
30
+ export interface SolixParamFrame {
31
+ deviceSn?: string;
32
+ /** tag byte → value bytes (still including the per-field leading type byte for measurement fields). */
33
+ fields: Map<number, Buffer>;
34
+ }
35
+ /**
36
+ * Telemetry field tags for the Smart Meter (AE1X0) that we emit under a stable NAME, keyed by ff09 tag
37
+ * byte. These twelve are the meter fields the vendor app itself names, and their tag→name bindings are
38
+ * confirmed:
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.
47
+ *
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}). Both `0xb2` and `0xb7` read
50
+ * zero at idle and non-zero under load, so they carry *something* load-related; what, is not established.
51
+ * `0xb2` is dimensionally consistent with **power factor** and rules **reactive power** out: on the same
52
+ * frame the line reads ~240 V at 1.371 A (apparent power S = V·I ≈ 328 VA), so a reactive-power slot would
53
+ * read in the hundreds of VAR, not `0xb2`'s 0.009 — whereas a power factor P/S is a sub-unity ratio of the
54
+ * right magnitude (~0.008). It is left raw regardless, since a single frame doesn't pin it. `0xb7` (~0.1
55
+ * under load) has no such magnitude tell and stays fully open.
56
+ *
57
+ * Each name is annotated with the equivalent register from Anker's OWN vendor integration for the
58
+ * newer Modbus-TCP meter generation (Smart Meter Gen 2), which independently corroborates the meaning
59
+ * of each tag: our `meterPowerL1` is their `primary_phase_1_active_power`, and so on. Same physical
60
+ * quantities, different hardware/transport (their meter reports two CT channels — `primary` and
61
+ * `secondary` — and also exposes `reactive_power`, `power_factor` and per-phase energy, none of which
62
+ * this single-channel ff09 frame carries).
63
+ *
64
+ * This table is **meter-family-specific**: the same tag carries a different quantity on another Solix
65
+ * device (a Solarbank's `0xac` reads a power value, not a voltage), so {@link solixReadings} applies
66
+ * these names ONLY to a frame from the meter family — see {@link SOLIX_METER_PRODUCT_PREFIXES}. Every
67
+ * measurement tag still surfaces as `channel_<hex tag>` regardless of device, so nothing on the wire is
68
+ * lost; the model layer names non-meter tags per capability.
69
+ */
70
+ export declare const SOLIX_METER_FIELD_NAMES: Readonly<Record<number, string>>;
71
+ /**
72
+ * Product-code prefixes of the Smart Meter family that {@link SOLIX_METER_FIELD_NAMES} decodes. The table
73
+ * is meter-specific, so {@link solixReadings} applies its named fields ONLY to a frame whose product code
74
+ * starts with one of these; a Solarbank (`AE103`) reporting the same `0xac` tag would otherwise be
75
+ * mislabelled `meterVoltageL1` with a nonsensical (negative-power) value. These are product-code prefixes
76
+ * used to select a decode table — not a model import — so the `transport ⊥ model` rule is untouched.
77
+ *
78
+ * Keep this in lockstep with `SOLIX_METER_MODELS` in `model/capabilities/solix.ts` (the same meter
79
+ * prefixes, model-side): a prefix added there but not here grants `energyMeter` to a device whose frames
80
+ * this decoder then refuses to name, and no guard can catch the split (the model layer can't import
81
+ * transport). Add a meter prefix to both.
82
+ */
83
+ export declare const SOLIX_METER_PRODUCT_PREFIXES: readonly string[];
84
+ /**
85
+ * Product-code prefix of the gen-4 Solarbank (the `ats_ax170` family, e.g. `AE103` Solarbank 4 E5000
86
+ * Pro) whose ff09 tag layout {@link SOLIX_SOLARBANK_FIELD_NAMES} + the SOC/temperature extraction
87
+ * describe. Like the meter table this is family-specific — the SAME tag carries a different quantity on
88
+ * the meter (`0xac` is line voltage there, battery power here), so the Solarbank names are applied ONLY
89
+ * to a frame from this family. A product-code prefix used to pick a decode table, not a model import.
90
+ * `AE10` covers the AE10x gen-4 Solarbanks and does NOT match the meter (`AE1X0`, whose 4th char is `X`).
91
+ */
92
+ export declare const SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
93
+ /**
94
+ * Confirmed ff09 tag → field bindings for the gen-4 Solarbank (`ats_ax170`), correlated live against the
95
+ * app UI. Power values in watts; signed fields note their sign convention:
96
+ * - `0xac` battery power, SIGNED (+ charging / − discharging) — the measured net pack power.
97
+ * - `0xbc` charge power (0 unless charging); `0xad` discharge power (0 unless discharging).
98
+ * - `0xae` AC plug power, SIGNED (+ feeding the home / − drawing in to charge).
99
+ * - `0xaf` socket power — the unit's own on-board AC outlet (an appliance plugged into the Solarbank).
100
+ * - `0xc4` grid input power; `0xc5` home load power.
101
+ * SOC and temperature are NOT float channels — see {@link solixReadings}, which reads SOC from tag `0xa3`
102
+ * (a uint8) and temperature from the `0xa4` BMS status blob. The 4 PV-string channels (`0xc6`–`0xc9`),
103
+ * the AC currents (`0xb2`/`0xb3`) and export energy (`0xb4`) are not yet confirmed, so they stay raw
104
+ * `channel_<hex>` until a capture pins them.
105
+ *
106
+ * Names are annotated with the equivalent register from Anker's OWN vendor integration for the newer
107
+ * Modbus-TCP Solarbank generation (which includes a "Solarbank 4 E5000 Pro" config — the same product as
108
+ * `AE103`, a newer hardware rev), cross-checking each meaning. Their integration splits our signed
109
+ * `batteryPower` into `battery_charging_power` / `battery_discharging_power` off one register, and exposes
110
+ * a single `pv_power` total rather than our four per-string channels; `socketPower` (the on-board AC
111
+ * outlet) has no register there. Same quantities, different transport.
112
+ */
113
+ export declare const SOLIX_SOLARBANK_FIELD_NAMES: Readonly<Record<number, string>>;
114
+ /**
115
+ * Confirmed `state_info` tag → field bindings for the gen-4 Solarbank. `state_info` is a SEPARATE push
116
+ * topic from `param_info` and, though it shares the ff09 framing, its tags carry SETTINGS/targets, NOT
117
+ * live measurements — so the SAME tag byte means something different here than in
118
+ * {@link SOLIX_SOLARBANK_FIELD_NAMES} (e.g. `0xab` is live PV power in param_info, the mode's AC-socket
119
+ * export limit here). Mapped by live observation against the app's SOC-setting screen; everything else
120
+ * stays raw `state_<hex>` until confirmed the same way.
121
+ */
122
+ export declare const SOLIX_STATE_FIELD_NAMES: Readonly<Record<number, string>>;
123
+ /**
124
+ * The Solarbank EMS `operating_mode` enumeration from Anker's OWN vendor integration for the newer
125
+ * **Modbus-TCP** hardware rev (Solarbank 4 E5000 Pro, register `operating_mode` gated by the `0x8006`
126
+ * capability mask). Value → English label:
127
+ * - `0` selfConsumption — "Self-Consumption Mode"
128
+ * - `1` timeOfUse — "Time Of Use Mode"
129
+ * - `3` thirdPartyControl — "Third-Party Controlled"
130
+ * - `4` custom — "Custom Mode"
131
+ * - `5` socketOverlay — "Socket Overlay Mode"
132
+ * - `6` smart — "Smart Mode"
133
+ * - `7` dynamicTariff — "Dynamic Tariff Mode"
134
+ *
135
+ * Value `2` is unassigned there — seven modes across `{0,1,3,4,5,6,7}`, not eight.
136
+ *
137
+ * NOT a decoder for this SDK's ff09 `mode` (`state_info` tag `0xa9`): that OLDER cloud/MQTT `AE103`
138
+ * numbering is DIFFERENT on every value — `1`=custom, `2`=self-consumption, `4`=rapid charge, `7`=smart,
139
+ * `8`=dynamic tariff (recorded on the `0xa9` field above, correlated against the app). Labelling an ff09
140
+ * `mode` value with this Modbus map would be confidently wrong. It is exported as the vendor's own
141
+ * reference enumeration and the thing an AE103 `0xa9` correlation capture would be checked against —
142
+ * fold the two only if such a capture proves the numbers match.
143
+ */
144
+ export declare const SOLIX_MODBUS_EMS_MODES: Readonly<Record<number, string>>;
145
+ /**
146
+ * Decode a `state_info` ff09 frame to named + raw settings values. Skips the header tags (`< 0xa5`:
147
+ * request marker, serial, timestamps). Each settings tag is emitted under `state_<hex>` (a plain number
148
+ * so it's watchable in a consumer while more tags get mapped) AND, when confirmed, under its name from
149
+ * {@link SOLIX_STATE_FIELD_NAMES}. Value is read type-aware: `0x05` float32, `0x02` u16, `0x01` u8, and
150
+ * `0x03` the whole-number settings byte (`payload[1]`).
151
+ *
152
+ * The header cutoff is `0xa5` here, deliberately one lower than {@link solixReadings}' `0xa6` for
153
+ * `param_info`: the two frames are different layouts under the same ff09 framing — `state_info` carries
154
+ * a settings value at `0xa5` (SOC), where `param_info` has a header tag. The cutoffs are not meant to
155
+ * match; the spec pins `0xa5`'s treatment in each so they can't silently drift together.
156
+ */
157
+ export declare function solixStateReadings(frame: SolixParamFrame): Record<string, number>;
158
+ /** Interpret one TLV value as a telemetry channel (leading type byte + payload). */
159
+ export declare function readSolixChannel(value: Buffer | undefined): SolixChannel | undefined;
160
+ /**
161
+ * Decode an ff09 Solix param frame into its serial + TLV field map. Returns `null` for a non-ff09
162
+ * buffer, a length field that doesn't fit, or a bad checksum. Validates the trailing XOR checksum first
163
+ * (so a corrupted frame is rejected rather than yielding plausible floats), then walks `tag|len|value`
164
+ * from the first `0xa1` tag to the declared length minus the checksum byte via the shared
165
+ * `walkFf09Tlv` (bounded by `end`, so a field length can't overrun into the checksum).
166
+ */
167
+ export declare function decodeSolixParamFrame(buf: Buffer): SolixParamFrame | null;
168
+ /**
169
+ * Reduce a param frame to named + raw telemetry values. Tags below `0xa6` are skipped — `a1`/`a2`/`a3`
170
+ * carry the field count, the serial and the status, not measurements. A measurement channel is one whose
171
+ * leading type byte is `0x05` (float32 LE over a 4-byte payload); any other type is a non-measurement
172
+ * param and contributes nothing. Each measurement is emitted under `channel_<hex tag>`, and additionally
173
+ * under its name when the tag has a confirmed one AND `productCode` is from a known family — pass the
174
+ * telemetry topic's product code so a device outside the meter/Solarbank families keeps raw
175
+ * `channel_<hex>` rather than borrowing another family's tag→name table. `productCode` is required (it
176
+ * comes straight from the telemetry topic); pass `""` for a frame of unknown origin and no names apply.
177
+ * Meter family: {@link SOLIX_METER_PRODUCT_PREFIXES}; Solarbank: {@link SOLIX_SOLARBANK_PRODUCT_PREFIX}.
178
+ */
179
+ export declare function solixReadings(frame: SolixParamFrame, productCode: string): Record<string, number>;
180
+ /** A live telemetry sample emitted by {@link SolixMqtt} as a `reading` event. */
181
+ export interface SolixReading {
182
+ deviceSn: string;
183
+ productCode: string;
184
+ topic: string;
185
+ frame: SolixParamFrame;
186
+ values: Record<string, number>;
187
+ }
188
+ /** The minimum device shape {@link SolixMqtt.watch} needs (as returned by `SolixClient.getDevices`). */
189
+ export interface SolixMqttDevice {
190
+ device_sn: string;
191
+ product_code: string;
192
+ }
193
+ /** Options for {@link SolixMqtt}. */
194
+ export interface SolixMqttOptions {
195
+ /** `get_user_mqtt_info` result — carries endpoint, cert/key, app_name, thing_name, user_id. */
196
+ mqttInfo: SecureMqttCredentials;
197
+ /** Override the MQTT clientId. Defaults to the cert CN (`thing_name`), distinct from the app's id. */
198
+ clientId?: string;
199
+ /**
200
+ * Account/user id (40-hex) for the arming `account_id` + heartbeat topic. Defaults to
201
+ * `mqttInfo.user_id`; set it if the credentials omit it.
202
+ */
203
+ userId?: string;
204
+ /**
205
+ * How often (ms) to re-send the device-info arming request that keeps realtime telemetry flowing.
206
+ * The device stops pushing `param_info` when no client keeps requesting it (the app re-arms on every
207
+ * foreground resume + a periodic heartbeat), so a passive subscriber goes silent after the server's
208
+ * reporting window closes. Default 25s — inside the observed ~30s cadence with keepalive 60. Set `0`
209
+ * to disable arming (subscribe-only, the old behaviour).
210
+ */
211
+ armIntervalMs?: number;
212
+ /**
213
+ * The `head.client_id` stamped into the command/heartbeat envelopes — the app-shaped
214
+ * `android-{app_name}-{user_id}-{mqttUuid}-{ts}` (see {@link buildAppShapedClientId}). Defaults to
215
+ * that shape built from {@link mqttUuid}. Pass this to pin the whole string.
216
+ */
217
+ appClientId?: string;
218
+ /**
219
+ * Stable 16-hex install UUID for the app-shaped client id. Defaults to one derived deterministically
220
+ * from the user id ({@link mqttUuidFrom}) — no storage needed, so the broker sees one stable client
221
+ * across restarts.
222
+ *
223
+ * The trade this makes: the default seed is the **account** id, which every client on that account
224
+ * shares, so two clients on one account derive the same uuid → the same `client_id`, and the broker
225
+ * evicts one to admit the other (they take the channel from each other indefinitely). Restart
226
+ * stability is the common case and this is the deliberate default, but pass an explicit `mqttUuid`
227
+ * (per host/install) when more than one client runs on the same account, to be told apart.
228
+ */
229
+ mqttUuid?: string;
230
+ /** The account's `site_id` for the `power_site` heartbeat. Omitted from the frame when unknown. */
231
+ siteId?: string;
232
+ logger?: Logger;
233
+ }
234
+ /**
235
+ * Subscribe to a Solix device's live telemetry and emit decoded `reading` events. Reuses
236
+ * `SecureMqtt` for the connection; adds only the Solix data topic + ff09 param decoding.
237
+ *
238
+ * const mqtt = new SolixMqtt({ mqttInfo: await solix.getUserMqttInfo() });
239
+ * mqtt.on("reading", (r) => console.log(r.deviceSn, r.values.meterVoltageL1));
240
+ * await mqtt.watch(device); // device = a SolixClient.getDevices() entry
241
+ */
242
+ export declare class SolixMqtt extends EventEmitter {
243
+ private readonly transport;
244
+ private readonly appName;
245
+ private readonly userId?;
246
+ private readonly appClientId;
247
+ private readonly armIntervalMs;
248
+ private readonly logger?;
249
+ private readonly siteId?;
250
+ private readonly watched;
251
+ private seq;
252
+ private armTimer?;
253
+ /**
254
+ * Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
255
+ * (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
256
+ * restart presents itself to the broker as a new client, so it defaults deterministically from the user
257
+ * id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
258
+ */
259
+ constructor(opts: SolixMqttOptions);
260
+ /**
261
+ * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
262
+ * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
263
+ *
264
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
265
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
266
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
267
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
268
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
269
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
270
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
271
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
272
+ *
273
+ * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
274
+ * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
275
+ * success: the call would resolve and arm on every interval while no reading ever arrives.
276
+ *
277
+ * The re-arm timer is unreffed, so a caller that watches and returns can still exit.
278
+ */
279
+ watch(device: SolixMqttDevice): Promise<void>;
280
+ /** Tear down the connection and stop the re-arm timer. */
281
+ close(): Promise<void>;
282
+ /**
283
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
284
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
285
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
286
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
287
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
288
+ */
289
+ setDisplayTimeout(device: SolixMqttDevice, index: number): Promise<void>;
290
+ /**
291
+ * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
292
+ * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
293
+ * heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
294
+ * (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
295
+ * not thrown, so one bad device doesn't stop the rest or kill the timer.
296
+ */
297
+ private armAll;
298
+ /** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
299
+ private arm;
300
+ /** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
301
+ private makeHead;
302
+ /**
303
+ * Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
304
+ * `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
305
+ * from a real value, so sending `""` would claim an account this client does not have.
306
+ */
307
+ private commandEnvelope;
308
+ /**
309
+ * The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
310
+ * `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
311
+ */
312
+ private heartbeatEnvelope;
313
+ /**
314
+ * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
315
+ * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
316
+ * own `a2` field wins for the serial when it carries one.
317
+ *
318
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
319
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
320
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
321
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
322
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
323
+ */
324
+ private onMessage;
325
+ /**
326
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
327
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
328
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
329
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
330
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
331
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
332
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
333
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
334
+ */
335
+ private handleCommand;
336
+ }
337
+ /**
338
+ * Pull the ff09 binary frame out of a received message. Solix telemetry arrives as a `{head, payload}`
339
+ * envelope whose `payload` is a JSON string carrying base64 `data` (or `trans`); `SecureMqtt`
340
+ * has already JSON-parsed the outer envelope. Returns the decoded frame bytes, or `null`.
341
+ */
342
+ export declare function extractFf09Payload(raw: unknown): Buffer | null;
343
+ /**
344
+ * Build the ff09 request frame the app base64-encodes into a `requestDeviceInfo` (cmd 17) command's
345
+ * `data`. Captured live from the Anker app — request-type tag `a1`=0x22; the `realtime` variant adds
346
+ * `a2`/`a3` params (this is the one that keeps `param_info` reporting flowing), while `info` is the bare
347
+ * device-info fetch. Frame:
348
+ * `ff09 | len(u16 LE, TOTAL bytes incl. ff09+len+xor) | 5-byte header | a1 01 22
349
+ * [| a2 02 01 01 | a3 03 02 2c 01] | fe … <ts32 LE> | xor`
350
+ * `fe` carries a fresh unix-timestamp nonce; the trailing byte is XOR of every preceding byte (the same
351
+ * checksum the meter's telemetry frames use — verified to reproduce the captured frames exactly).
352
+ */
353
+ /**
354
+ * Build the display screen-off-timeout command frame (ff09 msgtype `0x68`, tag `a5 = [01, index]`),
355
+ * captured live from the app on `cmd/anker_power/<pc>/<sn>/req` (cmd 17). `index` is the 1-based
356
+ * position in the app dropdown `[10s,20s,30s,1m,5m,30m]` — live-confirmed 10s=1, 30s=3, 1m=4. "Never"
357
+ * is a separate command (not this one). Byte-identical to the captured frames modulo the index byte.
358
+ */
359
+ export declare function buildDisplayTimeoutFrame(index: number): Buffer;
360
+ export declare function buildFf09Request(variant: "info" | "realtime", atUnixSec?: number): Buffer;
@@ -78,3 +78,33 @@ export interface ParsedTopic {
78
78
  * so an unparsed topic leaves `deviceSn` unset instead of carrying a guess.
79
79
  */
80
80
  export declare function parseSecureTopic(topic: string): ParsedTopic | undefined;
81
+ /** The per-device Solix topics for `{appName, productCode, deviceSn}`. */
82
+ export interface SolixDeviceTopics {
83
+ /** Telemetry the device pushes (SUBSCRIBE) — ff09 `param_info` frames (live measurements). */
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;
91
+ /** This device's command replies (SUBSCRIBE). */
92
+ cmdRes: string;
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
+ */
98
+ req: string;
99
+ }
100
+ /** Build the per-device Solix topics. `param_info` is the telemetry we decode; `req` is arm + read-back. */
101
+ export declare function solixDeviceTopics(appName: string, productCode: string, deviceSn: string): SolixDeviceTopics;
102
+ /** The per-account Solix topics keyed by `user_id`. */
103
+ export interface SolixUserTopics {
104
+ /** Account-level command replies (SUBSCRIBE). */
105
+ cmdRes: string;
106
+ /** The `power_site` heartbeat channel (PUBLISH only). */
107
+ powerSite: string;
108
+ }
109
+ /** Build the per-account Solix topics. Note: the account `…/req` channel is publish-side and NOT subscribed. */
110
+ export declare function solixUserTopics(appName: string, userId: string): SolixUserTopics;
@@ -28,10 +28,12 @@ import { FragmentRecording } from "./fragment-recording.js";
28
28
  * when these change.
29
29
  *
30
30
  * `connect` applies to every call on a station, because nothing can be addressed to one before its session is
31
- * up. `level2Grace` applies twice where the key is required: the negotiation is re-prompted once.
31
+ * up, and it is the session's own connect deadline: a session that reaches it closes itself, so waiting past it
32
+ * waits on a connection that can no longer answer. `level2Grace` applies twice where the key is required: the
33
+ * negotiation is re-prompted once.
32
34
  */
33
35
  export declare const P2P_STATION_WAITS: {
34
- readonly connect: 20000;
36
+ readonly connect: 15000;
35
37
  readonly level2Grace: 25000;
36
38
  readonly level2Settle: 8000;
37
39
  };
@@ -88,7 +90,7 @@ export interface P2PRouterDeps {
88
90
  }
89
91
  export declare class P2PCommandRouter {
90
92
  private readonly deps;
91
- /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
93
+ /** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
92
94
  private readonly manager;
93
95
  /** Error objects already forwarded while a station startup awaits the same session signal. */
94
96
  private readonly reportedErrors;
@@ -96,6 +98,21 @@ export declare class P2PCommandRouter {
96
98
  private readonly liveSources;
97
99
  /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
98
100
  private readonly liveSourceOpts;
101
+ /**
102
+ * The session each live source pulls over — the station's own serial, or the source's own media
103
+ * session key.
104
+ *
105
+ * Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
106
+ * a reservation rather than a record. Two cameras started together (the four-tile case this feature
107
+ * exists for) would otherwise both find the station's session free: a consumer attaches only after
108
+ * `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
109
+ * would take the shared connection and contend on it.
110
+ *
111
+ * A station entry is kept as well as a media one, because "is the station's own session already
112
+ * claimed" is the question being asked, and an entry that is present but not yet in
113
+ * {@link liveSources} is a claim in flight.
114
+ */
115
+ private readonly liveSessionKeys;
99
116
  /**
100
117
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
101
118
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -122,7 +139,10 @@ export declare class P2PCommandRouter {
122
139
  * `no P2P session`.
123
140
  */
124
141
  static claimsDevice(dev: EufyDevice): boolean;
125
- /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
142
+ /**
143
+ * The open P2P sessions by key — a station's own under its serial, a camera's media session under
144
+ * `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
145
+ */
126
146
  getSessions(): Map<string, P2PSession>;
127
147
  /**
128
148
  * Speculatively open + briefly hold a station's session (e.g. after a doorbell ring) so a
@@ -132,7 +152,7 @@ export declare class P2PCommandRouter {
132
152
  *
133
153
  * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
134
154
  * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
135
- * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
155
+ * A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
136
156
  * and the lookups are away, not once the peer has answered, so both would expire together.
137
157
  *
138
158
  * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
@@ -143,8 +163,6 @@ export declare class P2PCommandRouter {
143
163
  closeAll(): Promise<void>;
144
164
  /** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
145
165
  private recordFor;
146
- /** The parent-station key a device's session lives under (its HomeBase, or itself if standalone). */
147
- private stationKeyFor;
148
166
  /**
149
167
  * The parent-station serial a device serial's session lives under — the single source of truth for
150
168
  * session keying, used by the facade (e.g. to pre-warm the right station for an event). Returns the
@@ -165,7 +183,41 @@ export declare class P2PCommandRouter {
165
183
  * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
166
184
  * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
167
185
  */
168
- private openStation;
186
+ /**
187
+ * The key a camera's own media session is filed under, distinct from every station serial because a
188
+ * serial contains no `#`.
189
+ */
190
+ private static mediaSessionKey;
191
+ /** Whether `key` names a media session rather than a station's own. */
192
+ private static isMediaSessionKey;
193
+ /**
194
+ * Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
195
+ *
196
+ * One session serves one camera: a station fans its cameras over a session and answers the most recent
197
+ * start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
198
+ * how a station serves another camera.
199
+ *
200
+ * The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
201
+ * captured opening one PPCS session per camera, with three cameras' video arriving in the same second
202
+ * at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
203
+ * Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
204
+ * full frame rate at once over a session each, where the same pair down one session could only take
205
+ * turns.
206
+ *
207
+ * It carries media alone. The station announces its state to every client that connects, so a session
208
+ * wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
209
+ * unannounced, and the station's own session stays the single source of connection state, control
210
+ * notifications and frames.
211
+ *
212
+ * The level-2 key is waited for here on the same terms the station's own session gets, because a
213
+ * connection is not usable for this without one: an attached camera's media start has no level-1 form,
214
+ * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
215
+ * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
216
+ * carry the start is not a connected session, so this refuses rather than returning one.
217
+ */
218
+ private openMediaSession;
219
+ /** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
220
+ private openSession;
169
221
  /**
170
222
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
171
223
  *
@@ -177,6 +229,12 @@ export declare class P2PCommandRouter {
177
229
  *
178
230
  * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
179
231
  * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
232
+ *
233
+ * A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
234
+ * level-2 readiness and inbound frames all reach the owner through the station's own session, and a
235
+ * station announces those to every client that connects — so fanning a second connection's copies out
236
+ * under the same station serial would report each one twice, and a close would tear down the station
237
+ * while its own session is still serving.
180
238
  */
181
239
  private makeSession;
182
240
  /**
@@ -285,22 +343,40 @@ export declare class P2PCommandRouter {
285
343
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
286
344
  * reuse branch, so a reuse frees the station as a cold start does.
287
345
  *
346
+ * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
347
+ * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
348
+ * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
349
+ * so a still asked for while a sibling is being watched contends as it always did, and the caller's
350
+ * retained image answers it. A live view still outranks a tile; what changed is that two live views no
351
+ * longer have to outrank each other.
352
+ *
288
353
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
289
354
  * place.
290
355
  */
291
- sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts): Promise<SharedLiveSource>;
356
+ sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts, mayOpenOwnSession?: boolean): Promise<SharedLiveSource>;
292
357
  /**
293
358
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
294
359
  *
295
360
  * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
296
- * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
297
- * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
298
- * long as the linger lasts.
361
+ * re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
362
+ * which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
363
+ * for as long as the linger lasts.
299
364
  *
300
365
  * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
301
366
  * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
302
367
  * looking at competing with one somebody just asked for.
303
368
  *
369
+ * An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
370
+ * has sent no media start and is competing for nothing. Without that, two cameras opened at the same
371
+ * moment destroy each other — the second finds the first's source built but not yet attached to, reads
372
+ * zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
373
+ * this whole path exists for.
374
+ *
375
+ * A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
376
+ * contention this releases is contention over one session, and that sibling is not on this one. Dropping
377
+ * it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
378
+ * competition that is not happening.
379
+ *
304
380
  * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
305
381
  * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
306
382
  * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
@@ -311,14 +387,27 @@ export declare class P2PCommandRouter {
311
387
  */
312
388
  private releaseLingeringSiblings;
313
389
  /**
314
- * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
390
+ * Whether another camera has already claimed this station's OWN session, ignoring `key`.
391
+ *
392
+ * The question a newcomer has to answer is not whether the station is busy — it can serve one camera
393
+ * per connection — but whether the connection it would otherwise share is taken. A camera on a media
394
+ * session of its own does not hold this one, so a station whose first camera has since stopped hands
395
+ * its own session to the next arrival rather than opening a socket beside an idle one.
396
+ *
397
+ * A claim counts while its source is still `idle`, source or no source. That is a start that has been
398
+ * handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
399
+ * at the same moment can see each other: consumers attach after this method has already run for both.
400
+ *
401
+ * The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
402
+ * session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
403
+ * socket against destroying a start someone is waiting on, which is not a close trade.
315
404
  *
316
405
  * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
317
406
  * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
318
- * and counting that as a viewer would refuse every later stream on the station until the client
319
- * restarted. Only a source that can still deliver holds a place.
407
+ * and counting that as a viewer would put every later camera on a connection of its own until the
408
+ * client restarted. Only a source that can still deliver holds a place.
320
409
  */
321
- private occupiedSiblingChannel;
410
+ private stationSessionInUse;
322
411
  /**
323
412
  * Whether the stream on `key` should re-assert its channel to hold the station.
324
413
  *
@@ -328,8 +417,8 @@ export declare class P2PCommandRouter {
328
417
  * - Nothing attached: no. There is nobody to take the station for.
329
418
  * - A live viewer attached: yes. That is the picture someone is looking at.
330
419
  * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
331
- * tile that is off screen while the live view is on it, and a station serving one camera at a time
332
- * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
420
+ * tile that is off screen while the live view is on it, and one session serving one camera at a time
421
+ * cannot satisfy both — and a still does not open a connection of its own. Measured: a still on a sibling halved a live view's frame rate for as long as
333
422
  * it took, and its own capture then took fifteen seconds because it was contending.
334
423
  *
335
424
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
@@ -344,8 +433,24 @@ export declare class P2PCommandRouter {
344
433
  * not.
345
434
  */
346
435
  private attachUnlessAborted;
347
- /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
436
+ /**
437
+ * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
438
+ * on a session goes with it, and a media session opened for this source alone is closed: nothing else
439
+ * can reach that connection, so leaving it open would hold a socket and a station keepalive for a
440
+ * camera no longer being pulled. A claim on the STATION's own session is released without closing
441
+ * anything — that connection carries the station's control traffic and outlives any one camera.
442
+ */
348
443
  private dropLiveSource;
444
+ /** Close and forget the media session `key`'s live source owned, if it owned one. */
445
+ private closeMediaSession;
446
+ /**
447
+ * Drop the live source a media session was carrying, after that session closed on its own.
448
+ *
449
+ * The source holds the closed connection and never re-resolves it, so it can only answer its retained
450
+ * keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
451
+ * a fresh source — which, finding the station busy again, opens a fresh media session for it.
452
+ */
453
+ private tearDownMediaSession;
349
454
  /**
350
455
  * Drop everything that was riding a station's session, and report the station closed.
351
456
  *
@@ -568,6 +673,13 @@ export declare class P2PCommandRouter {
568
673
  * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
569
674
  * unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
570
675
  * camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
676
+ *
677
+ * Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
678
+ * control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
679
+ * least able to afford a single dropped one — it has no reply, no readback here, and nothing that
680
+ * would tell a caller the write was lost rather than refused. The level-1 form reports delivery by
681
+ * throwing (`sendSetPayload` throws when the session has no address) rather than by returning a
682
+ * boolean, so the first pass carries the failure and the rest are repeats.
571
683
  */
572
684
  private sendSetPayloadEnvelope;
573
685
  /**