@mega-yfue/eufy-sdk 0.5.0-beta.9 → 0.5.0-beta.90

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 (65) hide show
  1. package/dist/client/device-registry.d.ts +24 -2
  2. package/dist/client/eufy-mega.d.ts +73 -8
  3. package/dist/client/types.d.ts +1 -1
  4. package/dist/core/contracts.d.ts +96 -41
  5. package/dist/core/store.d.ts +4 -0
  6. package/dist/core/types.d.ts +2 -0
  7. package/dist/index.js +4152 -719
  8. package/dist/index.js.map +4 -4
  9. package/dist/model/capabilities/arming.d.ts +39 -4
  10. package/dist/model/capabilities/battery.d.ts +6 -1
  11. package/dist/model/capabilities/camera.d.ts +61 -18
  12. package/dist/model/capabilities/dp-catalog.d.ts +4 -3
  13. package/dist/model/capabilities/index.d.ts +17 -4
  14. package/dist/model/capabilities/keypad.d.ts +20 -23
  15. package/dist/model/capabilities/light.d.ts +3 -0
  16. package/dist/model/capabilities/lock.d.ts +42 -16
  17. package/dist/model/capabilities/motion.d.ts +17 -2
  18. package/dist/model/capabilities/mower.d.ts +230 -0
  19. package/dist/model/capabilities/rtsp.d.ts +16 -3
  20. package/dist/model/capabilities/smart-drop.d.ts +20 -0
  21. package/dist/model/capabilities/suction.d.ts +5 -5
  22. package/dist/model/capabilities/types.d.ts +10 -0
  23. package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
  24. package/dist/model/classify.d.ts +1 -2
  25. package/dist/model/device-family.d.ts +24 -0
  26. package/dist/model/param-dictionary.d.ts +6 -0
  27. package/dist/model/param-namespace.d.ts +1 -1
  28. package/dist/model/types.d.ts +20 -12
  29. package/dist/transport/abortable.d.ts +8 -0
  30. package/dist/transport/http/mega-client.d.ts +43 -2
  31. package/dist/transport/mqtt/broker-discovery.d.ts +2 -1
  32. package/dist/transport/mqtt/command-router.d.ts +3 -2
  33. package/dist/transport/p2p/audio-frame.d.ts +14 -0
  34. package/dist/transport/p2p/codec.d.ts +32 -0
  35. package/dist/transport/p2p/command-router.d.ts +93 -31
  36. package/dist/transport/p2p/fmp4.d.ts +12 -2
  37. package/dist/transport/p2p/keyed-payload.d.ts +112 -0
  38. package/dist/transport/p2p/live-trace.d.ts +25 -2
  39. package/dist/transport/p2p/media.d.ts +3 -5
  40. package/dist/transport/p2p/p2p-session.d.ts +81 -12
  41. package/dist/transport/p2p/readable-egress.d.ts +3 -0
  42. package/dist/transport/p2p/recording-download.d.ts +4 -3
  43. package/dist/transport/p2p/shared-live-source.d.ts +44 -56
  44. package/dist/transport/p2p/talkback.d.ts +21 -10
  45. package/dist/transport/p2p/video.d.ts +2 -0
  46. package/dist/transport/push/push-client.d.ts +5 -0
  47. package/dist/transport/rtc/call-live.d.ts +41 -0
  48. package/dist/transport/rtc/command-router.d.ts +145 -0
  49. package/dist/transport/rtc/live.d.ts +76 -0
  50. package/dist/transport/rtc/peer.d.ts +122 -0
  51. package/dist/transport/rtc/portal-keepalive.d.ts +11 -0
  52. package/dist/transport/rtc/portal-packet.d.ts +72 -0
  53. package/dist/transport/rtc/ptcs-framer.d.ts +86 -0
  54. package/dist/transport/rtc/scall-sdp.d.ts +50 -0
  55. package/dist/transport/rtc/session.d.ts +99 -0
  56. package/dist/transport/rtc/signaling.d.ts +139 -0
  57. package/dist/transport/token-echo.d.ts +8 -0
  58. package/dist/transport/tuya/account.d.ts +8 -5
  59. package/dist/transport/tuya/client.d.ts +19 -5
  60. package/dist/transport/tuya/command-router.d.ts +10 -14
  61. package/dist/transport/tuya/dp-codec.d.ts +2 -23
  62. package/dist/transport/tuya/et3.d.ts +22 -0
  63. package/dist/transport/tuya/request.d.ts +18 -8
  64. package/dist/transport/tuya/sign.d.ts +7 -0
  65. package/package.json +7 -6
@@ -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
  *
@@ -422,6 +452,15 @@ export declare class MegaHttpClient {
422
452
  downloadImage(url: string, p2pDid?: string): Promise<Buffer>;
423
453
  /** The security-app data host for this region (face recognition, media, etc.). */
424
454
  private securityAppHost;
455
+ /**
456
+ * The device's long-lived P-256 public key, as the cloud publishes it for key-wrapped commands
457
+ * (`type=2`): 64 bytes hex with no `04` prefix. A plain authed GET on the security-app host — the
458
+ * one read here that is neither signed nor body-encrypted, matching how the app fetches it.
459
+ * Cached per serial for the client's lifetime; the key does not rotate in normal operation.
460
+ */
461
+ getDevicePublicKey(deviceSn: string): Promise<string>;
462
+ /** Per-serial cache for {@link getDevicePublicKey}. */
463
+ private readonly devicePublicKeys;
425
464
  /**
426
465
  * Signed+encrypted POST to the security-app data host (face recognition, etc.).
427
466
  * Despite the different host, these endpoints use the SAME algo_ecdh pipeline as
@@ -563,7 +602,9 @@ export declare class MegaHttpClient {
563
602
  * Structural rather than a list of paths: a login round trip is itself an authenticated call while a limited
564
603
  * token is held, so re-logging in from inside one would recurse — and a path list would have to be
565
604
  * 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,
605
+ * is part-way through, and restarting it silently would discard it. A re-login that already came back needing
606
+ * a captcha or a 2FA code would need the same again, and each attempt sends the account another code, so
607
+ * none is tried until a login completes or a stored session is adopted. No credentials means nothing to try,
567
608
  * so the rejection surfaces instead.
568
609
  */
569
610
  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>>;
@@ -100,6 +100,22 @@ export interface EciesUnwrapOptions {
100
100
  * decrypted bytes, or `undefined` on any parse / HMAC / cipher failure (fails closed, never throws).
101
101
  */
102
102
  export declare function eciesUnwrap(envelope: Buffer, eccPrivateKeyHex: string, options?: EciesUnwrapOptions): Buffer | undefined;
103
+ /** The two random inputs of {@link eciesWrap}, injectable so a spec can pin an envelope's bytes. */
104
+ export interface EciesWrapOptions {
105
+ /** The ephemeral P-256 private key for the exchange (32 bytes); generated fresh when absent. */
106
+ ephemeralPrivateKey?: Buffer;
107
+ /** The AES-128-CBC IV, which the envelope carries (16 bytes); random when absent. */
108
+ iv?: Buffer;
109
+ }
110
+ /**
111
+ * The sealing counterpart of {@link eciesUnwrap} with `verifyHmac` and `pkcs7` both set.
112
+ *
113
+ * Seals `plaintext` for `peerPublicKey` (a P-256 point, compressed or uncompressed) as
114
+ * `ephemeralPub(33, compressed) ‖ iv(16) ‖ ct ‖ HMAC(32)`: `S = ECDH_X(ephemeral, peer)` →
115
+ * `kdf = eufyKDF(S)` → `ct = AES-128-CBC(kdf[0:16], iv, PKCS7(plaintext))`, tagged
116
+ * `HMAC-SHA256(kdf[16:48], iv ‖ ct)`.
117
+ */
118
+ export declare function eciesWrap(plaintext: Buffer, peerPublicKey: Buffer, options?: EciesWrapOptions): Buffer;
103
119
  /**
104
120
  * Derive the P2P **level-2** (signCode 2/8) AES-256-GCM session key from a *decrypted*
105
121
  * `CMD_GATEWAYINFO` (1100) payload and the station's ECC private key (from `get_ciphers`).
@@ -111,6 +127,22 @@ export declare function eciesUnwrap(envelope: Buffer, eccPrivateKeyHex: string,
111
127
  * Returns the 32-byte key, or undefined if the inputs don't parse.
112
128
  */
113
129
  export declare function deriveLevel2KeyFromGatewayInfo(gwPayload: Buffer, eccPrivateKeyHex: string): Buffer | undefined;
130
+ /**
131
+ * Derive the P2P **level-2** AES-128-ECB session key from a *decrypted* `CMD_GATEWAYINFO` (1100)
132
+ * payload and the RSA private key of a cipher whose `get_ciphers` answer carries no ECC key.
133
+ *
134
+ * Layout of the decrypted payload: `cipher_id(2 LE) ‖ 0000 ‖ RSA ciphertext ‖ …`, the ciphertext as
135
+ * long as the key's modulus. It is the 16-byte session key under PKCS#1 v1.5 encryption padding
136
+ * (`00 02 ‖ ≥8 non-zero bytes ‖ 00 ‖ key`). The padding is checked here over the raw RSA result so a wrong
137
+ * key fails closed: under OpenSSL's implicit rejection, `RSA_PKCS1_PADDING` answers random bytes for it
138
+ * instead of throwing, and those can be 16 bytes long.
139
+ *
140
+ * `rsaPrivateKey` is the `get_ciphers` `private_key`: base64 PKCS#8 DER.
141
+ *
142
+ * Returns the 16-byte key, or `undefined` when the key does not parse, the payload is short, or the
143
+ * padding or key length is wrong (fails closed, never throws).
144
+ */
145
+ export declare function deriveLevel2KeyFromRsaGatewayInfo(gwPayload: Buffer, rsaPrivateKey: string): Buffer | undefined;
114
146
  /** Local-lookup payload: two zero bytes. */
115
147
  export declare function buildLocalLookupPayload(): Buffer;
116
148
  /** 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,10 +108,21 @@ 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;
115
+ /**
116
+ * The `seq_num` last sent to each device over the keyed-payload wire. The device holds a mark for the life
117
+ * of a P2P connection: the first keyed command on a new connection is accepted at whatever number it
118
+ * carries and becomes the mark, and after that a number not above the mark is refused with the mark
119
+ * published ({@link keyedPayloadSeqMark}). Each accepted command moves this to its number, a refusal
120
+ * moves it to the published mark + 1, and a device absent here has not been sent one in this process and
121
+ * seeds from a small random number — which a new connection takes as sent.
122
+ */
123
+ private readonly keyedSequences;
124
+ /** The keyed-payload command in flight per device, which the next one for that device waits on. */
125
+ private readonly keyedQueue;
126
126
  constructor(deps: P2PRouterDeps);
127
127
  /** Forward one P2P failure once even when both the session listener and startup waiter observe it. */
128
128
  private reportError;
@@ -224,7 +224,7 @@ export declare class P2PCommandRouter {
224
224
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
225
225
  *
226
226
  * `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
227
+ * cipher's private keys via cloud `get_ciphers`, so signCode 2/8 frames decrypt live; results are
228
228
  * cached on the router instance so a lazy re-open (after idle-detach) reuses the lookup. Only a
229
229
  * SUCCESSFUL lookup is cached — caching `undefined` after a transient failure would permanently
230
230
  * disable level-2 for the session's life.
@@ -240,11 +240,12 @@ export declare class P2PCommandRouter {
240
240
  */
241
241
  private makeSession;
242
242
  /**
243
- * The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
244
- * answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
245
- * cached.
243
+ * The ECC and RSA private keys of `cipherId` (`ecc_private_key`, `private_key`), from the router's cache
244
+ * or one `get_ciphers` call. When the cloud answers with another cipher that carries an ECC key and the
245
+ * asked one does not, that one is used and traced on `session`. Only a successful lookup — one that
246
+ * yields either key — is cached.
246
247
  */
247
- private cipherKeyFor;
248
+ private cipherKeysFor;
248
249
  /**
249
250
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
250
251
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -272,7 +273,7 @@ export declare class P2PCommandRouter {
272
273
  * A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
273
274
  *
274
275
  * Every live egress here is a consumer of the SAME shared pull, so N `live()` calls collapse to one
275
- * PPCS session and a live snapshot against a warm, keyframe-primed source costs no extra pull at all; a
276
+ * PPCS session and a live snapshot against a warm shared source costs no extra pull at all; a
276
277
  * cold source warms one and waits for a clean keyframe. Each shared egress passes its complete options
277
278
  * through because any of them may create the source, whose power and retention hints are fixed for
278
279
  * everyone who joins later. The bounded {@link MediaProvider.record} clip is the exception: it opens its
@@ -311,6 +312,11 @@ export declare class P2PCommandRouter {
311
312
  * stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
312
313
  * instead of playing into silence.
313
314
  *
315
+ * **On a model in {@link ELD_TALKBACK_MODELS}, the speaker plays the codec the camera sends.** So the first
316
+ * audio frame of the media session this talkback holds decides its codec ({@link speakerCodec}), within
317
+ * {@link TALKBACK_CODEC_WAIT_MS} of the session's first frame, and the claim is re-checked after that wait as
318
+ * after the warm-up. Every other model opens `aac-lc` at once.
319
+ *
314
320
  * The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
315
321
  * {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
316
322
  * own-session camera that legitimately never negotiates one.
@@ -456,15 +462,6 @@ export declare class P2PCommandRouter {
456
462
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
457
463
  * unaffected.
458
464
  */
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
465
  /**
469
466
  * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
470
467
  * on a session goes with it, and a media session opened for this source alone is closed: nothing else
@@ -524,11 +521,11 @@ export declare class P2PCommandRouter {
524
521
  */
525
522
  private onLiveStartFailed;
526
523
  /**
527
- * Replace the session under a warming source whose media start nothing acknowledged, and warm again on it.
524
+ * Replace the session under a warming source it is not serving, and warm again on it.
528
525
  *
529
526
  * Only a STANDALONE device's session is replaced, for the reason {@link onLiveStartFailed} gives: an attached
530
527
  * 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.
528
+ * drop the rest. Such a source keeps the re-issue it always had, which the answer (false) tells it.
532
529
  *
533
530
  * The source is left warming throughout, holding the deadline it started, so this either produces a stream
534
531
  * within that window or fails exactly as it would have. A replacement that cannot be opened leaves the
@@ -562,6 +559,7 @@ export declare class P2PCommandRouter {
562
559
  */
563
560
  p2pQuery(sn: string, subCmd: number, opts?: {
564
561
  timeoutMs?: number;
562
+ payload?: Record<string, unknown>;
565
563
  }): Promise<Record<string, unknown>>;
566
564
  /**
567
565
  * **Generic control-payload request/reply query.** Sends a `CONTROL_PAYLOAD` (1700) `{commandType,
@@ -690,7 +688,7 @@ export declare class P2PCommandRouter {
690
688
  * **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
691
689
  * exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
692
690
  * 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
691
+ * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.SET_ALL_ACTION`) reuses
694
692
  * the same bare-JSON shape.
695
693
  */
696
694
  private sendJsonRaw;
@@ -705,7 +703,9 @@ export declare class P2PCommandRouter {
705
703
  * HomeBase-attached device takes the GCM signCode-8 form, a standalone one the level-1 form. A
706
704
  * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
707
705
  * 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.
706
+ * camera accepts the level-1 form. With no `form` (default) it stays level-2 only and waits for the
707
+ * key every level-2 sender uses; `level2Sender: "set-payload"` also takes an RSA cipher's AES-128-ECB
708
+ * key (see {@link P2PSession.sendControlLevel2}). A `resolved` session keeps the wait its caller gave it.
709
709
  *
710
710
  * Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
711
711
  * control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
@@ -715,6 +715,68 @@ export declare class P2PCommandRouter {
715
715
  * boolean, so the first pass carries the failure and the rest are repeats.
716
716
  */
717
717
  private sendSetPayloadEnvelope;
718
+ /**
719
+ * **`keyed-payload-actuate` intent** — the classic Wi-Fi lock's actuation: the sealed fields from
720
+ * {@link buildKeyedActuatePayload}, sent as a keyed envelope through {@link P2PSession.sendSetPayload}
721
+ * on the lock's device channel. No session key is waited for — the envelope seals its own per-command
722
+ * key for the device's public key (fetched from the cloud, cached per serial), so it goes as soon as
723
+ * the session is up. The device shows the acting member by display name, which lives on the device
724
+ * record's `member` rather than in the intent, so it is read here and falls back to the account name
725
+ * the intent carries.
726
+ *
727
+ * The device answers, so the outcome is read rather than assumed: {@link awaitKeyedReply} settles each
728
+ * send on the replies, and resolves a {@link KEYED_PAYLOAD_SEQ_ERROR} refusal with the mark the device
729
+ * publishes in the refusal's `CMD_DOORLOCK_P2P_SEQ` (1916) twin. On that mark the command resends once,
730
+ * at mark + 1; a second refusal, or a refusal that published no mark, throws. Commands run one at a time
731
+ * per device: the replies carry no correlation id, so one command's answer must never be read as
732
+ * another's.
733
+ */
734
+ private sendKeyedPayloadActuate;
735
+ /** Run `work` once every keyed-payload command already queued for `sn` has settled. */
736
+ private serializeKeyed;
737
+ /**
738
+ * Send one keyed command at the device's next sequence and, on a sequence refusal, resend once at the
739
+ * mark the refusal published.
740
+ *
741
+ * A device's first number in a process is a small random seed — the scale the vendor app's own counters
742
+ * run at — and every later one is the previous plus one. The device takes the first number on a new
743
+ * connection as sent, and from then on refuses one not above the mark it holds, stating the mark in the
744
+ * refusal's `CMD_DOORLOCK_P2P_SEQ` (1916) twin ({@link awaitKeyedReply} resolves the refusal with it).
745
+ * So a refusal is resolved to that mark and the command resent once at mark + 1, the number the device
746
+ * accepts after a refusal; a second refusal, or a refusal that carried no mark, throws. The accepted
747
+ * number is recorded, so the next command starts just above it.
748
+ *
749
+ * @param engage whether the command drives to the engaged (locked) state, which its own report states.
750
+ * @param send dispatches the envelope at the sequence it is given.
751
+ */
752
+ private sendKeyedAtSequence;
753
+ /**
754
+ * Send one keyed-payload command and read the device's answer for it from the replies on the command's
755
+ * channel: the `SET_PAYLOAD` echo (a `commandResult`), at once, carrying the result as an int32 — or,
756
+ * when the echo was not read and the command changed the device's state, the `NOTIFY_PAYLOAD` about two
757
+ * seconds later carrying the result again as a `P2P_QUERY_STATUS_IN_LOCK` (1955) document. A report
758
+ * is tied to the command by its `slState`: only one stating the state this command drives to, by
759
+ * `engage` ({@link keyedPayloadReportCode}), is read as its answer. A report of the other state is a
760
+ * previous command's, landing after that command settled on its echo, and is ignored.
761
+ *
762
+ * - An accepted result (code `0`) settles the command.
763
+ * - A sequence refusal is stated twice, both about a tenth of a second after the send: the `-151` echo
764
+ * and a `CMD_DOORLOCK_P2P_SEQ` (1916) notify whose `seq_num` is the mark the device holds. The
765
+ * resolved value carries that mark, so the caller resends just above it. Collection runs for
766
+ * {@link KEYED_REFUSAL_TWIN_MS} after the first of the two, so both land and neither is left on the
767
+ * channel for the resend to read as its own answer, and either one alone still resolves the refusal.
768
+ * - No reply within {@link KEYED_REPLY_MS} is a command that never landed, and rejects.
769
+ *
770
+ * Both listeners are armed before the send, and only replies on `channel` are read. The channel names
771
+ * the device, not the command: every other P2P write a lock takes waits for a level-2 key this device
772
+ * never negotiates, so nothing else the SDK sends can draw a reply here, and a future intent on this
773
+ * wrapper has to share {@link serializeKeyed}.
774
+ */
775
+ private awaitKeyedReply;
776
+ /** How long a keyed-payload command waits for any reply before it is taken as never having landed. */
777
+ private static readonly KEYED_REPLY_MS;
778
+ /** How long a refusal collects its two statements — the `-151` echo and the mark notify — before resolving. */
779
+ private static readonly KEYED_REFUSAL_TWIN_MS;
718
780
  /**
719
781
  * **`ff09-actuate` intent** — build the `ff09` AES-128-CBC frame ({@link buildFf09Frame}, shared with the
720
782
  * MQTT transport — see `transport/ff09.ts`) and dispatch it in the `1940` TRANSFER_PAYLOAD envelope
@@ -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;
@@ -0,0 +1,112 @@
1
+ import type { Ff09Identity } from "../../core/contracts.js";
2
+ /**
3
+ * Keyed-payload envelope — the `SET_PAYLOAD` (1350) shape the classic Wi-Fi smart lock takes for its
4
+ * actuation. There is no session key: every command carries its OWN fresh AES-128 key, sealed for the
5
+ * device's long-lived P-256 public key (published by the cloud, one per device), and the inner JSON is
6
+ * AES-128-CBC/PKCS#7 under that key. This module builds the two sealed fields; `P2PSession.sendSetPayload`
7
+ * wraps them in the ordinary envelope and carries a keyed one in the clear, as the vendor app sends it.
8
+ *
9
+ * ## Wire
10
+ *
11
+ * ```
12
+ * { key: <keyBlobHex>, account_id: <adminUserId>, cmd: <opcode>, mChannel: <ch>, mValue3: 0,
13
+ * payload: <base64(AES-128-CBC(aesKey, iv, innerJson))> }
14
+ * ```
15
+ * `iv` = ASCII(deviceSn) zero-padded/truncated to 16 bytes. `aesKey` = 16 random bytes, spelled as 32
16
+ * UPPERCASE hex characters — the hex SPELLING is what the key blob seals, not the raw bytes.
17
+ *
18
+ * `key` (hex) is the ECIES envelope {@link eciesWrap} produces for the device's public key over that
19
+ * spelling: `compressedEphemeralPub(33) ‖ nonce(16) ‖ sealedKey(48) ‖ hmac(32)`.
20
+ *
21
+ * Inner JSON for the actuation opcode, `P2P_ON_OFF_LOCK` (1961): `{shortUserId, slOperation: 1|0, userId,
22
+ * userName, seq_num}`.
23
+ *
24
+ * ## Answer
25
+ *
26
+ * The device answers every command at once with an int32 result on the `SET_PAYLOAD` echo — `0`
27
+ * accepted, {@link KEYED_PAYLOAD_SEQ_ERROR} when `seq_num` is not above the mark it holds — and, when the
28
+ * command changed its state, about two seconds later with a `NOTIFY_PAYLOAD` whose JSON carries
29
+ * `P2P_QUERY_STATUS_IN_LOCK` (1955) as `cmd`, the result again under `payload.code` and the state reached
30
+ * under `payload.slState` ({@link keyedPayloadReportCode} reads the code of the report stating the state
31
+ * a command drives to). A command it was already in the state of draws the echo alone.
32
+ *
33
+ * A refusal carries a second `NOTIFY_PAYLOAD`, `CMD_DOORLOCK_P2P_SEQ` (1916), whose `payload.seq_num`
34
+ * ({@link keyedPayloadSeqMark}) is the mark the device holds — so a refused command hands back the exact
35
+ * number to resend above, with no searching. The mark lives with the P2P connection: the first keyed
36
+ * command on a new connection is accepted at whatever number it carries.
37
+ *
38
+ * The command ids are {@link CommandType}'s; the inner field names follow a third-party reverse-engineering
39
+ * project, a name source only.
40
+ */
41
+ /**
42
+ * The int32 result the device answers a command with when its `seq_num` is not above the mark it holds for
43
+ * the connection. The command is dropped untried, so this is a retry signal rather than a failure: resend
44
+ * above the mark. The refusal publishes that mark in its `CMD_DOORLOCK_P2P_SEQ` (1916) twin
45
+ * ({@link keyedPayloadSeqMark}), so the resend goes to the next number rather than searching for it.
46
+ */
47
+ export declare const KEYED_PAYLOAD_SEQ_ERROR = -151;
48
+ /** Knobs a spec injects to make the otherwise random envelope deterministic, plus the sequence it carries. */
49
+ export interface KeyedPayloadRandomness {
50
+ /** The per-command AES key (16 bytes). */
51
+ aesKey?: Buffer;
52
+ /** The CBC nonce sealing that key inside the blob (16 bytes). */
53
+ nonce?: Buffer;
54
+ /** The ephemeral P-256 private key for the ECDH exchange (32 bytes). */
55
+ ephemeralPrivateKey?: Buffer;
56
+ /** The `seq_num` the inner JSON carries, issued by the sender. */
57
+ seqNum: number;
58
+ }
59
+ /**
60
+ * The cipher IV the device derives from its own serial: ASCII bytes, zero-padded to 16. It is the same for
61
+ * every command to a device, which is sound only because {@link buildKeyedActuatePayload} seals each command
62
+ * under a key minted for it alone — no two messages ever share a key with this IV.
63
+ */
64
+ export declare function serialIv(deviceSn: string): Buffer;
65
+ /** Spell 16 key bytes as the 32 uppercase hex characters the wire seals. */
66
+ export declare function spellAesKey(key: Buffer): string;
67
+ /**
68
+ * Seal a per-command AES key for the device's P-256 public key: the {@link eciesWrap} envelope over the
69
+ * key's hex spelling, as hex.
70
+ * @param aesKeyHex the 32-hex-char key spelling (what gets sealed — as ASCII).
71
+ * @param devicePublicKeyHex the device's uncompressed public key WITHOUT the `04` prefix (64 bytes hex).
72
+ */
73
+ export declare function buildKeyBlob(aesKeyHex: string, devicePublicKeyHex: string, rnd?: Pick<KeyedPayloadRandomness, "nonce" | "ephemeralPrivateKey">): string;
74
+ /** AES-128-CBC/PKCS#7 the inner JSON under the per-command key, IV from the serial; base64 out. */
75
+ export declare function sealInnerPayload(innerJson: string, aesKeyHex: string, deviceSn: string): string;
76
+ /** The two sealed fields of a keyed envelope; the sender supplies the rest of the `SET_PAYLOAD` value. */
77
+ export interface KeyedActuatePayload {
78
+ /** The sealed per-command key — the value's `key` field, hex. */
79
+ key: string;
80
+ /** The inner JSON sealed under that key — the value's `payload` field, base64. */
81
+ payload: string;
82
+ }
83
+ /**
84
+ * Build the sealed fields of the classic Wi-Fi lock's actuation.
85
+ * @param id the acting identity + direction.
86
+ * @param devicePublicKeyHex the device's public key from the cloud (no `04` prefix).
87
+ * @param userName the acting member's display name as the device shows it in its log.
88
+ */
89
+ export declare function buildKeyedActuatePayload(id: Ff09Identity, devicePublicKeyHex: string, userName: string, rnd: KeyedPayloadRandomness): KeyedActuatePayload;
90
+ /**
91
+ * The result a `NOTIFY_PAYLOAD` document reports for a keyed command driving to a state. The device sends
92
+ * one once a keyed command has changed its state, `{cmd, payload: {code, slState, …}}` with `cmd`
93
+ * `P2P_QUERY_STATUS_IN_LOCK` (1955) in {@link CommandType}, `payload.code` being the command's result and
94
+ * `payload.slState` the state reached. Answers `payload.code` when it is a number and `payload.slState` is
95
+ * the state the command drives to: `"4"` engaged (locked) when `engage`, `"3"` released (unlocked) when
96
+ * not, the values of the lock's state param 6000 as the device spells them. Answers `undefined` for any
97
+ * other document, a report of the other state included: that one answers some other command.
98
+ */
99
+ export declare function keyedPayloadReportCode(json: {
100
+ cmd?: number;
101
+ payload?: unknown;
102
+ } | undefined, engage: boolean): number | undefined;
103
+ /**
104
+ * The sequence mark a `NOTIFY_PAYLOAD` document publishes. The one that rides a sequence refusal is
105
+ * `{cmd, payload: {lock_cmd, seq_num}}` with `cmd` `CMD_DOORLOCK_P2P_SEQ` (1916) in {@link CommandType},
106
+ * `payload.seq_num` being the mark the device holds on this connection. Answers that number, or
107
+ * `undefined` for any other document.
108
+ */
109
+ export declare function keyedPayloadSeqMark(json: {
110
+ cmd?: number;
111
+ payload?: unknown;
112
+ } | undefined): number | undefined;
@@ -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.
@@ -24,11 +24,9 @@ import type { SharedLiveSource } from "./shared-live-source.js";
24
24
  export declare function openLiveStream(session: P2PSession, opts?: LiveStreamOptions): Promise<LiveStream>;
25
25
  /**
26
26
  * **Live snapshot off a SHARED source** (V6) — snapshot as just another consumer of the shared live
27
- * pull. If the source is already warm and has a cached keyframe (V2 keyframe-prime), the joining
28
- * consumer receives that IDR immediately and we decode it with **no extra pull** — a snapshot while
29
- * someone else watches costs nothing on the wire. Otherwise we warm the source and wait for a clean
30
- * keyframe (the first IDR after a cold start is frequently partial, so skip it by default). Requires
31
- * `ffmpeg` for the Annex-B → JPEG decode.
27
+ * pull. A warm source reuses its upstream session; collection starts from live keyframes rather than
28
+ * a retained picture. A live source captures its next keyframe by default; a cold source skips the
29
+ * first keyframe because its first IDR is frequently partial. Requires `ffmpeg` for the Annex-B → JPEG decode.
32
30
  *
33
31
  * The returned dimensions are the ENCODED IMAGE's own, read back out of it — never the frame header's.
34
32
  * The header states the stream's geometry when the capture started, and a stream that reconfigures