@mega-yfue/eufy-sdk 0.5.0-beta.21 → 0.5.0-beta.23

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.
@@ -418,9 +418,10 @@ export declare class EufyMega extends EventEmitter {
418
418
  * The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
419
419
  * Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
420
420
  * (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
421
- * kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000
421
+ * kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000 or T8N00
422
422
  * station and the devices attached to it go over RTC, with an attached device's command refused
423
- * unless its channel resolves back to it on the station. Everything else is P2P.
423
+ * unless its channel resolves back to it on the station. T8N00 uses full SDP and admits direct and
424
+ * relay candidates; T9000 retains compact SDP and relay-only candidates. Everything else is P2P.
424
425
  */
425
426
  private routeCommand;
426
427
  /**
@@ -55,6 +55,8 @@ export interface EufyDevice {
55
55
  * every attached sensor of a T8010 — and serves only as a fallback for a device naming no parent.
56
56
  */
57
57
  stationSn?: string;
58
+ /** The device's channel on its station, as its record states it (`device_channel`); absent where it states none. */
59
+ channel?: number;
58
60
  /** Present (and non-empty) for P2P devices. */
59
61
  p2pDid?: string;
60
62
  /**
package/dist/index.js CHANGED
@@ -3068,8 +3068,7 @@ var STATION_TYPES = /* @__PURE__ */ new Set([
3068
3068
  DeviceType.STATION_9000,
3069
3069
  DeviceType.MINIBASE_CHIME,
3070
3070
  DeviceType.HOMEBASE_MINI,
3071
- DeviceType.NVR_S4_MAX,
3072
- DeviceType.CAMERA_POE_S4
3071
+ DeviceType.NVR_S4_MAX
3073
3072
  ]);
3074
3073
  var LOCK_TYPES = /* @__PURE__ */ new Set([
3075
3074
  DeviceType.LOCK_BLE,
@@ -3143,7 +3142,7 @@ function codecFromModel(model) {
3143
3142
  return "vacuum";
3144
3143
  if (/^T8(00[0-9]|010|023|025|030)/.test(m))
3145
3144
  return "station";
3146
- if (/^T8N0/.test(m) || /^T8E0/.test(m))
3145
+ if (/^T8N0/.test(m))
3147
3146
  return "station";
3148
3147
  if (/^T9000/.test(m))
3149
3148
  return "station";
@@ -5095,6 +5094,7 @@ var isIndoorCamMini = (ctx) => ctx.deviceType === DeviceType.INDOOR_COST_DOWN_CA
5095
5094
  var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
5096
5095
  var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
5097
5096
  var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
5097
+ var isNvrT8N00 = (ctx) => ctx.model === "T8N00";
5098
5098
  var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
5099
5099
  var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
5100
5100
 
@@ -7683,7 +7683,8 @@ var ARMING_CMD = {
7683
7683
  */
7684
7684
  SET_ARMING: 1224,
7685
7685
  /**
7686
- * The per-mode alarm/arm-delay configuration write. ✅ WIRE CAPTURED live on a T8030 (
7686
+ * One guard mode's per-device action table (app `CMD_SET_ALL_ACTION`), which carries the alarm delays
7687
+ * and each device's actions in that mode. ✅ WIRE CAPTURED live on a T8030 (
7687
7688
  * 2026-07-23, both directions): a **bare
7688
7689
  * JSON frame, no `1350`/`1700` envelope** — outer P2P cmd IS `1255` itself, station channel 255,
7689
7690
  * plaintext `{account_id, count_down_alarm:{channel_list,delay_time},
@@ -7700,15 +7701,26 @@ var ARMING_CMD = {
7700
7701
  * corresponding GET command (`1310`/inner cmd `40003`, sent by the app right before editing) always
7701
7702
  * replied `{count:0,data:null}` in every capture — genuinely empty, not a decrypt failure (confirmed
7702
7703
  * via the same bidirectional decrypt this finding used) — so the app does NOT read the current
7703
- * config this way; how it does is still unknown. Without a working GET, safely PATCHING just one
7704
- * channel in or out of an existing list isn't possible without risking clobbering the rest — so
7704
+ * config this way; a T8010 reports it on its own params instead, see below. Without a working GET,
7705
+ * safely PATCHING just one channel in or out of an existing list isn't possible without risking
7706
+ * clobbering the rest — so
7705
7707
  * this ships as a caller-supplies-everything write instead of guessing a merge.
7706
7708
  * `devices`/`siren_sensor_action` are even less understood (raw per-device action codes, meaning
7707
7709
  * unconfirmed) and MUST come from a value independently read/captured for the target mode —
7708
7710
  * see `AlarmDelayConfig`'s field docs.
7711
+ *
7712
+ * ✅ On a T8010 the app writes the same command for the Device Control screen: header channel 0, the
7713
+ * whole table of the mode `{mode_id, devices, count_down_alarm, count_down_arm}`, acked by an all-zero
7714
+ * 1255 reply. The T8010 reports each table on its own params, Home on 1157 and Away on 1158, and the
7715
+ * reported table follows a write within 15 s. See {@link DEVICE_NOTIFY_FLAG}.
7709
7716
  */
7710
- ALARM_DELAY_CONFIG: 1255
7717
+ SET_ALL_ACTION: 1255
7711
7718
  };
7719
+ var DEVICE_NOTIFY_FLAG = 8;
7720
+ function actionTable(read, mode) {
7721
+ const value = read(`${mode}ActionTable`)?.value;
7722
+ return value && typeof value === "object" && Array.isArray(value.devices) ? value : void 0;
7723
+ }
7712
7724
  var ARMING_MODE_WIRE = {
7713
7725
  away: 0,
7714
7726
  home: 1,
@@ -7745,7 +7757,15 @@ function alarmDelayCommand(mode, config, ctx) {
7745
7757
  devices: config.devices.map((d) => ({ action: d.action, device_channel: d.deviceChannel })),
7746
7758
  siren_sensor_action: config.sirenSensorAction.map((d) => ({ action: d.action, device_channel: d.deviceChannel }))
7747
7759
  };
7748
- return setJsonRaw(ARMING_CMD.ALARM_DELAY_CONFIG, data, ctx, STATION_CHANNEL2);
7760
+ return setJsonRaw(ARMING_CMD.SET_ALL_ACTION, data, ctx, STATION_CHANNEL2);
7761
+ }
7762
+ var ACTION_TABLE_LAG_MS = 15e3;
7763
+ function withDeviceNotification(table, channel, on) {
7764
+ if (!table?.devices.some((d) => d.device_channel === channel))
7765
+ return void 0;
7766
+ const { account_id: _account, ...rest } = table;
7767
+ const devices = table.devices.map((d) => d.device_channel === channel ? { ...d, action: on ? d.action | DEVICE_NOTIFY_FLAG : d.action & ~DEVICE_NOTIFY_FLAG } : d);
7768
+ return { ...rest, devices };
7749
7769
  }
7750
7770
  var ARMING_MEMBERS = {
7751
7771
  /**
@@ -7803,7 +7823,45 @@ var ARMING_MEMBERS = {
7803
7823
  } catch (e) {
7804
7824
  return Promise.reject(e);
7805
7825
  }
7806
- }, "Write the full per-mode alarm/arm-delay configuration.")
7826
+ }, "Write the full per-mode alarm/arm-delay configuration."),
7827
+ /**
7828
+ * Whether the device on `channel` sends a push notification in `mode`: its {@link DEVICE_NOTIFY_FLAG}
7829
+ * in the action table the station reports for that mode. `undefined` where the station reports no table
7830
+ * or the table does not list `channel`. A device's channel is `EufyDevice.channel`.
7831
+ */
7832
+ deviceNotification: method(({ read }) => (mode, channel) => {
7833
+ const action = actionTable(read, mode)?.devices.find((d) => d.device_channel === channel)?.action;
7834
+ return typeof action === "number" ? (action & DEVICE_NOTIFY_FLAG) !== 0 : void 0;
7835
+ }, "Whether the device on a channel sends a push notification in a guard mode (home/away).", (ctx) => ctx.model === "T8010"),
7836
+ /**
7837
+ * Turn the push notification of the device on `channel` on or off in `mode` (cmd
7838
+ * {@link ARMING_CMD.SET_ALL_ACTION}): the station's reported table written back with that one flag
7839
+ * changed. Rejects, sending nothing, where the table does not list `channel`. A write builds on the last
7840
+ * one sent for that mode until the station reports a table observed 15 s after it, so a second write does
7841
+ * not revert it; a write whose dispatch rejects is not built on.
7842
+ */
7843
+ setDeviceNotification: method(({ ctx, sink, read }) => {
7844
+ const sent = /* @__PURE__ */ new Map();
7845
+ return (mode, channel, on) => {
7846
+ const last = sent.get(mode);
7847
+ const observed = read(`${mode}ActionTable`)?.ts ?? 0;
7848
+ const base = last && observed < last.at + ACTION_TABLE_LAG_MS ? last.table : actionTable(read, mode);
7849
+ const table = withDeviceNotification(base, channel, on);
7850
+ if (!table) {
7851
+ return Promise.reject(new Error(`arming: no ${mode} action table lists channel ${channel} [${describeDevice(ctx)}]`));
7852
+ }
7853
+ sent.set(mode, { table, at: Date.now() });
7854
+ return sink.dispatch(setJsonRaw(ARMING_CMD.SET_ALL_ACTION, table, ctx, 0)).catch((err) => {
7855
+ if (sent.get(mode)?.table === table) {
7856
+ if (last)
7857
+ sent.set(mode, last);
7858
+ else
7859
+ sent.delete(mode);
7860
+ }
7861
+ throw err;
7862
+ });
7863
+ };
7864
+ }, "Turn the push notification of the device on a channel on or off in a guard mode (home/away).", (ctx) => ctx.model === "T8010")
7807
7865
  };
7808
7866
  var ARMING = {
7809
7867
  capability: "arming",
@@ -10447,6 +10505,11 @@ var SuctionLevel = {
10447
10505
  };
10448
10506
  var SUCTION_LABEL = enumLabels(SuctionLevel);
10449
10507
  var ALL_SUCTION_LEVELS = [0, 1, 2, 3, 4, 5];
10508
+ function levelFromCatalog(member) {
10509
+ if (Object.hasOwn(SuctionLevel, member))
10510
+ return SuctionLevel[member];
10511
+ return Object.hasOwn(SUCTION_LABEL, member) ? Number(member) : void 0;
10512
+ }
10450
10513
  function suctionLevelName(value) {
10451
10514
  return SUCTION_LABEL[value];
10452
10515
  }
@@ -10507,13 +10570,15 @@ var SUCTION = {
10507
10570
  },
10508
10571
  /**
10509
10572
  * The per-model level range — sourced from the SKU's `get_product_data_point` catalog for DP 158
10510
- * when available. `undefined` when no catalog entry exists for DP 158. `setSuctionLevel` is only
10573
+ * when available. `undefined` when no catalog entry exists for DP 158, or when no listed member maps
10574
+ * to a known level (so a caller never reads `[]` as "nothing selectable"). `setSuctionLevel` is only
10511
10575
  * installed on AIoT devices (gated by `available: isAiotVacuum` on the `level` entry), so
10512
10576
  * `supportedLevels` is harmless data on a non-AIoT device.
10513
10577
  */
10514
10578
  actions({ ctx }) {
10515
10579
  const catalogRange = ctx.dpCatalog?.enumRanges.get(SUCTION_DP.SUCTION);
10516
- const supportedLevels = catalogRange?.filter((v) => v in SUCTION_LABEL);
10580
+ const levels = catalogRange?.flatMap((m) => levelFromCatalog(m) ?? []);
10581
+ const supportedLevels = levels?.length ? levels : void 0;
10517
10582
  return { supportedLevels };
10518
10583
  }
10519
10584
  };
@@ -10555,28 +10620,29 @@ function parseEnumRange(values) {
10555
10620
  if (values === null || values === void 0)
10556
10621
  return [];
10557
10622
  if (Array.isArray(values))
10558
- return toNumberArray(values);
10623
+ return toMemberArray(values);
10559
10624
  if (typeof values === "string") {
10560
10625
  try {
10561
10626
  const parsed = JSON.parse(values);
10562
10627
  if (Array.isArray(parsed))
10563
- return toNumberArray(parsed);
10628
+ return toMemberArray(parsed);
10564
10629
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
10565
10630
  const obj = parsed;
10566
10631
  if (Array.isArray(obj.range))
10567
- return toNumberArray(obj.range);
10632
+ return toMemberArray(obj.range);
10568
10633
  }
10569
10634
  } catch {
10570
10635
  }
10571
10636
  }
10572
10637
  return [];
10573
10638
  }
10574
- function toNumberArray(arr) {
10639
+ function toMemberArray(arr) {
10575
10640
  const result = [];
10576
10641
  for (const v of arr) {
10577
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
10578
- if (Number.isInteger(n))
10579
- result.push(n);
10642
+ if (typeof v === "number" && Number.isFinite(v))
10643
+ result.push(String(v));
10644
+ else if (typeof v === "string" && v !== "")
10645
+ result.push(v);
10580
10646
  }
10581
10647
  return result;
10582
10648
  }
@@ -11439,9 +11505,15 @@ var SECURITY_PARAMS = {
11439
11505
  provenance: "guessed"
11440
11506
  },
11441
11507
  1157: {
11442
- name: "jsDelayHome",
11508
+ name: "homeActionTable",
11443
11509
  type: "string",
11444
- provenance: "apk",
11510
+ provenance: "verified",
11511
+ encoding: "base64+json"
11512
+ },
11513
+ 1158: {
11514
+ name: "awayActionTable",
11515
+ type: "string",
11516
+ provenance: "verified",
11445
11517
  encoding: "base64+json"
11446
11518
  },
11447
11519
  1159: {
@@ -15367,7 +15439,7 @@ var PADDED_RESULT_BYTES = 132;
15367
15439
  var CMD_CAMERA_INFO = 1103;
15368
15440
  var CMD_DATABASE_IMAGE = 1308;
15369
15441
  var CMD_DATABASE = 1306;
15370
- var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
15442
+ var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
15371
15443
  var GCM_AAD = Buffer.from("eufy security");
15372
15444
  var LEVEL2_SEQ_BASE = 16909056;
15373
15445
  function paramReport(payload) {
@@ -15397,9 +15469,13 @@ function firstTableRows(text2) {
15397
15469
  } catch {
15398
15470
  continue;
15399
15471
  }
15400
- const data = parsed?.data;
15401
- if (Array.isArray(data))
15402
- return data;
15472
+ const reply = parsed;
15473
+ if (typeof reply?.mIntRet === "number" && reply.mIntRet !== 0)
15474
+ return { error: reply.mIntRet };
15475
+ if (reply?.data === "[]")
15476
+ return { rows: [] };
15477
+ if (Array.isArray(reply?.data))
15478
+ return { rows: reply.data };
15403
15479
  }
15404
15480
  return void 0;
15405
15481
  }
@@ -16021,6 +16097,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16021
16097
  socket.removeAllListeners("error");
16022
16098
  socket.on("error", (error) => this.emit("error", error));
16023
16099
  this.socket = socket;
16100
+ this.requestReceiveBuffer(socket);
16024
16101
  }
16025
16102
  this.connected = true;
16026
16103
  this.connectedAtMs = Date.now();
@@ -16759,6 +16836,29 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16759
16836
  query: this.fullTableQuery()
16760
16837
  });
16761
16838
  }
16839
+ /**
16840
+ * Read a date-ranged page of `history_record_info` from the station. The reply's `data` array groups
16841
+ * recording rows with companion tables; each group retains its `table_name` and `payload`.
16842
+ * `startTime` is `"0"` for the newest page or the oldest row's `yyyyMMddHHmmss` cursor for older rows.
16843
+ */
16844
+ queryRecordPage(opts) {
16845
+ return this.readDatabase("history_record_info", {
16846
+ accountId: opts.accountId,
16847
+ innerCmd: DB_QUERY.QUERY_LOCAL,
16848
+ query: {
16849
+ count: opts.count ?? 20,
16850
+ start_date: opts.startDate,
16851
+ end_date: opts.endDate,
16852
+ start_time: opts.startTime ?? "0",
16853
+ event_type: 0,
16854
+ ai_type: 0,
16855
+ storage_cloud: -1,
16856
+ trigger_type: 0,
16857
+ detection_type: 0,
16858
+ flag: 0
16859
+ }
16860
+ });
16861
+ }
16762
16862
  /**
16763
16863
  * Query one on-station table and answer its rows, once the reply is whole.
16764
16864
  *
@@ -16766,7 +16866,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16766
16866
  * several frames whose decrypted text is a fragment of one document, so the fragments are
16767
16867
  * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16768
16868
  * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16769
- * complete reply.
16869
+ * complete reply, and when the station answers a non-zero `mIntRet`.
16770
16870
  *
16771
16871
  * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16772
16872
  * session throws, so a second reply cannot land in this buffer.
@@ -16774,16 +16874,22 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16774
16874
  async readDatabase(table, opts = {}) {
16775
16875
  if (opts.signal?.aborted)
16776
16876
  throw new Error("readDatabase: aborted");
16777
- this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16877
+ this.queryDatabase(table, {
16878
+ accountId: opts.accountId,
16879
+ innerCmd: opts.innerCmd,
16880
+ query: opts.query ?? this.fullTableQuery()
16881
+ });
16778
16882
  this.dbReadInFlight = true;
16779
16883
  try {
16780
16884
  return await new Promise((resolve, reject) => {
16781
16885
  let text2 = "";
16782
16886
  const onChunk = (chunk) => {
16783
16887
  text2 += chunk.text;
16784
- const rows = firstTableRows(text2);
16785
- if (rows)
16786
- settle(() => resolve(rows));
16888
+ const result = firstTableRows(text2);
16889
+ if (result?.error !== void 0)
16890
+ settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
16891
+ else if (result?.rows)
16892
+ settle(() => resolve(result.rows));
16787
16893
  };
16788
16894
  const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16789
16895
  const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
@@ -20844,16 +20950,12 @@ function stationOf(dev) {
20844
20950
  const parent = typeof raw.parent_sn === "string" && raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : void 0;
20845
20951
  return parent ?? dev.stationSn ?? resolvedStationSn(raw, dev.sn);
20846
20952
  }
20847
- function statedChannel(dev) {
20848
- const v = dev.raw?.device_channel;
20849
- return typeof v === "number" ? v : void 0;
20850
- }
20851
20953
  function stationChannels(devices) {
20852
20954
  const out = /* @__PURE__ */ new Map();
20853
20955
  const claimants = /* @__PURE__ */ new Map();
20854
20956
  for (const d of devices) {
20855
20957
  const station = stationOf(d);
20856
- const stated = statedChannel(d);
20958
+ const stated = d.channel;
20857
20959
  if (d.sn === station || stated === void 0)
20858
20960
  continue;
20859
20961
  const counts = claimants.get(station) ?? /* @__PURE__ */ new Map();
@@ -20862,7 +20964,7 @@ function stationChannels(devices) {
20862
20964
  }
20863
20965
  for (const d of devices) {
20864
20966
  const station = stationOf(d);
20865
- const stated = statedChannel(d);
20967
+ const stated = d.channel;
20866
20968
  if (d.sn === station)
20867
20969
  out.set(d.sn, { channel: stated ?? 0 });
20868
20970
  else if (stated === void 0)
@@ -22264,7 +22366,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
22264
22366
  * **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
22265
22367
  * exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
22266
22368
  * capture of the app's own SET_SNOOZE_TIME (1271) frame — see `param-dictionary.ts`'s `1271` entry
22267
- * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.ALARM_DELAY_CONFIG`) reuses
22369
+ * (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.SET_ALL_ACTION`) reuses
22268
22370
  * the same bare-JSON shape.
22269
22371
  */
22270
22372
  async sendJsonRaw(sn, outerCmd, data, channel) {
@@ -22927,6 +23029,7 @@ var RtcPeer = class extends EventEmitter7 {
22927
23029
  wireTally = /* @__PURE__ */ new Map();
22928
23030
  commandOpen = false;
22929
23031
  remoteSet = false;
23032
+ remoteMid = HUB_SDP_MID;
22930
23033
  handlingOffer = false;
22931
23034
  channelsCreated = false;
22932
23035
  gatheringDone = false;
@@ -22947,7 +23050,7 @@ var RtcPeer = class extends EventEmitter7 {
22947
23050
  const createPeer = this.opts.createPeer ?? await loadNativePeerFactory();
22948
23051
  const config = {
22949
23052
  iceServers: turnServers(turn),
22950
- iceTransportPolicy: "relay",
23053
+ iceTransportPolicy: this.opts.iceTransportPolicy ?? "relay",
22951
23054
  maxMessageSize: ANKER_MAX_MESSAGE_SIZE,
22952
23055
  enableIceTcp: true
22953
23056
  };
@@ -22998,6 +23101,8 @@ var RtcPeer = class extends EventEmitter7 {
22998
23101
  this.handlingOffer = true;
22999
23102
  try {
23000
23103
  const offer = forceDtlsRole(offerSdp, "passive");
23104
+ const application = offer.split(/(?=^m=)/m).find((section) => section.startsWith("m=application "));
23105
+ this.remoteMid = application?.match(/^a=mid:([^\r\n]+)/m)?.[1] ?? HUB_SDP_MID;
23001
23106
  const answerWait = new Promise((resolve, reject) => {
23002
23107
  const timer = setTimeout(() => {
23003
23108
  this.localAnswer = void 0;
@@ -23070,7 +23175,7 @@ var RtcPeer = class extends EventEmitter7 {
23070
23175
  this.pending.length = 0;
23071
23176
  }
23072
23177
  acceptsCandidate(candidate) {
23073
- return iceCandidateType(candidate) === "relay";
23178
+ return this.opts.iceTransportPolicy === "all" || iceCandidateType(candidate) === "relay";
23074
23179
  }
23075
23180
  createChannels() {
23076
23181
  if (!this.pc || this.channelsCreated)
@@ -23126,7 +23231,7 @@ var RtcPeer = class extends EventEmitter7 {
23126
23231
  }
23127
23232
  addNow(candidate) {
23128
23233
  try {
23129
- this.pc?.addRemoteCandidate(candidate, HUB_SDP_MID);
23234
+ this.pc?.addRemoteCandidate(candidate, this.remoteMid);
23130
23235
  } catch (e) {
23131
23236
  this.logger.warn(`[rtc] addRemoteCandidate failed: ${e instanceof Error ? e.message : String(e)}`);
23132
23237
  }
@@ -23303,14 +23408,14 @@ var RtcSignalingClient = class extends EventEmitter8 {
23303
23408
  this.sendEnvelope(`${this.opts.authToken}_${this.makeMsgId()}`, inner);
23304
23409
  }
23305
23410
  sendCall() {
23306
- this.sendSession("scall");
23411
+ this.sendSession(this.opts.signalingMode ?? "scall");
23307
23412
  }
23308
23413
  sendAck() {
23309
23414
  this.sendSession("ack");
23310
23415
  }
23311
- /** The SDP answer, as scall JSON text, in an `info`. */
23312
- sendInfoSdp(scallJson) {
23313
- this.sendSession("info", { sdp: scallJson });
23416
+ /** The SDP answer, encoded for the negotiated call format, in an `info`. */
23417
+ sendInfoSdp(sdp) {
23418
+ this.sendSession("info", { sdp });
23314
23419
  }
23315
23420
  /** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
23316
23421
  sendInfoCandidate(candidate) {
@@ -23438,7 +23543,7 @@ var RtcSession = class extends EventEmitter9 {
23438
23543
  return this.connected;
23439
23544
  }
23440
23545
  /**
23441
- * Start the sequence; resolves once `scall` is sent. `connected` fires when the channel opens. A session
23546
+ * Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
23442
23547
  * closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
23443
23548
  * placing the call.
23444
23549
  */
@@ -23450,7 +23555,7 @@ var RtcSession = class extends EventEmitter9 {
23450
23555
  await this.waitForAuth();
23451
23556
  if (this.closed)
23452
23557
  throw new Error("RTC session closed before the call was placed");
23453
- this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 scall`);
23558
+ this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 ${this.opts.signalingMode ?? "scall"}`);
23454
23559
  this.signaling.sendCall();
23455
23560
  }
23456
23561
  /** Send one portal packet; false when the command channel isn't open. */
@@ -23587,7 +23692,7 @@ var RtcSession = class extends EventEmitter9 {
23587
23692
  offer = sdpText;
23588
23693
  }
23589
23694
  const answer = await this.peer.handleRemoteOffer(offer);
23590
- this.signaling.sendInfoSdp(JSON.stringify(sdpToScallJson(answer)));
23695
+ this.signaling.sendInfoSdp(this.opts.signalingMode === "call" ? answer : JSON.stringify(sdpToScallJson(answer)));
23591
23696
  this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
23592
23697
  }
23593
23698
  };
@@ -23608,7 +23713,7 @@ var RtcCommandRouter = class {
23608
23713
  }
23609
23714
  async dispatchCommand(route, cmd) {
23610
23715
  if (cmd.kind !== "set-payload" && cmd.kind !== "set-json") {
23611
- throw new Error(`rtc: ${cmd.kind} is not routable over the T9000 control channel (only set-payload or set-json)`);
23716
+ throw new Error(`rtc: ${cmd.kind} is not routable over the RTC control channel (only set-payload or set-json)`);
23612
23717
  }
23613
23718
  const identity = this.deps.identity();
23614
23719
  if (!identity)
@@ -23616,7 +23721,7 @@ var RtcCommandRouter = class {
23616
23721
  const { stationSn } = route;
23617
23722
  const adminUserId = route.adminUserId || identity.userId;
23618
23723
  const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
23619
- const st = await this.stationSession(stationSn, adminUserId, identity);
23724
+ const st = await this.stationSession(route, adminUserId, identity);
23620
23725
  const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
23621
23726
  const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
23622
23727
  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 };
@@ -23677,7 +23782,8 @@ var RtcCommandRouter = class {
23677
23782
  }
23678
23783
  });
23679
23784
  }
23680
- async stationSession(sn, adminUserId, identity) {
23785
+ async stationSession(route, adminUserId, identity) {
23786
+ const sn = route.stationSn;
23681
23787
  const existing = this.sessions.get(sn);
23682
23788
  if (existing) {
23683
23789
  await existing.ready;
@@ -23695,7 +23801,8 @@ var RtcCommandRouter = class {
23695
23801
  shard: this.deps.shard(),
23696
23802
  country: this.deps.country ?? "US",
23697
23803
  logger: this.deps.logger,
23698
- peer: { logger: this.deps.logger }
23804
+ signalingMode: route.signalingMode,
23805
+ peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy }
23699
23806
  });
23700
23807
  const ready = this.bringUp(sn, session);
23701
23808
  const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve() };
@@ -26556,6 +26663,7 @@ var DeviceRegistry = class {
26556
26663
  name: recordName(raw, deviceClass),
26557
26664
  model: raw.device_model,
26558
26665
  stationSn: resolvedStationSn(raw, raw.device_sn),
26666
+ ...typeof raw.device_channel === "number" ? { channel: raw.device_channel } : {},
26559
26667
  p2pDid: raw.p2p_did,
26560
26668
  params,
26561
26669
  paramUpdatedAt,
@@ -27908,9 +28016,10 @@ var EufyMega = class extends EventEmitter12 {
27908
28016
  * The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
27909
28017
  * Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
27910
28018
  * (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
27911
- * kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000
28019
+ * kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000 or T8N00
27912
28020
  * station and the devices attached to it go over RTC, with an attached device's command refused
27913
- * unless its channel resolves back to it on the station. Everything else is P2P.
28021
+ * unless its channel resolves back to it on the station. T8N00 uses full SDP and admits direct and
28022
+ * relay candidates; T9000 retains compact SDP and relay-only candidates. Everything else is P2P.
27914
28023
  */
27915
28024
  routeCommand(sn, cmd) {
27916
28025
  if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
@@ -27936,13 +28045,16 @@ var EufyMega = class extends EventEmitter12 {
27936
28045
  const station = devices.find((d) => d.sn === stationSn);
27937
28046
  const stationRaw = station?.raw ?? {};
27938
28047
  const deviceType = typeof stationRaw.device_type === "number" ? stationRaw.device_type : void 0;
27939
- if (target && station && isStation9000({ deviceType, model: station.model })) {
28048
+ const family = { deviceType, model: station?.model };
28049
+ const native = isNvrT8N00(family);
28050
+ if (target && station && (isStation9000(family) || native)) {
27940
28051
  const attached = stationSn !== sn;
27941
28052
  if (attached && this.registry.serialForFrame(stationSn, cmd.channel) !== sn)
27942
28053
  return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
27943
28054
  const member = stationRaw.member?.admin_user_id;
27944
28055
  const adminUserId = typeof member === "string" && member ? member : void 0;
27945
- return this.rtc.dispatchCommand({ stationSn, adminUserId, attached }, cmd);
28056
+ const base = { stationSn, adminUserId, attached };
28057
+ return this.rtc.dispatchCommand(native ? { ...base, signalingMode: "call", iceTransportPolicy: "all" } : base, cmd);
27946
28058
  }
27947
28059
  return this.p2p.dispatchCommand(sn, cmd);
27948
28060
  }