@mega-yfue/eufy-sdk 0.4.0-beta.2 → 0.4.0-beta.20

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.
@@ -552,6 +552,7 @@ export declare const CAMERA_MEMBERS: {
552
552
  } | undefined) => Promise<Buffer<ArrayBufferLike>>>;
553
553
  readonly openReadable: import("./members.js").ProvidedMember<"media", ((opts?: Parameters<NonNullable<MediaProvider["openReadable"]>>[0]) => Promise<import("node:stream").Readable>) | undefined>;
554
554
  readonly recordFragments: import("./members.js").ProvidedMember<"media", ((opts?: Parameters<NonNullable<MediaProvider["recordFragments"]>>[0]) => import("../../core/contracts.js").FragmentRecordingHandle) | undefined>;
555
+ readonly downloadRecording: import("./members.js").ProvidedMember<"media", ((opts: Parameters<NonNullable<MediaProvider["downloadRecording"]>>[0]) => Promise<import("../../core/contracts.js").RecordingDownload>) | undefined>;
555
556
  /**
556
557
  * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
557
558
  * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
@@ -164,7 +164,7 @@ export declare function parseQuickResponses(voiceList: Array<{
164
164
  }>): QuickResponse[];
165
165
  /**
166
166
  * `doorbell` — chime / ringtone configuration. CONFIRMED against a real Video Doorbell (T8214):
167
- * the live ids are the `1702-1719` `CMD_BAT_DOORBELL_*` range (provenance "mega", observed). The
167
+ * the live ids are the `1702-1719` `CMD_BAT_DOORBELL_*` range, observed on that device. The
168
168
  * legacy `2015/2022/1306` ids are excluded — they appear on NO owned device. The button-
169
169
  * press *event* (ring) is delivered out-of-band via `CMD_DOORBELL_NOTIFY_PAYLOAD` (1701) /
170
170
  * push/MQTT — it is handled by the Phase-1 event normalizers, not as a device-list param.
@@ -185,9 +185,9 @@ export declare function parseQuickResponses(voiceList: Array<{
185
185
  export declare const DOORBELL_MEMBERS: {
186
186
  /**
187
187
  * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
188
- * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame,
189
- * not merely the param id observed live: direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape
190
- * as its 1703 sibling — captured, not inferred from the shared param range.
188
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
189
+ * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
190
+ * inferred from the shared param range.
191
191
  */
192
192
  readonly chimeSwitch: {
193
193
  readonly param: 1702;
@@ -198,9 +198,8 @@ export declare const DOORBELL_MEMBERS: {
198
198
  readonly write: (v: string | number | boolean, ctx: import("./types.js").CommandContext) => import("../../core/contracts.js").Command;
199
199
  };
200
200
  /**
201
- * `provenance` is "verified" not "mega": the actual write wire is confirmed (
202
- * our own P2P decrypt), not merely the param id observed on a live device. Direct-binary
203
- * `[ch][value][acct]`, 1=on/0=off — verified live on a T8214 (ON then OFF).
201
+ * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
202
+ * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
204
203
  */
205
204
  readonly mechanicalChimeSwitch: {
206
205
  readonly param: 1703;
@@ -231,7 +230,7 @@ export declare const DOORBELL_MEMBERS: {
231
230
  readonly type: "number";
232
231
  readonly unit: "%";
233
232
  readonly kind: "percent";
234
- readonly provenance: "mega";
233
+ readonly provenance: "verified";
235
234
  readonly writtenElsewhere: true;
236
235
  readonly description: string;
237
236
  };
@@ -325,7 +324,7 @@ export declare const DOORBELL_MEMBERS: {
325
324
  readonly param: 1710;
326
325
  readonly type: "string";
327
326
  readonly kind: "text";
328
- readonly provenance: "mega";
327
+ readonly provenance: "verified";
329
328
  readonly description: string;
330
329
  };
331
330
  /**
@@ -61,8 +61,14 @@ export interface ActionDescriptor extends ActionSpec {
61
61
  /** What one capability exposes on a device — the join of its bound object and its own declaration. */
62
62
  export interface CapabilityDescriptor {
63
63
  capability: Capability;
64
- /** The fluent accessor this capability is reached under: `dev[accessor]()`. */
65
- accessor: string;
64
+ /**
65
+ * The fluent accessor this capability is reached under: `dev[accessor]()`.
66
+ *
67
+ * Absent for a capability with nothing to bind — one whose whole surface is inbound events, so it
68
+ * declares no members and no `actions()` and therefore has no object to reach. Such a capability is
69
+ * described for its {@link events} alone; every other field is empty.
70
+ */
71
+ accessor?: string;
66
72
  /** The reads INSTALLED on this device, never the theoretical set. */
67
73
  reads: readonly ReadDescriptor[];
68
74
  /** The installed actions that carry a description. */
@@ -102,6 +108,16 @@ export interface DeviceManifest {
102
108
  * Parameterised over the module list; the barrel binds it to the real one. A capability the device did
103
109
  * not bind — because it does not have it, or because nothing is bound yet — contributes no descriptor
104
110
  * at all.
111
+ *
112
+ * With ONE exception, and it is not a bound object: a capability whose whole surface is inbound events
113
+ * declares no members and no `actions()`, so `buildActions` builds nothing for it and there is no
114
+ * object here to walk. Its events are still device truth, and the resolved set in {@link
115
+ * AvailabilityContext.capabilities} is what says this device has it — so it is described from its own
116
+ * declaration, with no {@link CapabilityDescriptor.accessor} and every other field empty, and only on
117
+ * a device with at least one bound object.
118
+ *
119
+ * Claims resolve against an empty read set there, which is the truthful evidence — a module that binds
120
+ * nothing installs no getter, so a `reads` claim cannot hold. A topology claim still applies.
105
121
  * @internal
106
122
  */
107
123
  export declare function describeBound(modules: readonly CapabilityModule[], bound: Readonly<Record<string, unknown>>, ctx?: AvailabilityContext): CapabilityDescriptor[];
@@ -98,7 +98,6 @@ export declare const MOTION_CMD: {
98
98
  * payload key AND pass mChannel 0 explicitly; the two are separate choices, not linked.)
99
99
  *
100
100
  * ⚠️ Replay + readback confirmed on a HomeBase-attached T8425 (1719 `0`→`1`→`0`), NOT byte-captured.
101
- * Provenance is `apk`, not `verified`: a divergent-but-also-accepted frame can't be ruled out.
102
101
  */
103
102
  readonly HUMAN_ONLY_AT_NIGHT: 1719;
104
103
  /**
@@ -112,7 +111,7 @@ export declare const MOTION_CMD: {
112
111
  * form, so a feature that is on reads as off.
113
112
  * Decoded by {@link decodeRadarWdSwitch} to match the app.
114
113
  *
115
- * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured. Provenance `apk`.
114
+ * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured.
116
115
  */
117
116
  readonly LOITERING_DETECTION: 2706;
118
117
  /**
@@ -354,7 +353,7 @@ export declare const MOTION_MEMBERS: {
354
353
  readonly param: 1719;
355
354
  readonly type: "bool";
356
355
  readonly kind: "boolean";
357
- readonly provenance: "apk";
356
+ readonly provenance: "verified";
358
357
  readonly description: string;
359
358
  readonly requires: readonly [1719];
360
359
  readonly write: (v: string | number | boolean, ctx: CommandContext) => Command;
@@ -367,7 +366,7 @@ export declare const MOTION_MEMBERS: {
367
366
  readonly param: 2706;
368
367
  readonly type: "bool";
369
368
  readonly kind: "boolean";
370
- readonly provenance: "apk";
369
+ readonly provenance: "verified";
371
370
  readonly coerce: (raw: string | number | boolean) => boolean;
372
371
  readonly description: string;
373
372
  readonly requires: readonly [2706];
@@ -27,7 +27,7 @@ export declare const SIREN_CMD: {
27
27
  readonly ALARM_TEST: 1826;
28
28
  /** Manually stop a sounding alarm (app `APP_CMD_SIREN_SENSOR_MANUAL_STOP_ALARM`). */
29
29
  readonly MANUAL_STOP: 1871;
30
- /** Station-family HomeBase duration alarm, verified live on T8010 (app `SET_TONE_FILE`). */
30
+ /** Station-family HomeBase duration alarm, verified live on T8010 and T8030 (app `SET_TONE_FILE`). */
31
31
  readonly HOMEBASE_TONE: 1201;
32
32
  /** HomeBase alarm volume percentage (app `CMD_SET_HUB_SPK_VOLUME`). */
33
33
  readonly HUB_SPK_VOLUME: 1235;
@@ -5,10 +5,10 @@
5
5
  * Two namespaces (params are per-transport, NOT globally unique):
6
6
  * - SECURITY_PARAMS — eufy P2P param space (ids 1000+). **Membership is the observation**: an id is
7
7
  * listed only because the sweep saw it on a real owned device, so the id is real/accepted.
8
- * `provenance` is the trust of the NAME/meaning: "verified" (our captures) > "apk" (the app's own
9
- * decompiled constant name) > "guessed" (no name source — needs toggle-diff).
8
+ * `provenance` is the trust of the NAME/meaning; the tiers are defined on `PropertySource` in
9
+ * `types.ts`.
10
10
  * - CLEAN_PARAMS — RoboVac Tuya DP space (ids 1 and above), names from the cloud
11
- * `get_product_data_point` data_point_list (provenance "mega" — authoritative).
11
+ * `get_product_data_point` data_point_list, hence provenance `mega`.
12
12
  *
13
13
  * Which models reported an id, and the capture that named it, are in the commit that adds the entry.
14
14
  */
@@ -104,18 +104,17 @@ export type ValueKind = KnownValueKind | (string & {});
104
104
  */
105
105
  export declare function isKnownValueKind(kind: ValueKind): kind is KnownValueKind;
106
106
  /**
107
- * Trust provenance of a property's `param_type` mapping, most-trusted first:
108
- * - `mega` — confirmed against the live mega API / a real device's reported params.
109
- * - `apk` — extracted from the v6 app itself (the ids the app actually sends — authoritative).
110
- * - `verified` — confirmed by our own capture/observation.
111
- * - `guessed` — a plausible placeholder; lowest trust.
107
+ * How much a param's name and meaning can be trusted, most-trusted first. This is the one definition;
108
+ * the param dictionary and every `provenance` field use it.
109
+ * - `mega` — the vendor's cloud names it: its data-point catalog (`get_product_data_point`), or a
110
+ * reported value that matches what the cloud record already says (a model name or code).
111
+ * - `verified` — confirmed on a real device by this project: our own capture or observation, or
112
+ * identified by someone who has the hardware.
113
+ * - `apk` — the v6 app's own decompiled constant name, not yet confirmed on a device.
114
+ * - `guessed` — no name source; a plausible placeholder until a toggle-diff settles it.
112
115
  *
113
- * This project never relies on a third-party reverse-engineering project as a source of trust —
114
- * every id/behavior we ship is grounded in the app's
115
- * own decompiled code (`apk`) or our own capture/observation (`verified`), never someone else's
116
- * unverified guess. Absent provenance is treated as `guessed`.
117
- *
118
- * Provenance of a property definition — an internal trust label used when curating the model.
116
+ * A third-party reverse-engineering project is never a source: every name we ship comes from the
117
+ * vendor's cloud, our own observation or the app itself. Absent provenance is treated as `guessed`.
119
118
  * @internal
120
119
  */
121
120
  export type PropertySource = "mega" | "apk" | "verified" | "guessed";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The `algo_ecdh` passport steps the eufy and Solix clients share byte for byte. Hosts, header sets, key
3
+ * caching, 2FA delivery and the `gtoken` id differ per app line and stay in each client.
4
+ */
5
+ import { type SessionEntry } from "../../core/index.js";
6
+ /** The credential fields every `/passport/login` body starts with; each client adds its own challenge fields. */
7
+ export declare function loginCredentials(email: string, password: string, country: string): Record<string, unknown>;
8
+ /**
9
+ * Read a decrypted `/passport/login` reply; `undefined` when it carries no id or no token. `twoFactorPending`
10
+ * is true while `fa_info.info` is non-empty and false once 2FA is satisfied.
11
+ */
12
+ export declare function readLoginReply(data: Record<string, unknown>): {
13
+ userId: string;
14
+ accountUserId: string | undefined;
15
+ authToken: string;
16
+ geoKey: string | undefined;
17
+ tokenExpiresAt: number;
18
+ twoFactorPending: boolean;
19
+ } | undefined;
20
+ /** The headers that mark `encBody` as `algo_ecdh`-encrypted under `entry` and sign it. */
21
+ export declare function signedHeaders(entry: SessionEntry, encBody: string): Record<string, string>;
@@ -3,12 +3,11 @@
3
3
  * eufy client uses.
4
4
  *
5
5
  * Why this is separate from the eufy device client: Solix shares Anker's `algo_ecdh` passport (so
6
- * {@link prepareKeyExchange} / {@link encryptLoginPassword} / {@link signRequest} are reused verbatim
7
- * for the login handshake) but exposes a different device backend — its own `app-name`, host, and
8
- * bootstrap key (`SOLIX_APP_NAME`, `SOLIX_DEFAULT_API_HOST`, {@link SOLIX_LOCAL_KEY_HEX}) —
9
- * and its authenticated resource reads are PLAIN JSON, carrying only the auth token and a
10
- * `gtoken = md5(user_id)`, with no per-request encryption or signature. This client therefore does
11
- * the encrypted passport handshake to obtain a token, then makes plain authenticated reads.
6
+ * {@link prepareKeyExchange} and the steps in `./passport.ts` are shared with it) but exposes a different device
7
+ * backend — its own `app-name`, host, and bootstrap key (`SOLIX_APP_NAME`, `SOLIX_DEFAULT_API_HOST`,
8
+ * {@link SOLIX_LOCAL_KEY_HEX}) — and its authenticated resource reads are PLAIN JSON, carrying only the auth token
9
+ * and a `gtoken = md5(user_id)`, with no per-request encryption or signature. This client therefore does the
10
+ * encrypted passport handshake to obtain a token, then makes plain authenticated reads.
12
11
  *
13
12
  * This is the wire client (transport layer): it returns the vendor's typed JSON as received. Building
14
13
  * those records into capability-driven `SolixDevice` models is the model layer's job — see
@@ -99,28 +99,35 @@ export declare class SecureMqtt extends EventEmitter implements RealtimeTranspor
99
99
  private open;
100
100
  /**
101
101
  * Subscribe every inbound leg this device's line uses (see `topics.ts` — one `/res` for most lines,
102
- * four topics for `eufy_life`).
102
+ * four topics for `eufy_life` and the clean line).
103
103
  *
104
- * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
105
- * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
106
- * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
107
- * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
108
- * line that grants its state channel but refuses (say) the OTA leg still works.
104
+ * The grants are INSPECTED, not assumed: AWS IoT refuses a policy-denied filter with a
105
+ * {@link SUBACK_FAILURE} grant rather than failing the connection. A denied topic is reported via
106
+ * `error` naming the credential scope; only an all-denied device throws, so a line that grants its
107
+ * state channel but refuses (say) a business leg still works.
109
108
  */
110
109
  subscribeDevice(device: EufyDevice): Promise<void>;
111
110
  /**
112
111
  * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
113
- * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
114
- * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
115
- * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
112
+ * is dropped from the result instead of throwing — callers that need every leg check the returned
113
+ * list. Used by lines whose topic vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix
114
+ * `dt/{app}/{pn}/{sn}`).
116
115
  */
117
116
  subscribe(topics: string[]): Promise<string[]>;
118
117
  /**
119
- * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
120
- * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
121
- * this split and layer their own policy (drop vs report) on top.
118
+ * Subscribe each filter in a SUBSCRIBE of its own and split the outcome into granted vs refused.
119
+ *
120
+ * One request per filter because the MQTT engine treats a SUBACK as all-or-nothing: one refused grant
121
+ * rejects the whole request, and it forgets EVERY filter of that request for resubscription after a
122
+ * reconnect — so a batch holding one denied leg would lose the granted legs on the next drop. Alone,
123
+ * a refused filter costs only itself. A rejection that carries no SUBACK is a transport failure and
124
+ * is thrown.
125
+ *
126
+ * The cost is one SUBSCRIBE and one SUBACK per filter instead of one per device — four for a
127
+ * four-leg line. They are sent concurrently, so the wall-clock cost is one round trip. Folding them
128
+ * back into one request brings back the lost resubscription.
122
129
  */
123
- private partitionGrants;
130
+ private subscribeEach;
124
131
  /**
125
132
  * Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
126
133
  * is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the
@@ -59,6 +59,12 @@ export interface AdtsHeader {
59
59
  * nothing about whether the frame's payload has arrived yet; that is the scanner's job.
60
60
  */
61
61
  export declare function parseAdtsHeader(buf: Buffer, offset?: number): AdtsHeader | undefined;
62
+ /**
63
+ * The 7-byte ADTS header (no CRC, buffer fullness "variable") that frames one raw AAC-LC, 16 kHz, mono
64
+ * access unit of `payloadLength` bytes, so that header and payload together read back through
65
+ * {@link parseAdtsHeader} as a supported frame.
66
+ */
67
+ export declare function buildAdtsHeader(payloadLength: number): Buffer;
62
68
  /**
63
69
  * Whether a header describes the audio parameters the device's path is fixed at — AAC-LC, 16 kHz,
64
70
  * mono. A stream at any other rate or channel count is rejected rather than resampled: the device has
@@ -22,6 +22,11 @@ export declare const ResponseMessageType: {
22
22
  readonly LOOKUP_ADDR: Buffer<ArrayBuffer>;
23
23
  readonly LOOKUP_ADDR2: Buffer<ArrayBuffer>;
24
24
  readonly CAM_ID: Buffer<ArrayBuffer>;
25
+ /**
26
+ * The device's own address record, sent in answer to CHECK_CAM ahead of CAM_ID: its 20-byte device id, one
27
+ * record in `encodeSelfAddress`'s shape naming the address it answers from, then 8 zero bytes (44-byte payload).
28
+ */
29
+ readonly CAM_ADDR: Buffer<ArrayBuffer>;
25
30
  readonly TURN_SERVER_CAM_ID: Buffer<ArrayBuffer>;
26
31
  readonly PING: Buffer<ArrayBuffer>;
27
32
  readonly PONG: Buffer<ArrayBuffer>;
@@ -149,6 +154,14 @@ export declare function buildStringCommandPayload(value: string, channel?: numbe
149
154
  * length prefix ({@link stringWithLength}).
150
155
  */
151
156
  export declare function buildIntStringCommandPayload(value: number, valueSub: number, strValue: string, channel?: number, key?: Buffer, encType?: number): Buffer;
157
+ /**
158
+ * Build a **string-pair** command body: five zero bytes, then `strValue` and `strValueSub`, each in the
159
+ * 128-byte-chunk length form ({@link stringWithLength}), AES-128-ECB encrypted (level-1) like
160
+ * {@link buildStringCommandPayload} when `key` is given. `CMD_DOWNLOAD_VIDEO` (1024) takes this shape on a
161
+ * HomeBase 2: `strValue` = the recording's path on the station, `strValueSub` = the station admin
162
+ * `account_id`, on the camera's channel.
163
+ */
164
+ export declare function buildStringPairCommandPayload(strValue: string, strValueSub: string, channel?: number, key?: Buffer, encType?: number): Buffer;
152
165
  /**
153
166
  * Build a command body around an ALREADY-encrypted (or plaintext) `data` buffer with an explicit
154
167
  * `signCode` — used for level-2 (`signCode 8`, AES-256-GCM) commands like the media-start 1350 the
@@ -121,6 +121,8 @@ export declare class P2PCommandRouter {
121
121
  private readonly talkbacks;
122
122
  /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
123
123
  private readonly cipherKeyCache;
124
+ /** The recording download in flight per station; a station serves one at a time. */
125
+ private readonly recordingDownloads;
124
126
  constructor(deps: P2PRouterDeps);
125
127
  /** Forward one P2P failure once even when both the session listener and startup waiter observe it. */
126
128
  private reportError;
@@ -237,6 +239,12 @@ export declare class P2PCommandRouter {
237
239
  * while its own session is still serving.
238
240
  */
239
241
  private makeSession;
242
+ /**
243
+ * The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
244
+ * answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
245
+ * cached.
246
+ */
247
+ private cipherKeyFor;
240
248
  /**
241
249
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
242
250
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -269,6 +277,27 @@ export declare class P2PCommandRouter {
269
277
  * start has no level-1 form for.
270
278
  */
271
279
  mediaProviderFor(sn: string): MediaProvider;
280
+ /**
281
+ * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
282
+ * and frame formats are confirmed.
283
+ */
284
+ private downloadsRecordings;
285
+ /**
286
+ * Download one recording a HomeBase 2 holds for an attached camera, then decode it
287
+ * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
288
+ * on the camera's channel, so a download starts only once the previous one has drained
289
+ * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
290
+ * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
291
+ * turn is held until the transfer drains.
292
+ */
293
+ private downloadRecording;
294
+ /** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
295
+ private waitTurn;
296
+ /**
297
+ * Start one recording download, once the station's previous download has drained: the decoded recording,
298
+ * and when the station is done sending it.
299
+ */
300
+ private startRecordingDownload;
272
301
  /**
273
302
  * Open a {@link Talkback} on a device's camera channel.
274
303
  *
@@ -95,6 +95,8 @@ export declare class P2PSession extends EventEmitter {
95
95
  private connected;
96
96
  private connecting;
97
97
  private closed;
98
+ /** Whether a short receive buffer has been reported, so a reconnect does not repeat the same warning. */
99
+ private receiveBufferReported;
98
100
  private connectAddress?;
99
101
  private seqNumber;
100
102
  /**
@@ -121,8 +123,8 @@ export declare class P2PSession extends EventEmitter {
121
123
  private audioStalled;
122
124
  private audioRetransmitTimer?;
123
125
  private lastPongData?;
124
- /** When this connection last received a PONG — `undefined` until the first, see {@link pathSilentMs}. */
125
- private lastPongAt?;
126
+ /** When the selected peer last answered outbound P2P traffic on this connection. */
127
+ private lastPeerAt?;
126
128
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
127
129
  private pathStaleTraced;
128
130
  private lookupTimer?;
@@ -134,7 +136,10 @@ export declare class P2PSession extends EventEmitter {
134
136
  private cloudLookup?;
135
137
  /** Local outbound IPv4 reported inside LOOKUP_WITH_KEY requests. */
136
138
  private selfHost?;
137
- /** In-flight multi-datagram frame per data channel (see onData). */
139
+ /**
140
+ * In-flight multi-datagram frame per data channel (see reassemble): the payload gathered so far under its
141
+ * parsed header, or, without a header, the start of a frame header cut by the datagram boundary.
142
+ */
138
143
  private readonly pendingByDataType;
139
144
  /** Last delivered datagram sequence number per data type. */
140
145
  private readonly lastSeqByType;
@@ -182,15 +187,15 @@ export declare class P2PSession extends EventEmitter {
182
187
  /**
183
188
  * How long this connection's path has been silent, or nothing where it has never answered.
184
189
  *
185
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
186
- * that has said nothing either way.
190
+ * PONG and ACK from the selected peer prove the path answers outbound traffic. `undefined` means no such reply
191
+ * has arrived since connection, so silence alone does not establish a stale path.
187
192
  */
188
193
  get pathSilentMs(): number | undefined;
189
194
  /**
190
195
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
191
196
  *
192
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
193
- * not known to be dead, so it answers true.
197
+ * False where selected-peer replies arrived and then stopped for {@link PATH_SILENCE_MS}. A connection with
198
+ * no post-connect reply is not known to be dead, so it answers true.
194
199
  *
195
200
  * Traces the silence once per connection, on the read that first observes it.
196
201
  */
@@ -271,6 +276,14 @@ export declare class P2PSession extends EventEmitter {
271
276
  */
272
277
  private decryptLevel2;
273
278
  get isConnected(): boolean;
279
+ /**
280
+ * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and warn once per session when it grants
281
+ * less or refuses.
282
+ *
283
+ * The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
284
+ * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
285
+ */
286
+ private requestReceiveBuffer;
274
287
  /** Open the socket and start the lookup → hole-punch handshake. */
275
288
  connect(): Promise<void>;
276
289
  /** Cached across every `P2PSession` in this process — the local outbound IPv4 doesn't vary by
@@ -313,6 +326,9 @@ export declare class P2PSession extends EventEmitter {
313
326
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
314
327
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
315
328
  * as a cause.
329
+ *
330
+ * CAM_ADDR is recognised and dropped: it names the address the device is already answering from, and the
331
+ * CAM_ID that follows it is what completes the connect.
316
332
  */
317
333
  private onMessage;
318
334
  private beginCheckCam;
@@ -397,6 +413,8 @@ export declare class P2PSession extends EventEmitter {
397
413
  * `strValue` (admin `account_id`). See {@link buildIntStringCommandPayload}. Fire-and-forget.
398
414
  */
399
415
  sendIntStringCommand(commandType: number, value: number, valueSub: number, strValue: string, channel?: number): void;
416
+ /** Send a level-1 {@link buildStringPairCommandPayload} command on `channel`. */
417
+ sendStringPairCommand(commandType: number, strValue: string, strValueSub: string, channel: number): void;
400
418
  /**
401
419
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
402
420
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -497,11 +515,6 @@ export declare class P2PSession extends EventEmitter {
497
515
  * camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
498
516
  */
499
517
  private sendMediaPayloadLevel2;
500
- /**
501
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
502
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
503
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
504
- */
505
518
  /**
506
519
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
507
520
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -525,6 +538,11 @@ export declare class P2PSession extends EventEmitter {
525
538
  * key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
526
539
  */
527
540
  private rsaModulus;
541
+ /**
542
+ * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ seq(4) ‖ ciphertext`,
543
+ * AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of `decryptLevel2`.
544
+ * The 4-byte sub-header is cleartext (skipped on decrypt); `seq` counts up from {@link LEVEL2_SEQ_BASE}.
545
+ */
528
546
  private encryptLevel2;
529
547
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
530
548
  private sendCommand;
@@ -548,6 +566,10 @@ export declare class P2PSession extends EventEmitter {
548
566
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
549
567
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
550
568
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
569
+ *
570
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
571
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
572
+ * one's buffer and answered as its rows.
551
573
  */
552
574
  queryDatabase(table: string, opts?: {
553
575
  accountId?: string;
@@ -575,6 +597,25 @@ export declare class P2PSession extends EventEmitter {
575
597
  accountId?: string;
576
598
  channel?: number;
577
599
  }): void;
600
+ /**
601
+ * Query one on-station table and answer its rows, once the reply is whole.
602
+ *
603
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
604
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
605
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
606
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
607
+ * complete reply.
608
+ *
609
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
610
+ * session throws, so a second reply cannot land in this buffer.
611
+ */
612
+ readDatabase(table: string, opts?: {
613
+ accountId?: string;
614
+ timeoutMs?: number;
615
+ signal?: AbortSignal;
616
+ }): Promise<unknown[]>;
617
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
618
+ private dbReadInFlight;
578
619
  /**
579
620
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
580
621
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -643,7 +684,13 @@ export declare class P2PSession extends EventEmitter {
643
684
  */
644
685
  private abandonHole;
645
686
  private clearReorderTimer;
646
- /** Reassemble one in-sequence datagram body into logical frames. */
687
+ /**
688
+ * Reassemble one in-sequence datagram body into logical frames.
689
+ *
690
+ * Frames are packed back to back, so a frame header can itself be cut by a datagram boundary. The start
691
+ * of a header left at the end of a datagram is carried into the next one rather than discarded; dropping
692
+ * it would lose that frame and every frame after it until a datagram happened to begin on a header.
693
+ */
647
694
  private reassemble;
648
695
  /**
649
696
  * Forget where each data type's sequence numbering had reached, and drop any half-reassembled frame.
@@ -0,0 +1,54 @@
1
+ import { type RecordingDownload } from "../../core/contracts.js";
2
+ import type { P2PSession } from "./p2p-session.js";
3
+ /** Data type a station sends a recording's frames on: the binary channel (`0xd1 0x03`), not live video's. */
4
+ export declare const RECORDING_DATA_TYPE = 3;
5
+ /**
6
+ * Most frame bytes a transfer holds. The station sends faster than real time, so the time bounds alone do
7
+ * not bound memory; an event recording is far below this.
8
+ */
9
+ export declare const MAX_TRANSFER_BYTES: number;
10
+ /** One recording frame as received, in arrival order. */
11
+ export interface RecordingFrame {
12
+ commandId: number;
13
+ signCode: number;
14
+ raw: Buffer;
15
+ }
16
+ /**
17
+ * The path a HomeBase 2 stores a camera's recording under: the camera's channel, two digits, and the
18
+ * recording name the event push carries. Answers `undefined` for a name that is not the station's
19
+ * fourteen-digit `yyyyMMddHHmmss` form. The name comes from a push, so the test stays anchored and
20
+ * digits-only: no `..`, slash or other character that could reach another path on the station gets through.
21
+ */
22
+ export declare function homeBase2RecordingPath(channel: number, recording: string): string | undefined;
23
+ /** One recording transfer on a station session. */
24
+ export interface RecordingTransfer {
25
+ /** The recording's frames, in arrival order. Rejects as {@link receiveRecording} describes. */
26
+ frames: Promise<RecordingFrame[]>;
27
+ /** Resolves once the station has stopped sending this recording, which can be after `frames` rejected. */
28
+ drained: Promise<void>;
29
+ }
30
+ /**
31
+ * Request one recording and collect its frames until `CMD_DOWNLOAD_FINISH`. Only frames on
32
+ * {@link RECORDING_DATA_TYPE} tagged with the camera's channel belong to it, so a live stream open on the same
33
+ * station is never mixed in.
34
+ *
35
+ * `frames` rejects with {@link RecordingDownloadError}: `no-data` when nothing arrives within
36
+ * {@link FIRST_FRAME_WAIT_MS}, and `incomplete` when the transfer ends without its finish frame, on a
37
+ * {@link IDLE_END_MS} silence, on `timeoutMs` or on {@link MAX_TRANSFER_BYTES}. An abort rejects it at once
38
+ * with the signal's reason. After a time bound, the byte ceiling or an abort, the transfer stops collecting
39
+ * but keeps listening until the station finishes or goes quiet, and only then resolves `drained`.
40
+ */
41
+ export declare function receiveRecording(session: P2PSession, request: {
42
+ path: string;
43
+ accountId: string;
44
+ channel: number;
45
+ timeoutMs?: number;
46
+ signal?: AbortSignal;
47
+ }): RecordingTransfer;
48
+ /**
49
+ * Decode a recording's frames, in arrival order, into elementary streams. Keyframes go through one
50
+ * {@link VideoFrameDecoder}, whose media key then opens the audio; a keyframe that does not open under
51
+ * `eccPrivateKeyHex`, and every audio frame before the first media key, are dropped. Rejects with
52
+ * {@link RecordingDownloadError} `undecodable` when no video frame decodes.
53
+ */
54
+ export declare function decodeRecording(frames: readonly RecordingFrame[], eccPrivateKeyHex: string): RecordingDownload;
@@ -16,7 +16,7 @@ export interface SharedLiveSourceOptions {
16
16
  makeStream: (ctx: {
17
17
  reassertWanted: () => boolean;
18
18
  }) => LiveStreamHandle;
19
- /** No-consumer grace before teardown (default 8000ms). Distinct from the stream's keepalive. */
19
+ /** No-consumer grace before teardown (default 8000ms; 0 schedules teardown next tick). Distinct from the stream's keepalive. */
20
20
  lingerMs?: number;
21
21
  /** Per-consumer bounded queue depth; overflow → drop-to-keyframe (default 900 ≈ 30s @ 30fps). */
22
22
  maxQueue?: number;
@@ -1,3 +1,5 @@
1
+ /** AAD used for the AES-256-GCM body cipher of every video frame, and of a recording's audio. */
2
+ export declare const VIDEO_GCM_AAD: Buffer<ArrayBuffer>;
1
3
  /** Parsed fields of a `CMD_VIDEO_FRAME` 22-byte header. */
2
4
  export interface VideoFrameHeader {
3
5
  /**
@@ -24,6 +24,8 @@ export declare class StoredImageCache {
24
24
  clear(): void;
25
25
  private pump;
26
26
  private complete;
27
+ /** Schedule the next attempt after a 404; answers whether one was scheduled. */
28
+ private retryLater;
27
29
  private isValidJpeg;
28
30
  private diagnose;
29
31
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mega-yfue/eufy-sdk",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0-beta.20",
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",