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

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 = [];
@@ -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,14 @@ 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
+ /**
16457
+ * Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
16458
+ * settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
16459
+ */
16412
16460
  level2Negotiating = false;
16413
16461
  /**
16414
16462
  * 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.
16463
+ * once a negotiation concluded without one, and from the start when nothing can negotiate one.
16416
16464
  */
16417
16465
  level2Pending;
16418
16466
  /** Waiters parked in {@link awaitLevel2Key}, woken the moment the negotiation settles either way. */
@@ -16517,7 +16565,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16517
16565
  * The session grace runs from connect, when the station is prompted for `CMD_GATEWAYINFO`. A negotiation
16518
16566
  * beginning later does not restart it: best-effort media can proceed without the key, and restarting
16519
16567
  * 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.
16568
+ * settled answers without waiting at all: the answer stands until {@link repromptLevel2Key} re-opens the
16569
+ * wait, since the station is prompted for `CMD_GATEWAYINFO` only on connect and on that re-prompt.
16521
16570
  *
16522
16571
  * An own-session camera whose grace expires here is NOT thereby a camera that will fail to stream. Measured
16523
16572
  * on one account: own-session cameras of two device types negotiated a key, three others never did, and
@@ -16550,7 +16599,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16550
16599
  this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
16551
16600
  this.trace({ phase: "level2-wait", waitMs: remaining });
16552
16601
  const waiters = this.level2Waiters;
16553
- const outcome = await new Promise((resolve) => {
16602
+ const outcome = await new Promise((resolve2) => {
16554
16603
  let deadline;
16555
16604
  let settled = false;
16556
16605
  const finish = (result) => {
@@ -16561,7 +16610,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16561
16610
  const at = waiters.indexOf(wake);
16562
16611
  if (at >= 0)
16563
16612
  waiters.splice(at, 1);
16564
- resolve(result);
16613
+ resolve2(result);
16565
16614
  };
16566
16615
  const wake = (result) => finish(result);
16567
16616
  deadline = setTimeout(() => finish("timeout"), remaining);
@@ -16583,12 +16632,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16583
16632
  /**
16584
16633
  * Ask the station for its gateway info a second time, re-opening a negotiation that concluded without a key.
16585
16634
  *
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
16635
+ * Without this the station is prompted once on connect, and a negotiation that settles without a key
16636
+ * leaves {@link awaitLevel2Key} answering `false` at once for the rest of that connection. That is the
16588
16637
  * right answer for best-effort media, which proceeds at level-1 — but an operation whose ONLY wire is
16589
16638
  * level-2 is then refused for the whole life of that connection, while a fresh session over the same
16590
16639
  * 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.
16640
+ * until it was rebuilt, at which point the station answered with a cipher id straight away. The reply to
16641
+ * this ask runs the negotiation again and settles the re-opened wait either way.
16592
16642
  *
16593
16643
  * Bounded to one extra ask per connection, so a burst of such operations cannot turn a silent station into a
16594
16644
  * flood, and answers whether it asked — `false` when a key is already held, when nothing can negotiate one,
@@ -16609,6 +16659,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16609
16659
  /** Record that the level-2 negotiation has finished, with or without a key, and wake every waiter. */
16610
16660
  settleLevel2(reason = "terminal") {
16611
16661
  this.level2Pending = false;
16662
+ this.level2Negotiating = false;
16612
16663
  const outcome = this.level2Key ? "key" : reason;
16613
16664
  for (const wake of this.level2Waiters.splice(0))
16614
16665
  wake(outcome);
@@ -16618,13 +16669,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16618
16669
  * `cipher_id`, resolve that cipher's private keys once (cloud `get_ciphers`, via the configured
16619
16670
  * `resolveCipherKey`), run the ECIES unwrap with the ECC key, and `setLevel2Key()`. A cipher with no
16620
16671
  * 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
16672
+ * key. Emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
16622
16673
  * `level2-set-payload-only` instead for the AES-128-ECB key, and emits `error` when the ECC unwrap
16623
16674
  * fails; an RSA unwrap failure is only traced (non-fatal either way — level-1 traffic keeps working).
16624
16675
  *
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.
16676
+ * Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
16677
+ * left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
16678
+ * GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
16679
+ * generation ended settles nothing.
16628
16680
  */
16629
16681
  negotiateLevel2Key(gwPayload) {
16630
16682
  this.level2Negotiating = true;
@@ -16746,14 +16798,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16746
16798
  this.socket = socket;
16747
16799
  socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, socket));
16748
16800
  socket.on("error", (e) => this.emit("error", e));
16749
- await new Promise((resolve) => {
16801
+ await new Promise((resolve2) => {
16750
16802
  socket.bind(() => {
16751
16803
  try {
16752
16804
  socket.setBroadcast(true);
16753
16805
  } catch {
16754
16806
  }
16755
16807
  this.requestReceiveBuffer(socket);
16756
- resolve();
16808
+ resolve2();
16757
16809
  });
16758
16810
  });
16759
16811
  void _P2PSession.detectLocalIp().then((host) => {
@@ -16816,7 +16868,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16816
16868
  */
16817
16869
  static detectLocalIp() {
16818
16870
  if (!_P2PSession.localIpPromise) {
16819
- _P2PSession.localIpPromise = new Promise((resolve) => {
16871
+ _P2PSession.localIpPromise = new Promise((resolve2) => {
16820
16872
  const probe = dgram.createSocket("udp4");
16821
16873
  let settled = false;
16822
16874
  const finish = (result) => {
@@ -16828,7 +16880,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16828
16880
  probe.close();
16829
16881
  } catch {
16830
16882
  }
16831
- resolve(result);
16883
+ resolve2(result);
16832
16884
  };
16833
16885
  const timer = setTimeout(() => finish(void 0), 2e3);
16834
16886
  probe.connect(53, "8.8.8.8", () => {
@@ -16980,9 +17032,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16980
17032
  } else if (hasHeader(msg, ResponseMessageType.LOOKUP_ADDR)) {
16981
17033
  if (!this.connected) {
16982
17034
  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);
17035
+ if (addr) {
17036
+ this.logger.debug(`[p2p] ${this.cfg.stationSn} LOOKUP_ADDR -> ${addr.host}:${addr.port}`);
17037
+ if (addr.host !== "0.0.0.0")
17038
+ this.beginCheckCam(addr, socket);
17039
+ }
16986
17040
  }
16987
17041
  } else if (hasHeader(msg, ResponseMessageType.RLY_TO)) {
16988
17042
  const to = parseRelayTo(msg);
@@ -17036,7 +17090,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17036
17090
  probe.on("error", (error) => this.logger.warn("[p2p] lookup socket error", error.message));
17037
17091
  probe.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, probe));
17038
17092
  this.probeSockets.push(probe);
17039
- await new Promise((resolve) => {
17093
+ await new Promise((resolve2) => {
17040
17094
  let settled = false;
17041
17095
  const finish = (bound) => {
17042
17096
  if (settled)
@@ -17051,7 +17105,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17051
17105
  } catch {
17052
17106
  }
17053
17107
  }
17054
- resolve();
17108
+ resolve2();
17055
17109
  };
17056
17110
  const onError = () => finish(false);
17057
17111
  const onClose = () => finish(false);
@@ -17837,25 +17891,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17837
17891
  databasePayload(value, channel) {
17838
17892
  return !this.level2Key && this.sendsInTheClear() ? buildRawCommandPayload(Buffer.from(value, "utf-8"), channel, 0, this.nextClearCommandBytes(), 0) : buildStringCommandPayload(value, channel, this.level1Key, 1);
17839
17893
  }
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
17894
  /**
17860
17895
  * Request the on-device edge-AI **face roster** over P2P — the phone-free path.
17861
17896
  *
@@ -17872,7 +17907,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17872
17907
  accountId: opts.accountId,
17873
17908
  channel: opts.channel,
17874
17909
  innerCmd: DB_QUERY.FULL_TABLE,
17875
- query: this.fullTableQuery()
17910
+ query: fullTableQuery()
17876
17911
  });
17877
17912
  }
17878
17913
  /**
@@ -17921,11 +17956,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17921
17956
  this.queryDatabase(table, {
17922
17957
  accountId: opts.accountId,
17923
17958
  innerCmd: opts.innerCmd,
17924
- query: opts.query ?? this.fullTableQuery()
17959
+ query: opts.query ?? fullTableQuery()
17925
17960
  });
17926
17961
  this.dbReadInFlight = true;
17927
17962
  try {
17928
- return await new Promise((resolve, reject) => {
17963
+ return await new Promise((resolve2, reject) => {
17929
17964
  let text2 = "";
17930
17965
  const onChunk = (chunk) => {
17931
17966
  text2 += chunk.text;
@@ -17933,7 +17968,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17933
17968
  if (result?.error !== void 0)
17934
17969
  settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
17935
17970
  else if (result?.rows)
17936
- settle(() => resolve(result.rows));
17971
+ settle(() => resolve2(result.rows));
17937
17972
  };
17938
17973
  const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
17939
17974
  const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
@@ -17963,7 +17998,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17963
17998
  accountId: opts.accountId,
17964
17999
  channel: opts.channel,
17965
18000
  innerCmd: DB_QUERY.FULL_TABLE,
17966
- query: this.fullTableQuery()
18001
+ query: fullTableQuery()
17967
18002
  });
17968
18003
  }
17969
18004
  /**
@@ -18027,8 +18062,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18027
18062
  *
18028
18063
  * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
18029
18064
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
18065
+ *
18066
+ * A datagram shorter than the 8-byte DATA header carries no sequence number and is dropped unacknowledged.
18030
18067
  */
18031
18068
  onData(msg, addr) {
18069
+ if (msg.length < 8)
18070
+ return;
18032
18071
  const dataTypeBuffer = msg.subarray(4, 6);
18033
18072
  const seqNo = msg.subarray(6, 8).readUInt16BE();
18034
18073
  const dataType = dataTypeBuffer[1];
@@ -18194,6 +18233,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18194
18233
  return P2PDataTypeHeader.DATA;
18195
18234
  }
18196
18235
  }
18236
+ /**
18237
+ * Decrypt and dispatch one inbound data frame.
18238
+ *
18239
+ * A `CMD_GATEWAYINFO` (1100) reply carries the ECIES envelope for the level-2 session key. When a
18240
+ * cipher-key resolver is configured, no key that seals a set-payload is held and no negotiation is in
18241
+ * flight, the reply starts one (async, fire-and-forget); a reply landing while one is in flight is ignored.
18242
+ */
18197
18243
  handleFrame(header, payload, dataType) {
18198
18244
  let data = payload;
18199
18245
  const isMedia = header.commandId === CMD_VIDEO_FRAME || header.commandId === CMD_AUDIO_FRAME;
@@ -18299,12 +18345,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
18299
18345
  this.resetInboundSequencing();
18300
18346
  const socket = this.socket;
18301
18347
  if (socket && this.connectAddress) {
18302
- await new Promise((resolve) => this.send(this.connectAddress, RequestMessageType.END, void 0, socket, resolve));
18348
+ await new Promise((resolve2) => this.send(this.connectAddress, RequestMessageType.END, void 0, socket, resolve2));
18303
18349
  }
18304
- await new Promise((resolve) => {
18350
+ await new Promise((resolve2) => {
18305
18351
  if (!socket)
18306
- return resolve();
18307
- socket.close(() => resolve());
18352
+ return resolve2();
18353
+ socket.close(() => resolve2());
18308
18354
  });
18309
18355
  this.socket = void 0;
18310
18356
  this.emit("close");
@@ -19512,7 +19558,7 @@ async function captureSnapshotFromShared(source, opts = {}) {
19512
19558
  const skip = opts.skipKeyframes ?? (source.state === "live" ? 0 : 1);
19513
19559
  let burst;
19514
19560
  try {
19515
- burst = await new Promise((resolve, reject) => {
19561
+ burst = await new Promise((resolve2, reject) => {
19516
19562
  const bufs = [];
19517
19563
  let sets = source.parameterSets;
19518
19564
  let codec = "h264";
@@ -19535,7 +19581,7 @@ async function captureSnapshotFromShared(source, opts = {}) {
19535
19581
  if (!settle)
19536
19582
  settle = setTimeout(() => {
19537
19583
  cleanup();
19538
- resolve({ h264: Buffer.concat(bufs), codec, sets });
19584
+ resolve2({ h264: Buffer.concat(bufs), codec, sets });
19539
19585
  }, collectMs);
19540
19586
  };
19541
19587
  const onError = (err) => {
@@ -19579,7 +19625,7 @@ async function recordClip(session, seconds, opts = {}) {
19579
19625
  const stream = await openLiveStream(session, opts);
19580
19626
  let h264;
19581
19627
  try {
19582
- h264 = await new Promise((resolve, reject) => {
19628
+ h264 = await new Promise((resolve2, reject) => {
19583
19629
  const bufs = [];
19584
19630
  let keyCount = 0, capturing = false;
19585
19631
  let sets;
@@ -19592,7 +19638,7 @@ async function recordClip(session, seconds, opts = {}) {
19592
19638
  };
19593
19639
  const collected = () => {
19594
19640
  detach();
19595
- resolve(primeForDecode(Buffer.concat(bufs), sets, codec2));
19641
+ resolve2(primeForDecode(Buffer.concat(bufs), sets, codec2));
19596
19642
  };
19597
19643
  const failed = (reason, cause) => {
19598
19644
  detach();
@@ -19622,7 +19668,7 @@ async function recordClip(session, seconds, opts = {}) {
19622
19668
  stream.stop();
19623
19669
  }
19624
19670
  const codec = annexbFfmpegFormat(h264);
19625
- return new Promise((resolve, reject) => {
19671
+ return new Promise((resolve2, reject) => {
19626
19672
  const ff = spawnFfmpeg([
19627
19673
  // prettier-ignore
19628
19674
  "-f",
@@ -19645,7 +19691,7 @@ async function recordClip(session, seconds, opts = {}) {
19645
19691
  ff.on("close", (code) => {
19646
19692
  const mp4 = Buffer.concat(out);
19647
19693
  if (mp4.length)
19648
- resolve(mp4);
19694
+ resolve2(mp4);
19649
19695
  else
19650
19696
  reject(new Error(`ffmpeg mux failed (code ${code}): ${Buffer.concat(err).toString().slice(0, 200)}`));
19651
19697
  });
@@ -19684,7 +19730,7 @@ function jpegGeometry(jpeg) {
19684
19730
  }
19685
19731
  function annexbToJpeg(annexb, spawn2) {
19686
19732
  const codec = annexbFfmpegFormat(annexb);
19687
- return new Promise((resolve, reject) => {
19733
+ return new Promise((resolve2, reject) => {
19688
19734
  const ff = spawnFfmpeg([
19689
19735
  // prettier-ignore
19690
19736
  "-f",
@@ -19711,7 +19757,7 @@ function annexbToJpeg(annexb, spawn2) {
19711
19757
  const encoded = jpeg.length >= 3 && jpeg.subarray(0, 3).toString("hex") === "ffd8ff";
19712
19758
  const geometry = encoded ? jpegGeometry(jpeg) : void 0;
19713
19759
  if (geometry)
19714
- return resolve({ jpeg, ...geometry });
19760
+ return resolve2({ jpeg, ...geometry });
19715
19761
  const diagnostics = Buffer.concat(err).toString().slice(0, 200).trim();
19716
19762
  const what = code !== 0 ? `ffmpeg exited ${code}` : encoded ? "encoded image declares no frame header" : "no complete frame in the burst";
19717
19763
  reject(new LiveSnapshotUnavailableError("undecodable-burst", diagnostics ? `${what}: ${diagnostics}` : what));
@@ -19744,12 +19790,12 @@ function homeBase2RecordingPath(channel, recording) {
19744
19790
  function receiveRecording(session, request) {
19745
19791
  let resolveFrames;
19746
19792
  let rejectFrames;
19747
- const frames = new Promise((resolve, reject) => {
19748
- resolveFrames = resolve;
19793
+ const frames = new Promise((resolve2, reject) => {
19794
+ resolveFrames = resolve2;
19749
19795
  rejectFrames = reject;
19750
19796
  });
19751
19797
  let resolveDrained;
19752
- const drained = new Promise((resolve) => resolveDrained = resolve);
19798
+ const drained = new Promise((resolve2) => resolveDrained = resolve2);
19753
19799
  const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
19754
19800
  let collected = [];
19755
19801
  const started = Date.now();
@@ -20287,7 +20333,8 @@ var SharedLiveSource = class {
20287
20333
  *
20288
20334
  * The previous stream is dropped rather than stopped through the state machine: it speaks to a session that
20289
20335
  * 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.
20336
+ * rewarms, so this is inert on a source that is streaming or has already failed. Each call replaces the
20337
+ * re-issue ticker rather than adding one, so the start goes out once per interval however often it runs.
20291
20338
  */
20292
20339
  rewarm() {
20293
20340
  if (this.disposed || !this.sessionReplacementAsked || this._state !== "warming")
@@ -20299,6 +20346,8 @@ var SharedLiveSource = class {
20299
20346
  this.openStream();
20300
20347
  this.warmAttempts++;
20301
20348
  this.logger.debug(`${this.tag} warming again on a replacement session (attempt ${this.warmAttempts})`);
20349
+ if (this.warmRetryTimer)
20350
+ clearInterval(this.warmRetryTimer);
20302
20351
  this.warmRetryTimer = setInterval(() => this.reissueStart(), this.warmRetryMs);
20303
20352
  }
20304
20353
  /**
@@ -21019,6 +21068,7 @@ function openReadableFromConsumer(consumer, opts = {}) {
21019
21068
  if (!readable.push(value))
21020
21069
  consumer.pause();
21021
21070
  });
21071
+ consumer.on("budget", (notice) => readable.emit("budget", notice));
21022
21072
  consumer.on("stop", () => readable.push(null));
21023
21073
  consumer.on("error", (err) => readable.destroy(err));
21024
21074
  return readable;
@@ -21954,7 +22004,7 @@ var FragmentRecording = class extends EventEmitter6 {
21954
22004
  throw this.failure;
21955
22005
  if (this.ended)
21956
22006
  return;
21957
- await new Promise((resolve) => this.wake = resolve);
22007
+ await new Promise((resolve2) => this.wake = resolve2);
21958
22008
  }
21959
22009
  } finally {
21960
22010
  this.stop();
@@ -22070,7 +22120,7 @@ var TALKBACK_CODEC_WAIT_MS = 3e3;
22070
22120
  var OWN_LIVE_SESSION_MODELS = /* @__PURE__ */ new Set(["T8030"]);
22071
22121
  var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
22072
22122
  function speakerCodec(consumer, talk, timeoutMs) {
22073
- return new Promise((resolve) => {
22123
+ return new Promise((resolve2) => {
22074
22124
  let timer;
22075
22125
  const settle = (codec) => {
22076
22126
  clearTimeout(timer);
@@ -22078,7 +22128,7 @@ function speakerCodec(consumer, talk, timeoutMs) {
22078
22128
  consumer.off("audio", onAudio);
22079
22129
  consumer.off("stop", onStop);
22080
22130
  talk.off("stop", onStop);
22081
- resolve(codec);
22131
+ resolve2(codec);
22082
22132
  };
22083
22133
  const onVideo = () => {
22084
22134
  timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
@@ -22130,6 +22180,18 @@ var P2PCommandRouter = class _P2PCommandRouter {
22130
22180
  * {@link liveSources} is a claim in flight.
22131
22181
  */
22132
22182
  liveSessionKeys = /* @__PURE__ */ new Map();
22183
+ /**
22184
+ * The live source build in flight per `${parentSn}:${channel}`, settled once its connection is opened
22185
+ * or refused.
22186
+ *
22187
+ * A build awaits its media session before it can file the source in {@link liveSources}, so a second
22188
+ * request for the same camera in that window finds nothing cached. Building a second source there
22189
+ * overwrites the first, and the orphan's teardown then closes the connection the cached one streams
22190
+ * on. A request that finds a build here waits for it, until its own signal aborts, then resolves its
22191
+ * session again and reuses what the build filed, or builds its own when that build failed. The entry
22192
+ * is removed when the build settles and its promise never rejects, so each wait ends on a later state.
22193
+ */
22194
+ liveSourceBuilds = /* @__PURE__ */ new Map();
22133
22195
  /**
22134
22196
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
22135
22197
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -22309,17 +22371,30 @@ var P2PCommandRouter = class _P2PCommandRouter {
22309
22371
  * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
22310
22372
  * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
22311
22373
  * carry the start is not a connected session, so this refuses rather than returning one.
22374
+ *
22375
+ * A session refused its key within the grace, or never connected, is closed and dropped from the manager at
22376
+ * once. Nothing else releases it: no source holds it, a wired station's session never idles out, and the
22377
+ * manager hands the same session to every later open of the camera, so the next open dials a fresh
22378
+ * connection instead. A session another open has since replaced is left to its own close.
22312
22379
  */
22313
22380
  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;
22381
+ const mediaKey = _P2PCommandRouter.mediaSessionKey(parentSn, channel);
22382
+ const session = await this.openSession(mediaKey, parentSn);
22383
+ try {
22384
+ await this.awaitConnected(session, parentSn, signal);
22385
+ let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22386
+ if (!ready && session.repromptLevel2Key()) {
22387
+ ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22388
+ }
22389
+ if (!ready)
22390
+ throw new StationKeyUnavailableError(parentSn);
22391
+ return session;
22392
+ } catch (error) {
22393
+ if (!signal?.aborted && this.manager.get(mediaKey) === session) {
22394
+ void this.manager.close(mediaKey).catch((e) => this.reportError(e instanceof Error ? e : new Error(String(e))));
22395
+ }
22396
+ throw error;
22397
+ }
22323
22398
  }
22324
22399
  /**
22325
22400
  * Wait for `session` to connect, for up to the session's own connect deadline, and throw
@@ -22357,7 +22432,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22357
22432
  throw new Error(`no P2P endpoint (p2p_did) for station ${parentSn}`);
22358
22433
  let dskKey;
22359
22434
  try {
22360
- dskKey = (await this.deps.mega.getDskKeys([parentSn]))[parentSn]?.dskKey;
22435
+ dskKey = (await this.deps.mega.getDskKeys([stationDev]))[parentSn]?.dskKey;
22361
22436
  } catch (e) {
22362
22437
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
22363
22438
  }
@@ -22459,7 +22534,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22459
22534
  const session = await this.openSession(parentSn, parentSn);
22460
22535
  if (session.isConnected)
22461
22536
  return;
22462
- await new Promise((resolve, reject) => {
22537
+ await new Promise((resolve2, reject) => {
22463
22538
  const cleanup = () => {
22464
22539
  signal?.removeEventListener("abort", onAbort);
22465
22540
  session.off("connect", onConnect);
@@ -22468,7 +22543,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22468
22543
  };
22469
22544
  const onConnect = () => {
22470
22545
  cleanup();
22471
- resolve();
22546
+ resolve2();
22472
22547
  };
22473
22548
  const onError = (error) => {
22474
22549
  cleanup();
@@ -22828,6 +22903,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
22828
22903
  signal: opts.signal
22829
22904
  });
22830
22905
  const key = `${parentSn}:${channel}`;
22906
+ const build = this.liveSourceBuilds.get(key);
22907
+ if (build) {
22908
+ await abortable(build, opts.signal);
22909
+ return this.sharedLiveSourceFor(sn, opts, continuous);
22910
+ }
22831
22911
  const ownSession = continuous && homeBaseAttached && this.ownsLiveSession(parentSn);
22832
22912
  let source = this.liveSources.get(key);
22833
22913
  if (source && (source.state === "stopped" || ownSession && source.state !== "idle" && source.consumerCount === 0 && this.liveSessionKeys.get(key) === parentSn)) {
@@ -22839,6 +22919,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
22839
22919
  const logger = this.deps.logger ?? noopLogger;
22840
22920
  const sessionKey = ownSession || continuous && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
22841
22921
  this.liveSessionKeys.set(key, sessionKey);
22922
+ const built = Promise.withResolvers();
22923
+ this.liveSourceBuilds.set(key, built.promise);
22842
22924
  let held;
22843
22925
  try {
22844
22926
  held = {
@@ -22847,6 +22929,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
22847
22929
  } catch (error) {
22848
22930
  this.liveSessionKeys.delete(key);
22849
22931
  throw error;
22932
+ } finally {
22933
+ this.liveSourceBuilds.delete(key);
22934
+ built.resolve();
22850
22935
  }
22851
22936
  source = new SharedLiveSource({
22852
22937
  makeStream: (ctx) => new LiveStream(held.session, {
@@ -23151,7 +23236,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23151
23236
  async p2pQuery(sn, subCmd, opts = {}) {
23152
23237
  const { session, channel, accountId } = await this.resolveSession(sn, { waitLevel2: true });
23153
23238
  const timeoutMs = opts.timeoutMs ?? 15e3;
23154
- return await new Promise((resolve, reject) => {
23239
+ return await new Promise((resolve2, reject) => {
23155
23240
  const timer = setTimeout(() => {
23156
23241
  cleanup();
23157
23242
  reject(new Error(`timeout waiting for p2p query ${subCmd} reply from ${sn}`));
@@ -23160,7 +23245,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23160
23245
  const j = f.json;
23161
23246
  if (f.channel === channel && f.commandId === CommandType.CMD_NOTIFY_PAYLOAD && j?.cmd === subCmd && j.payload && typeof j.payload === "object") {
23162
23247
  cleanup();
23163
- resolve(j.payload);
23248
+ resolve2(j.payload);
23164
23249
  }
23165
23250
  };
23166
23251
  const cleanup = () => {
@@ -23190,7 +23275,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23190
23275
  async p2pControlQuery(sn, param, data, opts = {}) {
23191
23276
  const { session } = await this.resolveSession(sn, { waitLevel2: false });
23192
23277
  const timeoutMs = opts.timeoutMs ?? 15e3;
23193
- return await new Promise((resolve, reject) => {
23278
+ return await new Promise((resolve2, reject) => {
23194
23279
  const timer = setTimeout(() => {
23195
23280
  cleanup();
23196
23281
  reject(new Error(`timeout waiting for p2p control query ${param} reply from ${sn}`));
@@ -23199,7 +23284,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23199
23284
  const j = f.json;
23200
23285
  if (j?.cmd === param && j.payload && typeof j.payload === "object") {
23201
23286
  cleanup();
23202
- resolve(j.payload);
23287
+ resolve2(j.payload);
23203
23288
  }
23204
23289
  };
23205
23290
  const cleanup = () => {
@@ -23296,12 +23381,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
23296
23381
  * is ignored rather than resolving the wrong read.
23297
23382
  */
23298
23383
  awaitRtspUrl(session, channel, signal) {
23299
- return new Promise((resolve, reject) => {
23384
+ return new Promise((resolve2, reject) => {
23300
23385
  const onUrl = (ev) => {
23301
23386
  if (ev.channel !== channel)
23302
23387
  return;
23303
23388
  cleanup();
23304
- resolve(ev.url);
23389
+ resolve2(ev.url);
23305
23390
  };
23306
23391
  const onAbort = () => {
23307
23392
  cleanup();
@@ -23621,7 +23706,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23621
23706
  * wrapper has to share {@link serializeKeyed}.
23622
23707
  */
23623
23708
  awaitKeyedReply(sn, session, channel, engage, send) {
23624
- return new Promise((resolve, reject) => {
23709
+ return new Promise((resolve2, reject) => {
23625
23710
  let refused = false;
23626
23711
  let mark;
23627
23712
  let timer;
@@ -23641,7 +23726,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23641
23726
  if (refused)
23642
23727
  return;
23643
23728
  refused = true;
23644
- expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() => resolve({ code: KEYED_PAYLOAD_SEQ_ERROR, mark })));
23729
+ expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() => resolve2({ code: KEYED_PAYLOAD_SEQ_ERROR, mark })));
23645
23730
  };
23646
23731
  const onResult = (r) => {
23647
23732
  if (r.channel !== channel)
@@ -23649,7 +23734,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23649
23734
  if (r.code === KEYED_PAYLOAD_SEQ_ERROR)
23650
23735
  refuse();
23651
23736
  else if (!refused)
23652
- settle(() => resolve({ code: r.code }));
23737
+ settle(() => resolve2({ code: r.code }));
23653
23738
  };
23654
23739
  const onData = (f) => {
23655
23740
  if (f.channel !== channel || f.commandId !== CommandType.CMD_NOTIFY_PAYLOAD)
@@ -23661,7 +23746,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23661
23746
  }
23662
23747
  const code = keyedPayloadReportCode(f.json, engage);
23663
23748
  if (code !== void 0 && !refused)
23664
- settle(() => resolve({ code }));
23749
+ settle(() => resolve2({ code }));
23665
23750
  };
23666
23751
  session.on("commandResult", onResult);
23667
23752
  session.on("data", onData);
@@ -23766,7 +23851,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23766
23851
  clearTimeout(timer);
23767
23852
  session.off("data", onData);
23768
23853
  };
23769
- const getReplyPromise = new Promise((resolve) => {
23854
+ const getReplyPromise = new Promise((resolve2) => {
23770
23855
  onData = (f) => {
23771
23856
  const j = f.json;
23772
23857
  if (j?.cmd !== CMD_TRANSFER_PAYLOAD || !j.payload)
@@ -23779,11 +23864,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
23779
23864
  if (keyTime === void 0 || keyTime !== query.time)
23780
23865
  return;
23781
23866
  cleanup();
23782
- resolve({ lockPayload, keyTime });
23867
+ resolve2({ lockPayload, keyTime });
23783
23868
  };
23784
23869
  timer = setTimeout(() => {
23785
23870
  cleanup();
23786
- resolve(void 0);
23871
+ resolve2(void 0);
23787
23872
  }, _P2PCommandRouter.FF09_SETTINGS_GET_TIMEOUT_MS);
23788
23873
  session.on("data", onData);
23789
23874
  });
@@ -23912,7 +23997,9 @@ var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a
23912
23997
  var PORTAL_KEEPALIVE_MS = 29e3;
23913
23998
 
23914
23999
  // dist/transport/rtc/portal-packet.js
24000
+ import { zstdDecompressSync } from "node:zlib";
23915
24001
  var MAGIC2 = Buffer.from("XZYH", "ascii");
24002
+ var ZSTD_MAGIC_LE = 4247762216;
23916
24003
  var PORTAL_HEADER_LENGTH = 16;
23917
24004
  var PORTAL_CMD_SET_PAYLOAD = 1350;
23918
24005
  var PORTAL_STATION_CHANNEL = 255;
@@ -23976,11 +24063,23 @@ function parseJsonBody(buf) {
23976
24063
  return text2;
23977
24064
  }
23978
24065
  }
24066
+ function unwrapZstd(buf) {
24067
+ if (buf.length < 4 || buf.readUInt32LE(0) !== ZSTD_MAGIC_LE)
24068
+ return buf;
24069
+ try {
24070
+ return zstdDecompressSync(buf);
24071
+ } catch {
24072
+ return void 0;
24073
+ }
24074
+ }
23979
24075
  function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
23980
- const header = parsePortalHeader(buf);
24076
+ const packet = unwrapZstd(buf);
24077
+ if (!packet)
24078
+ return void 0;
24079
+ const header = parsePortalHeader(packet);
23981
24080
  if (!header)
23982
24081
  return void 0;
23983
- const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
24082
+ const body = packet.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
23984
24083
  if (linkType === PortalLinkType.NOTIFY) {
23985
24084
  const data = parseJsonBody(body);
23986
24085
  const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
@@ -24712,12 +24811,12 @@ var RtcPeer = class extends EventEmitter9 {
24712
24811
  this.remoteMid = media ? offer.match(/^a=mid:([^\r\n]+)/m)?.[1]?.trim() ?? HUB_SDP_MID : midOf(offer, "application") ?? HUB_SDP_MID;
24713
24812
  if (media)
24714
24813
  this.videoMid = midOf(offer, "video");
24715
- const answerWait = new Promise((resolve, reject) => {
24814
+ const answerWait = new Promise((resolve2, reject) => {
24716
24815
  const timer = setTimeout(() => {
24717
24816
  this.localAnswer = void 0;
24718
24817
  reject(new Error("timed out waiting for the local SDP answer"));
24719
24818
  }, ANSWER_TIMEOUT_MS);
24720
- this.localAnswer = { resolve, reject, timer };
24819
+ this.localAnswer = { resolve: resolve2, reject, timer };
24721
24820
  });
24722
24821
  pc.setRemoteDescription(offer, "offer");
24723
24822
  this.remoteSet = true;
@@ -24879,7 +24978,7 @@ var RtcPeer = class extends EventEmitter9 {
24879
24978
 
24880
24979
  // dist/transport/rtc/signaling.js
24881
24980
  import { EventEmitter as EventEmitter10 } from "node:events";
24882
- import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
24981
+ import { createHmac as createHmac3, randomUUID as randomUUID3 } from "node:crypto";
24883
24982
  var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
24884
24983
  var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
24885
24984
  var PORTAL_ORIGIN = "https://security.eufy.com";
@@ -24919,7 +25018,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24919
25018
  this.createSocket = opts.createSocket ?? defaultSocket;
24920
25019
  this.logger = opts.logger ?? noopLogger;
24921
25020
  this.now = opts.now ?? Date.now;
24922
- this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
25021
+ this.makeMsgId = opts.makeMsgId ?? (() => randomUUID3().replace(/-/g, ""));
24923
25022
  }
24924
25023
  get isOpen() {
24925
25024
  return this.ws?.readyState === WS_OPEN;
@@ -24963,7 +25062,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24963
25062
  };
24964
25063
  const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
24965
25064
  this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
24966
- await new Promise((resolve, reject) => {
25065
+ await new Promise((resolve2, reject) => {
24967
25066
  const timer = setTimeout(() => {
24968
25067
  reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
24969
25068
  this.close();
@@ -24977,7 +25076,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24977
25076
  this.sendAuth(sign);
24978
25077
  this.startKeepalive();
24979
25078
  this.emit("open");
24980
- resolve();
25079
+ resolve2();
24981
25080
  });
24982
25081
  ws.addEventListener("message", (ev) => {
24983
25082
  void this.handleWireMessage(ev.data);
@@ -25250,12 +25349,12 @@ var RtcSession = class extends EventEmitter11 {
25250
25349
  waitForAuth() {
25251
25350
  if (this.authOk)
25252
25351
  return Promise.resolve();
25253
- return new Promise((resolve, reject) => {
25352
+ return new Promise((resolve2, reject) => {
25254
25353
  const timer = setTimeout(() => {
25255
25354
  this.authWaiter = void 0;
25256
25355
  reject(new Error("RTC signalling auth timeout"));
25257
25356
  }, AUTH_TIMEOUT_MS);
25258
- this.authWaiter = { resolve, reject, timer };
25357
+ this.authWaiter = { resolve: resolve2, reject, timer };
25259
25358
  });
25260
25359
  }
25261
25360
  /** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
@@ -25382,6 +25481,7 @@ var RtcCommandRouter = class {
25382
25481
  lives = /* @__PURE__ */ new Map();
25383
25482
  warm = /* @__PURE__ */ new Map();
25384
25483
  callSources = /* @__PURE__ */ new Map();
25484
+ lastTransaction = 0;
25385
25485
  constructor(deps) {
25386
25486
  this.deps = deps;
25387
25487
  }
@@ -25391,21 +25491,80 @@ var RtcCommandRouter = class {
25391
25491
  }
25392
25492
  if (route.mediaTrack)
25393
25493
  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
25494
  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;
25495
+ if (cmd.kind === "set-json") {
25496
+ return this.sendQueued(route, PORTAL_CMD_CONTROL_PAYLOAD, cmd.param, channel, {
25497
+ cmd: cmd.param,
25498
+ commandType: cmd.param,
25499
+ data: cmd.data
25500
+ });
25501
+ }
25502
+ return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, cmd.cmd, channel, {
25503
+ cmd: cmd.cmd,
25504
+ mValue3: cmd.mValue3 ?? 0,
25505
+ payload: cmd.payload
25506
+ });
25507
+ }
25508
+ /**
25509
+ * Query one on-station table over the station's control channel and answer its rows.
25510
+ *
25511
+ * Sends the `1350` envelope with inner `{cmd: 1306, payload: {cmd: 10000, table, payload, transaction}}`,
25512
+ * the whole-table query, on the station channel. The hub ACKs on the command link and answers with one notify frame whose body
25513
+ * carries `{cmd, table, data, mIntRet, msg, transaction, …}`, echoing the read's `transaction`; that echo
25514
+ * is the only thing a reply is attributed by, and every read sends a `transaction` no other read on this
25515
+ * router has sent. Resolves with the reply's `data` rows. Rejects with the hub's `msg` on a non-zero
25516
+ * `mIntRet`, on an ACK refusing the envelope, at once when `signal` aborts (queued or sent), when the
25517
+ * session closes, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
25518
+ *
25519
+ * One read or command at a time per station: the read holds the station's send queue until it settles.
25520
+ */
25521
+ readTable(route, table, opts = {}) {
25522
+ const transaction = this.nextTransaction();
25523
+ const inner = {
25524
+ cmd: DB_QUERY.FULL_TABLE,
25525
+ table,
25526
+ payload: fullTableQuery(),
25527
+ transaction
25528
+ };
25529
+ return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, CMD_DATABASE, PORTAL_STATION_CHANNEL, { cmd: CMD_DATABASE, mValue3: 0, payload: inner }, {
25530
+ timeoutMs: opts.timeoutMs,
25531
+ signal: opts.signal,
25532
+ pick: (body) => {
25533
+ if (String(body.transaction) !== transaction)
25534
+ return void 0;
25535
+ if (typeof body.mIntRet === "number" && body.mIntRet !== 0) {
25536
+ throw new Error(`rtc: ${route.stationSn} refused ${table}: ${body.msg ?? body.mIntRet} (${body.mIntRet})`);
25537
+ }
25538
+ return Array.isArray(body.data) ? body.data : void 0;
25539
+ }
25540
+ });
25541
+ }
25542
+ /**
25543
+ * Fetch one stored image by its on-station path over the station's control channel.
25544
+ *
25545
+ * Sends the `1350` envelope with inner `{cmd: 1308, payload: [{file}]}` on the station channel. The hub
25546
+ * ACKs on the command link and answers with one notify frame whose body is `{file, content}`, `content`
25547
+ * the image in base64, and no `transaction`; the reply is the one whose `file` equals `filePath`. A reply
25548
+ * arriving after an abandoned read of the same path carries that path's bytes, which are the bytes a
25549
+ * later read of it asks for. Resolves with the decoded bytes. Rejects on an ACK refusing the envelope,
25550
+ * at once when `signal` aborts (queued or sent), when the session closes, and when `timeoutMs` (default
25551
+ * {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
25552
+ *
25553
+ * One read or command at a time per station: the read holds the station's send queue until it settles.
25554
+ */
25555
+ readImage(route, filePath, opts = {}) {
25556
+ return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, CMD_DATABASE_IMAGE, PORTAL_STATION_CHANNEL, { cmd: CMD_DATABASE_IMAGE, mValue3: 0, payload: [{ file: filePath }] }, {
25557
+ ...opts,
25558
+ pick: (body) => body.file === filePath && typeof body.content === "string" ? Buffer.from(body.content, "base64") : void 0
25559
+ });
25560
+ }
25561
+ /**
25562
+ * A `transaction` stamp in the vendor app's shape, a decimal epoch-in-milliseconds string, strictly
25563
+ * greater than the last one this router issued, so two reads in the same millisecond never share one.
25564
+ */
25565
+ nextTransaction() {
25566
+ this.lastTransaction = Math.max(Date.now(), this.lastTransaction + 1);
25567
+ return String(this.lastTransaction);
25409
25568
  }
25410
25569
  /**
25411
25570
  * The media of a camera on a T9000: live video, a readable feed and a live still over the station's
@@ -25581,45 +25740,109 @@ var RtcCommandRouter = class {
25581
25740
  return source;
25582
25741
  }
25583
25742
  /**
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}.
25743
+ * Send one envelope on the station's session, queued behind its other sends, and settle as
25744
+ * {@link sendAwaitAck} does. `body` is the inner payload; the station session, the `account_id` it is
25745
+ * stamped with and the packet are resolved here, so a command and a read reach the wire through the same
25746
+ * steps. With `reply.signal`, the caller's wait rejects the moment it aborts, also while the send is still
25747
+ * queued; a send not yet made by then is skipped.
25587
25748
  */
25588
- sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
25589
- return new Promise((resolve, reject) => {
25749
+ async sendQueued(route, outerCmd, innerCmd, channel, body, reply) {
25750
+ reply?.signal?.throwIfAborted();
25751
+ const identity = this.deps.identity();
25752
+ if (!identity)
25753
+ throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
25754
+ const adminUserId = route.adminUserId || identity.userId;
25755
+ const st = await abortable(this.stationSession(route, adminUserId, identity), reply?.signal);
25756
+ const segment = st.seg.next();
25757
+ const packet = buildPortalPacket({
25758
+ commandId: outerCmd,
25759
+ channel,
25760
+ segment,
25761
+ payload: { account_id: adminUserId, ...body }
25762
+ });
25763
+ const run = st.queue.then(() => this.sendAwaitAck(route.stationSn, st, packet, outerCmd, innerCmd, channel, segment, reply));
25764
+ st.queue = run.catch(() => void 0);
25765
+ return abortable(run, reply?.signal);
25766
+ }
25767
+ /**
25768
+ * Without `reply`, completes on the command ACK or a correlated control-result notification. The
25769
+ * notification must match the request channel, parameter and segment and contain a structured payload.
25770
+ * Receipt does not establish physical actuation. Bounded by {@link ACK_TIMEOUT_MS}.
25771
+ *
25772
+ * With `reply`, a zero-result ACK is not the answer: the first notify body (an object) for which
25773
+ * `reply.pick` answers a value resolves with it, one `pick` throws on rejects with that error, and every
25774
+ * other notify frame is left alone. Bounded by `reply.timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}),
25775
+ * and ended by `reply.signal`; one already aborted is not sent.
25776
+ *
25777
+ * Either way an ACK with a non-zero result code rejects, and the command is sent once.
25778
+ */
25779
+ sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment, reply) {
25780
+ const timeoutMs = reply ? reply.timeoutMs ?? DB_TABLE_TIMEOUT_MS : ACK_TIMEOUT_MS;
25781
+ const awaited = reply ? "reply" : "ACK";
25782
+ return new Promise((resolve2, reject) => {
25590
25783
  const onData = (frame, linkType) => {
25591
25784
  const p = parsePortalPacket(frame, linkType);
25592
- if (!p || p.segment !== segment)
25785
+ if (!p)
25786
+ return;
25787
+ if (reply && linkType === PortalLinkType.NOTIFY && typeof p.data === "object" && p.data !== null) {
25788
+ let picked;
25789
+ try {
25790
+ picked = reply.pick(p.data);
25791
+ } catch (e) {
25792
+ cleanup();
25793
+ reject(e instanceof Error ? e : new Error(String(e)));
25794
+ return;
25795
+ }
25796
+ if (picked === void 0)
25797
+ return;
25798
+ cleanup();
25799
+ this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} answered`);
25800
+ resolve2(picked);
25801
+ return;
25802
+ }
25803
+ if (p.segment !== segment)
25593
25804
  return;
25594
25805
  const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
25595
25806
  const d = p.data;
25596
25807
  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
25808
  if (!acknowledgement && !notification)
25598
25809
  return;
25810
+ if (reply && p.errCode === 0)
25811
+ return;
25599
25812
  cleanup();
25600
25813
  if (acknowledgement && p.errCode !== 0) {
25601
25814
  reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
25602
25815
  } else {
25603
25816
  this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
25604
- resolve();
25817
+ resolve2(void 0);
25605
25818
  }
25606
25819
  };
25607
25820
  const onClose = () => {
25608
25821
  cleanup();
25609
- reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
25822
+ reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ${awaited}`));
25823
+ };
25824
+ const onAbort = () => {
25825
+ cleanup();
25826
+ reject(reply?.signal?.reason);
25610
25827
  };
25611
25828
  const timer = setTimeout(() => {
25612
25829
  cleanup();
25613
- reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
25614
- }, ACK_TIMEOUT_MS);
25830
+ reject(new Error(`rtc: ${sn} cmd ${innerCmd} ${awaited} timed out after ${timeoutMs}ms`));
25831
+ }, timeoutMs);
25615
25832
  const cleanup = () => {
25616
25833
  clearTimeout(timer);
25617
25834
  st.session.off("commandData", onData);
25618
25835
  st.session.off("close", onClose);
25836
+ reply?.signal?.removeEventListener("abort", onAbort);
25619
25837
  this.touch(sn, st);
25620
25838
  };
25839
+ if (reply?.signal?.aborted) {
25840
+ onAbort();
25841
+ return;
25842
+ }
25621
25843
  st.session.on("commandData", onData);
25622
25844
  st.session.on("close", onClose);
25845
+ reply?.signal?.addEventListener("abort", onAbort, { once: true });
25623
25846
  if (!st.session.sendCommand(packet)) {
25624
25847
  cleanup();
25625
25848
  reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
@@ -25675,7 +25898,7 @@ var RtcCommandRouter = class {
25675
25898
  * promise, so an early failure cannot leave a second one rejecting unheard.
25676
25899
  */
25677
25900
  bringUp(sn, session) {
25678
- return new Promise((resolve, reject) => {
25901
+ return new Promise((resolve2, reject) => {
25679
25902
  let settled = false;
25680
25903
  const finish = (err) => {
25681
25904
  if (settled)
@@ -25692,7 +25915,7 @@ var RtcCommandRouter = class {
25692
25915
  reject(err);
25693
25916
  } else {
25694
25917
  this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
25695
- resolve();
25918
+ resolve2();
25696
25919
  }
25697
25920
  };
25698
25921
  const onConnected = () => finish();
@@ -26121,10 +26344,10 @@ function dpColorFields(spec) {
26121
26344
  }
26122
26345
 
26123
26346
  // dist/transport/mqtt/clean-codec.js
26124
- import { randomUUID as randomUUID3 } from "node:crypto";
26347
+ import { randomUUID as randomUUID4 } from "node:crypto";
26125
26348
  function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
26126
26349
  const payload = JSON.stringify({
26127
- t: opts?.uuid ?? randomUUID3(),
26350
+ t: opts?.uuid ?? randomUUID4(),
26128
26351
  protocol: 2,
26129
26352
  account_id: accountId,
26130
26353
  device_sn: deviceSn,
@@ -26363,13 +26586,13 @@ var MqttCommandRouter = class _MqttCommandRouter {
26363
26586
  * timeout with no unhandled listener left behind either way.
26364
26587
  */
26365
26588
  waitForMqttMessage(mqtt, match, timeoutMs) {
26366
- return new Promise((resolve) => {
26589
+ return new Promise((resolve2) => {
26367
26590
  const onMessage = (m) => {
26368
26591
  const result = match(m);
26369
26592
  if (result === void 0)
26370
26593
  return;
26371
26594
  cleanup();
26372
- resolve(result);
26595
+ resolve2(result);
26373
26596
  };
26374
26597
  const cleanup = () => {
26375
26598
  clearTimeout(timer);
@@ -26377,7 +26600,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
26377
26600
  };
26378
26601
  const timer = setTimeout(() => {
26379
26602
  cleanup();
26380
- resolve(void 0);
26603
+ resolve2(void 0);
26381
26604
  }, timeoutMs);
26382
26605
  timer.unref?.();
26383
26606
  mqtt.on("message", onMessage);
@@ -26897,7 +27120,7 @@ function decryptReply(key, resultB64) {
26897
27120
  }
26898
27121
 
26899
27122
  // dist/transport/tuya/request.js
26900
- import { randomUUID as randomUUID4 } from "node:crypto";
27123
+ import { randomUUID as randomUUID5 } from "node:crypto";
26901
27124
  var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
26902
27125
  var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
26903
27126
  var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
@@ -26929,7 +27152,7 @@ function buildApiParams(action, opts) {
26929
27152
  a: action.a,
26930
27153
  v: action.v,
26931
27154
  time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
26932
- requestId: opts.requestId ?? randomUUID4(),
27155
+ requestId: opts.requestId ?? randomUUID5(),
26933
27156
  sid: opts.session.sid,
26934
27157
  deviceId: opts.session.deviceId,
26935
27158
  chKey: opts.session.chKey,
@@ -28268,8 +28491,6 @@ var FcmRegistrar = class {
28268
28491
  };
28269
28492
 
28270
28493
  // 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
28494
  var MemoryFcmStore = class {
28274
28495
  p = null;
28275
28496
  load() {
@@ -28282,31 +28503,7 @@ var MemoryFcmStore = class {
28282
28503
  this.p = null;
28283
28504
  }
28284
28505
  };
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
- }
28506
+ var FileFcmStore = class extends FileSessionStore {
28310
28507
  };
28311
28508
 
28312
28509
  // dist/transport/media-failure.js
@@ -28335,6 +28532,12 @@ var StoredImageCache = class {
28335
28532
  activeDevices = /* @__PURE__ */ new Set();
28336
28533
  activeDownloads = 0;
28337
28534
  generation = 0;
28535
+ /**
28536
+ * Bumped by {@link clearLifecycleErrors}. A lifecycle failure from a download started under an older
28537
+ * count is not recorded; the candidate is queued again, unless a newer one is already queued, so the
28538
+ * current session fetches it.
28539
+ */
28540
+ lifecycle = 0;
28338
28541
  constructor(downloader, logger, clock = Date.now, isLifecycleError = () => false) {
28339
28542
  this.downloader = downloader;
28340
28543
  this.logger = logger;
@@ -28390,6 +28593,18 @@ var StoredImageCache = class {
28390
28593
  return Promise.resolve(state.retained.image);
28391
28594
  return Promise.reject(new StoredSnapshotUnavailableError(reason, "No stored snapshot is available"));
28392
28595
  }
28596
+ /**
28597
+ * Forget every device's recorded lifecycle failure, keeping retained bytes and candidate state.
28598
+ *
28599
+ * A lifecycle failure describes the session that was current when the download ran, so it no longer
28600
+ * holds once a login succeeds again; {@link snapshotStored} then answers from the retained image, or
28601
+ * with the reason a stored image is absent. A download in flight across the call cannot record one.
28602
+ */
28603
+ clearLifecycleErrors() {
28604
+ this.lifecycle += 1;
28605
+ for (const state of this.devices.values())
28606
+ state.lifecycleError = void 0;
28607
+ }
28393
28608
  /** Invalidate all retained and candidate state. */
28394
28609
  clear() {
28395
28610
  this.generation += 1;
@@ -28405,18 +28620,23 @@ var StoredImageCache = class {
28405
28620
  state.inFlight = candidate;
28406
28621
  this.activeDownloads += 1;
28407
28622
  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));
28623
+ const lifecycle = this.lifecycle;
28624
+ void this.downloader(candidate.url, candidate.deviceKey).then((image) => this.complete(candidate, lifecycle, image), (error) => this.complete(candidate, lifecycle, void 0, error));
28409
28625
  }
28410
28626
  }
28411
- complete(candidate, image, error) {
28627
+ complete(candidate, lifecycle, image, error) {
28412
28628
  this.activeDownloads -= 1;
28413
28629
  this.activeDevices.delete(candidate.deviceKey);
28414
28630
  const state = this.devices.get(candidate.deviceKey);
28415
28631
  if (candidate.generation === this.generation && state?.inFlight === candidate) {
28416
28632
  state.inFlight = void 0;
28417
28633
  if (error !== void 0 && this.isLifecycleError(error)) {
28418
- state.lifecycleError = error;
28419
- state.reason = void 0;
28634
+ if (lifecycle === this.lifecycle) {
28635
+ state.lifecycleError = error;
28636
+ state.reason = void 0;
28637
+ } else {
28638
+ state.queued ??= candidate;
28639
+ }
28420
28640
  } else if (image === void 0 && this.retryLater(state, candidate, error)) {
28421
28641
  state.lifecycleError = void 0;
28422
28642
  } else if (image === void 0) {
@@ -28908,8 +29128,8 @@ var DeviceRegistry = class {
28908
29128
  const waiters = this.stateWaiters.get(sn);
28909
29129
  if (waiters) {
28910
29130
  this.stateWaiters.delete(sn);
28911
- for (const resolve of waiters)
28912
- resolve();
29131
+ for (const resolve2 of waiters)
29132
+ resolve2();
28913
29133
  }
28914
29134
  }
28915
29135
  /**
@@ -28954,15 +29174,15 @@ var DeviceRegistry = class {
28954
29174
  awaitRealtimeState(sn, timeoutMs) {
28955
29175
  if (this.hasRealtimeState(sn) || timeoutMs <= 0)
28956
29176
  return Promise.resolve();
28957
- return new Promise((resolve) => {
29177
+ return new Promise((resolve2) => {
28958
29178
  const timer = setTimeout(() => {
28959
29179
  this.stateWaiters.get(sn)?.delete(done);
28960
- resolve();
29180
+ resolve2();
28961
29181
  }, timeoutMs);
28962
29182
  timer.unref?.();
28963
29183
  const done = () => {
28964
29184
  clearTimeout(timer);
28965
- resolve();
29185
+ resolve2();
28966
29186
  };
28967
29187
  const set = this.stateWaiters.get(sn) ?? /* @__PURE__ */ new Set();
28968
29188
  set.add(done);
@@ -29666,7 +29886,7 @@ var EufyMega = class extends EventEmitter14 {
29666
29886
  return true;
29667
29887
  const delay = Math.min(500, deadline - Date.now());
29668
29888
  if (delay > 0)
29669
- await new Promise((resolve) => setTimeout(resolve, delay));
29889
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
29670
29890
  if (refresh.expected !== void 0 && settled())
29671
29891
  return true;
29672
29892
  }
@@ -29715,7 +29935,7 @@ var EufyMega = class extends EventEmitter14 {
29715
29935
  * manager's own log, since by then nothing is waiting to receive it.
29716
29936
  */
29717
29937
  recycleStandaloneSession(sn) {
29718
- const releaseWrite = new Promise((resolve) => void setTimeout(resolve, SESSION_RECYCLE_WAIT_MS).unref?.());
29938
+ const releaseWrite = new Promise((resolve2) => void setTimeout(resolve2, SESSION_RECYCLE_WAIT_MS).unref?.());
29719
29939
  return Promise.race([
29720
29940
  this.p2p.resetStandaloneSession(sn).finally(() => this.reopenWiredStation(sn)),
29721
29941
  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 +29972,11 @@ var EufyMega = class extends EventEmitter14 {
29752
29972
  }
29753
29973
  /** Limit waiting on an unabortable dependency operation to the remaining semantic-event refresh window. */
29754
29974
  beforeDeadline(operation, timeoutMs) {
29755
- return new Promise((resolve, reject) => {
29975
+ return new Promise((resolve2, reject) => {
29756
29976
  const timeout = setTimeout(() => reject(new RefreshWindowClosedError("refresh window closed")), Math.max(0, timeoutMs));
29757
29977
  operation.then((value) => {
29758
29978
  clearTimeout(timeout);
29759
- resolve(value);
29979
+ resolve2(value);
29760
29980
  }, (error) => {
29761
29981
  clearTimeout(timeout);
29762
29982
  reject(error);
@@ -29861,12 +30081,16 @@ var EufyMega = class extends EventEmitter14 {
29861
30081
  /**
29862
30082
  * On a successful login, kick off auto-realtime (unless `autoRealtime:false`). Fire-and-forget so
29863
30083
  * `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.
30084
+ * surface failures via `error`. Idempotent through the retained generation promise. A login for a
30085
+ * different account drops every stored image; one for the same account drops only the lifecycle
30086
+ * failures recorded against the session it replaces.
29865
30087
  */
29866
30088
  afterLogin(result) {
29867
30089
  if (result.status === LoginStatus.Ok) {
29868
30090
  if (this.storedImageAccount && this.storedImageAccount !== result.session.userId)
29869
30091
  this.storedImages?.clear();
30092
+ else
30093
+ this.storedImages?.clearLifecycleErrors();
29870
30094
  this.storedImageAccount = result.session.userId;
29871
30095
  const auth = this.mega.auth;
29872
30096
  if (auth?.userId)
@@ -29903,12 +30127,12 @@ var EufyMega = class extends EventEmitter14 {
29903
30127
  if (options.timeoutMs === void 0)
29904
30128
  return generation.promise;
29905
30129
  const timeoutMs = Math.max(0, options.timeoutMs);
29906
- return new Promise((resolve) => {
29907
- const timer = setTimeout(() => resolve(readinessSnapshot(generation.readiness, "timed-out")), timeoutMs);
30130
+ return new Promise((resolve2) => {
30131
+ const timer = setTimeout(() => resolve2(readinessSnapshot(generation.readiness, "timed-out")), timeoutMs);
29908
30132
  timer.unref?.();
29909
30133
  void generation.promise.then((result) => {
29910
30134
  clearTimeout(timer);
29911
- resolve(result);
30135
+ resolve2(result);
29912
30136
  });
29913
30137
  });
29914
30138
  }
@@ -29949,10 +30173,10 @@ var EufyMega = class extends EventEmitter14 {
29949
30173
  let acknowledge;
29950
30174
  let reject;
29951
30175
  let acknowledged = false;
29952
- const acknowledgement = new Promise((resolve, rejectPromise) => {
30176
+ const acknowledgement = new Promise((resolve2, rejectPromise) => {
29953
30177
  acknowledge = () => {
29954
30178
  acknowledged = true;
29955
- resolve();
30179
+ resolve2();
29956
30180
  };
29957
30181
  reject = rejectPromise;
29958
30182
  });
@@ -30319,8 +30543,8 @@ var EufyMega = class extends EventEmitter14 {
30319
30543
  if (this.realtimeGeneration?.epoch === this.realtimeEpoch)
30320
30544
  return this.realtimeGeneration;
30321
30545
  const epoch = this.realtimeEpoch;
30322
- let resolve;
30323
- const promise = new Promise((done) => resolve = done);
30546
+ let resolve2;
30547
+ const promise = new Promise((done) => resolve2 = done);
30324
30548
  const generation = {
30325
30549
  epoch,
30326
30550
  readiness: {
@@ -30330,7 +30554,7 @@ var EufyMega = class extends EventEmitter14 {
30330
30554
  wiredP2p: createPlaneReadiness()
30331
30555
  },
30332
30556
  promise,
30333
- resolve,
30557
+ resolve: resolve2,
30334
30558
  abort: new AbortController(),
30335
30559
  settled: false
30336
30560
  };
@@ -30481,6 +30705,9 @@ var EufyMega = class extends EventEmitter14 {
30481
30705
  * device absent from the previous pass is skipped — first sight is discovery, not a transition;
30482
30706
  * {@link deviceState} answers an initial reading.
30483
30707
  *
30708
+ * A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
30709
+ * of its params moved, since each re-resolution is a cloud param-list read.
30710
+ *
30484
30711
  * Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
30485
30712
  * is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
30486
30713
  * stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
@@ -30499,8 +30726,8 @@ var EufyMega = class extends EventEmitter14 {
30499
30726
  this.emitSemantic(out.event, out.payload, { refresh: out.refresh });
30500
30727
  for (const dev of diff.reported)
30501
30728
  this.emit("deviceState", this.stateOf(dev));
30502
- for (const change of diff.params)
30503
- await this.widenCapabilities(change.deviceSn);
30729
+ for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
30730
+ await this.widenCapabilities(sn);
30504
30731
  } catch (e) {
30505
30732
  this.reportError(e);
30506
30733
  }
@@ -30886,31 +31113,72 @@ var EufyMega = class extends EventEmitter14 {
30886
31113
  return this.p2p.getSessions();
30887
31114
  }
30888
31115
  /**
30889
- * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
31116
+ * The people enrolled on a station, read from its own `person_basic_info` table through
31117
+ * {@link readStationTable}.
30890
31118
  *
30891
31119
  * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
30892
31120
  * station, because they were never uploaded — the station holds them.
30893
31121
  *
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.
31122
+ * Resolves with the rows the station returned that are objects. Rejects as {@link readStationTable}
31123
+ * does.
30897
31124
  *
30898
31125
  * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
30899
31126
  * returned as they arrive; a caller that wants only named people filters them.
30900
31127
  */
30901
31128
  async getStationFaces(stationSn, opts = {}) {
31129
+ const rows = await this.readStationTable(stationSn, "person_basic_info", opts);
31130
+ return rows.filter((row) => typeof row === "object" && row !== null);
31131
+ }
31132
+ /**
31133
+ * The rows of one of a station's own tables (`history_record_info`, `person_basic_info`, …), read over
31134
+ * the transport the station speaks: the control channel of a HomeBase S1 Pro (T9000), P2P for any
31135
+ * other station. The query is the table's whole content; the rows are the station's, shaped by it.
31136
+ *
31137
+ * Resolves with the rows the station returned. Rejects on a serial not in the device list, on a record
31138
+ * stating no `admin_user_id`, on a station that cannot be reached, on a query the station refuses, and
31139
+ * on the reply not arriving in time. Assembling the reply is the transport's; this resolves which
31140
+ * station, which transport and which account id.
31141
+ */
31142
+ async readStationTable(stationSn, table, opts = {}) {
31143
+ const { station, accountId, route } = await this.stationReadTarget("readStationTable", stationSn);
31144
+ if (route)
31145
+ return this.rtc.readTable(route, table, opts);
31146
+ await this.p2p.ensureStation(station, opts.signal);
31147
+ const session = this.p2p.getSessions().get(station);
31148
+ if (!session)
31149
+ throw new Error(`readStationTable: no P2P session for ${station}`);
31150
+ return session.readDatabase(table, { accountId, ...opts });
31151
+ }
31152
+ /**
31153
+ * One image stored on a HomeBase S1 Pro (T9000), by the on-station path a table row names
31154
+ * (`thumb_path`, …), read over the station's control channel.
31155
+ *
31156
+ * Resolves with the image bytes as the station holds them. Rejects on a serial not in the device list,
31157
+ * on a record stating no `admin_user_id`, on a station other than a T9000, on a station that cannot be
31158
+ * reached, and on the reply not arriving in time. Fetching the image is the transport's; this resolves
31159
+ * which station and which account id.
31160
+ */
31161
+ async readStationImage(stationSn, filePath, opts = {}) {
31162
+ const { station, route } = await this.stationReadTarget("readStationImage", stationSn);
31163
+ if (!route)
31164
+ throw new Error(`readStationImage: ${station} is not a T9000; its stored images are not read here`);
31165
+ return this.rtc.readImage(route, filePath, opts);
31166
+ }
31167
+ /**
31168
+ * The station a database read goes to, its `admin_user_id` and, for a T9000, its RTC route. Throws,
31169
+ * prefixed with `op`, on a serial the device list does not carry and on a record stating no admin id.
31170
+ */
31171
+ async stationReadTarget(op, stationSn) {
30902
31172
  if (!this.registry.list().length)
30903
31173
  await this.getDevices();
30904
31174
  const station = this.p2p.stationKeyOf(stationSn);
31175
+ const found = this.stationOf(station);
31176
+ if (!found.station)
31177
+ throw new Error(`${op}: ${station} is not in the device list`);
30905
31178
  const accountId = this.adminUserIdOf(station);
30906
31179
  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);
31180
+ throw new Error(`${op}: no admin_user_id on record for ${station}`);
31181
+ return { station, accountId, route: found.t9000 ? this.rtcRouteFor(station) : void 0 };
30914
31182
  }
30915
31183
  /** The station's own `admin_user_id`, which its database refuses the query without. */
30916
31184
  adminUserIdOf(stationSn) {
@@ -31137,6 +31405,9 @@ var EufyMega = class extends EventEmitter14 {
31137
31405
  * Returns the connected client rather than installing it. Registration can outlive a `disconnect()`,
31138
31406
  * and {@link ensureRealtime} owns the decision of whether a finished bring-up is still the current
31139
31407
  * one — so this never overwrites a channel a later login already brought up.
31408
+ *
31409
+ * Persisting the seen ids after each push reports a failed `save` through `error` rather than throwing:
31410
+ * the push handler runs inside the MCS socket's `data` callback, where a throw is an uncaught exception.
31140
31411
  */
31141
31412
  async startPush(signal) {
31142
31413
  if (!this.mega.auth)
@@ -31183,9 +31454,13 @@ var EufyMega = class extends EventEmitter14 {
31183
31454
  if (dsn)
31184
31455
  this.prewarmForEvent(out.event, dsn);
31185
31456
  }
31186
- store.save({ creds: persistedCreds, persistentIds: client.getPersistentIds() });
31457
+ try {
31458
+ store.save({ creds: persistedCreds, persistentIds: client.getPersistentIds() });
31459
+ } catch (e) {
31460
+ this.reportError(e);
31461
+ }
31187
31462
  });
31188
- await new Promise((resolve, reject) => {
31463
+ await new Promise((resolve2, reject) => {
31189
31464
  const cleanup = () => {
31190
31465
  signal?.removeEventListener("abort", onAbort);
31191
31466
  client.off("connect", onConnect);
@@ -31193,7 +31468,7 @@ var EufyMega = class extends EventEmitter14 {
31193
31468
  };
31194
31469
  const onConnect = () => {
31195
31470
  cleanup();
31196
- resolve();
31471
+ resolve2();
31197
31472
  };
31198
31473
  const onError = (error) => {
31199
31474
  cleanup();