@mega-yfue/eufy-sdk 0.5.0-beta.4 → 0.5.0-beta.41

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.
Files changed (40) hide show
  1. package/dist/client/device-registry.d.ts +24 -2
  2. package/dist/client/eufy-mega.d.ts +50 -6
  3. package/dist/client/types.d.ts +1 -1
  4. package/dist/core/contracts.d.ts +52 -26
  5. package/dist/core/store.d.ts +2 -0
  6. package/dist/core/types.d.ts +2 -0
  7. package/dist/index.js +2310 -277
  8. package/dist/index.js.map +4 -4
  9. package/dist/model/capabilities/arming.d.ts +31 -4
  10. package/dist/model/capabilities/camera.d.ts +24 -10
  11. package/dist/model/capabilities/dp-catalog.d.ts +4 -3
  12. package/dist/model/capabilities/light.d.ts +3 -0
  13. package/dist/model/capabilities/motion.d.ts +5 -0
  14. package/dist/model/capabilities/suction.d.ts +5 -5
  15. package/dist/model/capabilities/types.d.ts +6 -0
  16. package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
  17. package/dist/model/classify.d.ts +1 -2
  18. package/dist/model/device-family.d.ts +4 -0
  19. package/dist/model/registry.d.ts +3 -2
  20. package/dist/model/types.d.ts +6 -0
  21. package/dist/transport/abortable.d.ts +8 -0
  22. package/dist/transport/http/mega-client.d.ts +26 -0
  23. package/dist/transport/p2p/command-router.d.ts +14 -26
  24. package/dist/transport/p2p/fmp4.d.ts +12 -2
  25. package/dist/transport/p2p/live-stream.d.ts +3 -0
  26. package/dist/transport/p2p/p2p-session.d.ts +23 -3
  27. package/dist/transport/p2p/recording-download.d.ts +4 -3
  28. package/dist/transport/p2p/shared-live-source.d.ts +9 -0
  29. package/dist/transport/p2p/talkback.d.ts +21 -10
  30. package/dist/transport/p2p/video.d.ts +5 -2
  31. package/dist/transport/rtc/command-router.d.ts +109 -0
  32. package/dist/transport/rtc/live.d.ts +69 -0
  33. package/dist/transport/rtc/peer.d.ts +101 -0
  34. package/dist/transport/rtc/portal-packet.d.ts +72 -0
  35. package/dist/transport/rtc/ptcs-framer.d.ts +86 -0
  36. package/dist/transport/rtc/scall-sdp.d.ts +42 -0
  37. package/dist/transport/rtc/session.d.ts +88 -0
  38. package/dist/transport/rtc/signaling.d.ts +139 -0
  39. package/dist/transport/token-echo.d.ts +8 -0
  40. package/package.json +2 -1
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 EventEmitter9 } 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";
@@ -196,6 +196,11 @@ var ConsoleLogger = class {
196
196
  }
197
197
  };
198
198
 
199
+ // dist/transport/token-echo.js
200
+ function withoutTokenEcho(text2) {
201
+ return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
202
+ }
203
+
199
204
  // dist/transport/http/media-download.js
200
205
  import { lookup } from "node:dns/promises";
201
206
  import { BlockList, isIP } from "node:net";
@@ -1557,12 +1562,11 @@ var CONTENDED_SESSION_HINT = "the token was rejected again soon after it was las
1557
1562
  function tokenRejected(code, msg) {
1558
1563
  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 ?? "");
1559
1564
  }
1560
- function withoutTokenEcho(text2) {
1561
- return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
1562
- }
1563
1565
  var MegaHttpClient = class {
1564
1566
  cfg;
1565
1567
  region;
1568
+ /** The host `estimate_domain` answered, persisted with the session. */
1569
+ estimatedDomain = "";
1566
1570
  bootstrapDomain;
1567
1571
  sessionKey;
1568
1572
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
@@ -1643,6 +1647,7 @@ var MegaHttpClient = class {
1643
1647
  if (!isSessionValid(saved) || !saved)
1644
1648
  return void 0;
1645
1649
  this.region = saved.region;
1650
+ this.estimatedDomain = saved.estimatedDomain ?? "";
1646
1651
  this.auth_ = {
1647
1652
  userId: saved.userId,
1648
1653
  accountUserId: saved.accountUserId,
@@ -1667,6 +1672,24 @@ var MegaHttpClient = class {
1667
1672
  get regionShard() {
1668
1673
  return this.region;
1669
1674
  }
1675
+ /**
1676
+ * The shard the RTC signalling signs on: the regional prefix of the estimated domain
1677
+ * (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
1678
+ * like `ie-pr`, whose accounts need the IE sign host and cluster.
1679
+ */
1680
+ get rtcShard() {
1681
+ const m = /-([a-z]{2})(?:[.-]|$)/i.exec(this.estimatedDomain);
1682
+ return m ? `${m[1].toLowerCase()}-pr` : this.region;
1683
+ }
1684
+ /**
1685
+ * The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
1686
+ * ("gtoken not equal userid") unless its gtoken is the one every authed HTTP call carries.
1687
+ */
1688
+ rtcIdentity() {
1689
+ if (!this.auth_)
1690
+ return void 0;
1691
+ return { authToken: this.auth_.authToken, userId: this.auth_.userId, gtoken: gtoken(this.gtokenUserId()) };
1692
+ }
1670
1693
  /**
1671
1694
  * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
1672
1695
  * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
@@ -1764,6 +1787,7 @@ var MegaHttpClient = class {
1764
1787
  const env = res.data;
1765
1788
  const d = env?.data ?? {};
1766
1789
  const domain = d.domain ?? d.host ?? d.server_secret_info ?? "";
1790
+ this.estimatedDomain = domain;
1767
1791
  const blob = JSON.stringify(d);
1768
1792
  if (blob.includes("-eu-") || domain.includes("-eu-"))
1769
1793
  this.region = "eu-pr";
@@ -1974,6 +1998,17 @@ ${this.auth_?.authToken ?? ""}`;
1974
1998
  device_sns: [deviceSn]
1975
1999
  });
1976
2000
  }
2001
+ /**
2002
+ * Fetch the account's devices as the `devicerelation` service lists them, `{attribute: 7, house_id: ""}`.
2003
+ *
2004
+ * Each entry is `{device: {device_sn, dps, …}}`, and `dps` is the cloud's last-known value of every
2005
+ * data point that device has reported, keyed by DP number. Answered for an account a device is shared
2006
+ * with as well as for its owner. Sent with `category: eufy_home`, without which the service answers
2007
+ * `10000`.
2008
+ */
2009
+ getDeviceRelationList() {
2010
+ return this.post("devicerelation", "/app/devicerelation/get_device_list", { attribute: 7, house_id: "" }, { category: "eufy_home" });
2011
+ }
1977
2012
  /**
1978
2013
  * Fetch one page of a device's **cleaning history** from the mega `clean` service.
1979
2014
  *
@@ -2422,6 +2457,7 @@ ${this.auth_?.authToken ?? ""}`;
2422
2457
  authToken: this.auth_.authToken,
2423
2458
  geoKey: this.auth_.geoKey,
2424
2459
  region: this.region,
2460
+ estimatedDomain: this.estimatedDomain,
2425
2461
  openudid: this.openudid,
2426
2462
  phoneModel: this.phoneModel,
2427
2463
  mediaUserAgent: this.mediaUserAgent,
@@ -3034,8 +3070,7 @@ var STATION_TYPES = /* @__PURE__ */ new Set([
3034
3070
  DeviceType.STATION_9000,
3035
3071
  DeviceType.MINIBASE_CHIME,
3036
3072
  DeviceType.HOMEBASE_MINI,
3037
- DeviceType.NVR_S4_MAX,
3038
- DeviceType.CAMERA_POE_S4
3073
+ DeviceType.NVR_S4_MAX
3039
3074
  ]);
3040
3075
  var LOCK_TYPES = /* @__PURE__ */ new Set([
3041
3076
  DeviceType.LOCK_BLE,
@@ -3109,7 +3144,7 @@ function codecFromModel(model) {
3109
3144
  return "vacuum";
3110
3145
  if (/^T8(00[0-9]|010|023|025|030)/.test(m))
3111
3146
  return "station";
3112
- if (/^T8N0/.test(m) || /^T8E0/.test(m))
3147
+ if (/^T8N0/.test(m))
3113
3148
  return "station";
3114
3149
  if (/^T9000/.test(m))
3115
3150
  return "station";
@@ -4219,6 +4254,10 @@ var MOTION_MEMBERS = {
4219
4254
  * The raw sensitivity an indoor camera reports, on the one id whose write takes a `1700` control
4220
4255
  * payload carrying an index rather than the `1350` envelope its siblings use — a frame-shape
4221
4256
  * difference `sensitivityCommand` resolves from the scale. `unexposed` like its three siblings.
4257
+ *
4258
+ * Some devices report 6041 as a base64 JSON list of per-sensor entries
4259
+ * (`{"list":[{"sensor_id":…,"sensitivity":…}]}`) rather than a scalar. That value is stored verbatim
4260
+ * and this getter answers `undefined` for it: only the scalar form is confirmed to be this step.
4222
4261
  */
4223
4262
  indoorSensitivity: {
4224
4263
  param: MOTION_CMD.INDOOR_SENSITIVITY_INDEX,
@@ -4226,7 +4265,8 @@ var MOTION_MEMBERS = {
4226
4265
  kind: "scalar",
4227
4266
  provenance: "verified",
4228
4267
  unexposed: true,
4229
- description: "Raw sensitivity an indoor camera reports (verified: param 6041)."
4268
+ description: "Raw sensitivity an indoor camera reports (verified: param 6041).",
4269
+ decode: (raw) => typeof raw === "number" ? raw : void 0
4230
4270
  },
4231
4271
  /**
4232
4272
  * The raw sensitivity on the INVERTED seven-step camera scale — a HIGHER number is LESS sensitive, so
@@ -5060,6 +5100,8 @@ var isIndoorCamera = (ctx) => has(INDOOR_CAMERA_TYPES, ctx.deviceType);
5060
5100
  var isIndoorCamMini = (ctx) => ctx.deviceType === DeviceType.INDOOR_COST_DOWN_CAMERA;
5061
5101
  var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
5062
5102
  var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
5103
+ var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
5104
+ var isNvrT8N00 = (ctx) => ctx.model === "T8N00";
5063
5105
  var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
5064
5106
  var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
5065
5107
 
@@ -5205,6 +5247,8 @@ var AUDIO = {
5205
5247
 
5206
5248
  // dist/model/capabilities/camera.js
5207
5249
  var CAMERA_CMD = {
5250
+ /** Camera-reported Wi-Fi signal (app `CMD_GET_WIFI_RSSI`). */
5251
+ WIFI_RSSI: 1142,
5208
5252
  /** Camera on/off. Read and write polarity depend on the family and topology. */
5209
5253
  CAMERA_ENABLE: 1035,
5210
5254
  /** Wrapped T8410 power switch on HomeBase 3. */
@@ -5448,7 +5492,12 @@ function usesHomeBasePowerPayload(ctx) {
5448
5492
  function hasUnreflectedHomeBasePower(ctx) {
5449
5493
  return usesHomeBasePowerPayload(ctx) && !ctx.paramIds.has(2001);
5450
5494
  }
5495
+ function hasPrivacyPower(ctx) {
5496
+ return ctx.deviceType === DeviceType.INDOOR_PT_CAMERA_S350 && ctx.homeBaseAttached === true;
5497
+ }
5451
5498
  function powerCommand(on, ctx) {
5499
+ if (hasPrivacyPower(ctx))
5500
+ return privacyCommand(!on, ctx.channel);
5452
5501
  if (usesHomeBasePowerPayload(ctx))
5453
5502
  return setPayload(CAMERA_CMD.PRIVACY_MODE, { switch: on ? 0 : 1 }, ctx, 0);
5454
5503
  return setScalar(CAMERA_CMD.CAMERA_ENABLE, powerValue(on, ctx), ctx, "auto");
@@ -5465,11 +5514,11 @@ function poweredOf(ctx) {
5465
5514
  return cameraPowerTier(ctx.model, ctx.capabilities ?? /* @__PURE__ */ new Set());
5466
5515
  }
5467
5516
  function enablementReflection(on, ctx) {
5517
+ if (!CAMERA_MEMBERS.enabled.readReflectsWrite(ctx))
5518
+ return void 0;
5468
5519
  const alias = CAMERA_MEMBERS.enabled.readAliases[0].paramType;
5469
5520
  if (ctx.paramIds.has(alias))
5470
5521
  return { param: alias, expected: on, observed: on };
5471
- if (hasUnreflectedHomeBasePower(ctx))
5472
- return void 0;
5473
5522
  if (ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE)) {
5474
5523
  return {
5475
5524
  param: CAMERA_CMD.CAMERA_ENABLE,
@@ -5480,10 +5529,18 @@ function enablementReflection(on, ctx) {
5480
5529
  return void 0;
5481
5530
  }
5482
5531
  function refuseWhenDisabled(ctx, read) {
5483
- if (!hasUnreflectedHomeBasePower(ctx) && read("enabled")?.value === false)
5532
+ if (CAMERA_MEMBERS.enabled.readReflectsWrite(ctx) && read("enabled")?.value === false)
5484
5533
  throw new CameraDisabledError(ctx.name ?? ctx.serial);
5485
5534
  }
5486
5535
  var CAMERA_MEMBERS = {
5536
+ /** Reads the latest cached report and exposes no setter. */
5537
+ wifiRssi: {
5538
+ param: CAMERA_CMD.WIFI_RSSI,
5539
+ type: "number",
5540
+ kind: "scalar",
5541
+ provenance: "apk",
5542
+ description: "Camera-reported Wi-Fi signal; physical units and a quality scale are unspecified."
5543
+ },
5487
5544
  /**
5488
5545
  * The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
5489
5546
  * family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
@@ -5506,7 +5563,7 @@ var CAMERA_MEMBERS = {
5506
5563
  invert: true,
5507
5564
  invertFor: (ctx) => isT8410HomeBase3(ctx) ? false : void 0,
5508
5565
  readAliases: [{ paramType: 2001, invert: false }],
5509
- readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx),
5566
+ readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx) && !hasPrivacyPower(ctx),
5510
5567
  description: "Camera enabled. Family-dependent wire param: 1035 CMD_DEVS_SWITCH (enable bit on T8410/HomeBase 3, disable bit on battery/solo cams) or 2001 OPEN_DEVICE (standalone indoor/outdoor). Reported on/off state; unreflectedMembers identifies commands whose effect this report does not reliably reflect.",
5511
5568
  observation: {
5512
5569
  event: "cameraEnabledChanged",
@@ -5640,16 +5697,14 @@ var CAMERA_MEMBERS = {
5640
5697
  * Live-view quality, the pair to {@link CAMERA_MEMBERS.recordingQuality} and a different setting on a
5641
5698
  * different wire: changing one leaves the other's parameter untouched.
5642
5699
  *
5643
- * The READ is 1020, which reports the resolved tier as a plain integer. 1020 is also the name the
5644
- * param dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730
5645
- * under this name would collide with it on a device reporting both.
5700
+ * The READ is 1020, which reports the resolved tier as a plain integer, while the WRITE rides 2730's
5701
+ * `1350` payload. They are two ids for one setting because 2730 carries a per-mode config and 1020
5702
+ * carries the tier in force; the tier is what this property means. 1020 is also the name the param
5703
+ * dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730 under
5704
+ * this name would collide with it on a device reporting both.
5646
5705
  *
5647
- * `unverified: true` with no `write`: the app's 2730 frame is captured byte-exact, but replaying it
5648
- * from this SDK produced no observable change on a standalone T8171, where the recording sibling's
5649
- * 2731 write on the same envelope and the same session does land. The one difference the capture
5650
- * shows is that the app's frame carries no `mChannel`/`mValue3`, which this envelope always emits.
5651
- * Until a send is confirmed, the setter is absent — a control that silently does nothing is worse
5652
- * than none — and the read ships ahead of it, which verification per direction allows.
5706
+ * `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
5707
+ * confirmed frame carries no such field; neither is inferred from the other.
5653
5708
  */
5654
5709
  streamingQuality: {
5655
5710
  param: 1020,
@@ -5657,8 +5712,13 @@ var CAMERA_MEMBERS = {
5657
5712
  kind: "enum",
5658
5713
  enumValues: STREAMING_QUALITY_TIERS,
5659
5714
  provenance: "verified",
5660
- unverified: true,
5661
- description: "Live-view quality as a tier, 0 = Auto (1020). Distinct from recordingQuality, which is what gets stored. All four tiers confirmed on a T8170 and a T8171. The WRITE (2730) is unconfirmed from this SDK, so no setter is offered rather than one that reports success without acting \u2014 see CAMERA_CMD.STREAMING_QUALITY_SET."
5715
+ args: [{ name: "quality", kind: "enum", description: "A tier, or the name it maps to. 0 is Auto." }],
5716
+ ...accepts(),
5717
+ description: "Live-view quality as a tier, 0 = Auto (1020; the write rides 2730 APP_CMD_MULTI_CAM_SET_VIDEO_QUALITY). Distinct from recordingQuality, which is what gets stored. All four tiers wire-verified live on a T8170 and read back on 1020. Sent from this SDK to a standalone T8170 and T8171, tier 1 holds the live stream at 1280x720 and tier 0 restores the camera's own resolution ladder \u2014 see CAMERA_CMD.STREAMING_QUALITY_SET. A value outside the tier set is REJECTED, not clamped: every tier in range is a real quality, so a bad one would silently pick another.",
5718
+ write: (v, ctx) => {
5719
+ const q = resolveStreamingQualityTier(v);
5720
+ return q == null ? void 0 : setPayload(CAMERA_CMD.STREAMING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q, transaction: String(Date.now()) }, ctx, 0, void 0, "auto");
5721
+ }
5662
5722
  },
5663
5723
  /**
5664
5724
  * `type` is how the value is STORED, and 2731 stores the whole config — the ACTIVE tier is lifted out
@@ -5829,8 +5889,9 @@ function isStationAlarmOutput(ctx) {
5829
5889
  function isEvidencedStationAlarm(ctx) {
5830
5890
  return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
5831
5891
  }
5892
+ var S1_PRO_ALARM_MODELS = /* @__PURE__ */ new Set(["T8134"]);
5832
5893
  function isEvidencedAttachedCamera(ctx) {
5833
- return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx);
5894
+ return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx) && (!isStation9000({ deviceType: ctx.stationDeviceType }) || S1_PRO_ALARM_MODELS.has(ctx.model ?? ""));
5834
5895
  }
5835
5896
  function reportsParam(source, param) {
5836
5897
  return source.paramIds?.has(param) === true || Object.hasOwn(source.params ?? {}, param);
@@ -5849,7 +5910,18 @@ function homeBaseAlarm(seconds, ctx) {
5849
5910
  throw new Error("station-family HomeBase alarm output requires the acting account name");
5850
5911
  return setPayload(SIREN_CMD.HOMEBASE_TONE, { time_out: seconds, user_name: ctx.accountName }, ctx, 0, STATION_CHANNEL);
5851
5912
  }
5913
+ var PORTAL_MANUAL_ALARM_TYPE = 10;
5852
5914
  function cameraAlarm(seconds, ctx) {
5915
+ if (isStation9000({ deviceType: ctx.stationDeviceType })) {
5916
+ if (!ctx.accountName)
5917
+ throw new Error("a camera alarm behind a HomeBase S1 Pro requires the acting account name");
5918
+ return setPayload(SIREN_CMD.CAMERA_TONE, {
5919
+ time_out: seconds,
5920
+ type: PORTAL_MANUAL_ALARM_TYPE,
5921
+ channel: ctx.channel,
5922
+ username: ctx.accountName
5923
+ }, ctx);
5924
+ }
5853
5925
  return {
5854
5926
  kind: "p2p-int-string",
5855
5927
  cmd: SIREN_CMD.CAMERA_TONE,
@@ -6093,7 +6165,8 @@ var LIGHT_SWITCH_INT_STRING_TYPES = /* @__PURE__ */ new Set([
6093
6165
  DeviceType.CAMERA2_PRO,
6094
6166
  DeviceType.CAMERA2C_PRO,
6095
6167
  DeviceType.CAMERA3,
6096
- DeviceType.CAMERA3C
6168
+ DeviceType.CAMERA3C,
6169
+ DeviceType.PROFESSIONAL_247
6097
6170
  ]);
6098
6171
  function switchCommand(on, ctx) {
6099
6172
  switch (lightSwitchWire(ctx)) {
@@ -6188,6 +6261,9 @@ var LIGHT_MEMBERS = {
6188
6261
  * This is the switch a user changes and expects to STAY changed. {@link isOn} is a different fact:
6189
6262
  * the lamp being lit right now, driven by whichever
6190
6263
  * client is streaming — the vendor app lights it for a live view and drops it on quitting.
6264
+ *
6265
+ * The T8423 write takes the session-chosen level; level-1 int+string was verified live on a standalone
6266
+ * unit.
6191
6267
  */
6192
6268
  spotlightEnabled: {
6193
6269
  param: LIGHT_CMD.SPOTLIGHT_ENABLE,
@@ -6195,7 +6271,7 @@ var LIGHT_MEMBERS = {
6195
6271
  kind: "boolean",
6196
6272
  provenance: "verified",
6197
6273
  description: "The spotlight master switch, distinct from the on/off above \u2014 whether the spotlight may light at all. Read verified live on a T8170 (param 1403, direct polarity: 1 = enabled).",
6198
- write: (v, ctx) => directBinary(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx),
6274
+ write: (v, ctx) => setScalar(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx, ctx.deviceType === DeviceType.FLOODLIGHT_CAMERA_8423 ? "auto" : "direct-binary"),
6199
6275
  writeAs: "setEnabled"
6200
6276
  }
6201
6277
  };
@@ -6588,6 +6664,8 @@ var ZOOM_MODEL_HINT = /^T8170\b|^T8425\b|S340/i;
6588
6664
  function hasZoomEvidence(ctx) {
6589
6665
  return ZOOM_EVIDENCE_PARAMS.some((p) => ctx.paramIds.has(p)) || !!ctx.model && ZOOM_MODEL_HINT.test(ctx.model);
6590
6666
  }
6667
+ var PRESET_EVIDENCE_PARAMS = [1029, 6090, 6091, 6092, 6210];
6668
+ var FIXED_LENS_MODELS = /* @__PURE__ */ new Set(["T8134"]);
6591
6669
  function rotateCommand(direction, ctx, zoom = 1) {
6592
6670
  return setJson(PTZ_CMD.PTZ_ROTATE, { cmd_type: 1, rotate_type: PTZ_ROTATE[direction], zoom }, ctx);
6593
6671
  }
@@ -6752,8 +6830,9 @@ var PTZ = {
6752
6830
  members: PTZ_MEMBERS,
6753
6831
  properties: propertiesOf(PTZ_MEMBERS),
6754
6832
  // SoloCam proves PT via reported preset params (6090/6091/6092/6210) and a rotate command surface
6755
- // (1029); Indoor-PT (deviceType 31/35/111 + C220 range 10008-10011) reports NO PT param, so the
6756
- // vendor deviceType is the only honest signal; explicit PT/S340/S350 model names also prove it.
6833
+ // (1029), except on a FIXED_LENS_MODELS model; Indoor-PT (deviceType 31/35/111 + C220 range
6834
+ // 10008-10011) reports NO PT param, so the vendor deviceType is the only honest signal; explicit
6835
+ // PT/S340/S350 model names also prove it.
6757
6836
  // OUTDOOR_PT_CAMERA (48, e.g. SoloCam S340 / T8170) is pan-tilt by definition but a live unit can
6758
6837
  // report none of the preset params — trust the deviceType. The rotating SoloCam solar variant
6759
6838
  // T8124R shares deviceType 62 with the FIXED T8124 base, so its model code is the only honest
@@ -6761,7 +6840,7 @@ var PTZ = {
6761
6840
  // a `T81\d\dR` wildcard: the "R = rotating" reading is unverified for other models, and an
6762
6841
  // over-broad hint would false-positive a future non-PT model, the same class of bug as a mislabel.
6763
6842
  detection: {
6764
- evidenceParams: [1029, 6090, 6091, 6092, 6210],
6843
+ detect: (record) => !FIXED_LENS_MODELS.has(record.model ?? "") && PRESET_EVIDENCE_PARAMS.some((p) => Object.hasOwn(record.params ?? {}, p)),
6765
6844
  deviceTypes: [
6766
6845
  DeviceType.OUTDOOR_PT_CAMERA,
6767
6846
  DeviceType.INDOOR_PT_CAMERA,
@@ -6777,7 +6856,7 @@ var PTZ = {
6777
6856
  // no PTZ capability without this, on a real owned unit.
6778
6857
  DeviceType.FLOODLIGHT_CAMERA_8425
6779
6858
  ],
6780
- modelHints: [/pan.?tilt|\bpt\b|S3[45]0|indoor.?pt/i]
6859
+ modelHints: [/pan.?tilt|\bpt\b|S3[45]0|indoor.?pt/i, /\bT8E00\b/i]
6781
6860
  },
6782
6861
  emits: ["ptzNotify"],
6783
6862
  /**
@@ -7632,7 +7711,8 @@ var ARMING_CMD = {
7632
7711
  */
7633
7712
  SET_ARMING: 1224,
7634
7713
  /**
7635
- * The per-mode alarm/arm-delay configuration write. ✅ WIRE CAPTURED live on a T8030 (
7714
+ * One guard mode's per-device action table (app `CMD_SET_ALL_ACTION`), which carries the alarm delays
7715
+ * and each device's actions in that mode. ✅ WIRE CAPTURED live on a T8030 (
7636
7716
  * 2026-07-23, both directions): a **bare
7637
7717
  * JSON frame, no `1350`/`1700` envelope** — outer P2P cmd IS `1255` itself, station channel 255,
7638
7718
  * plaintext `{account_id, count_down_alarm:{channel_list,delay_time},
@@ -7649,15 +7729,26 @@ var ARMING_CMD = {
7649
7729
  * corresponding GET command (`1310`/inner cmd `40003`, sent by the app right before editing) always
7650
7730
  * replied `{count:0,data:null}` in every capture — genuinely empty, not a decrypt failure (confirmed
7651
7731
  * via the same bidirectional decrypt this finding used) — so the app does NOT read the current
7652
- * config this way; how it does is still unknown. Without a working GET, safely PATCHING just one
7653
- * channel in or out of an existing list isn't possible without risking clobbering the rest — so
7732
+ * config this way; a T8010 reports it on its own params instead, see below. Without a working GET,
7733
+ * safely PATCHING just one channel in or out of an existing list isn't possible without risking
7734
+ * clobbering the rest — so
7654
7735
  * this ships as a caller-supplies-everything write instead of guessing a merge.
7655
7736
  * `devices`/`siren_sensor_action` are even less understood (raw per-device action codes, meaning
7656
7737
  * unconfirmed) and MUST come from a value independently read/captured for the target mode —
7657
7738
  * see `AlarmDelayConfig`'s field docs.
7739
+ *
7740
+ * ✅ On a T8010 the app writes the same command for the Device Control screen: header channel 0, the
7741
+ * whole table of the mode `{mode_id, devices, count_down_alarm, count_down_arm}`, acked by an all-zero
7742
+ * 1255 reply. The T8010 reports each table on its own params, Home on 1157 and Away on 1158, and the
7743
+ * reported table follows a write within 15 s. See {@link DEVICE_NOTIFY_FLAG}.
7658
7744
  */
7659
- ALARM_DELAY_CONFIG: 1255
7745
+ SET_ALL_ACTION: 1255
7660
7746
  };
7747
+ var DEVICE_NOTIFY_FLAG = 8;
7748
+ function actionTable(read, mode) {
7749
+ const value = read(`${mode}ActionTable`)?.value;
7750
+ return value && typeof value === "object" && Array.isArray(value.devices) ? value : void 0;
7751
+ }
7661
7752
  var ARMING_MODE_WIRE = {
7662
7753
  away: 0,
7663
7754
  home: 1,
@@ -7694,7 +7785,15 @@ function alarmDelayCommand(mode, config, ctx) {
7694
7785
  devices: config.devices.map((d) => ({ action: d.action, device_channel: d.deviceChannel })),
7695
7786
  siren_sensor_action: config.sirenSensorAction.map((d) => ({ action: d.action, device_channel: d.deviceChannel }))
7696
7787
  };
7697
- return setJsonRaw(ARMING_CMD.ALARM_DELAY_CONFIG, data, ctx, STATION_CHANNEL2);
7788
+ return setJsonRaw(ARMING_CMD.SET_ALL_ACTION, data, ctx, STATION_CHANNEL2);
7789
+ }
7790
+ var ACTION_TABLE_LAG_MS = 15e3;
7791
+ function withDeviceNotification(table, channel, on) {
7792
+ if (!table?.devices.some((d) => d.device_channel === channel))
7793
+ return void 0;
7794
+ const { account_id: _account, ...rest } = table;
7795
+ const devices = table.devices.map((d) => d.device_channel === channel ? { ...d, action: on ? d.action | DEVICE_NOTIFY_FLAG : d.action & ~DEVICE_NOTIFY_FLAG } : d);
7796
+ return { ...rest, devices };
7698
7797
  }
7699
7798
  var ARMING_MEMBERS = {
7700
7799
  /**
@@ -7752,7 +7851,45 @@ var ARMING_MEMBERS = {
7752
7851
  } catch (e) {
7753
7852
  return Promise.reject(e);
7754
7853
  }
7755
- }, "Write the full per-mode alarm/arm-delay configuration.")
7854
+ }, "Write the full per-mode alarm/arm-delay configuration."),
7855
+ /**
7856
+ * Whether the device on `channel` sends a push notification in `mode`: its {@link DEVICE_NOTIFY_FLAG}
7857
+ * in the action table the station reports for that mode. `undefined` where the station reports no table
7858
+ * or the table does not list `channel`. A device's channel is `EufyDevice.channel`.
7859
+ */
7860
+ deviceNotification: method(({ read }) => (mode, channel) => {
7861
+ const action = actionTable(read, mode)?.devices.find((d) => d.device_channel === channel)?.action;
7862
+ return typeof action === "number" ? (action & DEVICE_NOTIFY_FLAG) !== 0 : void 0;
7863
+ }, "Whether the device on a channel sends a push notification in a guard mode (home/away).", (ctx) => ctx.model === "T8010"),
7864
+ /**
7865
+ * Turn the push notification of the device on `channel` on or off in `mode` (cmd
7866
+ * {@link ARMING_CMD.SET_ALL_ACTION}): the station's reported table written back with that one flag
7867
+ * changed. Rejects, sending nothing, where the table does not list `channel`. A write builds on the last
7868
+ * one sent for that mode until the station reports a table observed 15 s after it, so a second write does
7869
+ * not revert it; a write whose dispatch rejects is not built on.
7870
+ */
7871
+ setDeviceNotification: method(({ ctx, sink, read }) => {
7872
+ const sent = /* @__PURE__ */ new Map();
7873
+ return (mode, channel, on) => {
7874
+ const last = sent.get(mode);
7875
+ const observed = read(`${mode}ActionTable`)?.ts ?? 0;
7876
+ const base = last && observed < last.at + ACTION_TABLE_LAG_MS ? last.table : actionTable(read, mode);
7877
+ const table = withDeviceNotification(base, channel, on);
7878
+ if (!table) {
7879
+ return Promise.reject(new Error(`arming: no ${mode} action table lists channel ${channel} [${describeDevice(ctx)}]`));
7880
+ }
7881
+ sent.set(mode, { table, at: Date.now() });
7882
+ return sink.dispatch(setJsonRaw(ARMING_CMD.SET_ALL_ACTION, table, ctx, 0)).catch((err) => {
7883
+ if (sent.get(mode)?.table === table) {
7884
+ if (last)
7885
+ sent.set(mode, last);
7886
+ else
7887
+ sent.delete(mode);
7888
+ }
7889
+ throw err;
7890
+ });
7891
+ };
7892
+ }, "Turn the push notification of the device on a channel on or off in a guard mode (home/away).", (ctx) => ctx.model === "T8010")
7756
7893
  };
7757
7894
  var ARMING = {
7758
7895
  capability: "arming",
@@ -8312,8 +8449,6 @@ var MODE_CTRL_FIELD = {
8312
8449
  SEQ: 2
8313
8450
  };
8314
8451
  var ModeCtrlMethod = {
8315
- /** Live-verified on a T2351. Zero, so it is omitted from the wire per the proto3 default rule. */
8316
- START_AUTO_CLEAN: 0,
8317
8452
  /** Live-verified on a T2351. */
8318
8453
  START_GOHOME: 6,
8319
8454
  /** Live-verified on a T2351. */
@@ -8337,6 +8472,8 @@ var ModeCtrlMethod = {
8337
8472
  START_GLOBAL_CRUISE: 20
8338
8473
  };
8339
8474
  var ModeCtrlParamMethod = {
8475
+ /** `START_AUTO_CLEAN` with an `AutoClean` payload — clean the whole floor. */
8476
+ AUTO: { method: 0, param: 3 },
8340
8477
  /** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
8341
8478
  SELECT_ROOMS: { method: 1, param: 4 },
8342
8479
  /** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
@@ -8373,6 +8510,7 @@ var SELECT_ZONES_FIELD = {
8373
8510
  POINT_Y: 2
8374
8511
  };
8375
8512
  var SCENE_CLEAN_ID = 1;
8513
+ var AUTO_CLEAN_TIMES = 1;
8376
8514
  function encodeModeCtrlParam(method2, paramField, build) {
8377
8515
  return rawDp((w) => {
8378
8516
  if (method2 !== 0)
@@ -8419,14 +8557,17 @@ function encodeSceneClean(sceneId) {
8419
8557
  const { method: method2, param } = ModeCtrlParamMethod.SCENE;
8420
8558
  return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
8421
8559
  }
8560
+ function encodeAutoClean() {
8561
+ const { method: method2, param } = ModeCtrlParamMethod.AUTO;
8562
+ return encodeModeCtrlParam(method2, param, (p) => p.int(AUTO_CLEAN_TIMES, 1));
8563
+ }
8422
8564
  var modeCtrlSeq = 111;
8423
8565
  function nextModeCtrlSeq() {
8424
8566
  return ++modeCtrlSeq;
8425
8567
  }
8426
8568
  function encodeModeCtrl(method2, seq) {
8427
8569
  return rawDp((w) => {
8428
- if (method2 !== 0)
8429
- w.int(MODE_CTRL_FIELD.METHOD, method2);
8570
+ w.int(MODE_CTRL_FIELD.METHOD, method2);
8430
8571
  w.int(MODE_CTRL_FIELD.SEQ, seq);
8431
8572
  });
8432
8573
  }
@@ -10268,8 +10409,8 @@ var VACUUM_CLEAN_MEMBERS = {
10268
10409
  provenance: "mega",
10269
10410
  description: "Stop smart-follow mode (ModeCtrlRequest method 18 over DP 152). Method number not captured \u2014 unverified."
10270
10411
  },
10271
- /** Start an auto-clean run via ModeCtrlRequest method 0 (DP 152). AIoT only — Tuya write unverified. */
10272
- startCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.START_AUTO_CLEAN, nextModeCtrlSeq()))), "Start an auto-clean run (ModeCtrlRequest method 0 over DP 152).", isAiotVacuum),
10412
+ /** Start an auto-clean run via ModeCtrlRequest method 0 with one `AutoClean` pass (DP 152). AIoT only — Tuya write unverified. */
10413
+ startCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeAutoClean())), "Start an auto-clean run (ModeCtrlRequest method 0, AutoClean clean_times 1, over DP 152).", isAiotVacuum),
10273
10414
  /** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
10274
10415
  returnToDock: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.START_GOHOME, nextModeCtrlSeq()))), "Return to the dock (ModeCtrlRequest method 6 over DP 152).", isAiotVacuum),
10275
10416
  /** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
@@ -10392,6 +10533,11 @@ var SuctionLevel = {
10392
10533
  };
10393
10534
  var SUCTION_LABEL = enumLabels(SuctionLevel);
10394
10535
  var ALL_SUCTION_LEVELS = [0, 1, 2, 3, 4, 5];
10536
+ function levelFromCatalog(member) {
10537
+ if (Object.hasOwn(SuctionLevel, member))
10538
+ return SuctionLevel[member];
10539
+ return Object.hasOwn(SUCTION_LABEL, member) ? Number(member) : void 0;
10540
+ }
10395
10541
  function suctionLevelName(value) {
10396
10542
  return SUCTION_LABEL[value];
10397
10543
  }
@@ -10452,13 +10598,15 @@ var SUCTION = {
10452
10598
  },
10453
10599
  /**
10454
10600
  * The per-model level range — sourced from the SKU's `get_product_data_point` catalog for DP 158
10455
- * when available. `undefined` when no catalog entry exists for DP 158. `setSuctionLevel` is only
10601
+ * when available. `undefined` when no catalog entry exists for DP 158, or when no listed member maps
10602
+ * to a known level (so a caller never reads `[]` as "nothing selectable"). `setSuctionLevel` is only
10456
10603
  * installed on AIoT devices (gated by `available: isAiotVacuum` on the `level` entry), so
10457
10604
  * `supportedLevels` is harmless data on a non-AIoT device.
10458
10605
  */
10459
10606
  actions({ ctx }) {
10460
10607
  const catalogRange = ctx.dpCatalog?.enumRanges.get(SUCTION_DP.SUCTION);
10461
- const supportedLevels = catalogRange?.filter((v) => v in SUCTION_LABEL);
10608
+ const levels = catalogRange?.flatMap((m) => levelFromCatalog(m) ?? []);
10609
+ const supportedLevels = levels?.length ? levels : void 0;
10462
10610
  return { supportedLevels };
10463
10611
  }
10464
10612
  };
@@ -10500,28 +10648,29 @@ function parseEnumRange(values) {
10500
10648
  if (values === null || values === void 0)
10501
10649
  return [];
10502
10650
  if (Array.isArray(values))
10503
- return toNumberArray(values);
10651
+ return toMemberArray(values);
10504
10652
  if (typeof values === "string") {
10505
10653
  try {
10506
10654
  const parsed = JSON.parse(values);
10507
10655
  if (Array.isArray(parsed))
10508
- return toNumberArray(parsed);
10656
+ return toMemberArray(parsed);
10509
10657
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
10510
10658
  const obj = parsed;
10511
10659
  if (Array.isArray(obj.range))
10512
- return toNumberArray(obj.range);
10660
+ return toMemberArray(obj.range);
10513
10661
  }
10514
10662
  } catch {
10515
10663
  }
10516
10664
  }
10517
10665
  return [];
10518
10666
  }
10519
- function toNumberArray(arr) {
10667
+ function toMemberArray(arr) {
10520
10668
  const result = [];
10521
10669
  for (const v of arr) {
10522
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
10523
- if (Number.isInteger(n))
10524
- result.push(n);
10670
+ if (typeof v === "number" && Number.isFinite(v))
10671
+ result.push(String(v));
10672
+ else if (typeof v === "string" && v !== "")
10673
+ result.push(v);
10525
10674
  }
10526
10675
  return result;
10527
10676
  }
@@ -11016,8 +11165,12 @@ var MODEL_REGISTRY = {
11016
11165
  // Cameras with curated extras that aren't always provable from a fresh device's params.
11017
11166
  T8423: { codec: "camera", caps: ["light", "battery"], name: "Floodlight Cam" },
11018
11167
  T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
11168
+ // T8210 hardware variant, reported as its own code; shares the T8210 row.
11169
+ T8210C: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
11019
11170
  // Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
11020
11171
  T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
11172
+ // Battery Video Doorbell Dual, vendor deviceType 91 (BATTERY_DOORBELL_PLUS), reported by two users.
11173
+ T8213: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
11021
11174
  // Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
11022
11175
  // member on purpose — this model is wired, so the codec baseline plus inference is the whole
11023
11176
  // truth for power. Without this row it classified as a plain camera, so the doorbell capability
@@ -11038,6 +11191,8 @@ var MODEL_REGISTRY = {
11038
11191
  // shows a parent, so an attached cam never advertises the hub's guard mode.
11039
11192
  T8170: { codec: "camera", caps: ["battery", "arming"], name: "SoloCam" },
11040
11193
  T8171: { codec: "camera", caps: ["battery", "arming"], name: "T8171" },
11194
+ // Baby Monitor E21, vendor deviceType 86 (category eufy_care), standalone.
11195
+ T8354: { codec: "camera", name: "Baby Monitor E21" },
11041
11196
  T8400: { codec: "camera", caps: ["arming"], name: "Indoor Cam" },
11042
11197
  T8410: { codec: "camera", caps: ["arming"], name: "Indoor Cam Pan & Tilt" },
11043
11198
  T8442: { codec: "camera", caps: ["arming"], name: "Indoor/Outdoor Cam 1080p" },
@@ -11179,8 +11334,10 @@ function resolveDevice(rec) {
11179
11334
  }
11180
11335
  function resolveProperties(rec, codec, capabilities) {
11181
11336
  const paramIds = /* @__PURE__ */ new Set();
11182
- if (rec.params && typeof rec.params === "object") {
11183
- for (const k of Object.keys(rec.params)) {
11337
+ for (const reported of [rec.params, rec.dpParams]) {
11338
+ if (!reported || typeof reported !== "object")
11339
+ continue;
11340
+ for (const k of Object.keys(reported)) {
11184
11341
  const n = Number(k);
11185
11342
  if (Number.isFinite(n))
11186
11343
  paramIds.add(n);
@@ -11380,9 +11537,15 @@ var SECURITY_PARAMS = {
11380
11537
  provenance: "guessed"
11381
11538
  },
11382
11539
  1157: {
11383
- name: "jsDelayHome",
11540
+ name: "homeActionTable",
11384
11541
  type: "string",
11385
- provenance: "apk",
11542
+ provenance: "verified",
11543
+ encoding: "base64+json"
11544
+ },
11545
+ 1158: {
11546
+ name: "awayActionTable",
11547
+ type: "string",
11548
+ provenance: "verified",
11386
11549
  encoding: "base64+json"
11387
11550
  },
11388
11551
  1159: {
@@ -14383,6 +14546,19 @@ function decodeMapFrame(frame, codec) {
14383
14546
  return READERS.get(frame.channelId)?.(frame.payload, codec);
14384
14547
  }
14385
14548
 
14549
+ // dist/transport/abortable.js
14550
+ function abortable(work, signal) {
14551
+ if (!signal)
14552
+ return work;
14553
+ signal.throwIfAborted();
14554
+ return Promise.race([
14555
+ work,
14556
+ new Promise((_resolve, reject) => {
14557
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
14558
+ })
14559
+ ]);
14560
+ }
14561
+
14386
14562
  // dist/transport/p2p/command-router.js
14387
14563
  import { setTimeout as sleep2 } from "node:timers/promises";
14388
14564
 
@@ -15308,7 +15484,7 @@ var PADDED_RESULT_BYTES = 132;
15308
15484
  var CMD_CAMERA_INFO = 1103;
15309
15485
  var CMD_DATABASE_IMAGE = 1308;
15310
15486
  var CMD_DATABASE = 1306;
15311
- var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
15487
+ var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
15312
15488
  var GCM_AAD = Buffer.from("eufy security");
15313
15489
  var LEVEL2_SEQ_BASE = 16909056;
15314
15490
  function paramReport(payload) {
@@ -15338,9 +15514,13 @@ function firstTableRows(text2) {
15338
15514
  } catch {
15339
15515
  continue;
15340
15516
  }
15341
- const data = parsed?.data;
15342
- if (Array.isArray(data))
15343
- return data;
15517
+ const reply = parsed;
15518
+ if (typeof reply?.mIntRet === "number" && reply.mIntRet !== 0)
15519
+ return { error: reply.mIntRet };
15520
+ if (reply?.data === "[]")
15521
+ return { rows: [] };
15522
+ if (Array.isArray(reply?.data))
15523
+ return { rows: reply.data };
15344
15524
  }
15345
15525
  return void 0;
15346
15526
  }
@@ -15699,8 +15879,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15699
15879
  return this.connected;
15700
15880
  }
15701
15881
  /**
15702
- * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and warn once per session when it grants
15703
- * less or refuses.
15882
+ * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and log once per session when it grants
15883
+ * less (debug) or refuses (warn).
15704
15884
  *
15705
15885
  * The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
15706
15886
  * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
@@ -15716,7 +15896,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15716
15896
  if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
15717
15897
  return;
15718
15898
  this.receiveBufferReported = true;
15719
- this.logger.warn(`[p2p] ${this.cfg.stationSn} UDP receive buffer below the ${RECEIVE_BUFFER_BYTES} bytes requested` + (granted > 0 ? ` (granted ${granted})` : " (request refused)") + `; live video can lose keyframes. Raise the OS limit (net.core.rmem_max on Linux, kern.ipc.maxsockbuf on BSD) to at least ${RECEIVE_BUFFER_BYTES}.`);
15899
+ const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
15900
+ this.logger[level](`[p2p] ${this.cfg.stationSn} UDP receive buffer below the ${RECEIVE_BUFFER_BYTES} bytes requested` + outcome + `; live video can lose keyframes. Raise the OS limit (net.core.rmem_max on Linux, kern.ipc.maxsockbuf on BSD) to at least ${RECEIVE_BUFFER_BYTES}.`);
15720
15901
  }
15721
15902
  /** Open the socket and start the lookup → hole-punch handshake. */
15722
15903
  async connect() {
@@ -15961,6 +16142,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15961
16142
  socket.removeAllListeners("error");
15962
16143
  socket.on("error", (error) => this.emit("error", error));
15963
16144
  this.socket = socket;
16145
+ this.requestReceiveBuffer(socket);
15964
16146
  }
15965
16147
  this.connected = true;
15966
16148
  this.connectedAtMs = Date.now();
@@ -16248,6 +16430,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16248
16430
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16249
16431
  return true;
16250
16432
  }
16433
+ /**
16434
+ * The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
16435
+ * level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
16436
+ */
16437
+ sendRawLevel1Bytes(payload, channel, outerCmd) {
16438
+ if (!this.connectAddress)
16439
+ return false;
16440
+ const data = Buffer.concat([
16441
+ buildCommandHeader(this.seqNumber, outerCmd),
16442
+ buildRawCommandPayload(encryptP2PData(paddingP2PData(payload), this.level1Key), channel, 1)
16443
+ ]);
16444
+ this.seqNumber = this.seqNumber + 1 & 65535;
16445
+ this.send(this.connectAddress, RequestMessageType.DATA, data);
16446
+ return true;
16447
+ }
16251
16448
  /**
16252
16449
  * Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
16253
16450
  * its runtime topology takes, as {@link startLiveMedia} selects its start:
@@ -16468,7 +16665,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16468
16665
  camera_type: 0,
16469
16666
  entrytype: 0,
16470
16667
  key: this.rsaModulus(),
16471
- streamtype: 1,
16668
+ streamtype: 2,
16472
16669
  ...payload
16473
16670
  } : payload;
16474
16671
  const value = JSON.stringify({
@@ -16684,6 +16881,29 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16684
16881
  query: this.fullTableQuery()
16685
16882
  });
16686
16883
  }
16884
+ /**
16885
+ * Read a date-ranged page of `history_record_info` from the station. The reply's `data` array groups
16886
+ * recording rows with companion tables; each group retains its `table_name` and `payload`.
16887
+ * `startTime` is `"0"` for the newest page or the oldest row's `yyyyMMddHHmmss` cursor for older rows.
16888
+ */
16889
+ queryRecordPage(opts) {
16890
+ return this.readDatabase("history_record_info", {
16891
+ accountId: opts.accountId,
16892
+ innerCmd: DB_QUERY.QUERY_LOCAL,
16893
+ query: {
16894
+ count: opts.count ?? 20,
16895
+ start_date: opts.startDate,
16896
+ end_date: opts.endDate,
16897
+ start_time: opts.startTime ?? "0",
16898
+ event_type: 0,
16899
+ ai_type: 0,
16900
+ storage_cloud: -1,
16901
+ trigger_type: 0,
16902
+ detection_type: 0,
16903
+ flag: 0
16904
+ }
16905
+ });
16906
+ }
16687
16907
  /**
16688
16908
  * Query one on-station table and answer its rows, once the reply is whole.
16689
16909
  *
@@ -16691,7 +16911,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16691
16911
  * several frames whose decrypted text is a fragment of one document, so the fragments are
16692
16912
  * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16693
16913
  * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16694
- * complete reply.
16914
+ * complete reply, and when the station answers a non-zero `mIntRet`.
16695
16915
  *
16696
16916
  * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16697
16917
  * session throws, so a second reply cannot land in this buffer.
@@ -16699,16 +16919,22 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16699
16919
  async readDatabase(table, opts = {}) {
16700
16920
  if (opts.signal?.aborted)
16701
16921
  throw new Error("readDatabase: aborted");
16702
- this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16922
+ this.queryDatabase(table, {
16923
+ accountId: opts.accountId,
16924
+ innerCmd: opts.innerCmd,
16925
+ query: opts.query ?? this.fullTableQuery()
16926
+ });
16703
16927
  this.dbReadInFlight = true;
16704
16928
  try {
16705
16929
  return await new Promise((resolve, reject) => {
16706
16930
  let text2 = "";
16707
16931
  const onChunk = (chunk) => {
16708
16932
  text2 += chunk.text;
16709
- const rows = firstTableRows(text2);
16710
- if (rows)
16711
- settle(() => resolve(rows));
16933
+ const result = firstTableRows(text2);
16934
+ if (result?.error !== void 0)
16935
+ settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
16936
+ else if (result?.rows)
16937
+ settle(() => resolve(result.rows));
16712
16938
  };
16713
16939
  const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16714
16940
  const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
@@ -16809,11 +17035,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16809
17035
  if (advance > SEQUENCE_LOOKBACK) {
16810
17036
  if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
16811
17037
  return;
16812
- if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
17038
+ if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
16813
17039
  this.trace({ phase: "sequence-restart", dataType });
16814
17040
  this.clearReorderTimer(dataType);
16815
17041
  this.reorderByType.delete(dataType);
16816
17042
  this.pendingByDataType.delete(dataType);
17043
+ if (dataType === P2PDataType.VIDEO)
17044
+ this.emit("videoGap");
16817
17045
  this.lastSeqByType.set(dataType, seqNo);
16818
17046
  this.reassemble(dataType, msg.subarray(8));
16819
17047
  return;
@@ -16879,9 +17107,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16879
17107
  */
16880
17108
  abandonHole(dataType) {
16881
17109
  const held = this.reorderByType.get(dataType).held;
16882
- if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
17110
+ if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
16883
17111
  this.trace({ phase: "datagram-gap", dataType });
16884
17112
  this.pendingByDataType.delete(dataType);
17113
+ if (dataType === P2PDataType.VIDEO)
17114
+ this.emit("videoGap");
16885
17115
  const last = this.lastSeqByType.get(dataType);
16886
17116
  const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
16887
17117
  this.lastSeqByType.set(dataType, earliest - 1 & 65535);
@@ -16900,32 +17130,35 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16900
17130
  * Frames are packed back to back, so a frame header can itself be cut by a datagram boundary. The start
16901
17131
  * of a header left at the end of a datagram is carried into the next one rather than discarded; dropping
16902
17132
  * it would lose that frame and every frame after it until a datagram happened to begin on a header.
17133
+ * Payload fragments retain their original slices and are copied once, when the complete frame is delivered.
16903
17134
  */
16904
17135
  reassemble(dataType, datagramBody) {
16905
17136
  const pending = this.pendingByDataType.get(dataType);
16906
- let body = pending ? Buffer.concat([pending.buf, datagramBody]) : datagramBody;
16907
- const carryHeader = pending?.header;
16908
17137
  this.pendingByDataType.delete(dataType);
16909
- if (carryHeader) {
16910
- if (body.length < carryHeader.bytesToRead) {
16911
- this.pendingByDataType.set(dataType, { header: carryHeader, buf: body });
17138
+ let body = pending && !pending.header ? Buffer.concat([...pending.chunks, datagramBody]) : datagramBody;
17139
+ if (pending?.header) {
17140
+ const taken = Math.min(pending.header.bytesToRead - pending.byteLength, body.length);
17141
+ pending.chunks.push(body.subarray(0, taken));
17142
+ pending.byteLength += taken;
17143
+ body = body.subarray(taken);
17144
+ if (pending.byteLength < pending.header.bytesToRead) {
17145
+ this.pendingByDataType.set(dataType, pending);
16912
17146
  return;
16913
17147
  }
16914
- this.handleFrame(carryHeader, body.subarray(0, carryHeader.bytesToRead), dataType);
16915
- body = body.subarray(carryHeader.bytesToRead);
17148
+ this.handleFrame(pending.header, Buffer.concat(pending.chunks, pending.byteLength), dataType);
16916
17149
  }
16917
17150
  while (body.length >= P2P_DATA_HEADER_BYTES && body.subarray(0, 4).toString() === MAGIC_WORD) {
16918
17151
  const header = parseDataFrameHeader(body);
16919
17152
  const payload = body.subarray(P2P_DATA_HEADER_BYTES);
16920
17153
  if (payload.length < header.bytesToRead) {
16921
- this.pendingByDataType.set(dataType, { header, buf: payload });
17154
+ this.pendingByDataType.set(dataType, { header, chunks: [payload], byteLength: payload.length });
16922
17155
  return;
16923
17156
  }
16924
17157
  this.handleFrame(header, payload.subarray(0, header.bytesToRead), dataType);
16925
17158
  body = body.subarray(P2P_DATA_HEADER_BYTES + header.bytesToRead);
16926
17159
  }
16927
17160
  if (body.length > 0 && body.length < P2P_DATA_HEADER_BYTES && MAGIC_WORD.startsWith(body.subarray(0, 4).toString())) {
16928
- this.pendingByDataType.set(dataType, { buf: body });
17161
+ this.pendingByDataType.set(dataType, { chunks: [body], byteLength: body.length });
16929
17162
  }
16930
17163
  }
16931
17164
  /**
@@ -17518,7 +17751,8 @@ var AccessUnitAssembler = class {
17518
17751
  * `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
17519
17752
  * frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
17520
17753
  * of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
17521
- * carries).
17754
+ * carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
17755
+ * as a complete unit, even if its repeated header says keyframe.
17522
17756
  */
17523
17757
  push(payload, decode) {
17524
17758
  const header = parseVideoFrameHeader(payload);
@@ -17538,6 +17772,8 @@ var AccessUnitAssembler = class {
17538
17772
  return [unitOf(open2.header, Buffer.concat(open2.chunks))];
17539
17773
  }
17540
17774
  this.discard();
17775
+ if (!beginsAccessUnit(body))
17776
+ return [];
17541
17777
  if (full) {
17542
17778
  this.open = { header, chunks: [body], carried: body.length };
17543
17779
  return [];
@@ -17563,7 +17799,13 @@ function beginsAccessUnit(body) {
17563
17799
  return body[2] === 1 || body[2] === 0 && body[3] === 1;
17564
17800
  }
17565
17801
  function unitOf(header, data) {
17566
- return { keyframe: header.keyframe, width: header.width, height: header.height, data };
17802
+ return {
17803
+ sourceTimestamp: header.timestamp,
17804
+ keyframe: header.keyframe,
17805
+ width: header.width,
17806
+ height: header.height,
17807
+ data
17808
+ };
17567
17809
  }
17568
17810
 
17569
17811
  // dist/transport/p2p/annexb.js
@@ -17666,6 +17908,18 @@ function startCodeBegin(buf, payloadOffset) {
17666
17908
  return threeByte - 1;
17667
17909
  return threeByte;
17668
17910
  }
17911
+ function hasIdr(buf, codec) {
17912
+ for (const p of nalStarts(buf)) {
17913
+ const b = buf[p];
17914
+ if (codec === "h265") {
17915
+ const t = b >> 1 & 63;
17916
+ if (t === 19 || t === 20)
17917
+ return true;
17918
+ } else if ((b & 31) === 5)
17919
+ return true;
17920
+ }
17921
+ return false;
17922
+ }
17669
17923
  var MAX_CODED_DIMENSION = 32768;
17670
17924
  var H264_CHROMA_PROFILES = /* @__PURE__ */ new Set([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135, 144]);
17671
17925
  var CHROMA_SUBSAMPLING = {
@@ -17878,6 +18132,7 @@ var AUDIO_HEADER_LEN = 16;
17878
18132
  var AUDIO_TYPE_OFFSET = 5;
17879
18133
  var SC4 = Buffer.from([0, 0, 0, 1]);
17880
18134
  var SC3 = Buffer.from([0, 0, 1]);
18135
+ var AAC_ELD_AUDIO_SPECIFIC_CONFIG = Buffer.from("f8f03000", "hex");
17881
18136
  var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
17882
18137
  var MAX_TRACED_DECODE_FAILURES = 3;
17883
18138
  var DEFAULT_KEEPALIVE_MS = 3e3;
@@ -17891,6 +18146,12 @@ var LiveStream = class extends EventEmitter3 {
17891
18146
  lastCodec = "h264";
17892
18147
  /** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
17893
18148
  units = new AccessUnitAssembler((drop) => this.reportDroppedUnit(drop));
18149
+ /** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
18150
+ awaitingKeyframeAfterGap = false;
18151
+ videoGapHandler = () => {
18152
+ this.units.discard();
18153
+ this.awaitingKeyframeAfterGap = true;
18154
+ };
17894
18155
  /** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
17895
18156
  mediaChannel;
17896
18157
  tracedFirstVideoCommand = false;
@@ -17933,6 +18194,7 @@ var LiveStream = class extends EventEmitter3 {
17933
18194
  return this;
17934
18195
  this.listening = true;
17935
18196
  this.session.on("data", this.handler);
18197
+ this.session.on("videoGap", this.videoGapHandler);
17936
18198
  this.session.on("liveStartUnacknowledged", this.unackedHandler);
17937
18199
  this.sendStart();
17938
18200
  const keepAliveMs = this.opts.keepAliveMs ?? DEFAULT_KEEPALIVE_MS;
@@ -18030,6 +18292,8 @@ var LiveStream = class extends EventEmitter3 {
18030
18292
  return;
18031
18293
  this.listening = false;
18032
18294
  this.session.off("data", this.handler);
18295
+ this.session.off("videoGap", this.videoGapHandler);
18296
+ this.awaitingKeyframeAfterGap = false;
18033
18297
  this.session.off("liveStartUnacknowledged", this.unackedHandler);
18034
18298
  if (this.kaTimer)
18035
18299
  clearInterval(this.kaTimer);
@@ -18065,6 +18329,11 @@ var LiveStream = class extends EventEmitter3 {
18065
18329
  try {
18066
18330
  if (f.commandId === CMD_VIDEO_FRAME2) {
18067
18331
  for (const unit of this.units.push(f.data, (payload) => this.annexbOf(payload, f.signCode))) {
18332
+ if (this.awaitingKeyframeAfterGap) {
18333
+ if (!unit.keyframe)
18334
+ continue;
18335
+ this.awaitingKeyframeAfterGap = false;
18336
+ }
18068
18337
  if (!this.tracedFirstVideoUnit) {
18069
18338
  this.tracedFirstVideoUnit = true;
18070
18339
  this.trace({ phase: "first-video-unit", keyframe: unit.keyframe });
@@ -18077,6 +18346,7 @@ var LiveStream = class extends EventEmitter3 {
18077
18346
  this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
18078
18347
  this.settleKeepalive();
18079
18348
  this.emit("video", {
18349
+ sourceTimestamp: unit.sourceTimestamp,
18080
18350
  keyframe: unit.keyframe,
18081
18351
  width: unit.width,
18082
18352
  height: unit.height,
@@ -18092,7 +18362,7 @@ var LiveStream = class extends EventEmitter3 {
18092
18362
  if (!codec) {
18093
18363
  this.logger.debug(`[live] dropping audio frame: unknown codec id ${codecId ?? "missing"}`);
18094
18364
  } else {
18095
- this.emit("audio", { codec, data: audio });
18365
+ this.emit("audio", codec === "aac-eld" ? { codec, data: audio, config: AAC_ELD_AUDIO_SPECIFIC_CONFIG } : { codec, data: audio });
18096
18366
  }
18097
18367
  }
18098
18368
  }
@@ -18159,6 +18429,7 @@ var LiveStream = class extends EventEmitter3 {
18159
18429
  * otherwise silent in both directions: no frame reaches a consumer, and nothing states why.
18160
18430
  */
18161
18431
  reportDroppedUnit(drop) {
18432
+ this.awaitingKeyframeAfterGap = true;
18162
18433
  const message = `[live] dropped an incomplete access unit (${drop.carried} bytes in ${drop.chunks} frame(s), tail never arrived, ${drop.count} so far)`;
18163
18434
  if (drop.count === 1)
18164
18435
  this.logger.warn(message);
@@ -18572,9 +18843,11 @@ function decodeRecording(frames, eccPrivateKeyHex) {
18572
18843
  if (eccPrivateKey.length !== 32)
18573
18844
  throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
18574
18845
  const decoder = new VideoFrameDecoder(eccPrivateKey);
18846
+ const assembler = new AccessUnitAssembler();
18575
18847
  const video = [];
18576
18848
  const audio = [];
18577
18849
  const numbers = /* @__PURE__ */ new Set();
18850
+ const receivedNumbers = /* @__PURE__ */ new Set();
18578
18851
  let firstStamp;
18579
18852
  let lastStamp;
18580
18853
  for (const { commandId, raw } of frames) {
@@ -18582,14 +18855,15 @@ function decodeRecording(frames, eccPrivateKeyHex) {
18582
18855
  const header = parseVideoFrameHeader(raw);
18583
18856
  if (!header)
18584
18857
  continue;
18585
- const h264 = header.keyframe ? decoder.decodeFrame(raw)?.h264 : raw.length >= VIDEO_HEADER_LEN2 + header.payloadLength ? raw.subarray(VIDEO_HEADER_LEN2, VIDEO_HEADER_LEN2 + header.payloadLength) : void 0;
18586
- if (!h264?.length)
18587
- continue;
18588
- video.push(h264);
18589
- numbers.add(header.sequence);
18590
- const stamp = raw.readUIntLE(14, 6);
18591
- firstStamp ??= stamp;
18592
- lastStamp = stamp;
18858
+ receivedNumbers.add(header.sequence);
18859
+ const units = assembler.push(raw, (payload) => header.keyframe ? decoder.decodeFrame(payload)?.h264 : payload.length >= VIDEO_HEADER_LEN2 + header.payloadLength ? payload.subarray(VIDEO_HEADER_LEN2, VIDEO_HEADER_LEN2 + header.payloadLength) : void 0);
18860
+ for (const unit of units) {
18861
+ video.push(unit.data);
18862
+ numbers.add(header.sequence);
18863
+ const stamp = raw.readUIntLE(14, 6);
18864
+ firstStamp ??= stamp;
18865
+ lastStamp = stamp;
18866
+ }
18593
18867
  } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
18594
18868
  const mediaKey = decoder.currentMediaKey;
18595
18869
  if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
@@ -18605,9 +18879,10 @@ function decodeRecording(frames, eccPrivateKeyHex) {
18605
18879
  if (!video.length)
18606
18880
  throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
18607
18881
  const ordered = [...numbers];
18608
- let missingFrames = 0;
18609
- for (let i = 1; i < ordered.length; i++) {
18610
- const step = ordered[i] - ordered[i - 1] & 65535;
18882
+ const received = [...receivedNumbers];
18883
+ let missingFrames = receivedNumbers.size - numbers.size;
18884
+ for (let i = 1; i < received.length; i++) {
18885
+ const step = received[i] - received[i - 1] & 65535;
18611
18886
  if (step > 1 && step < 32768)
18612
18887
  missingFrames += step - 1;
18613
18888
  }
@@ -19415,6 +19690,14 @@ var SharedLiveSource = class {
19415
19690
  this.opts.onIdle?.();
19416
19691
  }
19417
19692
  };
19693
+ function attachUnlessAborted(source, signal) {
19694
+ const consumer = source.attach();
19695
+ if (signal?.aborted) {
19696
+ consumer.detach();
19697
+ signal.throwIfAborted();
19698
+ }
19699
+ return consumer;
19700
+ }
19418
19701
 
19419
19702
  // dist/transport/p2p/session-manager.js
19420
19703
  var SessionSupersededError = class extends Error {
@@ -19828,7 +20111,8 @@ var AdtsFrameReader = class {
19828
20111
  };
19829
20112
 
19830
20113
  // dist/transport/p2p/talkback.js
19831
- var DEFAULT_HIGH_WATER_FRAMES = Math.ceil(2e3 / AAC_FRAME_MS);
20114
+ var HIGH_WATER_MS = 2e3;
20115
+ var TALKBACK_ELD_FRAME_MS = 32;
19832
20116
  var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
19833
20117
  var Talkback = class extends EventEmitter5 {
19834
20118
  sink;
@@ -19836,7 +20120,8 @@ var Talkback = class extends EventEmitter5 {
19836
20120
  reader = new AdtsFrameReader();
19837
20121
  queue = [];
19838
20122
  logger;
19839
- highWater;
20123
+ /** Playback duration of one queued frame, the pacer's step: 64 ms for `aac-lc`, 32 ms for `aac-eld`. */
20124
+ frameMs = AAC_FRAME_MS;
19840
20125
  idleTimeoutMs;
19841
20126
  timer;
19842
20127
  started = false;
@@ -19862,9 +20147,14 @@ var Talkback = class extends EventEmitter5 {
19862
20147
  this.sink = sink;
19863
20148
  this.opts = opts;
19864
20149
  this.logger = opts.logger ?? noopLogger;
19865
- this.highWater = opts.highWaterFrames ?? DEFAULT_HIGH_WATER_FRAMES;
19866
20150
  this.idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
19867
20151
  }
20152
+ /** Frames the pacing queue holds before a writable applies backpressure: {@link HIGH_WATER_MS} of this codec. */
20153
+ get highWater() {
20154
+ return this.opts.highWaterFrames ?? Math.ceil(HIGH_WATER_MS / this.frameMs);
20155
+ }
20156
+ /** The codec this talkback sends, set by {@link start}. */
20157
+ codec = "aac-lc";
19868
20158
  /** Frames still queued for the wire. */
19869
20159
  get pending() {
19870
20160
  return this.queue.length;
@@ -19873,25 +20163,33 @@ var Talkback = class extends EventEmitter5 {
19873
20163
  * Open the device's talkback path and begin the pacing tick. Throws when the topology's control
19874
20164
  * frame could not be sent — for a HomeBase-attached camera that means the level-2 key was never
19875
20165
  * negotiated, which would otherwise leave a silent session that accepts audio nobody hears.
20166
+ *
20167
+ * `codec` is the codec the camera's speaker plays; it fixes the input this talkback accepts and the pacing step.
19876
20168
  */
19877
- start() {
20169
+ start(codec) {
19878
20170
  if (this.started)
19879
20171
  return this;
20172
+ if (codec === "aac-eld" && this.opts.encoder) {
20173
+ throw new Error("talkback: this camera plays aac-eld; an AacEncoder produces aac-lc");
20174
+ }
20175
+ this.codec = codec;
20176
+ this.frameMs = codec === "aac-eld" ? TALKBACK_ELD_FRAME_MS : AAC_FRAME_MS;
19880
20177
  if (!this.sink.startTalkback(this.opts.channel, this.opts.homeBaseAttached)) {
19881
20178
  throw new Error(`talkback: could not open the audio path on channel ${this.opts.channel}`);
19882
20179
  }
19883
20180
  this.started = true;
19884
20181
  const now2 = Date.now();
19885
- this.nextDueAt = now2 + AAC_FRAME_MS;
20182
+ this.nextDueAt = now2 + this.frameMs;
19886
20183
  this.lastWriteAt = now2;
19887
- this.timer = setInterval(() => this.tick(), AAC_FRAME_MS);
20184
+ this.timer = setInterval(() => this.tick(), this.frameMs);
19888
20185
  this.timer.unref?.();
19889
20186
  this.sink.on?.("audioGap", this.onAudioGap);
19890
20187
  return this;
19891
20188
  }
19892
20189
  /**
19893
- * Queue audio. Bytes are ADTS AAC, or PCM when an encoder was supplied; either way the input is
19894
- * treated as a stream, so a chunk carrying part of a frame is held until the rest arrives.
20190
+ * Queue audio in the codec this talkback sends. For `aac-lc` the bytes are ADTS, or PCM when an encoder was
20191
+ * supplied, read as a stream, so a chunk carrying part of a frame is held until the rest arrives. For `aac-eld`
20192
+ * each call is one whole access unit; a unit over the device's length limit is dropped with an `error`.
19895
20193
  *
19896
20194
  * Audio written after {@link end} is dropped with an `error` rather than queued: `finished` has
19897
20195
  * either already fired or is owed on the frames written before it, so a late chunk would either play
@@ -19905,6 +20203,15 @@ var Talkback = class extends EventEmitter5 {
19905
20203
  return;
19906
20204
  }
19907
20205
  this.lastWriteAt = Date.now();
20206
+ if (this.codec === "aac-eld") {
20207
+ if (chunk.length > MAX_AUDIO_FRAME_BYTES) {
20208
+ this.fail(new Error(`talkback: frame of ${chunk.length} B exceeds the device's ${MAX_AUDIO_FRAME_BYTES} B limit`));
20209
+ return;
20210
+ }
20211
+ if (chunk.length)
20212
+ this.queue.push(chunk);
20213
+ return;
20214
+ }
19908
20215
  if (!this.opts.encoder) {
19909
20216
  this.enqueue(this.reader.push(chunk));
19910
20217
  return;
@@ -19973,14 +20280,14 @@ var Talkback = class extends EventEmitter5 {
19973
20280
  return;
19974
20281
  const now2 = Date.now();
19975
20282
  if (!this.queue.length) {
19976
- this.nextDueAt = now2 + AAC_FRAME_MS;
20283
+ this.nextDueAt = now2 + this.frameMs;
19977
20284
  this.idleCheck(now2);
19978
20285
  this.announceFinishIfDone();
19979
20286
  return;
19980
20287
  }
19981
20288
  while (this.queue.length && now2 >= this.nextDueAt) {
19982
20289
  const frame = this.queue.shift();
19983
- this.nextDueAt += AAC_FRAME_MS;
20290
+ this.nextDueAt += this.frameMs;
19984
20291
  try {
19985
20292
  this.sink.sendAudioFrame(this.opts.channel, frame);
19986
20293
  } catch (e) {
@@ -20038,9 +20345,11 @@ var Talkback = class extends EventEmitter5 {
20038
20345
  /**
20039
20346
  * A Writable over {@link write} that withholds its callback while the queue is at the high-water
20040
20347
  * mark — which is what turns `pipe()` from a file into playback at speed rather than a memory spike.
20348
+ * For `aac-eld` it is object-mode: each write is one access unit.
20041
20349
  */
20042
20350
  writable() {
20043
20351
  return new Writable({
20352
+ objectMode: this.codec === "aac-eld",
20044
20353
  write: (chunk, _enc, cb) => {
20045
20354
  this.write(chunk);
20046
20355
  if (this.queue.length < this.highWater)
@@ -20109,7 +20418,7 @@ var CODEC_BOXES = {
20109
20418
  h264: { sample: "avc1", config: "avcC" },
20110
20419
  h265: { sample: "hvc1", config: "hvcC" }
20111
20420
  };
20112
- var AAC_ELD_SAMPLES_PER_FRAME = 512;
20421
+ var AAC_ELD_SAMPLES_PER_FRAME = 480;
20113
20422
  var ADTS_FREQUENCY_INDEX_16K = 8;
20114
20423
  var ADTS_CHANNELS_MONO = 1;
20115
20424
  var Fmp4Muxer = class {
@@ -20124,9 +20433,12 @@ var Fmp4Muxer = class {
20124
20433
  fragTicks = 0;
20125
20434
  // ticks accumulated in the open fragment
20126
20435
  samples = [];
20127
- lastVideoTimestampMs;
20436
+ /** Ticks from the first video sample's start to the newest sample's end, at its provisional duration. */
20437
+ videoTicks = 0;
20128
20438
  audioRequested;
20129
20439
  audioCodec;
20440
+ /** Decoder config of an AAC-ELD track, taken from its frames; absent for AAC-LC, whose `esds` config is fixed. */
20441
+ audioConfig;
20130
20442
  audioDisabled = false;
20131
20443
  audioSamples = [];
20132
20444
  audioBaseDecodeTime = 0;
@@ -20146,6 +20458,10 @@ var Fmp4Muxer = class {
20146
20458
  * Push one access unit. Returns any completed segments: the `init` on the first keyframe, and/or a
20147
20459
  * media fragment when this frame closed the open one. Returns `undefined` if nothing is emitted yet
20148
20460
  * (e.g. delta frames before the first keyframe).
20461
+ *
20462
+ * A sample lasts until the next frame's `timestampMs`, measured from the first keyframe's, so the video
20463
+ * timeline follows the arrival clock; a frame that shares the previous one's timestamp leaves that
20464
+ * sample one tick long, and the next frame's duration absorbs the difference.
20149
20465
  */
20150
20466
  push(frame, timestampMs = now()) {
20151
20467
  if (frame.codec === "av1")
@@ -20164,15 +20480,14 @@ var Fmp4Muxer = class {
20164
20480
  this.height = declared?.height ?? frame.height;
20165
20481
  this.firstVideoTimestampMs = timestampMs;
20166
20482
  }
20167
- if (this.lastVideoTimestampMs !== void 0 && this.samples.length) {
20168
- const measured = Math.round((timestampMs - this.lastVideoTimestampMs) * (TIMESCALE / 1e3));
20169
- if (measured > 0) {
20170
- const previous = this.samples[this.samples.length - 1];
20171
- this.fragTicks += measured - previous.duration;
20172
- previous.duration = measured;
20173
- }
20483
+ if (this.firstVideoTimestampMs !== void 0 && this.samples.length) {
20484
+ const previous = this.samples[this.samples.length - 1];
20485
+ const arrival = Math.round((timestampMs - this.firstVideoTimestampMs) * (TIMESCALE / 1e3));
20486
+ const duration = Math.max(1, arrival - (this.videoTicks - previous.duration));
20487
+ this.fragTicks += duration - previous.duration;
20488
+ this.videoTicks += duration - previous.duration;
20489
+ previous.duration = duration;
20174
20490
  }
20175
- this.lastVideoTimestampMs = timestampMs;
20176
20491
  let fragment;
20177
20492
  if (frame.keyframe && this.samples.length > 0 && this.fragTicks >= this.fragmentTicks) {
20178
20493
  if (!this.initSent) {
@@ -20184,6 +20499,7 @@ var Fmp4Muxer = class {
20184
20499
  }
20185
20500
  this.samples.push({ data: annexbToAvcc(frame.data), duration: this.firstDuration, keyframe: frame.keyframe });
20186
20501
  this.fragTicks += this.firstDuration;
20502
+ this.videoTicks += this.firstDuration;
20187
20503
  if (!this.initSent && (!this.audioRequested || this.audioCodec || this.audioDisabled)) {
20188
20504
  init = this.buildInit();
20189
20505
  this.initSent = true;
@@ -20193,7 +20509,10 @@ var Fmp4Muxer = class {
20193
20509
  }
20194
20510
  return void 0;
20195
20511
  }
20196
- /** Add one station-declared AAC access unit to the audio track. G.711 remains available via `live()`. */
20512
+ /**
20513
+ * Add one station-declared AAC access unit to the audio track: an ADTS frame for AAC-LC, a raw access unit
20514
+ * described by its frame's config for AAC-ELD. G.711 remains available via `live()`.
20515
+ */
20197
20516
  pushAudio(frame, timestampMs = now()) {
20198
20517
  if (!this.audioRequested || this.audioDisabled)
20199
20518
  return void 0;
@@ -20209,12 +20528,19 @@ var Fmp4Muxer = class {
20209
20528
  if (this.audioCodec && this.audioCodec !== frame.codec) {
20210
20529
  return this.disableAudio();
20211
20530
  }
20212
- const header = parseAdtsHeader(frame.data);
20213
- if (!header || header.frameLength > frame.data.length) {
20214
- throw new Error(`fMP4 muxer: ${frame.codec} frame is not a complete ADTS access unit`);
20215
- }
20216
- if (header.frequencyIndex !== ADTS_FREQUENCY_INDEX_16K || header.channels !== ADTS_CHANNELS_MONO) {
20217
- return this.disableAudio();
20531
+ let sample;
20532
+ if (frame.codec === "aac-eld") {
20533
+ this.audioConfig = frame.config;
20534
+ sample = frame.data;
20535
+ } else {
20536
+ const header = parseAdtsHeader(frame.data);
20537
+ if (!header || header.frameLength > frame.data.length) {
20538
+ throw new Error(`fMP4 muxer: ${frame.codec} frame is not a complete ADTS access unit`);
20539
+ }
20540
+ if (header.frequencyIndex !== ADTS_FREQUENCY_INDEX_16K || header.channels !== ADTS_CHANNELS_MONO) {
20541
+ return this.disableAudio();
20542
+ }
20543
+ sample = frame.data.subarray(header.headerLength, header.frameLength);
20218
20544
  }
20219
20545
  this.audioCodec = frame.codec;
20220
20546
  this.firstAudioTimestampMs ??= timestampMs;
@@ -20226,7 +20552,7 @@ var Fmp4Muxer = class {
20226
20552
  this.audioSamples[this.audioSamples.length - 1].duration = measured;
20227
20553
  }
20228
20554
  this.audioSamples.push({
20229
- data: frame.data.subarray(header.headerLength, header.frameLength),
20555
+ data: sample,
20230
20556
  duration: frame.codec === "aac-eld" ? AAC_ELD_SAMPLES_PER_FRAME : AAC_SAMPLES_PER_FRAME,
20231
20557
  keyframe: true
20232
20558
  });
@@ -20499,7 +20825,7 @@ var Fmp4Muxer = class {
20499
20825
  return box("mp4a", head, this.esds());
20500
20826
  }
20501
20827
  esds() {
20502
- const config = this.audioCodec === "aac-eld" ? Buffer.from([248, 240, 32]) : Buffer.from([20, 8]);
20828
+ const config = this.audioConfig ?? Buffer.from([20, 8]);
20503
20829
  const specific = descriptor(5, config);
20504
20830
  const decoder = descriptor(4, Buffer.concat([Buffer.from([64, 21, 0, 0, 0]), u32(64e3), u32(32e3), specific]));
20505
20831
  const sl = descriptor(6, Buffer.from([2]));
@@ -20704,16 +21030,12 @@ function stationOf(dev) {
20704
21030
  const parent = typeof raw.parent_sn === "string" && raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : void 0;
20705
21031
  return parent ?? dev.stationSn ?? resolvedStationSn(raw, dev.sn);
20706
21032
  }
20707
- function statedChannel(dev) {
20708
- const v = dev.raw?.device_channel;
20709
- return typeof v === "number" ? v : void 0;
20710
- }
20711
21033
  function stationChannels(devices) {
20712
21034
  const out = /* @__PURE__ */ new Map();
20713
21035
  const claimants = /* @__PURE__ */ new Map();
20714
21036
  for (const d of devices) {
20715
21037
  const station = stationOf(d);
20716
- const stated = statedChannel(d);
21038
+ const stated = d.channel;
20717
21039
  if (d.sn === station || stated === void 0)
20718
21040
  continue;
20719
21041
  const counts = claimants.get(station) ?? /* @__PURE__ */ new Map();
@@ -20722,7 +21044,7 @@ function stationChannels(devices) {
20722
21044
  }
20723
21045
  for (const d of devices) {
20724
21046
  const station = stationOf(d);
20725
- const stated = statedChannel(d);
21047
+ const stated = d.channel;
20726
21048
  if (d.sn === station)
20727
21049
  out.set(d.sn, { channel: stated ?? 0 });
20728
21050
  else if (stated === void 0)
@@ -20737,17 +21059,6 @@ function stationChannels(devices) {
20737
21059
 
20738
21060
  // dist/transport/p2p/command-router.js
20739
21061
  var DIRECT_CMD_SENDS = 5;
20740
- function abortable(work, signal) {
20741
- if (!signal)
20742
- return work;
20743
- signal.throwIfAborted();
20744
- return Promise.race([
20745
- work,
20746
- new Promise((_resolve, reject) => {
20747
- signal.addEventListener("abort", () => reject(signal.reason), { once: true });
20748
- })
20749
- ]);
20750
- }
20751
21062
  var LEVEL2_GRACE_MS = 25e3;
20752
21063
  var LEVEL2_SETTLE_MS = 8e3;
20753
21064
  var P2P_STATION_WAITS = {
@@ -20756,6 +21067,30 @@ var P2P_STATION_WAITS = {
20756
21067
  level2Settle: LEVEL2_SETTLE_MS
20757
21068
  };
20758
21069
  var RTSP_URL_READ_TIMEOUT_MS = 12e3;
21070
+ var TALKBACK_CODEC_WAIT_MS = 3e3;
21071
+ var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
21072
+ function speakerCodec(consumer, talk, timeoutMs) {
21073
+ return new Promise((resolve) => {
21074
+ let timer;
21075
+ const settle = (codec) => {
21076
+ clearTimeout(timer);
21077
+ consumer.off("video", onVideo);
21078
+ consumer.off("audio", onAudio);
21079
+ consumer.off("stop", onStop);
21080
+ talk.off("stop", onStop);
21081
+ resolve(codec);
21082
+ };
21083
+ const onVideo = () => {
21084
+ timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
21085
+ };
21086
+ const onAudio = (frame) => settle(frame.codec === "aac-eld" ? "aac-eld" : "aac-lc");
21087
+ const onStop = () => settle("aac-lc");
21088
+ consumer.on("video", onVideo);
21089
+ consumer.on("audio", onAudio);
21090
+ consumer.on("stop", onStop);
21091
+ talk.once("stop", onStop);
21092
+ });
21093
+ }
20759
21094
  var SHARED_LIVE_OPT_KEYS = [
20760
21095
  "eccPrivateKey",
20761
21096
  "keepAliveMs",
@@ -21174,14 +21509,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
21174
21509
  }
21175
21510
  }
21176
21511
  /**
21177
- * Restart a HomeBase. `RESTART_HUB` (1034) is a station-scalar on the broadcast channel 255: a
21178
- * level-2 frame whose body is `[u32 value][account_id padded]` — the same shape as the hub
21179
- * alarm-volume control. ✅ Wire-confirmed byte-exact from a capture of the app's own Restart
21180
- * (2026-08-03) and HW-tested: the captured frame carried value `0` and rebooted the hub. Replays
21181
- * like every other level-2 control, so a single dropped datagram doesn't lose it.
21512
+ * Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
21513
+ * broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
21514
+ * byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
21515
+ *
21516
+ * The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
21517
+ * level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
21518
+ * sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
21519
+ * control here is.
21182
21520
  */
21183
21521
  async rebootStation(sn) {
21184
- await this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8));
21522
+ await this.sendBySessionLevel(sn, {
21523
+ l2: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8), resolved),
21524
+ l1: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel1Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB), resolved)
21525
+ });
21185
21526
  }
21186
21527
  /**
21187
21528
  * A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
@@ -21209,11 +21550,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
21209
21550
  },
21210
21551
  live: async (opts) => {
21211
21552
  const source = await this.sharedLiveSourceFor(sn, opts, true);
21212
- return this.attachUnlessAborted(source, opts?.signal);
21553
+ return attachUnlessAborted(source, opts?.signal);
21213
21554
  },
21214
21555
  openReadable: async (opts) => {
21215
21556
  const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
21216
- return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
21557
+ return openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts);
21217
21558
  },
21218
21559
  recordFragments: (opts) => this.recordFragments(sn, opts),
21219
21560
  talkback: (opts) => this.openTalkback(sn, opts),
@@ -21303,6 +21644,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
21303
21644
  * stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
21304
21645
  * instead of playing into silence.
21305
21646
  *
21647
+ * **On a model in {@link ELD_TALKBACK_MODELS}, the speaker plays the codec the camera sends.** So the first
21648
+ * audio frame of the media session this talkback holds decides its codec ({@link speakerCodec}), within
21649
+ * {@link TALKBACK_CODEC_WAIT_MS} of the session's first frame, and the claim is re-checked after that wait as
21650
+ * after the warm-up. Every other model opens `aac-lc` at once.
21651
+ *
21306
21652
  * The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
21307
21653
  * {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
21308
21654
  * own-session camera that legitimately never negotiates one.
@@ -21372,7 +21718,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
21372
21718
  consumer.on("stop", () => {
21373
21719
  void talk.stop().catch((e) => logger.warn?.(`talkback: stop for ${sn} failed: ${String(e)}`));
21374
21720
  });
21375
- return talk.start();
21721
+ const codec = ELD_TALKBACK_MODELS.has(this.recordFor(sn)?.model ?? "") ? await speakerCodec(consumer, talk, TALKBACK_CODEC_WAIT_MS) : "aac-lc";
21722
+ if (this.talkbacks.get(key) !== talk) {
21723
+ throw new Error(`talkback: ${sn} was closed while its speaker codec was being read`);
21724
+ }
21725
+ return talk.start(codec);
21376
21726
  } catch (e) {
21377
21727
  if (this.talkbacks.get(key) === talk)
21378
21728
  this.talkbacks.delete(key);
@@ -21572,22 +21922,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
21572
21922
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
21573
21923
  * unaffected.
21574
21924
  */
21575
- /**
21576
- * Attach a consumer, unless the caller has already abandoned the call.
21577
- *
21578
- * The acquisition it just waited through can outlast the caller's interest, and a consumer attached for
21579
- * somebody who has gone keeps the pull warm for nobody. Detaching immediately gives the pull back, which
21580
- * lets it linger and fall away if this was the only thing holding it, and leaves it untouched if it was
21581
- * not.
21582
- */
21583
- attachUnlessAborted(source, signal) {
21584
- const consumer = source.attach();
21585
- if (signal?.aborted) {
21586
- consumer.detach();
21587
- signal.throwIfAborted();
21588
- }
21589
- return consumer;
21590
- }
21591
21925
  /**
21592
21926
  * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
21593
21927
  * on a session goes with it, and a media session opened for this source alone is closed: nothing else
@@ -22085,7 +22419,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22085
22419
  * **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
22086
22420
  * exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
22087
22421
  * capture of the app's own SET_SNOOZE_TIME (1271) frame — see `param-dictionary.ts`'s `1271` entry
22088
- * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.ALARM_DELAY_CONFIG`) reuses
22422
+ * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.SET_ALL_ACTION`) reuses
22089
22423
  * the same bare-JSON shape.
22090
22424
  */
22091
22425
  async sendJsonRaw(sn, outerCmd, data, channel) {
@@ -22357,98 +22691,1630 @@ var P2PCommandRouter = class _P2PCommandRouter {
22357
22691
  }
22358
22692
  };
22359
22693
 
22360
- // dist/transport/mqtt/command-router.js
22361
- import { randomBytes as randomBytes3 } from "node:crypto";
22694
+ // dist/transport/rtc/live.js
22695
+ import { EventEmitter as EventEmitter7 } from "node:events";
22362
22696
 
22363
- // dist/transport/mqtt/broker-discovery.js
22364
- import { resolve4 } from "node:dns/promises";
22365
- async function resolveBrokerIps(hostname) {
22697
+ // dist/transport/rtc/portal-packet.js
22698
+ var MAGIC2 = Buffer.from("XZYH", "ascii");
22699
+ var PORTAL_HEADER_LENGTH = 16;
22700
+ var PORTAL_CMD_SET_PAYLOAD = 1350;
22701
+ var PORTAL_STATION_CHANNEL = 255;
22702
+ var PortalLinkType = {
22703
+ /** Command channel: requests out, acknowledgements back. */
22704
+ COMMAND: 1,
22705
+ /** Station-originated frames: pushes, notify payloads, control results. */
22706
+ NOTIFY: 3,
22707
+ /** Live video. */
22708
+ LIVE: 5
22709
+ };
22710
+ function encodeLe(value, width) {
22711
+ const buf = Buffer.alloc(width);
22712
+ let v = value >>> 0;
22713
+ for (let i = 0; i < width; i++) {
22714
+ buf[i] = v & 255;
22715
+ v >>>= 8;
22716
+ }
22717
+ return buf;
22718
+ }
22719
+ function buildPortalHeader(commandId, paramLength, channel, segment, isResponse = 0, devType = 2) {
22720
+ const header = Buffer.alloc(PORTAL_HEADER_LENGTH);
22721
+ MAGIC2.copy(header, 0);
22722
+ encodeLe(commandId, 2).copy(header, 4);
22723
+ encodeLe(paramLength, 4).copy(header, 6);
22724
+ header[10] = 0;
22725
+ header[11] = segment & 255;
22726
+ header[12] = channel & 255;
22727
+ header[13] = 0;
22728
+ header[14] = isResponse & 255;
22729
+ header[15] = devType & 255;
22730
+ return header;
22731
+ }
22732
+ function parsePortalHeader(buf) {
22733
+ if (buf.length < PORTAL_HEADER_LENGTH || buf.subarray(0, 4).compare(MAGIC2) !== 0)
22734
+ return void 0;
22735
+ return {
22736
+ commandId: buf.readUInt16LE(4),
22737
+ paramLength: buf.readUInt32LE(6),
22738
+ segment: buf[11],
22739
+ channel: buf[12],
22740
+ isResponse: buf[14],
22741
+ devType: buf[15]
22742
+ };
22743
+ }
22744
+ function isPortalPacket(buf) {
22745
+ return buf.length >= PORTAL_HEADER_LENGTH && buf.subarray(0, 4).compare(MAGIC2) === 0;
22746
+ }
22747
+ function buildPortalPacket(req) {
22748
+ const body = Buffer.from(JSON.stringify(req.payload), "utf8");
22749
+ const header = buildPortalHeader(req.commandId, body.length, req.channel, req.segment, req.isResponse ?? 0, req.devType ?? 2);
22750
+ return Buffer.concat([header, body]);
22751
+ }
22752
+ function parseJsonBody(buf) {
22753
+ let text2 = buf.toString("utf8");
22754
+ while (text2.endsWith("\0"))
22755
+ text2 = text2.slice(0, -1);
22366
22756
  try {
22367
- return await resolve4(hostname);
22757
+ return JSON.parse(text2);
22368
22758
  } catch {
22369
- return [];
22759
+ return text2;
22370
22760
  }
22371
22761
  }
22372
- function mergeCandidateIps(dnsIps, extraIps = []) {
22373
- return Array.from(/* @__PURE__ */ new Set([...dnsIps, ...extraIps]));
22374
- }
22375
- function rankResults(results) {
22376
- return [...results].sort((a, b) => Number(b.granted) - Number(a.granted));
22377
- }
22378
- async function probeBrokerInstance(ip, creds, opts) {
22379
- const mqtt = await loadMqtt();
22380
- const start = Date.now();
22381
- const timeoutMs = opts.timeoutMs ?? 8e3;
22382
- return await new Promise((resolvePromise) => {
22383
- let settled = false;
22384
- const finish = (r) => {
22385
- if (settled)
22386
- return;
22387
- settled = true;
22388
- clearTimeout(timer);
22389
- try {
22390
- client.end(true);
22391
- } catch {
22392
- }
22393
- resolvePromise({ ip, ms: Date.now() - start, ...r });
22762
+ function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
22763
+ const header = parsePortalHeader(buf);
22764
+ if (!header)
22765
+ return void 0;
22766
+ const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
22767
+ if (linkType === PortalLinkType.NOTIFY) {
22768
+ const data = parseJsonBody(body);
22769
+ const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
22770
+ return {
22771
+ commandId: header.commandId,
22772
+ channel: header.channel,
22773
+ segment: header.segment,
22774
+ isResponse: header.isResponse,
22775
+ linkType,
22776
+ cmd,
22777
+ data
22394
22778
  };
22395
- const client = mqtt.connect({
22396
- host: ip,
22397
- port: creds.port ?? 8883,
22398
- protocol: "mqtts",
22399
- ...bareIpTlsOptions({
22400
- hostname: creds.hostname,
22401
- cert: creds.certificate_pem,
22402
- key: creds.private_key,
22403
- ca: creds.aws_root_ca1_pem
22404
- }),
22405
- clientId: opts.clientId,
22406
- protocolVersion: 4,
22407
- keepalive: 60,
22408
- clean: true,
22409
- reconnectPeriod: 0,
22410
- connectTimeout: timeoutMs
22411
- });
22412
- const timer = setTimeout(() => finish({ granted: false, error: "timeout" }), timeoutMs);
22413
- client.on("connect", () => {
22414
- client.subscribe(opts.topic, { qos: 1 }, (err, granted) => {
22415
- if (err)
22416
- return finish({ granted: false, error: err.message });
22417
- const g = granted?.[0];
22418
- const denied = !g || g.qos === 128;
22419
- finish({ granted: !denied, grantedQos: g?.qos });
22420
- });
22421
- });
22422
- client.on("error", (err) => finish({ granted: false, error: err.message }));
22423
- });
22424
- }
22425
- async function discoverReachableInstance(creds, opts) {
22426
- const dnsIps = await resolveBrokerIps(creds.hostname);
22427
- const all = mergeCandidateIps(dnsIps, opts.candidateIps);
22428
- const results = [];
22429
- for (const ip of all) {
22430
- const r = await probeBrokerInstance(ip, creds, {
22431
- clientId: opts.clientIdFor(ip),
22432
- topic: opts.topic,
22433
- timeoutMs: opts.perAttemptTimeoutMs
22434
- });
22435
- results.push(r);
22436
- if (r.granted && opts.stopOnFirstGrant)
22437
- break;
22438
22779
  }
22439
- return rankResults(results);
22780
+ return {
22781
+ commandId: header.commandId,
22782
+ channel: header.channel,
22783
+ segment: header.segment,
22784
+ isResponse: header.isResponse,
22785
+ linkType,
22786
+ errCode: body.length >= 4 ? body.readInt32LE(0) : -1,
22787
+ data: body.length > 4 ? parseJsonBody(body.subarray(4)) : void 0
22788
+ };
22440
22789
  }
22441
-
22442
- // dist/transport/dp-preset.js
22443
- var RGBCW_CH = {
22444
- R: [15.932, 0.7035, 0.294],
22445
- G: [39.83, 0.1629, 0.7208],
22446
- B: [2.2404, 0.1472, 0.0309],
22447
- W: [66.28, 0.4681, 0.4182],
22448
- C: [73.58, 0.3166, 0.3397]
22790
+ var SegmentCounter = class {
22791
+ current = 0;
22792
+ next() {
22793
+ this.current = this.current + 1 & 255;
22794
+ if (this.current === 0)
22795
+ this.current = 1;
22796
+ return this.current;
22797
+ }
22449
22798
  };
22450
- var RGBCW_X = Object.fromEntries(Object.entries(RGBCW_CH).map(([k, [Y, x, y]]) => [k, [x * Y / y, Y, (1 - x - y) * Y / y]]));
22451
- var RGB_HEX = /^[0-9a-fA-F]{6}$/;
22799
+
22800
+ // dist/transport/rtc/live.js
22801
+ var LIVE_START = 1003;
22802
+ var LIVE_STOP = 1004;
22803
+ var LIVE_MEDIA = 1300;
22804
+ var LIVE_MEDIA_CHANNEL = 101;
22805
+ var LIVE_STREAM_ID = 1;
22806
+ var KEEPALIVE_MS = 29e3;
22807
+ var KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
22808
+ var MEDIA_HEADER_LENGTH = 22;
22809
+ var STREAM_TYPE_HEVC = 1;
22810
+ function parseMediaBody(body) {
22811
+ if (body.length < MEDIA_HEADER_LENGTH || body[5] !== STREAM_TYPE_HEVC)
22812
+ return void 0;
22813
+ const length = body.readUInt32LE(0);
22814
+ if (body.length < MEDIA_HEADER_LENGTH + length)
22815
+ return void 0;
22816
+ return {
22817
+ data: body.subarray(MEDIA_HEADER_LENGTH, MEDIA_HEADER_LENGTH + length),
22818
+ width: body.readUInt16LE(10),
22819
+ height: body.readUInt16LE(12)
22820
+ };
22821
+ }
22822
+ var RtcLiveStream = class extends EventEmitter7 {
22823
+ opts;
22824
+ running = false;
22825
+ startSegment = -1;
22826
+ keepalive;
22827
+ width = 0;
22828
+ height = 0;
22829
+ onMedia = (frame) => this.onMediaFrame(frame);
22830
+ onCommand = (frame, linkType) => this.onCommandFrame(frame, linkType);
22831
+ onClose = () => this.fail(new Error(`rtc live ${this.tag}: session closed`));
22832
+ constructor(opts) {
22833
+ super();
22834
+ this.opts = opts;
22835
+ }
22836
+ get tag() {
22837
+ return `${this.opts.stationSn}#${this.opts.channel}`;
22838
+ }
22839
+ start() {
22840
+ if (this.running)
22841
+ return this;
22842
+ const { session, seg, accountId } = this.opts;
22843
+ this.running = true;
22844
+ session.on("mediaData", this.onMedia);
22845
+ session.on("commandData", this.onCommand);
22846
+ session.on("close", this.onClose);
22847
+ this.startSegment = seg.next();
22848
+ const sent = session.sendCommand(buildPortalPacket({
22849
+ commandId: PORTAL_CMD_SET_PAYLOAD,
22850
+ channel: PORTAL_STATION_CHANNEL,
22851
+ segment: this.startSegment,
22852
+ isResponse: LIVE_STREAM_ID,
22853
+ payload: {
22854
+ account_id: accountId,
22855
+ cmd: LIVE_START,
22856
+ payload: {
22857
+ ClientOS: "WEB",
22858
+ entrytype: 0,
22859
+ camera_type: 0,
22860
+ key: "",
22861
+ msg_id: 116,
22862
+ audio_chn: -1,
22863
+ streamtype: 2,
22864
+ stitch_mode: 1,
22865
+ chn_list: [{ index: 0, chn: this.opts.channel, sensor: 0, isUps: 0, isClicked: true }],
22866
+ pip_cord: { x1: 0, y1: 0, x2: 0, y2: 0 },
22867
+ station_video_type: 6,
22868
+ play_id: 1
22869
+ }
22870
+ }
22871
+ }));
22872
+ if (!sent) {
22873
+ queueMicrotask(() => this.fail(new Error(`rtc live ${this.tag}: command channel not open`)));
22874
+ return this;
22875
+ }
22876
+ this.keepalive = setInterval(() => {
22877
+ if (!session.sendRaw(KEEPALIVE))
22878
+ this.opts.logger?.warn?.(`[rtc] ${this.tag} keepalive not sent`);
22879
+ }, KEEPALIVE_MS);
22880
+ this.keepalive.unref?.();
22881
+ this.opts.logger?.debug?.(`[rtc] ${this.tag} live start sent`);
22882
+ return this;
22883
+ }
22884
+ stop() {
22885
+ if (!this.running)
22886
+ return;
22887
+ this.teardown();
22888
+ const { session, seg, accountId } = this.opts;
22889
+ session.sendCommand(buildPortalPacket({
22890
+ commandId: PORTAL_CMD_SET_PAYLOAD,
22891
+ channel: PORTAL_STATION_CHANNEL,
22892
+ segment: seg.next(),
22893
+ isResponse: LIVE_STREAM_ID,
22894
+ payload: { account_id: accountId, cmd: LIVE_STOP, payload: {} }
22895
+ }));
22896
+ this.emit("stop");
22897
+ }
22898
+ teardown() {
22899
+ this.running = false;
22900
+ if (this.keepalive)
22901
+ clearInterval(this.keepalive);
22902
+ this.keepalive = void 0;
22903
+ const { session } = this.opts;
22904
+ session.off("mediaData", this.onMedia);
22905
+ session.off("commandData", this.onCommand);
22906
+ session.off("close", this.onClose);
22907
+ }
22908
+ fail(err) {
22909
+ if (!this.running)
22910
+ return;
22911
+ this.teardown();
22912
+ this.emit("error", err);
22913
+ }
22914
+ /** The start's ACK: a non-zero result code ends the pull. */
22915
+ onCommandFrame(frame, linkType) {
22916
+ const p = parsePortalPacket(frame, linkType);
22917
+ if (!p?.isResponse || p.segment !== this.startSegment || p.commandId !== PORTAL_CMD_SET_PAYLOAD)
22918
+ return;
22919
+ if (p.errCode !== 0)
22920
+ this.fail(new Error(`rtc live ${this.tag}: start refused (err ${p.errCode})`));
22921
+ }
22922
+ onMediaFrame(frame) {
22923
+ const h = parsePortalHeader(frame);
22924
+ if (!h || h.commandId !== LIVE_MEDIA || h.channel !== LIVE_MEDIA_CHANNEL)
22925
+ return;
22926
+ if (h.paramLength > frame.length - PORTAL_HEADER_LENGTH)
22927
+ return;
22928
+ const media = parseMediaBody(frame.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + h.paramLength));
22929
+ if (!media)
22930
+ return;
22931
+ const { data, width, height } = media;
22932
+ if (width && height) {
22933
+ this.width = width;
22934
+ this.height = height;
22935
+ }
22936
+ const out = {
22937
+ codec: "h265",
22938
+ data,
22939
+ keyframe: hasIdr(data, "h265"),
22940
+ width: this.width,
22941
+ height: this.height
22942
+ };
22943
+ this.emit("video", out);
22944
+ }
22945
+ };
22946
+
22947
+ // dist/transport/rtc/session.js
22948
+ import { EventEmitter as EventEmitter10 } from "node:events";
22949
+
22950
+ // dist/transport/rtc/peer.js
22951
+ import { EventEmitter as EventEmitter8 } from "node:events";
22952
+
22953
+ // dist/transport/rtc/ptcs-framer.js
22954
+ var MAGIC3 = Buffer.from("PTCS", "ascii");
22955
+ var PTCS_HEADER_LENGTH = 28;
22956
+ var PTCS_DEFAULT_PAYLOAD_BYTES = 1e3;
22957
+ var FLAG_BASE = 16384;
22958
+ var FLAG_LAST = 1024;
22959
+ var LENGTH_MASK = 1023;
22960
+ var PtcsChannel = {
22961
+ COMMAND: 0,
22962
+ NOTIFY: 2,
22963
+ LIVE: 5
22964
+ };
22965
+ function linkTypeForChannel(channel) {
22966
+ if (channel === PtcsChannel.NOTIFY)
22967
+ return PortalLinkType.NOTIFY;
22968
+ if (channel === PtcsChannel.LIVE)
22969
+ return PortalLinkType.LIVE;
22970
+ return PortalLinkType.COMMAND;
22971
+ }
22972
+ var PTCS_STALE_MS = 15e3;
22973
+ function parsePtcsHeader(packet) {
22974
+ if (packet.length < PTCS_HEADER_LENGTH || packet.subarray(0, 4).compare(MAGIC3) !== 0)
22975
+ return void 0;
22976
+ const flags = packet.readUInt16LE(18);
22977
+ return {
22978
+ channel: packet[5],
22979
+ sequence: packet.readUInt16LE(6),
22980
+ frameId: packet.readUInt32LE(8),
22981
+ frameLength: packet.readUInt32LE(12),
22982
+ index: packet.readUInt16LE(16),
22983
+ last: (flags & FLAG_LAST) !== 0,
22984
+ payloadLength: flags & LENGTH_MASK
22985
+ };
22986
+ }
22987
+ function packetize(frame, opts) {
22988
+ const size = opts.payloadBytes ?? PTCS_DEFAULT_PAYLOAD_BYTES;
22989
+ if (size <= 0 || size > LENGTH_MASK)
22990
+ throw new RangeError(`PTCS payload size ${size} out of range`);
22991
+ const channel = opts.channel ?? PtcsChannel.COMMAND;
22992
+ const count2 = Math.max(1, Math.ceil(frame.length / size));
22993
+ const sequence = (opts.sequence ?? 0) & 65535;
22994
+ const packets = [];
22995
+ for (let i = 0; i < count2; i++) {
22996
+ const chunk = frame.subarray(i * size, Math.min(frame.length, (i + 1) * size));
22997
+ const last = i === count2 - 1;
22998
+ const packet = Buffer.alloc(PTCS_HEADER_LENGTH + size);
22999
+ MAGIC3.copy(packet, 0);
23000
+ packet[4] = 3;
23001
+ packet[5] = channel & 255;
23002
+ packet.writeUInt16LE(sequence, 6);
23003
+ packet.writeUInt32LE(opts.frameId >>> 0, 8);
23004
+ packet.writeUInt32LE(frame.length >>> 0, 12);
23005
+ packet.writeUInt16LE(i, 16);
23006
+ packet.writeUInt16LE(FLAG_BASE | (last ? FLAG_LAST : 0) | chunk.length, 18);
23007
+ chunk.copy(packet, PTCS_HEADER_LENGTH);
23008
+ packets.push(packet);
23009
+ }
23010
+ return packets;
23011
+ }
23012
+ var PtcsReassembler = class {
23013
+ onFrame;
23014
+ now;
23015
+ partials = /* @__PURE__ */ new Map();
23016
+ constructor(onFrame, now2 = Date.now) {
23017
+ this.onFrame = onFrame;
23018
+ this.now = now2;
23019
+ }
23020
+ push(packet) {
23021
+ const h = parsePtcsHeader(packet);
23022
+ if (!h)
23023
+ return false;
23024
+ const body = packet.subarray(PTCS_HEADER_LENGTH, PTCS_HEADER_LENGTH + h.payloadLength);
23025
+ if (body.length !== h.payloadLength)
23026
+ return false;
23027
+ const key = `${h.channel}:${h.frameId}`;
23028
+ const now2 = this.now();
23029
+ let p = this.partials.get(key);
23030
+ if (!p) {
23031
+ p = { channel: h.channel, frameLength: h.frameLength, chunks: /* @__PURE__ */ new Map(), touched: now2 };
23032
+ this.partials.set(key, p);
23033
+ }
23034
+ p.touched = now2;
23035
+ p.chunks.set(h.index, Buffer.from(body));
23036
+ if (h.last)
23037
+ p.lastIndex = h.index;
23038
+ if (p.lastIndex === void 0)
23039
+ return true;
23040
+ let total = 0;
23041
+ const parts = [];
23042
+ for (let i = 0; i <= p.lastIndex; i++) {
23043
+ const c = p.chunks.get(i);
23044
+ if (!c)
23045
+ return true;
23046
+ parts.push(c);
23047
+ total += c.length;
23048
+ }
23049
+ this.partials.delete(key);
23050
+ if (total !== p.frameLength)
23051
+ return false;
23052
+ this.onFrame(Buffer.concat(parts, total), p.channel);
23053
+ return true;
23054
+ }
23055
+ /** Drop frames that stopped arriving; call periodically. */
23056
+ expire() {
23057
+ const now2 = this.now();
23058
+ for (const [key, p] of this.partials) {
23059
+ if (now2 - p.touched > PTCS_STALE_MS)
23060
+ this.partials.delete(key);
23061
+ }
23062
+ }
23063
+ };
23064
+ function frameIdClock(now2 = Date.now) {
23065
+ let last = 0;
23066
+ return () => {
23067
+ let id = now2() >>> 0;
23068
+ if (id <= last)
23069
+ id = last + 1 >>> 0;
23070
+ last = id;
23071
+ return id;
23072
+ };
23073
+ }
23074
+ var PtcsFramer = class {
23075
+ onWire;
23076
+ onFrame;
23077
+ reassembler;
23078
+ sweep;
23079
+ ready = false;
23080
+ nextFrameId = frameIdClock();
23081
+ sequence = 0;
23082
+ /** Arm the two callbacks; `sendFrame` may be called from here on. */
23083
+ init(onWirePacket, onFrame) {
23084
+ this.onWire = onWirePacket;
23085
+ this.onFrame = onFrame;
23086
+ this.reassembler = new PtcsReassembler((frame, channel) => this.onFrame?.(frame, linkTypeForChannel(channel)));
23087
+ this.sweep = setInterval(() => this.reassembler?.expire(), 1e3);
23088
+ this.sweep.unref?.();
23089
+ this.ready = true;
23090
+ }
23091
+ isReady() {
23092
+ return this.ready;
23093
+ }
23094
+ sendFrame(portalPacket) {
23095
+ if (!this.ready)
23096
+ throw new Error("PTCS framer not initialised");
23097
+ const packets = packetize(portalPacket, { frameId: this.nextFrameId(), sequence: this.sequence });
23098
+ this.sequence = this.sequence + 1 & 65535;
23099
+ for (const packet of packets)
23100
+ this.onWire?.(packet);
23101
+ }
23102
+ recvPacket(wirePacket) {
23103
+ if (!this.ready)
23104
+ return;
23105
+ if (isPortalPacket(wirePacket)) {
23106
+ this.onFrame?.(wirePacket, PortalLinkType.COMMAND);
23107
+ return;
23108
+ }
23109
+ this.reassembler?.push(wirePacket);
23110
+ }
23111
+ destroy() {
23112
+ this.ready = false;
23113
+ if (this.sweep)
23114
+ clearInterval(this.sweep);
23115
+ this.sweep = void 0;
23116
+ this.reassembler = void 0;
23117
+ this.onWire = void 0;
23118
+ this.onFrame = void 0;
23119
+ }
23120
+ };
23121
+
23122
+ // dist/transport/rtc/scall-sdp.js
23123
+ var HUB_SDP_MID = "2";
23124
+ var ANKER_MAX_MESSAGE_SIZE = 262144;
23125
+ function scallJsonToSdp(json, now2 = Date.now) {
23126
+ let sdp = "";
23127
+ sdp += "v=0\r\n";
23128
+ sdp += `o=- ${Math.floor(now2())} 1 IN IP4 127.0.0.1\r
23129
+ `;
23130
+ sdp += "s=Anker Webrtc Stream\r\n";
23131
+ sdp += "t=0 0\r\n";
23132
+ sdp += `a=group:BUNDLE ${HUB_SDP_MID}\r
23133
+ `;
23134
+ sdp += "a=msid-semantic: WMS\r\n";
23135
+ sdp += "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n";
23136
+ sdp += "c=IN IP4 127.0.0.1\r\n";
23137
+ sdp += `a=mid:${HUB_SDP_MID}\r
23138
+ `;
23139
+ sdp += "a=ice-options:trickle\r\n";
23140
+ if (json.ice?.ufrag)
23141
+ sdp += `a=ice-ufrag:${json.ice.ufrag}\r
23142
+ `;
23143
+ if (json.ice?.pwd)
23144
+ sdp += `a=ice-pwd:${json.ice.pwd}\r
23145
+ `;
23146
+ if (json.ice?.fingerprint) {
23147
+ const fp = json.ice.fingerprint.replace(/(.{2})(?=.)/g, "$1:");
23148
+ sdp += `a=fingerprint:${json.ice.fingerprint_type ?? "sha-256"} ${fp}\r
23149
+ `;
23150
+ }
23151
+ sdp += `a=setup:${json.setup ?? "actpass"}\r
23152
+ `;
23153
+ sdp += "a=sctp-port:5000\r\n";
23154
+ sdp += `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}\r
23155
+ `;
23156
+ for (const c of json.candidate ?? [])
23157
+ sdp += `a=candidate:${c}\r
23158
+ `;
23159
+ return sdp;
23160
+ }
23161
+ function sdpToScallJson(sdp) {
23162
+ const json = { ice: { ufrag: "", pwd: "", fingerprint: "" } };
23163
+ const setup = sdp.match(/a=setup:([^\r\n]+)/)?.[1];
23164
+ if (setup && setup !== "actpass")
23165
+ json.setup = setup;
23166
+ const ufrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)?.[1];
23167
+ if (ufrag)
23168
+ json.ice.ufrag = ufrag;
23169
+ const pwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)?.[1];
23170
+ if (pwd)
23171
+ json.ice.pwd = pwd;
23172
+ const fp = sdp.match(/a=fingerprint:([^\s]+)\s+([^\r\n]+)/);
23173
+ if (fp?.[2]) {
23174
+ json.ice.fingerprint_type = fp[1];
23175
+ json.ice.fingerprint = fp[2].replace(/:/g, "");
23176
+ }
23177
+ const candidates = [];
23178
+ for (const line of sdp.split(/\r\n|\r|\n/)) {
23179
+ const m = line.match(/^a=candidate:(.+)/);
23180
+ if (m?.[1])
23181
+ candidates.push(m[1]);
23182
+ }
23183
+ if (candidates.length)
23184
+ json.candidate = candidates;
23185
+ return json;
23186
+ }
23187
+ function iceCandidateType(candidate) {
23188
+ return candidate.match(/typ (\w+)/)?.[1] ?? "unknown";
23189
+ }
23190
+ function pinMaxMessageSize(sdp) {
23191
+ return sdp.replace(/a=max-message-size:\d+/g, `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}`);
23192
+ }
23193
+ function forceDtlsRole(sdp, role) {
23194
+ if (/a=setup:(active|passive|actpass)/.test(sdp))
23195
+ return sdp.replace(/a=setup:(active|passive|actpass)/g, `a=setup:${role}`);
23196
+ return sdp.replace(/(m=application[^\r\n]*\r?\n)/, `$1a=setup:${role}\r
23197
+ `);
23198
+ }
23199
+ function toWireCandidate(candidate) {
23200
+ return candidate.trim().replace(/^a=/, "");
23201
+ }
23202
+
23203
+ // dist/transport/rtc/peer.js
23204
+ var DATA_CHANNEL_LABELS = ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
23205
+ var COMMAND_CHANNEL = DATA_CHANNEL_LABELS[0];
23206
+ var DATA_CHANNEL_IDS = {
23207
+ WebrtcDataChannel: 0,
23208
+ audio: 2,
23209
+ idr: 4,
23210
+ video: 6,
23211
+ notify: 8,
23212
+ download: 10
23213
+ };
23214
+ function turnServers(turn) {
23215
+ const both = (hostname, port) => [
23216
+ { hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnUdp" },
23217
+ { hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnTcp" }
23218
+ ];
23219
+ const servers = both(turn.turn_addr, turn.turn_port);
23220
+ if (turn.alt_turn_addr && turn.alt_turn_port)
23221
+ servers.push(...both(turn.alt_turn_addr, turn.alt_turn_port));
23222
+ return servers;
23223
+ }
23224
+ var UNBUILT_ARCHES = /* @__PURE__ */ new Set(["arm", "ia32"]);
23225
+ async function loadNativePeerFactory() {
23226
+ let ndc;
23227
+ try {
23228
+ ndc = await import("node-datachannel");
23229
+ } catch (err) {
23230
+ const message = UNBUILT_ARCHES.has(process.arch) ? `the WebRTC transport for a HomeBase S1 Pro (T9000) needs 'node-datachannel', which has no build for ${process.arch}; a 64-bit OS is needed to drive a T9000` : "the WebRTC transport for a HomeBase S1 Pro (T9000) could not load 'node-datachannel'";
23231
+ throw new Error(message, { cause: err });
23232
+ }
23233
+ return (name, config) => new ndc.PeerConnection(name, config);
23234
+ }
23235
+ var ANSWER_TIMEOUT_MS = 15e3;
23236
+ var RtcPeer = class extends EventEmitter8 {
23237
+ opts;
23238
+ pc;
23239
+ channels = /* @__PURE__ */ new Map();
23240
+ framer;
23241
+ wireTally = /* @__PURE__ */ new Map();
23242
+ commandOpen = false;
23243
+ remoteSet = false;
23244
+ remoteMid = HUB_SDP_MID;
23245
+ handlingOffer = false;
23246
+ channelsCreated = false;
23247
+ gatheringDone = false;
23248
+ /** Set by the framer's wire callback when the native channel refused a packet mid-send. */
23249
+ wireSendFailed = false;
23250
+ pending = [];
23251
+ localAnswer;
23252
+ logger;
23253
+ constructor(opts = {}) {
23254
+ super();
23255
+ this.opts = opts;
23256
+ this.logger = opts.logger ?? noopLogger;
23257
+ }
23258
+ /** Create the native peer on the relay the hub granted in `scall 100`. */
23259
+ async init(turn) {
23260
+ if (this.pc)
23261
+ return;
23262
+ const createPeer = this.opts.createPeer ?? await loadNativePeerFactory();
23263
+ const config = {
23264
+ iceServers: turnServers(turn),
23265
+ iceTransportPolicy: this.opts.iceTransportPolicy ?? "relay",
23266
+ maxMessageSize: ANKER_MAX_MESSAGE_SIZE,
23267
+ enableIceTcp: true
23268
+ };
23269
+ const pc = createPeer("eufy-sdk", config);
23270
+ this.pc = pc;
23271
+ pc.onLocalDescription((sdp, type) => {
23272
+ if (String(type).toLowerCase() === "answer" && this.localAnswer) {
23273
+ clearTimeout(this.localAnswer.timer);
23274
+ this.localAnswer.resolve(sdp);
23275
+ this.localAnswer = void 0;
23276
+ }
23277
+ });
23278
+ pc.onLocalCandidate((candidate) => {
23279
+ if (!candidate)
23280
+ return;
23281
+ if (!this.acceptsCandidate(candidate))
23282
+ return;
23283
+ this.emit("iceCandidate", candidate);
23284
+ });
23285
+ pc.onGatheringStateChange((state) => {
23286
+ if (state === "complete" && !this.gatheringDone) {
23287
+ this.gatheringDone = true;
23288
+ this.emit("iceGatheringComplete");
23289
+ }
23290
+ });
23291
+ pc.onStateChange((state) => {
23292
+ this.logger.debug(`[rtc] peer state ${state}`);
23293
+ this.emit("connectionState", state);
23294
+ });
23295
+ pc.onDataChannel((dc) => this.wireChannel(dc.getLabel(), dc));
23296
+ }
23297
+ /**
23298
+ * The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
23299
+ *
23300
+ * The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
23301
+ * `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
23302
+ * only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
23303
+ * a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
23304
+ * The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
23305
+ * is taken as the local description: one read in `have-local-offer` is an offer.
23306
+ */
23307
+ async handleRemoteOffer(offerSdp) {
23308
+ const pc = this.pc;
23309
+ if (!pc)
23310
+ throw new Error("RTC peer not initialised");
23311
+ if (this.handlingOffer)
23312
+ throw new Error("RTC peer already handling an offer");
23313
+ this.handlingOffer = true;
23314
+ try {
23315
+ const offer = forceDtlsRole(offerSdp, "passive");
23316
+ const application = offer.split(/(?=^m=)/m).find((section) => section.startsWith("m=application "));
23317
+ this.remoteMid = application?.match(/^a=mid:([^\r\n]+)/m)?.[1] ?? HUB_SDP_MID;
23318
+ const answerWait = new Promise((resolve, reject) => {
23319
+ const timer = setTimeout(() => {
23320
+ this.localAnswer = void 0;
23321
+ reject(new Error("timed out waiting for the local SDP answer"));
23322
+ }, ANSWER_TIMEOUT_MS);
23323
+ this.localAnswer = { resolve, reject, timer };
23324
+ });
23325
+ pc.setRemoteDescription(offer, "offer");
23326
+ this.remoteSet = true;
23327
+ this.createChannels();
23328
+ const local = pc.localDescription();
23329
+ let answer = String(local?.type ?? "").toLowerCase() === "answer" ? local?.sdp ?? "" : "";
23330
+ if (!answer)
23331
+ answer = await answerWait;
23332
+ else if (this.localAnswer) {
23333
+ clearTimeout(this.localAnswer.timer);
23334
+ this.localAnswer = void 0;
23335
+ }
23336
+ this.flushPending();
23337
+ return pinMaxMessageSize(answer);
23338
+ } finally {
23339
+ this.handlingOffer = false;
23340
+ }
23341
+ }
23342
+ addRemoteCandidate(candidate) {
23343
+ if (!this.pc)
23344
+ return;
23345
+ if (!this.acceptsCandidate(candidate))
23346
+ return;
23347
+ if (!this.remoteSet || this.handlingOffer) {
23348
+ this.pending.push(candidate);
23349
+ return;
23350
+ }
23351
+ this.addNow(candidate);
23352
+ }
23353
+ /**
23354
+ * Send bytes on the command channel as they are, without PTCS framing; false when it isn't open. The
23355
+ * portal's data-channel keepalive rides the wire unframed.
23356
+ */
23357
+ sendRaw(bytes2) {
23358
+ const dc = this.channels.get(COMMAND_CHANNEL);
23359
+ if (!dc?.isOpen())
23360
+ return false;
23361
+ return dc.sendMessageBinary(bytes2);
23362
+ }
23363
+ /**
23364
+ * Send one portal packet on the command channel, through the framer. False when the channel is not
23365
+ * usable or the native send refused a wire packet, so a dropped frame does not read as success.
23366
+ */
23367
+ sendCommand(portalPacket) {
23368
+ const dc = this.channels.get(COMMAND_CHANNEL);
23369
+ if (!dc?.isOpen() || !this.commandOpen || !this.framer?.isReady())
23370
+ return false;
23371
+ this.wireSendFailed = false;
23372
+ try {
23373
+ this.framer.sendFrame(portalPacket);
23374
+ } catch (e) {
23375
+ this.logger.warn(`[rtc] command send failed: ${e instanceof Error ? e.message : String(e)}`);
23376
+ return false;
23377
+ }
23378
+ return !this.wireSendFailed;
23379
+ }
23380
+ /** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
23381
+ close() {
23382
+ this.framer?.destroy();
23383
+ this.framer = void 0;
23384
+ if (this.localAnswer) {
23385
+ clearTimeout(this.localAnswer.timer);
23386
+ const pending = this.localAnswer;
23387
+ this.localAnswer = void 0;
23388
+ pending.reject(new Error("RTC peer closed while waiting for the local SDP answer"));
23389
+ }
23390
+ this.commandOpen = false;
23391
+ this.gatheringDone = false;
23392
+ this.pc?.close();
23393
+ this.pc = void 0;
23394
+ this.channels.clear();
23395
+ this.channelsCreated = false;
23396
+ this.remoteSet = false;
23397
+ this.pending.length = 0;
23398
+ }
23399
+ acceptsCandidate(candidate) {
23400
+ return this.opts.iceTransportPolicy === "all" || iceCandidateType(candidate) === "relay";
23401
+ }
23402
+ createChannels() {
23403
+ if (!this.pc || this.channelsCreated)
23404
+ return;
23405
+ this.channelsCreated = true;
23406
+ for (const label of DATA_CHANNEL_LABELS) {
23407
+ const dc = this.pc.createDataChannel(label, { id: DATA_CHANNEL_IDS[label], unordered: false });
23408
+ this.wireChannel(label, dc);
23409
+ }
23410
+ }
23411
+ /**
23412
+ * Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
23413
+ * message before that has nothing to reassemble into and is dropped.
23414
+ */
23415
+ wireChannel(label, dc) {
23416
+ if (this.channels.has(label))
23417
+ return;
23418
+ this.channels.set(label, dc);
23419
+ dc.onOpen(() => {
23420
+ this.logger.debug(`[rtc] data channel open ${label}`);
23421
+ if (label !== COMMAND_CHANNEL)
23422
+ return;
23423
+ if (!this.framer) {
23424
+ const framer = new PtcsFramer();
23425
+ framer.init((packet) => {
23426
+ if (!dc.isOpen() || !dc.sendMessageBinary(packet))
23427
+ this.wireSendFailed = true;
23428
+ }, (frame, linkType) => this.emit("data", frame, linkType));
23429
+ this.framer = framer;
23430
+ }
23431
+ this.commandOpen = true;
23432
+ this.emit("commandChannelOpen");
23433
+ });
23434
+ dc.onClosed(() => {
23435
+ this.logger.debug(`[rtc] data channel closed ${label}`);
23436
+ if (label !== COMMAND_CHANNEL)
23437
+ return;
23438
+ const wasOpen = this.commandOpen;
23439
+ this.commandOpen = false;
23440
+ if (wasOpen)
23441
+ this.emit("commandChannelClosed");
23442
+ });
23443
+ dc.onError((err) => this.emit("error", new Error(`RTC data channel ${label}: ${err}`)));
23444
+ dc.onMessage((msg) => {
23445
+ const buf = typeof msg === "string" ? Buffer.from(msg) : Buffer.isBuffer(msg) ? msg : Buffer.from(msg);
23446
+ const n = (this.wireTally.get(label) ?? 0) + 1;
23447
+ this.wireTally.set(label, n);
23448
+ if (n <= 3 || n % 100 === 0) {
23449
+ this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
23450
+ }
23451
+ this.framer?.recvPacket(buf);
23452
+ });
23453
+ }
23454
+ addNow(candidate) {
23455
+ try {
23456
+ this.pc?.addRemoteCandidate(candidate, this.remoteMid);
23457
+ } catch (e) {
23458
+ this.logger.warn(`[rtc] addRemoteCandidate failed: ${e instanceof Error ? e.message : String(e)}`);
23459
+ }
23460
+ }
23461
+ flushPending() {
23462
+ const queued = this.pending.splice(0);
23463
+ for (const c of queued)
23464
+ this.addNow(c);
23465
+ }
23466
+ };
23467
+
23468
+ // dist/transport/rtc/signaling.js
23469
+ import { EventEmitter as EventEmitter9 } from "node:events";
23470
+ import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
23471
+ var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
23472
+ var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
23473
+ var PORTAL_ORIGIN = "https://security.eufy.com";
23474
+ var SIGNALING_KEEPALIVE_MS = 25e3;
23475
+ var SIGNALING_CONNECT_TIMEOUT_MS = 15e3;
23476
+ var SOURCE = "WEB";
23477
+ function sessionAccount(channelId, adminUserId, ts, authToken) {
23478
+ return createHmac3("sha256", authToken).update(`${channelId}${adminUserId}${ts}`).digest("hex");
23479
+ }
23480
+ var WS_OPEN = 1;
23481
+ var defaultSocket = (url, init) => new WebSocket(url, {
23482
+ protocols: init.protocols,
23483
+ headers: { Origin: init.origin }
23484
+ });
23485
+ var RtcSignalingClient = class extends EventEmitter9 {
23486
+ opts;
23487
+ ws;
23488
+ sign;
23489
+ keepalive;
23490
+ wsUrl;
23491
+ signUrl;
23492
+ wsRegion;
23493
+ fetchImpl;
23494
+ createSocket;
23495
+ logger;
23496
+ now;
23497
+ makeMsgId;
23498
+ constructor(opts) {
23499
+ super();
23500
+ this.opts = opts;
23501
+ const prefix = (opts.shard.split("-")[0] || "us").toLowerCase();
23502
+ const smartHost = prefix === "us" ? "security-smart.eufylife.com" : `security-smart-${prefix}.eufylife.com`;
23503
+ this.wsUrl = `wss://${smartHost}${RTC_WS_PATH}`;
23504
+ this.signUrl = `https://${smartHost}${RTC_SIGN_PATH}`;
23505
+ this.wsRegion = prefix.toUpperCase();
23506
+ this.fetchImpl = opts.fetch ?? fetch;
23507
+ this.createSocket = opts.createSocket ?? defaultSocket;
23508
+ this.logger = opts.logger ?? noopLogger;
23509
+ this.now = opts.now ?? Date.now;
23510
+ this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
23511
+ }
23512
+ get isOpen() {
23513
+ return this.ws?.readyState === WS_OPEN;
23514
+ }
23515
+ /** Step 1: the sign blob the socket and every auth message carry. A non-JSON body is reported by status. */
23516
+ async fetchSign() {
23517
+ const res = await this.fetchImpl(this.signUrl, {
23518
+ headers: {
23519
+ "Web-Country": this.opts.country.toUpperCase(),
23520
+ "X-Auth-Token": this.opts.authToken,
23521
+ "App-Name": "eufy_mega",
23522
+ "Model-Type": "WEB",
23523
+ GToken: this.opts.gtoken,
23524
+ Origin: PORTAL_ORIGIN
23525
+ }
23526
+ });
23527
+ const body = await res.json().catch(() => ({})) ?? {};
23528
+ if (!res.ok || body.code !== 0 || !body.data) {
23529
+ throw new Error(`RTC sign for ${this.opts.stationSn} refused: HTTP ${res.status} code ${body.code ?? "?"} ${withoutTokenEcho(body.msg ?? "")}`.trim());
23530
+ }
23531
+ this.sign = body.data;
23532
+ return body.data;
23533
+ }
23534
+ /**
23535
+ * Step 2: open the socket and send the first auth; resolves on `open`. A socket that closes or errors
23536
+ * before it ever opened rejects, so the call always settles.
23537
+ */
23538
+ async connect() {
23539
+ if (this.ws)
23540
+ return;
23541
+ const sign = this.sign ?? await this.fetchSign();
23542
+ const auth = {
23543
+ region: this.wsRegion,
23544
+ type: "NVR",
23545
+ sn: this.opts.stationSn,
23546
+ token: this.opts.authToken,
23547
+ gtoken: this.opts.gtoken,
23548
+ sign,
23549
+ appName: "eufy_mega",
23550
+ modelType: "WEB"
23551
+ };
23552
+ const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
23553
+ this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
23554
+ await new Promise((resolve, reject) => {
23555
+ const timer = setTimeout(() => {
23556
+ reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
23557
+ this.close();
23558
+ }, SIGNALING_CONNECT_TIMEOUT_MS);
23559
+ const ws = this.createSocket(this.wsUrl, { protocols, origin: PORTAL_ORIGIN });
23560
+ this.ws = ws;
23561
+ let opened = false;
23562
+ ws.addEventListener("open", () => {
23563
+ opened = true;
23564
+ clearTimeout(timer);
23565
+ this.sendAuth(sign);
23566
+ this.startKeepalive();
23567
+ this.emit("open");
23568
+ resolve();
23569
+ });
23570
+ ws.addEventListener("message", (ev) => {
23571
+ void this.handleWireMessage(ev.data);
23572
+ });
23573
+ ws.addEventListener("close", (ev) => {
23574
+ clearTimeout(timer);
23575
+ this.stopKeepalive();
23576
+ this.ws = void 0;
23577
+ const code = ev.code ?? 1006;
23578
+ const reason = ev.reason ?? "";
23579
+ this.logger.debug(`[rtc] ${this.opts.stationSn} signalling closed ${code} ${reason}`);
23580
+ if (!opened)
23581
+ reject(new Error(`RTC signalling closed before open: ${code} ${reason}`.trim()));
23582
+ this.emit("close", code, reason);
23583
+ });
23584
+ ws.addEventListener("error", (ev) => {
23585
+ clearTimeout(timer);
23586
+ const detail = ev ?? {};
23587
+ const cause = detail.error?.message ?? detail.error?.code ?? detail.message ?? "";
23588
+ reject(new Error(`RTC signalling socket error${cause ? ": " + cause : ""}`));
23589
+ });
23590
+ });
23591
+ }
23592
+ /** `action 1`, also what keeps the socket alive when re-sent. */
23593
+ sendAuth(sign) {
23594
+ const s = sign ?? this.sign;
23595
+ if (!s)
23596
+ throw new Error("RTC signalling: no sign to authenticate with");
23597
+ this.sendEnvelope("0", {
23598
+ code: 200,
23599
+ action: 1,
23600
+ data: s,
23601
+ sn: this.opts.stationSn,
23602
+ source: SOURCE,
23603
+ ts: Math.floor(this.now() / 1e3)
23604
+ });
23605
+ }
23606
+ /**
23607
+ * `action 3`, a session message on channel 0, the hub's own session; `scall` opens the negotiation.
23608
+ * `subSn` is empty for the hub's session.
23609
+ */
23610
+ sendSession(dataType, payload = {}) {
23611
+ const channelId = 0;
23612
+ const ts = Math.floor(this.now() / 1e3);
23613
+ const inner = {
23614
+ code: 200,
23615
+ action: 3,
23616
+ sessionId: this.sign,
23617
+ sn: this.opts.stationSn,
23618
+ subSn: "",
23619
+ channelId,
23620
+ isResponse: 0,
23621
+ dataType,
23622
+ source: SOURCE,
23623
+ ts,
23624
+ data: JSON.stringify({
23625
+ timestamp: ts,
23626
+ account: sessionAccount(channelId, this.opts.adminUserId, ts, this.opts.authToken),
23627
+ ...payload
23628
+ })
23629
+ };
23630
+ this.sendEnvelope(`${this.opts.authToken}_${this.makeMsgId()}`, inner);
23631
+ }
23632
+ sendCall() {
23633
+ this.sendSession(this.opts.signalingMode ?? "scall");
23634
+ }
23635
+ sendAck() {
23636
+ this.sendSession("ack");
23637
+ }
23638
+ /** The SDP answer, encoded for the negotiated call format, in an `info`. */
23639
+ sendInfoSdp(sdp) {
23640
+ this.sendSession("info", { sdp });
23641
+ }
23642
+ /** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
23643
+ sendInfoCandidate(candidate) {
23644
+ this.sendSession("info", { candidate });
23645
+ }
23646
+ sendHangup() {
23647
+ this.sendSession("hangup");
23648
+ }
23649
+ close() {
23650
+ this.stopKeepalive();
23651
+ const ws = this.ws;
23652
+ this.ws = void 0;
23653
+ try {
23654
+ ws?.close();
23655
+ } catch {
23656
+ }
23657
+ }
23658
+ startKeepalive() {
23659
+ this.stopKeepalive();
23660
+ this.keepalive = setInterval(() => {
23661
+ if (!this.isOpen)
23662
+ return;
23663
+ try {
23664
+ this.sendAuth();
23665
+ } catch (e) {
23666
+ this.logger.warn(`[rtc] ${this.opts.stationSn} signalling keepalive failed`, e);
23667
+ }
23668
+ }, SIGNALING_KEEPALIVE_MS);
23669
+ this.keepalive.unref?.();
23670
+ }
23671
+ stopKeepalive() {
23672
+ if (this.keepalive)
23673
+ clearInterval(this.keepalive);
23674
+ this.keepalive = void 0;
23675
+ }
23676
+ sendEnvelope(msgid, inner) {
23677
+ if (!this.isOpen || !this.ws)
23678
+ throw new Error("RTC signalling not connected");
23679
+ const envelope = { msgid, data: JSON.stringify(inner) };
23680
+ this.ws.send(JSON.stringify(envelope));
23681
+ }
23682
+ async handleWireMessage(raw) {
23683
+ let text2;
23684
+ if (typeof raw === "string")
23685
+ text2 = raw;
23686
+ else if (Buffer.isBuffer(raw))
23687
+ text2 = raw.toString("utf8");
23688
+ else if (raw instanceof ArrayBuffer)
23689
+ text2 = Buffer.from(raw).toString("utf8");
23690
+ else if (typeof raw?.text === "function")
23691
+ text2 = await raw.text();
23692
+ else
23693
+ return;
23694
+ let envelope;
23695
+ let inner;
23696
+ try {
23697
+ envelope = JSON.parse(text2);
23698
+ if (typeof envelope?.data !== "string")
23699
+ return;
23700
+ inner = JSON.parse(envelope.data);
23701
+ } catch {
23702
+ return;
23703
+ }
23704
+ this.emit("message", inner, envelope);
23705
+ }
23706
+ };
23707
+
23708
+ // dist/transport/rtc/session.js
23709
+ var AUTH_TIMEOUT_MS = 15e3;
23710
+ var RtcSession = class extends EventEmitter10 {
23711
+ opts;
23712
+ signaling;
23713
+ peer;
23714
+ logger;
23715
+ turn;
23716
+ authOk = false;
23717
+ authWaiter;
23718
+ connected = false;
23719
+ closed = false;
23720
+ closeAnnounced = false;
23721
+ sdpHandled = false;
23722
+ chain = Promise.resolve();
23723
+ constructor(opts) {
23724
+ super();
23725
+ this.opts = opts;
23726
+ this.logger = opts.logger ?? noopLogger;
23727
+ this.signaling = (opts.createSignaling ?? ((o) => new RtcSignalingClient(o)))(opts);
23728
+ this.peer = (opts.createPeer ?? ((o) => new RtcPeer(o)))({ logger: this.logger, ...opts.peer });
23729
+ this.signaling.on("message", (inner) => {
23730
+ this.chain = this.chain.then(() => this.onSignaling(inner)).catch((e) => {
23731
+ this.emit("error", e instanceof Error ? e : new Error(String(e)));
23732
+ });
23733
+ });
23734
+ this.signaling.on("close", () => {
23735
+ this.connected = false;
23736
+ this.announceClose();
23737
+ });
23738
+ this.signaling.on("error", (e) => this.emit("error", e));
23739
+ this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
23740
+ this.peer.on("iceGatheringComplete", () => this.trickle(""));
23741
+ this.peer.on("commandChannelOpen", () => {
23742
+ if (this.connected)
23743
+ return;
23744
+ this.connected = true;
23745
+ this.logger.debug(`[rtc] ${this.opts.stationSn} command channel open`);
23746
+ this.emit("connected");
23747
+ });
23748
+ this.peer.on("commandChannelClosed", () => {
23749
+ if (!this.connected)
23750
+ return;
23751
+ this.connected = false;
23752
+ this.logger.debug(`[rtc] ${this.opts.stationSn} command channel closed`);
23753
+ this.announceClose();
23754
+ });
23755
+ this.peer.on("connectionState", (state) => {
23756
+ if ((state === "failed" || state === "closed") && this.connected) {
23757
+ this.connected = false;
23758
+ this.announceClose();
23759
+ }
23760
+ });
23761
+ this.peer.on("error", (e) => this.emit("error", e));
23762
+ this.peer.on("data", (frame, linkType) => {
23763
+ if (linkType === PortalLinkType.LIVE)
23764
+ this.emit("mediaData", frame);
23765
+ else
23766
+ this.emit("commandData", frame, linkType);
23767
+ });
23768
+ }
23769
+ get isConnected() {
23770
+ return this.connected;
23771
+ }
23772
+ /**
23773
+ * Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
23774
+ * closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
23775
+ * placing the call.
23776
+ */
23777
+ async connect() {
23778
+ await this.signaling.fetchSign();
23779
+ if (this.closed)
23780
+ throw new Error("RTC session closed before the call was placed");
23781
+ await this.signaling.connect();
23782
+ await this.waitForAuth();
23783
+ if (this.closed)
23784
+ throw new Error("RTC session closed before the call was placed");
23785
+ this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 ${this.opts.signalingMode ?? "scall"}`);
23786
+ this.signaling.sendCall();
23787
+ }
23788
+ /** Send one portal packet; false when the command channel isn't open. */
23789
+ sendCommand(portalPacket) {
23790
+ return this.peer.sendCommand(portalPacket);
23791
+ }
23792
+ /** Send unframed bytes on the command channel; false when it isn't open. */
23793
+ sendRaw(bytes2) {
23794
+ return this.peer.sendRaw(bytes2);
23795
+ }
23796
+ /**
23797
+ * Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
23798
+ * `connect()` still waiting for the signalling auth is rejected and its timer stopped.
23799
+ */
23800
+ close() {
23801
+ if (this.closed)
23802
+ return;
23803
+ this.closed = true;
23804
+ this.connected = false;
23805
+ try {
23806
+ if (this.signaling.isOpen)
23807
+ this.signaling.sendHangup();
23808
+ } catch {
23809
+ }
23810
+ this.signaling.close();
23811
+ this.peer.close();
23812
+ this.settleAuth(new Error("RTC session closed while waiting for signalling auth"));
23813
+ this.announceClose();
23814
+ }
23815
+ /**
23816
+ * Trickle one local candidate, or the empty end-of-candidates. The native peer can report one after the
23817
+ * signalling socket has closed, and sending then throws inside a native callback, where the throw can't
23818
+ * be caught and aborts the process. A candidate with no open socket is dropped.
23819
+ */
23820
+ trickle(candidate) {
23821
+ if (!this.signaling.isOpen)
23822
+ return;
23823
+ this.signaling.sendInfoCandidate(candidate);
23824
+ }
23825
+ announceClose() {
23826
+ if (this.closeAnnounced)
23827
+ return;
23828
+ this.closeAnnounced = true;
23829
+ this.emit("close");
23830
+ }
23831
+ waitForAuth() {
23832
+ if (this.authOk)
23833
+ return Promise.resolve();
23834
+ return new Promise((resolve, reject) => {
23835
+ const timer = setTimeout(() => {
23836
+ this.authWaiter = void 0;
23837
+ reject(new Error("RTC signalling auth timeout"));
23838
+ }, AUTH_TIMEOUT_MS);
23839
+ this.authWaiter = { resolve, reject, timer };
23840
+ });
23841
+ }
23842
+ /** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
23843
+ settleAuth(err) {
23844
+ const waiter = this.authWaiter;
23845
+ if (!waiter)
23846
+ return;
23847
+ this.authWaiter = void 0;
23848
+ clearTimeout(waiter.timer);
23849
+ if (err)
23850
+ waiter.reject(err);
23851
+ else
23852
+ waiter.resolve();
23853
+ }
23854
+ async onSignaling(inner) {
23855
+ if (inner.action === 1 && inner.code === 200) {
23856
+ this.authOk = true;
23857
+ this.settleAuth();
23858
+ }
23859
+ if (!inner.data)
23860
+ return;
23861
+ let payload;
23862
+ try {
23863
+ payload = JSON.parse(inner.data);
23864
+ } catch {
23865
+ return;
23866
+ }
23867
+ switch (inner.dataType) {
23868
+ case "scall":
23869
+ case "call":
23870
+ await this.onCall(payload);
23871
+ return;
23872
+ case "info":
23873
+ await this.onInfo(payload);
23874
+ return;
23875
+ case "hangup":
23876
+ this.logger.debug(`[rtc] ${this.opts.stationSn} hub hung up`);
23877
+ return;
23878
+ default:
23879
+ return;
23880
+ }
23881
+ }
23882
+ /** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
23883
+ async onCall(payload) {
23884
+ if (this.closed)
23885
+ return;
23886
+ const status = payload.status;
23887
+ if (status === 100 && payload.turn) {
23888
+ this.turn = payload.turn;
23889
+ await this.peer.init(payload.turn);
23890
+ return;
23891
+ }
23892
+ if (status === 200) {
23893
+ this.signaling.sendAck();
23894
+ return;
23895
+ }
23896
+ if (status === 486 || status === 408) {
23897
+ this.emit("error", new Error(`RTC ${this.opts.stationSn} scall answered ${status}`));
23898
+ this.close();
23899
+ }
23900
+ }
23901
+ /**
23902
+ * An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
23903
+ * is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
23904
+ * is reported and left unanswered.
23905
+ */
23906
+ async onInfo(payload) {
23907
+ if (this.closed)
23908
+ return;
23909
+ if (payload.format === "CANDIDATE") {
23910
+ if (payload.value)
23911
+ this.peer.addRemoteCandidate(payload.value);
23912
+ return;
23913
+ }
23914
+ if (payload.candidate !== void 0) {
23915
+ if (payload.candidate)
23916
+ this.peer.addRemoteCandidate(payload.candidate);
23917
+ return;
23918
+ }
23919
+ const sdpText = payload.value ?? payload.sdp;
23920
+ if (!sdpText || payload.format !== "SDP" && !payload.sdp)
23921
+ return;
23922
+ if (this.sdpHandled)
23923
+ return;
23924
+ if (!this.turn) {
23925
+ this.emit("error", new Error(`RTC ${this.opts.stationSn} offered before granting a relay`));
23926
+ return;
23927
+ }
23928
+ this.sdpHandled = true;
23929
+ let offer;
23930
+ try {
23931
+ offer = scallJsonToSdp(JSON.parse(sdpText));
23932
+ } catch {
23933
+ offer = sdpText;
23934
+ }
23935
+ const answer = await this.peer.handleRemoteOffer(offer);
23936
+ this.signaling.sendInfoSdp(this.opts.signalingMode === "call" ? answer : JSON.stringify(sdpToScallJson(answer)));
23937
+ this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
23938
+ }
23939
+ };
23940
+
23941
+ // dist/transport/rtc/command-router.js
23942
+ var PORTAL_CMD_CONTROL_PAYLOAD = 1700;
23943
+ var PORTAL_CMD_NOTIFY_PAYLOAD = 1351;
23944
+ var ACK_TIMEOUT_MS = 8e3;
23945
+ var CONNECT_TIMEOUT_MS2 = 12e3;
23946
+ var IDLE_CLOSE_MS = 6e4;
23947
+ var StationStreamBusyError = class extends Error {
23948
+ constructor(stationSn, servingSn) {
23949
+ super(`rtc live: ${stationSn} is serving ${servingSn}; the station streams one camera at a time`);
23950
+ this.name = "StationStreamBusyError";
23951
+ }
23952
+ };
23953
+ var RtcCommandRouter = class {
23954
+ deps;
23955
+ sessions = /* @__PURE__ */ new Map();
23956
+ lives = /* @__PURE__ */ new Map();
23957
+ constructor(deps) {
23958
+ this.deps = deps;
23959
+ }
23960
+ async dispatchCommand(route, cmd) {
23961
+ if (cmd.kind !== "set-payload" && cmd.kind !== "set-json") {
23962
+ throw new Error(`rtc: ${cmd.kind} is not routable over the RTC control channel (only set-payload or set-json)`);
23963
+ }
23964
+ const identity = this.deps.identity();
23965
+ if (!identity)
23966
+ throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
23967
+ const { stationSn } = route;
23968
+ const adminUserId = route.adminUserId || identity.userId;
23969
+ const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
23970
+ const st = await this.stationSession(route, adminUserId, identity);
23971
+ const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
23972
+ const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
23973
+ 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 };
23974
+ const segment = st.seg.next();
23975
+ const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
23976
+ const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
23977
+ st.queue = run.catch(() => void 0);
23978
+ return run;
23979
+ }
23980
+ /**
23981
+ * The media of a camera on a T9000: live video, a readable feed and a live still over the station's
23982
+ * session. Recording has no wire here and is refused; the optional members it has no wire for are absent.
23983
+ */
23984
+ mediaProviderFor(route) {
23985
+ return {
23986
+ live: (opts) => this.withLiveSource(route, opts ?? {}, (source) => attachUnlessAborted(source, opts?.signal)),
23987
+ openReadable: (opts) => this.withLiveSource(route, opts ?? {}, (source) => openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts)),
23988
+ snapshotLive: (opts) => this.withLiveSource(route, opts ?? {}, (source) => captureSnapshotFromShared(source, { ...opts, logger: this.deps.logger, ffmpegPath: this.deps.ffmpegPath })).catch((e) => {
23989
+ if (!(e instanceof StationStreamBusyError))
23990
+ throw e;
23991
+ throw new LiveSnapshotUnavailableError("source-failed", e.message, { cause: e });
23992
+ }),
23993
+ record: async () => {
23994
+ throw new Error(`record is not available for ${route.cameraSn} over the T9000 control channel`);
23995
+ }
23996
+ };
23997
+ }
23998
+ /**
23999
+ * Tear down every station session (logout / shutdown). Each live source is disposed before its session
24000
+ * closes, so its consumers end cleanly and its stop still rides the session.
24001
+ */
24002
+ close() {
24003
+ for (const [sn, live] of this.lives) {
24004
+ this.lives.delete(sn);
24005
+ live.source.dispose();
24006
+ }
24007
+ for (const [sn, st] of this.sessions)
24008
+ this.drop(sn, st);
24009
+ }
24010
+ /**
24011
+ * Hand `use` the station's live source for this camera, in the same turn the source is created or reused,
24012
+ * so a consumer it attaches is counted before any other caller looks.
24013
+ *
24014
+ * The station streams one camera at a time. Another camera whose source has consumers refuses this one,
24015
+ * before the session is awaited and again after; one with none (never attached or lingering)
24016
+ * holds nothing a viewer needs, so it is disposed and the slot taken. The first caller's
24017
+ * {@link SharedSourceHints} configure a new source.
24018
+ */
24019
+ async withLiveSource(route, opts, use) {
24020
+ opts.signal?.throwIfAborted();
24021
+ this.refuseIfWatched(route);
24022
+ const identity = this.deps.identity();
24023
+ if (!identity)
24024
+ throw new Error(`rtc: not logged in, cannot open ${route.stationSn}`);
24025
+ const accountId = route.adminUserId || identity.userId;
24026
+ const st = await abortable(this.stationSession(route, accountId, identity), opts.signal);
24027
+ this.refuseIfWatched(route);
24028
+ const current = this.lives.get(route.stationSn);
24029
+ if (current && current.cameraSn !== route.cameraSn) {
24030
+ this.deps.logger?.debug?.(`[rtc] ${route.stationSn} releasing the unwatched source of ${current.cameraSn} for ${route.cameraSn}`);
24031
+ this.lives.delete(route.stationSn);
24032
+ current.source.dispose();
24033
+ }
24034
+ let live = this.lives.get(route.stationSn);
24035
+ if (!live) {
24036
+ live = { cameraSn: route.cameraSn, source: this.newLiveSource(route, st, accountId, opts) };
24037
+ this.lives.set(route.stationSn, live);
24038
+ }
24039
+ return await use(live.source);
24040
+ }
24041
+ /** Refuse a camera while another camera on its station has consumers. */
24042
+ refuseIfWatched(route) {
24043
+ const current = this.lives.get(route.stationSn);
24044
+ if (current && current.cameraSn !== route.cameraSn && current.source.consumerCount > 0) {
24045
+ throw new StationStreamBusyError(route.stationSn, current.cameraSn);
24046
+ }
24047
+ }
24048
+ newLiveSource(route, st, accountId, hints) {
24049
+ let leased = false;
24050
+ const release = () => {
24051
+ if (!leased)
24052
+ return;
24053
+ leased = false;
24054
+ st.leases = Math.max(0, st.leases - 1);
24055
+ this.touch(route.stationSn, st);
24056
+ };
24057
+ const source = new SharedLiveSource({
24058
+ makeStream: () => new RtcLiveStream({
24059
+ session: st.session,
24060
+ seg: st.seg,
24061
+ stationSn: route.stationSn,
24062
+ channel: route.channel,
24063
+ accountId,
24064
+ logger: this.deps.logger
24065
+ }),
24066
+ label: `${route.stationSn}#${route.channel}`,
24067
+ logger: this.deps.logger,
24068
+ powered: hints.powered,
24069
+ preBufferSeconds: hints.preBufferSeconds,
24070
+ onActive: () => {
24071
+ if (leased)
24072
+ return;
24073
+ leased = true;
24074
+ st.leases++;
24075
+ if (st.idle)
24076
+ clearTimeout(st.idle);
24077
+ },
24078
+ onStopped: () => {
24079
+ release();
24080
+ if (this.lives.get(route.stationSn)?.source === source)
24081
+ this.lives.delete(route.stationSn);
24082
+ }
24083
+ });
24084
+ return source;
24085
+ }
24086
+ /**
24087
+ * Completes on the command ACK or a correlated control-result notification. The notification must
24088
+ * match the request channel, parameter and segment and contain a structured payload. Receipt does
24089
+ * not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
24090
+ */
24091
+ sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
24092
+ return new Promise((resolve, reject) => {
24093
+ const onData = (frame, linkType) => {
24094
+ const p = parsePortalPacket(frame, linkType);
24095
+ if (!p || p.segment !== segment)
24096
+ return;
24097
+ const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
24098
+ const d = p.data;
24099
+ 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;
24100
+ if (!acknowledgement && !notification)
24101
+ return;
24102
+ cleanup();
24103
+ if (acknowledgement && p.errCode !== 0) {
24104
+ reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
24105
+ } else {
24106
+ this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
24107
+ resolve();
24108
+ }
24109
+ };
24110
+ const onClose = () => {
24111
+ cleanup();
24112
+ reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
24113
+ };
24114
+ const timer = setTimeout(() => {
24115
+ cleanup();
24116
+ reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
24117
+ }, ACK_TIMEOUT_MS);
24118
+ const cleanup = () => {
24119
+ clearTimeout(timer);
24120
+ st.session.off("commandData", onData);
24121
+ st.session.off("close", onClose);
24122
+ this.touch(sn, st);
24123
+ };
24124
+ st.session.on("commandData", onData);
24125
+ st.session.on("close", onClose);
24126
+ if (!st.session.sendCommand(packet)) {
24127
+ cleanup();
24128
+ reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
24129
+ }
24130
+ });
24131
+ }
24132
+ async stationSession(route, adminUserId, identity) {
24133
+ const sn = route.stationSn;
24134
+ const existing = this.sessions.get(sn);
24135
+ if (existing) {
24136
+ await existing.ready;
24137
+ if (existing.session.isConnected) {
24138
+ this.touch(sn, existing);
24139
+ return existing;
24140
+ }
24141
+ this.drop(sn, existing);
24142
+ }
24143
+ const session = (this.deps.createSession ?? ((o) => new RtcSession(o)))({
24144
+ authToken: identity.authToken,
24145
+ gtoken: identity.gtoken,
24146
+ stationSn: sn,
24147
+ adminUserId,
24148
+ shard: this.deps.shard(),
24149
+ country: this.deps.country ?? "US",
24150
+ logger: this.deps.logger,
24151
+ signalingMode: route.signalingMode,
24152
+ peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy }
24153
+ });
24154
+ const ready = this.bringUp(sn, session);
24155
+ const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve(), leases: 0 };
24156
+ session.on("error", (e) => this.deps.onError?.(e));
24157
+ session.on("close", () => {
24158
+ if (this.sessions.get(sn) === st)
24159
+ this.sessions.delete(sn);
24160
+ });
24161
+ this.sessions.set(sn, st);
24162
+ try {
24163
+ await ready;
24164
+ } catch (e) {
24165
+ this.drop(sn, st);
24166
+ throw e;
24167
+ }
24168
+ this.touch(sn, st);
24169
+ return st;
24170
+ }
24171
+ /**
24172
+ * One bounded bring-up: the session is up when its command channel opens, and it fails, with the
24173
+ * session closed and every listener gone, when `connect()` throws, the session closes first, or
24174
+ * {@link CONNECT_TIMEOUT_MS} passes. A single promise, so an early failure cannot leave a second one
24175
+ * rejecting unheard.
24176
+ */
24177
+ bringUp(sn, session) {
24178
+ return new Promise((resolve, reject) => {
24179
+ let settled = false;
24180
+ const finish = (err) => {
24181
+ if (settled)
24182
+ return;
24183
+ settled = true;
24184
+ clearTimeout(timer);
24185
+ session.off("connected", onConnected);
24186
+ session.off("close", onClose);
24187
+ if (err) {
24188
+ try {
24189
+ session.close();
24190
+ } catch {
24191
+ }
24192
+ reject(err);
24193
+ } else {
24194
+ this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
24195
+ resolve();
24196
+ }
24197
+ };
24198
+ const onConnected = () => finish();
24199
+ const onClose = () => finish(new Error(`rtc: ${sn} session closed before the command channel opened`));
24200
+ const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms`)), CONNECT_TIMEOUT_MS2);
24201
+ session.once("connected", onConnected);
24202
+ session.once("close", onClose);
24203
+ Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
24204
+ });
24205
+ }
24206
+ touch(sn, st) {
24207
+ if (st.idle)
24208
+ clearTimeout(st.idle);
24209
+ if (st.leases > 0)
24210
+ return;
24211
+ st.idle = setTimeout(() => this.drop(sn, st), IDLE_CLOSE_MS);
24212
+ st.idle.unref?.();
24213
+ }
24214
+ drop(sn, st) {
24215
+ if (st.idle)
24216
+ clearTimeout(st.idle);
24217
+ if (this.sessions.get(sn) === st)
24218
+ this.sessions.delete(sn);
24219
+ try {
24220
+ st.session.close();
24221
+ } catch {
24222
+ }
24223
+ }
24224
+ };
24225
+
24226
+ // dist/transport/mqtt/command-router.js
24227
+ import { randomBytes as randomBytes3 } from "node:crypto";
24228
+
24229
+ // dist/transport/mqtt/broker-discovery.js
24230
+ import { resolve4 } from "node:dns/promises";
24231
+ async function resolveBrokerIps(hostname) {
24232
+ try {
24233
+ return await resolve4(hostname);
24234
+ } catch {
24235
+ return [];
24236
+ }
24237
+ }
24238
+ function mergeCandidateIps(dnsIps, extraIps = []) {
24239
+ return Array.from(/* @__PURE__ */ new Set([...dnsIps, ...extraIps]));
24240
+ }
24241
+ function rankResults(results) {
24242
+ return [...results].sort((a, b) => Number(b.granted) - Number(a.granted));
24243
+ }
24244
+ async function probeBrokerInstance(ip, creds, opts) {
24245
+ const mqtt = await loadMqtt();
24246
+ const start = Date.now();
24247
+ const timeoutMs = opts.timeoutMs ?? 8e3;
24248
+ return await new Promise((resolvePromise) => {
24249
+ let settled = false;
24250
+ const finish = (r) => {
24251
+ if (settled)
24252
+ return;
24253
+ settled = true;
24254
+ clearTimeout(timer);
24255
+ try {
24256
+ client.end(true);
24257
+ } catch {
24258
+ }
24259
+ resolvePromise({ ip, ms: Date.now() - start, ...r });
24260
+ };
24261
+ const client = mqtt.connect({
24262
+ host: ip,
24263
+ port: creds.port ?? 8883,
24264
+ protocol: "mqtts",
24265
+ ...bareIpTlsOptions({
24266
+ hostname: creds.hostname,
24267
+ cert: creds.certificate_pem,
24268
+ key: creds.private_key,
24269
+ ca: creds.aws_root_ca1_pem
24270
+ }),
24271
+ clientId: opts.clientId,
24272
+ protocolVersion: 4,
24273
+ keepalive: 60,
24274
+ clean: true,
24275
+ reconnectPeriod: 0,
24276
+ connectTimeout: timeoutMs
24277
+ });
24278
+ const timer = setTimeout(() => finish({ granted: false, error: "timeout" }), timeoutMs);
24279
+ client.on("connect", () => {
24280
+ client.subscribe(opts.topic, { qos: 1 }, (err, granted) => {
24281
+ if (err)
24282
+ return finish({ granted: false, error: err.message });
24283
+ const g = granted?.[0];
24284
+ const denied = !g || g.qos === 128;
24285
+ finish({ granted: !denied, grantedQos: g?.qos });
24286
+ });
24287
+ });
24288
+ client.on("error", (err) => finish({ granted: false, error: err.message }));
24289
+ });
24290
+ }
24291
+ async function discoverReachableInstance(creds, opts) {
24292
+ const dnsIps = await resolveBrokerIps(creds.hostname);
24293
+ const all = mergeCandidateIps(dnsIps, opts.candidateIps);
24294
+ const results = [];
24295
+ for (const ip of all) {
24296
+ const r = await probeBrokerInstance(ip, creds, {
24297
+ clientId: opts.clientIdFor(ip),
24298
+ topic: opts.topic,
24299
+ timeoutMs: opts.perAttemptTimeoutMs
24300
+ });
24301
+ results.push(r);
24302
+ if (r.granted && opts.stopOnFirstGrant)
24303
+ break;
24304
+ }
24305
+ return rankResults(results);
24306
+ }
24307
+
24308
+ // dist/transport/dp-preset.js
24309
+ var RGBCW_CH = {
24310
+ R: [15.932, 0.7035, 0.294],
24311
+ G: [39.83, 0.1629, 0.7208],
24312
+ B: [2.2404, 0.1472, 0.0309],
24313
+ W: [66.28, 0.4681, 0.4182],
24314
+ C: [73.58, 0.3166, 0.3397]
24315
+ };
24316
+ var RGBCW_X = Object.fromEntries(Object.entries(RGBCW_CH).map(([k, [Y, x, y]]) => [k, [x * Y / y, Y, (1 - x - y) * Y / y]]));
24317
+ var RGB_HEX = /^[0-9a-fA-F]{6}$/;
22452
24318
  var RGBCW_GAMMA = 2.2;
22453
24319
  var RGBCW_SAT = 1.65;
22454
24320
  var dec = (b) => Math.pow(b / 255, RGBCW_GAMMA);
@@ -22660,10 +24526,10 @@ function dpColorFields(spec) {
22660
24526
  }
22661
24527
 
22662
24528
  // dist/transport/mqtt/clean-codec.js
22663
- import { randomUUID as randomUUID2 } from "node:crypto";
24529
+ import { randomUUID as randomUUID3 } from "node:crypto";
22664
24530
  function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
22665
24531
  const payload = JSON.stringify({
22666
- t: opts?.uuid ?? randomUUID2(),
24532
+ t: opts?.uuid ?? randomUUID3(),
22667
24533
  protocol: 2,
22668
24534
  account_id: accountId,
22669
24535
  device_sn: deviceSn,
@@ -23350,7 +25216,7 @@ function deriveTuyaAccount(eufyUserId, phoneCode) {
23350
25216
  }
23351
25217
 
23352
25218
  // dist/transport/tuya/sign.js
23353
- import { createHash as createHash5, createHmac as createHmac3 } from "node:crypto";
25219
+ import { createHash as createHash5, createHmac as createHmac4 } from "node:crypto";
23354
25220
  var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
23355
25221
  "a",
23356
25222
  "v",
@@ -23393,7 +25259,7 @@ var HmacSigner = class {
23393
25259
  this.key = key ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
23394
25260
  }
23395
25261
  sign(preimage) {
23396
- return createHmac3("sha256", this.key).update(preimage, "utf-8").digest("hex");
25262
+ return createHmac4("sha256", this.key).update(preimage, "utf-8").digest("hex");
23397
25263
  }
23398
25264
  };
23399
25265
  var TUYA_CHKEY = "7cbfe6d8";
@@ -23402,7 +25268,7 @@ var TUYA_CHKEY = "7cbfe6d8";
23402
25268
  import { randomBytes as randomBytes4, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
23403
25269
 
23404
25270
  // dist/transport/tuya/request.js
23405
- import { randomUUID as randomUUID3 } from "node:crypto";
25271
+ import { randomUUID as randomUUID4 } from "node:crypto";
23406
25272
  var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
23407
25273
  var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
23408
25274
  var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
@@ -23434,7 +25300,7 @@ function buildApiParams(action, opts) {
23434
25300
  a: action.a,
23435
25301
  v: action.v,
23436
25302
  time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
23437
- requestId: opts.requestId ?? randomUUID3(),
25303
+ requestId: opts.requestId ?? randomUUID4(),
23438
25304
  sid: opts.session.sid,
23439
25305
  deviceId: opts.session.deviceId,
23440
25306
  chKey: opts.session.chKey,
@@ -23971,7 +25837,7 @@ async function resolveLightEffect(mega, lightId) {
23971
25837
  }
23972
25838
 
23973
25839
  // dist/transport/push/push-client.js
23974
- import { EventEmitter as EventEmitter8 } from "node:events";
25840
+ import { EventEmitter as EventEmitter12 } from "node:events";
23975
25841
  import tls2 from "node:tls";
23976
25842
 
23977
25843
  // dist/transport/push/proto.js
@@ -24226,7 +26092,7 @@ var MessageTag;
24226
26092
  })(MessageTag || (MessageTag = {}));
24227
26093
 
24228
26094
  // dist/transport/push/parser.js
24229
- import { EventEmitter as EventEmitter7 } from "node:events";
26095
+ import { EventEmitter as EventEmitter11 } from "node:events";
24230
26096
  var State;
24231
26097
  (function(State2) {
24232
26098
  State2[State2["VERSION_TAG_AND_SIZE"] = 0] = "VERSION_TAG_AND_SIZE";
@@ -24244,7 +26110,7 @@ var TAG_TO_TYPE = {
24244
26110
  [MessageTag.DataMessageStanza]: "mcs_proto.DataMessageStanza",
24245
26111
  [MessageTag.StreamErrorStanza]: "mcs_proto.StreamErrorStanza"
24246
26112
  };
24247
- var McsParser = class extends EventEmitter7 {
26113
+ var McsParser = class extends EventEmitter11 {
24248
26114
  data = Buffer.alloc(0);
24249
26115
  state = State.VERSION_TAG_AND_SIZE;
24250
26116
  messageTag = 0;
@@ -24422,7 +26288,7 @@ function normalizePushEvent(raw) {
24422
26288
  raw
24423
26289
  };
24424
26290
  }
24425
- var PushClient = class _PushClient extends EventEmitter8 {
26291
+ var PushClient = class _PushClient extends EventEmitter12 {
24426
26292
  creds;
24427
26293
  logger;
24428
26294
  /** Consecutive MCS login rejections tolerated (self-healing propagation) before surfacing an error. */
@@ -25012,7 +26878,14 @@ function deviceClassOf(codec, realtime) {
25012
26878
  const cls = CLASS_BY_CODEC[codec];
25013
26879
  return cls === "camera" && realtime !== "p2p" ? "other" : cls;
25014
26880
  }
26881
+ function recordName(raw, deviceClass) {
26882
+ if (deviceClass === "vacuum") {
26883
+ return raw.alias_name || raw.device_alias_name || raw.device_name || void 0;
26884
+ }
26885
+ return raw.device_name ?? raw.device_alias_name ?? raw.alias_name;
26886
+ }
25015
26887
  var LIST_REUSE_MS = 5e3;
26888
+ var CLOUD_DPS_MODELS = /* @__PURE__ */ new Set(["T2351"]);
25016
26889
  var DeviceRegistry = class {
25017
26890
  mega;
25018
26891
  onError;
@@ -25037,6 +26910,8 @@ var DeviceRegistry = class {
25037
26910
  overlayRefusalReported = false;
25038
26911
  /** Per-serial capability cache for {@link capabilitiesForDevice}; `null` = negative hit. */
25039
26912
  deviceCapsCache = /* @__PURE__ */ new Map();
26913
+ /** Per-serial cloud data points of an AIoT robot, joined beneath the params in {@link record} — see {@link loadCloudDps}. */
26914
+ cloudDps = /* @__PURE__ */ new Map();
25040
26915
  /** Per-serial realtime state, keyed by param id in the device's own namespace (see {@link DeviceRecord.dpParams}). */
25041
26916
  dpParams = /* @__PURE__ */ new Map();
25042
26917
  /** Callers blocked in {@link awaitRealtimeState}, released by the device's first report. */
@@ -25131,21 +27006,24 @@ var DeviceRegistry = class {
25131
27006
  category: raw.category,
25132
27007
  params
25133
27008
  }).codec;
27009
+ const deviceClass = deviceClassOf(codec, c.realtime);
25134
27010
  seen.set(raw.device_sn, {
25135
27011
  sn: raw.device_sn,
25136
- name: raw.device_name ?? raw.device_alias_name ?? raw.alias_name,
27012
+ name: recordName(raw, deviceClass),
25137
27013
  model: raw.device_model,
25138
27014
  stationSn: resolvedStationSn(raw, raw.device_sn),
27015
+ ...typeof raw.device_channel === "number" ? { channel: raw.device_channel } : {},
25139
27016
  p2pDid: raw.p2p_did,
25140
27017
  params,
25141
27018
  paramUpdatedAt,
25142
27019
  lastSeenMs: lastSeenMsOf(paramUpdatedAt),
25143
27020
  raw,
25144
- deviceClass: deviceClassOf(codec, c.realtime),
27021
+ deviceClass,
25145
27022
  ...c
25146
27023
  });
25147
27024
  }
25148
27025
  }
27026
+ await this.loadCloudDps(seen);
25149
27027
  this.lastRefreshPartial = partial;
25150
27028
  if (partial) {
25151
27029
  for (const prev of this.devices)
@@ -25236,9 +27114,46 @@ var DeviceRegistry = class {
25236
27114
  }
25237
27115
  return dev;
25238
27116
  }
27117
+ /**
27118
+ * Hold an AIoT robot's cloud data points: the cloud's last-known value of each one it has reported,
27119
+ * from `getDeviceRelationList`'s per-device `dps`.
27120
+ *
27121
+ * The device list carries only some of a robot's data points, so a point the robot publishes on change
27122
+ * alone (its scene list, its schedules, its consumables) has no value until it next reports. The
27123
+ * relation list holds every one, for an account the robot is shared with as well as its owner.
27124
+ *
27125
+ * Kept apart from the device list's params rather than merged into them: those are what a poll diffs,
27126
+ * and a diff retires the realtime report for that id, so a cloud value lagging the robot's last report
27127
+ * would replace it. {@link record} joins this beneath both halves. A failure keeps what was held.
27128
+ *
27129
+ * Limited to {@link CLOUD_DPS_MODELS}, the models whose relation-list `dps` are checked to carry the
27130
+ * DP numbers their realtime reports use.
27131
+ */
27132
+ async loadCloudDps(seen) {
27133
+ const robots = [...seen.values()].filter((d) => CLOUD_DPS_MODELS.has(d.model ?? ""));
27134
+ if (!robots.length)
27135
+ return;
27136
+ let res;
27137
+ try {
27138
+ res = await this.mega.getDeviceRelationList();
27139
+ } catch (e) {
27140
+ if (e instanceof SessionExpiredError)
27141
+ throw e;
27142
+ this.logger.debug(`[registry] device relation list unavailable: ${e?.message ?? e}`);
27143
+ return;
27144
+ }
27145
+ const wanted = new Set(robots.map((d) => d.sn));
27146
+ for (const entry of res.devices ?? []) {
27147
+ const sn = entry?.device?.device_sn;
27148
+ const dps = parseAiotDpReport({ payload: entry?.device?.dps });
27149
+ if (sn && wanted.has(sn) && dps)
27150
+ this.cloudDps.set(sn, dps);
27151
+ }
27152
+ }
25239
27153
  /**
25240
27154
  * Resolve a serial to a {@link DeviceRecord} with current params: starts from the device-list params, then
25241
- * overlays a fresh `get_device_param_list` when that call is available to this account.
27155
+ * overlays a fresh `get_device_param_list` when that call is available to this account. An AIoT robot's
27156
+ * cloud data points ({@link loadCloudDps}) sit beneath both, filling only ids neither carries.
25242
27157
  *
25243
27158
  * The overlay is **owner-gated** — a shared or member account is refused it for every device, permanently,
25244
27159
  * which {@link OWNER_ONLY_CODE} identifies — so when it is unavailable the device list is the source
@@ -25267,7 +27182,7 @@ var DeviceRegistry = class {
25267
27182
  let dev = this.devices.find((d) => d.sn === sn);
25268
27183
  if (!dev)
25269
27184
  throw new Error(`device ${sn} not found (have: ${this.devices.map((d) => d.sn).join(", ")})`);
25270
- const params = { ...dev.params ?? {} };
27185
+ const params = { ...this.cloudDps.get(sn), ...dev.params ?? {} };
25271
27186
  const paramUpdatedAt = { ...dev.paramUpdatedAt ?? {} };
25272
27187
  if (!this.overlayRefused.has(sn)) {
25273
27188
  try {
@@ -25560,6 +27475,8 @@ function tuyaDevIdFrom(raw) {
25560
27475
  var DEFAULT_POLL_MS = 6e5;
25561
27476
  var DEFAULT_PREWARM_TIERS = ["wired", "battery"];
25562
27477
  var SESSION_RECYCLE_WAIT_MS = 5e3;
27478
+ var PUSH_RETRY_MIN_MS = 3e4;
27479
+ var PUSH_RETRY_MAX_MS = 6e5;
25563
27480
  var RealtimeStartupSupersededError = class extends Error {
25564
27481
  };
25565
27482
  var RefreshWindowClosedError = class extends Error {
@@ -25575,7 +27492,7 @@ function readinessSnapshot(readiness, state = readiness.state) {
25575
27492
  wiredP2p: Object.freeze({ ...readiness.wiredP2p })
25576
27493
  });
25577
27494
  }
25578
- var EufyMega = class extends EventEmitter9 {
27495
+ var EufyMega = class extends EventEmitter13 {
25579
27496
  opts;
25580
27497
  mega;
25581
27498
  /**
@@ -25635,6 +27552,8 @@ var EufyMega = class extends EventEmitter9 {
25635
27552
  prewarmTiers;
25636
27553
  /** Transport-side owner of the P2P sessions + all wire senders. */
25637
27554
  p2p;
27555
+ /** Transport-side owner of the T9000 station sessions (sibling of {@link p2p}). */
27556
+ rtc;
25638
27557
  /** Transport-side owner of the secure-MQTT ff09 lock/garage command path (sibling of {@link p2p}). */
25639
27558
  mqtt;
25640
27559
  /** Transport-side owner of the legacy Tuya REST command path for non-AIoT vacuums (G-series). */
@@ -25656,6 +27575,8 @@ var EufyMega = class extends EventEmitter9 {
25656
27575
  mapStores = /* @__PURE__ */ new Map();
25657
27576
  /** Re-armed after each cloud-param poll; cancelled by {@link disconnect}. */
25658
27577
  pollTimer = new Timer();
27578
+ /** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
27579
+ pushRetryTimer = new Timer();
25659
27580
  constructor(opts) {
25660
27581
  super();
25661
27582
  this.opts = opts;
@@ -25695,6 +27616,14 @@ var EufyMega = class extends EventEmitter9 {
25695
27616
  onLevel2Ready: (sn, cipherId) => this.emit("p2pLevel2Ready", { stationSn: sn, cipherId }),
25696
27617
  onFrame: (stationSn, f) => this.onP2PFrame(stationSn, f)
25697
27618
  });
27619
+ this.rtc = new RtcCommandRouter({
27620
+ identity: () => this.mega.rtcIdentity(),
27621
+ shard: () => this.mega.rtcShard,
27622
+ country: opts.countryCode,
27623
+ logger: opts.logger,
27624
+ ffmpegPath: opts.ffmpegPath,
27625
+ onError: (e) => this.reportError(e)
27626
+ });
25698
27627
  this.mqtt = new MqttCommandRouter({
25699
27628
  mega: this.mega,
25700
27629
  logger: opts.logger,
@@ -25960,13 +27889,22 @@ var EufyMega = class extends EventEmitter9 {
25960
27889
  *
25961
27890
  * The evidence set is widened, never replaced: the ids come back through the cloud record, and a
25962
27891
  * record that omits a realtime-only id would otherwise un-know it and re-trigger on the next report.
27892
+ *
27893
+ * The device is re-resolved against the fresh record before binding. A property gated on a
27894
+ * realtime-only param enters the schema only once that param is evidence, and the value that made it
27895
+ * evidence was stored before the property existed, so the record's realtime params are applied again
27896
+ * to land it under the property's name.
25963
27897
  */
25964
27898
  async rebindReads(sn) {
25965
27899
  const dev = this.liveDevices.get(sn)?.deref();
25966
27900
  if (!dev)
25967
27901
  return;
25968
27902
  try {
25969
- const ctx = await this.commandContext(sn);
27903
+ const rec = await this.registry.record(sn);
27904
+ dev.reresolve(rec);
27905
+ if (rec.dpParams)
27906
+ this.applyAndAnnounce(dev, rec.dpParams);
27907
+ const ctx = await this.commandContext(sn, rec);
25970
27908
  dev.bindActions(ctx, this.commandSinkFor(sn), this.mediaProviderFor(sn), this.ff09SettingsReaderFor(sn, ctx), rawDpCodec);
25971
27909
  this.boundParamIds.set(sn, /* @__PURE__ */ new Set([...this.boundParamIds.get(sn) ?? [], ...ctx.paramIds]));
25972
27910
  this.emit("deviceState", this.deviceState(sn));
@@ -26026,6 +27964,8 @@ var EufyMega = class extends EventEmitter9 {
26026
27964
  * only "differs from what was read before", which state already on hand can satisfy spuriously — and the
26027
27965
  * caller that has no expectation is the push path, where the signal itself is the news that a re-read is owed.
26028
27966
  *
27967
+ * Only a write's observation throws at the deadline; a push with nothing moved answers `false`.
27968
+ *
26029
27969
  * The cloud half is asked for through {@link DeviceRegistry.refreshedList}, never by fetching the account
26030
27970
  * list outright. The fetch is account-wide — one house list plus one device list per house — so a param that
26031
27971
  * never converges would otherwise spend a whole burst of those every iteration of this loop, and concurrent
@@ -26076,6 +28016,8 @@ var EufyMega = class extends EventEmitter9 {
26076
28016
  if (refresh.expected !== void 0 && settled())
26077
28017
  return true;
26078
28018
  }
28019
+ if (refresh.expected === void 0)
28020
+ return false;
26079
28021
  throw new StateConvergenceError({
26080
28022
  sn,
26081
28023
  property: refresh.property,
@@ -26381,6 +28323,28 @@ var EufyMega = class extends EventEmitter9 {
26381
28323
  }
26382
28324
  };
26383
28325
  }
28326
+ /**
28327
+ * The station a device's commands go through: the device itself, or the HomeBase the registry links it
28328
+ * to. Answers the target record, the station record and serial, the station's `device_type`, and the
28329
+ * `t9000` / `t8n00` family flags.
28330
+ */
28331
+ stationOf(sn) {
28332
+ const devices = this.registry.list();
28333
+ const target = devices.find((d) => d.sn === sn);
28334
+ const stationSn = target?.stationSn || sn;
28335
+ const station = devices.find((d) => d.sn === stationSn);
28336
+ const raw = station?.raw ?? {};
28337
+ const deviceType = typeof raw.device_type === "number" ? raw.device_type : void 0;
28338
+ const family = { deviceType, model: station?.model };
28339
+ return { target, station, stationSn, raw, deviceType, t9000: isStation9000(family), t8n00: isNvrT8N00(family) };
28340
+ }
28341
+ /** The vendor DeviceType of the station a device hangs off; `undefined` for a station itself. */
28342
+ stationDeviceTypeOf(sn) {
28343
+ const st = this.stationOf(sn);
28344
+ if (!st.station || st.stationSn === sn)
28345
+ return void 0;
28346
+ return st.deviceType;
28347
+ }
26384
28348
  /**
26385
28349
  * Combine explicit P2P media with the optional passive push-thumbnail provider.
26386
28350
  *
@@ -26391,7 +28355,7 @@ var EufyMega = class extends EventEmitter9 {
26391
28355
  * current. With nothing retained the refusal stands.
26392
28356
  */
26393
28357
  mediaProviderFor(sn) {
26394
- const media = this.p2p.mediaProviderFor(sn);
28358
+ const media = this.rtcMediaFor(sn) ?? this.p2p.mediaProviderFor(sn);
26395
28359
  const cache = this.storedImages;
26396
28360
  if (!cache)
26397
28361
  return media;
@@ -26428,8 +28392,10 @@ var EufyMega = class extends EventEmitter9 {
26428
28392
  * The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
26429
28393
  * Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
26430
28394
  * (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
26431
- * kind and stays transport-agnostic; only the facade sees both sides and decides here. Everything
26432
- * else is P2P.
28395
+ * kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000 or T8N00
28396
+ * station and the devices attached to it go over RTC, with an attached device's command refused
28397
+ * unless its channel resolves back to it on the station. T8N00 uses full SDP and admits direct and
28398
+ * relay candidates; T9000 retains compact SDP and relay-only candidates. Everything else is P2P.
26433
28399
  */
26434
28400
  routeCommand(sn, cmd) {
26435
28401
  if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
@@ -26449,8 +28415,42 @@ var EufyMega = class extends EventEmitter9 {
26449
28415
  return this.tuya.dispatchCommand(sn, cmd);
26450
28416
  return this.mqtt.dispatchCommand(sn, cmd);
26451
28417
  }
28418
+ const route = this.rtcRouteFor(sn);
28419
+ if (route) {
28420
+ if (route.attached && this.registry.serialForFrame(route.stationSn, cmd.channel) !== sn)
28421
+ return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
28422
+ return this.rtc.dispatchCommand(route, cmd);
28423
+ }
26452
28424
  return this.p2p.dispatchCommand(sn, cmd);
26453
28425
  }
28426
+ /**
28427
+ * The RTC route for a T9000 or T8N00 station or a device attached to one, decided by the station's model
28428
+ * classification; `undefined` for any other device. A T8N00 route carries its native `call` signalling. The admin id is the station's
28429
+ * `member.admin_user_id`, else the logged-in user.
28430
+ */
28431
+ rtcRouteFor(sn) {
28432
+ const { target, station, stationSn, raw, t9000, t8n00: native } = this.stationOf(sn);
28433
+ if (!target || !station || !(t9000 || native))
28434
+ return void 0;
28435
+ const member = raw.member?.admin_user_id;
28436
+ const adminUserId = typeof member === "string" && member ? member : void 0;
28437
+ const base = { stationSn, adminUserId, attached: stationSn !== sn };
28438
+ return native ? { ...base, signalingMode: "call", iceTransportPolicy: "all" } : base;
28439
+ }
28440
+ /**
28441
+ * The RTC media provider for a camera attached to a T9000 whose `device_channel` resolves back to it on
28442
+ * the station; `undefined` otherwise, including a camera behind a T8N00, whose route carries commands only.
28443
+ */
28444
+ rtcMediaFor(sn) {
28445
+ const route = this.rtcRouteFor(sn);
28446
+ if (!route?.attached || route.signalingMode === "call")
28447
+ return void 0;
28448
+ const raw = this.registry.list().find((d) => d.sn === sn)?.raw ?? {};
28449
+ const channel = typeof raw.device_channel === "number" ? raw.device_channel : void 0;
28450
+ if (channel === void 0 || this.registry.serialForFrame(route.stationSn, channel) !== sn)
28451
+ return void 0;
28452
+ return this.rtc.mediaProviderFor({ ...route, cameraSn: sn, channel });
28453
+ }
26454
28454
  /**
26455
28455
  * List + classify devices across all houses (mega API). Each device is tagged with its API backend
26456
28456
  * + realtime transport. Camera/HomeBase records still appear here for inventory; driving them is
@@ -26683,13 +28683,38 @@ var EufyMega = class extends EventEmitter9 {
26683
28683
  this.schedulePoll();
26684
28684
  const failures = readiness.push.failed + readiness.mqtt.failed + readiness.wiredP2p.failed;
26685
28685
  this.settleRealtimeGeneration(generation, failures || startupFailed ? "partial" : "ready");
28686
+ if (readiness.push.failed)
28687
+ this.retryPush(generation);
26686
28688
  } catch (e) {
26687
28689
  this.reportError(e);
26688
28690
  readiness.push.failed += readiness.push.pending;
26689
28691
  readiness.push.pending = 0;
26690
28692
  this.settleRealtimeGeneration(generation, epoch === this.realtimeEpoch ? "partial" : "superseded");
28693
+ if (epoch === this.realtimeEpoch)
28694
+ this.retryPush(generation);
26691
28695
  }
26692
28696
  }
28697
+ /**
28698
+ * Bring push up again after the generation's startup attempt failed, backing off between attempts
28699
+ * until one connects, the generation is superseded, or it is aborted by {@link disconnect}.
28700
+ *
28701
+ * The MCS socket reconnects on its own only once it has connected. The settled
28702
+ * {@link RealtimeReadiness} is a startup snapshot and is not revised when a retry connects.
28703
+ */
28704
+ retryPush(generation, delayMs = PUSH_RETRY_MIN_MS) {
28705
+ this.pushRetryTimer.arm(delayMs, () => {
28706
+ this.startPush(generation.abort.signal).then((client) => {
28707
+ if (generation.epoch !== this.realtimeEpoch)
28708
+ return client.close();
28709
+ this.pushClient = client;
28710
+ }, (error) => {
28711
+ if (generation.epoch !== this.realtimeEpoch)
28712
+ return;
28713
+ this.reportError(error);
28714
+ this.retryPush(generation, Math.min(delayMs * 2, PUSH_RETRY_MAX_MS));
28715
+ });
28716
+ });
28717
+ }
26693
28718
  /** Resolve a generation with an immutable count snapshot; later transport completions are ignored. */
26694
28719
  settleRealtimeGeneration(generation, state) {
26695
28720
  if (generation.settled)
@@ -27287,16 +29312,20 @@ var EufyMega = class extends EventEmitter9 {
27287
29312
  await this.commandSinkFor(sn).dispatch(cmd);
27288
29313
  }
27289
29314
  /**
27290
- * Restart a HomeBase.
29315
+ * Restart a station: a HomeBase, or a camera-family device that is its own station.
27291
29316
  *
27292
- * **HomeBases only** — restart is a hub operation, so a non-HomeBase serial (a camera, an NVR)
27293
- * throws rather than doing nothing. The hub drops its connection and returns after a minute or two,
27294
- * so everything behind it is briefly offline. Verified on real hardware.
29317
+ * The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
29318
+ * P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
29319
+ * A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
29320
+ * throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
29321
+ * and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
29322
+ * Verified on real hardware against a HomeBase and a standalone indoor camera.
27295
29323
  */
27296
29324
  async reboot(sn) {
27297
29325
  const ctx = await this.commandContext(sn);
27298
- if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model })) {
27299
- throw new Error(`reboot: ${sn} is not a HomeBase (deviceType ${ctx.deviceType ?? "?"}, model ${ctx.model ?? "?"}) \u2014 restart is a hub-only operation`);
29326
+ const standaloneCamera = ctx.codec === "camera" && this.p2p.stationKeyOf(sn) === sn;
29327
+ if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model }) && !standaloneCamera) {
29328
+ throw new Error(`reboot: ${sn} is neither a HomeBase nor a standalone camera (deviceType ${ctx.deviceType ?? "?"}, model ${ctx.model ?? "?"}) \u2014 restart addresses the station that owns the P2P session`);
27300
29329
  }
27301
29330
  await this.p2p.rebootStation(sn);
27302
29331
  }
@@ -27328,8 +29357,9 @@ var EufyMega = class extends EventEmitter9 {
27328
29357
  return EMPTY_DP_CATALOG;
27329
29358
  }
27330
29359
  }
27331
- async commandContext(sn) {
27332
- const rec = await this.registry.record(sn);
29360
+ /** The {@link CommandContext} for a device, built from `known` when that record is already in hand. */
29361
+ async commandContext(sn, known) {
29362
+ const rec = known ?? await this.registry.record(sn);
27333
29363
  const dev = this.registry.require(sn);
27334
29364
  const raw = dev.raw ?? {};
27335
29365
  const rawChannel = raw.device_channel;
@@ -27366,6 +29396,7 @@ var EufyMega = class extends EventEmitter9 {
27366
29396
  hasP2p: P2PCommandRouter.claimsDevice(dev),
27367
29397
  // Topology as the record states it: a parent that isn't the device itself means HomeBase-attached.
27368
29398
  homeBaseAttached: !!raw.parent_sn && raw.parent_sn !== dev.sn,
29399
+ stationDeviceType: this.stationDeviceTypeOf(sn),
27369
29400
  dpCatalog
27370
29401
  };
27371
29402
  }
@@ -27540,8 +29571,10 @@ var EufyMega = class extends EventEmitter9 {
27540
29571
  */
27541
29572
  async teardownRealtime() {
27542
29573
  this.pollTimer.cancel();
29574
+ this.pushRetryTimer.cancel();
27543
29575
  this.lastStateAnnounced.clear();
27544
29576
  await this.closeMqttTransports();
29577
+ this.rtc.close();
27545
29578
  await this.p2p.closeAll();
27546
29579
  this.pushClient?.close();
27547
29580
  this.pushClient = void 0;
@@ -28046,7 +30079,7 @@ var SolixClient = class _SolixClient {
28046
30079
  };
28047
30080
 
28048
30081
  // dist/transport/mqtt/solix-mqtt.js
28049
- import { EventEmitter as EventEmitter10 } from "node:events";
30082
+ import { EventEmitter as EventEmitter14 } from "node:events";
28050
30083
  var SOLIX_METER_FIELD_NAMES = {
28051
30084
  168: "meterPowerL1",
28052
30085
  // Anker Modbus: primary_phase_1_active_power
@@ -28224,7 +30257,7 @@ function addSolarbankScalars(frame, out) {
28224
30257
  out.gridExportLimit = df.readUInt16LE(5);
28225
30258
  }
28226
30259
  }
28227
- var SolixMqtt = class extends EventEmitter10 {
30260
+ var SolixMqtt = class extends EventEmitter14 {
28228
30261
  transport;
28229
30262
  appName;
28230
30263
  userId;