@mega-yfue/eufy-sdk 0.5.0-beta.26 → 0.5.0-beta.28

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
@@ -5,7 +5,7 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // dist/client/eufy-mega.js
8
- import { EventEmitter as EventEmitter12 } from "node:events";
8
+ import { EventEmitter as EventEmitter13 } from "node:events";
9
9
 
10
10
  // dist/transport/http/mega-client.js
11
11
  import { createHash as createHash3 } from "node:crypto";
@@ -5877,8 +5877,9 @@ function isStationAlarmOutput(ctx) {
5877
5877
  function isEvidencedStationAlarm(ctx) {
5878
5878
  return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
5879
5879
  }
5880
+ var S1_PRO_ALARM_MODELS = /* @__PURE__ */ new Set(["T8134"]);
5880
5881
  function isEvidencedAttachedCamera(ctx) {
5881
- return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx);
5882
+ return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx) && (!isStation9000({ deviceType: ctx.stationDeviceType }) || S1_PRO_ALARM_MODELS.has(ctx.model ?? ""));
5882
5883
  }
5883
5884
  function reportsParam(source, param) {
5884
5885
  return source.paramIds?.has(param) === true || Object.hasOwn(source.params ?? {}, param);
@@ -5897,7 +5898,18 @@ function homeBaseAlarm(seconds, ctx) {
5897
5898
  throw new Error("station-family HomeBase alarm output requires the acting account name");
5898
5899
  return setPayload(SIREN_CMD.HOMEBASE_TONE, { time_out: seconds, user_name: ctx.accountName }, ctx, 0, STATION_CHANNEL);
5899
5900
  }
5901
+ var PORTAL_MANUAL_ALARM_TYPE = 10;
5900
5902
  function cameraAlarm(seconds, ctx) {
5903
+ if (isStation9000({ deviceType: ctx.stationDeviceType })) {
5904
+ if (!ctx.accountName)
5905
+ throw new Error("a camera alarm behind a HomeBase S1 Pro requires the acting account name");
5906
+ return setPayload(SIREN_CMD.CAMERA_TONE, {
5907
+ time_out: seconds,
5908
+ type: PORTAL_MANUAL_ALARM_TYPE,
5909
+ channel: ctx.channel,
5910
+ username: ctx.accountName
5911
+ }, ctx);
5912
+ }
5901
5913
  return {
5902
5914
  kind: "p2p-int-string",
5903
5915
  cmd: SIREN_CMD.CAMERA_TONE,
@@ -14514,6 +14526,19 @@ function decodeMapFrame(frame, codec) {
14514
14526
  return READERS.get(frame.channelId)?.(frame.payload, codec);
14515
14527
  }
14516
14528
 
14529
+ // dist/transport/abortable.js
14530
+ function abortable(work, signal) {
14531
+ if (!signal)
14532
+ return work;
14533
+ signal.throwIfAborted();
14534
+ return Promise.race([
14535
+ work,
14536
+ new Promise((_resolve, reject) => {
14537
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
14538
+ })
14539
+ ]);
14540
+ }
14541
+
14517
14542
  // dist/transport/p2p/command-router.js
14518
14543
  import { setTimeout as sleep2 } from "node:timers/promises";
14519
14544
 
@@ -17860,6 +17885,18 @@ function startCodeBegin(buf, payloadOffset) {
17860
17885
  return threeByte - 1;
17861
17886
  return threeByte;
17862
17887
  }
17888
+ function hasIdr(buf, codec) {
17889
+ for (const p of nalStarts(buf)) {
17890
+ const b = buf[p];
17891
+ if (codec === "h265") {
17892
+ const t = b >> 1 & 63;
17893
+ if (t === 19 || t === 20)
17894
+ return true;
17895
+ } else if ((b & 31) === 5)
17896
+ return true;
17897
+ }
17898
+ return false;
17899
+ }
17863
17900
  var MAX_CODED_DIMENSION = 32768;
17864
17901
  var H264_CHROMA_PROFILES = /* @__PURE__ */ new Set([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135, 144]);
17865
17902
  var CHROMA_SUBSAMPLING = {
@@ -19630,6 +19667,14 @@ var SharedLiveSource = class {
19630
19667
  this.opts.onIdle?.();
19631
19668
  }
19632
19669
  };
19670
+ function attachUnlessAborted(source, signal) {
19671
+ const consumer = source.attach();
19672
+ if (signal?.aborted) {
19673
+ consumer.detach();
19674
+ signal.throwIfAborted();
19675
+ }
19676
+ return consumer;
19677
+ }
19633
19678
 
19634
19679
  // dist/transport/p2p/session-manager.js
19635
19680
  var SessionSupersededError = class extends Error {
@@ -20986,17 +21031,6 @@ function stationChannels(devices) {
20986
21031
 
20987
21032
  // dist/transport/p2p/command-router.js
20988
21033
  var DIRECT_CMD_SENDS = 5;
20989
- function abortable(work, signal) {
20990
- if (!signal)
20991
- return work;
20992
- signal.throwIfAborted();
20993
- return Promise.race([
20994
- work,
20995
- new Promise((_resolve, reject) => {
20996
- signal.addEventListener("abort", () => reject(signal.reason), { once: true });
20997
- })
20998
- ]);
20999
- }
21000
21034
  var LEVEL2_GRACE_MS = 25e3;
21001
21035
  var LEVEL2_SETTLE_MS = 8e3;
21002
21036
  var P2P_STATION_WAITS = {
@@ -21488,11 +21522,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
21488
21522
  },
21489
21523
  live: async (opts) => {
21490
21524
  const source = await this.sharedLiveSourceFor(sn, opts, true);
21491
- return this.attachUnlessAborted(source, opts?.signal);
21525
+ return attachUnlessAborted(source, opts?.signal);
21492
21526
  },
21493
21527
  openReadable: async (opts) => {
21494
21528
  const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
21495
- return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
21529
+ return openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts);
21496
21530
  },
21497
21531
  recordFragments: (opts) => this.recordFragments(sn, opts),
21498
21532
  talkback: (opts) => this.openTalkback(sn, opts),
@@ -21860,22 +21894,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
21860
21894
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
21861
21895
  * unaffected.
21862
21896
  */
21863
- /**
21864
- * Attach a consumer, unless the caller has already abandoned the call.
21865
- *
21866
- * The acquisition it just waited through can outlast the caller's interest, and a consumer attached for
21867
- * somebody who has gone keeps the pull warm for nobody. Detaching immediately gives the pull back, which
21868
- * lets it linger and fall away if this was the only thing holding it, and leaves it untouched if it was
21869
- * not.
21870
- */
21871
- attachUnlessAborted(source, signal) {
21872
- const consumer = source.attach();
21873
- if (signal?.aborted) {
21874
- consumer.detach();
21875
- signal.throwIfAborted();
21876
- }
21877
- return consumer;
21878
- }
21879
21897
  /**
21880
21898
  * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
21881
21899
  * on a session goes with it, and a media session opened for this source alone is closed: nothing else
@@ -22645,20 +22663,21 @@ var P2PCommandRouter = class _P2PCommandRouter {
22645
22663
  }
22646
22664
  };
22647
22665
 
22648
- // dist/transport/rtc/session.js
22649
- import { EventEmitter as EventEmitter9 } from "node:events";
22650
-
22651
- // dist/transport/rtc/peer.js
22666
+ // dist/transport/rtc/live.js
22652
22667
  import { EventEmitter as EventEmitter7 } from "node:events";
22653
22668
 
22654
22669
  // dist/transport/rtc/portal-packet.js
22655
22670
  var MAGIC2 = Buffer.from("XZYH", "ascii");
22656
22671
  var PORTAL_HEADER_LENGTH = 16;
22672
+ var PORTAL_CMD_SET_PAYLOAD = 1350;
22673
+ var PORTAL_STATION_CHANNEL = 255;
22657
22674
  var PortalLinkType = {
22658
22675
  /** Command channel: requests out, acknowledgements back. */
22659
22676
  COMMAND: 1,
22660
22677
  /** Station-originated frames: pushes, notify payloads, control results. */
22661
- NOTIFY: 3
22678
+ NOTIFY: 3,
22679
+ /** Live video. */
22680
+ LIVE: 5
22662
22681
  };
22663
22682
  function encodeLe(value, width) {
22664
22683
  const buf = Buffer.alloc(width);
@@ -22750,6 +22769,159 @@ var SegmentCounter = class {
22750
22769
  }
22751
22770
  };
22752
22771
 
22772
+ // dist/transport/rtc/live.js
22773
+ var LIVE_START = 1003;
22774
+ var LIVE_STOP = 1004;
22775
+ var LIVE_MEDIA = 1300;
22776
+ var LIVE_MEDIA_CHANNEL = 101;
22777
+ var LIVE_STREAM_ID = 1;
22778
+ var KEEPALIVE_MS = 29e3;
22779
+ var KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
22780
+ var MEDIA_HEADER_LENGTH = 22;
22781
+ var STREAM_TYPE_HEVC = 1;
22782
+ function parseMediaBody(body) {
22783
+ if (body.length < MEDIA_HEADER_LENGTH || body[5] !== STREAM_TYPE_HEVC)
22784
+ return void 0;
22785
+ const length = body.readUInt32LE(0);
22786
+ if (body.length < MEDIA_HEADER_LENGTH + length)
22787
+ return void 0;
22788
+ return {
22789
+ data: body.subarray(MEDIA_HEADER_LENGTH, MEDIA_HEADER_LENGTH + length),
22790
+ width: body.readUInt16LE(10),
22791
+ height: body.readUInt16LE(12)
22792
+ };
22793
+ }
22794
+ var RtcLiveStream = class extends EventEmitter7 {
22795
+ opts;
22796
+ running = false;
22797
+ startSegment = -1;
22798
+ keepalive;
22799
+ width = 0;
22800
+ height = 0;
22801
+ onMedia = (frame) => this.onMediaFrame(frame);
22802
+ onCommand = (frame, linkType) => this.onCommandFrame(frame, linkType);
22803
+ onClose = () => this.fail(new Error(`rtc live ${this.tag}: session closed`));
22804
+ constructor(opts) {
22805
+ super();
22806
+ this.opts = opts;
22807
+ }
22808
+ get tag() {
22809
+ return `${this.opts.stationSn}#${this.opts.channel}`;
22810
+ }
22811
+ start() {
22812
+ if (this.running)
22813
+ return this;
22814
+ const { session, seg, accountId } = this.opts;
22815
+ this.running = true;
22816
+ session.on("mediaData", this.onMedia);
22817
+ session.on("commandData", this.onCommand);
22818
+ session.on("close", this.onClose);
22819
+ this.startSegment = seg.next();
22820
+ const sent = session.sendCommand(buildPortalPacket({
22821
+ commandId: PORTAL_CMD_SET_PAYLOAD,
22822
+ channel: PORTAL_STATION_CHANNEL,
22823
+ segment: this.startSegment,
22824
+ isResponse: LIVE_STREAM_ID,
22825
+ payload: {
22826
+ account_id: accountId,
22827
+ cmd: LIVE_START,
22828
+ payload: {
22829
+ ClientOS: "WEB",
22830
+ entrytype: 0,
22831
+ camera_type: 0,
22832
+ key: "",
22833
+ msg_id: 116,
22834
+ audio_chn: -1,
22835
+ streamtype: 2,
22836
+ stitch_mode: 1,
22837
+ chn_list: [{ index: 0, chn: this.opts.channel, sensor: 0, isUps: 0, isClicked: true }],
22838
+ pip_cord: { x1: 0, y1: 0, x2: 0, y2: 0 },
22839
+ station_video_type: 6,
22840
+ play_id: 1
22841
+ }
22842
+ }
22843
+ }));
22844
+ if (!sent) {
22845
+ queueMicrotask(() => this.fail(new Error(`rtc live ${this.tag}: command channel not open`)));
22846
+ return this;
22847
+ }
22848
+ this.keepalive = setInterval(() => {
22849
+ if (!session.sendRaw(KEEPALIVE))
22850
+ this.opts.logger?.warn?.(`[rtc] ${this.tag} keepalive not sent`);
22851
+ }, KEEPALIVE_MS);
22852
+ this.keepalive.unref?.();
22853
+ this.opts.logger?.debug?.(`[rtc] ${this.tag} live start sent`);
22854
+ return this;
22855
+ }
22856
+ stop() {
22857
+ if (!this.running)
22858
+ return;
22859
+ this.teardown();
22860
+ const { session, seg, accountId } = this.opts;
22861
+ session.sendCommand(buildPortalPacket({
22862
+ commandId: PORTAL_CMD_SET_PAYLOAD,
22863
+ channel: PORTAL_STATION_CHANNEL,
22864
+ segment: seg.next(),
22865
+ isResponse: LIVE_STREAM_ID,
22866
+ payload: { account_id: accountId, cmd: LIVE_STOP, payload: {} }
22867
+ }));
22868
+ this.emit("stop");
22869
+ }
22870
+ teardown() {
22871
+ this.running = false;
22872
+ if (this.keepalive)
22873
+ clearInterval(this.keepalive);
22874
+ this.keepalive = void 0;
22875
+ const { session } = this.opts;
22876
+ session.off("mediaData", this.onMedia);
22877
+ session.off("commandData", this.onCommand);
22878
+ session.off("close", this.onClose);
22879
+ }
22880
+ fail(err) {
22881
+ if (!this.running)
22882
+ return;
22883
+ this.teardown();
22884
+ this.emit("error", err);
22885
+ }
22886
+ /** The start's ACK: a non-zero result code ends the pull. */
22887
+ onCommandFrame(frame, linkType) {
22888
+ const p = parsePortalPacket(frame, linkType);
22889
+ if (!p?.isResponse || p.segment !== this.startSegment || p.commandId !== PORTAL_CMD_SET_PAYLOAD)
22890
+ return;
22891
+ if (p.errCode !== 0)
22892
+ this.fail(new Error(`rtc live ${this.tag}: start refused (err ${p.errCode})`));
22893
+ }
22894
+ onMediaFrame(frame) {
22895
+ const h = parsePortalHeader(frame);
22896
+ if (!h || h.commandId !== LIVE_MEDIA || h.channel !== LIVE_MEDIA_CHANNEL)
22897
+ return;
22898
+ if (h.paramLength > frame.length - PORTAL_HEADER_LENGTH)
22899
+ return;
22900
+ const media = parseMediaBody(frame.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + h.paramLength));
22901
+ if (!media)
22902
+ return;
22903
+ const { data, width, height } = media;
22904
+ if (width && height) {
22905
+ this.width = width;
22906
+ this.height = height;
22907
+ }
22908
+ const out = {
22909
+ codec: "h265",
22910
+ data,
22911
+ keyframe: hasIdr(data, "h265"),
22912
+ width: this.width,
22913
+ height: this.height
22914
+ };
22915
+ this.emit("video", out);
22916
+ }
22917
+ };
22918
+
22919
+ // dist/transport/rtc/session.js
22920
+ import { EventEmitter as EventEmitter10 } from "node:events";
22921
+
22922
+ // dist/transport/rtc/peer.js
22923
+ import { EventEmitter as EventEmitter8 } from "node:events";
22924
+
22753
22925
  // dist/transport/rtc/ptcs-framer.js
22754
22926
  var MAGIC3 = Buffer.from("PTCS", "ascii");
22755
22927
  var PTCS_HEADER_LENGTH = 28;
@@ -22759,10 +22931,15 @@ var FLAG_LAST = 1024;
22759
22931
  var LENGTH_MASK = 1023;
22760
22932
  var PtcsChannel = {
22761
22933
  COMMAND: 0,
22762
- NOTIFY: 2
22934
+ NOTIFY: 2,
22935
+ LIVE: 5
22763
22936
  };
22764
22937
  function linkTypeForChannel(channel) {
22765
- return channel === PtcsChannel.NOTIFY ? PortalLinkType.NOTIFY : PortalLinkType.COMMAND;
22938
+ if (channel === PtcsChannel.NOTIFY)
22939
+ return PortalLinkType.NOTIFY;
22940
+ if (channel === PtcsChannel.LIVE)
22941
+ return PortalLinkType.LIVE;
22942
+ return PortalLinkType.COMMAND;
22766
22943
  }
22767
22944
  var PTCS_STALE_MS = 15e3;
22768
22945
  function parsePtcsHeader(packet) {
@@ -23028,7 +23205,7 @@ async function loadNativePeerFactory() {
23028
23205
  return (name, config) => new ndc.PeerConnection(name, config);
23029
23206
  }
23030
23207
  var ANSWER_TIMEOUT_MS = 15e3;
23031
- var RtcPeer = class extends EventEmitter7 {
23208
+ var RtcPeer = class extends EventEmitter8 {
23032
23209
  opts;
23033
23210
  pc;
23034
23211
  channels = /* @__PURE__ */ new Map();
@@ -23145,6 +23322,16 @@ var RtcPeer = class extends EventEmitter7 {
23145
23322
  }
23146
23323
  this.addNow(candidate);
23147
23324
  }
23325
+ /**
23326
+ * Send bytes on the command channel as they are, without PTCS framing; false when it isn't open. The
23327
+ * portal's data-channel keepalive rides the wire unframed.
23328
+ */
23329
+ sendRaw(bytes2) {
23330
+ const dc = this.channels.get(COMMAND_CHANNEL);
23331
+ if (!dc?.isOpen())
23332
+ return false;
23333
+ return dc.sendMessageBinary(bytes2);
23334
+ }
23148
23335
  /**
23149
23336
  * Send one portal packet on the command channel, through the framer. False when the channel is not
23150
23337
  * usable or the native send refused a wire packet, so a dropped frame does not read as success.
@@ -23251,7 +23438,7 @@ var RtcPeer = class extends EventEmitter7 {
23251
23438
  };
23252
23439
 
23253
23440
  // dist/transport/rtc/signaling.js
23254
- import { EventEmitter as EventEmitter8 } from "node:events";
23441
+ import { EventEmitter as EventEmitter9 } from "node:events";
23255
23442
  import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
23256
23443
  var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
23257
23444
  var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
@@ -23267,7 +23454,7 @@ var defaultSocket = (url, init) => new WebSocket(url, {
23267
23454
  protocols: init.protocols,
23268
23455
  headers: { Origin: init.origin }
23269
23456
  });
23270
- var RtcSignalingClient = class extends EventEmitter8 {
23457
+ var RtcSignalingClient = class extends EventEmitter9 {
23271
23458
  opts;
23272
23459
  ws;
23273
23460
  sign;
@@ -23492,7 +23679,7 @@ var RtcSignalingClient = class extends EventEmitter8 {
23492
23679
 
23493
23680
  // dist/transport/rtc/session.js
23494
23681
  var AUTH_TIMEOUT_MS = 15e3;
23495
- var RtcSession = class extends EventEmitter9 {
23682
+ var RtcSession = class extends EventEmitter10 {
23496
23683
  opts;
23497
23684
  signaling;
23498
23685
  peer;
@@ -23544,7 +23731,12 @@ var RtcSession = class extends EventEmitter9 {
23544
23731
  }
23545
23732
  });
23546
23733
  this.peer.on("error", (e) => this.emit("error", e));
23547
- this.peer.on("data", (frame, linkType) => this.emit("commandData", frame, linkType));
23734
+ this.peer.on("data", (frame, linkType) => {
23735
+ if (linkType === PortalLinkType.LIVE)
23736
+ this.emit("mediaData", frame);
23737
+ else
23738
+ this.emit("commandData", frame, linkType);
23739
+ });
23548
23740
  }
23549
23741
  get isConnected() {
23550
23742
  return this.connected;
@@ -23569,6 +23761,10 @@ var RtcSession = class extends EventEmitter9 {
23569
23761
  sendCommand(portalPacket) {
23570
23762
  return this.peer.sendCommand(portalPacket);
23571
23763
  }
23764
+ /** Send unframed bytes on the command channel; false when it isn't open. */
23765
+ sendRaw(bytes2) {
23766
+ return this.peer.sendRaw(bytes2);
23767
+ }
23572
23768
  /**
23573
23769
  * Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
23574
23770
  * `connect()` still waiting for the signalling auth is rejected and its timer stopped.
@@ -23715,16 +23911,21 @@ var RtcSession = class extends EventEmitter9 {
23715
23911
  };
23716
23912
 
23717
23913
  // dist/transport/rtc/command-router.js
23718
- var PORTAL_CMD_SET_PAYLOAD = 1350;
23719
23914
  var PORTAL_CMD_CONTROL_PAYLOAD = 1700;
23720
23915
  var PORTAL_CMD_NOTIFY_PAYLOAD = 1351;
23721
- var PORTAL_STATION_CHANNEL = 255;
23722
23916
  var ACK_TIMEOUT_MS = 8e3;
23723
23917
  var CONNECT_TIMEOUT_MS2 = 12e3;
23724
23918
  var IDLE_CLOSE_MS = 6e4;
23919
+ var StationStreamBusyError = class extends Error {
23920
+ constructor(stationSn, servingSn) {
23921
+ super(`rtc live: ${stationSn} is serving ${servingSn}; the station streams one camera at a time`);
23922
+ this.name = "StationStreamBusyError";
23923
+ }
23924
+ };
23725
23925
  var RtcCommandRouter = class {
23726
23926
  deps;
23727
23927
  sessions = /* @__PURE__ */ new Map();
23928
+ lives = /* @__PURE__ */ new Map();
23728
23929
  constructor(deps) {
23729
23930
  this.deps = deps;
23730
23931
  }
@@ -23748,11 +23949,112 @@ var RtcCommandRouter = class {
23748
23949
  st.queue = run.catch(() => void 0);
23749
23950
  return run;
23750
23951
  }
23751
- /** Tear down every station session (logout / shutdown). */
23952
+ /**
23953
+ * The media of a camera on a T9000: live video, a readable feed and a live still over the station's
23954
+ * session. Recording has no wire here and is refused; the optional members it has no wire for are absent.
23955
+ */
23956
+ mediaProviderFor(route) {
23957
+ return {
23958
+ live: (opts) => this.withLiveSource(route, opts ?? {}, (source) => attachUnlessAborted(source, opts?.signal)),
23959
+ openReadable: (opts) => this.withLiveSource(route, opts ?? {}, (source) => openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts)),
23960
+ snapshotLive: (opts) => this.withLiveSource(route, opts ?? {}, (source) => captureSnapshotFromShared(source, { ...opts, logger: this.deps.logger, ffmpegPath: this.deps.ffmpegPath })).catch((e) => {
23961
+ if (!(e instanceof StationStreamBusyError))
23962
+ throw e;
23963
+ throw new LiveSnapshotUnavailableError("source-failed", e.message, { cause: e });
23964
+ }),
23965
+ record: async () => {
23966
+ throw new Error(`record is not available for ${route.cameraSn} over the T9000 control channel`);
23967
+ }
23968
+ };
23969
+ }
23970
+ /**
23971
+ * Tear down every station session (logout / shutdown). Each live source is disposed before its session
23972
+ * closes, so its consumers end cleanly and its stop still rides the session.
23973
+ */
23752
23974
  close() {
23975
+ for (const [sn, live] of this.lives) {
23976
+ this.lives.delete(sn);
23977
+ live.source.dispose();
23978
+ }
23753
23979
  for (const [sn, st] of this.sessions)
23754
23980
  this.drop(sn, st);
23755
23981
  }
23982
+ /**
23983
+ * Hand `use` the station's live source for this camera, in the same turn the source is created or reused,
23984
+ * so a consumer it attaches is counted before any other caller looks.
23985
+ *
23986
+ * The station streams one camera at a time. Another camera whose source has consumers refuses this one,
23987
+ * before the session is awaited and again after; one with none (never attached or lingering)
23988
+ * holds nothing a viewer needs, so it is disposed and the slot taken. The first caller's
23989
+ * {@link SharedSourceHints} configure a new source.
23990
+ */
23991
+ async withLiveSource(route, opts, use) {
23992
+ opts.signal?.throwIfAborted();
23993
+ this.refuseIfWatched(route);
23994
+ const identity = this.deps.identity();
23995
+ if (!identity)
23996
+ throw new Error(`rtc: not logged in, cannot open ${route.stationSn}`);
23997
+ const accountId = route.adminUserId || identity.userId;
23998
+ const st = await abortable(this.stationSession(route, accountId, identity), opts.signal);
23999
+ this.refuseIfWatched(route);
24000
+ const current = this.lives.get(route.stationSn);
24001
+ if (current && current.cameraSn !== route.cameraSn) {
24002
+ this.deps.logger?.debug?.(`[rtc] ${route.stationSn} releasing the unwatched source of ${current.cameraSn} for ${route.cameraSn}`);
24003
+ this.lives.delete(route.stationSn);
24004
+ current.source.dispose();
24005
+ }
24006
+ let live = this.lives.get(route.stationSn);
24007
+ if (!live) {
24008
+ live = { cameraSn: route.cameraSn, source: this.newLiveSource(route, st, accountId, opts) };
24009
+ this.lives.set(route.stationSn, live);
24010
+ }
24011
+ return await use(live.source);
24012
+ }
24013
+ /** Refuse a camera while another camera on its station has consumers. */
24014
+ refuseIfWatched(route) {
24015
+ const current = this.lives.get(route.stationSn);
24016
+ if (current && current.cameraSn !== route.cameraSn && current.source.consumerCount > 0) {
24017
+ throw new StationStreamBusyError(route.stationSn, current.cameraSn);
24018
+ }
24019
+ }
24020
+ newLiveSource(route, st, accountId, hints) {
24021
+ let leased = false;
24022
+ const release = () => {
24023
+ if (!leased)
24024
+ return;
24025
+ leased = false;
24026
+ st.leases = Math.max(0, st.leases - 1);
24027
+ this.touch(route.stationSn, st);
24028
+ };
24029
+ const source = new SharedLiveSource({
24030
+ makeStream: () => new RtcLiveStream({
24031
+ session: st.session,
24032
+ seg: st.seg,
24033
+ stationSn: route.stationSn,
24034
+ channel: route.channel,
24035
+ accountId,
24036
+ logger: this.deps.logger
24037
+ }),
24038
+ label: `${route.stationSn}#${route.channel}`,
24039
+ logger: this.deps.logger,
24040
+ powered: hints.powered,
24041
+ preBufferSeconds: hints.preBufferSeconds,
24042
+ onActive: () => {
24043
+ if (leased)
24044
+ return;
24045
+ leased = true;
24046
+ st.leases++;
24047
+ if (st.idle)
24048
+ clearTimeout(st.idle);
24049
+ },
24050
+ onStopped: () => {
24051
+ release();
24052
+ if (this.lives.get(route.stationSn)?.source === source)
24053
+ this.lives.delete(route.stationSn);
24054
+ }
24055
+ });
24056
+ return source;
24057
+ }
23756
24058
  /**
23757
24059
  * Completes on the command ACK or a correlated control-result notification. The notification must
23758
24060
  * match the request channel, parameter and segment and contain a structured payload. Receipt does
@@ -23822,7 +24124,7 @@ var RtcCommandRouter = class {
23822
24124
  peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy }
23823
24125
  });
23824
24126
  const ready = this.bringUp(sn, session);
23825
- const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve() };
24127
+ const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve(), leases: 0 };
23826
24128
  session.on("error", (e) => this.deps.onError?.(e));
23827
24129
  session.on("close", () => {
23828
24130
  if (this.sessions.get(sn) === st)
@@ -23876,6 +24178,8 @@ var RtcCommandRouter = class {
23876
24178
  touch(sn, st) {
23877
24179
  if (st.idle)
23878
24180
  clearTimeout(st.idle);
24181
+ if (st.leases > 0)
24182
+ return;
23879
24183
  st.idle = setTimeout(() => this.drop(sn, st), IDLE_CLOSE_MS);
23880
24184
  st.idle.unref?.();
23881
24185
  }
@@ -25505,7 +25809,7 @@ async function resolveLightEffect(mega, lightId) {
25505
25809
  }
25506
25810
 
25507
25811
  // dist/transport/push/push-client.js
25508
- import { EventEmitter as EventEmitter11 } from "node:events";
25812
+ import { EventEmitter as EventEmitter12 } from "node:events";
25509
25813
  import tls2 from "node:tls";
25510
25814
 
25511
25815
  // dist/transport/push/proto.js
@@ -25760,7 +26064,7 @@ var MessageTag;
25760
26064
  })(MessageTag || (MessageTag = {}));
25761
26065
 
25762
26066
  // dist/transport/push/parser.js
25763
- import { EventEmitter as EventEmitter10 } from "node:events";
26067
+ import { EventEmitter as EventEmitter11 } from "node:events";
25764
26068
  var State;
25765
26069
  (function(State2) {
25766
26070
  State2[State2["VERSION_TAG_AND_SIZE"] = 0] = "VERSION_TAG_AND_SIZE";
@@ -25778,7 +26082,7 @@ var TAG_TO_TYPE = {
25778
26082
  [MessageTag.DataMessageStanza]: "mcs_proto.DataMessageStanza",
25779
26083
  [MessageTag.StreamErrorStanza]: "mcs_proto.StreamErrorStanza"
25780
26084
  };
25781
- var McsParser = class extends EventEmitter10 {
26085
+ var McsParser = class extends EventEmitter11 {
25782
26086
  data = Buffer.alloc(0);
25783
26087
  state = State.VERSION_TAG_AND_SIZE;
25784
26088
  messageTag = 0;
@@ -25956,7 +26260,7 @@ function normalizePushEvent(raw) {
25956
26260
  raw
25957
26261
  };
25958
26262
  }
25959
- var PushClient = class _PushClient extends EventEmitter11 {
26263
+ var PushClient = class _PushClient extends EventEmitter12 {
25960
26264
  creds;
25961
26265
  logger;
25962
26266
  /** Consecutive MCS login rejections tolerated (self-healing propagation) before surfacing an error. */
@@ -27143,6 +27447,8 @@ function tuyaDevIdFrom(raw) {
27143
27447
  var DEFAULT_POLL_MS = 6e5;
27144
27448
  var DEFAULT_PREWARM_TIERS = ["wired", "battery"];
27145
27449
  var SESSION_RECYCLE_WAIT_MS = 5e3;
27450
+ var PUSH_RETRY_MIN_MS = 3e4;
27451
+ var PUSH_RETRY_MAX_MS = 6e5;
27146
27452
  var RealtimeStartupSupersededError = class extends Error {
27147
27453
  };
27148
27454
  var RefreshWindowClosedError = class extends Error {
@@ -27158,7 +27464,7 @@ function readinessSnapshot(readiness, state = readiness.state) {
27158
27464
  wiredP2p: Object.freeze({ ...readiness.wiredP2p })
27159
27465
  });
27160
27466
  }
27161
- var EufyMega = class extends EventEmitter12 {
27467
+ var EufyMega = class extends EventEmitter13 {
27162
27468
  opts;
27163
27469
  mega;
27164
27470
  /**
@@ -27241,6 +27547,8 @@ var EufyMega = class extends EventEmitter12 {
27241
27547
  mapStores = /* @__PURE__ */ new Map();
27242
27548
  /** Re-armed after each cloud-param poll; cancelled by {@link disconnect}. */
27243
27549
  pollTimer = new Timer();
27550
+ /** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
27551
+ pushRetryTimer = new Timer();
27244
27552
  constructor(opts) {
27245
27553
  super();
27246
27554
  this.opts = opts;
@@ -27285,6 +27593,7 @@ var EufyMega = class extends EventEmitter12 {
27285
27593
  shard: () => this.mega.rtcShard,
27286
27594
  country: opts.countryCode,
27287
27595
  logger: opts.logger,
27596
+ ffmpegPath: opts.ffmpegPath,
27288
27597
  onError: (e) => this.reportError(e)
27289
27598
  });
27290
27599
  this.mqtt = new MqttCommandRouter({
@@ -27986,6 +28295,28 @@ var EufyMega = class extends EventEmitter12 {
27986
28295
  }
27987
28296
  };
27988
28297
  }
28298
+ /**
28299
+ * The station a device's commands go through: the device itself, or the HomeBase the registry links it
28300
+ * to. Answers the target record, the station record and serial, the station's `device_type`, and the
28301
+ * `t9000` / `t8n00` family flags.
28302
+ */
28303
+ stationOf(sn) {
28304
+ const devices = this.registry.list();
28305
+ const target = devices.find((d) => d.sn === sn);
28306
+ const stationSn = target?.stationSn || sn;
28307
+ const station = devices.find((d) => d.sn === stationSn);
28308
+ const raw = station?.raw ?? {};
28309
+ const deviceType = typeof raw.device_type === "number" ? raw.device_type : void 0;
28310
+ const family = { deviceType, model: station?.model };
28311
+ return { target, station, stationSn, raw, deviceType, t9000: isStation9000(family), t8n00: isNvrT8N00(family) };
28312
+ }
28313
+ /** The vendor DeviceType of the station a device hangs off; `undefined` for a station itself. */
28314
+ stationDeviceTypeOf(sn) {
28315
+ const st = this.stationOf(sn);
28316
+ if (!st.station || st.stationSn === sn)
28317
+ return void 0;
28318
+ return st.deviceType;
28319
+ }
27989
28320
  /**
27990
28321
  * Combine explicit P2P media with the optional passive push-thumbnail provider.
27991
28322
  *
@@ -27996,7 +28327,7 @@ var EufyMega = class extends EventEmitter12 {
27996
28327
  * current. With nothing retained the refusal stands.
27997
28328
  */
27998
28329
  mediaProviderFor(sn) {
27999
- const media = this.p2p.mediaProviderFor(sn);
28330
+ const media = this.rtcMediaFor(sn) ?? this.p2p.mediaProviderFor(sn);
28000
28331
  const cache = this.storedImages;
28001
28332
  if (!cache)
28002
28333
  return media;
@@ -28056,25 +28387,42 @@ var EufyMega = class extends EventEmitter12 {
28056
28387
  return this.tuya.dispatchCommand(sn, cmd);
28057
28388
  return this.mqtt.dispatchCommand(sn, cmd);
28058
28389
  }
28059
- const devices = this.registry.list();
28060
- const target = devices.find((d) => d.sn === sn);
28061
- const stationSn = target?.stationSn || sn;
28062
- const station = devices.find((d) => d.sn === stationSn);
28063
- const stationRaw = station?.raw ?? {};
28064
- const deviceType = typeof stationRaw.device_type === "number" ? stationRaw.device_type : void 0;
28065
- const family = { deviceType, model: station?.model };
28066
- const native = isNvrT8N00(family);
28067
- if (target && station && (isStation9000(family) || native)) {
28068
- const attached = stationSn !== sn;
28069
- if (attached && this.registry.serialForFrame(stationSn, cmd.channel) !== sn)
28390
+ const route = this.rtcRouteFor(sn);
28391
+ if (route) {
28392
+ if (route.attached && this.registry.serialForFrame(route.stationSn, cmd.channel) !== sn)
28070
28393
  return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
28071
- const member = stationRaw.member?.admin_user_id;
28072
- const adminUserId = typeof member === "string" && member ? member : void 0;
28073
- const base = { stationSn, adminUserId, attached };
28074
- return this.rtc.dispatchCommand(native ? { ...base, signalingMode: "call", iceTransportPolicy: "all" } : base, cmd);
28394
+ return this.rtc.dispatchCommand(route, cmd);
28075
28395
  }
28076
28396
  return this.p2p.dispatchCommand(sn, cmd);
28077
28397
  }
28398
+ /**
28399
+ * The RTC route for a T9000 or T8N00 station or a device attached to one, decided by the station's model
28400
+ * classification; `undefined` for any other device. A T8N00 route carries its native `call` signalling. The admin id is the station's
28401
+ * `member.admin_user_id`, else the logged-in user.
28402
+ */
28403
+ rtcRouteFor(sn) {
28404
+ const { target, station, stationSn, raw, t9000, t8n00: native } = this.stationOf(sn);
28405
+ if (!target || !station || !(t9000 || native))
28406
+ return void 0;
28407
+ const member = raw.member?.admin_user_id;
28408
+ const adminUserId = typeof member === "string" && member ? member : void 0;
28409
+ const base = { stationSn, adminUserId, attached: stationSn !== sn };
28410
+ return native ? { ...base, signalingMode: "call", iceTransportPolicy: "all" } : base;
28411
+ }
28412
+ /**
28413
+ * The RTC media provider for a camera attached to a T9000 whose `device_channel` resolves back to it on
28414
+ * the station; `undefined` otherwise, including a camera behind a T8N00, whose route carries commands only.
28415
+ */
28416
+ rtcMediaFor(sn) {
28417
+ const route = this.rtcRouteFor(sn);
28418
+ if (!route?.attached || route.signalingMode === "call")
28419
+ return void 0;
28420
+ const raw = this.registry.list().find((d) => d.sn === sn)?.raw ?? {};
28421
+ const channel = typeof raw.device_channel === "number" ? raw.device_channel : void 0;
28422
+ if (channel === void 0 || this.registry.serialForFrame(route.stationSn, channel) !== sn)
28423
+ return void 0;
28424
+ return this.rtc.mediaProviderFor({ ...route, cameraSn: sn, channel });
28425
+ }
28078
28426
  /**
28079
28427
  * List + classify devices across all houses (mega API). Each device is tagged with its API backend
28080
28428
  * + realtime transport. Camera/HomeBase records still appear here for inventory; driving them is
@@ -28307,13 +28655,38 @@ var EufyMega = class extends EventEmitter12 {
28307
28655
  this.schedulePoll();
28308
28656
  const failures = readiness.push.failed + readiness.mqtt.failed + readiness.wiredP2p.failed;
28309
28657
  this.settleRealtimeGeneration(generation, failures || startupFailed ? "partial" : "ready");
28658
+ if (readiness.push.failed)
28659
+ this.retryPush(generation);
28310
28660
  } catch (e) {
28311
28661
  this.reportError(e);
28312
28662
  readiness.push.failed += readiness.push.pending;
28313
28663
  readiness.push.pending = 0;
28314
28664
  this.settleRealtimeGeneration(generation, epoch === this.realtimeEpoch ? "partial" : "superseded");
28665
+ if (epoch === this.realtimeEpoch)
28666
+ this.retryPush(generation);
28315
28667
  }
28316
28668
  }
28669
+ /**
28670
+ * Bring push up again after the generation's startup attempt failed, backing off between attempts
28671
+ * until one connects, the generation is superseded, or it is aborted by {@link disconnect}.
28672
+ *
28673
+ * The MCS socket reconnects on its own only once it has connected. The settled
28674
+ * {@link RealtimeReadiness} is a startup snapshot and is not revised when a retry connects.
28675
+ */
28676
+ retryPush(generation, delayMs = PUSH_RETRY_MIN_MS) {
28677
+ this.pushRetryTimer.arm(delayMs, () => {
28678
+ this.startPush(generation.abort.signal).then((client) => {
28679
+ if (generation.epoch !== this.realtimeEpoch)
28680
+ return client.close();
28681
+ this.pushClient = client;
28682
+ }, (error) => {
28683
+ if (generation.epoch !== this.realtimeEpoch)
28684
+ return;
28685
+ this.reportError(error);
28686
+ this.retryPush(generation, Math.min(delayMs * 2, PUSH_RETRY_MAX_MS));
28687
+ });
28688
+ });
28689
+ }
28317
28690
  /** Resolve a generation with an immutable count snapshot; later transport completions are ignored. */
28318
28691
  settleRealtimeGeneration(generation, state) {
28319
28692
  if (generation.settled)
@@ -28995,6 +29368,7 @@ var EufyMega = class extends EventEmitter12 {
28995
29368
  hasP2p: P2PCommandRouter.claimsDevice(dev),
28996
29369
  // Topology as the record states it: a parent that isn't the device itself means HomeBase-attached.
28997
29370
  homeBaseAttached: !!raw.parent_sn && raw.parent_sn !== dev.sn,
29371
+ stationDeviceType: this.stationDeviceTypeOf(sn),
28998
29372
  dpCatalog
28999
29373
  };
29000
29374
  }
@@ -29169,6 +29543,7 @@ var EufyMega = class extends EventEmitter12 {
29169
29543
  */
29170
29544
  async teardownRealtime() {
29171
29545
  this.pollTimer.cancel();
29546
+ this.pushRetryTimer.cancel();
29172
29547
  this.lastStateAnnounced.clear();
29173
29548
  await this.closeMqttTransports();
29174
29549
  this.rtc.close();
@@ -29676,7 +30051,7 @@ var SolixClient = class _SolixClient {
29676
30051
  };
29677
30052
 
29678
30053
  // dist/transport/mqtt/solix-mqtt.js
29679
- import { EventEmitter as EventEmitter13 } from "node:events";
30054
+ import { EventEmitter as EventEmitter14 } from "node:events";
29680
30055
  var SOLIX_METER_FIELD_NAMES = {
29681
30056
  168: "meterPowerL1",
29682
30057
  // Anker Modbus: primary_phase_1_active_power
@@ -29854,7 +30229,7 @@ function addSolarbankScalars(frame, out) {
29854
30229
  out.gridExportLimit = df.readUInt16LE(5);
29855
30230
  }
29856
30231
  }
29857
- var SolixMqtt = class extends EventEmitter13 {
30232
+ var SolixMqtt = class extends EventEmitter14 {
29858
30233
  transport;
29859
30234
  appName;
29860
30235
  userId;