@mega-yfue/eufy-sdk 0.4.0-beta.9 → 0.5.0-beta.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.
@@ -1,5 +1,5 @@
1
1
  import { type Surface } from "./members.js";
2
- import type { CapabilityModule, CommandContext } from "./types.js";
2
+ import type { AvailabilityContext, CapabilityModule, CommandContext } from "./types.js";
3
3
  import { type Command, type MediaProvider } from "../../core/contracts.js";
4
4
  /**
5
5
  * The P2P **feature-command ids** this camera capability drives (direct-binary switches + `1350`
@@ -8,8 +8,10 @@ import { type Command, type MediaProvider } from "../../core/contracts.js";
8
8
  * `transport/p2p/commands.ts`). Each entry notes its app `CommandType` name.
9
9
  */
10
10
  export declare const CAMERA_CMD: {
11
- /** Camera on/off. switch is inverted: camera ON ⇒ 0, OFF ⇒ 1. */
11
+ /** Camera on/off. Read and write polarity depend on the family and topology. */
12
12
  readonly CAMERA_ENABLE: 1035;
13
+ /** Wrapped T8410 power switch on HomeBase 3. */
14
+ readonly PRIVACY_MODE: 6250;
13
15
  /**
14
16
  * Camera status LED on/off — the "power/recording" indicator. The
15
17
  * app JS has a sibling param `1056` (`APP_CMD_LIVEVIEW_LED_SWITCH`) for the SAME UI "Status Light"
@@ -270,17 +272,17 @@ export type CameraActions = Surface<typeof CAMERA_MEMBERS> & {
270
272
  */
271
273
  export declare const CAMERA_MEMBERS: {
272
274
  /**
273
- * The READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
275
+ * The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
274
276
  * family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
275
277
  * 1035, standalone indoor/outdoor cams under 2001 OPEN_DEVICE with direct polarity, so 2001 is a
276
278
  * read-alias. Both verified live, and the write polarity is confirmed against the app's own frames.
277
279
  *
278
- * The read and the setter observe the SAME wire on every family — see `powerCommand` — which is what
279
- * makes this value track what it is told, and what lets `enablementReflection` confirm a write.
280
+ * T8410 on HomeBase 3 reports 1035 as an enable bit ("1" ⇒ ON). That reported state does not reliably
281
+ * reflect the privacy command. `readReflectsWrite` states this distinction.
280
282
  *
281
283
  * The privacy param (6250) is reported by the outdoor-PT family and by no other camera measured, and both
282
284
  * of its polarities are observed. It is deliberately NOT aliased here: it moved in the same step as 1035, so
283
- * the reading cannot say whether power and privacy are one state or two, and the app drives 1035 — so
285
+ * the reading cannot say whether power and privacy are one state or two — so
284
286
  * aliasing a second param could only fold two possible states into one getter for no gain.
285
287
  */
286
288
  readonly enabled: {
@@ -289,10 +291,12 @@ export declare const CAMERA_MEMBERS: {
289
291
  readonly kind: "boolean";
290
292
  readonly provenance: "verified";
291
293
  readonly invert: true;
294
+ readonly invertFor: (ctx: AvailabilityContext) => false | undefined;
292
295
  readonly readAliases: readonly [{
293
296
  readonly paramType: 2001;
294
297
  readonly invert: false;
295
298
  }];
299
+ readonly readReflectsWrite: (ctx: CommandContext) => boolean;
296
300
  readonly description: string;
297
301
  readonly observation: {
298
302
  readonly event: "cameraEnabledChanged";
@@ -512,10 +516,10 @@ export declare const CAMERA_MEMBERS: {
512
516
  readonly type: "bool";
513
517
  readonly kind: "boolean";
514
518
  readonly provenance: "verified";
515
- readonly readAvailable: (ctx: import("./types.js").AvailabilityContext) => boolean;
519
+ readonly readAvailable: (ctx: AvailabilityContext) => boolean;
516
520
  readonly readAliases: readonly [{
517
521
  readonly paramType: 1716;
518
- readonly available: (ctx: import("./types.js").AvailabilityContext) => boolean;
522
+ readonly available: (ctx: AvailabilityContext) => boolean;
519
523
  }];
520
524
  readonly requiresRead: true;
521
525
  readonly description: "Camera status LED. Video doorbells report the same state under their button-ring LED parameter.";
@@ -526,7 +530,7 @@ export declare const CAMERA_MEMBERS: {
526
530
  * value, and it exists only on a device bound to a provider. Each is declared once, with its
527
531
  * signature taken FROM {@link MediaProvider} — so a change there is a compile error here, not a drift.
528
532
  *
529
- * Every pull is refused where {@link CAMERA_MEMBERS.enabled} reads false, with {@link CameraDisabledError} —
533
+ * Every pull is refused where a reliable {@link CAMERA_MEMBERS.enabled} reads false, with {@link CameraDisabledError} —
530
534
  * `live`, `snapshotLive`, `record`, `openReadable` and `recordFragments` alike, since each opens media on a
531
535
  * camera that serves none. That reading is the on/off source; a live probe is not one, since a disabled
532
536
  * camera answers a start with audio and never a video frame.
@@ -552,6 +556,7 @@ export declare const CAMERA_MEMBERS: {
552
556
  } | undefined) => Promise<Buffer<ArrayBufferLike>>>;
553
557
  readonly openReadable: import("./members.js").ProvidedMember<"media", ((opts?: Parameters<NonNullable<MediaProvider["openReadable"]>>[0]) => Promise<import("node:stream").Readable>) | undefined>;
554
558
  readonly recordFragments: import("./members.js").ProvidedMember<"media", ((opts?: Parameters<NonNullable<MediaProvider["recordFragments"]>>[0]) => import("../../core/contracts.js").FragmentRecordingHandle) | undefined>;
559
+ readonly downloadRecording: import("./members.js").ProvidedMember<"media", ((opts: Parameters<NonNullable<MediaProvider["downloadRecording"]>>[0]) => Promise<import("../../core/contracts.js").RecordingDownload>) | undefined>;
555
560
  /**
556
561
  * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
557
562
  * 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[];
@@ -48,6 +48,8 @@ export interface ValueMember {
48
48
  enumValuesFor?: (ctx: AvailabilityContext) => Record<number, string> | undefined;
49
49
  provenance?: PropertySpec["provenance"];
50
50
  invert?: boolean;
51
+ /** Per-device read polarity; undefined retains the static default. */
52
+ invertFor?: (ctx: AvailabilityContext) => boolean | undefined;
51
53
  description: string;
52
54
  /** The wire, or absent for read-only. `undefined` from it = this value is not one we accept. */
53
55
  write?: (value: boolean | number | string, ctx: CommandContext) => Command | undefined;
@@ -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;
@@ -245,6 +245,8 @@ export interface AvailabilityContext {
245
245
  * cannot do what its name promises there.
246
246
  */
247
247
  homeBaseAttached?: boolean;
248
+ /** Serial of the covering station, when the device record names one. */
249
+ stationSerial?: string;
248
250
  }
249
251
  export interface CommandContext extends AvailabilityContext {
250
252
  /** Device channel (0 for standalone, `device_channel` on a HomeBase). */
@@ -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
@@ -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.
@@ -123,8 +123,8 @@ export declare class P2PSession extends EventEmitter {
123
123
  private audioStalled;
124
124
  private audioRetransmitTimer?;
125
125
  private lastPongData?;
126
- /** When this connection last received a PONG — `undefined` until the first, see {@link pathSilentMs}. */
127
- private lastPongAt?;
126
+ /** When the selected peer last answered outbound P2P traffic on this connection. */
127
+ private lastPeerAt?;
128
128
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
129
129
  private pathStaleTraced;
130
130
  private lookupTimer?;
@@ -187,15 +187,15 @@ export declare class P2PSession extends EventEmitter {
187
187
  /**
188
188
  * How long this connection's path has been silent, or nothing where it has never answered.
189
189
  *
190
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
191
- * 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.
192
192
  */
193
193
  get pathSilentMs(): number | undefined;
194
194
  /**
195
195
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
196
196
  *
197
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
198
- * 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.
199
199
  *
200
200
  * Traces the silence once per connection, on the read that first observes it.
201
201
  */
@@ -326,6 +326,9 @@ export declare class P2PSession extends EventEmitter {
326
326
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
327
327
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
328
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.
329
332
  */
330
333
  private onMessage;
331
334
  private beginCheckCam;
@@ -410,6 +413,8 @@ export declare class P2PSession extends EventEmitter {
410
413
  * `strValue` (admin `account_id`). See {@link buildIntStringCommandPayload}. Fire-and-forget.
411
414
  */
412
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;
413
418
  /**
414
419
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
415
420
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -430,8 +435,17 @@ export declare class P2PSession extends EventEmitter {
430
435
  * "direct" commands (e.g. CAMERA_SWITCH 1035) carry a binary struct, not JSON.
431
436
  */
432
437
  sendRawLevel2Bytes(payload: Buffer, channel: number, outerCmd?: number, signCode?: number): boolean;
433
- /** Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`. */
434
- stopLiveMedia(channel?: number, accountId?: string): void;
438
+ /**
439
+ * Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
440
+ * its runtime topology takes, as {@link startLiveMedia} selects its start:
441
+ * - `homeBaseAttached`: `CMD_SET_PAYLOAD` (1350) wrapping `{cmd:1004, mChannel:channel}` at level-2.
442
+ * - own-session at level-2: the direct 1004 frame whose entire plaintext is the camera channel as a
443
+ * `uint32`, the same 4-byte body as the attached talkback frames. The app sends this frame on closing
444
+ * an own-session camera's live view; the camera stops streaming on it within a second, and keeps
445
+ * streaming through the 1350-wrapped form.
446
+ * - without a level-2 key: the bare 1004 command.
447
+ */
448
+ stopLiveMedia(channel?: number, accountId?: string, homeBaseAttached?: boolean): void;
435
449
  /**
436
450
  * Open the device's talkback (host→device audio) path on a camera `channel`, after which
437
451
  * {@link sendAudioFrame} is accepted until {@link stopTalkback}. Two protocols, selected by runtime
@@ -510,11 +524,6 @@ export declare class P2PSession extends EventEmitter {
510
524
  * camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
511
525
  */
512
526
  private sendMediaPayloadLevel2;
513
- /**
514
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
515
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
516
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
517
- */
518
527
  /**
519
528
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
520
529
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -538,6 +547,11 @@ export declare class P2PSession extends EventEmitter {
538
547
  * key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
539
548
  */
540
549
  private rsaModulus;
550
+ /**
551
+ * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ seq(4) ‖ ciphertext`,
552
+ * AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of `decryptLevel2`.
553
+ * The 4-byte sub-header is cleartext (skipped on decrypt); `seq` counts up from {@link LEVEL2_SEQ_BASE}.
554
+ */
541
555
  private encryptLevel2;
542
556
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
543
557
  private sendCommand;
@@ -561,6 +575,10 @@ export declare class P2PSession extends EventEmitter {
561
575
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
562
576
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
563
577
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
578
+ *
579
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
580
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
581
+ * one's buffer and answered as its rows.
564
582
  */
565
583
  queryDatabase(table: string, opts?: {
566
584
  accountId?: string;
@@ -588,6 +606,25 @@ export declare class P2PSession extends EventEmitter {
588
606
  accountId?: string;
589
607
  channel?: number;
590
608
  }): void;
609
+ /**
610
+ * Query one on-station table and answer its rows, once the reply is whole.
611
+ *
612
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
613
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
614
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
615
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
616
+ * complete reply.
617
+ *
618
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
619
+ * session throws, so a second reply cannot land in this buffer.
620
+ */
621
+ readDatabase(table: string, opts?: {
622
+ accountId?: string;
623
+ timeoutMs?: number;
624
+ signal?: AbortSignal;
625
+ }): Promise<unknown[]>;
626
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
627
+ private dbReadInFlight;
591
628
  /**
592
629
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
593
630
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -640,7 +677,7 @@ export declare class P2PSession extends EventEmitter {
640
677
  * if the missing one arrives. A logical frame's payload spans datagrams that carry no header of their
641
678
  * own, so reassembling around a hole is impossible; waiting for it is what keeps the frame whole.
642
679
  *
643
- * Only once {@link REORDER_WAIT_MS} passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
680
+ * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
644
681
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
645
682
  */
646
683
  private onData;