@mega-yfue/eufy-sdk 0.6.0-beta.14 → 0.6.0-beta.16

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.
package/dist/index.js CHANGED
@@ -106,8 +106,9 @@ function encryptLoginPassword(password) {
106
106
  }
107
107
 
108
108
  // dist/core/store.js
109
- import { mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
110
- import { dirname } from "node:path";
109
+ import { randomUUID } from "node:crypto";
110
+ import { lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
111
+ import { dirname, resolve } from "node:path";
111
112
  var MemorySessionStore = class {
112
113
  s = null;
113
114
  load() {
@@ -120,6 +121,22 @@ var MemorySessionStore = class {
120
121
  this.s = null;
121
122
  }
122
123
  };
124
+ var IN_PLACE_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "EBUSY", "EXDEV", "EROFS"]);
125
+ function realTarget(path) {
126
+ try {
127
+ return realpathSync(path);
128
+ } catch (e) {
129
+ if (e.code !== "ENOENT")
130
+ throw e;
131
+ try {
132
+ if (!lstatSync(path).isSymbolicLink())
133
+ return path;
134
+ } catch {
135
+ return path;
136
+ }
137
+ return realTarget(resolve(dirname(path), readlinkSync(path)));
138
+ }
139
+ }
123
140
  var FileSessionStore = class {
124
141
  path;
125
142
  constructor(path) {
@@ -133,11 +150,22 @@ var FileSessionStore = class {
133
150
  }
134
151
  }
135
152
  save(s) {
153
+ const target = realTarget(this.path);
136
154
  try {
137
- mkdirSync(dirname(this.path), { recursive: true });
155
+ mkdirSync(dirname(target), { recursive: true });
138
156
  } catch {
139
157
  }
140
- writeFileSync(this.path, JSON.stringify(s, null, 2), { mode: 384 });
158
+ const data = JSON.stringify(s, null, 2);
159
+ const tmp = `${target}.${randomUUID()}.tmp`;
160
+ try {
161
+ writeFileSync(tmp, data, { mode: 384, flag: "wx" });
162
+ renameSync(tmp, target);
163
+ } catch (e) {
164
+ rmSync(tmp, { force: true });
165
+ if (!IN_PLACE_CODES.has(e.code ?? ""))
166
+ throw e;
167
+ writeFileSync(target, data, { mode: 384 });
168
+ }
141
169
  }
142
170
  clear() {
143
171
  try {
@@ -1973,22 +2001,22 @@ ${this.auth_?.authToken ?? ""}`;
1973
2001
  return this.postSigned(`app-${service}-${this.region}.eufy.com`, path, body, true, {}, headerOverrides);
1974
2002
  }
1975
2003
  /**
1976
- * Fetch the per-station DSK key used in the P2P cloud-lookup payload. Mirrors
2004
+ * Fetch the per-station DSK key using each station's reported cloud category. Mirrors
1977
2005
  * the legacy `get_dsk_keys` body shape on the mega devicerelation service.
1978
2006
  * The P2P *local* lookup path does not need this; only the cloud path does.
1979
2007
  */
1980
- async getDskKeys(stationSns, priorDsks = {}) {
2008
+ async getDskKeys(stations, priorDsks = {}) {
1981
2009
  const invalid = {};
1982
2010
  const deviceDsks = [];
1983
- for (const sn of stationSns) {
2011
+ for (const { sn, category } of stations) {
1984
2012
  const prev = priorDsks[sn] ?? "";
1985
2013
  invalid[sn] = prev;
1986
- deviceDsks.push({ invalid_dsk: prev, device_sn: sn, category: "eufy_security" });
2014
+ deviceDsks.push({ invalid_dsk: prev, device_sn: sn, category: category || "eufy_security" });
1987
2015
  }
1988
2016
  const res = await this.post("devicerelation", "/app/devicerelation/get_dsk_keys", {
1989
2017
  device_dsks: deviceDsks,
1990
2018
  invalid_dsks: invalid,
1991
- station_sns: stationSns
2019
+ station_sns: stations.map(({ sn }) => sn)
1992
2020
  });
1993
2021
  const out = {};
1994
2022
  for (const k of res.device_dsks ?? [])
@@ -2665,7 +2693,7 @@ var SecureMqtt = class extends EventEmitter {
2665
2693
  const port = c.endpoint_port ?? 8883;
2666
2694
  const pinned = this.o.instanceIp;
2667
2695
  const reconnectPeriod = this.o.reconnectPeriod ?? 5e3;
2668
- return await new Promise((resolve, reject) => {
2696
+ return await new Promise((resolve2, reject) => {
2669
2697
  const client = pinned ? mqtt.connect({
2670
2698
  host: pinned,
2671
2699
  port,
@@ -2697,7 +2725,7 @@ var SecureMqtt = class extends EventEmitter {
2697
2725
  client.on("connect", () => {
2698
2726
  if (!settled) {
2699
2727
  settled = true;
2700
- resolve();
2728
+ resolve2();
2701
2729
  }
2702
2730
  this.emit("connect");
2703
2731
  });
@@ -2840,7 +2868,7 @@ function parseStateInfoSignal(topic, raw) {
2840
2868
  }
2841
2869
 
2842
2870
  // dist/transport/mqtt/dp-codec.js
2843
- import { randomUUID } from "node:crypto";
2871
+ import { randomUUID as randomUUID2 } from "node:crypto";
2844
2872
  var DP_MAGIC = [255, 9];
2845
2873
  var DP_HEADER_LEN = 9;
2846
2874
  function xorChecksum(buf) {
@@ -2878,7 +2906,7 @@ function buildDpEnvelope(opts) {
2878
2906
  };
2879
2907
  const head = {
2880
2908
  version: "1.0.0.1",
2881
- client_id: `ios-eufy_mega-${opts.accountId}-${randomUUID()}`,
2909
+ client_id: `ios-eufy_mega-${opts.accountId}-${randomUUID2()}`,
2882
2910
  sess_id: "0000",
2883
2911
  msg_seq: 1,
2884
2912
  seed: "",
@@ -15560,7 +15588,7 @@ function readAddress(buf, offset) {
15560
15588
  };
15561
15589
  }
15562
15590
  function parseLookupAddr(msg) {
15563
- return readAddress(msg, 4);
15591
+ return msg.length >= 12 ? readAddress(msg, 4) : void 0;
15564
15592
  }
15565
15593
  function parseRelayList(msg) {
15566
15594
  const out = [];
@@ -16198,7 +16226,7 @@ import { EventEmitter as EventEmitter2 } from "node:events";
16198
16226
  import dgram from "node:dgram";
16199
16227
  import { resolve4 } from "node:dns/promises";
16200
16228
  import { isIP as isIP2 } from "node:net";
16201
- 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";
16202
16230
 
16203
16231
  // dist/transport/p2p/live-trace.js
16204
16232
  var LIVE_TRACE_MESSAGE = "[live] start trace";
@@ -16209,6 +16237,27 @@ function traceLiveStart(logger, trace, source) {
16209
16237
  // dist/transport/p2p/level2-settle.js
16210
16238
  var LEVEL2_SETTLE_MS = 8e3;
16211
16239
 
16240
+ // dist/transport/p2p/database-wire.js
16241
+ var CMD_DATABASE_IMAGE = 1308;
16242
+ var CMD_DATABASE = 1306;
16243
+ var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
16244
+ var DB_TABLE_TIMEOUT_MS = 15e3;
16245
+ function fullTableQuery(count2 = 2e3) {
16246
+ return {
16247
+ count: count2,
16248
+ start_date: "",
16249
+ end_date: "",
16250
+ start_id: 0,
16251
+ end_id: 1,
16252
+ flag: 0,
16253
+ need_ai: 1,
16254
+ res_unzip: 1,
16255
+ update_time: "0",
16256
+ start_time: "0",
16257
+ alarm_id: ""
16258
+ };
16259
+ }
16260
+
16212
16261
  // dist/transport/p2p/p2p-session.js
16213
16262
  var LOCAL_LOOKUP_PORT = 32108;
16214
16263
  var HEARTBEAT_MS = 5e3;
@@ -16262,9 +16311,6 @@ var CMD_SET_PAYLOAD = 1350;
16262
16311
  var CMD_NOTIFY_PAYLOAD = 1351;
16263
16312
  var PADDED_RESULT_BYTES = 132;
16264
16313
  var CMD_CAMERA_INFO = 1103;
16265
- var CMD_DATABASE_IMAGE = 1308;
16266
- var CMD_DATABASE = 1306;
16267
- var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
16268
16314
  var GCM_AAD = Buffer.from("eufy security");
16269
16315
  var LEVEL2_SEQ_BASE = 16909056;
16270
16316
  function paramReport(payload) {
@@ -16281,7 +16327,6 @@ function paramReport(payload) {
16281
16327
  return Object.keys(out).length ? out : void 0;
16282
16328
  }
16283
16329
  var traceSequence = 0;
16284
- var DB_TABLE_TIMEOUT_MS = 15e3;
16285
16330
  function firstTableRows(text2) {
16286
16331
  const decoded = Buffer.from(text2, "latin1").toString("utf8");
16287
16332
  for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
@@ -16408,11 +16453,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16408
16453
  level2Seq = 0;
16409
16454
  rsaPrivateKey;
16410
16455
  rsaModulusHex;
16411
- /** Guards the one-shot level-2 key negotiation kicked off by the GATEWAYINFO reply. */
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();
16463
+ /**
16464
+ * Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
16465
+ * settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
16466
+ */
16412
16467
  level2Negotiating = false;
16413
16468
  /**
16414
16469
  * Whether waiting for a level-2 key can still change the answer: `false` once one has been negotiated,
16415
- * once the one-shot negotiation concluded without one, and from the start when nothing can negotiate one.
16470
+ * once a negotiation concluded without one, and from the start when nothing can negotiate one.
16416
16471
  */
16417
16472
  level2Pending;
16418
16473
  /** Waiters parked in {@link awaitLevel2Key}, woken the moment the negotiation settles either way. */
@@ -16517,7 +16572,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16517
16572
  * The session grace runs from connect, when the station is prompted for `CMD_GATEWAYINFO`. A negotiation
16518
16573
  * beginning later does not restart it: best-effort media can proceed without the key, and restarting
16519
16574
  * would charge another grace to a source that may already be streaming. A session whose negotiation has
16520
- * settled answers without waiting at all, since being one-shot is what makes that answer final.
16575
+ * settled answers without waiting at all: the answer stands until {@link repromptLevel2Key} re-opens the
16576
+ * wait, since the station is prompted for `CMD_GATEWAYINFO` only on connect and on that re-prompt.
16521
16577
  *
16522
16578
  * An own-session camera whose grace expires here is NOT thereby a camera that will fail to stream. Measured
16523
16579
  * on one account: own-session cameras of two device types negotiated a key, three others never did, and
@@ -16550,7 +16606,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16550
16606
  this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
16551
16607
  this.trace({ phase: "level2-wait", waitMs: remaining });
16552
16608
  const waiters = this.level2Waiters;
16553
- const outcome = await new Promise((resolve) => {
16609
+ const outcome = await new Promise((resolve2) => {
16554
16610
  let deadline;
16555
16611
  let settled = false;
16556
16612
  const finish = (result) => {
@@ -16561,7 +16617,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16561
16617
  const at = waiters.indexOf(wake);
16562
16618
  if (at >= 0)
16563
16619
  waiters.splice(at, 1);
16564
- resolve(result);
16620
+ resolve2(result);
16565
16621
  };
16566
16622
  const wake = (result) => finish(result);
16567
16623
  deadline = setTimeout(() => finish("timeout"), remaining);
@@ -16583,12 +16639,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16583
16639
  /**
16584
16640
  * Ask the station for its gateway info a second time, re-opening a negotiation that concluded without a key.
16585
16641
  *
16586
- * The negotiation is one-shot per connection: the station is prompted once on connect, and a reply that
16587
- * never lands settles the wait so {@link awaitLevel2Key} answers `false` at once forever after. That is the
16642
+ * Without this the station is prompted once on connect, and a negotiation that settles without a key
16643
+ * leaves {@link awaitLevel2Key} answering `false` at once for the rest of that connection. That is the
16588
16644
  * right answer for best-effort media, which proceeds at level-1 — but an operation whose ONLY wire is
16589
16645
  * level-2 is then refused for the whole life of that connection, while a fresh session over the same
16590
16646
  * device negotiates a key normally. Measured: a session that had settled refused every such operation
16591
- * until it was rebuilt, at which point the station answered with a cipher id straight away.
16647
+ * until it was rebuilt, at which point the station answered with a cipher id straight away. The reply to
16648
+ * this ask runs the negotiation again and settles the re-opened wait either way.
16592
16649
  *
16593
16650
  * Bounded to one extra ask per connection, so a burst of such operations cannot turn a silent station into a
16594
16651
  * flood, and answers whether it asked — `false` when a key is already held, when nothing can negotiate one,
@@ -16609,6 +16666,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16609
16666
  /** Record that the level-2 negotiation has finished, with or without a key, and wake every waiter. */
16610
16667
  settleLevel2(reason = "terminal") {
16611
16668
  this.level2Pending = false;
16669
+ this.level2Negotiating = false;
16612
16670
  const outcome = this.level2Key ? "key" : reason;
16613
16671
  for (const wake of this.level2Waiters.splice(0))
16614
16672
  wake(outcome);
@@ -16618,13 +16676,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16618
16676
  * `cipher_id`, resolve that cipher's private keys once (cloud `get_ciphers`, via the configured
16619
16677
  * `resolveCipherKey`), run the ECIES unwrap with the ECC key, and `setLevel2Key()`. A cipher with no
16620
16678
  * ECC key uses its RSA key instead and holds the RSA-decrypted 16-byte key as the AES-128-ECB level-2
16621
- * key. One-shot; emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
16679
+ * key. Emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
16622
16680
  * `level2-set-payload-only` instead for the AES-128-ECB key, and emits `error` when the ECC unwrap
16623
16681
  * fails; an RSA unwrap failure is only traced (non-fatal either way — level-1 traffic keeps working).
16624
16682
  *
16625
- * Every outcome settles the wait in {@link awaitLevel2Key}, because being one-shot is what makes a
16626
- * failure final for this connection generation: nothing will retry it there, so a later command must be
16627
- * told at once rather than left to time out against a key that is not coming.
16683
+ * Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
16684
+ * left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
16685
+ * GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
16686
+ * generation ended settles nothing.
16628
16687
  */
16629
16688
  negotiateLevel2Key(gwPayload) {
16630
16689
  this.level2Negotiating = true;
@@ -16726,7 +16785,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16726
16785
  const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
16727
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}.`);
16728
16787
  }
16729
- /** 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
+ */
16730
16797
  async connect() {
16731
16798
  if (this.connecting || this.connected)
16732
16799
  return;
@@ -16734,6 +16801,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16734
16801
  this.closed = false;
16735
16802
  this.connectionGeneration += 1;
16736
16803
  this.ackedRounds = void 0;
16804
+ this.attachedStartKeys.clear();
16737
16805
  this.resetInboundSequencing();
16738
16806
  if (!this.level2KeyServes("set-payload")) {
16739
16807
  this.level2Pending = this.cfg.resolveCipherKey !== void 0;
@@ -16746,16 +16814,25 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16746
16814
  this.socket = socket;
16747
16815
  socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, socket));
16748
16816
  socket.on("error", (e) => this.emit("error", e));
16749
- await new Promise((resolve) => {
16817
+ const generation = this.connectionGeneration;
16818
+ await new Promise((resolve2, reject) => {
16819
+ socket.once("close", resolve2);
16820
+ socket.once("error", reject);
16750
16821
  socket.bind(() => {
16822
+ socket.off("error", reject);
16751
16823
  try {
16752
16824
  socket.setBroadcast(true);
16753
16825
  } catch {
16754
16826
  }
16755
16827
  this.requestReceiveBuffer(socket);
16756
- resolve();
16828
+ resolve2();
16757
16829
  });
16830
+ }).catch(async (error) => {
16831
+ await this.close();
16832
+ throw new StationUnreachableError(this.cfg.stationSn, 0, { cause: error });
16758
16833
  });
16834
+ if (generation !== this.connectionGeneration)
16835
+ return;
16759
16836
  void _P2PSession.detectLocalIp().then((host) => {
16760
16837
  if (host && !this.closed)
16761
16838
  this.selfHost = host;
@@ -16763,7 +16840,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16763
16840
  const { dskKey, cloudAddresses } = this.cfg;
16764
16841
  this.cloudLookup = dskKey && cloudAddresses?.length ? { key: dskKey, addresses: cloudAddresses } : void 0;
16765
16842
  if (this.cloudLookup && cloudAddresses) {
16766
- const generation = this.connectionGeneration;
16767
16843
  void resolveLookupServers(cloudAddresses).then((addresses) => {
16768
16844
  if (this.cloudLookup && !this.closed && generation === this.connectionGeneration)
16769
16845
  this.cloudLookup.addresses = addresses;
@@ -16816,7 +16892,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16816
16892
  */
16817
16893
  static detectLocalIp() {
16818
16894
  if (!_P2PSession.localIpPromise) {
16819
- _P2PSession.localIpPromise = new Promise((resolve) => {
16895
+ _P2PSession.localIpPromise = new Promise((resolve2) => {
16820
16896
  const probe = dgram.createSocket("udp4");
16821
16897
  let settled = false;
16822
16898
  const finish = (result) => {
@@ -16828,7 +16904,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16828
16904
  probe.close();
16829
16905
  } catch {
16830
16906
  }
16831
- resolve(result);
16907
+ resolve2(result);
16832
16908
  };
16833
16909
  const timer = setTimeout(() => finish(void 0), 2e3);
16834
16910
  probe.connect(53, "8.8.8.8", () => {
@@ -16980,9 +17056,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16980
17056
  } else if (hasHeader(msg, ResponseMessageType.LOOKUP_ADDR)) {
16981
17057
  if (!this.connected) {
16982
17058
  const addr = parseLookupAddr(msg);
16983
- this.logger.debug(`[p2p] ${this.cfg.stationSn} LOOKUP_ADDR -> ${addr.host}:${addr.port}`);
16984
- if (addr.host !== "0.0.0.0")
16985
- this.beginCheckCam(addr, socket);
17059
+ if (addr) {
17060
+ this.logger.debug(`[p2p] ${this.cfg.stationSn} LOOKUP_ADDR -> ${addr.host}:${addr.port}`);
17061
+ if (addr.host !== "0.0.0.0")
17062
+ this.beginCheckCam(addr, socket);
17063
+ }
16986
17064
  }
16987
17065
  } else if (hasHeader(msg, ResponseMessageType.RLY_TO)) {
16988
17066
  const to = parseRelayTo(msg);
@@ -17036,7 +17114,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17036
17114
  probe.on("error", (error) => this.logger.warn("[p2p] lookup socket error", error.message));
17037
17115
  probe.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, probe));
17038
17116
  this.probeSockets.push(probe);
17039
- await new Promise((resolve) => {
17117
+ await new Promise((resolve2) => {
17040
17118
  let settled = false;
17041
17119
  const finish = (bound) => {
17042
17120
  if (settled)
@@ -17051,7 +17129,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17051
17129
  } catch {
17052
17130
  }
17053
17131
  }
17054
- resolve();
17132
+ resolve2();
17055
17133
  };
17056
17134
  const onError = () => finish(false);
17057
17135
  const onClose = () => finish(false);
@@ -17129,13 +17207,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17129
17207
  startLiveMedia(channel = STATION_CHANNEL3, accountId = "", homeBaseAttached = false, opts) {
17130
17208
  if (homeBaseAttached) {
17131
17209
  this.tracedDatagramGaps = 0;
17210
+ const key = this.connectAddress && this.level2Key ? this.attachedStartKey(channel) : void 0;
17132
17211
  this.trace({
17133
17212
  phase: "media-command",
17134
17213
  topology: "attached",
17135
17214
  action: "start",
17136
- level2: !!this.level2Key
17215
+ level2: !!this.level2Key,
17216
+ key
17137
17217
  });
17138
- this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {});
17218
+ this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {}, key);
17139
17219
  return;
17140
17220
  }
17141
17221
  const want = this.level2Key ? "l2" : this.sendsInTheClear() ? "clear" : "l1";
@@ -17635,8 +17715,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17635
17715
  * Send a CMD_SET_PAYLOAD(1350) wrapping `{account_id, cmd:<subCmd>, mChannel, mValue3:<subCmd>,
17636
17716
  * payload}`, encrypted level-2 (AES-256-GCM, signCode 8) — the app's media-control path. The
17637
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"`.
17638
17722
  */
17639
- sendMediaPayloadLevel2(subCmd, channel, accountId, payload) {
17723
+ sendMediaPayloadLevel2(subCmd, channel, accountId, payload, key) {
17640
17724
  if (!this.connectAddress || !this.level2Key) {
17641
17725
  this.trace({
17642
17726
  phase: "media-command-unsent",
@@ -17646,11 +17730,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17646
17730
  }
17647
17731
  const isStart = subCmd === CMD_START_REALTIME_MEDIA;
17648
17732
  const innerPayload = isStart ? {
17649
- ClientOS: "Android",
17733
+ ClientOS: key === "ecc" ? "IOS" : "Android",
17650
17734
  accountId,
17651
17735
  camera_type: 0,
17652
17736
  entrytype: 0,
17653
- key: this.rsaModulus(),
17737
+ key: key === "ecc" ? this.mediaEccPair().point : this.rsaModulus(),
17654
17738
  streamtype: 2,
17655
17739
  ...payload
17656
17740
  } : payload;
@@ -17725,6 +17809,70 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17725
17809
  return void 0;
17726
17810
  }
17727
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
+ }
17728
17876
  /**
17729
17877
  * The RSA-1024 public-key modulus (128-byte hex) the station uses to wrap the per-stream media
17730
17878
  * key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
@@ -17837,25 +17985,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17837
17985
  databasePayload(value, channel) {
17838
17986
  return !this.level2Key && this.sendsInTheClear() ? buildRawCommandPayload(Buffer.from(value, "utf-8"), channel, 0, this.nextClearCommandBytes(), 0) : buildStringCommandPayload(value, channel, this.level1Key, 1);
17839
17987
  }
17840
- /**
17841
- * Standard "give me the whole table" query params the eufy app uses for a direct
17842
- * `CMD_DATABASE` read (inner `cmd 10000`). `count` bounds the row count.
17843
- */
17844
- fullTableQuery(count2 = 2e3) {
17845
- return {
17846
- count: count2,
17847
- start_date: "",
17848
- end_date: "",
17849
- start_id: 0,
17850
- end_id: 1,
17851
- flag: 0,
17852
- need_ai: 1,
17853
- res_unzip: 1,
17854
- update_time: "0",
17855
- start_time: "0",
17856
- alarm_id: ""
17857
- };
17858
- }
17859
17988
  /**
17860
17989
  * Request the on-device edge-AI **face roster** over P2P — the phone-free path.
17861
17990
  *
@@ -17872,7 +18001,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17872
18001
  accountId: opts.accountId,
17873
18002
  channel: opts.channel,
17874
18003
  innerCmd: DB_QUERY.FULL_TABLE,
17875
- query: this.fullTableQuery()
18004
+ query: fullTableQuery()
17876
18005
  });
17877
18006
  }
17878
18007
  /**
@@ -17921,11 +18050,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17921
18050
  this.queryDatabase(table, {
17922
18051
  accountId: opts.accountId,
17923
18052
  innerCmd: opts.innerCmd,
17924
- query: opts.query ?? this.fullTableQuery()
18053
+ query: opts.query ?? fullTableQuery()
17925
18054
  });
17926
18055
  this.dbReadInFlight = true;
17927
18056
  try {
17928
- return await new Promise((resolve, reject) => {
18057
+ return await new Promise((resolve2, reject) => {
17929
18058
  let text2 = "";
17930
18059
  const onChunk = (chunk) => {
17931
18060
  text2 += chunk.text;
@@ -17933,7 +18062,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17933
18062
  if (result?.error !== void 0)
17934
18063
  settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
17935
18064
  else if (result?.rows)
17936
- settle(() => resolve(result.rows));
18065
+ settle(() => resolve2(result.rows));
17937
18066
  };
17938
18067
  const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
17939
18068
  const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
@@ -17963,7 +18092,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17963
18092
  accountId: opts.accountId,
17964
18093
  channel: opts.channel,
17965
18094
  innerCmd: DB_QUERY.FULL_TABLE,
17966
- query: this.fullTableQuery()
18095
+ query: fullTableQuery()
17967
18096
  });
17968
18097
  }
17969
18098
  /**
@@ -18027,8 +18156,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18027
18156
  *
18028
18157
  * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
18029
18158
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
18159
+ *
18160
+ * A datagram shorter than the 8-byte DATA header carries no sequence number and is dropped unacknowledged.
18030
18161
  */
18031
18162
  onData(msg, addr) {
18163
+ if (msg.length < 8)
18164
+ return;
18032
18165
  const dataTypeBuffer = msg.subarray(4, 6);
18033
18166
  const seqNo = msg.subarray(6, 8).readUInt16BE();
18034
18167
  const dataType = dataTypeBuffer[1];
@@ -18194,6 +18327,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18194
18327
  return P2PDataTypeHeader.DATA;
18195
18328
  }
18196
18329
  }
18330
+ /**
18331
+ * Decrypt and dispatch one inbound data frame.
18332
+ *
18333
+ * A `CMD_GATEWAYINFO` (1100) reply carries the ECIES envelope for the level-2 session key. When a
18334
+ * cipher-key resolver is configured, no key that seals a set-payload is held and no negotiation is in
18335
+ * flight, the reply starts one (async, fire-and-forget); a reply landing while one is in flight is ignored.
18336
+ */
18197
18337
  handleFrame(header, payload, dataType) {
18198
18338
  let data = payload;
18199
18339
  const isMedia = header.commandId === CMD_VIDEO_FRAME || header.commandId === CMD_AUDIO_FRAME;
@@ -18299,12 +18439,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18299
18439
  this.resetInboundSequencing();
18300
18440
  const socket = this.socket;
18301
18441
  if (socket && this.connectAddress) {
18302
- await new Promise((resolve) => this.send(this.connectAddress, RequestMessageType.END, void 0, socket, resolve));
18442
+ await new Promise((resolve2) => this.send(this.connectAddress, RequestMessageType.END, void 0, socket, resolve2));
18303
18443
  }
18304
- await new Promise((resolve) => {
18444
+ await new Promise((resolve2) => {
18305
18445
  if (!socket)
18306
- return resolve();
18307
- socket.close(() => resolve());
18446
+ return resolve2();
18447
+ socket.close(() => resolve2());
18308
18448
  });
18309
18449
  this.socket = void 0;
18310
18450
  this.emit("close");
@@ -19138,10 +19278,29 @@ function h265Geometry(sps) {
19138
19278
  }
19139
19279
 
19140
19280
  // dist/transport/p2p/audio-frame.js
19281
+ import { createDecipheriv as createDecipheriv7 } from "node:crypto";
19141
19282
  var CMD_AUDIO_FRAME2 = 1301;
19142
19283
  var AUDIO_HEADER_LEN = 16;
19143
19284
  var AUDIO_TYPE_OFFSET = 5;
19144
19285
  var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
19286
+ var AUDIO_NONCE_START = 32;
19287
+ var AUDIO_BODY_START = 44;
19288
+ function isSealedAudio(frame) {
19289
+ return frame.length >= AUDIO_BODY_START && frame.length >= AUDIO_BODY_START + frame.readUInt32LE(0);
19290
+ }
19291
+ function openSealedAudio(frame, mediaKey) {
19292
+ if (!isSealedAudio(frame))
19293
+ return void 0;
19294
+ try {
19295
+ const decipher = createDecipheriv7("aes-256-gcm", mediaKey, frame.subarray(AUDIO_NONCE_START, AUDIO_BODY_START));
19296
+ decipher.setAAD(VIDEO_GCM_AAD);
19297
+ decipher.setAuthTag(frame.subarray(AUDIO_HEADER_LEN, AUDIO_NONCE_START));
19298
+ const body = frame.subarray(AUDIO_BODY_START, AUDIO_BODY_START + frame.readUInt32LE(0));
19299
+ return Buffer.concat([decipher.update(body), decipher.final()]);
19300
+ } catch {
19301
+ return void 0;
19302
+ }
19303
+ }
19145
19304
 
19146
19305
  // dist/transport/p2p/live-stream.js
19147
19306
  var CMD_VIDEO_FRAME2 = 1300;
@@ -19173,6 +19332,8 @@ var LiveStream = class extends EventEmitter3 {
19173
19332
  tracedFirstVideoUnit = false;
19174
19333
  tracedFirstKeyframe = false;
19175
19334
  tracedDecodeFailures = 0;
19335
+ /** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
19336
+ unitOpenedBy = "plain";
19176
19337
  tracedFirstForeignFrame = false;
19177
19338
  stallTimer;
19178
19339
  /**
@@ -19195,8 +19356,9 @@ var LiveStream = class extends EventEmitter3 {
19195
19356
  this.channel = opts.channel ?? STATION_CHANNEL3;
19196
19357
  if (opts.homeBaseAttached && opts.channel !== void 0)
19197
19358
  this.mediaChannel = opts.channel;
19198
- if (opts.eccPrivateKey)
19199
- this.decoder = new VideoFrameDecoder(opts.eccPrivateKey);
19359
+ const eccPrivateKey = opts.eccPrivateKey ?? (opts.homeBaseAttached ? session.mediaEccPrivateKey : void 0);
19360
+ if (eccPrivateKey)
19361
+ this.decoder = new VideoFrameDecoder(eccPrivateKey);
19200
19362
  }
19201
19363
  /** Emit a live trace under its session's handle and the channel this stream pulls. */
19202
19364
  trace(trace) {
@@ -19357,8 +19519,10 @@ var LiveStream = class extends EventEmitter3 {
19357
19519
  this.tracedFirstKeyframe = true;
19358
19520
  this.trace({ phase: "first-keyframe" });
19359
19521
  }
19360
- if (unit.keyframe)
19522
+ if (unit.keyframe) {
19361
19523
  this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
19524
+ this.settleStartKey();
19525
+ }
19362
19526
  this.settleKeepalive();
19363
19527
  this.emit("video", {
19364
19528
  sourceTimestamp: unit.sourceTimestamp,
@@ -19370,8 +19534,8 @@ var LiveStream = class extends EventEmitter3 {
19370
19534
  });
19371
19535
  }
19372
19536
  } else if (f.commandId === CMD_AUDIO_FRAME2) {
19373
- const audio = f.data.length > AUDIO_HEADER_LEN ? f.data.subarray(AUDIO_HEADER_LEN) : f.data;
19374
- if (audio.length) {
19537
+ const audio = this.audioPayload(f.data);
19538
+ if (audio?.length) {
19375
19539
  const codecId = f.data.length > AUDIO_TYPE_OFFSET ? f.data.readUInt8(AUDIO_TYPE_OFFSET) : void 0;
19376
19540
  const codec = codecId === void 0 ? void 0 : AUDIO_CODECS[codecId];
19377
19541
  if (!codec) {
@@ -19422,19 +19586,54 @@ var LiveStream = class extends EventEmitter3 {
19422
19586
  return false;
19423
19587
  }
19424
19588
  /**
19425
- * The Annex-B payload of a frame that starts an access unit. The session decodes it when it can (it
19426
- * holds the RSA private key, and handles the plaintext form); the legacy header-strip and the ECIES
19427
- * {@link VideoFrameDecoder} are the fallbacks for an E2E camera.
19589
+ * The Annex-B payload of a frame that starts an access unit. The authenticated ECIES/GCM
19590
+ * {@link VideoFrameDecoder} goes first: its HMAC and GCM checks fail closed on a frame not sealed for its
19591
+ * key. The session's RSA path cannot: on Node ≥ 24.5, PKCS#1 v1.5 decryption of a key not wrapped for it
19592
+ * returns pseudo-random bytes instead of throwing (implicit rejection), which would decode an ECC
19593
+ * keyframe into noise. The session's decode, which also passes plaintext frames, and the header strip
19594
+ * follow. A payload that opens an access unit records how it was read: by the ECIES path, or otherwise by
19595
+ * its sign code, sealed (`"rsa"`) or in the clear (`"plain"`).
19428
19596
  */
19429
19597
  annexbOf(payload, signCode) {
19430
- const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
19431
- if (annexb)
19432
- return annexb;
19433
19598
  const decoded = this.decoder?.decodeFrame(payload)?.h264;
19434
- if (!decoded && this.tracedDecodeFailures++ < MAX_TRACED_DECODE_FAILURES) {
19599
+ if (decoded) {
19600
+ if (beginsAccessUnit(decoded))
19601
+ this.unitOpenedBy = "ecc";
19602
+ return decoded;
19603
+ }
19604
+ const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
19605
+ if (!annexb && this.tracedDecodeFailures++ < MAX_TRACED_DECODE_FAILURES) {
19435
19606
  this.trace({ phase: "video-decode-empty", signCode });
19436
19607
  }
19437
- return decoded;
19608
+ if (annexb && beginsAccessUnit(annexb))
19609
+ this.unitOpenedBy = signCode > 0 ? "rsa" : "plain";
19610
+ return annexb;
19611
+ }
19612
+ /**
19613
+ * Tell the session how this keyframe was opened, which answers the attached starts on this channel
19614
+ * ({@link P2PSession.settleAttachedKey}), and re-issue the start when the session asks for one: a plaintext
19615
+ * keyframe answering the session's P-256 offer, from a camera that did not use the point, which the next
19616
+ * start asks with RSA. Only a delivered keyframe answers: a frame no key opens, or a continuation without the
19617
+ * frame that opened its unit, delivers nothing.
19618
+ */
19619
+ settleStartKey() {
19620
+ if (!this.session.settleAttachedKey?.(this.channel, this.unitOpenedBy))
19621
+ return;
19622
+ this.logger.debug(`[live ch${this.channel}] plaintext keyframe answered the P-256 offer \u2014 re-starting with RSA`);
19623
+ this.sendStart();
19624
+ }
19625
+ /**
19626
+ * The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
19627
+ * opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
19628
+ * before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
19629
+ * its header.
19630
+ */
19631
+ audioPayload(frame) {
19632
+ if (!this.decoder || !isSealedAudio(frame)) {
19633
+ return frame.length > AUDIO_HEADER_LEN ? frame.subarray(AUDIO_HEADER_LEN) : frame;
19634
+ }
19635
+ const mediaKey = this.decoder.currentMediaKey;
19636
+ return mediaKey ? openSealedAudio(frame, mediaKey) : void 0;
19438
19637
  }
19439
19638
  /**
19440
19639
  * Report an access unit the transport could not complete.
@@ -19512,7 +19711,7 @@ async function captureSnapshotFromShared(source, opts = {}) {
19512
19711
  const skip = opts.skipKeyframes ?? (source.state === "live" ? 0 : 1);
19513
19712
  let burst;
19514
19713
  try {
19515
- burst = await new Promise((resolve, reject) => {
19714
+ burst = await new Promise((resolve2, reject) => {
19516
19715
  const bufs = [];
19517
19716
  let sets = source.parameterSets;
19518
19717
  let codec = "h264";
@@ -19535,7 +19734,7 @@ async function captureSnapshotFromShared(source, opts = {}) {
19535
19734
  if (!settle)
19536
19735
  settle = setTimeout(() => {
19537
19736
  cleanup();
19538
- resolve({ h264: Buffer.concat(bufs), codec, sets });
19737
+ resolve2({ h264: Buffer.concat(bufs), codec, sets });
19539
19738
  }, collectMs);
19540
19739
  };
19541
19740
  const onError = (err) => {
@@ -19579,7 +19778,7 @@ async function recordClip(session, seconds, opts = {}) {
19579
19778
  const stream = await openLiveStream(session, opts);
19580
19779
  let h264;
19581
19780
  try {
19582
- h264 = await new Promise((resolve, reject) => {
19781
+ h264 = await new Promise((resolve2, reject) => {
19583
19782
  const bufs = [];
19584
19783
  let keyCount = 0, capturing = false;
19585
19784
  let sets;
@@ -19592,7 +19791,7 @@ async function recordClip(session, seconds, opts = {}) {
19592
19791
  };
19593
19792
  const collected = () => {
19594
19793
  detach();
19595
- resolve(primeForDecode(Buffer.concat(bufs), sets, codec2));
19794
+ resolve2(primeForDecode(Buffer.concat(bufs), sets, codec2));
19596
19795
  };
19597
19796
  const failed = (reason, cause) => {
19598
19797
  detach();
@@ -19622,7 +19821,7 @@ async function recordClip(session, seconds, opts = {}) {
19622
19821
  stream.stop();
19623
19822
  }
19624
19823
  const codec = annexbFfmpegFormat(h264);
19625
- return new Promise((resolve, reject) => {
19824
+ return new Promise((resolve2, reject) => {
19626
19825
  const ff = spawnFfmpeg([
19627
19826
  // prettier-ignore
19628
19827
  "-f",
@@ -19645,7 +19844,7 @@ async function recordClip(session, seconds, opts = {}) {
19645
19844
  ff.on("close", (code) => {
19646
19845
  const mp4 = Buffer.concat(out);
19647
19846
  if (mp4.length)
19648
- resolve(mp4);
19847
+ resolve2(mp4);
19649
19848
  else
19650
19849
  reject(new Error(`ffmpeg mux failed (code ${code}): ${Buffer.concat(err).toString().slice(0, 200)}`));
19651
19850
  });
@@ -19684,7 +19883,7 @@ function jpegGeometry(jpeg) {
19684
19883
  }
19685
19884
  function annexbToJpeg(annexb, spawn2) {
19686
19885
  const codec = annexbFfmpegFormat(annexb);
19687
- return new Promise((resolve, reject) => {
19886
+ return new Promise((resolve2, reject) => {
19688
19887
  const ff = spawnFfmpeg([
19689
19888
  // prettier-ignore
19690
19889
  "-f",
@@ -19711,7 +19910,7 @@ function annexbToJpeg(annexb, spawn2) {
19711
19910
  const encoded = jpeg.length >= 3 && jpeg.subarray(0, 3).toString("hex") === "ffd8ff";
19712
19911
  const geometry = encoded ? jpegGeometry(jpeg) : void 0;
19713
19912
  if (geometry)
19714
- return resolve({ jpeg, ...geometry });
19913
+ return resolve2({ jpeg, ...geometry });
19715
19914
  const diagnostics = Buffer.concat(err).toString().slice(0, 200).trim();
19716
19915
  const what = code !== 0 ? `ffmpeg exited ${code}` : encoded ? "encoded image declares no frame header" : "no complete frame in the burst";
19717
19916
  reject(new LiveSnapshotUnavailableError("undecodable-burst", diagnostics ? `${what}: ${diagnostics}` : what));
@@ -19724,12 +19923,8 @@ function annexbToJpeg(annexb, spawn2) {
19724
19923
  }
19725
19924
 
19726
19925
  // dist/transport/p2p/recording-download.js
19727
- import { createDecipheriv as createDecipheriv7 } from "node:crypto";
19728
19926
  var RECORDING_DATA_TYPE = 3;
19729
19927
  var VIDEO_HEADER_LEN2 = 22;
19730
- var AUDIO_HEADER_LEN2 = 16;
19731
- var AUDIO_NONCE_START = 32;
19732
- var AUDIO_BODY_START = 44;
19733
19928
  var AUDIO_CODEC_AAC_LC = 0;
19734
19929
  var FINISH_CHANNEL = 0;
19735
19930
  var FIRST_FRAME_WAIT_MS = 2e4;
@@ -19744,12 +19939,12 @@ function homeBase2RecordingPath(channel, recording) {
19744
19939
  function receiveRecording(session, request) {
19745
19940
  let resolveFrames;
19746
19941
  let rejectFrames;
19747
- const frames = new Promise((resolve, reject) => {
19748
- resolveFrames = resolve;
19942
+ const frames = new Promise((resolve2, reject) => {
19943
+ resolveFrames = resolve2;
19749
19944
  rejectFrames = reject;
19750
19945
  });
19751
19946
  let resolveDrained;
19752
- const drained = new Promise((resolve) => resolveDrained = resolve);
19947
+ const drained = new Promise((resolve2) => resolveDrained = resolve2);
19753
19948
  const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
19754
19949
  let collected = [];
19755
19950
  const started = Date.now();
@@ -19841,16 +20036,6 @@ function receiveRecording(session, request) {
19841
20036
  }
19842
20037
  return { frames, drained };
19843
20038
  }
19844
- function openAudio(key, nonce, tag2, body) {
19845
- try {
19846
- const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
19847
- decipher.setAAD(VIDEO_GCM_AAD);
19848
- decipher.setAuthTag(tag2);
19849
- return Buffer.concat([decipher.update(body), decipher.final()]);
19850
- } catch {
19851
- return void 0;
19852
- }
19853
- }
19854
20039
  function decodeRecording(frames, eccPrivateKeyHex) {
19855
20040
  const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
19856
20041
  if (eccPrivateKey.length !== 32)
@@ -19879,12 +20064,9 @@ function decodeRecording(frames, eccPrivateKeyHex) {
19879
20064
  }
19880
20065
  } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
19881
20066
  const mediaKey = decoder.currentMediaKey;
19882
- if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
20067
+ if (!mediaKey || raw[5] !== AUDIO_CODEC_AAC_LC)
19883
20068
  continue;
19884
- const len = raw.readUInt32LE(0);
19885
- if (raw.length < AUDIO_BODY_START + len)
19886
- continue;
19887
- 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));
20069
+ const aac = openSealedAudio(raw, mediaKey);
19888
20070
  if (aac?.length)
19889
20071
  audio.push(aac);
19890
20072
  }
@@ -20287,7 +20469,8 @@ var SharedLiveSource = class {
20287
20469
  *
20288
20470
  * The previous stream is dropped rather than stopped through the state machine: it speaks to a session that
20289
20471
  * is gone, and its `stop` would be read as an upstream end. Only a warm-up that asked for a replacement
20290
- * rewarms, so this is inert on a source that is streaming or has already failed.
20472
+ * rewarms, so this is inert on a source that is streaming or has already failed. Each call replaces the
20473
+ * re-issue ticker rather than adding one, so the start goes out once per interval however often it runs.
20291
20474
  */
20292
20475
  rewarm() {
20293
20476
  if (this.disposed || !this.sessionReplacementAsked || this._state !== "warming")
@@ -20299,6 +20482,8 @@ var SharedLiveSource = class {
20299
20482
  this.openStream();
20300
20483
  this.warmAttempts++;
20301
20484
  this.logger.debug(`${this.tag} warming again on a replacement session (attempt ${this.warmAttempts})`);
20485
+ if (this.warmRetryTimer)
20486
+ clearInterval(this.warmRetryTimer);
20302
20487
  this.warmRetryTimer = setInterval(() => this.reissueStart(), this.warmRetryMs);
20303
20488
  }
20304
20489
  /**
@@ -21019,6 +21204,7 @@ function openReadableFromConsumer(consumer, opts = {}) {
21019
21204
  if (!readable.push(value))
21020
21205
  consumer.pause();
21021
21206
  });
21207
+ consumer.on("budget", (notice) => readable.emit("budget", notice));
21022
21208
  consumer.on("stop", () => readable.push(null));
21023
21209
  consumer.on("error", (err) => readable.destroy(err));
21024
21210
  return readable;
@@ -21954,7 +22140,7 @@ var FragmentRecording = class extends EventEmitter6 {
21954
22140
  throw this.failure;
21955
22141
  if (this.ended)
21956
22142
  return;
21957
- await new Promise((resolve) => this.wake = resolve);
22143
+ await new Promise((resolve2) => this.wake = resolve2);
21958
22144
  }
21959
22145
  } finally {
21960
22146
  this.stop();
@@ -22070,7 +22256,7 @@ var TALKBACK_CODEC_WAIT_MS = 3e3;
22070
22256
  var OWN_LIVE_SESSION_MODELS = /* @__PURE__ */ new Set(["T8030"]);
22071
22257
  var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
22072
22258
  function speakerCodec(consumer, talk, timeoutMs) {
22073
- return new Promise((resolve) => {
22259
+ return new Promise((resolve2) => {
22074
22260
  let timer;
22075
22261
  const settle = (codec) => {
22076
22262
  clearTimeout(timer);
@@ -22078,7 +22264,7 @@ function speakerCodec(consumer, talk, timeoutMs) {
22078
22264
  consumer.off("audio", onAudio);
22079
22265
  consumer.off("stop", onStop);
22080
22266
  talk.off("stop", onStop);
22081
- resolve(codec);
22267
+ resolve2(codec);
22082
22268
  };
22083
22269
  const onVideo = () => {
22084
22270
  timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
@@ -22130,6 +22316,18 @@ var P2PCommandRouter = class _P2PCommandRouter {
22130
22316
  * {@link liveSources} is a claim in flight.
22131
22317
  */
22132
22318
  liveSessionKeys = /* @__PURE__ */ new Map();
22319
+ /**
22320
+ * The live source build in flight per `${parentSn}:${channel}`, settled once its connection is opened
22321
+ * or refused.
22322
+ *
22323
+ * A build awaits its media session before it can file the source in {@link liveSources}, so a second
22324
+ * request for the same camera in that window finds nothing cached. Building a second source there
22325
+ * overwrites the first, and the orphan's teardown then closes the connection the cached one streams
22326
+ * on. A request that finds a build here waits for it, until its own signal aborts, then resolves its
22327
+ * session again and reuses what the build filed, or builds its own when that build failed. The entry
22328
+ * is removed when the build settles and its promise never rejects, so each wait ends on a later state.
22329
+ */
22330
+ liveSourceBuilds = /* @__PURE__ */ new Map();
22133
22331
  /**
22134
22332
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
22135
22333
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -22309,17 +22507,30 @@ var P2PCommandRouter = class _P2PCommandRouter {
22309
22507
  * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
22310
22508
  * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
22311
22509
  * carry the start is not a connected session, so this refuses rather than returning one.
22510
+ *
22511
+ * A session refused its key within the grace, or never connected, is closed and dropped from the manager at
22512
+ * once. Nothing else releases it: no source holds it, a wired station's session never idles out, and the
22513
+ * manager hands the same session to every later open of the camera, so the next open dials a fresh
22514
+ * connection instead. A session another open has since replaced is left to its own close.
22312
22515
  */
22313
22516
  async openMediaSession(parentSn, channel, signal) {
22314
- const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
22315
- await this.awaitConnected(session, parentSn, signal);
22316
- let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22317
- if (!ready && session.repromptLevel2Key()) {
22318
- ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22319
- }
22320
- if (!ready)
22321
- throw new StationKeyUnavailableError(parentSn);
22322
- return session;
22517
+ const mediaKey = _P2PCommandRouter.mediaSessionKey(parentSn, channel);
22518
+ const session = await this.openSession(mediaKey, parentSn);
22519
+ try {
22520
+ await this.awaitConnected(session, parentSn, signal);
22521
+ let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22522
+ if (!ready && session.repromptLevel2Key()) {
22523
+ ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22524
+ }
22525
+ if (!ready)
22526
+ throw new StationKeyUnavailableError(parentSn);
22527
+ return session;
22528
+ } catch (error) {
22529
+ if (!signal?.aborted && this.manager.get(mediaKey) === session) {
22530
+ void this.manager.close(mediaKey).catch((e) => this.reportError(e instanceof Error ? e : new Error(String(e))));
22531
+ }
22532
+ throw error;
22533
+ }
22323
22534
  }
22324
22535
  /**
22325
22536
  * Wait for `session` to connect, for up to the session's own connect deadline, and throw
@@ -22357,7 +22568,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22357
22568
  throw new Error(`no P2P endpoint (p2p_did) for station ${parentSn}`);
22358
22569
  let dskKey;
22359
22570
  try {
22360
- dskKey = (await this.deps.mega.getDskKeys([parentSn]))[parentSn]?.dskKey;
22571
+ dskKey = (await this.deps.mega.getDskKeys([stationDev]))[parentSn]?.dskKey;
22361
22572
  } catch (e) {
22362
22573
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
22363
22574
  }
@@ -22459,7 +22670,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22459
22670
  const session = await this.openSession(parentSn, parentSn);
22460
22671
  if (session.isConnected)
22461
22672
  return;
22462
- await new Promise((resolve, reject) => {
22673
+ await new Promise((resolve2, reject) => {
22463
22674
  const cleanup = () => {
22464
22675
  signal?.removeEventListener("abort", onAbort);
22465
22676
  session.off("connect", onConnect);
@@ -22468,7 +22679,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22468
22679
  };
22469
22680
  const onConnect = () => {
22470
22681
  cleanup();
22471
- resolve();
22682
+ resolve2();
22472
22683
  };
22473
22684
  const onError = (error) => {
22474
22685
  cleanup();
@@ -22828,6 +23039,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
22828
23039
  signal: opts.signal
22829
23040
  });
22830
23041
  const key = `${parentSn}:${channel}`;
23042
+ const build = this.liveSourceBuilds.get(key);
23043
+ if (build) {
23044
+ await abortable(build, opts.signal);
23045
+ return this.sharedLiveSourceFor(sn, opts, continuous);
23046
+ }
22831
23047
  const ownSession = continuous && homeBaseAttached && this.ownsLiveSession(parentSn);
22832
23048
  let source = this.liveSources.get(key);
22833
23049
  if (source && (source.state === "stopped" || ownSession && source.state !== "idle" && source.consumerCount === 0 && this.liveSessionKeys.get(key) === parentSn)) {
@@ -22839,6 +23055,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
22839
23055
  const logger = this.deps.logger ?? noopLogger;
22840
23056
  const sessionKey = ownSession || continuous && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
22841
23057
  this.liveSessionKeys.set(key, sessionKey);
23058
+ const built = Promise.withResolvers();
23059
+ this.liveSourceBuilds.set(key, built.promise);
22842
23060
  let held;
22843
23061
  try {
22844
23062
  held = {
@@ -22847,6 +23065,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
22847
23065
  } catch (error) {
22848
23066
  this.liveSessionKeys.delete(key);
22849
23067
  throw error;
23068
+ } finally {
23069
+ this.liveSourceBuilds.delete(key);
23070
+ built.resolve();
22850
23071
  }
22851
23072
  source = new SharedLiveSource({
22852
23073
  makeStream: (ctx) => new LiveStream(held.session, {
@@ -23151,7 +23372,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23151
23372
  async p2pQuery(sn, subCmd, opts = {}) {
23152
23373
  const { session, channel, accountId } = await this.resolveSession(sn, { waitLevel2: true });
23153
23374
  const timeoutMs = opts.timeoutMs ?? 15e3;
23154
- return await new Promise((resolve, reject) => {
23375
+ return await new Promise((resolve2, reject) => {
23155
23376
  const timer = setTimeout(() => {
23156
23377
  cleanup();
23157
23378
  reject(new Error(`timeout waiting for p2p query ${subCmd} reply from ${sn}`));
@@ -23160,7 +23381,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23160
23381
  const j = f.json;
23161
23382
  if (f.channel === channel && f.commandId === CommandType.CMD_NOTIFY_PAYLOAD && j?.cmd === subCmd && j.payload && typeof j.payload === "object") {
23162
23383
  cleanup();
23163
- resolve(j.payload);
23384
+ resolve2(j.payload);
23164
23385
  }
23165
23386
  };
23166
23387
  const cleanup = () => {
@@ -23190,7 +23411,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23190
23411
  async p2pControlQuery(sn, param, data, opts = {}) {
23191
23412
  const { session } = await this.resolveSession(sn, { waitLevel2: false });
23192
23413
  const timeoutMs = opts.timeoutMs ?? 15e3;
23193
- return await new Promise((resolve, reject) => {
23414
+ return await new Promise((resolve2, reject) => {
23194
23415
  const timer = setTimeout(() => {
23195
23416
  cleanup();
23196
23417
  reject(new Error(`timeout waiting for p2p control query ${param} reply from ${sn}`));
@@ -23199,7 +23420,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23199
23420
  const j = f.json;
23200
23421
  if (j?.cmd === param && j.payload && typeof j.payload === "object") {
23201
23422
  cleanup();
23202
- resolve(j.payload);
23423
+ resolve2(j.payload);
23203
23424
  }
23204
23425
  };
23205
23426
  const cleanup = () => {
@@ -23296,12 +23517,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
23296
23517
  * is ignored rather than resolving the wrong read.
23297
23518
  */
23298
23519
  awaitRtspUrl(session, channel, signal) {
23299
- return new Promise((resolve, reject) => {
23520
+ return new Promise((resolve2, reject) => {
23300
23521
  const onUrl = (ev) => {
23301
23522
  if (ev.channel !== channel)
23302
23523
  return;
23303
23524
  cleanup();
23304
- resolve(ev.url);
23525
+ resolve2(ev.url);
23305
23526
  };
23306
23527
  const onAbort = () => {
23307
23528
  cleanup();
@@ -23621,7 +23842,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23621
23842
  * wrapper has to share {@link serializeKeyed}.
23622
23843
  */
23623
23844
  awaitKeyedReply(sn, session, channel, engage, send) {
23624
- return new Promise((resolve, reject) => {
23845
+ return new Promise((resolve2, reject) => {
23625
23846
  let refused = false;
23626
23847
  let mark;
23627
23848
  let timer;
@@ -23641,7 +23862,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23641
23862
  if (refused)
23642
23863
  return;
23643
23864
  refused = true;
23644
- expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() => resolve({ code: KEYED_PAYLOAD_SEQ_ERROR, mark })));
23865
+ expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() => resolve2({ code: KEYED_PAYLOAD_SEQ_ERROR, mark })));
23645
23866
  };
23646
23867
  const onResult = (r) => {
23647
23868
  if (r.channel !== channel)
@@ -23649,7 +23870,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23649
23870
  if (r.code === KEYED_PAYLOAD_SEQ_ERROR)
23650
23871
  refuse();
23651
23872
  else if (!refused)
23652
- settle(() => resolve({ code: r.code }));
23873
+ settle(() => resolve2({ code: r.code }));
23653
23874
  };
23654
23875
  const onData = (f) => {
23655
23876
  if (f.channel !== channel || f.commandId !== CommandType.CMD_NOTIFY_PAYLOAD)
@@ -23661,7 +23882,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23661
23882
  }
23662
23883
  const code = keyedPayloadReportCode(f.json, engage);
23663
23884
  if (code !== void 0 && !refused)
23664
- settle(() => resolve({ code }));
23885
+ settle(() => resolve2({ code }));
23665
23886
  };
23666
23887
  session.on("commandResult", onResult);
23667
23888
  session.on("data", onData);
@@ -23766,7 +23987,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23766
23987
  clearTimeout(timer);
23767
23988
  session.off("data", onData);
23768
23989
  };
23769
- const getReplyPromise = new Promise((resolve) => {
23990
+ const getReplyPromise = new Promise((resolve2) => {
23770
23991
  onData = (f) => {
23771
23992
  const j = f.json;
23772
23993
  if (j?.cmd !== CMD_TRANSFER_PAYLOAD || !j.payload)
@@ -23779,11 +24000,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
23779
24000
  if (keyTime === void 0 || keyTime !== query.time)
23780
24001
  return;
23781
24002
  cleanup();
23782
- resolve({ lockPayload, keyTime });
24003
+ resolve2({ lockPayload, keyTime });
23783
24004
  };
23784
24005
  timer = setTimeout(() => {
23785
24006
  cleanup();
23786
- resolve(void 0);
24007
+ resolve2(void 0);
23787
24008
  }, _P2PCommandRouter.FF09_SETTINGS_GET_TIMEOUT_MS);
23788
24009
  session.on("data", onData);
23789
24010
  });
@@ -23912,7 +24133,9 @@ var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a
23912
24133
  var PORTAL_KEEPALIVE_MS = 29e3;
23913
24134
 
23914
24135
  // dist/transport/rtc/portal-packet.js
24136
+ import { zstdDecompressSync } from "node:zlib";
23915
24137
  var MAGIC2 = Buffer.from("XZYH", "ascii");
24138
+ var ZSTD_MAGIC_LE = 4247762216;
23916
24139
  var PORTAL_HEADER_LENGTH = 16;
23917
24140
  var PORTAL_CMD_SET_PAYLOAD = 1350;
23918
24141
  var PORTAL_STATION_CHANNEL = 255;
@@ -23976,11 +24199,23 @@ function parseJsonBody(buf) {
23976
24199
  return text2;
23977
24200
  }
23978
24201
  }
24202
+ function unwrapZstd(buf) {
24203
+ if (buf.length < 4 || buf.readUInt32LE(0) !== ZSTD_MAGIC_LE)
24204
+ return buf;
24205
+ try {
24206
+ return zstdDecompressSync(buf);
24207
+ } catch {
24208
+ return void 0;
24209
+ }
24210
+ }
23979
24211
  function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
23980
- const header = parsePortalHeader(buf);
24212
+ const packet = unwrapZstd(buf);
24213
+ if (!packet)
24214
+ return void 0;
24215
+ const header = parsePortalHeader(packet);
23981
24216
  if (!header)
23982
24217
  return void 0;
23983
- const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
24218
+ const body = packet.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
23984
24219
  if (linkType === PortalLinkType.NOTIFY) {
23985
24220
  const data = parseJsonBody(body);
23986
24221
  const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
@@ -24712,12 +24947,12 @@ var RtcPeer = class extends EventEmitter9 {
24712
24947
  this.remoteMid = media ? offer.match(/^a=mid:([^\r\n]+)/m)?.[1]?.trim() ?? HUB_SDP_MID : midOf(offer, "application") ?? HUB_SDP_MID;
24713
24948
  if (media)
24714
24949
  this.videoMid = midOf(offer, "video");
24715
- const answerWait = new Promise((resolve, reject) => {
24950
+ const answerWait = new Promise((resolve2, reject) => {
24716
24951
  const timer = setTimeout(() => {
24717
24952
  this.localAnswer = void 0;
24718
24953
  reject(new Error("timed out waiting for the local SDP answer"));
24719
24954
  }, ANSWER_TIMEOUT_MS);
24720
- this.localAnswer = { resolve, reject, timer };
24955
+ this.localAnswer = { resolve: resolve2, reject, timer };
24721
24956
  });
24722
24957
  pc.setRemoteDescription(offer, "offer");
24723
24958
  this.remoteSet = true;
@@ -24879,7 +25114,7 @@ var RtcPeer = class extends EventEmitter9 {
24879
25114
 
24880
25115
  // dist/transport/rtc/signaling.js
24881
25116
  import { EventEmitter as EventEmitter10 } from "node:events";
24882
- import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
25117
+ import { createHmac as createHmac3, randomUUID as randomUUID3 } from "node:crypto";
24883
25118
  var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
24884
25119
  var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
24885
25120
  var PORTAL_ORIGIN = "https://security.eufy.com";
@@ -24919,7 +25154,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24919
25154
  this.createSocket = opts.createSocket ?? defaultSocket;
24920
25155
  this.logger = opts.logger ?? noopLogger;
24921
25156
  this.now = opts.now ?? Date.now;
24922
- this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
25157
+ this.makeMsgId = opts.makeMsgId ?? (() => randomUUID3().replace(/-/g, ""));
24923
25158
  }
24924
25159
  get isOpen() {
24925
25160
  return this.ws?.readyState === WS_OPEN;
@@ -24963,7 +25198,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24963
25198
  };
24964
25199
  const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
24965
25200
  this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
24966
- await new Promise((resolve, reject) => {
25201
+ await new Promise((resolve2, reject) => {
24967
25202
  const timer = setTimeout(() => {
24968
25203
  reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
24969
25204
  this.close();
@@ -24977,7 +25212,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24977
25212
  this.sendAuth(sign);
24978
25213
  this.startKeepalive();
24979
25214
  this.emit("open");
24980
- resolve();
25215
+ resolve2();
24981
25216
  });
24982
25217
  ws.addEventListener("message", (ev) => {
24983
25218
  void this.handleWireMessage(ev.data);
@@ -25144,10 +25379,7 @@ var RtcSession = class extends EventEmitter11 {
25144
25379
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
25145
25380
  });
25146
25381
  });
25147
- this.signaling.on("close", () => {
25148
- this.connected = false;
25149
- this.announceClose();
25150
- });
25382
+ this.signaling.on("close", () => this.shutdown());
25151
25383
  this.signaling.on("error", (e) => this.emit("error", e));
25152
25384
  this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
25153
25385
  this.peer.on("iceGatheringComplete", () => this.trickle(""));
@@ -25250,12 +25482,12 @@ var RtcSession = class extends EventEmitter11 {
25250
25482
  waitForAuth() {
25251
25483
  if (this.authOk)
25252
25484
  return Promise.resolve();
25253
- return new Promise((resolve, reject) => {
25485
+ return new Promise((resolve2, reject) => {
25254
25486
  const timer = setTimeout(() => {
25255
25487
  this.authWaiter = void 0;
25256
25488
  reject(new Error("RTC signalling auth timeout"));
25257
25489
  }, AUTH_TIMEOUT_MS);
25258
- this.authWaiter = { resolve, reject, timer };
25490
+ this.authWaiter = { resolve: resolve2, reject, timer };
25259
25491
  });
25260
25492
  }
25261
25493
  /** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
@@ -25382,6 +25614,7 @@ var RtcCommandRouter = class {
25382
25614
  lives = /* @__PURE__ */ new Map();
25383
25615
  warm = /* @__PURE__ */ new Map();
25384
25616
  callSources = /* @__PURE__ */ new Map();
25617
+ lastTransaction = 0;
25385
25618
  constructor(deps) {
25386
25619
  this.deps = deps;
25387
25620
  }
@@ -25391,21 +25624,80 @@ var RtcCommandRouter = class {
25391
25624
  }
25392
25625
  if (route.mediaTrack)
25393
25626
  throw new Error(`rtc: ${route.stationSn} serves media only and takes no portal command`);
25394
- const identity = this.deps.identity();
25395
- if (!identity)
25396
- throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
25397
- const { stationSn } = route;
25398
- const adminUserId = route.adminUserId || identity.userId;
25399
25627
  const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
25400
- const st = await this.stationSession(route, adminUserId, identity);
25401
- const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
25402
- const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
25403
- const payload = cmd.kind === "set-json" ? { account_id: adminUserId, cmd: cmd.param, commandType: cmd.param, data: cmd.data } : { account_id: adminUserId, cmd: cmd.cmd, mValue3: cmd.mValue3 ?? 0, payload: cmd.payload };
25404
- const segment = st.seg.next();
25405
- const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
25406
- const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
25407
- st.queue = run.catch(() => void 0);
25408
- return run;
25628
+ if (cmd.kind === "set-json") {
25629
+ return this.sendQueued(route, PORTAL_CMD_CONTROL_PAYLOAD, cmd.param, channel, {
25630
+ cmd: cmd.param,
25631
+ commandType: cmd.param,
25632
+ data: cmd.data
25633
+ });
25634
+ }
25635
+ return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, cmd.cmd, channel, {
25636
+ cmd: cmd.cmd,
25637
+ mValue3: cmd.mValue3 ?? 0,
25638
+ payload: cmd.payload
25639
+ });
25640
+ }
25641
+ /**
25642
+ * Query one on-station table over the station's control channel and answer its rows.
25643
+ *
25644
+ * Sends the `1350` envelope with inner `{cmd: 1306, payload: {cmd: 10000, table, payload, transaction}}`,
25645
+ * the whole-table query, on the station channel. The hub ACKs on the command link and answers with one notify frame whose body
25646
+ * carries `{cmd, table, data, mIntRet, msg, transaction, …}`, echoing the read's `transaction`; that echo
25647
+ * is the only thing a reply is attributed by, and every read sends a `transaction` no other read on this
25648
+ * router has sent. Resolves with the reply's `data` rows. Rejects with the hub's `msg` on a non-zero
25649
+ * `mIntRet`, on an ACK refusing the envelope, at once when `signal` aborts (queued or sent), when the
25650
+ * session closes, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
25651
+ *
25652
+ * One read or command at a time per station: the read holds the station's send queue until it settles.
25653
+ */
25654
+ readTable(route, table, opts = {}) {
25655
+ const transaction = this.nextTransaction();
25656
+ const inner = {
25657
+ cmd: DB_QUERY.FULL_TABLE,
25658
+ table,
25659
+ payload: fullTableQuery(),
25660
+ transaction
25661
+ };
25662
+ return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, CMD_DATABASE, PORTAL_STATION_CHANNEL, { cmd: CMD_DATABASE, mValue3: 0, payload: inner }, {
25663
+ timeoutMs: opts.timeoutMs,
25664
+ signal: opts.signal,
25665
+ pick: (body) => {
25666
+ if (String(body.transaction) !== transaction)
25667
+ return void 0;
25668
+ if (typeof body.mIntRet === "number" && body.mIntRet !== 0) {
25669
+ throw new Error(`rtc: ${route.stationSn} refused ${table}: ${body.msg ?? body.mIntRet} (${body.mIntRet})`);
25670
+ }
25671
+ return Array.isArray(body.data) ? body.data : void 0;
25672
+ }
25673
+ });
25674
+ }
25675
+ /**
25676
+ * Fetch one stored image by its on-station path over the station's control channel.
25677
+ *
25678
+ * Sends the `1350` envelope with inner `{cmd: 1308, payload: [{file}]}` on the station channel. The hub
25679
+ * ACKs on the command link and answers with one notify frame whose body is `{file, content}`, `content`
25680
+ * the image in base64, and no `transaction`; the reply is the one whose `file` equals `filePath`. A reply
25681
+ * arriving after an abandoned read of the same path carries that path's bytes, which are the bytes a
25682
+ * later read of it asks for. Resolves with the decoded bytes. Rejects on an ACK refusing the envelope,
25683
+ * at once when `signal` aborts (queued or sent), when the session closes, and when `timeoutMs` (default
25684
+ * {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
25685
+ *
25686
+ * One read or command at a time per station: the read holds the station's send queue until it settles.
25687
+ */
25688
+ readImage(route, filePath, opts = {}) {
25689
+ return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, CMD_DATABASE_IMAGE, PORTAL_STATION_CHANNEL, { cmd: CMD_DATABASE_IMAGE, mValue3: 0, payload: [{ file: filePath }] }, {
25690
+ ...opts,
25691
+ pick: (body) => body.file === filePath && typeof body.content === "string" ? Buffer.from(body.content, "base64") : void 0
25692
+ });
25693
+ }
25694
+ /**
25695
+ * A `transaction` stamp in the vendor app's shape, a decimal epoch-in-milliseconds string, strictly
25696
+ * greater than the last one this router issued, so two reads in the same millisecond never share one.
25697
+ */
25698
+ nextTransaction() {
25699
+ this.lastTransaction = Math.max(Date.now(), this.lastTransaction + 1);
25700
+ return String(this.lastTransaction);
25409
25701
  }
25410
25702
  /**
25411
25703
  * The media of a camera on a T9000: live video, a readable feed and a live still over the station's
@@ -25581,45 +25873,109 @@ var RtcCommandRouter = class {
25581
25873
  return source;
25582
25874
  }
25583
25875
  /**
25584
- * Completes on the command ACK or a correlated control-result notification. The notification must
25585
- * match the request channel, parameter and segment and contain a structured payload. Receipt does
25586
- * not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
25876
+ * Send one envelope on the station's session, queued behind its other sends, and settle as
25877
+ * {@link sendAwaitAck} does. `body` is the inner payload; the station session, the `account_id` it is
25878
+ * stamped with and the packet are resolved here, so a command and a read reach the wire through the same
25879
+ * steps. With `reply.signal`, the caller's wait rejects the moment it aborts, also while the send is still
25880
+ * queued; a send not yet made by then is skipped.
25881
+ */
25882
+ async sendQueued(route, outerCmd, innerCmd, channel, body, reply) {
25883
+ reply?.signal?.throwIfAborted();
25884
+ const identity = this.deps.identity();
25885
+ if (!identity)
25886
+ throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
25887
+ const adminUserId = route.adminUserId || identity.userId;
25888
+ const st = await abortable(this.stationSession(route, adminUserId, identity), reply?.signal);
25889
+ const segment = st.seg.next();
25890
+ const packet = buildPortalPacket({
25891
+ commandId: outerCmd,
25892
+ channel,
25893
+ segment,
25894
+ payload: { account_id: adminUserId, ...body }
25895
+ });
25896
+ const run = st.queue.then(() => this.sendAwaitAck(route.stationSn, st, packet, outerCmd, innerCmd, channel, segment, reply));
25897
+ st.queue = run.catch(() => void 0);
25898
+ return abortable(run, reply?.signal);
25899
+ }
25900
+ /**
25901
+ * Without `reply`, completes on the command ACK or a correlated control-result notification. The
25902
+ * notification must match the request channel, parameter and segment and contain a structured payload.
25903
+ * Receipt does not establish physical actuation. Bounded by {@link ACK_TIMEOUT_MS}.
25904
+ *
25905
+ * With `reply`, a zero-result ACK is not the answer: the first notify body (an object) for which
25906
+ * `reply.pick` answers a value resolves with it, one `pick` throws on rejects with that error, and every
25907
+ * other notify frame is left alone. Bounded by `reply.timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}),
25908
+ * and ended by `reply.signal`; one already aborted is not sent.
25909
+ *
25910
+ * Either way an ACK with a non-zero result code rejects, and the command is sent once.
25587
25911
  */
25588
- sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
25589
- return new Promise((resolve, reject) => {
25912
+ sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment, reply) {
25913
+ const timeoutMs = reply ? reply.timeoutMs ?? DB_TABLE_TIMEOUT_MS : ACK_TIMEOUT_MS;
25914
+ const awaited = reply ? "reply" : "ACK";
25915
+ return new Promise((resolve2, reject) => {
25590
25916
  const onData = (frame, linkType) => {
25591
25917
  const p = parsePortalPacket(frame, linkType);
25592
- if (!p || p.segment !== segment)
25918
+ if (!p)
25919
+ return;
25920
+ if (reply && linkType === PortalLinkType.NOTIFY && typeof p.data === "object" && p.data !== null) {
25921
+ let picked;
25922
+ try {
25923
+ picked = reply.pick(p.data);
25924
+ } catch (e) {
25925
+ cleanup();
25926
+ reject(e instanceof Error ? e : new Error(String(e)));
25927
+ return;
25928
+ }
25929
+ if (picked === void 0)
25930
+ return;
25931
+ cleanup();
25932
+ this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} answered`);
25933
+ resolve2(picked);
25934
+ return;
25935
+ }
25936
+ if (p.segment !== segment)
25593
25937
  return;
25594
25938
  const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
25595
25939
  const d = p.data;
25596
25940
  const notification = outerCmd === PORTAL_CMD_CONTROL_PAYLOAD && linkType === PortalLinkType.NOTIFY && p.commandId === PORTAL_CMD_NOTIFY_PAYLOAD && p.isResponse === 0 && p.channel === channel && p.cmd === innerCmd && typeof d?.payload === "object" && d.payload !== null;
25597
25941
  if (!acknowledgement && !notification)
25598
25942
  return;
25943
+ if (reply && p.errCode === 0)
25944
+ return;
25599
25945
  cleanup();
25600
25946
  if (acknowledgement && p.errCode !== 0) {
25601
25947
  reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
25602
25948
  } else {
25603
25949
  this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
25604
- resolve();
25950
+ resolve2(void 0);
25605
25951
  }
25606
25952
  };
25607
25953
  const onClose = () => {
25608
25954
  cleanup();
25609
- reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
25955
+ reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ${awaited}`));
25956
+ };
25957
+ const onAbort = () => {
25958
+ cleanup();
25959
+ reject(reply?.signal?.reason);
25610
25960
  };
25611
25961
  const timer = setTimeout(() => {
25612
25962
  cleanup();
25613
- reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
25614
- }, ACK_TIMEOUT_MS);
25963
+ reject(new Error(`rtc: ${sn} cmd ${innerCmd} ${awaited} timed out after ${timeoutMs}ms`));
25964
+ }, timeoutMs);
25615
25965
  const cleanup = () => {
25616
25966
  clearTimeout(timer);
25617
25967
  st.session.off("commandData", onData);
25618
25968
  st.session.off("close", onClose);
25969
+ reply?.signal?.removeEventListener("abort", onAbort);
25619
25970
  this.touch(sn, st);
25620
25971
  };
25972
+ if (reply?.signal?.aborted) {
25973
+ onAbort();
25974
+ return;
25975
+ }
25621
25976
  st.session.on("commandData", onData);
25622
25977
  st.session.on("close", onClose);
25978
+ reply?.signal?.addEventListener("abort", onAbort, { once: true });
25623
25979
  if (!st.session.sendCommand(packet)) {
25624
25980
  cleanup();
25625
25981
  reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
@@ -25675,7 +26031,7 @@ var RtcCommandRouter = class {
25675
26031
  * promise, so an early failure cannot leave a second one rejecting unheard.
25676
26032
  */
25677
26033
  bringUp(sn, session) {
25678
- return new Promise((resolve, reject) => {
26034
+ return new Promise((resolve2, reject) => {
25679
26035
  let settled = false;
25680
26036
  const finish = (err) => {
25681
26037
  if (settled)
@@ -25692,7 +26048,7 @@ var RtcCommandRouter = class {
25692
26048
  reject(err);
25693
26049
  } else {
25694
26050
  this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
25695
- resolve();
26051
+ resolve2();
25696
26052
  }
25697
26053
  };
25698
26054
  const onConnected = () => finish();
@@ -26121,10 +26477,10 @@ function dpColorFields(spec) {
26121
26477
  }
26122
26478
 
26123
26479
  // dist/transport/mqtt/clean-codec.js
26124
- import { randomUUID as randomUUID3 } from "node:crypto";
26480
+ import { randomUUID as randomUUID4 } from "node:crypto";
26125
26481
  function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
26126
26482
  const payload = JSON.stringify({
26127
- t: opts?.uuid ?? randomUUID3(),
26483
+ t: opts?.uuid ?? randomUUID4(),
26128
26484
  protocol: 2,
26129
26485
  account_id: accountId,
26130
26486
  device_sn: deviceSn,
@@ -26363,13 +26719,13 @@ var MqttCommandRouter = class _MqttCommandRouter {
26363
26719
  * timeout with no unhandled listener left behind either way.
26364
26720
  */
26365
26721
  waitForMqttMessage(mqtt, match, timeoutMs) {
26366
- return new Promise((resolve) => {
26722
+ return new Promise((resolve2) => {
26367
26723
  const onMessage = (m) => {
26368
26724
  const result = match(m);
26369
26725
  if (result === void 0)
26370
26726
  return;
26371
26727
  cleanup();
26372
- resolve(result);
26728
+ resolve2(result);
26373
26729
  };
26374
26730
  const cleanup = () => {
26375
26731
  clearTimeout(timer);
@@ -26377,7 +26733,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
26377
26733
  };
26378
26734
  const timer = setTimeout(() => {
26379
26735
  cleanup();
26380
- resolve(void 0);
26736
+ resolve2(void 0);
26381
26737
  }, timeoutMs);
26382
26738
  timer.unref?.();
26383
26739
  mqtt.on("message", onMessage);
@@ -26897,7 +27253,7 @@ function decryptReply(key, resultB64) {
26897
27253
  }
26898
27254
 
26899
27255
  // dist/transport/tuya/request.js
26900
- import { randomUUID as randomUUID4 } from "node:crypto";
27256
+ import { randomUUID as randomUUID5 } from "node:crypto";
26901
27257
  var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
26902
27258
  var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
26903
27259
  var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
@@ -26929,7 +27285,7 @@ function buildApiParams(action, opts) {
26929
27285
  a: action.a,
26930
27286
  v: action.v,
26931
27287
  time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
26932
- requestId: opts.requestId ?? randomUUID4(),
27288
+ requestId: opts.requestId ?? randomUUID5(),
26933
27289
  sid: opts.session.sid,
26934
27290
  deviceId: opts.session.deviceId,
26935
27291
  chKey: opts.session.chKey,
@@ -28268,8 +28624,6 @@ var FcmRegistrar = class {
28268
28624
  };
28269
28625
 
28270
28626
  // dist/transport/push/store.js
28271
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, rmSync as rmSync2 } from "node:fs";
28272
- import { dirname as dirname2 } from "node:path";
28273
28627
  var MemoryFcmStore = class {
28274
28628
  p = null;
28275
28629
  load() {
@@ -28282,31 +28636,7 @@ var MemoryFcmStore = class {
28282
28636
  this.p = null;
28283
28637
  }
28284
28638
  };
28285
- var FileFcmStore = class {
28286
- path;
28287
- constructor(path) {
28288
- this.path = path;
28289
- }
28290
- load() {
28291
- try {
28292
- return JSON.parse(readFileSync2(this.path, "utf-8"));
28293
- } catch {
28294
- return null;
28295
- }
28296
- }
28297
- save(p) {
28298
- try {
28299
- mkdirSync2(dirname2(this.path), { recursive: true });
28300
- } catch {
28301
- }
28302
- writeFileSync2(this.path, JSON.stringify(p, null, 2), { mode: 384 });
28303
- }
28304
- clear() {
28305
- try {
28306
- rmSync2(this.path);
28307
- } catch {
28308
- }
28309
- }
28639
+ var FileFcmStore = class extends FileSessionStore {
28310
28640
  };
28311
28641
 
28312
28642
  // dist/transport/media-failure.js
@@ -28335,6 +28665,12 @@ var StoredImageCache = class {
28335
28665
  activeDevices = /* @__PURE__ */ new Set();
28336
28666
  activeDownloads = 0;
28337
28667
  generation = 0;
28668
+ /**
28669
+ * Bumped by {@link clearLifecycleErrors}. A lifecycle failure from a download started under an older
28670
+ * count is not recorded; the candidate is queued again, unless a newer one is already queued, so the
28671
+ * current session fetches it.
28672
+ */
28673
+ lifecycle = 0;
28338
28674
  constructor(downloader, logger, clock = Date.now, isLifecycleError = () => false) {
28339
28675
  this.downloader = downloader;
28340
28676
  this.logger = logger;
@@ -28390,6 +28726,18 @@ var StoredImageCache = class {
28390
28726
  return Promise.resolve(state.retained.image);
28391
28727
  return Promise.reject(new StoredSnapshotUnavailableError(reason, "No stored snapshot is available"));
28392
28728
  }
28729
+ /**
28730
+ * Forget every device's recorded lifecycle failure, keeping retained bytes and candidate state.
28731
+ *
28732
+ * A lifecycle failure describes the session that was current when the download ran, so it no longer
28733
+ * holds once a login succeeds again; {@link snapshotStored} then answers from the retained image, or
28734
+ * with the reason a stored image is absent. A download in flight across the call cannot record one.
28735
+ */
28736
+ clearLifecycleErrors() {
28737
+ this.lifecycle += 1;
28738
+ for (const state of this.devices.values())
28739
+ state.lifecycleError = void 0;
28740
+ }
28393
28741
  /** Invalidate all retained and candidate state. */
28394
28742
  clear() {
28395
28743
  this.generation += 1;
@@ -28405,18 +28753,23 @@ var StoredImageCache = class {
28405
28753
  state.inFlight = candidate;
28406
28754
  this.activeDownloads += 1;
28407
28755
  this.activeDevices.add(candidate.deviceKey);
28408
- void this.downloader(candidate.url, candidate.deviceKey).then((image) => this.complete(candidate, image), (error) => this.complete(candidate, void 0, error));
28756
+ const lifecycle = this.lifecycle;
28757
+ void this.downloader(candidate.url, candidate.deviceKey).then((image) => this.complete(candidate, lifecycle, image), (error) => this.complete(candidate, lifecycle, void 0, error));
28409
28758
  }
28410
28759
  }
28411
- complete(candidate, image, error) {
28760
+ complete(candidate, lifecycle, image, error) {
28412
28761
  this.activeDownloads -= 1;
28413
28762
  this.activeDevices.delete(candidate.deviceKey);
28414
28763
  const state = this.devices.get(candidate.deviceKey);
28415
28764
  if (candidate.generation === this.generation && state?.inFlight === candidate) {
28416
28765
  state.inFlight = void 0;
28417
28766
  if (error !== void 0 && this.isLifecycleError(error)) {
28418
- state.lifecycleError = error;
28419
- state.reason = void 0;
28767
+ if (lifecycle === this.lifecycle) {
28768
+ state.lifecycleError = error;
28769
+ state.reason = void 0;
28770
+ } else {
28771
+ state.queued ??= candidate;
28772
+ }
28420
28773
  } else if (image === void 0 && this.retryLater(state, candidate, error)) {
28421
28774
  state.lifecycleError = void 0;
28422
28775
  } else if (image === void 0) {
@@ -28908,8 +29261,8 @@ var DeviceRegistry = class {
28908
29261
  const waiters = this.stateWaiters.get(sn);
28909
29262
  if (waiters) {
28910
29263
  this.stateWaiters.delete(sn);
28911
- for (const resolve of waiters)
28912
- resolve();
29264
+ for (const resolve2 of waiters)
29265
+ resolve2();
28913
29266
  }
28914
29267
  }
28915
29268
  /**
@@ -28954,15 +29307,15 @@ var DeviceRegistry = class {
28954
29307
  awaitRealtimeState(sn, timeoutMs) {
28955
29308
  if (this.hasRealtimeState(sn) || timeoutMs <= 0)
28956
29309
  return Promise.resolve();
28957
- return new Promise((resolve) => {
29310
+ return new Promise((resolve2) => {
28958
29311
  const timer = setTimeout(() => {
28959
29312
  this.stateWaiters.get(sn)?.delete(done);
28960
- resolve();
29313
+ resolve2();
28961
29314
  }, timeoutMs);
28962
29315
  timer.unref?.();
28963
29316
  const done = () => {
28964
29317
  clearTimeout(timer);
28965
- resolve();
29318
+ resolve2();
28966
29319
  };
28967
29320
  const set = this.stateWaiters.get(sn) ?? /* @__PURE__ */ new Set();
28968
29321
  set.add(done);
@@ -29143,6 +29496,12 @@ var EufyMega = class extends EventEmitter14 {
29143
29496
  /** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
29144
29497
  * publish await the SAME fully-connected transport instead of racing (and never open two). */
29145
29498
  mqttReady = /* @__PURE__ */ new Map();
29499
+ /**
29500
+ * Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
29501
+ * subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
29502
+ * starts empty.
29503
+ */
29504
+ mqttSubscribed = /* @__PURE__ */ new WeakMap();
29146
29505
  /** Device list/record/capability resolution + the frame→caps cache. */
29147
29506
  registry;
29148
29507
  pushClient;
@@ -29666,7 +30025,7 @@ var EufyMega = class extends EventEmitter14 {
29666
30025
  return true;
29667
30026
  const delay = Math.min(500, deadline - Date.now());
29668
30027
  if (delay > 0)
29669
- await new Promise((resolve) => setTimeout(resolve, delay));
30028
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
29670
30029
  if (refresh.expected !== void 0 && settled())
29671
30030
  return true;
29672
30031
  }
@@ -29715,7 +30074,7 @@ var EufyMega = class extends EventEmitter14 {
29715
30074
  * manager's own log, since by then nothing is waiting to receive it.
29716
30075
  */
29717
30076
  recycleStandaloneSession(sn) {
29718
- const releaseWrite = new Promise((resolve) => void setTimeout(resolve, SESSION_RECYCLE_WAIT_MS).unref?.());
30077
+ const releaseWrite = new Promise((resolve2) => void setTimeout(resolve2, SESSION_RECYCLE_WAIT_MS).unref?.());
29719
30078
  return Promise.race([
29720
30079
  this.p2p.resetStandaloneSession(sn).finally(() => this.reopenWiredStation(sn)),
29721
30080
  releaseWrite.then(() => this.opts.logger?.debug(`[session ${sn}] recycle still waiting on an attached viewer \u2014 releasing the write that asked for it`))
@@ -29752,11 +30111,11 @@ var EufyMega = class extends EventEmitter14 {
29752
30111
  }
29753
30112
  /** Limit waiting on an unabortable dependency operation to the remaining semantic-event refresh window. */
29754
30113
  beforeDeadline(operation, timeoutMs) {
29755
- return new Promise((resolve, reject) => {
30114
+ return new Promise((resolve2, reject) => {
29756
30115
  const timeout = setTimeout(() => reject(new RefreshWindowClosedError("refresh window closed")), Math.max(0, timeoutMs));
29757
30116
  operation.then((value) => {
29758
30117
  clearTimeout(timeout);
29759
- resolve(value);
30118
+ resolve2(value);
29760
30119
  }, (error) => {
29761
30120
  clearTimeout(timeout);
29762
30121
  reject(error);
@@ -29861,12 +30220,16 @@ var EufyMega = class extends EventEmitter14 {
29861
30220
  /**
29862
30221
  * On a successful login, kick off auto-realtime (unless `autoRealtime:false`). Fire-and-forget so
29863
30222
  * `login()` returns as soon as the session is ready — realtime channels come up in the background and
29864
- * surface failures via `error`. Idempotent through the retained generation promise.
30223
+ * surface failures via `error`. Idempotent through the retained generation promise. A login for a
30224
+ * different account drops every stored image; one for the same account drops only the lifecycle
30225
+ * failures recorded against the session it replaces.
29865
30226
  */
29866
30227
  afterLogin(result) {
29867
30228
  if (result.status === LoginStatus.Ok) {
29868
30229
  if (this.storedImageAccount && this.storedImageAccount !== result.session.userId)
29869
30230
  this.storedImages?.clear();
30231
+ else
30232
+ this.storedImages?.clearLifecycleErrors();
29870
30233
  this.storedImageAccount = result.session.userId;
29871
30234
  const auth = this.mega.auth;
29872
30235
  if (auth?.userId)
@@ -29903,12 +30266,12 @@ var EufyMega = class extends EventEmitter14 {
29903
30266
  if (options.timeoutMs === void 0)
29904
30267
  return generation.promise;
29905
30268
  const timeoutMs = Math.max(0, options.timeoutMs);
29906
- return new Promise((resolve) => {
29907
- const timer = setTimeout(() => resolve(readinessSnapshot(generation.readiness, "timed-out")), timeoutMs);
30269
+ return new Promise((resolve2) => {
30270
+ const timer = setTimeout(() => resolve2(readinessSnapshot(generation.readiness, "timed-out")), timeoutMs);
29908
30271
  timer.unref?.();
29909
30272
  void generation.promise.then((result) => {
29910
30273
  clearTimeout(timer);
29911
- resolve(result);
30274
+ resolve2(result);
29912
30275
  });
29913
30276
  });
29914
30277
  }
@@ -29949,10 +30312,10 @@ var EufyMega = class extends EventEmitter14 {
29949
30312
  let acknowledge;
29950
30313
  let reject;
29951
30314
  let acknowledged = false;
29952
- const acknowledgement = new Promise((resolve, rejectPromise) => {
30315
+ const acknowledgement = new Promise((resolve2, rejectPromise) => {
29953
30316
  acknowledge = () => {
29954
30317
  acknowledged = true;
29955
- resolve();
30318
+ resolve2();
29956
30319
  };
29957
30320
  reject = rejectPromise;
29958
30321
  });
@@ -30319,8 +30682,8 @@ var EufyMega = class extends EventEmitter14 {
30319
30682
  if (this.realtimeGeneration?.epoch === this.realtimeEpoch)
30320
30683
  return this.realtimeGeneration;
30321
30684
  const epoch = this.realtimeEpoch;
30322
- let resolve;
30323
- const promise = new Promise((done) => resolve = done);
30685
+ let resolve2;
30686
+ const promise = new Promise((done) => resolve2 = done);
30324
30687
  const generation = {
30325
30688
  epoch,
30326
30689
  readiness: {
@@ -30330,7 +30693,7 @@ var EufyMega = class extends EventEmitter14 {
30330
30693
  wiredP2p: createPlaneReadiness()
30331
30694
  },
30332
30695
  promise,
30333
- resolve,
30696
+ resolve: resolve2,
30334
30697
  abort: new AbortController(),
30335
30698
  settled: false
30336
30699
  };
@@ -30481,6 +30844,12 @@ var EufyMega = class extends EventEmitter14 {
30481
30844
  * device absent from the previous pass is skipped — first sight is discovery, not a transition;
30482
30845
  * {@link deviceState} answers an initial reading.
30483
30846
  *
30847
+ * A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
30848
+ * of its params moved, since each re-resolution is a cloud param-list read.
30849
+ *
30850
+ * Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
30851
+ * sized it from the roster at login, which is empty when the cloud was unreachable then.
30852
+ *
30484
30853
  * Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
30485
30854
  * is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
30486
30855
  * stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
@@ -30499,8 +30868,9 @@ var EufyMega = class extends EventEmitter14 {
30499
30868
  this.emitSemantic(out.event, out.payload, { refresh: out.refresh });
30500
30869
  for (const dev of diff.reported)
30501
30870
  this.emit("deviceState", this.stateOf(dev));
30502
- for (const change of diff.params)
30503
- await this.widenCapabilities(change.deviceSn);
30871
+ for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
30872
+ await this.widenCapabilities(sn);
30873
+ await this.syncMqttRoster();
30504
30874
  } catch (e) {
30505
30875
  this.reportError(e);
30506
30876
  }
@@ -30840,12 +31210,21 @@ var EufyMega = class extends EventEmitter14 {
30840
31210
  /**
30841
31211
  * Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
30842
31212
  * unreachable device must not stop the rest of the roster from coming up).
31213
+ * A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
31214
+ * whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
31215
+ * per transport.
30843
31216
  *
30844
31217
  * A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
30845
31218
  * and the broker refuses its `eufy_home` topics on every credential.
30846
31219
  */
30847
31220
  async subscribeMqttDevices(scope, transport) {
31221
+ let subscribed = this.mqttSubscribed.get(transport);
31222
+ if (!subscribed)
31223
+ this.mqttSubscribed.set(transport, subscribed = /* @__PURE__ */ new Set());
30848
31224
  for (const d of this.getMqttDevices().filter((dev) => !isTuyaVacuum(dev) && mqttScopeFor(dev) === scope)) {
31225
+ if (subscribed.has(d.sn))
31226
+ continue;
31227
+ subscribed.add(d.sn);
30849
31228
  try {
30850
31229
  await transport.subscribeDevice(d);
30851
31230
  await this.sendRealtimeInit(d.sn);
@@ -30854,6 +31233,23 @@ var EufyMega = class extends EventEmitter14 {
30854
31233
  }
30855
31234
  }
30856
31235
  }
31236
+ /**
31237
+ * Start every credential scope the roster now needs and has no transport for, and subscribe the
31238
+ * devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
31239
+ * the realtime generation is superseded or was never started.
31240
+ */
31241
+ async syncMqttRoster() {
31242
+ if (this.realtimeGeneration?.epoch !== this.realtimeEpoch)
31243
+ return;
31244
+ await Promise.all(this.mqttScopesInUse().map(async (scope) => {
31245
+ const transport = this.transports.get(scope);
31246
+ try {
31247
+ await (transport ? this.subscribeMqttDevices(scope, transport) : this.ensureMqttStarted(scope));
31248
+ } catch (e) {
31249
+ this.reportError(e);
31250
+ }
31251
+ }));
31252
+ }
30857
31253
  /**
30858
31254
  * Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
30859
31255
  * state is pushed on change with no heartbeat, the request that makes its state readable before the
@@ -30886,31 +31282,72 @@ var EufyMega = class extends EventEmitter14 {
30886
31282
  return this.p2p.getSessions();
30887
31283
  }
30888
31284
  /**
30889
- * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
31285
+ * The people enrolled on a station, read from its own `person_basic_info` table through
31286
+ * {@link readStationTable}.
30890
31287
  *
30891
31288
  * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
30892
31289
  * station, because they were never uploaded — the station holds them.
30893
31290
  *
30894
- * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
30895
- * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
30896
- * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
31291
+ * Resolves with the rows the station returned that are objects. Rejects as {@link readStationTable}
31292
+ * does.
30897
31293
  *
30898
31294
  * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
30899
31295
  * returned as they arrive; a caller that wants only named people filters them.
30900
31296
  */
30901
31297
  async getStationFaces(stationSn, opts = {}) {
31298
+ const rows = await this.readStationTable(stationSn, "person_basic_info", opts);
31299
+ return rows.filter((row) => typeof row === "object" && row !== null);
31300
+ }
31301
+ /**
31302
+ * The rows of one of a station's own tables (`history_record_info`, `person_basic_info`, …), read over
31303
+ * the transport the station speaks: the control channel of a HomeBase S1 Pro (T9000), P2P for any
31304
+ * other station. The query is the table's whole content; the rows are the station's, shaped by it.
31305
+ *
31306
+ * Resolves with the rows the station returned. Rejects on a serial not in the device list, on a record
31307
+ * stating no `admin_user_id`, on a station that cannot be reached, on a query the station refuses, and
31308
+ * on the reply not arriving in time. Assembling the reply is the transport's; this resolves which
31309
+ * station, which transport and which account id.
31310
+ */
31311
+ async readStationTable(stationSn, table, opts = {}) {
31312
+ const { station, accountId, route } = await this.stationReadTarget("readStationTable", stationSn);
31313
+ if (route)
31314
+ return this.rtc.readTable(route, table, opts);
31315
+ await this.p2p.ensureStation(station, opts.signal);
31316
+ const session = this.p2p.getSessions().get(station);
31317
+ if (!session)
31318
+ throw new Error(`readStationTable: no P2P session for ${station}`);
31319
+ return session.readDatabase(table, { accountId, ...opts });
31320
+ }
31321
+ /**
31322
+ * One image stored on a HomeBase S1 Pro (T9000), by the on-station path a table row names
31323
+ * (`thumb_path`, …), read over the station's control channel.
31324
+ *
31325
+ * Resolves with the image bytes as the station holds them. Rejects on a serial not in the device list,
31326
+ * on a record stating no `admin_user_id`, on a station other than a T9000, on a station that cannot be
31327
+ * reached, and on the reply not arriving in time. Fetching the image is the transport's; this resolves
31328
+ * which station and which account id.
31329
+ */
31330
+ async readStationImage(stationSn, filePath, opts = {}) {
31331
+ const { station, route } = await this.stationReadTarget("readStationImage", stationSn);
31332
+ if (!route)
31333
+ throw new Error(`readStationImage: ${station} is not a T9000; its stored images are not read here`);
31334
+ return this.rtc.readImage(route, filePath, opts);
31335
+ }
31336
+ /**
31337
+ * The station a database read goes to, its `admin_user_id` and, for a T9000, its RTC route. Throws,
31338
+ * prefixed with `op`, on a serial the device list does not carry and on a record stating no admin id.
31339
+ */
31340
+ async stationReadTarget(op, stationSn) {
30902
31341
  if (!this.registry.list().length)
30903
31342
  await this.getDevices();
30904
31343
  const station = this.p2p.stationKeyOf(stationSn);
31344
+ const found = this.stationOf(station);
31345
+ if (!found.station)
31346
+ throw new Error(`${op}: ${station} is not in the device list`);
30905
31347
  const accountId = this.adminUserIdOf(station);
30906
31348
  if (!accountId)
30907
- throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
30908
- await this.p2p.ensureStation(station, opts.signal);
30909
- const session = this.p2p.getSessions().get(station);
30910
- if (!session)
30911
- throw new Error(`getStationFaces: no P2P session for ${station}`);
30912
- const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
30913
- return rows.filter((row) => typeof row === "object" && row !== null);
31349
+ throw new Error(`${op}: no admin_user_id on record for ${station}`);
31350
+ return { station, accountId, route: found.t9000 ? this.rtcRouteFor(station) : void 0 };
30914
31351
  }
30915
31352
  /** The station's own `admin_user_id`, which its database refuses the query without. */
30916
31353
  adminUserIdOf(stationSn) {
@@ -31137,6 +31574,9 @@ var EufyMega = class extends EventEmitter14 {
31137
31574
  * Returns the connected client rather than installing it. Registration can outlive a `disconnect()`,
31138
31575
  * and {@link ensureRealtime} owns the decision of whether a finished bring-up is still the current
31139
31576
  * one — so this never overwrites a channel a later login already brought up.
31577
+ *
31578
+ * Persisting the seen ids after each push reports a failed `save` through `error` rather than throwing:
31579
+ * the push handler runs inside the MCS socket's `data` callback, where a throw is an uncaught exception.
31140
31580
  */
31141
31581
  async startPush(signal) {
31142
31582
  if (!this.mega.auth)
@@ -31183,9 +31623,13 @@ var EufyMega = class extends EventEmitter14 {
31183
31623
  if (dsn)
31184
31624
  this.prewarmForEvent(out.event, dsn);
31185
31625
  }
31186
- store.save({ creds: persistedCreds, persistentIds: client.getPersistentIds() });
31626
+ try {
31627
+ store.save({ creds: persistedCreds, persistentIds: client.getPersistentIds() });
31628
+ } catch (e) {
31629
+ this.reportError(e);
31630
+ }
31187
31631
  });
31188
- await new Promise((resolve, reject) => {
31632
+ await new Promise((resolve2, reject) => {
31189
31633
  const cleanup = () => {
31190
31634
  signal?.removeEventListener("abort", onAbort);
31191
31635
  client.off("connect", onConnect);
@@ -31193,7 +31637,7 @@ var EufyMega = class extends EventEmitter14 {
31193
31637
  };
31194
31638
  const onConnect = () => {
31195
31639
  cleanup();
31196
- resolve();
31640
+ resolve2();
31197
31641
  };
31198
31642
  const onError = (error) => {
31199
31643
  cleanup();
@@ -32483,6 +32927,7 @@ export {
32483
32927
  aesKey,
32484
32928
  asBool,
32485
32929
  assertNever,
32930
+ beginsAccessUnit,
32486
32931
  bizChannelName,
32487
32932
  buildActions,
32488
32933
  buildAppShapedClientId,