@mega-yfue/eufy-sdk 0.6.0-beta.15 → 0.6.0-beta.17

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.
@@ -54,6 +54,12 @@ export declare class EufyMega extends EventEmitter {
54
54
  /** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
55
55
  * publish await the SAME fully-connected transport instead of racing (and never open two). */
56
56
  private readonly mqttReady;
57
+ /**
58
+ * Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
59
+ * subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
60
+ * starts empty.
61
+ */
62
+ private readonly mqttSubscribed;
57
63
  /** Device list/record/capability resolution + the frame→caps cache. */
58
64
  private readonly registry;
59
65
  private pushClient?;
@@ -632,6 +638,9 @@ export declare class EufyMega extends EventEmitter {
632
638
  * A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
633
639
  * of its params moved, since each re-resolution is a cloud param-list read.
634
640
  *
641
+ * Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
642
+ * sized it from the roster at login, which is empty when the cloud was unreachable then.
643
+ *
635
644
  * Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
636
645
  * is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
637
646
  * stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
@@ -818,11 +827,20 @@ export declare class EufyMega extends EventEmitter {
818
827
  /**
819
828
  * Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
820
829
  * unreachable device must not stop the rest of the roster from coming up).
830
+ * A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
831
+ * whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
832
+ * per transport.
821
833
  *
822
834
  * A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
823
835
  * and the broker refuses its `eufy_home` topics on every credential.
824
836
  */
825
837
  private subscribeMqttDevices;
838
+ /**
839
+ * Start every credential scope the roster now needs and has no transport for, and subscribe the
840
+ * devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
841
+ * the realtime generation is superseded or was never started.
842
+ */
843
+ private syncMqttRoster;
826
844
  /**
827
845
  * Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
828
846
  * state is pushed on change with no heartbeat, the request that makes its state readable before the
package/dist/index.js CHANGED
@@ -16226,7 +16226,7 @@ import { EventEmitter as EventEmitter2 } from "node:events";
16226
16226
  import dgram from "node:dgram";
16227
16227
  import { resolve4 } from "node:dns/promises";
16228
16228
  import { isIP as isIP2 } from "node:net";
16229
- import { constants as constants2, createCipheriv as createCipheriv4, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt as privateDecrypt2, randomBytes as randomBytes4 } from "node:crypto";
16229
+ import { constants as constants2, createCipheriv as createCipheriv4, createECDH as createECDH3, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt as privateDecrypt2, randomBytes as randomBytes4 } from "node:crypto";
16230
16230
 
16231
16231
  // dist/transport/p2p/live-trace.js
16232
16232
  var LIVE_TRACE_MESSAGE = "[live] start trace";
@@ -16453,6 +16453,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16453
16453
  level2Seq = 0;
16454
16454
  rsaPrivateKey;
16455
16455
  rsaModulusHex;
16456
+ /** The P-256 pair an attached camera's media start offers; see {@link mediaEccPrivateKey}. */
16457
+ mediaEcc;
16458
+ /**
16459
+ * Per attached channel, the media-key type its latest start offered on this connection, or that last answered,
16460
+ * and whether a keyframe has answered it. See {@link attachedStartKey} and {@link settleAttachedKey}.
16461
+ */
16462
+ attachedStartKeys = /* @__PURE__ */ new Map();
16456
16463
  /**
16457
16464
  * Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
16458
16465
  * settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
@@ -16778,7 +16785,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16778
16785
  const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
16779
16786
  this.logger[level](`[p2p] ${this.cfg.stationSn} UDP receive buffer below the ${RECEIVE_BUFFER_BYTES} bytes requested` + outcome + `; live video can lose keyframes. Raise the OS limit (net.core.rmem_max on Linux, kern.ipc.maxsockbuf on BSD) to at least ${RECEIVE_BUFFER_BYTES}.`);
16780
16787
  }
16781
- /** Open the socket and start the lookup → hole-punch handshake. */
16788
+ /**
16789
+ * Open the socket and start the lookup → hole-punch handshake.
16790
+ *
16791
+ * A socket closed or failing while binding never invokes its bind callback, so the bind wait also
16792
+ * settles on the socket's `close` and `error`. On `close`, a connect whose session was closed meanwhile
16793
+ * returns without starting the handshake, so it never sends a lookup or arms a timer on a closed
16794
+ * session. On `error`, connect closes the session and rejects with {@link StationUnreachableError}
16795
+ * carrying the bind error as its `cause`.
16796
+ */
16782
16797
  async connect() {
16783
16798
  if (this.connecting || this.connected)
16784
16799
  return;
@@ -16786,6 +16801,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16786
16801
  this.closed = false;
16787
16802
  this.connectionGeneration += 1;
16788
16803
  this.ackedRounds = void 0;
16804
+ this.attachedStartKeys.clear();
16789
16805
  this.resetInboundSequencing();
16790
16806
  if (!this.level2KeyServes("set-payload")) {
16791
16807
  this.level2Pending = this.cfg.resolveCipherKey !== void 0;
@@ -16798,8 +16814,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16798
16814
  this.socket = socket;
16799
16815
  socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, socket));
16800
16816
  socket.on("error", (e) => this.emit("error", e));
16801
- await new Promise((resolve2) => {
16817
+ const generation = this.connectionGeneration;
16818
+ await new Promise((resolve2, reject) => {
16819
+ socket.once("close", resolve2);
16820
+ socket.once("error", reject);
16802
16821
  socket.bind(() => {
16822
+ socket.off("error", reject);
16803
16823
  try {
16804
16824
  socket.setBroadcast(true);
16805
16825
  } catch {
@@ -16807,7 +16827,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16807
16827
  this.requestReceiveBuffer(socket);
16808
16828
  resolve2();
16809
16829
  });
16830
+ }).catch(async (error) => {
16831
+ await this.close();
16832
+ throw new StationUnreachableError(this.cfg.stationSn, 0, { cause: error });
16810
16833
  });
16834
+ if (generation !== this.connectionGeneration)
16835
+ return;
16811
16836
  void _P2PSession.detectLocalIp().then((host) => {
16812
16837
  if (host && !this.closed)
16813
16838
  this.selfHost = host;
@@ -16815,7 +16840,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16815
16840
  const { dskKey, cloudAddresses } = this.cfg;
16816
16841
  this.cloudLookup = dskKey && cloudAddresses?.length ? { key: dskKey, addresses: cloudAddresses } : void 0;
16817
16842
  if (this.cloudLookup && cloudAddresses) {
16818
- const generation = this.connectionGeneration;
16819
16843
  void resolveLookupServers(cloudAddresses).then((addresses) => {
16820
16844
  if (this.cloudLookup && !this.closed && generation === this.connectionGeneration)
16821
16845
  this.cloudLookup.addresses = addresses;
@@ -17183,13 +17207,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17183
17207
  startLiveMedia(channel = STATION_CHANNEL3, accountId = "", homeBaseAttached = false, opts) {
17184
17208
  if (homeBaseAttached) {
17185
17209
  this.tracedDatagramGaps = 0;
17210
+ const key = this.connectAddress && this.level2Key ? this.attachedStartKey(channel) : void 0;
17186
17211
  this.trace({
17187
17212
  phase: "media-command",
17188
17213
  topology: "attached",
17189
17214
  action: "start",
17190
- level2: !!this.level2Key
17215
+ level2: !!this.level2Key,
17216
+ key
17191
17217
  });
17192
- this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {});
17218
+ this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {}, key);
17193
17219
  return;
17194
17220
  }
17195
17221
  const want = this.level2Key ? "l2" : this.sendsInTheClear() ? "clear" : "l1";
@@ -17689,8 +17715,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17689
17715
  * Send a CMD_SET_PAYLOAD(1350) wrapping `{account_id, cmd:<subCmd>, mChannel, mValue3:<subCmd>,
17690
17716
  * payload}`, encrypted level-2 (AES-256-GCM, signCode 8) — the app's media-control path. The
17691
17717
  * camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
17718
+ * A start (HomeBase-controlled camera path, the V6 app's media-start envelope) also carries an
17719
+ * `accountId` inside its payload and the media key `key` offers (see {@link attachedStartKey}). A start
17720
+ * offering the P-256 point labels itself `ClientOS: "IOS"`, as every simple attached start in the app
17721
+ * captures that carry that key form does; the RSA start keeps `"Android"`.
17692
17722
  */
17693
- sendMediaPayloadLevel2(subCmd, channel, accountId, payload) {
17723
+ sendMediaPayloadLevel2(subCmd, channel, accountId, payload, key) {
17694
17724
  if (!this.connectAddress || !this.level2Key) {
17695
17725
  this.trace({
17696
17726
  phase: "media-command-unsent",
@@ -17700,11 +17730,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17700
17730
  }
17701
17731
  const isStart = subCmd === CMD_START_REALTIME_MEDIA;
17702
17732
  const innerPayload = isStart ? {
17703
- ClientOS: "Android",
17733
+ ClientOS: key === "ecc" ? "IOS" : "Android",
17704
17734
  accountId,
17705
17735
  camera_type: 0,
17706
17736
  entrytype: 0,
17707
- key: this.rsaModulus(),
17737
+ key: key === "ecc" ? this.mediaEccPair().point : this.rsaModulus(),
17708
17738
  streamtype: 2,
17709
17739
  ...payload
17710
17740
  } : payload;
@@ -17779,6 +17809,70 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17779
17809
  return void 0;
17780
17810
  }
17781
17811
  }
17812
+ /**
17813
+ * The media-key type the next attached start sent on `channel` offers, recorded as offered: ECC, the current
17814
+ * app's form, for the first start this connection sends there; RSA for every start after one that no keyframe
17815
+ * answered before it was re-issued; and, once a keyframe has answered, the type that opened it, until
17816
+ * {@link settleAttachedKey} records another. Only an opened keyframe answers: frames no key opens, and deltas,
17817
+ * do not. A re-issue is a full restart on an attached camera, so a camera that cannot read the point is asked
17818
+ * with RSA, the start every station answers, and is not asked with the point again while that goes unanswered.
17819
+ * A stream decoding with the session's pair reads either answer. A station without {@link mediaEccPrivateKey}
17820
+ * is offered RSA only. A start that cannot be sent offers nothing and records nothing.
17821
+ */
17822
+ attachedStartKey(channel) {
17823
+ if (!this.mediaEccPrivateKey)
17824
+ return "rsa";
17825
+ const last = this.attachedStartKeys.get(channel);
17826
+ if (last?.answered)
17827
+ return last.kind;
17828
+ const kind = last ? "rsa" : "ecc";
17829
+ this.attachedStartKeys.set(channel, { kind, answered: false });
17830
+ return kind;
17831
+ }
17832
+ /**
17833
+ * Record how a keyframe of `channel` was opened, which answers the starts there. A keyframe opened under a
17834
+ * key type is an answer of that type, whichever type the latest start offered: a station can serve the
17835
+ * P-256 start after the RSA re-issue went out. A plaintext keyframe
17836
+ * answering an unanswered P-256 offer is a camera that did not use the point, and the next start there offers
17837
+ * RSA; once a P-256 answer has been opened it changes nothing, and answering RSA, it settles RSA. Answers
17838
+ * whether a new start is needed. A channel no attached start has offered a key on is left as it is.
17839
+ */
17840
+ settleAttachedKey(channel, opened) {
17841
+ const last = this.attachedStartKeys.get(channel);
17842
+ if (!last)
17843
+ return false;
17844
+ if (opened === "plain" && last.kind === "ecc")
17845
+ return !last.answered;
17846
+ this.attachedStartKeys.set(channel, { kind: opened === "ecc" ? "ecc" : "rsa", answered: true });
17847
+ return false;
17848
+ }
17849
+ /**
17850
+ * The 32-byte P-256 private scalar paired with the point an attached camera's media start offers as `key`.
17851
+ * A camera answering that start wraps each keyframe's AES-256-GCM media key in an ECIES envelope for this
17852
+ * key ({@link VideoFrameDecoder}). Generated once per session, like the RSA pair.
17853
+ *
17854
+ * `undefined` unless the station is a HomeBase 3 (`T8030`): the app's P-256 start and the device runs
17855
+ * that answered it were both observed on that station only.
17856
+ */
17857
+ get mediaEccPrivateKey() {
17858
+ return this.cfg.stationSn.startsWith("T8030") ? this.mediaEccPair().privateKey : void 0;
17859
+ }
17860
+ /**
17861
+ * The session's P-256 pair: the private scalar as 32 bytes, and the public point as 128 hex characters,
17862
+ * `x ‖ y` without the uncompressed-point prefix, the form the current app's attached-camera start carries in
17863
+ * `key`. `ECDH.getPrivateKey()` drops a scalar's leading zero bytes, so the scalar is padded back to 32.
17864
+ */
17865
+ mediaEccPair() {
17866
+ if (!this.mediaEcc) {
17867
+ const ecdh = createECDH3("prime256v1");
17868
+ ecdh.generateKeys();
17869
+ this.mediaEcc = {
17870
+ privateKey: Buffer.from(ecdh.getPrivateKey("hex").padStart(64, "0"), "hex"),
17871
+ point: ecdh.getPublicKey().subarray(1).toString("hex")
17872
+ };
17873
+ }
17874
+ return this.mediaEcc;
17875
+ }
17782
17876
  /**
17783
17877
  * The RSA-1024 public-key modulus (128-byte hex) the station uses to wrap the per-stream media
17784
17878
  * key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
@@ -18849,8 +18943,9 @@ var AccessUnitAssembler = class {
18849
18943
  this.onDropped?.({ carried: open2.carried, chunks: open2.chunks.length, count: this.droppedUnits });
18850
18944
  }
18851
18945
  };
18946
+ var SPLIT_UNIT_STAMP_SPREAD_MS = 100;
18852
18947
  function sameUnit(open2, next) {
18853
- return next.timestamp === open2.timestamp && next.sequence === open2.sequence;
18948
+ return next.sequence === open2.sequence && next.timestamp - open2.timestamp >>> 0 <= SPLIT_UNIT_STAMP_SPREAD_MS;
18854
18949
  }
18855
18950
  function beginsAccessUnit(body) {
18856
18951
  if (body.length < 4 || body[0] !== 0 || body[1] !== 0)
@@ -19184,10 +19279,29 @@ function h265Geometry(sps) {
19184
19279
  }
19185
19280
 
19186
19281
  // dist/transport/p2p/audio-frame.js
19282
+ import { createDecipheriv as createDecipheriv7 } from "node:crypto";
19187
19283
  var CMD_AUDIO_FRAME2 = 1301;
19188
19284
  var AUDIO_HEADER_LEN = 16;
19189
19285
  var AUDIO_TYPE_OFFSET = 5;
19190
19286
  var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
19287
+ var AUDIO_NONCE_START = 32;
19288
+ var AUDIO_BODY_START = 44;
19289
+ function isSealedAudio(frame) {
19290
+ return frame.length >= AUDIO_BODY_START && frame.length >= AUDIO_BODY_START + frame.readUInt32LE(0);
19291
+ }
19292
+ function openSealedAudio(frame, mediaKey) {
19293
+ if (!isSealedAudio(frame))
19294
+ return void 0;
19295
+ try {
19296
+ const decipher = createDecipheriv7("aes-256-gcm", mediaKey, frame.subarray(AUDIO_NONCE_START, AUDIO_BODY_START));
19297
+ decipher.setAAD(VIDEO_GCM_AAD);
19298
+ decipher.setAuthTag(frame.subarray(AUDIO_HEADER_LEN, AUDIO_NONCE_START));
19299
+ const body = frame.subarray(AUDIO_BODY_START, AUDIO_BODY_START + frame.readUInt32LE(0));
19300
+ return Buffer.concat([decipher.update(body), decipher.final()]);
19301
+ } catch {
19302
+ return void 0;
19303
+ }
19304
+ }
19191
19305
 
19192
19306
  // dist/transport/p2p/live-stream.js
19193
19307
  var CMD_VIDEO_FRAME2 = 1300;
@@ -19219,6 +19333,8 @@ var LiveStream = class extends EventEmitter3 {
19219
19333
  tracedFirstVideoUnit = false;
19220
19334
  tracedFirstKeyframe = false;
19221
19335
  tracedDecodeFailures = 0;
19336
+ /** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
19337
+ unitOpenedBy = "plain";
19222
19338
  tracedFirstForeignFrame = false;
19223
19339
  stallTimer;
19224
19340
  /**
@@ -19241,8 +19357,9 @@ var LiveStream = class extends EventEmitter3 {
19241
19357
  this.channel = opts.channel ?? STATION_CHANNEL3;
19242
19358
  if (opts.homeBaseAttached && opts.channel !== void 0)
19243
19359
  this.mediaChannel = opts.channel;
19244
- if (opts.eccPrivateKey)
19245
- this.decoder = new VideoFrameDecoder(opts.eccPrivateKey);
19360
+ const eccPrivateKey = opts.eccPrivateKey ?? (opts.homeBaseAttached ? session.mediaEccPrivateKey : void 0);
19361
+ if (eccPrivateKey)
19362
+ this.decoder = new VideoFrameDecoder(eccPrivateKey);
19246
19363
  }
19247
19364
  /** Emit a live trace under its session's handle and the channel this stream pulls. */
19248
19365
  trace(trace) {
@@ -19403,8 +19520,10 @@ var LiveStream = class extends EventEmitter3 {
19403
19520
  this.tracedFirstKeyframe = true;
19404
19521
  this.trace({ phase: "first-keyframe" });
19405
19522
  }
19406
- if (unit.keyframe)
19523
+ if (unit.keyframe) {
19407
19524
  this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
19525
+ this.settleStartKey();
19526
+ }
19408
19527
  this.settleKeepalive();
19409
19528
  this.emit("video", {
19410
19529
  sourceTimestamp: unit.sourceTimestamp,
@@ -19416,8 +19535,8 @@ var LiveStream = class extends EventEmitter3 {
19416
19535
  });
19417
19536
  }
19418
19537
  } else if (f.commandId === CMD_AUDIO_FRAME2) {
19419
- const audio = f.data.length > AUDIO_HEADER_LEN ? f.data.subarray(AUDIO_HEADER_LEN) : f.data;
19420
- if (audio.length) {
19538
+ const audio = this.audioPayload(f.data);
19539
+ if (audio?.length) {
19421
19540
  const codecId = f.data.length > AUDIO_TYPE_OFFSET ? f.data.readUInt8(AUDIO_TYPE_OFFSET) : void 0;
19422
19541
  const codec = codecId === void 0 ? void 0 : AUDIO_CODECS[codecId];
19423
19542
  if (!codec) {
@@ -19468,19 +19587,54 @@ var LiveStream = class extends EventEmitter3 {
19468
19587
  return false;
19469
19588
  }
19470
19589
  /**
19471
- * The Annex-B payload of a frame that starts an access unit. The session decodes it when it can (it
19472
- * holds the RSA private key, and handles the plaintext form); the legacy header-strip and the ECIES
19473
- * {@link VideoFrameDecoder} are the fallbacks for an E2E camera.
19590
+ * The Annex-B payload of a frame that starts an access unit. The authenticated ECIES/GCM
19591
+ * {@link VideoFrameDecoder} goes first: its HMAC and GCM checks fail closed on a frame not sealed for its
19592
+ * key. The session's RSA path cannot: on Node ≥ 24.5, PKCS#1 v1.5 decryption of a key not wrapped for it
19593
+ * returns pseudo-random bytes instead of throwing (implicit rejection), which would decode an ECC
19594
+ * keyframe into noise. The session's decode, which also passes plaintext frames, and the header strip
19595
+ * follow. A payload that opens an access unit records how it was read: by the ECIES path, or otherwise by
19596
+ * its sign code, sealed (`"rsa"`) or in the clear (`"plain"`).
19474
19597
  */
19475
19598
  annexbOf(payload, signCode) {
19476
- const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
19477
- if (annexb)
19478
- return annexb;
19479
19599
  const decoded = this.decoder?.decodeFrame(payload)?.h264;
19480
- if (!decoded && this.tracedDecodeFailures++ < MAX_TRACED_DECODE_FAILURES) {
19600
+ if (decoded) {
19601
+ if (beginsAccessUnit(decoded))
19602
+ this.unitOpenedBy = "ecc";
19603
+ return decoded;
19604
+ }
19605
+ const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
19606
+ if (!annexb && this.tracedDecodeFailures++ < MAX_TRACED_DECODE_FAILURES) {
19481
19607
  this.trace({ phase: "video-decode-empty", signCode });
19482
19608
  }
19483
- return decoded;
19609
+ if (annexb && beginsAccessUnit(annexb))
19610
+ this.unitOpenedBy = signCode > 0 ? "rsa" : "plain";
19611
+ return annexb;
19612
+ }
19613
+ /**
19614
+ * Tell the session how this keyframe was opened, which answers the attached starts on this channel
19615
+ * ({@link P2PSession.settleAttachedKey}), and re-issue the start when the session asks for one: a plaintext
19616
+ * keyframe answering the session's P-256 offer, from a camera that did not use the point, which the next
19617
+ * start asks with RSA. Only a delivered keyframe answers: a frame no key opens, or a continuation without the
19618
+ * frame that opened its unit, delivers nothing.
19619
+ */
19620
+ settleStartKey() {
19621
+ if (!this.session.settleAttachedKey?.(this.channel, this.unitOpenedBy))
19622
+ return;
19623
+ this.logger.debug(`[live ch${this.channel}] plaintext keyframe answered the P-256 offer \u2014 re-starting with RSA`);
19624
+ this.sendStart();
19625
+ }
19626
+ /**
19627
+ * The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
19628
+ * opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
19629
+ * before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
19630
+ * its header.
19631
+ */
19632
+ audioPayload(frame) {
19633
+ if (!this.decoder || !isSealedAudio(frame)) {
19634
+ return frame.length > AUDIO_HEADER_LEN ? frame.subarray(AUDIO_HEADER_LEN) : frame;
19635
+ }
19636
+ const mediaKey = this.decoder.currentMediaKey;
19637
+ return mediaKey ? openSealedAudio(frame, mediaKey) : void 0;
19484
19638
  }
19485
19639
  /**
19486
19640
  * Report an access unit the transport could not complete.
@@ -19770,12 +19924,8 @@ function annexbToJpeg(annexb, spawn2) {
19770
19924
  }
19771
19925
 
19772
19926
  // dist/transport/p2p/recording-download.js
19773
- import { createDecipheriv as createDecipheriv7 } from "node:crypto";
19774
19927
  var RECORDING_DATA_TYPE = 3;
19775
19928
  var VIDEO_HEADER_LEN2 = 22;
19776
- var AUDIO_HEADER_LEN2 = 16;
19777
- var AUDIO_NONCE_START = 32;
19778
- var AUDIO_BODY_START = 44;
19779
19929
  var AUDIO_CODEC_AAC_LC = 0;
19780
19930
  var FINISH_CHANNEL = 0;
19781
19931
  var FIRST_FRAME_WAIT_MS = 2e4;
@@ -19887,16 +20037,6 @@ function receiveRecording(session, request) {
19887
20037
  }
19888
20038
  return { frames, drained };
19889
20039
  }
19890
- function openAudio(key, nonce, tag2, body) {
19891
- try {
19892
- const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
19893
- decipher.setAAD(VIDEO_GCM_AAD);
19894
- decipher.setAuthTag(tag2);
19895
- return Buffer.concat([decipher.update(body), decipher.final()]);
19896
- } catch {
19897
- return void 0;
19898
- }
19899
- }
19900
20040
  function decodeRecording(frames, eccPrivateKeyHex) {
19901
20041
  const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
19902
20042
  if (eccPrivateKey.length !== 32)
@@ -19925,12 +20065,9 @@ function decodeRecording(frames, eccPrivateKeyHex) {
19925
20065
  }
19926
20066
  } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
19927
20067
  const mediaKey = decoder.currentMediaKey;
19928
- if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
19929
- continue;
19930
- const len = raw.readUInt32LE(0);
19931
- if (raw.length < AUDIO_BODY_START + len)
20068
+ if (!mediaKey || raw[5] !== AUDIO_CODEC_AAC_LC)
19932
20069
  continue;
19933
- const aac = openAudio(mediaKey, raw.subarray(AUDIO_NONCE_START, AUDIO_BODY_START), raw.subarray(AUDIO_HEADER_LEN2, AUDIO_NONCE_START), raw.subarray(AUDIO_BODY_START, AUDIO_BODY_START + len));
20070
+ const aac = openSealedAudio(raw, mediaKey);
19934
20071
  if (aac?.length)
19935
20072
  audio.push(aac);
19936
20073
  }
@@ -25243,10 +25380,7 @@ var RtcSession = class extends EventEmitter11 {
25243
25380
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
25244
25381
  });
25245
25382
  });
25246
- this.signaling.on("close", () => {
25247
- this.connected = false;
25248
- this.announceClose();
25249
- });
25383
+ this.signaling.on("close", () => this.shutdown());
25250
25384
  this.signaling.on("error", (e) => this.emit("error", e));
25251
25385
  this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
25252
25386
  this.peer.on("iceGatheringComplete", () => this.trickle(""));
@@ -29363,6 +29497,12 @@ var EufyMega = class extends EventEmitter14 {
29363
29497
  /** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
29364
29498
  * publish await the SAME fully-connected transport instead of racing (and never open two). */
29365
29499
  mqttReady = /* @__PURE__ */ new Map();
29500
+ /**
29501
+ * Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
29502
+ * subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
29503
+ * starts empty.
29504
+ */
29505
+ mqttSubscribed = /* @__PURE__ */ new WeakMap();
29366
29506
  /** Device list/record/capability resolution + the frame→caps cache. */
29367
29507
  registry;
29368
29508
  pushClient;
@@ -30708,6 +30848,9 @@ var EufyMega = class extends EventEmitter14 {
30708
30848
  * A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
30709
30849
  * of its params moved, since each re-resolution is a cloud param-list read.
30710
30850
  *
30851
+ * Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
30852
+ * sized it from the roster at login, which is empty when the cloud was unreachable then.
30853
+ *
30711
30854
  * Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
30712
30855
  * is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
30713
30856
  * stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
@@ -30728,6 +30871,7 @@ var EufyMega = class extends EventEmitter14 {
30728
30871
  this.emit("deviceState", this.stateOf(dev));
30729
30872
  for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
30730
30873
  await this.widenCapabilities(sn);
30874
+ await this.syncMqttRoster();
30731
30875
  } catch (e) {
30732
30876
  this.reportError(e);
30733
30877
  }
@@ -31067,12 +31211,21 @@ var EufyMega = class extends EventEmitter14 {
31067
31211
  /**
31068
31212
  * Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
31069
31213
  * unreachable device must not stop the rest of the roster from coming up).
31214
+ * A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
31215
+ * whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
31216
+ * per transport.
31070
31217
  *
31071
31218
  * A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
31072
31219
  * and the broker refuses its `eufy_home` topics on every credential.
31073
31220
  */
31074
31221
  async subscribeMqttDevices(scope, transport) {
31222
+ let subscribed = this.mqttSubscribed.get(transport);
31223
+ if (!subscribed)
31224
+ this.mqttSubscribed.set(transport, subscribed = /* @__PURE__ */ new Set());
31075
31225
  for (const d of this.getMqttDevices().filter((dev) => !isTuyaVacuum(dev) && mqttScopeFor(dev) === scope)) {
31226
+ if (subscribed.has(d.sn))
31227
+ continue;
31228
+ subscribed.add(d.sn);
31076
31229
  try {
31077
31230
  await transport.subscribeDevice(d);
31078
31231
  await this.sendRealtimeInit(d.sn);
@@ -31081,6 +31234,23 @@ var EufyMega = class extends EventEmitter14 {
31081
31234
  }
31082
31235
  }
31083
31236
  }
31237
+ /**
31238
+ * Start every credential scope the roster now needs and has no transport for, and subscribe the
31239
+ * devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
31240
+ * the realtime generation is superseded or was never started.
31241
+ */
31242
+ async syncMqttRoster() {
31243
+ if (this.realtimeGeneration?.epoch !== this.realtimeEpoch)
31244
+ return;
31245
+ await Promise.all(this.mqttScopesInUse().map(async (scope) => {
31246
+ const transport = this.transports.get(scope);
31247
+ try {
31248
+ await (transport ? this.subscribeMqttDevices(scope, transport) : this.ensureMqttStarted(scope));
31249
+ } catch (e) {
31250
+ this.reportError(e);
31251
+ }
31252
+ }));
31253
+ }
31084
31254
  /**
31085
31255
  * Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
31086
31256
  * state is pushed on change with no heartbeat, the request that makes its state readable before the
@@ -32758,6 +32928,7 @@ export {
32758
32928
  aesKey,
32759
32929
  asBool,
32760
32930
  assertNever,
32931
+ beginsAccessUnit,
32761
32932
  bizChannelName,
32762
32933
  buildActions,
32763
32934
  buildAppShapedClientId,