@mega-yfue/eufy-sdk 0.5.0-beta.45 → 0.5.0-beta.47

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
@@ -22012,8 +22012,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
22012
22012
  tearDownStation(stationSn) {
22013
22013
  this.manager.remove(stationSn);
22014
22014
  for (const key of [...this.liveSources.keys()]) {
22015
- if (key.startsWith(`${stationSn}:`))
22015
+ const owner = this.liveSessionKeys.get(key);
22016
+ if (key.startsWith(`${stationSn}:`) && !(owner && _P2PCommandRouter.isMediaSessionKey(owner))) {
22016
22017
  this.dropLiveSource(key);
22018
+ }
22017
22019
  }
22018
22020
  for (const [key, talk] of this.talkbacks) {
22019
22021
  if (key.startsWith(`${stationSn}:`)) {
@@ -22724,6 +22726,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
22724
22726
  // dist/transport/rtc/live.js
22725
22727
  import { EventEmitter as EventEmitter7 } from "node:events";
22726
22728
 
22729
+ // dist/transport/rtc/portal-keepalive.js
22730
+ var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
22731
+ var PORTAL_KEEPALIVE_MS = 29e3;
22732
+
22727
22733
  // dist/transport/rtc/portal-packet.js
22728
22734
  var MAGIC2 = Buffer.from("XZYH", "ascii");
22729
22735
  var PORTAL_HEADER_LENGTH = 16;
@@ -22833,8 +22839,6 @@ var LIVE_STOP = 1004;
22833
22839
  var LIVE_MEDIA = 1300;
22834
22840
  var LIVE_MEDIA_CHANNEL = 101;
22835
22841
  var LIVE_STREAM_ID = 1;
22836
- var KEEPALIVE_MS = 29e3;
22837
- var KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
22838
22842
  var MEDIA_HEADER_LENGTH = 22;
22839
22843
  var STREAM_TYPE_HEVC = 1;
22840
22844
  function parseMediaBody(body) {
@@ -22904,9 +22908,9 @@ var RtcLiveStream = class extends EventEmitter7 {
22904
22908
  return this;
22905
22909
  }
22906
22910
  this.keepalive = setInterval(() => {
22907
- if (!session.sendRaw(KEEPALIVE))
22911
+ if (!session.sendRaw(PORTAL_KEEPALIVE))
22908
22912
  this.opts.logger?.warn?.(`[rtc] ${this.tag} keepalive not sent`);
22909
- }, KEEPALIVE_MS);
22913
+ }, PORTAL_KEEPALIVE_MS);
22910
22914
  this.keepalive.unref?.();
22911
22915
  this.opts.logger?.debug?.(`[rtc] ${this.tag} live start sent`);
22912
22916
  return this;
@@ -23440,7 +23444,8 @@ var RtcPeer = class extends EventEmitter8 {
23440
23444
  }
23441
23445
  /**
23442
23446
  * Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
23443
- * message before that has nothing to reassemble into and is dropped.
23447
+ * message before that has nothing to reassemble into and is dropped. The hub's echo of the portal
23448
+ * keepalive on the command channel is unframed and surfaces as `keepaliveEcho` instead.
23444
23449
  */
23445
23450
  wireChannel(label, dc) {
23446
23451
  if (this.channels.has(label))
@@ -23478,6 +23483,10 @@ var RtcPeer = class extends EventEmitter8 {
23478
23483
  if (n <= 3 || n % 100 === 0) {
23479
23484
  this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
23480
23485
  }
23486
+ if (label === COMMAND_CHANNEL && buf.equals(PORTAL_KEEPALIVE)) {
23487
+ this.emit("keepaliveEcho");
23488
+ return;
23489
+ }
23481
23490
  this.framer?.recvPacket(buf);
23482
23491
  });
23483
23492
  }
@@ -23789,6 +23798,7 @@ var RtcSession = class extends EventEmitter10 {
23789
23798
  }
23790
23799
  });
23791
23800
  this.peer.on("error", (e) => this.emit("error", e));
23801
+ this.peer.on("keepaliveEcho", () => this.emit("keepaliveEcho"));
23792
23802
  this.peer.on("data", (frame, linkType) => {
23793
23803
  if (linkType === PortalLinkType.LIVE)
23794
23804
  this.emit("mediaData", frame);
@@ -23984,6 +23994,7 @@ var RtcCommandRouter = class {
23984
23994
  deps;
23985
23995
  sessions = /* @__PURE__ */ new Map();
23986
23996
  lives = /* @__PURE__ */ new Map();
23997
+ warm = /* @__PURE__ */ new Map();
23987
23998
  constructor(deps) {
23988
23999
  this.deps = deps;
23989
24000
  }
@@ -24233,6 +24244,96 @@ var RtcCommandRouter = class {
24233
24244
  Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
24234
24245
  });
24235
24246
  }
24247
+ /**
24248
+ * Hold a station's command session open: one hold per station, its idle-close ({@link IDLE_CLOSE_MS})
24249
+ * suppressed, {@link PORTAL_KEEPALIVE} sent every {@link PORTAL_KEEPALIVE_MS}, and the session dropped
24250
+ * on a tick that finds the previous keepalive unechoed. A closed session ends the hold.
24251
+ *
24252
+ * Answers an idempotent disposer that releases the hold. Throws when no account is logged in.
24253
+ */
24254
+ keepWarm(route) {
24255
+ const sn = route.stationSn;
24256
+ const existing = this.warm.get(sn);
24257
+ if (existing)
24258
+ return existing.dispose;
24259
+ const identity = this.deps.identity();
24260
+ if (!identity)
24261
+ throw new Error(`rtc: not logged in, cannot warm ${sn}`);
24262
+ const hold = {
24263
+ awaitingEcho: false,
24264
+ dispose: () => {
24265
+ if (this.warm.get(sn) !== hold)
24266
+ return;
24267
+ this.warm.delete(sn);
24268
+ const st = hold.watch?.st;
24269
+ this.unwatch(hold);
24270
+ if (st) {
24271
+ st.leases = Math.max(0, st.leases - 1);
24272
+ this.touch(sn, st);
24273
+ }
24274
+ }
24275
+ };
24276
+ this.warm.set(sn, hold);
24277
+ void this.warmUp(sn, route, identity, hold);
24278
+ return hold.dispose;
24279
+ }
24280
+ /**
24281
+ * Bring a station session up for a {@link keepWarm} hold and start its keepalive. A bring-up that fails,
24282
+ * or a hold disposed before it completes, clears the hold and arms no timer. Each tick first checks
24283
+ * that the hub echoed the previous keepalive, and drops the session when it did not. The keepalive
24284
+ * stops when the session closes, which also clears the hold so the next command or `keepWarm`
24285
+ * rebuilds cleanly.
24286
+ */
24287
+ async warmUp(sn, route, identity, hold) {
24288
+ const adminUserId = route.adminUserId || identity.userId;
24289
+ let st;
24290
+ try {
24291
+ st = await this.stationSession(route, adminUserId, identity);
24292
+ } catch (e) {
24293
+ if (this.warm.get(sn) === hold)
24294
+ this.warm.delete(sn);
24295
+ this.deps.onError?.(e instanceof Error ? e : new Error(String(e)));
24296
+ return;
24297
+ }
24298
+ if (this.warm.get(sn) !== hold) {
24299
+ this.touch(sn, st);
24300
+ return;
24301
+ }
24302
+ st.leases++;
24303
+ this.touch(sn, st);
24304
+ const onClose = () => {
24305
+ this.unwatch(hold);
24306
+ if (this.warm.get(sn) === hold)
24307
+ this.warm.delete(sn);
24308
+ };
24309
+ const onEcho = () => {
24310
+ hold.awaitingEcho = false;
24311
+ };
24312
+ hold.watch = { st, onClose, onEcho };
24313
+ st.session.once("close", onClose);
24314
+ st.session.on("keepaliveEcho", onEcho);
24315
+ hold.keepalive = setInterval(() => {
24316
+ if (hold.awaitingEcho) {
24317
+ this.deps.logger?.warn?.(`[rtc] ${sn} warm keepalive went unechoed, dropping the session`);
24318
+ this.drop(sn, st);
24319
+ return;
24320
+ }
24321
+ hold.awaitingEcho = true;
24322
+ st.session.sendRaw(PORTAL_KEEPALIVE);
24323
+ }, PORTAL_KEEPALIVE_MS);
24324
+ hold.keepalive.unref?.();
24325
+ }
24326
+ /** Stop a hold's keepalive and remove its listeners from the session it watches. */
24327
+ unwatch(hold) {
24328
+ if (hold.keepalive)
24329
+ clearInterval(hold.keepalive);
24330
+ hold.keepalive = void 0;
24331
+ if (hold.watch) {
24332
+ hold.watch.st.session.off("close", hold.watch.onClose);
24333
+ hold.watch.st.session.off("keepaliveEcho", hold.watch.onEcho);
24334
+ }
24335
+ hold.watch = void 0;
24336
+ }
24236
24337
  touch(sn, st) {
24237
24338
  if (st.idle)
24238
24339
  clearTimeout(st.idle);
@@ -28993,6 +29094,17 @@ var EufyMega = class extends EventEmitter13 {
28993
29094
  await this.getDevices();
28994
29095
  await this.p2p.ensureStation(this.p2p.stationKeyOf(deviceSn), signal);
28995
29096
  }
29097
+ /**
29098
+ * Keep a T9000 station's RTC command session open and warm, so a command fires without a cold reconnect.
29099
+ * Answers the hold's disposer; throws for any other station, and while logged out.
29100
+ */
29101
+ keepStationWarm(stationSn) {
29102
+ const { t9000 } = this.stationOf(stationSn);
29103
+ const route = this.rtcRouteFor(stationSn);
29104
+ if (!t9000 || !route)
29105
+ throw new Error(`rtc: ${stationSn} has no warm command session`);
29106
+ return this.rtc.keepWarm(route);
29107
+ }
28996
29108
  /** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
28997
29109
  * stations stay closed until an on-demand open. Best-effort per station. */
28998
29110
  async warmWiredP2P(readiness = createPlaneReadiness(), signal) {