@mega-yfue/eufy-sdk 0.6.0-beta.31 → 0.6.0-beta.33

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
@@ -1609,11 +1609,17 @@ var CONTENDED_SESSION_HINT = "the token was rejected again soon after it was las
1609
1609
  function tokenRejected(code, msg) {
1610
1610
  return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1611
1611
  }
1612
+ function shardOfDomain(domain) {
1613
+ const m = /-([a-z]{2})(?:[.-]|$)/i.exec(domain);
1614
+ return m ? `${m[1].toLowerCase()}-pr` : void 0;
1615
+ }
1612
1616
  var MegaHttpClient = class {
1613
1617
  cfg;
1614
1618
  region;
1615
1619
  /** The host `estimate_domain` answered, persisted with the session. */
1616
1620
  estimatedDomain = "";
1621
+ /** The in-flight (then settled, so cached) RTC-sign shard request from {@link portalShard}. */
1622
+ portalShardInFlight;
1617
1623
  bootstrapDomain;
1618
1624
  sessionKey;
1619
1625
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
@@ -1730,13 +1736,36 @@ var MegaHttpClient = class {
1730
1736
  return this.region;
1731
1737
  }
1732
1738
  /**
1733
- * The shard the RTC signalling signs on: the regional prefix of the estimated domain
1734
- * (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
1735
- * like `ie-pr`, whose accounts need the IE sign host and cluster.
1739
+ * The shard the estimated domain names (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The
1740
+ * eu/us classification cannot name shards like `ie-pr`.
1736
1741
  */
1737
1742
  get rtcShard() {
1738
- const m = /-([a-z]{2})(?:[.-]|$)/i.exec(this.estimatedDomain);
1739
- return m ? `${m[1].toLowerCase()}-pr` : this.region;
1743
+ return shardOfDomain(this.estimatedDomain) ?? this.region;
1744
+ }
1745
+ /**
1746
+ * The shard the RTC signalling signs on, from `estimate_domain` asked with the portal's WEB identity
1747
+ * (`Model-Type: WEB`, `App-Name: eufy_mega`) — the identity the sign itself is sent with, which can name
1748
+ * a different shard than the PHONE identity does for one account. The in-flight request is shared by
1749
+ * concurrent first callers, and once it settles to a shard the resolved promise is the cache; a call
1750
+ * that fails or names no shard answers {@link rtcShard}, clears the field and is asked again next time.
1751
+ */
1752
+ async portalShard() {
1753
+ return this.portalShardInFlight ??= this.fetchPortalShard();
1754
+ }
1755
+ async fetchPortalShard() {
1756
+ try {
1757
+ const res = await this.estimateDomainRequest({ "Model-Type": "WEB", "App-Name": "eufy_mega" });
1758
+ const domain = res.data?.data?.domain;
1759
+ const shard = typeof domain === "string" ? shardOfDomain(domain) : void 0;
1760
+ if (shard) {
1761
+ this.logger.debug("[mega] portal shard:", shard);
1762
+ return shard;
1763
+ }
1764
+ } catch (e) {
1765
+ this.logger.debug("[mega] portal estimate_domain failed:", e instanceof Error ? e.message : String(e));
1766
+ }
1767
+ this.portalShardInFlight = void 0;
1768
+ return this.rtcShard;
1740
1769
  }
1741
1770
  /**
1742
1771
  * The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
@@ -1833,13 +1862,13 @@ var MegaHttpClient = class {
1833
1862
  });
1834
1863
  return { status: res.status, data: parseMaybeJson(await res.text()) };
1835
1864
  }
1865
+ /** POST `estimate_domain` with the given identity headers. */
1866
+ estimateDomainRequest(headers) {
1867
+ return this.httpPost(`https://mega-${this.region}.eufy.com/passport/estimate_domain`, { ab: this.cfg.countryCode.toLowerCase(), mode: 1 }, { ...headers, "content-type": "application/json" });
1868
+ }
1836
1869
  /** Step 1: discover the account's region shard (and the bootstrap host). */
1837
1870
  async estimateDomain() {
1838
- const url = `https://mega-${this.region}.eufy.com/passport/estimate_domain`;
1839
- const res = await this.httpPost(url, { ab: this.cfg.countryCode.toLowerCase(), mode: 1 }, {
1840
- ...this.baseHeaders(),
1841
- "content-type": "application/json"
1842
- });
1871
+ const res = await this.estimateDomainRequest(this.baseHeaders());
1843
1872
  this.logger.debug("[mega] estimate_domain raw:", JSON.stringify(res.data).slice(0, 400));
1844
1873
  const env = res.data;
1845
1874
  const d = env?.data ?? {};
@@ -5332,7 +5361,7 @@ var AUDIO_MEMBERS = {
5332
5361
  kind: "percent",
5333
5362
  writeOnly: true,
5334
5363
  provenance: "verified",
5335
- available: (ctx) => hasCapability(ctx, "doorbell"),
5364
+ available: (ctx) => hasCapability(ctx, "doorbell") && !isCallDoorbell(ctx),
5336
5365
  min: 0,
5337
5366
  max: 100,
5338
5367
  description: "Doorbell ring/chime volume 0..100 (1708 DOORBELL_RINGTONE_VOLUME). Wire verified live on T8214.",
@@ -5390,8 +5419,11 @@ var DOORBELL_TYPES = [
5390
5419
  DeviceType.BATTERY_DOORBELL_2,
5391
5420
  DeviceType.BATTERY_DOORBELL_PLUS,
5392
5421
  DeviceType.DOORBELL_SOLO,
5393
- DeviceType.BATTERY_DOORBELL_PLUS_E340
5422
+ DeviceType.BATTERY_DOORBELL_PLUS_E340,
5423
+ DeviceType.BATTERY_DOORBELL_C30,
5424
+ DeviceType.BATTERY_DOORBELL_C31
5394
5425
  ];
5426
+ var writable = (ctx) => !isCallDoorbell(ctx);
5395
5427
  var DOORBELL_CMD = {
5396
5428
  /**
5397
5429
  * Doorbell "quick response" — make the doorbell PLAY a stored voice reply. Same 136-byte
@@ -5528,6 +5560,7 @@ var DOORBELL_MEMBERS = {
5528
5560
  */
5529
5561
  chimeSwitch: {
5530
5562
  param: DOORBELL_CMD.CHIME_SWITCH,
5563
+ available: writable,
5531
5564
  type: "bool",
5532
5565
  kind: "boolean",
5533
5566
  provenance: "verified",
@@ -5540,6 +5573,7 @@ var DOORBELL_MEMBERS = {
5540
5573
  */
5541
5574
  mechanicalChimeSwitch: {
5542
5575
  param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
5576
+ available: writable,
5543
5577
  type: "bool",
5544
5578
  kind: "boolean",
5545
5579
  provenance: "verified",
@@ -5549,6 +5583,7 @@ var DOORBELL_MEMBERS = {
5549
5583
  /** Same reasoning and the same capture session as {@link DOORBELL_MEMBERS.mechanicalChimeSwitch}. */
5550
5584
  wdrSwitch: {
5551
5585
  param: DOORBELL_CMD.WDR_SWITCH,
5586
+ available: writable,
5552
5587
  type: "bool",
5553
5588
  kind: "boolean",
5554
5589
  provenance: "verified",
@@ -5579,6 +5614,7 @@ var DOORBELL_MEMBERS = {
5579
5614
  */
5580
5615
  dingdongVolume: {
5581
5616
  param: DOORBELL_CMD.DINGDONG_VOLUME,
5617
+ available: writable,
5582
5618
  type: "number",
5583
5619
  unit: "%",
5584
5620
  kind: "percent",
@@ -5596,6 +5632,7 @@ var DOORBELL_MEMBERS = {
5596
5632
  */
5597
5633
  dingdongRingtone: {
5598
5634
  param: DOORBELL_CMD.DINGDONG_RINGTONE,
5635
+ available: writable,
5599
5636
  type: "number",
5600
5637
  kind: "enum",
5601
5638
  enumValues: enumLabels(DoorbellRingtone),
@@ -5661,7 +5698,7 @@ var DOORBELL_MEMBERS = {
5661
5698
  throw new Error(`videoQuality: ${String(quality)} is not one of ${enumLabels(DoorbellVideoQuality)}`);
5662
5699
  }
5663
5700
  await sink.dispatch(setScalar(DOORBELL_CMD.VIDEO_QUALITY, q + (asBool(highCompression) ? 5 : 0), ctx, "direct-binary"));
5664
- }, "Set the live-view quality and the video encoding format, which share one wire."),
5701
+ }, "Set the live-view quality and the video encoding format, which share one wire.", writable),
5665
5702
  /**
5666
5703
  * A config blob the device reports WHOLE, so it is typed `string` and handed back unparsed — three
5667
5704
  * settings live inside it (motion notifications, ring notifications, notification style) and the SDK
@@ -5709,7 +5746,7 @@ var DOORBELL_MEMBERS = {
5709
5746
  } catch {
5710
5747
  }
5711
5748
  }
5712
- }, "Play a quick response at the visitor."),
5749
+ }, "Play a quick response at the visitor.", writable),
5713
5750
  /**
5714
5751
  * Fetch the doorbell's quick-response list — a P2P request/reply query that returns data, so it
5715
5752
  * exists only on a device bound to a media provider that offers one.
@@ -6019,8 +6056,9 @@ function powerCommand(on, ctx) {
6019
6056
  function privacyCommand(enabled, channel) {
6020
6057
  return { kind: "p2p-privacy-burst", enabled, channel };
6021
6058
  }
6059
+ var doorbellLed = (ctx) => hasCapability(ctx, "doorbell") && !isCallDoorbell(ctx);
6022
6060
  function statusLedCommand(on, ctx) {
6023
- if (hasCapability(ctx, "doorbell"))
6061
+ if (doorbellLed(ctx))
6024
6062
  return setPayload(CAMERA_CMD.DOORBELL_LED, { light_enable: on ? 1 : 0 }, ctx);
6025
6063
  return setScalar(CAMERA_CMD.DEV_LED_SWITCH, on ? 1 : 0, ctx, "int-string");
6026
6064
  }
@@ -6331,8 +6369,8 @@ var CAMERA_MEMBERS = {
6331
6369
  kind: "boolean",
6332
6370
  provenance: "verified",
6333
6371
  available: isCameraCodec,
6334
- readAvailable: (ctx) => !hasCapability(ctx, "doorbell"),
6335
- readAliases: [{ paramType: CAMERA_CMD.DOORBELL_LED, available: (ctx) => hasCapability(ctx, "doorbell") }],
6372
+ readAvailable: (ctx) => !doorbellLed(ctx),
6373
+ readAliases: [{ paramType: CAMERA_CMD.DOORBELL_LED, available: doorbellLed }],
6336
6374
  requiresRead: true,
6337
6375
  description: "Camera status LED. Video doorbells report the same state under their button-ring LED parameter.",
6338
6376
  write: (v, ctx) => statusLedCommand(asBool(v), ctx)
@@ -25727,6 +25765,8 @@ var RtcCommandRouter = class {
25727
25765
  warm = /* @__PURE__ */ new Map();
25728
25766
  callSources = /* @__PURE__ */ new Map();
25729
25767
  lastTransaction = 0;
25768
+ /** The latest shard each `call` source resolved, read by its `makeStream` (see {@link callMediaProviderFor}). */
25769
+ callShards = /* @__PURE__ */ new Map();
25730
25770
  constructor(deps) {
25731
25771
  this.deps = deps;
25732
25772
  }
@@ -25853,7 +25893,13 @@ var RtcCommandRouter = class {
25853
25893
  views
25854
25894
  };
25855
25895
  }
25856
- /** Share a dedicated-call source and its consumer operations under one route key. */
25896
+ /**
25897
+ * Share a dedicated-call source and its consumer operations under one route key.
25898
+ *
25899
+ * `SharedLiveSource` calls `makeStream` synchronously, so the shard cannot be awaited inside it. Each
25900
+ * async entry point resolves the shard and records it in {@link callShards} before touching the source;
25901
+ * `makeStream` reads the latest recorded value.
25902
+ */
25857
25903
  callMediaProviderFor(route, key, makeStream, recordError, warmTimeoutMs) {
25858
25904
  const sourceFor = (opts = {}) => {
25859
25905
  opts.signal?.throwIfAborted();
@@ -25867,7 +25913,7 @@ var RtcCommandRouter = class {
25867
25913
  if (!identity)
25868
25914
  throw new Error(`rtc: not logged in, cannot call ${route.stationSn}`);
25869
25915
  const accountId = route.adminUserId || identity.userId;
25870
- return makeStream(this.newSession(route, accountId, identity), accountId, view);
25916
+ return makeStream(this.newSession(route, accountId, identity, this.callShards.get(key)), accountId, view);
25871
25917
  },
25872
25918
  label: sourceKey,
25873
25919
  logger: this.deps.logger,
@@ -25879,14 +25925,26 @@ var RtcCommandRouter = class {
25879
25925
  }
25880
25926
  return source;
25881
25927
  };
25928
+ const resolveShard = async () => {
25929
+ this.callShards.set(key, await this.deps.shard(route));
25930
+ };
25882
25931
  return {
25883
- live: async (opts) => attachUnlessAborted(sourceFor(opts), opts?.signal),
25884
- openReadable: async (opts) => openReadableFromConsumer(attachUnlessAborted(sourceFor(opts), opts?.signal), opts),
25885
- snapshotLive: async (opts) => captureSnapshotFromShared(sourceFor(opts), {
25886
- ...opts,
25887
- logger: this.deps.logger,
25888
- ffmpegPath: this.deps.ffmpegPath
25889
- }),
25932
+ live: async (opts) => {
25933
+ await resolveShard();
25934
+ return attachUnlessAborted(sourceFor(opts), opts?.signal);
25935
+ },
25936
+ openReadable: async (opts) => {
25937
+ await resolveShard();
25938
+ return openReadableFromConsumer(attachUnlessAborted(sourceFor(opts), opts?.signal), opts);
25939
+ },
25940
+ snapshotLive: async (opts) => {
25941
+ await resolveShard();
25942
+ return captureSnapshotFromShared(sourceFor(opts), {
25943
+ ...opts,
25944
+ logger: this.deps.logger,
25945
+ ffmpegPath: this.deps.ffmpegPath
25946
+ });
25947
+ },
25890
25948
  record: async () => {
25891
25949
  throw new Error(recordError);
25892
25950
  }
@@ -25901,6 +25959,7 @@ var RtcCommandRouter = class {
25901
25959
  this.callSources.delete(sn);
25902
25960
  src.dispose();
25903
25961
  }
25962
+ this.callShards.clear();
25904
25963
  for (const [sn, live] of this.lives) {
25905
25964
  this.lives.delete(sn);
25906
25965
  live.source.dispose();
@@ -26094,31 +26153,50 @@ var RtcCommandRouter = class {
26094
26153
  }
26095
26154
  });
26096
26155
  }
26097
- newSession(route, adminUserId, identity) {
26156
+ newSession(route, adminUserId, identity, shard) {
26098
26157
  return (this.deps.createSession ?? ((o) => new RtcSession(o)))({
26099
26158
  authToken: identity.authToken,
26100
26159
  gtoken: identity.gtoken,
26101
26160
  stationSn: route.stationSn,
26102
26161
  adminUserId,
26103
- shard: this.deps.shard(),
26162
+ shard,
26104
26163
  country: this.deps.country ?? "US",
26105
26164
  logger: this.deps.logger,
26106
26165
  signalingMode: route.signalingMode,
26107
26166
  peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy, mediaTrack: route.mediaTrack }
26108
26167
  });
26109
26168
  }
26169
+ /** A connected session for `sn` to reuse, or undefined (dropping a dead one). */
26170
+ async reusable(sn) {
26171
+ const st = this.sessions.get(sn);
26172
+ if (!st)
26173
+ return void 0;
26174
+ await st.ready;
26175
+ if (st.session.isConnected) {
26176
+ this.touch(sn, st);
26177
+ return st;
26178
+ }
26179
+ this.drop(sn, st);
26180
+ return void 0;
26181
+ }
26182
+ /**
26183
+ * Get or open the station's session. A connected session is reused before the shard is awaited, so a
26184
+ * command on an already-connected station never waits on the unauthenticated `estimate_domain` round
26185
+ * trip — only the create path awaits it. After the await the map is re-checked, with no `await` between
26186
+ * the lookup and `this.sessions.set`, so concurrent creators still share one session.
26187
+ */
26110
26188
  async stationSession(route, adminUserId, identity) {
26111
26189
  const sn = route.stationSn;
26112
- const existing = this.sessions.get(sn);
26113
- if (existing) {
26114
- await existing.ready;
26115
- if (existing.session.isConnected) {
26116
- this.touch(sn, existing);
26117
- return existing;
26118
- }
26119
- this.drop(sn, existing);
26120
- }
26121
- const session = this.newSession(route, adminUserId, identity);
26190
+ const reused = await this.reusable(sn);
26191
+ if (reused)
26192
+ return reused;
26193
+ const shard = await this.deps.shard(route);
26194
+ if (this.sessions.has(sn)) {
26195
+ const again = await this.reusable(sn);
26196
+ if (again)
26197
+ return again;
26198
+ }
26199
+ const session = this.newSession(route, adminUserId, identity, shard);
26122
26200
  const ready = this.bringUp(sn, session);
26123
26201
  const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve(), leases: 0 };
26124
26202
  session.on("error", (e) => this.deps.onError?.(e));
@@ -29768,7 +29846,7 @@ var EufyMega = class _EufyMega extends EventEmitter14 {
29768
29846
  });
29769
29847
  this.rtc = new RtcCommandRouter({
29770
29848
  identity: () => this.mega.rtcIdentity(),
29771
- shard: () => this.mega.rtcShard,
29849
+ shard: (route) => route.mediaTrack ? this.mega.rtcShard : this.mega.portalShard(),
29772
29850
  country: opts.countryCode,
29773
29851
  logger: opts.logger,
29774
29852
  ffmpegPath: opts.ffmpegPath,