@mega-yfue/eufy-sdk 0.6.0-beta.13 → 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();
@@ -22067,9 +22117,10 @@ var P2P_STATION_WAITS = {
22067
22117
  };
22068
22118
  var RTSP_URL_READ_TIMEOUT_MS = 12e3;
22069
22119
  var TALKBACK_CODEC_WAIT_MS = 3e3;
22120
+ var OWN_LIVE_SESSION_MODELS = /* @__PURE__ */ new Set(["T8030"]);
22070
22121
  var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
22071
22122
  function speakerCodec(consumer, talk, timeoutMs) {
22072
- return new Promise((resolve) => {
22123
+ return new Promise((resolve2) => {
22073
22124
  let timer;
22074
22125
  const settle = (codec) => {
22075
22126
  clearTimeout(timer);
@@ -22077,7 +22128,7 @@ function speakerCodec(consumer, talk, timeoutMs) {
22077
22128
  consumer.off("audio", onAudio);
22078
22129
  consumer.off("stop", onStop);
22079
22130
  talk.off("stop", onStop);
22080
- resolve(codec);
22131
+ resolve2(codec);
22081
22132
  };
22082
22133
  const onVideo = () => {
22083
22134
  timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
@@ -22129,6 +22180,18 @@ var P2PCommandRouter = class _P2PCommandRouter {
22129
22180
  * {@link liveSources} is a claim in flight.
22130
22181
  */
22131
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();
22132
22195
  /**
22133
22196
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
22134
22197
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -22308,17 +22371,30 @@ var P2PCommandRouter = class _P2PCommandRouter {
22308
22371
  * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
22309
22372
  * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
22310
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.
22311
22379
  */
22312
22380
  async openMediaSession(parentSn, channel, signal) {
22313
- const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
22314
- await this.awaitConnected(session, parentSn, signal);
22315
- let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22316
- if (!ready && session.repromptLevel2Key()) {
22317
- ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
22318
- }
22319
- if (!ready)
22320
- throw new StationKeyUnavailableError(parentSn);
22321
- 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
+ }
22322
22398
  }
22323
22399
  /**
22324
22400
  * Wait for `session` to connect, for up to the session's own connect deadline, and throw
@@ -22356,7 +22432,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22356
22432
  throw new Error(`no P2P endpoint (p2p_did) for station ${parentSn}`);
22357
22433
  let dskKey;
22358
22434
  try {
22359
- dskKey = (await this.deps.mega.getDskKeys([parentSn]))[parentSn]?.dskKey;
22435
+ dskKey = (await this.deps.mega.getDskKeys([stationDev]))[parentSn]?.dskKey;
22360
22436
  } catch (e) {
22361
22437
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
22362
22438
  }
@@ -22458,7 +22534,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22458
22534
  const session = await this.openSession(parentSn, parentSn);
22459
22535
  if (session.isConnected)
22460
22536
  return;
22461
- await new Promise((resolve, reject) => {
22537
+ await new Promise((resolve2, reject) => {
22462
22538
  const cleanup = () => {
22463
22539
  signal?.removeEventListener("abort", onAbort);
22464
22540
  session.off("connect", onConnect);
@@ -22467,7 +22543,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22467
22543
  };
22468
22544
  const onConnect = () => {
22469
22545
  cleanup();
22470
- resolve();
22546
+ resolve2();
22471
22547
  };
22472
22548
  const onError = (error) => {
22473
22549
  cleanup();
@@ -22809,8 +22885,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
22809
22885
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
22810
22886
  * reuse branch, so a reuse frees the station as a cold start does.
22811
22887
  *
22812
- * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
22813
- * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
22888
+ * `continuous` decides whether a camera that finds the station's own session claimed may open a
22889
+ * connection of its own for it; on an {@link OWN_LIVE_SESSION_MODELS} station it always gets one, and an
22890
+ * unwatched source left on the station's session is rebuilt there. Only the continuous-pull egresses pass it. A still must not: it wants one
22814
22891
  * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
22815
22892
  * so a still asked for while a sibling is being watched contends as it always did, and the caller's
22816
22893
  * retained image answers it. A live view still outranks a tile; what changed is that two live views no
@@ -22819,23 +22896,31 @@ var P2PCommandRouter = class _P2PCommandRouter {
22819
22896
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
22820
22897
  * place.
22821
22898
  */
22822
- async sharedLiveSourceFor(sn, opts = {}, mayOpenOwnSession = false) {
22899
+ async sharedLiveSourceFor(sn, opts = {}, continuous = false) {
22823
22900
  const { session, parentSn, channel, accountId, homeBaseAttached } = await this.resolveSession(sn, {
22824
22901
  waitLevel2: "soft",
22825
22902
  requireLevel2ForAttached: true,
22826
22903
  signal: opts.signal
22827
22904
  });
22828
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
+ }
22911
+ const ownSession = continuous && homeBaseAttached && this.ownsLiveSession(parentSn);
22829
22912
  let source = this.liveSources.get(key);
22830
- if (source && source.state === "stopped") {
22913
+ if (source && (source.state === "stopped" || ownSession && source.state !== "idle" && source.consumerCount === 0 && this.liveSessionKeys.get(key) === parentSn)) {
22831
22914
  this.dropLiveSource(key);
22832
22915
  source = void 0;
22833
22916
  }
22834
22917
  this.releaseLingeringSiblings(parentSn, channel);
22835
22918
  if (!source) {
22836
22919
  const logger = this.deps.logger ?? noopLogger;
22837
- const sessionKey = mayOpenOwnSession && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
22920
+ const sessionKey = ownSession || continuous && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
22838
22921
  this.liveSessionKeys.set(key, sessionKey);
22922
+ const built = Promise.withResolvers();
22923
+ this.liveSourceBuilds.set(key, built.promise);
22839
22924
  let held;
22840
22925
  try {
22841
22926
  held = {
@@ -22844,6 +22929,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
22844
22929
  } catch (error) {
22845
22930
  this.liveSessionKeys.delete(key);
22846
22931
  throw error;
22932
+ } finally {
22933
+ this.liveSourceBuilds.delete(key);
22934
+ built.resolve();
22847
22935
  }
22848
22936
  source = new SharedLiveSource({
22849
22937
  makeStream: (ctx) => new LiveStream(held.session, {
@@ -23128,11 +23216,18 @@ var P2PCommandRouter = class _P2PCommandRouter {
23128
23216
  * yield CMAF fragments (init segment first, then a `moof`+`mdat` per keyframe boundary) muxed by the
23129
23217
  * dependency-free internal fMP4 muxer. The returned recording handle exposes battery-budget notices
23130
23218
  * and detaches its consumer on `stop`, iterator return, or iterator throw. No ffmpeg.
23219
+ *
23220
+ * It is a continuous pull only on an {@link OWN_LIVE_SESSION_MODELS} station; elsewhere it never opens a
23221
+ * connection of its own.
23131
23222
  */
23132
23223
  recordFragments(sn, opts = {}) {
23133
- const source = this.sharedLiveSourceFor(sn, opts);
23224
+ const source = this.sharedLiveSourceFor(sn, opts, this.ownsLiveSession(this.stationKeyOf(sn)));
23134
23225
  return new FragmentRecording(source, opts);
23135
23226
  }
23227
+ /** Whether `stationSn` is an {@link OWN_LIVE_SESSION_MODELS} station. */
23228
+ ownsLiveSession(stationSn) {
23229
+ return OWN_LIVE_SESSION_MODELS.has(this.recordFor(stationSn)?.model ?? "");
23230
+ }
23136
23231
  /**
23137
23232
  * **Generic P2P request/reply query.** Sends a `SET_PAYLOAD` (1350) wrapper carrying `subCmd` on the
23138
23233
  * device channel, then resolves with the reply frame's `payload` — the `NOTIFY_PAYLOAD` (1351)
@@ -23141,7 +23236,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23141
23236
  async p2pQuery(sn, subCmd, opts = {}) {
23142
23237
  const { session, channel, accountId } = await this.resolveSession(sn, { waitLevel2: true });
23143
23238
  const timeoutMs = opts.timeoutMs ?? 15e3;
23144
- return await new Promise((resolve, reject) => {
23239
+ return await new Promise((resolve2, reject) => {
23145
23240
  const timer = setTimeout(() => {
23146
23241
  cleanup();
23147
23242
  reject(new Error(`timeout waiting for p2p query ${subCmd} reply from ${sn}`));
@@ -23150,7 +23245,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23150
23245
  const j = f.json;
23151
23246
  if (f.channel === channel && f.commandId === CommandType.CMD_NOTIFY_PAYLOAD && j?.cmd === subCmd && j.payload && typeof j.payload === "object") {
23152
23247
  cleanup();
23153
- resolve(j.payload);
23248
+ resolve2(j.payload);
23154
23249
  }
23155
23250
  };
23156
23251
  const cleanup = () => {
@@ -23180,7 +23275,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23180
23275
  async p2pControlQuery(sn, param, data, opts = {}) {
23181
23276
  const { session } = await this.resolveSession(sn, { waitLevel2: false });
23182
23277
  const timeoutMs = opts.timeoutMs ?? 15e3;
23183
- return await new Promise((resolve, reject) => {
23278
+ return await new Promise((resolve2, reject) => {
23184
23279
  const timer = setTimeout(() => {
23185
23280
  cleanup();
23186
23281
  reject(new Error(`timeout waiting for p2p control query ${param} reply from ${sn}`));
@@ -23189,7 +23284,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23189
23284
  const j = f.json;
23190
23285
  if (j?.cmd === param && j.payload && typeof j.payload === "object") {
23191
23286
  cleanup();
23192
- resolve(j.payload);
23287
+ resolve2(j.payload);
23193
23288
  }
23194
23289
  };
23195
23290
  const cleanup = () => {
@@ -23286,12 +23381,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
23286
23381
  * is ignored rather than resolving the wrong read.
23287
23382
  */
23288
23383
  awaitRtspUrl(session, channel, signal) {
23289
- return new Promise((resolve, reject) => {
23384
+ return new Promise((resolve2, reject) => {
23290
23385
  const onUrl = (ev) => {
23291
23386
  if (ev.channel !== channel)
23292
23387
  return;
23293
23388
  cleanup();
23294
- resolve(ev.url);
23389
+ resolve2(ev.url);
23295
23390
  };
23296
23391
  const onAbort = () => {
23297
23392
  cleanup();
@@ -23611,7 +23706,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23611
23706
  * wrapper has to share {@link serializeKeyed}.
23612
23707
  */
23613
23708
  awaitKeyedReply(sn, session, channel, engage, send) {
23614
- return new Promise((resolve, reject) => {
23709
+ return new Promise((resolve2, reject) => {
23615
23710
  let refused = false;
23616
23711
  let mark;
23617
23712
  let timer;
@@ -23631,7 +23726,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23631
23726
  if (refused)
23632
23727
  return;
23633
23728
  refused = true;
23634
- 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 })));
23635
23730
  };
23636
23731
  const onResult = (r) => {
23637
23732
  if (r.channel !== channel)
@@ -23639,7 +23734,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23639
23734
  if (r.code === KEYED_PAYLOAD_SEQ_ERROR)
23640
23735
  refuse();
23641
23736
  else if (!refused)
23642
- settle(() => resolve({ code: r.code }));
23737
+ settle(() => resolve2({ code: r.code }));
23643
23738
  };
23644
23739
  const onData = (f) => {
23645
23740
  if (f.channel !== channel || f.commandId !== CommandType.CMD_NOTIFY_PAYLOAD)
@@ -23651,7 +23746,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23651
23746
  }
23652
23747
  const code = keyedPayloadReportCode(f.json, engage);
23653
23748
  if (code !== void 0 && !refused)
23654
- settle(() => resolve({ code }));
23749
+ settle(() => resolve2({ code }));
23655
23750
  };
23656
23751
  session.on("commandResult", onResult);
23657
23752
  session.on("data", onData);
@@ -23756,7 +23851,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23756
23851
  clearTimeout(timer);
23757
23852
  session.off("data", onData);
23758
23853
  };
23759
- const getReplyPromise = new Promise((resolve) => {
23854
+ const getReplyPromise = new Promise((resolve2) => {
23760
23855
  onData = (f) => {
23761
23856
  const j = f.json;
23762
23857
  if (j?.cmd !== CMD_TRANSFER_PAYLOAD || !j.payload)
@@ -23769,11 +23864,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
23769
23864
  if (keyTime === void 0 || keyTime !== query.time)
23770
23865
  return;
23771
23866
  cleanup();
23772
- resolve({ lockPayload, keyTime });
23867
+ resolve2({ lockPayload, keyTime });
23773
23868
  };
23774
23869
  timer = setTimeout(() => {
23775
23870
  cleanup();
23776
- resolve(void 0);
23871
+ resolve2(void 0);
23777
23872
  }, _P2PCommandRouter.FF09_SETTINGS_GET_TIMEOUT_MS);
23778
23873
  session.on("data", onData);
23779
23874
  });
@@ -23902,7 +23997,9 @@ var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a
23902
23997
  var PORTAL_KEEPALIVE_MS = 29e3;
23903
23998
 
23904
23999
  // dist/transport/rtc/portal-packet.js
24000
+ import { zstdDecompressSync } from "node:zlib";
23905
24001
  var MAGIC2 = Buffer.from("XZYH", "ascii");
24002
+ var ZSTD_MAGIC_LE = 4247762216;
23906
24003
  var PORTAL_HEADER_LENGTH = 16;
23907
24004
  var PORTAL_CMD_SET_PAYLOAD = 1350;
23908
24005
  var PORTAL_STATION_CHANNEL = 255;
@@ -23966,11 +24063,23 @@ function parseJsonBody(buf) {
23966
24063
  return text2;
23967
24064
  }
23968
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
+ }
23969
24075
  function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
23970
- const header = parsePortalHeader(buf);
24076
+ const packet = unwrapZstd(buf);
24077
+ if (!packet)
24078
+ return void 0;
24079
+ const header = parsePortalHeader(packet);
23971
24080
  if (!header)
23972
24081
  return void 0;
23973
- 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);
23974
24083
  if (linkType === PortalLinkType.NOTIFY) {
23975
24084
  const data = parseJsonBody(body);
23976
24085
  const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
@@ -24702,12 +24811,12 @@ var RtcPeer = class extends EventEmitter9 {
24702
24811
  this.remoteMid = media ? offer.match(/^a=mid:([^\r\n]+)/m)?.[1]?.trim() ?? HUB_SDP_MID : midOf(offer, "application") ?? HUB_SDP_MID;
24703
24812
  if (media)
24704
24813
  this.videoMid = midOf(offer, "video");
24705
- const answerWait = new Promise((resolve, reject) => {
24814
+ const answerWait = new Promise((resolve2, reject) => {
24706
24815
  const timer = setTimeout(() => {
24707
24816
  this.localAnswer = void 0;
24708
24817
  reject(new Error("timed out waiting for the local SDP answer"));
24709
24818
  }, ANSWER_TIMEOUT_MS);
24710
- this.localAnswer = { resolve, reject, timer };
24819
+ this.localAnswer = { resolve: resolve2, reject, timer };
24711
24820
  });
24712
24821
  pc.setRemoteDescription(offer, "offer");
24713
24822
  this.remoteSet = true;
@@ -24869,7 +24978,7 @@ var RtcPeer = class extends EventEmitter9 {
24869
24978
 
24870
24979
  // dist/transport/rtc/signaling.js
24871
24980
  import { EventEmitter as EventEmitter10 } from "node:events";
24872
- import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
24981
+ import { createHmac as createHmac3, randomUUID as randomUUID3 } from "node:crypto";
24873
24982
  var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
24874
24983
  var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
24875
24984
  var PORTAL_ORIGIN = "https://security.eufy.com";
@@ -24909,7 +25018,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24909
25018
  this.createSocket = opts.createSocket ?? defaultSocket;
24910
25019
  this.logger = opts.logger ?? noopLogger;
24911
25020
  this.now = opts.now ?? Date.now;
24912
- this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
25021
+ this.makeMsgId = opts.makeMsgId ?? (() => randomUUID3().replace(/-/g, ""));
24913
25022
  }
24914
25023
  get isOpen() {
24915
25024
  return this.ws?.readyState === WS_OPEN;
@@ -24953,7 +25062,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24953
25062
  };
24954
25063
  const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
24955
25064
  this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
24956
- await new Promise((resolve, reject) => {
25065
+ await new Promise((resolve2, reject) => {
24957
25066
  const timer = setTimeout(() => {
24958
25067
  reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
24959
25068
  this.close();
@@ -24967,7 +25076,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
24967
25076
  this.sendAuth(sign);
24968
25077
  this.startKeepalive();
24969
25078
  this.emit("open");
24970
- resolve();
25079
+ resolve2();
24971
25080
  });
24972
25081
  ws.addEventListener("message", (ev) => {
24973
25082
  void this.handleWireMessage(ev.data);
@@ -25240,12 +25349,12 @@ var RtcSession = class extends EventEmitter11 {
25240
25349
  waitForAuth() {
25241
25350
  if (this.authOk)
25242
25351
  return Promise.resolve();
25243
- return new Promise((resolve, reject) => {
25352
+ return new Promise((resolve2, reject) => {
25244
25353
  const timer = setTimeout(() => {
25245
25354
  this.authWaiter = void 0;
25246
25355
  reject(new Error("RTC signalling auth timeout"));
25247
25356
  }, AUTH_TIMEOUT_MS);
25248
- this.authWaiter = { resolve, reject, timer };
25357
+ this.authWaiter = { resolve: resolve2, reject, timer };
25249
25358
  });
25250
25359
  }
25251
25360
  /** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
@@ -25372,6 +25481,7 @@ var RtcCommandRouter = class {
25372
25481
  lives = /* @__PURE__ */ new Map();
25373
25482
  warm = /* @__PURE__ */ new Map();
25374
25483
  callSources = /* @__PURE__ */ new Map();
25484
+ lastTransaction = 0;
25375
25485
  constructor(deps) {
25376
25486
  this.deps = deps;
25377
25487
  }
@@ -25381,21 +25491,80 @@ var RtcCommandRouter = class {
25381
25491
  }
25382
25492
  if (route.mediaTrack)
25383
25493
  throw new Error(`rtc: ${route.stationSn} serves media only and takes no portal command`);
25384
- const identity = this.deps.identity();
25385
- if (!identity)
25386
- throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
25387
- const { stationSn } = route;
25388
- const adminUserId = route.adminUserId || identity.userId;
25389
25494
  const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
25390
- const st = await this.stationSession(route, adminUserId, identity);
25391
- const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
25392
- const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
25393
- 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 };
25394
- const segment = st.seg.next();
25395
- const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
25396
- const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
25397
- st.queue = run.catch(() => void 0);
25398
- 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);
25399
25568
  }
25400
25569
  /**
25401
25570
  * The media of a camera on a T9000: live video, a readable feed and a live still over the station's
@@ -25571,45 +25740,109 @@ var RtcCommandRouter = class {
25571
25740
  return source;
25572
25741
  }
25573
25742
  /**
25574
- * Completes on the command ACK or a correlated control-result notification. The notification must
25575
- * match the request channel, parameter and segment and contain a structured payload. Receipt does
25576
- * 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.
25577
25748
  */
25578
- sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
25579
- 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) => {
25580
25783
  const onData = (frame, linkType) => {
25581
25784
  const p = parsePortalPacket(frame, linkType);
25582
- 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)
25583
25804
  return;
25584
25805
  const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
25585
25806
  const d = p.data;
25586
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;
25587
25808
  if (!acknowledgement && !notification)
25588
25809
  return;
25810
+ if (reply && p.errCode === 0)
25811
+ return;
25589
25812
  cleanup();
25590
25813
  if (acknowledgement && p.errCode !== 0) {
25591
25814
  reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
25592
25815
  } else {
25593
25816
  this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
25594
- resolve();
25817
+ resolve2(void 0);
25595
25818
  }
25596
25819
  };
25597
25820
  const onClose = () => {
25598
25821
  cleanup();
25599
- 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);
25600
25827
  };
25601
25828
  const timer = setTimeout(() => {
25602
25829
  cleanup();
25603
- reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
25604
- }, ACK_TIMEOUT_MS);
25830
+ reject(new Error(`rtc: ${sn} cmd ${innerCmd} ${awaited} timed out after ${timeoutMs}ms`));
25831
+ }, timeoutMs);
25605
25832
  const cleanup = () => {
25606
25833
  clearTimeout(timer);
25607
25834
  st.session.off("commandData", onData);
25608
25835
  st.session.off("close", onClose);
25836
+ reply?.signal?.removeEventListener("abort", onAbort);
25609
25837
  this.touch(sn, st);
25610
25838
  };
25839
+ if (reply?.signal?.aborted) {
25840
+ onAbort();
25841
+ return;
25842
+ }
25611
25843
  st.session.on("commandData", onData);
25612
25844
  st.session.on("close", onClose);
25845
+ reply?.signal?.addEventListener("abort", onAbort, { once: true });
25613
25846
  if (!st.session.sendCommand(packet)) {
25614
25847
  cleanup();
25615
25848
  reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
@@ -25665,7 +25898,7 @@ var RtcCommandRouter = class {
25665
25898
  * promise, so an early failure cannot leave a second one rejecting unheard.
25666
25899
  */
25667
25900
  bringUp(sn, session) {
25668
- return new Promise((resolve, reject) => {
25901
+ return new Promise((resolve2, reject) => {
25669
25902
  let settled = false;
25670
25903
  const finish = (err) => {
25671
25904
  if (settled)
@@ -25682,7 +25915,7 @@ var RtcCommandRouter = class {
25682
25915
  reject(err);
25683
25916
  } else {
25684
25917
  this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
25685
- resolve();
25918
+ resolve2();
25686
25919
  }
25687
25920
  };
25688
25921
  const onConnected = () => finish();
@@ -26111,10 +26344,10 @@ function dpColorFields(spec) {
26111
26344
  }
26112
26345
 
26113
26346
  // dist/transport/mqtt/clean-codec.js
26114
- import { randomUUID as randomUUID3 } from "node:crypto";
26347
+ import { randomUUID as randomUUID4 } from "node:crypto";
26115
26348
  function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
26116
26349
  const payload = JSON.stringify({
26117
- t: opts?.uuid ?? randomUUID3(),
26350
+ t: opts?.uuid ?? randomUUID4(),
26118
26351
  protocol: 2,
26119
26352
  account_id: accountId,
26120
26353
  device_sn: deviceSn,
@@ -26353,13 +26586,13 @@ var MqttCommandRouter = class _MqttCommandRouter {
26353
26586
  * timeout with no unhandled listener left behind either way.
26354
26587
  */
26355
26588
  waitForMqttMessage(mqtt, match, timeoutMs) {
26356
- return new Promise((resolve) => {
26589
+ return new Promise((resolve2) => {
26357
26590
  const onMessage = (m) => {
26358
26591
  const result = match(m);
26359
26592
  if (result === void 0)
26360
26593
  return;
26361
26594
  cleanup();
26362
- resolve(result);
26595
+ resolve2(result);
26363
26596
  };
26364
26597
  const cleanup = () => {
26365
26598
  clearTimeout(timer);
@@ -26367,7 +26600,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
26367
26600
  };
26368
26601
  const timer = setTimeout(() => {
26369
26602
  cleanup();
26370
- resolve(void 0);
26603
+ resolve2(void 0);
26371
26604
  }, timeoutMs);
26372
26605
  timer.unref?.();
26373
26606
  mqtt.on("message", onMessage);
@@ -26887,7 +27120,7 @@ function decryptReply(key, resultB64) {
26887
27120
  }
26888
27121
 
26889
27122
  // dist/transport/tuya/request.js
26890
- import { randomUUID as randomUUID4 } from "node:crypto";
27123
+ import { randomUUID as randomUUID5 } from "node:crypto";
26891
27124
  var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
26892
27125
  var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
26893
27126
  var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
@@ -26919,7 +27152,7 @@ function buildApiParams(action, opts) {
26919
27152
  a: action.a,
26920
27153
  v: action.v,
26921
27154
  time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
26922
- requestId: opts.requestId ?? randomUUID4(),
27155
+ requestId: opts.requestId ?? randomUUID5(),
26923
27156
  sid: opts.session.sid,
26924
27157
  deviceId: opts.session.deviceId,
26925
27158
  chKey: opts.session.chKey,
@@ -28258,8 +28491,6 @@ var FcmRegistrar = class {
28258
28491
  };
28259
28492
 
28260
28493
  // dist/transport/push/store.js
28261
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, rmSync as rmSync2 } from "node:fs";
28262
- import { dirname as dirname2 } from "node:path";
28263
28494
  var MemoryFcmStore = class {
28264
28495
  p = null;
28265
28496
  load() {
@@ -28272,31 +28503,7 @@ var MemoryFcmStore = class {
28272
28503
  this.p = null;
28273
28504
  }
28274
28505
  };
28275
- var FileFcmStore = class {
28276
- path;
28277
- constructor(path) {
28278
- this.path = path;
28279
- }
28280
- load() {
28281
- try {
28282
- return JSON.parse(readFileSync2(this.path, "utf-8"));
28283
- } catch {
28284
- return null;
28285
- }
28286
- }
28287
- save(p) {
28288
- try {
28289
- mkdirSync2(dirname2(this.path), { recursive: true });
28290
- } catch {
28291
- }
28292
- writeFileSync2(this.path, JSON.stringify(p, null, 2), { mode: 384 });
28293
- }
28294
- clear() {
28295
- try {
28296
- rmSync2(this.path);
28297
- } catch {
28298
- }
28299
- }
28506
+ var FileFcmStore = class extends FileSessionStore {
28300
28507
  };
28301
28508
 
28302
28509
  // dist/transport/media-failure.js
@@ -28325,6 +28532,12 @@ var StoredImageCache = class {
28325
28532
  activeDevices = /* @__PURE__ */ new Set();
28326
28533
  activeDownloads = 0;
28327
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;
28328
28541
  constructor(downloader, logger, clock = Date.now, isLifecycleError = () => false) {
28329
28542
  this.downloader = downloader;
28330
28543
  this.logger = logger;
@@ -28380,6 +28593,18 @@ var StoredImageCache = class {
28380
28593
  return Promise.resolve(state.retained.image);
28381
28594
  return Promise.reject(new StoredSnapshotUnavailableError(reason, "No stored snapshot is available"));
28382
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
+ }
28383
28608
  /** Invalidate all retained and candidate state. */
28384
28609
  clear() {
28385
28610
  this.generation += 1;
@@ -28395,18 +28620,23 @@ var StoredImageCache = class {
28395
28620
  state.inFlight = candidate;
28396
28621
  this.activeDownloads += 1;
28397
28622
  this.activeDevices.add(candidate.deviceKey);
28398
- 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));
28399
28625
  }
28400
28626
  }
28401
- complete(candidate, image, error) {
28627
+ complete(candidate, lifecycle, image, error) {
28402
28628
  this.activeDownloads -= 1;
28403
28629
  this.activeDevices.delete(candidate.deviceKey);
28404
28630
  const state = this.devices.get(candidate.deviceKey);
28405
28631
  if (candidate.generation === this.generation && state?.inFlight === candidate) {
28406
28632
  state.inFlight = void 0;
28407
28633
  if (error !== void 0 && this.isLifecycleError(error)) {
28408
- state.lifecycleError = error;
28409
- 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
+ }
28410
28640
  } else if (image === void 0 && this.retryLater(state, candidate, error)) {
28411
28641
  state.lifecycleError = void 0;
28412
28642
  } else if (image === void 0) {
@@ -28898,8 +29128,8 @@ var DeviceRegistry = class {
28898
29128
  const waiters = this.stateWaiters.get(sn);
28899
29129
  if (waiters) {
28900
29130
  this.stateWaiters.delete(sn);
28901
- for (const resolve of waiters)
28902
- resolve();
29131
+ for (const resolve2 of waiters)
29132
+ resolve2();
28903
29133
  }
28904
29134
  }
28905
29135
  /**
@@ -28944,15 +29174,15 @@ var DeviceRegistry = class {
28944
29174
  awaitRealtimeState(sn, timeoutMs) {
28945
29175
  if (this.hasRealtimeState(sn) || timeoutMs <= 0)
28946
29176
  return Promise.resolve();
28947
- return new Promise((resolve) => {
29177
+ return new Promise((resolve2) => {
28948
29178
  const timer = setTimeout(() => {
28949
29179
  this.stateWaiters.get(sn)?.delete(done);
28950
- resolve();
29180
+ resolve2();
28951
29181
  }, timeoutMs);
28952
29182
  timer.unref?.();
28953
29183
  const done = () => {
28954
29184
  clearTimeout(timer);
28955
- resolve();
29185
+ resolve2();
28956
29186
  };
28957
29187
  const set = this.stateWaiters.get(sn) ?? /* @__PURE__ */ new Set();
28958
29188
  set.add(done);
@@ -29656,7 +29886,7 @@ var EufyMega = class extends EventEmitter14 {
29656
29886
  return true;
29657
29887
  const delay = Math.min(500, deadline - Date.now());
29658
29888
  if (delay > 0)
29659
- await new Promise((resolve) => setTimeout(resolve, delay));
29889
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
29660
29890
  if (refresh.expected !== void 0 && settled())
29661
29891
  return true;
29662
29892
  }
@@ -29705,7 +29935,7 @@ var EufyMega = class extends EventEmitter14 {
29705
29935
  * manager's own log, since by then nothing is waiting to receive it.
29706
29936
  */
29707
29937
  recycleStandaloneSession(sn) {
29708
- 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?.());
29709
29939
  return Promise.race([
29710
29940
  this.p2p.resetStandaloneSession(sn).finally(() => this.reopenWiredStation(sn)),
29711
29941
  releaseWrite.then(() => this.opts.logger?.debug(`[session ${sn}] recycle still waiting on an attached viewer \u2014 releasing the write that asked for it`))
@@ -29742,11 +29972,11 @@ var EufyMega = class extends EventEmitter14 {
29742
29972
  }
29743
29973
  /** Limit waiting on an unabortable dependency operation to the remaining semantic-event refresh window. */
29744
29974
  beforeDeadline(operation, timeoutMs) {
29745
- return new Promise((resolve, reject) => {
29975
+ return new Promise((resolve2, reject) => {
29746
29976
  const timeout = setTimeout(() => reject(new RefreshWindowClosedError("refresh window closed")), Math.max(0, timeoutMs));
29747
29977
  operation.then((value) => {
29748
29978
  clearTimeout(timeout);
29749
- resolve(value);
29979
+ resolve2(value);
29750
29980
  }, (error) => {
29751
29981
  clearTimeout(timeout);
29752
29982
  reject(error);
@@ -29851,12 +30081,16 @@ var EufyMega = class extends EventEmitter14 {
29851
30081
  /**
29852
30082
  * On a successful login, kick off auto-realtime (unless `autoRealtime:false`). Fire-and-forget so
29853
30083
  * `login()` returns as soon as the session is ready — realtime channels come up in the background and
29854
- * 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.
29855
30087
  */
29856
30088
  afterLogin(result) {
29857
30089
  if (result.status === LoginStatus.Ok) {
29858
30090
  if (this.storedImageAccount && this.storedImageAccount !== result.session.userId)
29859
30091
  this.storedImages?.clear();
30092
+ else
30093
+ this.storedImages?.clearLifecycleErrors();
29860
30094
  this.storedImageAccount = result.session.userId;
29861
30095
  const auth = this.mega.auth;
29862
30096
  if (auth?.userId)
@@ -29893,12 +30127,12 @@ var EufyMega = class extends EventEmitter14 {
29893
30127
  if (options.timeoutMs === void 0)
29894
30128
  return generation.promise;
29895
30129
  const timeoutMs = Math.max(0, options.timeoutMs);
29896
- return new Promise((resolve) => {
29897
- 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);
29898
30132
  timer.unref?.();
29899
30133
  void generation.promise.then((result) => {
29900
30134
  clearTimeout(timer);
29901
- resolve(result);
30135
+ resolve2(result);
29902
30136
  });
29903
30137
  });
29904
30138
  }
@@ -29939,10 +30173,10 @@ var EufyMega = class extends EventEmitter14 {
29939
30173
  let acknowledge;
29940
30174
  let reject;
29941
30175
  let acknowledged = false;
29942
- const acknowledgement = new Promise((resolve, rejectPromise) => {
30176
+ const acknowledgement = new Promise((resolve2, rejectPromise) => {
29943
30177
  acknowledge = () => {
29944
30178
  acknowledged = true;
29945
- resolve();
30179
+ resolve2();
29946
30180
  };
29947
30181
  reject = rejectPromise;
29948
30182
  });
@@ -30309,8 +30543,8 @@ var EufyMega = class extends EventEmitter14 {
30309
30543
  if (this.realtimeGeneration?.epoch === this.realtimeEpoch)
30310
30544
  return this.realtimeGeneration;
30311
30545
  const epoch = this.realtimeEpoch;
30312
- let resolve;
30313
- const promise = new Promise((done) => resolve = done);
30546
+ let resolve2;
30547
+ const promise = new Promise((done) => resolve2 = done);
30314
30548
  const generation = {
30315
30549
  epoch,
30316
30550
  readiness: {
@@ -30320,7 +30554,7 @@ var EufyMega = class extends EventEmitter14 {
30320
30554
  wiredP2p: createPlaneReadiness()
30321
30555
  },
30322
30556
  promise,
30323
- resolve,
30557
+ resolve: resolve2,
30324
30558
  abort: new AbortController(),
30325
30559
  settled: false
30326
30560
  };
@@ -30471,6 +30705,9 @@ var EufyMega = class extends EventEmitter14 {
30471
30705
  * device absent from the previous pass is skipped — first sight is discovery, not a transition;
30472
30706
  * {@link deviceState} answers an initial reading.
30473
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
+ *
30474
30711
  * Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
30475
30712
  * is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
30476
30713
  * stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
@@ -30489,8 +30726,8 @@ var EufyMega = class extends EventEmitter14 {
30489
30726
  this.emitSemantic(out.event, out.payload, { refresh: out.refresh });
30490
30727
  for (const dev of diff.reported)
30491
30728
  this.emit("deviceState", this.stateOf(dev));
30492
- for (const change of diff.params)
30493
- await this.widenCapabilities(change.deviceSn);
30729
+ for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
30730
+ await this.widenCapabilities(sn);
30494
30731
  } catch (e) {
30495
30732
  this.reportError(e);
30496
30733
  }
@@ -30876,31 +31113,72 @@ var EufyMega = class extends EventEmitter14 {
30876
31113
  return this.p2p.getSessions();
30877
31114
  }
30878
31115
  /**
30879
- * 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}.
30880
31118
  *
30881
31119
  * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
30882
31120
  * station, because they were never uploaded — the station holds them.
30883
31121
  *
30884
- * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
30885
- * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
30886
- * 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.
30887
31124
  *
30888
31125
  * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
30889
31126
  * returned as they arrive; a caller that wants only named people filters them.
30890
31127
  */
30891
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) {
30892
31172
  if (!this.registry.list().length)
30893
31173
  await this.getDevices();
30894
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`);
30895
31178
  const accountId = this.adminUserIdOf(station);
30896
31179
  if (!accountId)
30897
- throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
30898
- await this.p2p.ensureStation(station, opts.signal);
30899
- const session = this.p2p.getSessions().get(station);
30900
- if (!session)
30901
- throw new Error(`getStationFaces: no P2P session for ${station}`);
30902
- const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
30903
- 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 };
30904
31182
  }
30905
31183
  /** The station's own `admin_user_id`, which its database refuses the query without. */
30906
31184
  adminUserIdOf(stationSn) {
@@ -31127,6 +31405,9 @@ var EufyMega = class extends EventEmitter14 {
31127
31405
  * Returns the connected client rather than installing it. Registration can outlive a `disconnect()`,
31128
31406
  * and {@link ensureRealtime} owns the decision of whether a finished bring-up is still the current
31129
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.
31130
31411
  */
31131
31412
  async startPush(signal) {
31132
31413
  if (!this.mega.auth)
@@ -31173,9 +31454,13 @@ var EufyMega = class extends EventEmitter14 {
31173
31454
  if (dsn)
31174
31455
  this.prewarmForEvent(out.event, dsn);
31175
31456
  }
31176
- 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
+ }
31177
31462
  });
31178
- await new Promise((resolve, reject) => {
31463
+ await new Promise((resolve2, reject) => {
31179
31464
  const cleanup = () => {
31180
31465
  signal?.removeEventListener("abort", onAbort);
31181
31466
  client.off("connect", onConnect);
@@ -31183,7 +31468,7 @@ var EufyMega = class extends EventEmitter14 {
31183
31468
  };
31184
31469
  const onConnect = () => {
31185
31470
  cleanup();
31186
- resolve();
31471
+ resolve2();
31187
31472
  };
31188
31473
  const onError = (error) => {
31189
31474
  cleanup();