@mega-yfue/eufy-sdk 0.6.0-beta.3 → 0.6.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.
Files changed (39) hide show
  1. package/dist/client/device-registry.d.ts +6 -0
  2. package/dist/client/eufy-mega.d.ts +98 -14
  3. package/dist/client/types.d.ts +12 -9
  4. package/dist/core/contracts.d.ts +17 -10
  5. package/dist/core/store.d.ts +12 -1
  6. package/dist/core/types.d.ts +4 -2
  7. package/dist/index.js +2853 -2175
  8. package/dist/index.js.map +4 -4
  9. package/dist/model/capabilities/access.d.ts +3 -2
  10. package/dist/model/capabilities/arming.d.ts +2 -1
  11. package/dist/model/capabilities/battery.d.ts +5 -1
  12. package/dist/model/capabilities/camera.d.ts +8 -1
  13. package/dist/model/capabilities/index.d.ts +1 -1
  14. package/dist/model/capabilities/members.d.ts +3 -1
  15. package/dist/model/capabilities/mower.d.ts +3 -2
  16. package/dist/model/capabilities/ptz.d.ts +11 -5
  17. package/dist/model/capabilities/vacuum-clean.d.ts +23 -0
  18. package/dist/model/device-family.d.ts +1 -1
  19. package/dist/model/device-types.d.ts +1 -0
  20. package/dist/transport/http/mega-client.d.ts +3 -2
  21. package/dist/transport/mqtt/biz-stream.d.ts +22 -25
  22. package/dist/transport/p2p/audio-frame.d.ts +13 -0
  23. package/dist/transport/p2p/codec.d.ts +2 -2
  24. package/dist/transport/p2p/command-router.d.ts +45 -13
  25. package/dist/transport/p2p/database-wire.d.ts +30 -0
  26. package/dist/transport/p2p/level2-settle.d.ts +9 -0
  27. package/dist/transport/p2p/live-stream.d.ts +34 -7
  28. package/dist/transport/p2p/live-trace.d.ts +6 -4
  29. package/dist/transport/p2p/p2p-session.d.ts +99 -15
  30. package/dist/transport/p2p/readable-egress.d.ts +3 -1
  31. package/dist/transport/p2p/recording-download.d.ts +95 -12
  32. package/dist/transport/p2p/shared-live-source.d.ts +9 -3
  33. package/dist/transport/p2p/video.d.ts +11 -5
  34. package/dist/transport/push/store.d.ts +7 -6
  35. package/dist/transport/rtc/command-router.d.ts +63 -3
  36. package/dist/transport/rtc/portal-packet.d.ts +3 -13
  37. package/dist/transport/rtc/session.d.ts +4 -2
  38. package/dist/transport/stored-image-cache.d.ts +14 -0
  39. package/package.json +1 -1
@@ -76,9 +76,10 @@ export declare function setJsonRaw(cmd: number, data: Record<string, unknown>, c
76
76
  export declare function setPayload(cmd: number, payload: Record<string, unknown>, ctx: CommandContext, mValue3?: number, channel?: number, form?: ScalarForm): Command;
77
77
  /**
78
78
  * Set a station-scoped scalar (132-byte body, no channel field) on an EXPLICIT channel — for the
79
- * HomeBase's own controls on the station broadcast channel 255 (alarm/speaker volume 1235).
79
+ * HomeBase's own controls on the station broadcast channel 255 (alarm/speaker volume 1235). Level-2
80
+ * only, unless `form` is `"auto"`, which seals it at the session's own level.
80
81
  */
81
- export declare function setStationScalar(cmd: number, value: number, channel: number): Command;
82
+ export declare function setStationScalar(cmd: number, value: number, channel: number, form?: "auto"): Command;
82
83
  /**
83
84
  * A one-line device descriptor for error messages — carries every identifier needed to reproduce or
84
85
  * triage from a log later (deviceType, model T-code, full serial, channel, codec), so a "capability
@@ -77,7 +77,8 @@ export declare const ARMING_CMD: {
77
77
  * was captured on holds a level-2 key and seals it level-2, while an own-session camera that never
78
78
  * negotiates one carries the same envelope level-1. See `armingCommand`.
79
79
  *
80
- * A T8410C takes the mode as a direct frame instead of this envelope; see {@link DIRECT_ARMING_MODELS}.
80
+ * A T8410C and a T8410 take the mode as a direct frame instead of this envelope; see
81
+ * {@link DIRECT_ARMING_MODELS} and {@link BROADCAST_ARMING_MODELS}.
81
82
  */
82
83
  readonly SET_ARMING: 1224;
83
84
  /**
@@ -144,6 +144,9 @@ export declare const BATTERY_MEMBERS: {
144
144
  * The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
145
145
  * device is battery-powered, which is also the fact `MediaProvider` reads to decide a stream needs a
146
146
  * power budget. Published flat as `battery` — `level` alone is claimed by `suction` too.
147
+ *
148
+ * Withheld on a device that resolved `lock`, whose own `battery` member reads the same param 1101 and
149
+ * owns the `battery` property there, so the two do not both claim it.
147
150
  */
148
151
  readonly level: {
149
152
  readonly param: 1101;
@@ -152,6 +155,7 @@ export declare const BATTERY_MEMBERS: {
152
155
  readonly unit: "%";
153
156
  readonly kind: "percent";
154
157
  readonly provenance: "verified";
158
+ readonly available: (ctx: AvailabilityContext) => boolean;
155
159
  readonly description: "Battery level 0-100 (verified: param 1101).";
156
160
  };
157
161
  /**
@@ -170,7 +174,7 @@ export declare const BATTERY_MEMBERS: {
170
174
  /**
171
175
  * 1293 is the READABLE indicator (6445 is write-only/cloud-hidden). Verified live on a T8124R via an
172
176
  * app-toggle before/after diff: 0 = Battery, 1 = External Solar Panel. Values beyond 0/1 (some models
173
- * report 5) are shown raw — `enumValues` only labels the two that are confirmed.
177
+ * report 5) read as `undefined` — `enumValues` holds only the two that are confirmed.
174
178
  *
175
179
  * The write is a `1350` SET_PAYLOAD, inner cmd 1293, `{charge_mode:N}`, `mValue3` 0 (the app uses 0,
176
180
  * not the cmd id), and the setter accepts a NAME as well as the index the getter answers.
@@ -439,6 +439,10 @@ export declare const CAMERA_MEMBERS: {
439
439
  * sensor omits it, and the wire accepts the value regardless — the device's own reported set is the
440
440
  * only statement of which of the three it has. `coerceEnumValue` rejects a value outside
441
441
  * {@link NightVision} rather than coercing it. The payload key on the wire is `night_sion`.
442
+ *
443
+ * 1277 carries more than these three on some models: the V6 app's per-model mappings put modes on
444
+ * 3, 5, 6 and 8 as well, and which model takes which mapping is not in the app bundle. A reported value
445
+ * outside the published options reads as `undefined` rather than as an unnamed number.
442
446
  */
443
447
  readonly nightVision: {
444
448
  readonly param: 1277;
@@ -475,6 +479,8 @@ export declare const CAMERA_MEMBERS: {
475
479
  *
476
480
  * `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
477
481
  * confirmed frame carries no such field; neither is inferred from the other.
482
+ *
483
+ * Not offered where the device reports the doorbell's own live-view quality (1705).
478
484
  */
479
485
  readonly streamingQuality: {
480
486
  readonly accepts: StreamingQualityName;
@@ -483,7 +489,7 @@ export declare const CAMERA_MEMBERS: {
483
489
  readonly kind: "enum";
484
490
  readonly enumValues: Readonly<Record<number, string>>;
485
491
  readonly provenance: "verified";
486
- readonly available: typeof isCameraCodec;
492
+ readonly available: (ctx: AvailabilityContext) => boolean;
487
493
  readonly args: readonly [{
488
494
  readonly name: "quality";
489
495
  readonly kind: "enum";
@@ -596,6 +602,7 @@ export declare const CAMERA_MEMBERS: {
596
602
  timeoutMs?: number;
597
603
  skipKeyframes?: number;
598
604
  } | undefined) => Promise<Buffer<ArrayBufferLike>>)>;
605
+ /** The T8530 serves its live feed as a Readable too; its other media pulls stay withheld. */
599
606
  readonly openReadable: import("./members.js").ProvidedMember<"media", false | ((opts?: Parameters<NonNullable<MediaProvider["openReadable"]>>[0]) => Promise<import("node:stream").Readable>) | undefined>;
600
607
  readonly recordFragments: import("./members.js").ProvidedMember<"media", false | ((opts?: Parameters<NonNullable<MediaProvider["recordFragments"]>>[0]) => import("../../core/contracts.js").FragmentRecordingHandle) | undefined>;
601
608
  readonly downloadRecording: import("./members.js").ProvidedMember<"media", false | ((opts: Parameters<NonNullable<MediaProvider["downloadRecording"]>>[0]) => Promise<import("../../core/contracts.js").RecordingDownload>) | undefined>;
@@ -388,7 +388,7 @@ export interface DeviceEventMap {
388
388
  * with the type. (Only capabilities whose module defines `actions()` appear.)
389
389
  */
390
390
  export interface DeviceActionMap {
391
- /** Pan/tilt/zoom control: `rotate(dir, step)` + `left`/`right`/`up`/`down`, `zoom`, preset() namespace (goto/preview/save/setDefault/delete/list/image). */
391
+ /** Pan/tilt/zoom control: `rotate(dir, step)` + `left`/`right`/`up`/`down`, `rotate360`, `zoom`, preset() namespace (goto/preview/save/setDefault/delete/list/image). */
392
392
  ptz: PtzActions;
393
393
  /** Floodlight/spotlight: `on`/`off`/`set`, `setBrightness`/`setColorTemp`/`setEnabled`, `setAutoSpotlight`. */
394
394
  light: LightActions;
@@ -576,12 +576,14 @@ export declare function memberWrite(name: string, m: ValueMember, value: boolean
576
576
  * is stored as a decoded object. Handing either through a getter typed `number` is the one lie this
577
577
  * table exists to prevent.
578
578
  *
579
+ * A number outside `values`, the options published for this device, answers `undefined`.
580
+ *
579
581
  * Exported so the ONE narrowing the getters use is also the one a property-change announcement carries.
580
582
  * A second conversion of the wire value is precisely how a payload comes to disagree with the getter
581
583
  * beside it, which would defeat the point of announcing the change at all.
582
584
  * @internal
583
585
  */
584
- export declare function narrow(type: PropertyValueType, read: CapabilityStateReader, prop: string): boolean | number | string | undefined;
586
+ export declare function narrow(type: PropertyValueType, read: CapabilityStateReader, prop: string, values?: Record<number, string>): boolean | number | string | undefined;
585
587
  /**
586
588
  * Both presence gates at once: the params a member requires, and its own {@link ValueMember.available}.
587
589
  *
@@ -34,9 +34,9 @@ export declare const MOWER_DP: {
34
34
  readonly SPARSE_LAWN_OPTIMIZATION: 141;
35
35
  };
36
36
  /** The `net_media_type` (DP 134) range, as the schema lists it. */
37
- export declare const MOWER_NETWORKS: readonly ["None", "Wifi", "Cellular"];
37
+ declare const MOWER_NETWORKS: readonly ["None", "Wifi", "Cellular"];
38
38
  /** The mower's network link (DP 134). */
39
- export type MowerNetwork = (typeof MOWER_NETWORKS)[number];
39
+ type MowerNetwork = (typeof MOWER_NETWORKS)[number];
40
40
  /**
41
41
  * Every `mower` read, declared once.
42
42
  *
@@ -228,3 +228,4 @@ export declare const MOWER_MEMBERS: {
228
228
  export type MowerActions = Surface<typeof MOWER_MEMBERS>;
229
229
  /** `mower` — robot-mower state from its Tuya data points. */
230
230
  export declare const MOWER: CapabilityModule;
231
+ export {};
@@ -5,7 +5,7 @@ import type { Command } from "../../core/contracts.js";
5
5
  * The P2P **feature-command ids** this pan-tilt capability drives. Capability-owned wire vocabulary
6
6
  * (transport forwards `cmd.param` opaquely; full id→name catalog in the generated
7
7
  * `transport/p2p/commands.ts`). Distinct from the {@link PTZ_ROTATE} direction map below (which is
8
- * `rotate_type` 1..4, not a command id).
8
+ * `rotate_type` 0..4, not a command id).
9
9
  *
10
10
  * Ids + envelopes are taken from the V6 app's own command builders (the `Set*Parser`/`*PositionsParser`
11
11
  * classes in the decompiled JS), which is this project's wire ground truth — NOT a third-party
@@ -135,9 +135,11 @@ export declare const PtzDirection: {
135
135
  readonly down: "down";
136
136
  };
137
137
  export type PtzDirection = (typeof PtzDirection)[keyof typeof PtzDirection];
138
- /** Pan-tilt rotate direction → `rotate_type` (the on-wire code). Not re-exported by the barrel —
139
- * stays internal to the capability. Confirmed on hardware (T8425): 1=left, 2=right, 3=up, 4=down. */
138
+ /** Pan-tilt rotate motion → `rotate_type` (the on-wire code). Not re-exported by the barrel —
139
+ * stays internal to the capability. Confirmed on hardware (T8425): 1=left, 2=right, 3=up, 4=down.
140
+ * `sweep` (0) is a full 360° pan (room scan), from the app's 360 button. */
140
141
  export declare const PTZ_ROTATE: {
142
+ readonly sweep: 0;
141
143
  readonly left: 1;
142
144
  readonly right: 2;
143
145
  readonly up: 3;
@@ -145,7 +147,7 @@ export declare const PTZ_ROTATE: {
145
147
  };
146
148
  /**
147
149
  * Pan-tilt rotate as a transport-neutral {@link Command}. CONFIRMED wire command (captured on
148
- * T8425 ch3 during live-view): `{"commandType":6030,"data":{"cmd_type":1,"rotate_type":1..4,
150
+ * T8425 ch3 during live-view): `{"commandType":6030,"data":{"cmd_type":1,"rotate_type":0..4,
149
151
  * "zoom":1.0}}` under the generic control wrapper `1700` on the camera channel. `cmd_type:1` =
150
152
  * move; `zoom` defaults to 1.0.
151
153
  *
@@ -153,7 +155,7 @@ export declare const PTZ_ROTATE: {
153
155
  * resolver picks the encryption level by topology (standalone → L1, HomeBase → L2). A PT cam can be
154
156
  * either, so the level is NOT fixed here.
155
157
  */
156
- export declare function rotateCommand(direction: PtzDirection, ctx: CommandContext, zoom?: number): Command;
158
+ export declare function rotateCommand(direction: keyof typeof PTZ_ROTATE, ctx: CommandContext, zoom?: number): Command;
157
159
  /**
158
160
  * Digital-zoom as a transport-neutral {@link Command}. The V6 app's `SetPictureZoomParser` builds a
159
161
  * `SET_PAYLOAD` (1350) sub-command `COMMAND_DUAL_CAMERA_ZOOM` with the crop-window payload
@@ -266,6 +268,10 @@ export declare const PTZ_MEMBERS: {
266
268
  readonly up: import("./members.js").MethodMember<() => Promise<void>>;
267
269
  /** Rotate one step down. */
268
270
  readonly down: import("./members.js").MethodMember<() => Promise<void>>;
271
+ /** Pan a full 360° (room scan). */
272
+ readonly rotate360: import("./members.js").MethodMember<() => Promise<void>> & {
273
+ available: (ctx: CommandContext) => boolean;
274
+ };
269
275
  /**
270
276
  * Zoom rides a SECOND (telephoto) lens, so it is offered only where the device shows zoom evidence —
271
277
  * otherwise `dev.ptz()?.zoom` would be a silent no-op on a single-lens pan-tilt camera.
@@ -280,6 +280,16 @@ export declare function encodeSelectZonesClean(mapId: number, zones: readonly Va
280
280
  * @internal
281
281
  */
282
282
  export declare function encodeSceneClean(sceneId: number): string;
283
+ /**
284
+ * Build one of the two DP 153 writes the vendor app sends when its map view opens: the time in
285
+ * microseconds, and on the second write field 2 set to `1`.
286
+ *
287
+ * Captured from the vendor app on the device's `cmd/…/req` leg, three times with the same layout. The
288
+ * product catalogue lists DP 153 as uplink-only (`WorkStatus`); the vendor app writes this message to it
289
+ * all the same. {@link VACUUM_CLEAN_MEMBERS.requestMap} dispatches both.
290
+ * @internal
291
+ */
292
+ export declare function encodeMapWatch(nowMs: number, watching: boolean): string;
283
293
  /**
284
294
  * Build a whole-floor auto clean carrying an `AutoClean` payload of one pass.
285
295
  * @internal
@@ -1986,6 +1996,19 @@ export declare const VACUUM_CLEAN_MEMBERS: {
1986
1996
  readonly startScene: import("./members.js").MethodMember<(sceneId: number) => Promise<void>> & {
1987
1997
  available: (ctx: import("./types.js").CommandContext) => boolean;
1988
1998
  };
1999
+ /**
2000
+ * Ask the robot to push its whole map — plane, room outline, room list and restricted zones — on the
2001
+ * map stream, as the vendor app does when its map view opens: two DP 153 writes, the time and then the
2002
+ * time with field 2 set (see {@link encodeMapWatch}).
2003
+ *
2004
+ * A docked robot sends no map of its own accord, so this is how its room ids and names become
2005
+ * readable without a clean. The map arrives on the stream like any other, not as this call's result.
2006
+ *
2007
+ * WATCHED: sent to a docked robot, it pushed its whole map within a second.
2008
+ */
2009
+ readonly requestMap: import("./members.js").MethodMember<() => Promise<void>> & {
2010
+ available: (ctx: import("./types.js").CommandContext) => boolean;
2011
+ };
1989
2012
  /**
1990
2013
  * State the cleaning settings a run uses — `CleanParamRequest.clean_param` over DP 154.
1991
2014
  *
@@ -52,7 +52,7 @@ export declare const isHomeBase: (ctx: FamilyContext) => boolean;
52
52
  export declare const isStation9000: (ctx: FamilyContext) => boolean;
53
53
  /** T8N00 NVR, identified by its reported model. */
54
54
  export declare const isNvrT8N00: (ctx: FamilyContext) => boolean;
55
- /** PoE cameras whose media is served by their NVR's portal call. */
55
+ /** A camera attached to a T8N00 whose media the NVR's call serves. */
56
56
  export declare const isNvrMediaCamera: (ctx: FamilyContext) => boolean;
57
57
  /** PoE S4 camera with independently streamed fixed and movable optical views. */
58
58
  export declare const isNvrDualLensCamera: (ctx: FamilyContext) => boolean;
@@ -112,6 +112,7 @@ export declare const DeviceType: {
112
112
  readonly LOCK_85D0: 202;
113
113
  readonly LOCK_85V0: 203;
114
114
  readonly LOCK_85P0: 209;
115
+ readonly WALL_LIGHT_CAM_T86C1: 298;
115
116
  readonly NVR_S4_MAX: 300;
116
117
  readonly CAMERA_POE_S4: 301;
117
118
  readonly WALL_LIGHT_CAM_81A0: 10005;
@@ -1,6 +1,7 @@
1
1
  import { type SessionEntry } from "../../core/crypto.js";
2
2
  import { type SessionStore } from "../../core/store.js";
3
3
  import { type Logger } from "../../core/logger.js";
4
+ import type { EufyDevice } from "../../core/types.js";
4
5
  import type { SecureMqttCredentials } from "../mqtt/secure-mqtt.js";
5
6
  export type RegionShard = "eu-pr" | "us-pr";
6
7
  /**
@@ -354,11 +355,11 @@ export declare class MegaHttpClient {
354
355
  /** Authed call to a mega service host: app-{service}-{region}.eufy.com. */
355
356
  post<T = unknown>(service: string, path: string, body?: unknown, headerOverrides?: Record<string, string>): Promise<T>;
356
357
  /**
357
- * Fetch the per-station DSK key used in the P2P cloud-lookup payload. Mirrors
358
+ * Fetch the per-station DSK key using each station's reported cloud category. Mirrors
358
359
  * the legacy `get_dsk_keys` body shape on the mega devicerelation service.
359
360
  * The P2P *local* lookup path does not need this; only the cloud path does.
360
361
  */
361
- getDskKeys(stationSns: string[], priorDsks?: Record<string, string>): Promise<Record<string, {
362
+ getDskKeys(stations: ReadonlyArray<Pick<EufyDevice, "sn" | "category">>, priorDsks?: Record<string, string>): Promise<Record<string, {
362
363
  dskKey: string;
363
364
  expiration: number;
364
365
  }>>;
@@ -12,44 +12,41 @@ export declare const BIZ_PROTOCOL: {
12
12
  readonly LIVE_PHOTO: 43;
13
13
  };
14
14
  /**
15
- * The channels a map frame can arrive on, by id.
15
+ * The channels a map frame arrives on, by id, as a robot's own frames identify them.
16
16
  *
17
- * **The trap.** Ids 1–10 come from `Metadata.ChanIds` in `stream.proto`, where each *field* names a
18
- * message and each field's *value* is the id the device chose for it. The names below pair each
19
- * channel with its field NUMBER, because that is the numbering the vendor ships and the one a live
20
- * capture shows — but a device announcing a `Metadata` frame is announcing its own numbering, and a
21
- * firmware that renumbers would make this table wrong without making it look wrong. Anything that
22
- * *decodes* a channel's bytes must therefore prefer a device-declared mapping when one has been seen;
23
- * this table is the default to fall back on, not an authority.
17
+ * Each id is paired with the `stream.proto` message whose field layout its frames carry, which is not
18
+ * the field-number order of `Metadata.ChanIds`: the cell plane (`Map`) arrives on 2, its room outline
19
+ * on 4, the room list on 5, the restricted zones on 6, the robot's pose (`DynamicData`) on 7, the map's
20
+ * `MapDescription` on 60 and a whole `MapBackup` on 68. A `Metadata` frame would declare a device's own numbering; none has been seen,
21
+ * so none is read, and this table is the numbering every frame is read by.
24
22
  *
25
- * Channel 0 is the exception and is not in `ChanIds` at all: the app hard-codes it, routing it to a
26
- * scene-data event rather than a map one, and asks for the scene list on it by number. That one is
27
- * fixed.
23
+ * Only channels whose message is identified are listed. A channel whose frames carry too little to
24
+ * identify, or that carries a message nothing here reads, is absent.
25
+ *
26
+ * Channel 0 is not in `ChanIds` at all: the app hard-codes it, routing it to a scene-data event rather
27
+ * than a map one, and asks for the scene list on it by number.
28
28
  */
29
29
  export declare const BIZ_CHANNEL: {
30
30
  /** Scene (room-group) data. Hard-coded in the app, not part of `ChanIds`. */
31
31
  readonly SCENES: 0;
32
- readonly MAP_INFO: 1;
33
- readonly PATH: 2;
34
- readonly ROOM_OUTLINE: 3;
35
- readonly ROOM_PARAMS: 4;
36
- readonly RESTRICTED_ZONE: 5;
37
- readonly DYNAMIC_DATA: 6;
38
- readonly TEMPORARY_DATA: 7;
39
- readonly OBSTACLE_INFO: 8;
40
- readonly MAP_DATA: 9;
41
- readonly CRUISE_DATA: 10;
32
+ readonly MAP_DATA: 2;
33
+ readonly ROOM_OUTLINE: 4;
34
+ readonly ROOM_PARAMS: 5;
35
+ readonly RESTRICTED_ZONE: 6;
36
+ readonly DYNAMIC_DATA: 7;
37
+ readonly MAP_DESCRIPTION: 60;
38
+ readonly MAP_BACKUP: 68;
42
39
  };
43
- /** A channel's name under the default numbering — see the trap in {@link BIZ_CHANNEL}. */
40
+ /** A channel's name under the {@link BIZ_CHANNEL} numbering. */
44
41
  export type BizChannel = keyof typeof BIZ_CHANNEL;
45
42
  /**
46
- * Name a channel id under the default numbering, or `undefined` for an id the vendor's table does not
47
- * list. Not a substitute for a device-declared mapping where one exists.
43
+ * Name a channel id under the {@link BIZ_CHANNEL} numbering, or `undefined` for an id it does not list.
44
+ * Not a substitute for a device-declared mapping where one exists.
48
45
  */
49
46
  export declare function bizChannelName(id: number): BizChannel | undefined;
50
47
  /** One frame off the map stream, unwrapped as far as its bytes but not decoded. */
51
48
  export interface BizMapFrame {
52
- /** Which channel the bytes belong to. Compare against {@link BIZ_CHANNEL}, mindful of its trap. */
49
+ /** Which channel the bytes belong to. Compare against {@link BIZ_CHANNEL}. */
53
50
  readonly channelId: number;
54
51
  /**
55
52
  * The vendor's `clear_type`. Forwarded, not interpreted: the app passes it straight through to its
@@ -12,3 +12,16 @@ export declare const AUDIO_TYPE_OFFSET = 5;
12
12
  * itself when a stream produces no audio at all, to feed its decoder silence.
13
13
  */
14
14
  export declare const AUDIO_CODECS: Readonly<Record<number, AudioCodec>>;
15
+ /**
16
+ * Whether an audio frame is sealed: after its header, a 16-byte GCM tag and a 12-byte nonce precede the
17
+ * `u32@0` bytes its header declares, where a frame in the clear carries those bytes straight after the header.
18
+ * A stream whose keyframes wrap their media key in an ECIES envelope seals its audio this way, measured on
19
+ * HomeBase recordings and on HomeBase 3 live streams answering a P-256 start.
20
+ */
21
+ export declare function isSealedAudio(frame: Buffer): boolean;
22
+ /**
23
+ * The payload of a sealed audio frame ({@link isSealedAudio}), opened with AES-256-GCM under `mediaKey`, the
24
+ * media key of the keyframe before it, with the video frames' AAD. Answers `undefined` for a frame that is not
25
+ * sealed or does not authenticate under that key.
26
+ */
27
+ export declare function openSealedAudio(frame: Buffer, mediaKey: Buffer): Buffer | undefined;
@@ -224,8 +224,8 @@ export declare function buildRawCommandPayload(data: Buffer, channel?: number, s
224
224
  export declare function buildAckPayload(dataTypeHeader: Buffer, seqNo: number): Buffer;
225
225
  /** Parse the 16-byte data-frame header that follows the "XZYH" magic. */
226
226
  export declare function parseDataFrameHeader(frame: Buffer): P2PDataFrameHeader;
227
- /** Parse a LOOKUP_ADDR or RLY_TO message's leading address record. */
228
- export declare function parseLookupAddr(msg: Buffer): Address;
227
+ /** A LOOKUP_ADDR message's leading address record, or `undefined` for a message too short to carry it. */
228
+ export declare function parseLookupAddr(msg: Buffer): Address | undefined;
229
229
  /** The relay servers a LIST_REQ_ACK names, in the order listed, without the unspecified address; records past the message's end are dropped. */
230
230
  export declare function parseRelayList(msg: Buffer): Address[];
231
231
  /** A RLY_PORT_ACK's mark and relay port, or `undefined` for a message too short to carry them. */
@@ -62,6 +62,8 @@ export interface P2PRouterDeps {
62
62
  onClose: (stationSn: string) => void;
63
63
  onError: (err: Error) => void;
64
64
  onLevel2Ready: (stationSn: string, cipherId: number) => void;
65
+ /** The station answered on its own session at `atMs` — a PONG or an ACK from the connected peer. */
66
+ onPeerAnswer?: (stationSn: string, atMs: number) => void;
65
67
  /** A raw decoded frame — the client emits the low-level `p2p` event + runs the semantic decode. */
66
68
  onFrame: (stationSn: string, frame: P2PFrame) => void;
67
69
  /**
@@ -102,6 +104,18 @@ export declare class P2PCommandRouter {
102
104
  * {@link liveSources} is a claim in flight.
103
105
  */
104
106
  private readonly liveSessionKeys;
107
+ /**
108
+ * The live source build in flight per `${parentSn}:${channel}`, settled once its connection is opened
109
+ * or refused.
110
+ *
111
+ * A build awaits its media session before it can file the source in {@link liveSources}, so a second
112
+ * request for the same camera in that window finds nothing cached. Building a second source there
113
+ * overwrites the first, and the orphan's teardown then closes the connection the cached one streams
114
+ * on. A request that finds a build here waits for it, until its own signal aborts, then resolves its
115
+ * session again and reuses what the build filed, or builds its own when that build failed. The entry
116
+ * is removed when the build settles and its promise never rejects, so each wait ends on a later state.
117
+ */
118
+ private readonly liveSourceBuilds;
105
119
  /**
106
120
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
107
121
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -220,6 +234,11 @@ export declare class P2PCommandRouter {
220
234
  * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
221
235
  * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
222
236
  * carry the start is not a connected session, so this refuses rather than returning one.
237
+ *
238
+ * A session refused its key within the grace, or never connected, is closed and dropped from the manager at
239
+ * once. Nothing else releases it: no source holds it, a wired station's session never idles out, and the
240
+ * manager hands the same session to every later open of the camera, so the next open dials a fresh
241
+ * connection instead. A session another open has since replaced is left to its own close.
223
242
  */
224
243
  private openMediaSession;
225
244
  /**
@@ -295,24 +314,28 @@ export declare class P2PCommandRouter {
295
314
  */
296
315
  mediaProviderFor(sn: string): MediaProvider;
297
316
  /**
298
- * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
299
- * and frame formats are confirmed.
317
+ * The {@link RecordingStation} of the station `sn` is attached to, when `sn` is a camera attached to a
318
+ * station with an entry in {@link RECORDING_STATIONS}. A standalone device, or a camera on any other
319
+ * station, answers `undefined`.
300
320
  */
301
- private downloadsRecordings;
321
+ private recordingStationFor;
302
322
  /**
303
- * Download one recording a HomeBase 2 holds for an attached camera, then decode it
304
- * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
305
- * on the camera's channel, so a download starts only once the previous one has drained
306
- * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
307
- * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
308
- * turn is held until the transfer drains.
323
+ * Download one recording `station` holds for the attached camera `sn`, then decode it
324
+ * ({@link decodeRecording}). `station` is the entry {@link mediaProviderFor} bound. Downloads queue per
325
+ * station: the station streams one recording at a time on the camera's channel, so a download starts only
326
+ * once the previous one has drained ({@link RecordingTransfer.drained}), even when that one's caller already
327
+ * has its answer. An abort while waiting rejects without sending anything; an abort mid-transfer rejects at
328
+ * once and the station's turn is held until the transfer drains.
309
329
  */
310
330
  private downloadRecording;
311
331
  /** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
312
332
  private waitTurn;
313
333
  /**
314
334
  * Start one recording download, once the station's previous download has drained: the decoded recording,
315
- * and when the station is done sending it.
335
+ * and when the station is done sending it. The path is `station.path(channel, opts.recording)`, and a name
336
+ * it refuses rejects `invalid-recording` before anything is sent. On a sealed station the cipher's ECC key
337
+ * is looked up, and its absence rejects `key-unavailable` before anything is sent. A station in the clear
338
+ * reads no cipher, and `opts.cipherId` is not read.
316
339
  */
317
340
  private startRecordingDownload;
318
341
  /**
@@ -394,8 +417,9 @@ export declare class P2PCommandRouter {
394
417
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
395
418
  * reuse branch, so a reuse frees the station as a cold start does.
396
419
  *
397
- * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
398
- * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
420
+ * `continuous` decides whether a camera that finds the station's own session claimed may open a
421
+ * connection of its own for it; on an {@link OWN_LIVE_SESSION_MODELS} station it always gets one, and an
422
+ * unwatched source left on the station's session is rebuilt there. Only the continuous-pull egresses pass it. A still must not: it wants one
399
423
  * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
400
424
  * so a still asked for while a sibling is being watched contends as it always did, and the caller's
401
425
  * retained image answers it. A live view still outranks a tile; what changed is that two live views no
@@ -404,7 +428,7 @@ export declare class P2PCommandRouter {
404
428
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
405
429
  * place.
406
430
  */
407
- sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts, mayOpenOwnSession?: boolean): Promise<SharedLiveSource>;
431
+ sharedLiveSourceFor(sn: string, opts?: SharedLiveOpts, continuous?: boolean): Promise<SharedLiveSource>;
408
432
  /**
409
433
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
410
434
  *
@@ -559,12 +583,17 @@ export declare class P2PCommandRouter {
559
583
  * yield CMAF fragments (init segment first, then a `moof`+`mdat` per keyframe boundary) muxed by the
560
584
  * dependency-free internal fMP4 muxer. The returned recording handle exposes battery-budget notices
561
585
  * and detaches its consumer on `stop`, iterator return, or iterator throw. No ffmpeg.
586
+ *
587
+ * It is a continuous pull only on an {@link OWN_LIVE_SESSION_MODELS} station; elsewhere it never opens a
588
+ * connection of its own.
562
589
  */
563
590
  recordFragments(sn: string, opts?: {
564
591
  fragmentSeconds?: number;
565
592
  eccPrivateKey?: Buffer;
566
593
  keepAliveMs?: number;
567
594
  } & SharedSourceHints): FragmentRecording;
595
+ /** Whether `stationSn` is an {@link OWN_LIVE_SESSION_MODELS} station. */
596
+ private ownsLiveSession;
568
597
  /**
569
598
  * **Generic P2P request/reply query.** Sends a `SET_PAYLOAD` (1350) wrapper carrying `subCmd` on the
570
599
  * device channel, then resolves with the reply frame's `payload` — the `NOTIFY_PAYLOAD` (1351)
@@ -693,6 +722,9 @@ export declare class P2PCommandRouter {
693
722
  * **Station-scoped scalar** (`p2p-station-scalar` intent): the 132-byte channel-less body
694
723
  * ({@link buildDirectBinaryBody} with no `channel`) on an EXPLICIT channel, signCode 8. The
695
724
  * HomeBase's own controls ride the station broadcast channel 255 (alarm/speaker volume 1235).
725
+ *
726
+ * `form` `"auto"` seals it at the session's level, as {@link sendBySessionLevel} defines it: level-2 on a
727
+ * keyed session, the same body sealed level-1 on a keyless one. Absent, the send waits for the level-2 key.
696
728
  */
697
729
  private sendStationScalar;
698
730
  /** Send a level-1 int-plus-string frame with authenticated account identity injected by the transport. */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Database wire — the ids and the whole-table query a station's on-station table and stored-image
3
+ * reads are built from.
4
+ *
5
+ * A HomeBase answers them over its P2P session and a T9000 over its portal control channel, with the
6
+ * same inner payloads, so both transports build their reads from this one module. The barrel does not
7
+ * re-export it: these are wire ids, not part of the package surface.
8
+ *
9
+ * @module p2p/database-wire
10
+ */
11
+ /** CMD_DATABASE_IMAGE (1308) — fetch one stored image by its on-station path. */
12
+ export declare const CMD_DATABASE_IMAGE = 1308;
13
+ /** CMD_DATABASE (1306) — query one on-station table; reply carries `{cmd:10000,count,data:[…]}`. */
14
+ export declare const CMD_DATABASE = 1306;
15
+ /**
16
+ * Inner query cmds carried in a {@link CMD_DATABASE} payload. `FULL_TABLE` reads one table whole;
17
+ * `COMBINATION_WITH_AI` is the AI event-history read that bundles the face roster.
18
+ */
19
+ export declare const DB_QUERY: {
20
+ readonly FULL_TABLE: 10000;
21
+ readonly COMBINATION_WITH_AI: 10011;
22
+ readonly QUERY_LOCAL: 10017;
23
+ };
24
+ /** How long a station's database reply has to arrive whole, when the caller states no bound. */
25
+ export declare const DB_TABLE_TIMEOUT_MS = 15000;
26
+ /**
27
+ * The query params the vendor app sends with a whole-table `CMD_DATABASE` read (inner `cmd 10000`).
28
+ * `count` bounds the row count.
29
+ */
30
+ export declare function fullTableQuery(count?: number): Record<string, unknown>;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The bound a caller waits for the level-2 negotiation to SETTLE, measured from connect.
3
+ *
4
+ * For a caller that picks its seal once from `P2PSession.hasLevel2Key` and has no second chance. A media
5
+ * start reads the key on every send and is re-issued by the warm-up, so it needs no wait; a property write
6
+ * framed level-1 to a family that only accepts level-2 is ignored, and nothing re-frames it. Session-scoped,
7
+ * so a station that offers no key does not charge this to every later command.
8
+ */
9
+ export declare const LEVEL2_SETTLE_MS = 8000;
@@ -7,9 +7,11 @@
7
7
  * - **Plaintext** (`signCode 0`, e.g. HomeBase-attached cameras): a fixed 22-byte frame header
8
8
  * (`parseVideoFrameHeader`) followed directly by Annex-B NAL units — verified live (960×540,
9
9
  * keyframe = header flag bit0). We strip the 22-byte header.
10
- * - **Encrypted** (E2E cameras): the body is AES-256-GCM under a per-stream media key wrapped in a
11
- * keyframe ECIES envelope. If an `eccPrivateKey` is supplied we run `VideoFrameDecoder`; otherwise
12
- * those frames are skipped (no key → no video).
10
+ * - **Encrypted keyframes**: either the AES-256-GCM body under a media key wrapped in an ECIES envelope
11
+ * for an offered P-256 key ({@link VideoFrameDecoder}), or an AES-ECB head under a media key wrapped for
12
+ * the session's RSA key ({@link P2PSession.decodeVideoFrame}). An attached camera's start on a station
13
+ * with a {@link P2PSession.mediaEccPrivateKey} offers that P-256 key first, so its stream decodes with
14
+ * that pair unless `eccPrivateKey` names another.
13
15
  *
14
16
  * Emits: `video` ({@link LiveVideoFrame} — Annex-B), `audio` ({@link LiveAudioFrame}), `start`, `stop`,
15
17
  * `error`.
@@ -54,7 +56,10 @@ export interface LiveStreamOptions {
54
56
  * however long the station keeps serving that sibling instead of this one.
55
57
  */
56
58
  channel?: number;
57
- /** Camera ECC private key (32B) for E2E/encrypted cameras; omit for plaintext cameras. */
59
+ /**
60
+ * P-256 private key (32B) whose public point the camera wraps each keyframe's media key for. Defaults, on an
61
+ * attached camera, to the session's `P2PSession.mediaEccPrivateKey`, the pair its start offers.
62
+ */
58
63
  eccPrivateKey?: Buffer;
59
64
  /** Admin account id — required for the level-2 (`signCode 8`) media-start payload selecting a camera. */
60
65
  accountId?: string;
@@ -112,6 +117,8 @@ export declare class LiveStream extends EventEmitter {
112
117
  private tracedFirstVideoUnit;
113
118
  private tracedFirstKeyframe;
114
119
  private tracedDecodeFailures;
120
+ /** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
121
+ private unitOpenedBy;
115
122
  private tracedFirstForeignFrame;
116
123
  private stallTimer?;
117
124
  /**
@@ -173,6 +180,7 @@ export declare class LiveStream extends EventEmitter {
173
180
  stop(): void;
174
181
  /** Extract the Annex-B payload from a plaintext 1300 frame (fixed 22-byte header), or undefined. */
175
182
  private plaintextAnnexB;
183
+ /** Emit one accepted media frame; an audio frame with no bytes past its header emits nothing. */
176
184
  private onFrame;
177
185
  /**
178
186
  * Whether this stream may take `frame` — the demultiplexer for a station that serves several cameras.
@@ -198,11 +206,30 @@ export declare class LiveStream extends EventEmitter {
198
206
  */
199
207
  private acceptsMedia;
200
208
  /**
201
- * The Annex-B payload of a frame that starts an access unit. The session decodes it when it can (it
202
- * holds the RSA private key, and handles the plaintext form); the legacy header-strip and the ECIES
203
- * {@link VideoFrameDecoder} are the fallbacks for an E2E camera.
209
+ * The Annex-B payload of a frame that starts an access unit. The authenticated ECIES/GCM
210
+ * {@link VideoFrameDecoder} goes first: its HMAC and GCM checks fail closed on a frame not sealed for its
211
+ * key. The session's RSA path cannot: on Node ≥ 24.5, PKCS#1 v1.5 decryption of a key not wrapped for it
212
+ * returns pseudo-random bytes instead of throwing (implicit rejection), which would decode an ECC
213
+ * keyframe into noise. The session's decode, which also passes plaintext frames, and the header strip
214
+ * follow. A payload that opens an access unit records how it was read: by the ECIES path, or otherwise by
215
+ * its sign code, sealed (`"rsa"`) or in the clear (`"plain"`).
204
216
  */
205
217
  private annexbOf;
218
+ /**
219
+ * Tell the session how this keyframe was opened, which answers the attached starts on this channel
220
+ * ({@link P2PSession.settleAttachedKey}), and re-issue the start when the session asks for one: a plaintext
221
+ * keyframe answering the session's P-256 offer, from a camera that did not use the point, which the next
222
+ * start asks with RSA. Only a delivered keyframe answers: a frame no key opens, or a continuation without the
223
+ * frame that opened its unit, delivers nothing.
224
+ */
225
+ private settleStartKey;
226
+ /**
227
+ * The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
228
+ * opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
229
+ * before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
230
+ * its header, empty for a frame that carries only its header.
231
+ */
232
+ private audioPayload;
206
233
  /**
207
234
  * Report an access unit the transport could not complete.
208
235
  *