@mega-yfue/eufy-sdk 0.4.0-beta.3 → 0.4.0-beta.31

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
  /**
@@ -19,7 +19,7 @@ export { SirenVolume, type SirenVolumeValue, SirenAlarmDuration, type SirenAlarm
19
19
  */
20
20
  export { unobservableMembers, unreflectedMembers } from "./members.js";
21
21
  export { Watermark, type WatermarkValue, NightVision, type NightVisionValue, RecordingQuality, type RecordingQualityName, RECORDING_QUALITY_TIERS, StreamingQuality, type StreamingQualityName, STREAMING_QUALITY_TIERS, resolveStreamingQuality, resolveStreamingQualityTier, resolveRecordingQuality, resolveRecordingQualityValue, resolveRecordingQualityTier, } from "./camera.js";
22
- export { WorkingMode, PowerSource, resolveWorkingMode, resolveWorkingModeValue, WORKING_MODE_MAPS, type WorkingModeName, type PowerSourceName, } from "./battery.js";
22
+ export { WorkingMode, PowerSource, resolveWorkingMode, resolveWorkingModeValue, cameraPowerTier, WORKING_MODE_MAPS, type WorkingModeName, type PowerSourceName, } from "./battery.js";
23
23
  export { type DpCatalog, EMPTY_DP_CATALOG } from "./dp-catalog.js";
24
24
  import type { PtzActions } from "./ptz.js";
25
25
  import type { LightActions } from "./light.js";
@@ -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
@@ -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
  *
@@ -461,9 +490,9 @@ export declare class P2PCommandRouter {
461
490
  *
462
491
  * Reached two ways, both idempotent: the session's own `close` event, when it died while still the
463
492
  * station's registered session, and {@link SessionManagerOpts.onAutoClose}, when the manager closed it
464
- * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} disposes its own
465
- * sources first, and {@link replaceUnreachableSession} keeps its source alive on purpose to rewarm it
466
- * on the replacement session.
493
+ * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} and
494
+ * {@link resolveSession} dispose their own sources first, while {@link replaceUnreachableSession} keeps
495
+ * its source alive on purpose to rewarm it on the replacement session.
467
496
  */
468
497
  private tearDownStation;
469
498
  /**
@@ -621,9 +650,10 @@ export declare class P2PCommandRouter {
621
650
  *
622
651
  * A session whose {@link P2PSession.pathAnswering} is false is closed and re-resolved before it is handed
623
652
  * over: the station answers every heartbeat, so a path silent past several of them is gone. A session
624
- * reporting nothing about its path is not reporting that evidence and is handed over as it is. Replaced at
625
- * most once per resolution, so a station whose replacement is silent too is returned rather than closed
626
- * again.
653
+ * reporting nothing about its path is not reporting that evidence and is handed over as it is. Cached live
654
+ * sources on the replaced session are discarded before closing it (see {@link tearDownStation}).
655
+ * Replaced at most once per resolution, so a station whose replacement is silent too is returned rather
656
+ * than closed again.
627
657
  */
628
658
  private resolveSession;
629
659
  /**
@@ -71,11 +71,16 @@ export type LiveTrace =
71
71
  * an address to ask. A connect that had one channel failed for that channel's reason alone, and a connect
72
72
  * that had neither could not have succeeded — outcomes a station that is switched off is otherwise
73
73
  * indistinguishable from, because nothing else in a failed connect states what was even attempted.
74
+ *
75
+ * `cloudMissing` names the input the cloud channel lacked, present exactly when `cloud` is false: `dsk-key`
76
+ * where no key was held for the station, `cloud-addresses` where its record named no address to ask. The key
77
+ * is named where both are absent.
74
78
  */
75
79
  | {
76
80
  phase: "lookup-channels";
77
81
  local: boolean;
78
82
  cloud: boolean;
83
+ cloudMissing?: "dsk-key" | "cloud-addresses";
79
84
  }
80
85
  /**
81
86
  * Work on a station is holding for its session to connect, with the milliseconds it will wait.
@@ -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?;
@@ -185,15 +187,15 @@ export declare class P2PSession extends EventEmitter {
185
187
  /**
186
188
  * How long this connection's path has been silent, or nothing where it has never answered.
187
189
  *
188
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
189
- * 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.
190
192
  */
191
193
  get pathSilentMs(): number | undefined;
192
194
  /**
193
195
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
194
196
  *
195
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
196
- * 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.
197
199
  *
198
200
  * Traces the silence once per connection, on the read that first observes it.
199
201
  */
@@ -274,6 +276,14 @@ export declare class P2PSession extends EventEmitter {
274
276
  */
275
277
  private decryptLevel2;
276
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;
277
287
  /** Open the socket and start the lookup → hole-punch handshake. */
278
288
  connect(): Promise<void>;
279
289
  /** Cached across every `P2PSession` in this process — the local outbound IPv4 doesn't vary by
@@ -316,6 +326,9 @@ export declare class P2PSession extends EventEmitter {
316
326
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
317
327
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
318
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.
319
332
  */
320
333
  private onMessage;
321
334
  private beginCheckCam;
@@ -400,6 +413,8 @@ export declare class P2PSession extends EventEmitter {
400
413
  * `strValue` (admin `account_id`). See {@link buildIntStringCommandPayload}. Fire-and-forget.
401
414
  */
402
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;
403
418
  /**
404
419
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
405
420
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -500,11 +515,6 @@ export declare class P2PSession extends EventEmitter {
500
515
  * camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
501
516
  */
502
517
  private sendMediaPayloadLevel2;
503
- /**
504
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
505
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
506
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
507
- */
508
518
  /**
509
519
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
510
520
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -528,6 +538,11 @@ export declare class P2PSession extends EventEmitter {
528
538
  * key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
529
539
  */
530
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
+ */
531
546
  private encryptLevel2;
532
547
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
533
548
  private sendCommand;
@@ -551,6 +566,10 @@ export declare class P2PSession extends EventEmitter {
551
566
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
552
567
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
553
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.
554
573
  */
555
574
  queryDatabase(table: string, opts?: {
556
575
  accountId?: string;
@@ -578,6 +597,25 @@ export declare class P2PSession extends EventEmitter {
578
597
  accountId?: string;
579
598
  channel?: number;
580
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;
581
619
  /**
582
620
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
583
621
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -630,7 +668,7 @@ export declare class P2PSession extends EventEmitter {
630
668
  * if the missing one arrives. A logical frame's payload spans datagrams that carry no header of their
631
669
  * own, so reassembling around a hole is impossible; waiting for it is what keeps the frame whole.
632
670
  *
633
- * Only once {@link REORDER_WAIT_MS} passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
671
+ * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
634
672
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
635
673
  */
636
674
  private onData;
@@ -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. The finish frame is taken on the camera's channel or channel 0.
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
  /**