@mega-yfue/eufy-sdk 0.5.0-beta.8 → 0.5.0-beta.81

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 (61) hide show
  1. package/dist/client/device-registry.d.ts +24 -2
  2. package/dist/client/eufy-mega.d.ts +75 -6
  3. package/dist/client/types.d.ts +1 -1
  4. package/dist/core/contracts.d.ts +80 -33
  5. package/dist/core/store.d.ts +4 -0
  6. package/dist/core/types.d.ts +2 -0
  7. package/dist/index.js +3754 -617
  8. package/dist/index.js.map +4 -4
  9. package/dist/model/capabilities/arming.d.ts +39 -4
  10. package/dist/model/capabilities/camera.d.ts +47 -18
  11. package/dist/model/capabilities/dp-catalog.d.ts +4 -3
  12. package/dist/model/capabilities/index.d.ts +16 -3
  13. package/dist/model/capabilities/light.d.ts +3 -0
  14. package/dist/model/capabilities/lock.d.ts +21 -7
  15. package/dist/model/capabilities/motion.d.ts +17 -2
  16. package/dist/model/capabilities/mower.d.ts +230 -0
  17. package/dist/model/capabilities/rtsp.d.ts +16 -3
  18. package/dist/model/capabilities/smart-drop.d.ts +20 -0
  19. package/dist/model/capabilities/suction.d.ts +5 -5
  20. package/dist/model/capabilities/types.d.ts +10 -0
  21. package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
  22. package/dist/model/classify.d.ts +1 -2
  23. package/dist/model/device-family.d.ts +12 -0
  24. package/dist/model/param-dictionary.d.ts +6 -0
  25. package/dist/model/param-namespace.d.ts +1 -1
  26. package/dist/model/registry.d.ts +3 -2
  27. package/dist/model/types.d.ts +26 -12
  28. package/dist/transport/abortable.d.ts +8 -0
  29. package/dist/transport/http/mega-client.d.ts +34 -2
  30. package/dist/transport/mqtt/broker-discovery.d.ts +2 -1
  31. package/dist/transport/mqtt/command-router.d.ts +3 -2
  32. package/dist/transport/p2p/audio-frame.d.ts +14 -0
  33. package/dist/transport/p2p/codec.d.ts +16 -0
  34. package/dist/transport/p2p/command-router.d.ts +19 -30
  35. package/dist/transport/p2p/fmp4.d.ts +12 -2
  36. package/dist/transport/p2p/live-trace.d.ts +25 -2
  37. package/dist/transport/p2p/p2p-session.d.ts +55 -12
  38. package/dist/transport/p2p/readable-egress.d.ts +4 -0
  39. package/dist/transport/p2p/recording-download.d.ts +4 -3
  40. package/dist/transport/p2p/shared-live-source.d.ts +29 -14
  41. package/dist/transport/p2p/talkback.d.ts +21 -10
  42. package/dist/transport/p2p/video.d.ts +2 -0
  43. package/dist/transport/rtc/call-live.d.ts +41 -0
  44. package/dist/transport/rtc/command-router.d.ts +145 -0
  45. package/dist/transport/rtc/live.d.ts +76 -0
  46. package/dist/transport/rtc/peer.d.ts +122 -0
  47. package/dist/transport/rtc/portal-keepalive.d.ts +11 -0
  48. package/dist/transport/rtc/portal-packet.d.ts +72 -0
  49. package/dist/transport/rtc/ptcs-framer.d.ts +86 -0
  50. package/dist/transport/rtc/scall-sdp.d.ts +50 -0
  51. package/dist/transport/rtc/session.d.ts +99 -0
  52. package/dist/transport/rtc/signaling.d.ts +139 -0
  53. package/dist/transport/token-echo.d.ts +8 -0
  54. package/dist/transport/tuya/account.d.ts +8 -5
  55. package/dist/transport/tuya/client.d.ts +19 -5
  56. package/dist/transport/tuya/command-router.d.ts +10 -14
  57. package/dist/transport/tuya/dp-codec.d.ts +2 -23
  58. package/dist/transport/tuya/et3.d.ts +22 -0
  59. package/dist/transport/tuya/request.d.ts +18 -8
  60. package/dist/transport/tuya/sign.d.ts +7 -0
  61. package/package.json +2 -1
@@ -209,6 +209,8 @@ export interface SignedRetry {
209
209
  export declare class MegaHttpClient {
210
210
  private readonly cfg;
211
211
  private region;
212
+ /** The host `estimate_domain` answered, persisted with the session. */
213
+ private estimatedDomain;
212
214
  private bootstrapDomain?;
213
215
  private sessionKey?;
214
216
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
@@ -228,6 +230,8 @@ export declare class MegaHttpClient {
228
230
  /** True while a 2FA code is outstanding: `auth_` holds only the limited pre-verify token, so the
229
231
  * restored-session short-circuit must NOT treat it as a usable session. Cleared on Ok/reset. */
230
232
  private pending2fa;
233
+ /** An autonomous re-login answered captcha/2FA; see {@link canReauthenticate}. */
234
+ private reloginChallenged;
231
235
  private tokenExpiresAt;
232
236
  /**
233
237
  * Stable per-install device id: `openudid` as configured, as restored from the session store, or as
@@ -259,10 +263,12 @@ export declare class MegaHttpClient {
259
263
  constructor(cfg: MegaClientConfig);
260
264
  /**
261
265
  * Install the session the store holds, if it holds a usable one: the token + its bound ECDH key, skipping
262
- * estimate/key-exchange/login/2FA entirely. Answers the token adopted, or `undefined`.
266
+ * estimate/key-exchange/login/2FA entirely. Answers the token adopted, or `undefined`. A session logged in
267
+ * with a country other than the configured one is not usable: its shard is the other country's.
263
268
  *
264
269
  * Read at construction, and again when a rejection is being recovered from — a store SHARED with another
265
270
  * client may already hold the session that client obtained, which is cheaper to adopt than to compete with.
271
+ * An adopted session is a complete one, so it ends any 2FA or challenged re-login this client was left with.
266
272
  *
267
273
  * Note: the device identity (openudid + phone model + media UA) is NOT restored here — it is resolved once
268
274
  * in the constructor, where an explicit config wins over the stored value, and must not be re-derived from a
@@ -275,6 +281,21 @@ export declare class MegaHttpClient {
275
281
  } | undefined;
276
282
  /** The active region shard (e.g. `"eu-pr"`, `"us-pr"`), set after {@link login} or a region override. */
277
283
  get regionShard(): RegionShard;
284
+ /**
285
+ * The shard the RTC signalling signs on: the regional prefix of the estimated domain
286
+ * (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
287
+ * like `ie-pr`, whose accounts need the IE sign host and cluster.
288
+ */
289
+ get rtcShard(): string;
290
+ /**
291
+ * The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
292
+ * ("gtoken not equal userid") unless its gtoken is the one every authed HTTP call carries.
293
+ */
294
+ rtcIdentity(): {
295
+ authToken: string;
296
+ userId: string;
297
+ gtoken: string;
298
+ } | undefined;
278
299
  /**
279
300
  * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
280
301
  * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
@@ -367,6 +388,15 @@ export declare class MegaHttpClient {
367
388
  * params instead, which also carry `{param_type, param_value, update_time}` and are not owner-gated.
368
389
  */
369
390
  getDeviceParamList<T = unknown>(deviceSn: string): Promise<T>;
391
+ /**
392
+ * Fetch the account's devices as the `devicerelation` service lists them, `{attribute: 7, house_id: ""}`.
393
+ *
394
+ * Each entry is `{device: {device_sn, dps, …}}`, and `dps` is the cloud's last-known value of every
395
+ * data point that device has reported, keyed by DP number. Answered for an account a device is shared
396
+ * with as well as for its owner. Sent with `category: eufy_home`, without which the service answers
397
+ * `10000`.
398
+ */
399
+ getDeviceRelationList<T = unknown>(): Promise<T>;
370
400
  /**
371
401
  * Fetch one page of a device's **cleaning history** from the mega `clean` service.
372
402
  *
@@ -563,7 +593,9 @@ export declare class MegaHttpClient {
563
593
  * Structural rather than a list of paths: a login round trip is itself an authenticated call while a limited
564
594
  * token is held, so re-logging in from inside one would recurse — and a path list would have to be
565
595
  * maintained alongside every request the login flow makes. A 2FA code already outstanding is a login a human
566
- * is part-way through, and restarting it silently would discard it. No credentials means nothing to try,
596
+ * is part-way through, and restarting it silently would discard it. A re-login that already came back needing
597
+ * a captcha or a 2FA code would need the same again, and each attempt sends the account another code, so
598
+ * none is tried until a login completes or a stored session is adopted. No credentials means nothing to try,
567
599
  * so the rejection surfaces instead.
568
600
  */
569
601
  private canReauthenticate;
@@ -8,8 +8,9 @@ export interface BrokerCredentials {
8
8
  }
9
9
  export interface ProbeResult {
10
10
  ip: string;
11
- /** True iff SUBSCRIBE to the target topic was granted (qos 0/1/2, not the 0x80 deny code). */
11
+ /** True iff SUBSCRIBE to the target topic was granted (qos 0/1/2). */
12
12
  granted: boolean;
13
+ /** The SUBACK return code for the topic, the 0x80 deny code included. */
13
14
  grantedQos?: number;
14
15
  error?: string;
15
16
  ms: number;
@@ -136,8 +136,9 @@ export declare class MqttCommandRouter {
136
136
  * One exception is known: a single garage unit whose own-cert SUBSCRIBE was denied on every candidate
137
137
  * while a phone-extracted cert granted on the same one, with a sibling of the same model on the same
138
138
  * account granting fine. That is a per-device authorization gap on the vendor's backend, not a
139
- * broker-instance problem and not something this SDK works around — such a device reports as offline
140
- * (the throw below) until the backend grants the account's own credentials.
139
+ * broker-instance problem and not something this SDK works around — the throw below names each
140
+ * candidate's outcome (its SUBACK deny code, a timeout or a connection error) until the backend grants
141
+ * the account's own credentials.
141
142
  *
142
143
  * One fresh, explicitly non-reconnecting (`reconnectPeriod: 0`) connection per call — it repays the
143
144
  * full mTLS handshake every command and tears the socket down right after (see
@@ -0,0 +1,14 @@
1
+ import type { AudioCodec } from "../../core/contracts.js";
2
+ export declare const CMD_AUDIO_FRAME = 1301;
3
+ /** Length of the audio frame header before the payload. */
4
+ export declare const AUDIO_HEADER_LEN = 16;
5
+ /** Byte offset of the codec id between the frame-size and frame-number fields. */
6
+ export declare const AUDIO_TYPE_OFFSET = 5;
7
+ /**
8
+ * Codec ids the station puts at {@link AUDIO_TYPE_OFFSET}, as the v6 app maps them
9
+ * (`AudioReader.AAC_LC = 0`, `G711A = 2`, `AAC_ELD = 7`).
10
+ *
11
+ * The app's `PCM_SILENCE = -1` is absent on purpose: it is not a wire value. The app injects it
12
+ * itself when a stream produces no audio at all, to feed its decoder silence.
13
+ */
14
+ export declare const AUDIO_CODECS: Readonly<Record<number, AudioCodec>>;
@@ -111,6 +111,22 @@ export declare function eciesUnwrap(envelope: Buffer, eccPrivateKeyHex: string,
111
111
  * Returns the 32-byte key, or undefined if the inputs don't parse.
112
112
  */
113
113
  export declare function deriveLevel2KeyFromGatewayInfo(gwPayload: Buffer, eccPrivateKeyHex: string): Buffer | undefined;
114
+ /**
115
+ * Derive the P2P **level-2** AES-128-ECB session key from a *decrypted* `CMD_GATEWAYINFO` (1100)
116
+ * payload and the RSA private key of a cipher whose `get_ciphers` answer carries no ECC key.
117
+ *
118
+ * Layout of the decrypted payload: `cipher_id(2 LE) ‖ 0000 ‖ RSA ciphertext ‖ …`, the ciphertext as
119
+ * long as the key's modulus. It is the 16-byte session key under PKCS#1 v1.5 encryption padding
120
+ * (`00 02 ‖ ≥8 non-zero bytes ‖ 00 ‖ key`). The padding is checked here over the raw RSA result so a wrong
121
+ * key fails closed: under OpenSSL's implicit rejection, `RSA_PKCS1_PADDING` answers random bytes for it
122
+ * instead of throwing, and those can be 16 bytes long.
123
+ *
124
+ * `rsaPrivateKey` is the `get_ciphers` `private_key`: base64 PKCS#8 DER.
125
+ *
126
+ * Returns the 16-byte key, or `undefined` when the key does not parse, the payload is short, or the
127
+ * padding or key length is wrong (fails closed, never throws).
128
+ */
129
+ export declare function deriveLevel2KeyFromRsaGatewayInfo(gwPayload: Buffer, rsaPrivateKey: string): Buffer | undefined;
114
130
  /** Local-lookup payload: two zero bytes. */
115
131
  export declare function buildLocalLookupPayload(): Buffer;
116
132
  /** Cloud-lookup payload (variant 2): `[p2pDid:20][dskKey][0x00000000]`. */
@@ -1,14 +1,3 @@
1
- /**
2
- * P2P command router — the transport-side owner of the ThroughTek PPCS sessions and every wire
3
- * operation over them: opening sessions, resolving a serial to its session + routing params,
4
- * mapping a transport-neutral {@link Command} to a concrete frame (encryption level, wire shape),
5
- * the fire-and-forget control senders, request/reply queries, and the media provider.
6
- *
7
- * Layering: this module knows P2P bytes; it does NOT know capabilities. Frame → semantic-event
8
- * decoding is a model concern, so raw frames are handed back to the client via {@link P2PRouterDeps.onFrame}
9
- * (the client gates them on device capabilities and emits typed events). This keeps transport free of
10
- * any `model/` import — the capability↔transport decorrelation invariant.
11
- */
12
1
  import type { MegaHttpClient } from "../http/mega-client.js";
13
2
  import type { EufyDevice } from "../../core/types.js";
14
3
  import type { Command, AutoLockSnapshot, MediaProvider, SharedSourceHints, AbortableCall } from "../../core/contracts.js";
@@ -119,7 +108,7 @@ export declare class P2PCommandRouter {
119
108
  * shared — see {@link P2PCommandRouter.openTalkback}.
120
109
  */
121
110
  private readonly talkbacks;
122
- /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
111
+ /** cipher_id → its ECC and RSA private keys (one eufylife get_ciphers call per cipher), shared across (re)opens. */
123
112
  private readonly cipherKeyCache;
124
113
  /** The recording download in flight per station; a station serves one at a time. */
125
114
  private readonly recordingDownloads;
@@ -224,7 +213,7 @@ export declare class P2PCommandRouter {
224
213
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
225
214
  *
226
215
  * `resolveCipherKey` auto-negotiates the level-2 session key from `CMD_GATEWAYINFO` by resolving the
227
- * cipher's ECC private key via cloud `get_ciphers`, so signCode 2/8 frames decrypt live; results are
216
+ * cipher's private keys via cloud `get_ciphers`, so signCode 2/8 frames decrypt live; results are
228
217
  * cached on the router instance so a lazy re-open (after idle-detach) reuses the lookup. Only a
229
218
  * SUCCESSFUL lookup is cached — caching `undefined` after a transient failure would permanently
230
219
  * disable level-2 for the session's life.
@@ -240,11 +229,12 @@ export declare class P2PCommandRouter {
240
229
  */
241
230
  private makeSession;
242
231
  /**
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.
232
+ * The ECC and RSA private keys of `cipherId` (`ecc_private_key`, `private_key`), from the router's cache
233
+ * or one `get_ciphers` call. When the cloud answers with another cipher that carries an ECC key and the
234
+ * asked one does not, that one is used and traced on `session`. Only a successful lookup — one that
235
+ * yields either key — is cached.
246
236
  */
247
- private cipherKeyFor;
237
+ private cipherKeysFor;
248
238
  /**
249
239
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
250
240
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -311,6 +301,11 @@ export declare class P2PCommandRouter {
311
301
  * stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
312
302
  * instead of playing into silence.
313
303
  *
304
+ * **On a model in {@link ELD_TALKBACK_MODELS}, the speaker plays the codec the camera sends.** So the first
305
+ * audio frame of the media session this talkback holds decides its codec ({@link speakerCodec}), within
306
+ * {@link TALKBACK_CODEC_WAIT_MS} of the session's first frame, and the claim is re-checked after that wait as
307
+ * after the warm-up. Every other model opens `aac-lc` at once.
308
+ *
314
309
  * The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
315
310
  * {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
316
311
  * own-session camera that legitimately never negotiates one.
@@ -456,15 +451,6 @@ export declare class P2PCommandRouter {
456
451
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
457
452
  * unaffected.
458
453
  */
459
- /**
460
- * Attach a consumer, unless the caller has already abandoned the call.
461
- *
462
- * The acquisition it just waited through can outlast the caller's interest, and a consumer attached for
463
- * somebody who has gone keeps the pull warm for nobody. Detaching immediately gives the pull back, which
464
- * lets it linger and fall away if this was the only thing holding it, and leaves it untouched if it was
465
- * not.
466
- */
467
- private attachUnlessAborted;
468
454
  /**
469
455
  * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
470
456
  * on a session goes with it, and a media session opened for this source alone is closed: nothing else
@@ -524,11 +510,11 @@ export declare class P2PCommandRouter {
524
510
  */
525
511
  private onLiveStartFailed;
526
512
  /**
527
- * Replace the session under a warming source whose media start nothing acknowledged, and warm again on it.
513
+ * Replace the session under a warming source it is not serving, and warm again on it.
528
514
  *
529
515
  * Only a STANDALONE device's session is replaced, for the reason {@link onLiveStartFailed} gives: an attached
530
516
  * camera shares its HomeBase session with every other camera on it, and closing that to recover one would
531
- * drop the rest. Such a source keeps the re-issue it always had.
517
+ * drop the rest. Such a source keeps the re-issue it always had, which the answer (false) tells it.
532
518
  *
533
519
  * The source is left warming throughout, holding the deadline it started, so this either produces a stream
534
520
  * within that window or fails exactly as it would have. A replacement that cannot be opened leaves the
@@ -562,6 +548,7 @@ export declare class P2PCommandRouter {
562
548
  */
563
549
  p2pQuery(sn: string, subCmd: number, opts?: {
564
550
  timeoutMs?: number;
551
+ payload?: Record<string, unknown>;
565
552
  }): Promise<Record<string, unknown>>;
566
553
  /**
567
554
  * **Generic control-payload request/reply query.** Sends a `CONTROL_PAYLOAD` (1700) `{commandType,
@@ -690,7 +677,7 @@ export declare class P2PCommandRouter {
690
677
  * **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
691
678
  * exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
692
679
  * capture of the app's own SET_SNOOZE_TIME (1271) frame — see `param-dictionary.ts`'s `1271` entry
693
- * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.ALARM_DELAY_CONFIG`) reuses
680
+ * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.SET_ALL_ACTION`) reuses
694
681
  * the same bare-JSON shape.
695
682
  */
696
683
  private sendJsonRaw;
@@ -705,7 +692,9 @@ export declare class P2PCommandRouter {
705
692
  * HomeBase-attached device takes the GCM signCode-8 form, a standalone one the level-1 form. A
706
693
  * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
707
694
  * unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
708
- * camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
695
+ * camera accepts the level-1 form. With no `form` (default) it stays level-2 only and waits for the
696
+ * key every level-2 sender uses; `level2Sender: "set-payload"` also takes an RSA cipher's AES-128-ECB
697
+ * key (see {@link P2PSession.sendControlLevel2}). A `resolved` session keeps the wait its caller gave it.
709
698
  *
710
699
  * Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
711
700
  * control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
@@ -17,9 +17,12 @@ export declare class Fmp4Muxer {
17
17
  private baseDecodeTime;
18
18
  private fragTicks;
19
19
  private samples;
20
- private lastVideoTimestampMs?;
20
+ /** Ticks from the first video sample's start to the newest sample's end, at its provisional duration. */
21
+ private videoTicks;
21
22
  private readonly audioRequested;
22
23
  private audioCodec?;
24
+ /** Decoder config of an AAC-ELD track, taken from its frames; absent for AAC-LC, whose `esds` config is fixed. */
25
+ private audioConfig?;
23
26
  private audioDisabled;
24
27
  private audioSamples;
25
28
  private audioBaseDecodeTime;
@@ -35,9 +38,16 @@ export declare class Fmp4Muxer {
35
38
  * Push one access unit. Returns any completed segments: the `init` on the first keyframe, and/or a
36
39
  * media fragment when this frame closed the open one. Returns `undefined` if nothing is emitted yet
37
40
  * (e.g. delta frames before the first keyframe).
41
+ *
42
+ * A sample lasts until the next frame's `timestampMs`, measured from the first keyframe's, so the video
43
+ * timeline follows the arrival clock; a frame that shares the previous one's timestamp leaves that
44
+ * sample one tick long, and the next frame's duration absorbs the difference.
38
45
  */
39
46
  push(frame: LiveVideoFrame, timestampMs?: number): MediaFragment | undefined;
40
- /** Add one station-declared AAC access unit to the audio track. G.711 remains available via `live()`. */
47
+ /**
48
+ * Add one station-declared AAC access unit to the audio track: an ADTS frame for AAC-LC, a raw access unit
49
+ * described by its frame's config for AAC-ELD. G.711 remains available via `live()`.
50
+ */
41
51
  pushAudio(frame: LiveAudioFrame, timestampMs?: number): MediaFragment | undefined;
42
52
  /** Permanently omit incompatible audio while allowing the video recording to continue. */
43
53
  private disableAudio;
@@ -11,6 +11,7 @@
11
11
  *
12
12
  * @module p2p/live-trace
13
13
  */
14
+ import type { LiveStreamStartFailureReason, LiveStreamStartStage } from "../../core/contracts.js";
14
15
  import type { Logger } from "../../core/logger.js";
15
16
  /** The message every startup trace is logged under. */
16
17
  export declare const LIVE_TRACE_MESSAGE = "[live] start trace";
@@ -118,11 +119,19 @@ export type LiveTrace =
118
119
  phase: "level2-wait";
119
120
  waitMs: number;
120
121
  }
121
- /** The station's level-2 key was negotiated, under the cipher it selected. */
122
+ /** The station's AES-256-GCM level-2 key was negotiated, under the cipher it selected. */
122
123
  | {
123
124
  phase: "level2-ready";
124
125
  cipherId: number;
125
126
  }
127
+ /**
128
+ * The station's cipher negotiated only the AES-128-ECB level-2 key of an RSA cipher, under the cipher it
129
+ * selected. That key seals a level-2 set-payload and nothing else, so this is not `level2-ready`.
130
+ */
131
+ | {
132
+ phase: "level2-set-payload-only";
133
+ cipherId: number;
134
+ }
126
135
  /**
127
136
  * The station's key is not coming, why, and the cipher where a station named one.
128
137
  *
@@ -145,7 +154,8 @@ export type LiveTrace =
145
154
  * A station's cipher was answered with material for a DIFFERENT cipher, which was used in its place.
146
155
  *
147
156
  * The one lookup outcome no other phase accounts for: material for the cipher the station named is followed
148
- * by `level2-ready` or by `level2-unavailable` with `derivation-failed`, an answer holding none by
157
+ * by `level2-ready`, `level2-set-payload-only` or `level2-unavailable` with `derivation-failed`, an answer
158
+ * holding none by
149
159
  * `no-cipher-key`, and a lookup that threw is reported as an error. Substituted material derives to
150
160
  * nothing and otherwise reads as a station fault. `cipherId` is the cipher the station asked for,
151
161
  * `answeredCipherId` the one whose material was used.
@@ -236,6 +246,19 @@ export type LiveTrace =
236
246
  phase: "channel-silent";
237
247
  silentMs: number;
238
248
  outcome: "reasserted" | "declined";
249
+ }
250
+ /**
251
+ * A live start failed, with the facts of the `LiveStreamStartError` it was refused with.
252
+ *
253
+ * `errorName` is the upstream error's class name, present for `source-error` only; its message is never
254
+ * carried, because an upstream message can name a station.
255
+ */
256
+ | {
257
+ phase: "start-failed";
258
+ reason: LiveStreamStartFailureReason;
259
+ stage: LiveStreamStartStage;
260
+ attempts: number;
261
+ errorName?: string;
239
262
  };
240
263
  /**
241
264
  * Record one startup observation at debug level.
@@ -46,12 +46,18 @@ export interface P2PSessionConfig {
46
46
  /** Disable UDP broadcast local lookup (e.g. cloud-only). Default false. */
47
47
  noBroadcast?: boolean;
48
48
  /**
49
- * Resolve a station `cipher_id` → its ECC private key hex (from cloud `get_ciphers`). When
50
- * provided, the session auto-negotiates the **level-2** session key on connect: it reads the
51
- * `cipher_id` + ECIES envelope from the `CMD_GATEWAYINFO` reply, derives the key, and calls
52
- * `setLevel2Key()` — so signCode 2/8 frames (camera info, event DB, event images) decrypt live.
53
- */
54
- resolveCipherKey?: (cipherId: number) => Promise<string | undefined>;
49
+ * Resolve a station `cipher_id` → its private keys (from cloud `get_ciphers`): `ecc`, the ECC private
50
+ * key hex (`ecc_private_key`), and `rsa`, the RSA private key (`private_key`). A plain string is the ECC key. Called once per
51
+ * negotiation. When provided, the session auto-negotiates the **level-2** session key on connect: it
52
+ * reads the `cipher_id` + envelope from the `CMD_GATEWAYINFO` reply and derives the key. With an ECC
53
+ * key that is the ECIES unwrap and `setLevel2Key()` — so signCode 2/8 frames (camera info, event DB,
54
+ * event images) decrypt live. A cipher with no ECC key carries the key RSA-encrypted instead, and only
55
+ * {@link P2PSession.sendControlLevel2} seals with it.
56
+ */
57
+ resolveCipherKey?: (cipherId: number) => Promise<string | {
58
+ ecc?: string;
59
+ rsa?: string;
60
+ } | undefined>;
55
61
  /** Diagnostics sink. Omit for silence. */
56
62
  logger?: Logger;
57
63
  }
@@ -149,6 +155,12 @@ export declare class P2PSession extends EventEmitter {
149
155
  private readonly level1Key;
150
156
  /** Negotiated 32-byte level-2/gateway key (AES-256-GCM). Set via setLevel2Key once known. */
151
157
  private level2Key?;
158
+ /**
159
+ * Negotiated 16-byte level-2 key of a cipher with no ECC key (RSA-encrypted in `CMD_GATEWAYINFO`). Only
160
+ * {@link sendControlLevel2} seals with it, AES-128-ECB in the level-1 layout at encryption type 2; never
161
+ * held beside {@link level2Key}.
162
+ */
163
+ private level2EcbKey?;
152
164
  private level2Seq;
153
165
  private rsaPrivateKey?;
154
166
  private rsaModulusHex?;
@@ -204,10 +216,18 @@ export declare class P2PSession extends EventEmitter {
204
216
  private trace;
205
217
  /** Provide the negotiated 32-byte session key so level-2 (signCode 2/8) frames can be decrypted. */
206
218
  setLevel2Key(key: Buffer): void;
207
- /** Whether the level-2 session key has been negotiated/set. */
219
+ /** Whether the AES-256-GCM level-2 session key has been negotiated/set. */
208
220
  get hasLevel2Key(): boolean;
221
+ /**
222
+ * Whether the held level-2 key can frame for `sender`: every level-2 sender needs the AES-256-GCM key,
223
+ * and `"set-payload"` ({@link sendControlLevel2}) also takes the AES-128-ECB key of an RSA cipher.
224
+ */
225
+ private level2KeyServes;
209
226
  /**
210
227
  * Resolve with whether a level-2 key is available, waiting only while waiting can still change that.
228
+ * `sender` names who will frame with it: by default every level-2 sender, which needs the AES-256-GCM
229
+ * key; `"set-payload"` is {@link sendControlLevel2} alone, which also takes the AES-128-ECB key of an RSA
230
+ * cipher, so `true` for it does not imply {@link hasLevel2Key}.
211
231
  *
212
232
  * A `"session"` grace is measured once, not restarted per call. Best-effort media uses it because every
213
233
  * egress asks this same session and can proceed without the key on an own-session camera; a per-call
@@ -232,7 +252,7 @@ export declare class P2PSession extends EventEmitter {
232
252
  * `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
233
253
  * cipher and the cause are known, and re-stating it here would double every settled negotiation.
234
254
  */
235
- awaitLevel2Key(graceMs: number, graceFrom?: "call" | "session"): Promise<boolean>;
255
+ awaitLevel2Key(graceMs: number, graceFrom?: "call" | "session", sender?: "every" | "set-payload"): Promise<boolean>;
236
256
  /**
237
257
  * Ask the station for its gateway info a second time, re-opening a negotiation that concluded without a key.
238
258
  *
@@ -253,9 +273,12 @@ export declare class P2PSession extends EventEmitter {
253
273
  private settleLevel2;
254
274
  /**
255
275
  * Negotiate the level-2 session key from the decrypted CMD_GATEWAYINFO payload: read its
256
- * `cipher_id`, resolve that cipher's ECC private key (cloud `get_ciphers`, via the configured
257
- * `resolveCipherKey`), run the ECIES unwrap, and `setLevel2Key()`. One-shot; emits `level2Ready`
258
- * on success and `error` on failure (non-fatal — level-1 traffic keeps working regardless).
276
+ * `cipher_id`, resolve that cipher's private keys once (cloud `get_ciphers`, via the configured
277
+ * `resolveCipherKey`), run the ECIES unwrap with the ECC key, and `setLevel2Key()`. A cipher with no
278
+ * ECC key uses its RSA key instead and holds the RSA-decrypted 16-byte key as the AES-128-ECB level-2
279
+ * key. One-shot; emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
280
+ * `level2-set-payload-only` instead for the AES-128-ECB key, and emits `error` on failure
281
+ * (non-fatal — level-1 traffic keeps working regardless).
259
282
  *
260
283
  * Every outcome settles the wait in {@link awaitLevel2Key}, because being one-shot is what makes a
261
284
  * failure final for this connection generation: nothing will retry it there, so a later command must be
@@ -421,6 +444,10 @@ export declare class P2PSession extends EventEmitter {
421
444
  * mechanism proven for camera selection in media start. Use for control commands routed through a
422
445
  * HomeBase (where level-1 ECB is rejected). Returns `false` if the level-2 key isn't negotiated
423
446
  * yet (caller can fall back to {@link sendStringPayloadCommand}).
447
+ *
448
+ * On a session holding only the AES-128-ECB level-2 key of an RSA cipher, the frame is the level-1
449
+ * layout ({@link sendRawLevel1Bytes}) sealed under that key at encryption type 2: the encryption-type
450
+ * byte names the key the body is sealed with (1 = level-1 key, 2 = the negotiated key).
424
451
  */
425
452
  sendControlLevel2(cmd: number, channel: number, accountId: string, payload: Record<string, unknown>, mValue3?: number): boolean;
426
453
  /**
@@ -611,6 +638,18 @@ export declare class P2PSession extends EventEmitter {
611
638
  accountId?: string;
612
639
  channel?: number;
613
640
  }): void;
641
+ /**
642
+ * Read a date-ranged page of `history_record_info` from the station. The reply's `data` array groups
643
+ * recording rows with companion tables; each group retains its `table_name` and `payload`.
644
+ * `startTime` is `"0"` for the newest page or the oldest row's `yyyyMMddHHmmss` cursor for older rows.
645
+ */
646
+ queryRecordPage(opts: {
647
+ accountId?: string;
648
+ startDate: string;
649
+ endDate: string;
650
+ startTime?: string;
651
+ count?: number;
652
+ }): Promise<unknown[]>;
614
653
  /**
615
654
  * Query one on-station table and answer its rows, once the reply is whole.
616
655
  *
@@ -618,13 +657,15 @@ export declare class P2PSession extends EventEmitter {
618
657
  * several frames whose decrypted text is a fragment of one document, so the fragments are
619
658
  * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
620
659
  * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
621
- * complete reply.
660
+ * complete reply, and when the station answers a non-zero `mIntRet`.
622
661
  *
623
662
  * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
624
663
  * session throws, so a second reply cannot land in this buffer.
625
664
  */
626
665
  readDatabase(table: string, opts?: {
627
666
  accountId?: string;
667
+ innerCmd?: number;
668
+ query?: Record<string, unknown>;
628
669
  timeoutMs?: number;
629
670
  signal?: AbortSignal;
630
671
  }): Promise<unknown[]>;
@@ -704,6 +745,7 @@ export declare class P2PSession extends EventEmitter {
704
745
  * Frames are packed back to back, so a frame header can itself be cut by a datagram boundary. The start
705
746
  * of a header left at the end of a datagram is carried into the next one rather than discarded; dropping
706
747
  * it would lose that frame and every frame after it until a datagram happened to begin on a header.
748
+ * Payload fragments retain their original slices and are copied once, when the complete frame is delivered.
707
749
  */
708
750
  private reassemble;
709
751
  /**
@@ -718,5 +760,6 @@ export declare class P2PSession extends EventEmitter {
718
760
  private ackTypeHeader;
719
761
  private handleFrame;
720
762
  private send;
763
+ /** Finish the END send on the current socket before closing it; send completion is not a peer ACK. */
721
764
  close(): Promise<void>;
722
765
  }
@@ -17,11 +17,15 @@ import type { Consumer } from "./shared-live-source.js";
17
17
  export interface ReadableEgressOptions {
18
18
  /** Emit {@link LiveVideoFrame} objects instead of raw Annex-B bytes (default false = bytes). */
19
19
  objectMode?: boolean;
20
+ /** Yield ReceivedVideoFrame envelopes with original source-arrival times; ignored in raw mode. */
21
+ arrivalTime?: boolean;
20
22
  /** Readable highWaterMark (bytes, or object count in objectMode). */
21
23
  highWaterMark?: number;
22
24
  }
23
25
  /**
24
26
  * Wrap a shared-source {@link Consumer} in a fresh Readable. The consumer is detached when the
25
27
  * Readable is destroyed/ended, so callers own the lifetime by owning the stream.
28
+ * Frames arrive through onMedia; an empty video listener releases the keyframe-prime
29
+ * because ConsumerImpl replays it when the first video listener is added.
26
30
  */
27
31
  export declare function openReadableFromConsumer(consumer: Consumer, opts?: ReadableEgressOptions): Readable;
@@ -46,9 +46,10 @@ export declare function receiveRecording(session: P2PSession, request: {
46
46
  signal?: AbortSignal;
47
47
  }): RecordingTransfer;
48
48
  /**
49
- * Decode a recording's frames, in arrival order, into elementary streams. Keyframes go through one
50
- * {@link VideoFrameDecoder}, whose media key then opens the audio; a keyframe that does not open under
51
- * `eccPrivateKeyHex`, and every audio frame before the first media key, are dropped. Rejects with
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
52
53
  * {@link RecordingDownloadError} `undecodable` when no video frame decodes.
53
54
  */
54
55
  export declare function decodeRecording(frames: readonly RecordingFrame[], eccPrivateKeyHex: string): RecordingDownload;
@@ -83,12 +83,13 @@ export interface SharedLiveSourceOptions {
83
83
  */
84
84
  onStopped?: () => void;
85
85
  /**
86
- * A media start was abandoned unacknowledged before anything was delivered, so this session is not being
87
- * heard. The owner is asked for a replacement and calls {@link SharedLiveSource.rewarm} once it has one.
86
+ * This session is not serving the warm-up: a media start was abandoned unacknowledged, or forced starts went
87
+ * unanswered by any media, before anything was delivered. The owner answers whether it is replacing the
88
+ * session, and calls {@link SharedLiveSource.rewarm} once it has the replacement.
88
89
  *
89
- * Asked at most once per warm-up: further abandonments are the same session saying the same thing.
90
+ * Asked at most once per warm-up: further evidence is the same session saying the same thing.
90
91
  */
91
- onSessionUnreachable?: () => void;
92
+ onSessionUnreachable?: () => boolean;
92
93
  }
93
94
  /**
94
95
  * A single consumer of a {@link SharedLiveSource}. A {@link LiveStreamConsumer} (so `live()` can hand it
@@ -251,18 +252,17 @@ export declare class SharedLiveSource {
251
252
  */
252
253
  private warm;
253
254
  /**
254
- * A start was abandoned unacknowledged. Ask for a replacement session where nothing has been delivered yet.
255
+ * Ask the owner for a replacement session where nothing has been delivered yet, answering whether one is coming.
255
256
  *
256
- * The abandonment is roughly twenty byte-identical sends with no reply, against acknowledgement latencies of
257
- * 4–37 ms awake and 238 ms waking, so it is the session that is not being heard rather than a slow device —
258
- * `P2PSession` says as much: the camera was never told to stream, so this warm-up can only time out. Where
259
- * media has already flowed the abandonment means something else and this does nothing.
257
+ * An unacknowledged start is roughly twenty byte-identical sends with no reply, against acknowledgement latencies
258
+ * of 4–37 ms awake and 238 ms waking, so it is the session that is not being heard rather than a slow device: the
259
+ * camera was never told to stream. Where media has already flowed the abandonment means something else and this
260
+ * does nothing. The DEADLINE is left running: the window belongs to the attempt, not to the session it started on.
260
261
  *
261
- * The retry ticker is stopped while a replacement is awaited, because every tick it issues goes to the same
262
- * unheard session. The DEADLINE is left running: the window belongs to the attempt, not to the session it
263
- * started on.
262
+ * The retry ticker is stopped only once the owner has said it is replacing the session: an owner that cannot
263
+ * (an attached camera shares its HomeBase session) leaves the source re-issuing as it always did.
264
264
  */
265
- private onStartUnacknowledged;
265
+ private askForFreshSession;
266
266
  /**
267
267
  * Warm again on a session the owner has replaced, inside the deadline the first attempt started.
268
268
  *
@@ -290,6 +290,11 @@ export declare class SharedLiveSource {
290
290
  * {@link fruitlessReissues} for why one is not enough and why what the stream delivered earlier is not
291
291
  * evidence. Once media arrives the keepalive is what is wanted, and an attached camera re-sends a full start
292
292
  * either way.
293
+ *
294
+ * A forced start that a whole interval later has still produced nothing is a session that acknowledges and
295
+ * does not serve: a fresh session on the same camera delivers a keyframe within a second, so a warm-up asks
296
+ * for one rather than forcing starts onto this one until its deadline. Wired only: a cold battery camera is
297
+ * slow rather than deaf, measured at about 8 s to first media, and replacing its session would cost a wake.
293
298
  */
294
299
  private reissueStart;
295
300
  /**
@@ -338,7 +343,8 @@ export declare class SharedLiveSource {
338
343
  * at all, audio without a single video frame, or access units a decoder cannot begin at.
339
344
  *
340
345
  * Video takes precedence when both arrived: audio alongside video says nothing a caller needs, while video
341
- * without a keyframe does.
346
+ * without a keyframe does. Every failed start is built here, so this is where its `start-failed` trace is
347
+ * emitted.
342
348
  */
343
349
  private startFailure;
344
350
  private onVideo;
@@ -438,3 +444,12 @@ export declare class SharedLiveSource {
438
444
  */
439
445
  dispose(): void;
440
446
  }
447
+ /**
448
+ * Attach a consumer, unless the caller has already abandoned the call.
449
+ *
450
+ * The acquisition it just waited through can outlast the caller's interest, and a consumer attached for
451
+ * somebody who has gone keeps the pull warm for nobody. Detaching immediately gives the pull back, which
452
+ * lets it linger and fall away if this was the only thing holding it, and leaves it untouched if it was
453
+ * not.
454
+ */
455
+ export declare function attachUnlessAborted(source: SharedLiveSource, signal?: AbortSignal): Consumer;