@mega-yfue/eufy-sdk 0.5.0-beta.44 → 0.5.0-beta.46

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.
@@ -711,6 +711,11 @@ export declare class EufyMega extends EventEmitter {
711
711
  * — an already-open session resolves immediately.
712
712
  */
713
713
  connectStation(deviceSn: string, signal?: AbortSignal): Promise<void>;
714
+ /**
715
+ * Keep a T9000 station's RTC command session open and warm, so a command fires without a cold reconnect.
716
+ * Answers the hold's disposer; throws for any other station, and while logged out.
717
+ */
718
+ keepStationWarm(stationSn: string): () => void;
714
719
  /** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
715
720
  * stations stay closed until an on-demand open. Best-effort per station. */
716
721
  private warmWiredP2P;
package/dist/index.js CHANGED
@@ -620,7 +620,8 @@ function scanEntropy(tail, subsampling, extraTables) {
620
620
  // dist/transport/http/decodeImageV2.js
621
621
  import { decode as jpegDecode } from "jpeg-js";
622
622
  var V2_PREFIX = "v2_eufysecurity:";
623
- var DC_CHROMA = Buffer.from([255, 196, 0, 31, 1]);
623
+ var DRI_SEGMENT = Buffer.from([255, 221, 0, 4]);
624
+ var SOS_HEADER = Buffer.from([255, 218, 0, 12, 3]);
624
625
  var ZIGZAG = [
625
626
  0,
626
627
  1,
@@ -821,6 +822,9 @@ var QUANT_CHROMA = [
821
822
  ];
822
823
  var DHT_DC_LUMA = Buffer.from("ffc4001f0000010501010101010100000000000000000102030405060708090a0b", "hex");
823
824
  var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9fa", "hex");
825
+ var DHT_DC_CHROMA = Buffer.from("ffc4001f0100030101010101010101010000000000000102030405060708090a0b", "hex");
826
+ var DC_CHROMA = DHT_DC_CHROMA.subarray(0, 5);
827
+ var DHT_AC_CHROMA = Buffer.from("ffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9fa", "hex");
824
828
  var SOI = Buffer.from([255, 216]);
825
829
  var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
826
830
  var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
@@ -973,16 +977,31 @@ function findGeometry(tail) {
973
977
  }
974
978
  return best;
975
979
  }
980
+ function findScanCut(data) {
981
+ const sos = data.lastIndexOf(SOS_HEADER);
982
+ if (sos < 0)
983
+ return -1;
984
+ const dri = sos - DRI_SEGMENT.length - 2;
985
+ return dri >= 0 && data.subarray(dri, dri + DRI_SEGMENT.length).equals(DRI_SEGMENT) ? dri : -1;
986
+ }
987
+ function plaintextTail(data) {
988
+ const cut = data.indexOf(DC_CHROMA);
989
+ if (cut >= 0)
990
+ return data.subarray(cut);
991
+ const scan = findScanCut(data);
992
+ if (scan < 0)
993
+ return null;
994
+ return Buffer.concat([DHT_DC_CHROMA, DHT_AC_CHROMA, data.subarray(scan)]);
995
+ }
976
996
  function isV2Image(data) {
977
997
  return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
978
998
  }
979
999
  function decodeImageV2(data) {
980
1000
  if (!isV2Image(data))
981
1001
  return null;
982
- const cut = data.indexOf(DC_CHROMA);
983
- if (cut < 0)
1002
+ const tail = plaintextTail(data);
1003
+ if (!tail)
984
1004
  return null;
985
- const tail = data.subarray(cut);
986
1005
  const geometry = findGeometry(tail);
987
1006
  if (!geometry)
988
1007
  return null;
@@ -22705,6 +22724,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
22705
22724
  // dist/transport/rtc/live.js
22706
22725
  import { EventEmitter as EventEmitter7 } from "node:events";
22707
22726
 
22727
+ // dist/transport/rtc/portal-keepalive.js
22728
+ var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
22729
+ var PORTAL_KEEPALIVE_MS = 29e3;
22730
+
22708
22731
  // dist/transport/rtc/portal-packet.js
22709
22732
  var MAGIC2 = Buffer.from("XZYH", "ascii");
22710
22733
  var PORTAL_HEADER_LENGTH = 16;
@@ -22814,8 +22837,6 @@ var LIVE_STOP = 1004;
22814
22837
  var LIVE_MEDIA = 1300;
22815
22838
  var LIVE_MEDIA_CHANNEL = 101;
22816
22839
  var LIVE_STREAM_ID = 1;
22817
- var KEEPALIVE_MS = 29e3;
22818
- var KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
22819
22840
  var MEDIA_HEADER_LENGTH = 22;
22820
22841
  var STREAM_TYPE_HEVC = 1;
22821
22842
  function parseMediaBody(body) {
@@ -22885,9 +22906,9 @@ var RtcLiveStream = class extends EventEmitter7 {
22885
22906
  return this;
22886
22907
  }
22887
22908
  this.keepalive = setInterval(() => {
22888
- if (!session.sendRaw(KEEPALIVE))
22909
+ if (!session.sendRaw(PORTAL_KEEPALIVE))
22889
22910
  this.opts.logger?.warn?.(`[rtc] ${this.tag} keepalive not sent`);
22890
- }, KEEPALIVE_MS);
22911
+ }, PORTAL_KEEPALIVE_MS);
22891
22912
  this.keepalive.unref?.();
22892
22913
  this.opts.logger?.debug?.(`[rtc] ${this.tag} live start sent`);
22893
22914
  return this;
@@ -23421,7 +23442,8 @@ var RtcPeer = class extends EventEmitter8 {
23421
23442
  }
23422
23443
  /**
23423
23444
  * Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
23424
- * message before that has nothing to reassemble into and is dropped.
23445
+ * message before that has nothing to reassemble into and is dropped. The hub's echo of the portal
23446
+ * keepalive on the command channel is unframed and surfaces as `keepaliveEcho` instead.
23425
23447
  */
23426
23448
  wireChannel(label, dc) {
23427
23449
  if (this.channels.has(label))
@@ -23459,6 +23481,10 @@ var RtcPeer = class extends EventEmitter8 {
23459
23481
  if (n <= 3 || n % 100 === 0) {
23460
23482
  this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
23461
23483
  }
23484
+ if (label === COMMAND_CHANNEL && buf.equals(PORTAL_KEEPALIVE)) {
23485
+ this.emit("keepaliveEcho");
23486
+ return;
23487
+ }
23462
23488
  this.framer?.recvPacket(buf);
23463
23489
  });
23464
23490
  }
@@ -23770,6 +23796,7 @@ var RtcSession = class extends EventEmitter10 {
23770
23796
  }
23771
23797
  });
23772
23798
  this.peer.on("error", (e) => this.emit("error", e));
23799
+ this.peer.on("keepaliveEcho", () => this.emit("keepaliveEcho"));
23773
23800
  this.peer.on("data", (frame, linkType) => {
23774
23801
  if (linkType === PortalLinkType.LIVE)
23775
23802
  this.emit("mediaData", frame);
@@ -23965,6 +23992,7 @@ var RtcCommandRouter = class {
23965
23992
  deps;
23966
23993
  sessions = /* @__PURE__ */ new Map();
23967
23994
  lives = /* @__PURE__ */ new Map();
23995
+ warm = /* @__PURE__ */ new Map();
23968
23996
  constructor(deps) {
23969
23997
  this.deps = deps;
23970
23998
  }
@@ -24214,6 +24242,96 @@ var RtcCommandRouter = class {
24214
24242
  Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
24215
24243
  });
24216
24244
  }
24245
+ /**
24246
+ * Hold a station's command session open: one hold per station, its idle-close ({@link IDLE_CLOSE_MS})
24247
+ * suppressed, {@link PORTAL_KEEPALIVE} sent every {@link PORTAL_KEEPALIVE_MS}, and the session dropped
24248
+ * on a tick that finds the previous keepalive unechoed. A closed session ends the hold.
24249
+ *
24250
+ * Answers an idempotent disposer that releases the hold. Throws when no account is logged in.
24251
+ */
24252
+ keepWarm(route) {
24253
+ const sn = route.stationSn;
24254
+ const existing = this.warm.get(sn);
24255
+ if (existing)
24256
+ return existing.dispose;
24257
+ const identity = this.deps.identity();
24258
+ if (!identity)
24259
+ throw new Error(`rtc: not logged in, cannot warm ${sn}`);
24260
+ const hold = {
24261
+ awaitingEcho: false,
24262
+ dispose: () => {
24263
+ if (this.warm.get(sn) !== hold)
24264
+ return;
24265
+ this.warm.delete(sn);
24266
+ const st = hold.watch?.st;
24267
+ this.unwatch(hold);
24268
+ if (st) {
24269
+ st.leases = Math.max(0, st.leases - 1);
24270
+ this.touch(sn, st);
24271
+ }
24272
+ }
24273
+ };
24274
+ this.warm.set(sn, hold);
24275
+ void this.warmUp(sn, route, identity, hold);
24276
+ return hold.dispose;
24277
+ }
24278
+ /**
24279
+ * Bring a station session up for a {@link keepWarm} hold and start its keepalive. A bring-up that fails,
24280
+ * or a hold disposed before it completes, clears the hold and arms no timer. Each tick first checks
24281
+ * that the hub echoed the previous keepalive, and drops the session when it did not. The keepalive
24282
+ * stops when the session closes, which also clears the hold so the next command or `keepWarm`
24283
+ * rebuilds cleanly.
24284
+ */
24285
+ async warmUp(sn, route, identity, hold) {
24286
+ const adminUserId = route.adminUserId || identity.userId;
24287
+ let st;
24288
+ try {
24289
+ st = await this.stationSession(route, adminUserId, identity);
24290
+ } catch (e) {
24291
+ if (this.warm.get(sn) === hold)
24292
+ this.warm.delete(sn);
24293
+ this.deps.onError?.(e instanceof Error ? e : new Error(String(e)));
24294
+ return;
24295
+ }
24296
+ if (this.warm.get(sn) !== hold) {
24297
+ this.touch(sn, st);
24298
+ return;
24299
+ }
24300
+ st.leases++;
24301
+ this.touch(sn, st);
24302
+ const onClose = () => {
24303
+ this.unwatch(hold);
24304
+ if (this.warm.get(sn) === hold)
24305
+ this.warm.delete(sn);
24306
+ };
24307
+ const onEcho = () => {
24308
+ hold.awaitingEcho = false;
24309
+ };
24310
+ hold.watch = { st, onClose, onEcho };
24311
+ st.session.once("close", onClose);
24312
+ st.session.on("keepaliveEcho", onEcho);
24313
+ hold.keepalive = setInterval(() => {
24314
+ if (hold.awaitingEcho) {
24315
+ this.deps.logger?.warn?.(`[rtc] ${sn} warm keepalive went unechoed, dropping the session`);
24316
+ this.drop(sn, st);
24317
+ return;
24318
+ }
24319
+ hold.awaitingEcho = true;
24320
+ st.session.sendRaw(PORTAL_KEEPALIVE);
24321
+ }, PORTAL_KEEPALIVE_MS);
24322
+ hold.keepalive.unref?.();
24323
+ }
24324
+ /** Stop a hold's keepalive and remove its listeners from the session it watches. */
24325
+ unwatch(hold) {
24326
+ if (hold.keepalive)
24327
+ clearInterval(hold.keepalive);
24328
+ hold.keepalive = void 0;
24329
+ if (hold.watch) {
24330
+ hold.watch.st.session.off("close", hold.watch.onClose);
24331
+ hold.watch.st.session.off("keepaliveEcho", hold.watch.onEcho);
24332
+ }
24333
+ hold.watch = void 0;
24334
+ }
24217
24335
  touch(sn, st) {
24218
24336
  if (st.idle)
24219
24337
  clearTimeout(st.idle);
@@ -28974,6 +29092,17 @@ var EufyMega = class extends EventEmitter13 {
28974
29092
  await this.getDevices();
28975
29093
  await this.p2p.ensureStation(this.p2p.stationKeyOf(deviceSn), signal);
28976
29094
  }
29095
+ /**
29096
+ * Keep a T9000 station's RTC command session open and warm, so a command fires without a cold reconnect.
29097
+ * Answers the hold's disposer; throws for any other station, and while logged out.
29098
+ */
29099
+ keepStationWarm(stationSn) {
29100
+ const { t9000 } = this.stationOf(stationSn);
29101
+ const route = this.rtcRouteFor(stationSn);
29102
+ if (!t9000 || !route)
29103
+ throw new Error(`rtc: ${stationSn} has no warm command session`);
29104
+ return this.rtc.keepWarm(route);
29105
+ }
28977
29106
  /** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
28978
29107
  * stations stay closed until an on-demand open. Best-effort per station. */
28979
29108
  async warmWiredP2P(readiness = createPlaneReadiness(), signal) {