@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
@@ -23,6 +23,8 @@ export type LiveTrace =
23
23
  topology: "attached" | "own";
24
24
  action: "start" | "keepalive";
25
25
  level2: boolean;
26
+ /** The media-key type an attached start offered. */
27
+ key?: "ecc" | "rsa";
26
28
  }
27
29
  /** The device acknowledged a retained start, or that start was repeated / abandoned unacknowledged. */
28
30
  | {
@@ -137,10 +139,10 @@ export type LiveTrace =
137
139
  *
138
140
  * Every ending of a level-2 wait carries one of these reasons, so a start refused for want of a key is
139
141
  * accounted for however it ended. `grace-elapsed` is a wait that ran out and states how long was waited;
140
- * the rest are answered without waiting, because the negotiation is one-shot per connection and a
141
- * concluded one is final. `no-cipher-key` and `derivation-failed` are about this account's cipher
142
- * material, `not-negotiating` and `session-closed` about the station or its connection — and only a
143
- * reason reached under a negotiation has a cipher to name.
142
+ * the rest are answered without waiting, because a concluded negotiation stays concluded until a
143
+ * re-prompt for `CMD_GATEWAYINFO` re-opens it. `no-cipher-key` and `derivation-failed` are about this
144
+ * account's cipher material, `not-negotiating` and `session-closed` about the station or its connection —
145
+ * and only a reason reached under a negotiation has a cipher to name.
144
146
  *
145
147
  * A `grace-elapsed` start proceeds at level 1 where it has such a form, and not at all where it does not.
146
148
  */
@@ -95,6 +95,12 @@ export interface P2PFrame extends P2PDataFrameHeader {
95
95
  * the values as device state without any per-capability decoder.
96
96
  */
97
97
  params?: Record<number, string>;
98
+ /**
99
+ * A station's camera-info table, one `param_type → param_value` map per channel it names (255 is the
100
+ * station). Set instead of {@link params} when the station answered on {@link STATION_CHANNEL}, so each
101
+ * channel's state can land on the device attached there.
102
+ */
103
+ channelParams?: Record<number, Record<number, string>>;
98
104
  }
99
105
  /**
100
106
  * A live PPCS session. Internal transport; a host drives cameras through the capability surface.
@@ -134,7 +140,12 @@ export declare class P2PSession extends EventEmitter {
134
140
  private audioStalled;
135
141
  private audioRetransmitTimer?;
136
142
  private lastPongData?;
137
- /** When the selected peer last answered outbound P2P traffic on this connection. */
143
+ /**
144
+ * When the selected peer last answered outbound P2P traffic on this connection.
145
+ *
146
+ * Each answer also emits `peerAnswer` with this time: a PONG or an ACK from the connected address is the
147
+ * station replying on this connection now, which a heartbeat draws every {@link HEARTBEAT_MS}.
148
+ */
138
149
  private lastPeerAt?;
139
150
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
140
151
  private pathStaleTraced;
@@ -180,11 +191,21 @@ export declare class P2PSession extends EventEmitter {
180
191
  private level2Seq;
181
192
  private rsaPrivateKey?;
182
193
  private rsaModulusHex?;
183
- /** Guards the one-shot level-2 key negotiation kicked off by the GATEWAYINFO reply. */
194
+ /** The P-256 pair an attached camera's media start offers; see {@link mediaEccPrivateKey}. */
195
+ private mediaEcc?;
196
+ /**
197
+ * Per attached channel, the media-key type its latest start offered on this connection, or that last answered,
198
+ * and whether a keyframe has answered it. See {@link attachedStartKey} and {@link settleAttachedKey}.
199
+ */
200
+ private readonly attachedStartKeys;
201
+ /**
202
+ * Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
203
+ * settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
204
+ */
184
205
  private level2Negotiating;
185
206
  /**
186
207
  * Whether waiting for a level-2 key can still change the answer: `false` once one has been negotiated,
187
- * once the one-shot negotiation concluded without one, and from the start when nothing can negotiate one.
208
+ * once a negotiation concluded without one, and from the start when nothing can negotiate one.
188
209
  */
189
210
  private level2Pending;
190
211
  /** Waiters parked in {@link awaitLevel2Key}, woken the moment the negotiation settles either way. */
@@ -260,7 +281,8 @@ export declare class P2PSession extends EventEmitter {
260
281
  * The session grace runs from connect, when the station is prompted for `CMD_GATEWAYINFO`. A negotiation
261
282
  * beginning later does not restart it: best-effort media can proceed without the key, and restarting
262
283
  * would charge another grace to a source that may already be streaming. A session whose negotiation has
263
- * settled answers without waiting at all, since being one-shot is what makes that answer final.
284
+ * settled answers without waiting at all: the answer stands until {@link repromptLevel2Key} re-opens the
285
+ * wait, since the station is prompted for `CMD_GATEWAYINFO` only on connect and on that re-prompt.
264
286
  *
265
287
  * An own-session camera whose grace expires here is NOT thereby a camera that will fail to stream. Measured
266
288
  * on one account: own-session cameras of two device types negotiated a key, three others never did, and
@@ -276,12 +298,13 @@ export declare class P2PSession extends EventEmitter {
276
298
  /**
277
299
  * Ask the station for its gateway info a second time, re-opening a negotiation that concluded without a key.
278
300
  *
279
- * The negotiation is one-shot per connection: the station is prompted once on connect, and a reply that
280
- * never lands settles the wait so {@link awaitLevel2Key} answers `false` at once forever after. That is the
301
+ * Without this the station is prompted once on connect, and a negotiation that settles without a key
302
+ * leaves {@link awaitLevel2Key} answering `false` at once for the rest of that connection. That is the
281
303
  * right answer for best-effort media, which proceeds at level-1 — but an operation whose ONLY wire is
282
304
  * level-2 is then refused for the whole life of that connection, while a fresh session over the same
283
305
  * device negotiates a key normally. Measured: a session that had settled refused every such operation
284
- * until it was rebuilt, at which point the station answered with a cipher id straight away.
306
+ * until it was rebuilt, at which point the station answered with a cipher id straight away. The reply to
307
+ * this ask runs the negotiation again and settles the re-opened wait either way.
285
308
  *
286
309
  * Bounded to one extra ask per connection, so a burst of such operations cannot turn a silent station into a
287
310
  * flood, and answers whether it asked — `false` when a key is already held, when nothing can negotiate one,
@@ -296,13 +319,14 @@ export declare class P2PSession extends EventEmitter {
296
319
  * `cipher_id`, resolve that cipher's private keys once (cloud `get_ciphers`, via the configured
297
320
  * `resolveCipherKey`), run the ECIES unwrap with the ECC key, and `setLevel2Key()`. A cipher with no
298
321
  * ECC key uses its RSA key instead and holds the RSA-decrypted 16-byte key as the AES-128-ECB level-2
299
- * key. One-shot; emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
322
+ * key. Emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
300
323
  * `level2-set-payload-only` instead for the AES-128-ECB key, and emits `error` when the ECC unwrap
301
324
  * fails; an RSA unwrap failure is only traced (non-fatal either way — level-1 traffic keeps working).
302
325
  *
303
- * Every outcome settles the wait in {@link awaitLevel2Key}, because being one-shot is what makes a
304
- * failure final for this connection generation: nothing will retry it there, so a later command must be
305
- * told at once rather than left to time out against a key that is not coming.
326
+ * Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
327
+ * left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
328
+ * GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
329
+ * generation ended settles nothing.
306
330
  */
307
331
  private negotiateLevel2Key;
308
332
  /**
@@ -327,7 +351,15 @@ export declare class P2PSession extends EventEmitter {
327
351
  * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
328
352
  */
329
353
  private requestReceiveBuffer;
330
- /** Open the socket and start the lookup → hole-punch handshake. */
354
+ /**
355
+ * Open the socket and start the lookup → hole-punch handshake.
356
+ *
357
+ * A socket closed or failing while binding never invokes its bind callback, so the bind wait also
358
+ * settles on the socket's `close` and `error`. On `close`, a connect whose session was closed meanwhile
359
+ * returns without starting the handshake, so it never sends a lookup or arms a timer on a closed
360
+ * session. On `error`, connect closes the session and rejects with {@link StationUnreachableError}
361
+ * carrying the bind error as its `cause`.
362
+ */
331
363
  connect(): Promise<void>;
332
364
  /** Cached across every `P2PSession` in this process — the local outbound IPv4 doesn't vary by
333
365
  * station, so there's no reason to re-probe it per session (e.g. once per station in a fleet). Only
@@ -612,6 +644,10 @@ export declare class P2PSession extends EventEmitter {
612
644
  * Send a CMD_SET_PAYLOAD(1350) wrapping `{account_id, cmd:<subCmd>, mChannel, mValue3:<subCmd>,
613
645
  * payload}`, encrypted level-2 (AES-256-GCM, signCode 8) — the app's media-control path. The
614
646
  * camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
647
+ * A start (HomeBase-controlled camera path, the V6 app's media-start envelope) also carries an
648
+ * `accountId` inside its payload and the media key `key` offers (see {@link attachedStartKey}). A start
649
+ * offering the P-256 point labels itself `ClientOS: "IOS"`, as every simple attached start in the app
650
+ * captures that carry that key form does; the RSA start keeps `"Android"`.
615
651
  */
616
652
  private sendMediaPayloadLevel2;
617
653
  /**
@@ -632,6 +668,41 @@ export declare class P2PSession extends EventEmitter {
632
668
  * (T8171 2560×1440, T8210 640×480). Returns `undefined` on failure (frame is skipped).
633
669
  */
634
670
  private rsaUnwrapKey;
671
+ /**
672
+ * The media-key type the next attached start sent on `channel` offers, recorded as offered: ECC, the current
673
+ * app's form, for the first start this connection sends there; RSA for every start after one that no keyframe
674
+ * answered before it was re-issued; and, once a keyframe has answered, the type that opened it, until
675
+ * {@link settleAttachedKey} records another. Only an opened keyframe answers: frames no key opens, and deltas,
676
+ * do not. A re-issue is a full restart on an attached camera, so a camera that cannot read the point is asked
677
+ * with RSA, the start every station answers, and is not asked with the point again while that goes unanswered.
678
+ * A stream decoding with the session's pair reads either answer. A station without {@link mediaEccPrivateKey}
679
+ * is offered RSA only. A start that cannot be sent offers nothing and records nothing.
680
+ */
681
+ private attachedStartKey;
682
+ /**
683
+ * Record how a keyframe of `channel` was opened, which answers the starts there. A keyframe opened under a
684
+ * key type is an answer of that type, whichever type the latest start offered: a station can serve the
685
+ * P-256 start after the RSA re-issue went out. A plaintext keyframe
686
+ * answering an unanswered P-256 offer is a camera that did not use the point, and the next start there offers
687
+ * RSA; once a P-256 answer has been opened it changes nothing, and answering RSA, it settles RSA. Answers
688
+ * whether a new start is needed. A channel no attached start has offered a key on is left as it is.
689
+ */
690
+ settleAttachedKey(channel: number, opened: "ecc" | "rsa" | "plain"): boolean;
691
+ /**
692
+ * The 32-byte P-256 private scalar paired with the point an attached camera's media start offers as `key`.
693
+ * A camera answering that start wraps each keyframe's AES-256-GCM media key in an ECIES envelope for this
694
+ * key ({@link VideoFrameDecoder}). Generated once per session, like the RSA pair.
695
+ *
696
+ * `undefined` unless the station is a HomeBase 3 (`T8030`): the app's P-256 start and the device runs
697
+ * that answered it were both observed on that station only.
698
+ */
699
+ get mediaEccPrivateKey(): Buffer | undefined;
700
+ /**
701
+ * The session's P-256 pair: the private scalar as 32 bytes, and the public point as 128 hex characters,
702
+ * `x ‖ y` without the uncompressed-point prefix, the form the current app's attached-camera start carries in
703
+ * `key`. `ECDH.getPrivateKey()` drops a scalar's leading zero bytes, so the scalar is padded back to 32.
704
+ */
705
+ private mediaEccPair;
635
706
  /**
636
707
  * The RSA-1024 public-key modulus (128-byte hex) the station uses to wrap the per-stream media
637
708
  * key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
@@ -677,10 +748,11 @@ export declare class P2PSession extends EventEmitter {
677
748
  innerCmd?: number;
678
749
  }): void;
679
750
  /**
680
- * Standard "give me the whole table" query params the eufy app uses for a direct
681
- * `CMD_DATABASE` read (inner `cmd 10000`). `count` bounds the row count.
751
+ * The `CMD_SET_PAYLOAD` payload of a database read or stored-image request: the JSON level-1 encrypted, or
752
+ * in the clear with header bytes from {@link nextClearCommandBytes} on a session that {@link sendsInTheClear}
753
+ * and holds no level-2 key.
682
754
  */
683
- private fullTableQuery;
755
+ private databasePayload;
684
756
  /**
685
757
  * Request the on-device edge-AI **face roster** over P2P — the phone-free path.
686
758
  *
@@ -719,6 +791,9 @@ export declare class P2PSession extends EventEmitter {
719
791
  *
720
792
  * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
721
793
  * session throws, so a second reply cannot land in this buffer.
794
+ *
795
+ * The query's framing follows the cipher the gateway-info reply names, so on a session still negotiating
796
+ * its level-2 key the query waits for that to settle, up to {@link LEVEL2_SETTLE_MS} from connect.
722
797
  */
723
798
  readDatabase(table: string, opts?: {
724
799
  accountId?: string;
@@ -788,6 +863,8 @@ export declare class P2PSession extends EventEmitter {
788
863
  *
789
864
  * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
790
865
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
866
+ *
867
+ * A datagram shorter than the 8-byte DATA header carries no sequence number and is dropped unacknowledged.
791
868
  */
792
869
  private onData;
793
870
  /** Hold a datagram that arrived past a hole, and arm the wait for the missing one to be repeated. */
@@ -821,6 +898,13 @@ export declare class P2PSession extends EventEmitter {
821
898
  */
822
899
  private resetInboundSequencing;
823
900
  private ackTypeHeader;
901
+ /**
902
+ * Decrypt and dispatch one inbound data frame.
903
+ *
904
+ * A `CMD_GATEWAYINFO` (1100) reply carries the ECIES envelope for the level-2 session key. When a
905
+ * cipher-key resolver is configured, no key that seals a set-payload is held and no negotiation is in
906
+ * flight, the reply starts one (async, fire-and-forget); a reply landing while one is in flight is ignored.
907
+ */
824
908
  private handleFrame;
825
909
  private send;
826
910
  /** Finish the END send on the current socket before closing it; send completion is not a peer ACK. */
@@ -25,6 +25,8 @@ export interface ReadableEgressOptions {
25
25
  /**
26
26
  * Wrap a shared-source {@link Consumer} in a fresh Readable. The consumer is detached when the
27
27
  * Readable is destroyed/ended, so callers own the lifetime by owning the stream.
28
- * Frames arrive through onMedia, preserving their source-arrival metadata.
28
+ * Frames arrive through onMedia, preserving their source-arrival metadata. The consumer's battery
29
+ * `budget` notice is re-emitted unchanged as a `budget` event carrying a {@link StreamBudgetNotice};
30
+ * without an `extend()` within its grace the shared source ends the consumer and the Readable reaches EOF.
29
31
  */
30
32
  export declare function openReadableFromConsumer(consumer: Consumer, opts?: ReadableEgressOptions): Readable;
@@ -1,5 +1,35 @@
1
+ /**
2
+ * Download of a recording held on a HomeBase, over the station's P2P session. {@link RECORDING_STATIONS}
3
+ * holds every way the station models differ; the rest is shared.
4
+ *
5
+ * The request is `CMD_DOWNLOAD_VIDEO` (1024) in the string-pair shape
6
+ * ({@link buildStringPairCommandPayload}) on the camera's channel. The station answers on
7
+ * {@link RECORDING_DATA_TYPE}, tagged with that channel, with the recording's `CMD_VIDEO_FRAME` (1300) and
8
+ * `CMD_AUDIO_FRAME` (1301) frames, faster than real time, then `CMD_DOWNLOAD_FINISH` (1304) on the control
9
+ * data type, tagged with the station's {@link RecordingStation.finishChannel} whatever the camera's channel.
10
+ *
11
+ * Frame layouts on a station that seals its recordings (HomeBase 2):
12
+ *
13
+ * ```
14
+ * video, keyframe: the live E2E layout ({@link VideoFrameDecoder}), length = 179 + len exactly
15
+ * video, other frames: the same 22B header (u32@0 = len · u16@6 = frame number · u48@0x0e = stamp in ms),
16
+ * then len bytes of plaintext H.264
17
+ * audio: 16B header (u32@0 = len · u8@5 = codec, 0 = AAC-LC), then 16B GCM tag, 12B GCM nonce and len
18
+ * bytes of AES-256-GCM AAC under the media key of the preceding keyframe, AAD as live video; the
19
+ * plaintext is a whole ADTS frame, header included
20
+ * ```
21
+ *
22
+ * Frame layouts on a station that stores them in the clear (HomeBase 3):
23
+ *
24
+ * ```
25
+ * video, every frame: the same 22B header, then len bytes of Annex-B H.264 or H.265, length = 22 + len
26
+ * audio: the same 16B header, then len bytes of ADTS AAC
27
+ * ```
28
+ *
29
+ * @module p2p/recording-download
30
+ */
1
31
  import { type RecordingDownload } from "../../core/contracts.js";
2
- import type { P2PSession } from "./p2p-session.js";
32
+ import { type P2PSession } from "./p2p-session.js";
3
33
  /** Data type a station sends a recording's frames on: the binary channel (`0xd1 0x03`), not live video's. */
4
34
  export declare const RECORDING_DATA_TYPE = 3;
5
35
  /**
@@ -14,12 +44,56 @@ export interface RecordingFrame {
14
44
  raw: Buffer;
15
45
  }
16
46
  /**
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.
47
+ * How one station model stores its attached cameras' recordings and serves them over `CMD_DOWNLOAD_VIDEO`
48
+ * (1024): where a recording lives, whether it is sealed, which channel the finish frame is tagged with, and
49
+ * where a split picture continues. A model without an entry in {@link RECORDING_STATIONS} offers no download.
50
+ */
51
+ export interface RecordingStation {
52
+ /**
53
+ * The path the station stores the recording named `recording` of the camera on station channel `channel`
54
+ * under. Answers `undefined` for a name the station could not hold. The SDK builds every path from these two
55
+ * values, so a request never reaches outside the camera's own folder.
56
+ */
57
+ readonly path: (channel: number, recording: string) => string | undefined;
58
+ /**
59
+ * Whether the station stores a recording sealed under its event's cipher, which opens with that cipher's ECC
60
+ * private key, or in the clear, which reads no key. The frame layouts of each are in this module's
61
+ * documentation.
62
+ */
63
+ readonly sealed: boolean;
64
+ /** The channel the station tags `CMD_DOWNLOAD_FINISH` (1304) with. The camera's own channel is taken too. */
65
+ readonly finishChannel: number;
66
+ /**
67
+ * The payload size the station fills a `CMD_VIDEO_FRAME` to when an access unit continues in the next frame
68
+ * ({@link AccessUnitAssembler}).
69
+ */
70
+ readonly chunkBytes: number;
71
+ }
72
+ /**
73
+ * The path a HomeBase 2 stores a camera's recording under: its {@link cameraFolder}, then the recording's name
74
+ * as `yyyyMMddHHmmss` or `h265_yyyyMMddHHmmss`. Answers `undefined` for any other name, and for a channel
75
+ * {@link cameraFolder} refuses. Both forms are digits behind one fixed prefix, so the anchored test lets no
76
+ * `..`, slash or other character that could reach another path on the station through.
21
77
  */
22
78
  export declare function homeBase2RecordingPath(channel: number, recording: string): string | undefined;
79
+ /**
80
+ * The path a HomeBase 3 stores a camera's recording under:
81
+ * `/zx/emmcdata/<cameraFolder>/<yyyyMM>/<name>/<name>.zxvideo`, where the name is the recording's start as
82
+ * `yyyyMMddHHmmss`, whatever the codec, and `<yyyyMM>` is its first six digits. Answers `undefined` for any
83
+ * other name (a codec prefix, an extension or a path included), and for a channel {@link cameraFolder}
84
+ * refuses. The anchored test lets no character but digits through.
85
+ */
86
+ export declare function homeBase3RecordingPath(channel: number, recording: string): string | undefined;
87
+ /**
88
+ * The stations whose recording download is confirmed, by station model.
89
+ *
90
+ * `T8010`, HomeBase 2: `/media/mmcblk0p1/CameraNN/<name>.dat`, sealed under the event's cipher, finish tagged
91
+ * with channel 0, split pictures filled to {@link STATION_CHUNK_BYTES}.
92
+ *
93
+ * `T8030`, HomeBase 3: `/zx/emmcdata/CameraNN/<yyyyMM>/<name>/<name>.zxvideo`, in the clear, finish tagged
94
+ * with {@link STATION_CHANNEL}, a keyframe over 400000 bytes split at exactly 400000.
95
+ */
96
+ export declare const RECORDING_STATIONS: ReadonlyMap<string, RecordingStation>;
23
97
  /** One recording transfer on a station session. */
24
98
  export interface RecordingTransfer {
25
99
  /** The recording's frames, in arrival order. Rejects as {@link receiveRecording} describes. */
@@ -30,7 +104,8 @@ export interface RecordingTransfer {
30
104
  /**
31
105
  * Request one recording and collect its frames until `CMD_DOWNLOAD_FINISH`. Only frames on
32
106
  * {@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.
107
+ * station is never mixed in. The finish frame is taken on the camera's channel or on `finishChannel`
108
+ * ({@link RecordingStation.finishChannel}).
34
109
  *
35
110
  * `frames` rejects with {@link RecordingDownloadError}: `no-data` when nothing arrives within
36
111
  * {@link FIRST_FRAME_WAIT_MS}, and `incomplete` when the transfer ends without its finish frame, on a
@@ -42,14 +117,22 @@ export declare function receiveRecording(session: P2PSession, request: {
42
117
  path: string;
43
118
  accountId: string;
44
119
  channel: number;
120
+ finishChannel: number;
45
121
  timeoutMs?: number;
46
122
  signal?: AbortSignal;
47
123
  }): RecordingTransfer;
48
124
  /**
49
- * Decode a recording's frames, in arrival order, into elementary streams. Split pictures are assembled
50
- * before they enter the output; an orphan continuation cannot become its own picture. Keyframes go
51
- * through one {@link VideoFrameDecoder}, whose media key then opens the audio; a keyframe that does not
52
- * open under `eccPrivateKeyHex`, and every audio frame before the first media key, are dropped. Rejects with
53
- * {@link RecordingDownloadError} `undecodable` when no video frame decodes.
125
+ * Decode a recording's frames, in arrival order, into elementary streams. `station` sets where a split
126
+ * picture continues ({@link RecordingStation.chunkBytes}) and whether the frames are sealed
127
+ * ({@link RecordingStation.sealed}). On a sealed station a keyframe goes through one {@link VideoFrameDecoder}
128
+ * under `eccPrivateKeyHex`, and audio opens under that decoder's current media key; a keyframe that does not
129
+ * open, and audio before the first media key, are dropped. On a station in the clear every video payload is read
130
+ * as it is, and audio is everything after its 16-byte header; a keyframe that is in fact sealed begins with an
131
+ * ECIES point rather than a start code, so the assembler drops it. Split pictures are assembled before they
132
+ * enter the output, and an orphan continuation cannot become its own picture. Audio frames whose codec byte is
133
+ * not AAC-LC are dropped.
134
+ *
135
+ * Rejects with {@link RecordingDownloadError} `undecodable` when no video frame decodes, and on a sealed
136
+ * station whose key is absent or not a P-256 key.
54
137
  */
55
- export declare function decodeRecording(frames: readonly RecordingFrame[], eccPrivateKeyHex: string): RecordingDownload;
138
+ export declare function decodeRecording(frames: readonly RecordingFrame[], station: Pick<RecordingStation, "sealed" | "chunkBytes">, eccPrivateKeyHex?: string): RecordingDownload;
@@ -256,7 +256,8 @@ export declare class SharedLiveSource {
256
256
  *
257
257
  * The previous stream is dropped rather than stopped through the state machine: it speaks to a session that
258
258
  * is gone, and its `stop` would be read as an upstream end. Only a warm-up that asked for a replacement
259
- * rewarms, so this is inert on a source that is streaming or has already failed.
259
+ * rewarms, so this is inert on a source that is streaming or has already failed. Each call replaces the
260
+ * re-issue ticker rather than adding one, so the start goes out once per interval however often it runs.
260
261
  */
261
262
  rewarm(): void;
262
263
  /**
@@ -376,8 +377,13 @@ export declare class SharedLiveSource {
376
377
  private isKeyframe;
377
378
  /**
378
379
  * Handle a consumer leaving. When the last one detaches (1→0), release the station-session user via
379
- * {@link SharedLiveSourceOptions.onIdle} (its own longer idle timer then arms) and arm the stream's
380
- * linger teardown.
380
+ * {@link SharedLiveSourceOptions.onIdle} (its own longer idle timer then arms) and, while a stream is
381
+ * still running, arm its linger teardown.
382
+ *
383
+ * A source that ended its own stream ends its consumers before tearing down, and a Readable consumer
384
+ * detaches only once it has reached EOF — after that teardown. The source is then already `stopped`,
385
+ * with its session claim released, and stays so: lingering would make the owner hand it out again
386
+ * instead of building a source that holds a session.
381
387
  */
382
388
  private onDetach;
383
389
  /** Refcount hit zero — arm the linger teardown. A new attach in the window cancels it. */
@@ -102,8 +102,8 @@ export declare const STATION_CHUNK_BYTES = 64000;
102
102
  * total length is nowhere on the wire and cannot be waited for. What the wire does carry, verified on two
103
103
  * models:
104
104
  *
105
- * - the frames of one unit **repeat its header** — same timestamp, same {@link VideoFrameHeader.sequence} —
106
- * while consecutive units differ in both;
105
+ * - the frames of one unit **repeat its {@link VideoFrameHeader.sequence}**, and carry the unit's timestamp
106
+ * or one stamped up to `SPLIT_UNIT_STAMP_SPREAD_MS` later;
107
107
  * - every frame of a split unit is filled to {@link STATION_CHUNK_BYTES} except the last;
108
108
  * - a frame that STARTS a unit begins with an Annex-B start code once decoded, and a continuation begins
109
109
  * mid-NAL, so it does not.
@@ -122,23 +122,27 @@ export declare const STATION_CHUNK_BYTES = 64000;
122
122
  *
123
123
  * Two consequences worth stating. A unit that is exactly the threshold long, or an exact multiple of it,
124
124
  * ends on a full frame and is dropped as truncated — one frame in ~64000, reported, and the alternative is
125
- * handing a decoder bytes that may genuinely be short. And a station that fills to some OTHER size is not
126
- * recognised: its units stay exactly as they arrive rather than being mis-joined.
125
+ * handing a decoder bytes that may genuinely be short. And a station that fills to some OTHER size is
126
+ * recognised only by an assembler constructed with that size: elsewhere its units stay exactly as they arrive
127
+ * rather than being mis-joined.
127
128
  */
128
129
  export declare class AccessUnitAssembler {
129
130
  private readonly onDropped?;
131
+ private readonly chunkBytes;
130
132
  private open?;
131
133
  private droppedUnits;
132
134
  /**
133
135
  * @param onDropped called for each incomplete unit discarded, with what had arrived, how many frames it
134
136
  * arrived in, and how many this assembler has dropped in total — a running count, so no caller keeps
135
137
  * its own.
138
+ * @param chunkBytes the payload size the station fills a frame to before continuing a unit in the next. A
139
+ * unit is held while its latest frame declares exactly this. {@link STATION_CHUNK_BYTES} when omitted.
136
140
  */
137
141
  constructor(onDropped?: ((drop: {
138
142
  carried: number;
139
143
  chunks: number;
140
144
  count: number;
141
- }) => void) | undefined);
145
+ }) => void) | undefined, chunkBytes?: number);
142
146
  /**
143
147
  * Feed one raw `CMD_VIDEO_FRAME` payload; returns the access units it completed — none while a unit is
144
148
  * still being filled, one in the ordinary case.
@@ -153,3 +157,5 @@ export declare class AccessUnitAssembler {
153
157
  /** Forget an incomplete unit, counting and reporting it. */
154
158
  discard(): void;
155
159
  }
160
+ /** Whether a decoded payload OPENS an access unit — a continuation begins mid-NAL, with no start code. */
161
+ export declare function beginsAccessUnit(body: Buffer): boolean;
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Persistence for FCM credentials + seen persistent-ids, so we register the FCM
3
+ * token ONCE and just reconnect (and don't re-receive old pushes) across runs.
4
+ */
5
+ import { FileSessionStore } from "../../core/store.js";
1
6
  import type { FcmCredentials } from "./types.js";
2
7
  export interface PersistedPush {
3
8
  creds: FcmCredentials;
@@ -14,10 +19,6 @@ export declare class MemoryFcmStore implements FcmStore {
14
19
  save(p: PersistedPush): void;
15
20
  clear(): void;
16
21
  }
17
- export declare class FileFcmStore implements FcmStore {
18
- private readonly path;
19
- constructor(path: string);
20
- load(): PersistedPush | null;
21
- save(p: PersistedPush): void;
22
- clear(): void;
22
+ /** JSON-file FCM store: the {@link FileSessionStore} file format and write guarantees, on the push record. */
23
+ export declare class FileFcmStore extends FileSessionStore<PersistedPush> implements FcmStore {
23
24
  }
@@ -14,6 +14,11 @@
14
14
  * a live pull holds it open. Sends on a session are serialised; an ACK is correlated by its outer command
15
15
  * and segment, since it carries no inner command. An ACK is not an observation of the resulting state.
16
16
  *
17
+ * The station's own database is read over the same channel: a `set-payload` with inner `1306`
18
+ * (CMD_DATABASE) answers a table and inner `1308` (CMD_DATABASE_IMAGE) a stored image, each as one
19
+ * notify frame carrying the whole document, zstd-compressed when large. The notify link carries
20
+ * unrelated station reports between them, so a reply is matched by its content, never by arrival order.
21
+ *
17
22
  * Live view of an attached camera rides the same session (the hub accepts one RTC session per account),
18
23
  * through one shared live source per station: the station serves one camera at a time, so a second
19
24
  * camera is refused until the first one's view ends.
@@ -72,8 +77,48 @@ export declare class RtcCommandRouter {
72
77
  private readonly lives;
73
78
  private readonly warm;
74
79
  private readonly callSources;
80
+ private lastTransaction;
75
81
  constructor(deps: RtcCommandRouterDeps);
76
82
  dispatchCommand(route: RtcRoute, cmd: Command): Promise<void>;
83
+ /**
84
+ * Query one on-station table over the station's control channel and answer its rows.
85
+ *
86
+ * Sends the `1350` envelope with inner `{cmd: 1306, payload: {cmd: 10000, table, payload, transaction}}`,
87
+ * the whole-table query, on the station channel. The hub ACKs on the command link and answers with one notify frame whose body
88
+ * carries `{cmd, table, data, mIntRet, msg, transaction, …}`, echoing the read's `transaction`; that echo
89
+ * is the only thing a reply is attributed by, and every read sends a `transaction` no other read on this
90
+ * router has sent. Resolves with the reply's `data` rows. Rejects with the hub's `msg` on a non-zero
91
+ * `mIntRet`, on an ACK refusing the envelope, at once when `signal` aborts (queued or sent), when the
92
+ * session closes, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
93
+ *
94
+ * One read or command at a time per station: the read holds the station's send queue until it settles.
95
+ */
96
+ readTable(route: RtcRoute, table: string, opts?: {
97
+ timeoutMs?: number;
98
+ signal?: AbortSignal;
99
+ }): Promise<unknown[]>;
100
+ /**
101
+ * Fetch one stored image by its on-station path over the station's control channel.
102
+ *
103
+ * Sends the `1350` envelope with inner `{cmd: 1308, payload: [{file}]}` on the station channel. The hub
104
+ * ACKs on the command link and answers with one notify frame whose body is `{file, content}`, `content`
105
+ * the image in base64, and no `transaction`; the reply is the one whose `file` equals `filePath`. A reply
106
+ * arriving after an abandoned read of the same path carries that path's bytes, which are the bytes a
107
+ * later read of it asks for. Resolves with the decoded bytes. Rejects on an ACK refusing the envelope,
108
+ * at once when `signal` aborts (queued or sent), when the session closes, and when `timeoutMs` (default
109
+ * {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
110
+ *
111
+ * One read or command at a time per station: the read holds the station's send queue until it settles.
112
+ */
113
+ readImage(route: RtcRoute, filePath: string, opts?: {
114
+ timeoutMs?: number;
115
+ signal?: AbortSignal;
116
+ }): Promise<Buffer>;
117
+ /**
118
+ * A `transaction` stamp in the vendor app's shape, a decimal epoch-in-milliseconds string, strictly
119
+ * greater than the last one this router issued, so two reads in the same millisecond never share one.
120
+ */
121
+ private nextTransaction;
77
122
  /**
78
123
  * The media of a camera on a T9000: live video, a readable feed and a live still over the station's
79
124
  * session. Recording has no wire here and is refused; the optional members it has no wire for are absent.
@@ -108,9 +153,24 @@ export declare class RtcCommandRouter {
108
153
  private refuseIfWatched;
109
154
  private newLiveSource;
110
155
  /**
111
- * Completes on the command ACK or a correlated control-result notification. The notification must
112
- * match the request channel, parameter and segment and contain a structured payload. Receipt does
113
- * not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
156
+ * Send one envelope on the station's session, queued behind its other sends, and settle as
157
+ * {@link sendAwaitAck} does. `body` is the inner payload; the station session, the `account_id` it is
158
+ * stamped with and the packet are resolved here, so a command and a read reach the wire through the same
159
+ * steps. With `reply.signal`, the caller's wait rejects the moment it aborts, also while the send is still
160
+ * queued; a send not yet made by then is skipped.
161
+ */
162
+ private sendQueued;
163
+ /**
164
+ * Without `reply`, completes on the command ACK or a correlated control-result notification. The
165
+ * notification must match the request channel, parameter and segment and contain a structured payload.
166
+ * Receipt does not establish physical actuation. Bounded by {@link ACK_TIMEOUT_MS}.
167
+ *
168
+ * With `reply`, a zero-result ACK is not the answer: the first notify body (an object) for which
169
+ * `reply.pick` answers a value resolves with it, one `pick` throws on rejects with that error, and every
170
+ * other notify frame is left alone. Bounded by `reply.timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}),
171
+ * and ended by `reply.signal`; one already aborted is not sent.
172
+ *
173
+ * Either way an ACK with a non-zero result code rejects, and the command is sent once.
114
174
  */
115
175
  private sendAwaitAck;
116
176
  private newSession;
@@ -1,14 +1,3 @@
1
- /**
2
- * The command framing a HomeBase S1 Pro (T9000) speaks over its WebRTC data channel — the wire the
3
- * security.eufy.com web client builds, reproduced byte-exact.
4
- *
5
- * A packet is a 16-byte header behind the ASCII magic `XZYH`, then a JSON body, sent in the clear (the
6
- * DTLS channel is the encryption). Replies come back with `isResponse = 1`, the same `segment` the
7
- * request carried, and a body whose first four bytes are a little-endian result code.
8
- *
9
- * Every offset is the portal bundle's header builder and parser (`Gr()` / `Qr()`) and its
10
- * `worker_sctp_send`.
11
- */
12
1
  export declare const PORTAL_HEADER_LENGTH = 16;
13
2
  /** The SET_PAYLOAD envelope. */
14
3
  export declare const PORTAL_CMD_SET_PAYLOAD = 1350;
@@ -61,8 +50,9 @@ export declare function isPortalPacket(buf: Buffer): boolean;
61
50
  /** A complete request packet: header + body. */
62
51
  export declare function buildPortalPacket(req: PortalRequest): Buffer;
63
52
  /**
64
- * Parse an inbound packet. On the command channel the body opens with the int32 result code and may
65
- * carry JSON after it; on the notify channel the whole body is JSON with a nested `cmd`.
53
+ * Parse an inbound packet, opening a zstd-compressed one first. On the command channel the body opens
54
+ * with the int32 result code and may carry JSON after it; on the notify channel the whole body is JSON
55
+ * with a nested `cmd`. `undefined` for a frame that is neither a packet nor a stream holding one.
66
56
  */
67
57
  export declare function parsePortalPacket(buf: Buffer, linkType?: number): PortalResponse | undefined;
68
58
  /** A 1..255 segment counter that never yields 0 (the portal reserves it). */
@@ -15,8 +15,10 @@
15
15
  *
16
16
  * The command data channel is the session: `close` fires once, when the channel closes, the peer fails,
17
17
  * the signalling socket drops or {@link RtcSession.close} is called, even while the peer connection is
18
- * nominally up. A peer that cannot be built when the hub grants the relay ends the session there: the
19
- * failure is emitted as `error` and `close` fires carrying it.
18
+ * nominally up. A dropped signalling socket closes the peer with it: a hub that still sees the peer
19
+ * answers every later `scall` with 486 until that peer's ICE gives up. A peer that cannot be built when
20
+ * the hub grants the relay ends the session there: the failure is emitted as `error` and `close` fires
21
+ * carrying it.
20
22
  *
21
23
  * A session whose peer is a {@link RtcPeerOptions.mediaTrack} peer has no command channel: `close` fires
22
24
  * when the peer fails or closes, and the video track's packets are emitted as `rtp`.