@mega-yfue/eufy-sdk 0.5.0-beta.87 → 0.5.0-beta.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2160,6 +2160,32 @@ ${this.auth_?.authToken ?? ""}`;
2160
2160
  const shard = this.region.split("-")[0];
2161
2161
  return shard === "us" ? "security-app.eufylife.com" : `security-app-${shard}.eufylife.com`;
2162
2162
  }
2163
+ /**
2164
+ * The device's long-lived P-256 public key, as the cloud publishes it for key-wrapped commands
2165
+ * (`type=2`): 64 bytes hex with no `04` prefix. A plain authed GET on the security-app host — the
2166
+ * one read here that is neither signed nor body-encrypted, matching how the app fetches it.
2167
+ * Cached per serial for the client's lifetime; the key does not rotate in normal operation.
2168
+ */
2169
+ async getDevicePublicKey(deviceSn) {
2170
+ const cached2 = this.devicePublicKeys.get(deviceSn);
2171
+ if (cached2)
2172
+ return cached2;
2173
+ const url = `https://${this.securityAppHost()}/v1/app/public_key/query?device_sn=${encodeURIComponent(deviceSn)}&type=2`;
2174
+ const res = await fetch(url, {
2175
+ method: "GET",
2176
+ headers: { ...this.baseHeaders(), ...this.authTokenHeaders() },
2177
+ signal: AbortSignal.timeout(2e4)
2178
+ });
2179
+ const env = parseMaybeJson(await res.text());
2180
+ const key = env?.data?.public_key;
2181
+ if (res.status !== 200 || env?.code !== 0 || typeof key !== "string" || !key.length) {
2182
+ throw new MegaApiError(`public_key/query failed (${res.status}/${env?.code}): ${env?.msg ?? "no public key"}`, env?.code, res.status);
2183
+ }
2184
+ this.devicePublicKeys.set(deviceSn, key);
2185
+ return key;
2186
+ }
2187
+ /** Per-serial cache for {@link getDevicePublicKey}. */
2188
+ devicePublicKeys = /* @__PURE__ */ new Map();
2163
2189
  /**
2164
2190
  * Signed+encrypted POST to the security-app data host (face recognition, etc.).
2165
2191
  * Despite the different host, these endpoints use the SAME algo_ecdh pipeline as
@@ -3661,6 +3687,7 @@ var isDoorbellT8223 = (ctx) => ctx.model === "T8223";
3661
3687
  var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
3662
3688
  var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
3663
3689
  var isVideoSmartLock = (ctx) => ctx.deviceType === DeviceType.LOCK_8530;
3690
+ var isClassicWifiLock = (ctx) => ctx.deviceType === DeviceType.LOCK_WIFI && typeof ctx.serial === "string" && ctx.serial.startsWith("T8520") && ctx.serial.charAt(6) === "2";
3664
3691
 
3665
3692
  // dist/model/capabilities/rtsp.js
3666
3693
  var RTSP_PARAM = {
@@ -4712,6 +4739,10 @@ function cellGated(members) {
4712
4739
  });
4713
4740
  return Object.fromEntries(gated);
4714
4741
  }
4742
+ function isChargingState(v) {
4743
+ const n = Number(v);
4744
+ return n !== 0 && n !== 2;
4745
+ }
4715
4746
  var BATTERY_MEMBERS = cellGated({
4716
4747
  /**
4717
4748
  * The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
@@ -4737,10 +4768,7 @@ var BATTERY_MEMBERS = cellGated({
4737
4768
  type: "bool",
4738
4769
  kind: "boolean",
4739
4770
  provenance: "apk",
4740
- coerce: (v) => {
4741
- const n = Number(v);
4742
- return n !== 0 && n !== 2;
4743
- },
4771
+ coerce: isChargingState,
4744
4772
  description: "Charging in progress \u2014 derived from BATTERY_STATUS (2111) as `value \u2209 {0,2}`, matching the app's own decode. 0/2 = not charging; a solar/USB charge (incl. a built-in solar panel, which reads 4) is non-zero."
4745
4773
  },
4746
4774
  /**
@@ -7534,7 +7562,7 @@ function actuate(engage, ctx, sink) {
7534
7562
  });
7535
7563
  }
7536
7564
  return sink.dispatch({
7537
- kind: "ff09-actuate",
7565
+ kind: isClassicWifiLock(ctx) ? "keyed-payload-actuate" : "ff09-actuate",
7538
7566
  engage,
7539
7567
  adminUserId: ctx.adminUserId,
7540
7568
  username: ctx.accountName ?? "",
@@ -7579,6 +7607,8 @@ function nativeReport(signal) {
7579
7607
  const report = signal.json.payload;
7580
7608
  return report?.code === 0 ? report : void 0;
7581
7609
  }
7610
+ var speaksFf09Settings = (ctx) => !isClassicWifiLock(ctx);
7611
+ var rainModeAvailable = (ctx) => overP2p(ctx) && speaksFf09Settings(ctx);
7582
7612
  var LOCK_MEMBERS = {
7583
7613
  /**
7584
7614
  * The lock's own state, verified on param 6000: "4" = locked, "3" = unlocked.
@@ -7609,8 +7639,8 @@ var LOCK_MEMBERS = {
7609
7639
  description: "Lock battery level 0-100 (verified: param 1101, alias 6001)."
7610
7640
  },
7611
7641
  /**
7612
- * Link quality in dBm as the lock measures it, on the shared param 1141. Both actuation methods here
7613
- * are fire-and-forget, so a weak link is silent rather than an error.
7642
+ * Link quality in dBm as the lock measures it, on the shared param 1141. On the `ff09` wire the
7643
+ * actuations are fire-and-forget, so a weak link is silent rather than an error.
7614
7644
  */
7615
7645
  rssi: {
7616
7646
  param: 1141,
@@ -7620,9 +7650,15 @@ var LOCK_MEMBERS = {
7620
7650
  provenance: "verified",
7621
7651
  description: "Lock signal strength (verified: param 1141 = RSSI)."
7622
7652
  },
7623
- /** Lock the deadbolt/door. Resolves after sending; does not confirm physical state. */
7653
+ /**
7654
+ * Lock the deadbolt/door. Resolves after sending, without confirming physical state — except on the
7655
+ * classic Wi-Fi lock, which answers: there it rejects on a device refusal or no reply as well.
7656
+ */
7624
7657
  lock: method(({ ctx, sink }) => () => actuate(true, ctx, sink), "Lock the deadbolt/door.", actuationAvailable),
7625
- /** Unlock the deadbolt/door. Resolves after sending; does not confirm physical state. */
7658
+ /**
7659
+ * Unlock the deadbolt/door. Resolves after sending, without confirming physical state — except on the
7660
+ * classic Wi-Fi lock, which answers: there it rejects on a device refusal or no reply as well.
7661
+ */
7626
7662
  unlock: method(({ ctx, sink }) => () => actuate(false, ctx, sink), "Unlock the deadbolt/door.", actuationAvailable),
7627
7663
  /**
7628
7664
  * Read-modify-write the auto-lock setting: the transport GETs the device's current settings, changes
@@ -7644,13 +7680,13 @@ var LOCK_MEMBERS = {
7644
7680
  enabled,
7645
7681
  delaySeconds
7646
7682
  });
7647
- }, "Read-modify-write the auto-lock setting, preserving the fields it does not change.", (ctx) => !isVideoSmartLock(ctx)),
7683
+ }, "Read-modify-write the auto-lock setting, preserving the fields it does not change.", (ctx) => !isVideoSmartLock(ctx) && speaksFf09Settings(ctx)),
7648
7684
  /**
7649
7685
  * Toggle Rain Mode — a pure blind write, unlike `setAutoLock`: the compact frame carries only this one
7650
7686
  * field, so there is no GET pass and it is fire-and-forget. Confirmed on-device end-to-end in both
7651
7687
  * directions, with the app UI reflecting the new state afterward. P2P video lock only.
7652
7688
  */
7653
- setRainMode: method(({ ctx, sink }) => (enabled) => settingToggle(LOCK_SETTING_ID.RAIN_MODE, "setRainMode", enabled, ctx, sink), "Toggle Rain Mode.", overP2p),
7689
+ setRainMode: method(({ ctx, sink }) => (enabled) => settingToggle(LOCK_SETTING_ID.RAIN_MODE, "setRainMode", enabled, ctx, sink), "Toggle Rain Mode.", rainModeAvailable),
7654
7690
  /**
7655
7691
  * Lock the door by a single touch on the pad, with no code. First of the six compact toggles: all
7656
7692
  * `unverified` (frame shape read out of the app's JS, never captured), so no setter is installed and
@@ -7809,36 +7845,34 @@ var LOCK = {
7809
7845
  // dist/model/capabilities/keypad.js
7810
7846
  var KEYPAD_MEMBERS = {
7811
7847
  /**
7812
- * `unexposed`, so it reaches a diagnosis through `getProperty` but gets no typed getter. 1103 is a
7813
- * placeholder AND a likely collision — the app's own param table names 1103 GET_CAMERA_INFO — so a
7814
- * getter here would be asserting a meaning nothing backs. The keypad's real battery state arrives
7815
- * under `keypadBatteryCapState`, whose low/charging semantics are not confirmed.
7848
+ * `APP_CMD_KEYPAD_BATTERY_CAP_STATE` (1653). The app's sensor parser maps it to `sensor_is_low_power`,
7849
+ * the field `APP_CMD_ENTRY_SENSOR_BAT_STATE` and `APP_CMD_MOTION_SENSOR_BAT_STATE` decode as a boolean,
7850
+ * yet gives 1653 itself no decode case. `unexposed` until a device confirms which value means low; in
7851
+ * the schema for `getProperty`, absent from the typed surface.
7816
7852
  */
7817
7853
  batteryLow: {
7818
- param: 1103,
7854
+ param: 1653,
7819
7855
  type: "bool",
7820
7856
  kind: "boolean",
7821
- provenance: "guessed",
7857
+ provenance: "apk",
7822
7858
  unexposed: true,
7823
- description: "Keypad low-battery flag. UNVERIFIED: placeholder id pending verification \u2014 and the app's own param table names 1103 GET_CAMERA_INFO, so the id is very likely wrong here."
7859
+ description: "Keypad low-battery flag (APP_CMD_KEYPAD_BATTERY_CAP_STATE, 1653). UNVERIFIED polarity: the app names it its low-battery field but does not decode its value."
7824
7860
  },
7825
7861
  /**
7826
- * The charging half of the battery pair, `unexposed` for the same reason as `batteryLow`: 1102 is a
7827
- * placeholder id, and the state the keypad actually reports (`keypadBatteryChargerState`) has no
7828
- * confirmed value space. In the schema for `getProperty`, absent from the typed surface.
7862
+ * `APP_CMD_KEYPAD_BATTERY_CHARGER_STATE` (1655), decoded the way the app's sensor parser decodes it:
7863
+ * `charging = value ∉ {0,2}` ({@link isChargingState}).
7829
7864
  */
7830
7865
  charging: {
7831
- param: 1102,
7866
+ param: 1655,
7832
7867
  type: "bool",
7833
7868
  kind: "boolean",
7834
- provenance: "guessed",
7835
- unexposed: true,
7836
- description: "Keypad charging flag. UNVERIFIED: placeholder id pending verification."
7869
+ provenance: "apk",
7870
+ coerce: isChargingState,
7871
+ description: "Keypad charging \u2014 derived from APP_CMD_KEYPAD_BATTERY_CHARGER_STATE (1655) as `value \u2209 {0,2}`, matching the app's own decode."
7837
7872
  },
7838
7873
  /**
7839
- * The keypad's link quality in dBm — the one verified read in this table, on the same param 1141 the
7840
- * other sub-1G sensors report signal strength on. Reported as the device measures it: raw dBm, never
7841
- * normalised to a bar count.
7874
+ * The keypad's link quality in dBm, on the same param 1141 the other sub-1G sensors report signal
7875
+ * strength on. Reported as the device measures it: raw dBm, never normalised to a bar count.
7842
7876
  */
7843
7877
  rssi: {
7844
7878
  param: 1141,
@@ -15141,13 +15175,8 @@ function abortable(work, signal) {
15141
15175
  ]);
15142
15176
  }
15143
15177
 
15144
- // dist/transport/p2p/command-router.js
15145
- import { setTimeout as sleep2 } from "node:timers/promises";
15146
-
15147
- // dist/transport/p2p/p2p-session.js
15148
- import { EventEmitter as EventEmitter2 } from "node:events";
15149
- import dgram from "node:dgram";
15150
- import { constants as constants2, createCipheriv as createCipheriv3, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt as privateDecrypt2, randomBytes as randomBytes2 } from "node:crypto";
15178
+ // dist/transport/p2p/keyed-payload.js
15179
+ import { createCipheriv as createCipheriv3, randomBytes as randomBytes3 } from "node:crypto";
15151
15180
 
15152
15181
  // dist/transport/p2p/codec.js
15153
15182
  var codec_exports = {};
@@ -15174,6 +15203,7 @@ __export(codec_exports, {
15174
15203
  deriveLevel2KeyFromGatewayInfo: () => deriveLevel2KeyFromGatewayInfo,
15175
15204
  deriveLevel2KeyFromRsaGatewayInfo: () => deriveLevel2KeyFromRsaGatewayInfo,
15176
15205
  eciesUnwrap: () => eciesUnwrap,
15206
+ eciesWrap: () => eciesWrap,
15177
15207
  encryptP2PData: () => encryptP2PData,
15178
15208
  frameMessage: () => frameMessage,
15179
15209
  gatewayInfoCipherId: () => gatewayInfoCipherId,
@@ -15186,7 +15216,7 @@ __export(codec_exports, {
15186
15216
  readNullTerminatedString: () => readNullTerminatedString,
15187
15217
  stringWithLength: () => stringWithLength
15188
15218
  });
15189
- import { constants, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv3, createECDH as createECDH2, createHmac as createHmac2, createPrivateKey, privateDecrypt, timingSafeEqual } from "node:crypto";
15219
+ import { constants, createCipheriv as createCipheriv2, createDecipheriv as createDecipheriv3, createECDH as createECDH2, createHmac as createHmac2, createPrivateKey, privateDecrypt, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
15190
15220
  var MAGIC_WORD = "XZYH";
15191
15221
  var RequestMessageType = {
15192
15222
  LOCAL_LOOKUP: Buffer.from([241, 48]),
@@ -15323,6 +15353,19 @@ function eciesUnwrap(envelope, eccPrivateKeyHex, options = {}) {
15323
15353
  return void 0;
15324
15354
  }
15325
15355
  }
15356
+ function eciesWrap(plaintext, peerPublicKey, options = {}) {
15357
+ const ecdh = createECDH2("prime256v1");
15358
+ if (options.ephemeralPrivateKey)
15359
+ ecdh.setPrivateKey(options.ephemeralPrivateKey);
15360
+ else
15361
+ ecdh.generateKeys();
15362
+ const kdf = eufyKdf(ecdh.computeSecret(peerPublicKey), 48);
15363
+ const iv = options.iv ?? randomBytes2(16);
15364
+ const cipher = createCipheriv2("aes-128-cbc", kdf.subarray(0, 16), iv);
15365
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
15366
+ const tag2 = createHmac2("sha256", kdf.subarray(16, 48)).update(iv).update(ct).digest();
15367
+ return Buffer.concat([ecdh.getPublicKey(null, "compressed"), iv, ct, tag2]);
15368
+ }
15326
15369
  function deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivateKeyHex) {
15327
15370
  const plain = eciesUnwrap(gwPayload.subarray(4, 4 + 129), eccPrivateKeyHex);
15328
15371
  if (!plain)
@@ -16042,6 +16085,65 @@ function commandName(id) {
16042
16085
  return COMMAND_NAMES[id] ?? `CMD_${id}`;
16043
16086
  }
16044
16087
 
16088
+ // dist/transport/p2p/keyed-payload.js
16089
+ var KEYED_PAYLOAD_SEQ_ERROR = -151;
16090
+ function serialIv(deviceSn) {
16091
+ const iv = Buffer.alloc(16);
16092
+ Buffer.from(deviceSn, "utf8").copy(iv, 0, 0, 16);
16093
+ return iv;
16094
+ }
16095
+ function spellAesKey(key) {
16096
+ return key.toString("hex").toUpperCase();
16097
+ }
16098
+ function buildKeyBlob(aesKeyHex, devicePublicKeyHex, rnd = {}) {
16099
+ const devicePublicKey = Buffer.concat([Buffer.from([4]), Buffer.from(devicePublicKeyHex, "hex")]);
16100
+ return eciesWrap(Buffer.from(aesKeyHex, "utf8"), devicePublicKey, {
16101
+ ephemeralPrivateKey: rnd.ephemeralPrivateKey,
16102
+ iv: rnd.nonce
16103
+ }).toString("hex");
16104
+ }
16105
+ function sealInnerPayload(innerJson, aesKeyHex, deviceSn) {
16106
+ const cipher = createCipheriv3("aes-128-cbc", Buffer.from(aesKeyHex, "hex"), serialIv(deviceSn));
16107
+ return Buffer.concat([cipher.update(Buffer.from(innerJson, "utf8")), cipher.final()]).toString("base64");
16108
+ }
16109
+ function buildKeyedActuatePayload(id, devicePublicKeyHex, userName, rnd) {
16110
+ const aesKeyHex = spellAesKey(rnd.aesKey ?? randomBytes3(16));
16111
+ const inner = JSON.stringify({
16112
+ shortUserId: id.shortUserId,
16113
+ slOperation: id.engage ? 1 : 0,
16114
+ userId: id.adminUserId,
16115
+ userName,
16116
+ seq_num: rnd.seqNum
16117
+ });
16118
+ return {
16119
+ key: buildKeyBlob(aesKeyHex, devicePublicKeyHex, rnd),
16120
+ payload: sealInnerPayload(inner, aesKeyHex, id.deviceSn)
16121
+ };
16122
+ }
16123
+ function keyedPayloadReportCode(json, engage) {
16124
+ if (json?.cmd !== CommandType.P2P_QUERY_STATUS_IN_LOCK)
16125
+ return void 0;
16126
+ const payload = json.payload;
16127
+ const slState = engage ? "4" : "3";
16128
+ if (payload?.slState !== slState)
16129
+ return void 0;
16130
+ return typeof payload?.code === "number" ? payload.code : void 0;
16131
+ }
16132
+ function keyedPayloadSeqMark(json) {
16133
+ if (json?.cmd !== CommandType.CMD_DOORLOCK_P2P_SEQ)
16134
+ return void 0;
16135
+ const seq = json.payload?.seq_num;
16136
+ return typeof seq === "number" && Number.isFinite(seq) ? seq : void 0;
16137
+ }
16138
+
16139
+ // dist/transport/p2p/command-router.js
16140
+ import { setTimeout as sleep2 } from "node:timers/promises";
16141
+
16142
+ // dist/transport/p2p/p2p-session.js
16143
+ import { EventEmitter as EventEmitter2 } from "node:events";
16144
+ import dgram from "node:dgram";
16145
+ import { constants as constants2, createCipheriv as createCipheriv4, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt as privateDecrypt2, randomBytes as randomBytes4 } from "node:crypto";
16146
+
16045
16147
  // dist/transport/p2p/live-trace.js
16046
16148
  var LIVE_TRACE_MESSAGE = "[live] start trace";
16047
16149
  function traceLiveStart(logger, trace, source) {
@@ -17450,8 +17552,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17450
17552
  encryptLevel2(plaintext) {
17451
17553
  if (!this.level2Key)
17452
17554
  return void 0;
17453
- const nonce = randomBytes2(12);
17454
- const c = createCipheriv3("aes-256-gcm", this.level2Key, nonce);
17555
+ const nonce = randomBytes4(12);
17556
+ const c = createCipheriv4("aes-256-gcm", this.level2Key, nonce);
17455
17557
  c.setAAD(GCM_AAD);
17456
17558
  const ct = Buffer.concat([c.update(plaintext), c.final()]);
17457
17559
  const sub2 = Buffer.alloc(4);
@@ -17661,22 +17763,28 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17661
17763
  * Low-level: send a `CMD_SET_PAYLOAD` (1350) wrapping `{account_id, cmd:<subCmd>, mChannel,
17662
17764
  * payload, transaction}` over the level-1 channel. The reply arrives as a `NOTIFY_PAYLOAD`
17663
17765
  * (1351) frame (level-1 decrypted, surfaced via the `data` event / `frame.json`).
17766
+ *
17767
+ * `opts.key` makes it a keyed envelope: the sealed per-command key rides ahead of the other fields
17768
+ * as the value's `key`, and the value goes out unencrypted, encryption-type 0, with `=` written
17769
+ * `=` — the form the vendor app sends one in, and the one form the device reads it in.
17664
17770
  */
17665
17771
  sendSetPayload(subCmd, payload = {}, opts = {}) {
17666
17772
  if (!this.connectAddress)
17667
17773
  throw new Error("not connected");
17668
17774
  const channel = opts.channel ?? 0;
17775
+ const keyed = opts.key !== void 0;
17669
17776
  const inner = opts.rawValue ?? {
17777
+ ...keyed ? { key: opts.key } : {},
17670
17778
  account_id: opts.accountId ?? "",
17671
17779
  cmd: subCmd,
17672
17780
  mChannel: channel,
17673
17781
  mValue3: 0,
17674
17782
  ...opts.omitPayload ? {} : { payload }
17675
17783
  };
17676
- const value = JSON.stringify(inner);
17784
+ const value = keyed ? JSON.stringify(inner).replace(/=/g, "\\u003d") : JSON.stringify(inner);
17677
17785
  const body = Buffer.concat([
17678
17786
  buildCommandHeader(this.seqNumber, opts.wrapCmd ?? CMD_SET_PAYLOAD),
17679
- buildStringCommandPayload(value, channel, this.level1Key, 1)
17787
+ buildStringCommandPayload(value, channel, keyed ? void 0 : this.level1Key, 1)
17680
17788
  ]);
17681
17789
  this.seqNumber = this.seqNumber + 1 & 65535;
17682
17790
  this.logger.debug(`[p2p] ${this.cfg.stationSn} sendSetPayload subCmd=${subCmd}`);
@@ -18027,7 +18135,7 @@ function buildDeviceNameBody(channel, name, accountId) {
18027
18135
  }
18028
18136
 
18029
18137
  // dist/transport/ff09.js
18030
- import { createCipheriv as createCipheriv4, createDecipheriv as createDecipheriv5 } from "node:crypto";
18138
+ import { createCipheriv as createCipheriv5, createDecipheriv as createDecipheriv5 } from "node:crypto";
18031
18139
  var LOCK_API_COMMAND = {
18032
18140
  /** Lock / unlock the deadbolt — video lock AND garage door, both directions (see above). */
18033
18141
  ON_OFF_LOCK: 6018,
@@ -18107,7 +18215,7 @@ function assembleFf09Frame(commandCode, apiCommand, plain, adminUserId, deviceSn
18107
18215
  const key = Buffer.concat([Buffer.from(adminUserId.slice(-12), "ascii"), u32be(keyTime)]);
18108
18216
  const iv = Buffer.alloc(16);
18109
18217
  Buffer.from(deviceSn, "ascii").copy(iv);
18110
- const cipher = createCipheriv4("aes-128-cbc", key, iv);
18218
+ const cipher = createCipheriv5("aes-128-cbc", key, iv);
18111
18219
  const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
18112
18220
  const body = Buffer.concat([Buffer.from([3, 0, 2]), u16be(16384 | commandCode), ct]);
18113
18221
  const size = 2 + 2 + body.length + 1;
@@ -21815,6 +21923,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
21815
21923
  cipherKeyCache = /* @__PURE__ */ new Map();
21816
21924
  /** The recording download in flight per station; a station serves one at a time. */
21817
21925
  recordingDownloads = /* @__PURE__ */ new Map();
21926
+ /**
21927
+ * The `seq_num` last sent to each device over the keyed-payload wire. The device holds a mark for the life
21928
+ * of a P2P connection: the first keyed command on a new connection is accepted at whatever number it
21929
+ * carries and becomes the mark, and after that a number not above the mark is refused with the mark
21930
+ * published ({@link keyedPayloadSeqMark}). Each accepted command moves this to its number, a refusal
21931
+ * moves it to the published mark + 1, and a device absent here has not been sent one in this process and
21932
+ * seeds from a small random number — which a new connection takes as sent.
21933
+ */
21934
+ keyedSequences = /* @__PURE__ */ new Map();
21935
+ /** The keyed-payload command in flight per device, which the next one for that device waits on. */
21936
+ keyedQueue = /* @__PURE__ */ new Map();
21818
21937
  constructor(deps) {
21819
21938
  this.deps = deps;
21820
21939
  this.manager = new SessionManager({
@@ -22170,6 +22289,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
22170
22289
  case "ff09-actuate":
22171
22290
  await this.sendFf09Actuate(sn, cmd);
22172
22291
  return;
22292
+ case "keyed-payload-actuate":
22293
+ await this.sendKeyedPayloadActuate(sn, cmd);
22294
+ return;
22173
22295
  case "ff09-autolock":
22174
22296
  await this.sendFf09Autolock(sn, cmd);
22175
22297
  return;
@@ -23152,6 +23274,163 @@ var P2PCommandRouter = class _P2PCommandRouter {
23152
23274
  }
23153
23275
  await this.replayLevel2Send(sn, `set-payload cmd ${cmd}`, ({ session, accountId }) => session.sendControlLevel2(cmd, channel, accountId, payload, mValue3 ?? cmd), resolved ?? await this.resolveSession(sn, { waitLevel2: true, level2Sender }));
23154
23276
  }
23277
+ /**
23278
+ * **`keyed-payload-actuate` intent** — the classic Wi-Fi lock's actuation: the sealed fields from
23279
+ * {@link buildKeyedActuatePayload}, sent as a keyed envelope through {@link P2PSession.sendSetPayload}
23280
+ * on the lock's device channel. No session key is waited for — the envelope seals its own per-command
23281
+ * key for the device's public key (fetched from the cloud, cached per serial), so it goes as soon as
23282
+ * the session is up. The device shows the acting member by display name, which lives on the device
23283
+ * record's `member` rather than in the intent, so it is read here and falls back to the account name
23284
+ * the intent carries.
23285
+ *
23286
+ * The device answers, so the outcome is read rather than assumed: {@link awaitKeyedReply} settles each
23287
+ * send on the replies, and resolves a {@link KEYED_PAYLOAD_SEQ_ERROR} refusal with the mark the device
23288
+ * publishes in the refusal's `CMD_DOORLOCK_P2P_SEQ` (1916) twin. On that mark the command resends once,
23289
+ * at mark + 1; a second refusal, or a refusal that published no mark, throws. Commands run one at a time
23290
+ * per device: the replies carry no correlation id, so one command's answer must never be read as
23291
+ * another's.
23292
+ */
23293
+ sendKeyedPayloadActuate(sn, cmd) {
23294
+ return this.serializeKeyed(sn, async () => {
23295
+ const dev = await this.deviceFor(sn);
23296
+ const member = (dev.raw ?? {}).member;
23297
+ const userName = typeof member?.nick_name === "string" && member.nick_name ? member.nick_name : cmd.username;
23298
+ const devicePublicKey = await this.deps.mega.getDevicePublicKey(sn);
23299
+ const resolved = await this.resolveSession(sn, { waitLevel2: false });
23300
+ const send = (seqNum) => {
23301
+ const { key, payload } = buildKeyedActuatePayload(cmd, devicePublicKey, userName, { seqNum });
23302
+ resolved.session.sendSetPayload(CommandType.P2P_ON_OFF_LOCK, payload, {
23303
+ accountId: cmd.adminUserId,
23304
+ channel: resolved.channel,
23305
+ key
23306
+ });
23307
+ };
23308
+ await this.sendKeyedAtSequence(sn, resolved.session, resolved.channel, cmd.engage, send);
23309
+ });
23310
+ }
23311
+ /** Run `work` once every keyed-payload command already queued for `sn` has settled. */
23312
+ serializeKeyed(sn, work) {
23313
+ const previous = this.keyedQueue.get(sn) ?? Promise.resolve();
23314
+ const run = previous.then(work);
23315
+ const settled = run.then(() => void 0, () => void 0);
23316
+ this.keyedQueue.set(sn, settled);
23317
+ void settled.then(() => {
23318
+ if (this.keyedQueue.get(sn) === settled)
23319
+ this.keyedQueue.delete(sn);
23320
+ });
23321
+ return run;
23322
+ }
23323
+ /**
23324
+ * Send one keyed command at the device's next sequence and, on a sequence refusal, resend once at the
23325
+ * mark the refusal published.
23326
+ *
23327
+ * A device's first number in a process is a small random seed — the scale the vendor app's own counters
23328
+ * run at — and every later one is the previous plus one. The device takes the first number on a new
23329
+ * connection as sent, and from then on refuses one not above the mark it holds, stating the mark in the
23330
+ * refusal's `CMD_DOORLOCK_P2P_SEQ` (1916) twin ({@link awaitKeyedReply} resolves the refusal with it).
23331
+ * So a refusal is resolved to that mark and the command resent once at mark + 1, the number the device
23332
+ * accepts after a refusal; a second refusal, or a refusal that carried no mark, throws. The accepted
23333
+ * number is recorded, so the next command starts just above it.
23334
+ *
23335
+ * @param engage whether the command drives to the engaged (locked) state, which its own report states.
23336
+ * @param send dispatches the envelope at the sequence it is given.
23337
+ */
23338
+ async sendKeyedAtSequence(sn, session, channel, engage, send) {
23339
+ const last = this.keyedSequences.get(sn);
23340
+ const first = last === void 0 ? Math.trunc(Math.random() * 1e3) : last + 1;
23341
+ this.keyedSequences.set(sn, first);
23342
+ const reply = await this.awaitKeyedReply(sn, session, channel, engage, () => send(first));
23343
+ if (reply.code === 0)
23344
+ return;
23345
+ if (reply.code !== KEYED_PAYLOAD_SEQ_ERROR || reply.mark === void 0) {
23346
+ throw new Error(`keyed-payload command rejected by ${sn} with code ${reply.code}`);
23347
+ }
23348
+ const resend = reply.mark + 1;
23349
+ this.keyedSequences.set(sn, resend);
23350
+ const second = await this.awaitKeyedReply(sn, session, channel, engage, () => send(resend));
23351
+ if (second.code === 0)
23352
+ return;
23353
+ throw new Error(`keyed-payload command rejected by ${sn} with code ${second.code}`);
23354
+ }
23355
+ /**
23356
+ * Send one keyed-payload command and read the device's answer for it from the replies on the command's
23357
+ * channel: the `SET_PAYLOAD` echo (a `commandResult`), at once, carrying the result as an int32 — or,
23358
+ * when the echo was not read and the command changed the device's state, the `NOTIFY_PAYLOAD` about two
23359
+ * seconds later carrying the result again as a `P2P_QUERY_STATUS_IN_LOCK` (1955) document. A report
23360
+ * is tied to the command by its `slState`: only one stating the state this command drives to, by
23361
+ * `engage` ({@link keyedPayloadReportCode}), is read as its answer. A report of the other state is a
23362
+ * previous command's, landing after that command settled on its echo, and is ignored.
23363
+ *
23364
+ * - An accepted result (code `0`) settles the command.
23365
+ * - A sequence refusal is stated twice, both about a tenth of a second after the send: the `-151` echo
23366
+ * and a `CMD_DOORLOCK_P2P_SEQ` (1916) notify whose `seq_num` is the mark the device holds. The
23367
+ * resolved value carries that mark, so the caller resends just above it. Collection runs for
23368
+ * {@link KEYED_REFUSAL_TWIN_MS} after the first of the two, so both land and neither is left on the
23369
+ * channel for the resend to read as its own answer, and either one alone still resolves the refusal.
23370
+ * - No reply within {@link KEYED_REPLY_MS} is a command that never landed, and rejects.
23371
+ *
23372
+ * Both listeners are armed before the send, and only replies on `channel` are read. The channel names
23373
+ * the device, not the command: every other P2P write a lock takes waits for a level-2 key this device
23374
+ * never negotiates, so nothing else the SDK sends can draw a reply here, and a future intent on this
23375
+ * wrapper has to share {@link serializeKeyed}.
23376
+ */
23377
+ awaitKeyedReply(sn, session, channel, engage, send) {
23378
+ return new Promise((resolve, reject) => {
23379
+ let refused = false;
23380
+ let mark;
23381
+ let timer;
23382
+ const settle = (outcome) => {
23383
+ if (timer)
23384
+ clearTimeout(timer);
23385
+ session.off("commandResult", onResult);
23386
+ session.off("data", onData);
23387
+ outcome();
23388
+ };
23389
+ const expire = (ms, onExpiry) => {
23390
+ if (timer)
23391
+ clearTimeout(timer);
23392
+ timer = setTimeout(onExpiry, ms);
23393
+ };
23394
+ const refuse = () => {
23395
+ if (refused)
23396
+ return;
23397
+ refused = true;
23398
+ expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() => resolve({ code: KEYED_PAYLOAD_SEQ_ERROR, mark })));
23399
+ };
23400
+ const onResult = (r) => {
23401
+ if (r.channel !== channel)
23402
+ return;
23403
+ if (r.code === KEYED_PAYLOAD_SEQ_ERROR)
23404
+ refuse();
23405
+ else if (!refused)
23406
+ settle(() => resolve({ code: r.code }));
23407
+ };
23408
+ const onData = (f) => {
23409
+ if (f.channel !== channel || f.commandId !== CommandType.CMD_NOTIFY_PAYLOAD)
23410
+ return;
23411
+ const published = keyedPayloadSeqMark(f.json);
23412
+ if (published !== void 0) {
23413
+ mark = published;
23414
+ return refuse();
23415
+ }
23416
+ const code = keyedPayloadReportCode(f.json, engage);
23417
+ if (code !== void 0 && !refused)
23418
+ settle(() => resolve({ code }));
23419
+ };
23420
+ session.on("commandResult", onResult);
23421
+ session.on("data", onData);
23422
+ expire(_P2PCommandRouter.KEYED_REPLY_MS, () => settle(() => reject(new Error(`keyed-payload command to ${sn} drew no reply`))));
23423
+ try {
23424
+ send();
23425
+ } catch (e) {
23426
+ settle(() => reject(e));
23427
+ }
23428
+ });
23429
+ }
23430
+ /** How long a keyed-payload command waits for any reply before it is taken as never having landed. */
23431
+ static KEYED_REPLY_MS = 6e3;
23432
+ /** How long a refusal collects its two statements — the `-151` echo and the mark notify — before resolving. */
23433
+ static KEYED_REFUSAL_TWIN_MS = 500;
23155
23434
  /**
23156
23435
  * **`ff09-actuate` intent** — build the `ff09` AES-128-CBC frame ({@link buildFf09Frame}, shared with the
23157
23436
  * MQTT transport — see `transport/ff09.ts`) and dispatch it in the `1940` TRANSFER_PAYLOAD envelope
@@ -25289,7 +25568,7 @@ var RtcCommandRouter = class {
25289
25568
  };
25290
25569
 
25291
25570
  // dist/transport/mqtt/command-router.js
25292
- import { randomBytes as randomBytes3 } from "node:crypto";
25571
+ import { randomBytes as randomBytes5 } from "node:crypto";
25293
25572
 
25294
25573
  // dist/transport/mqtt/broker-discovery.js
25295
25574
  import { resolve4 } from "node:dns/promises";
@@ -25672,9 +25951,9 @@ function buildFf09MqttEnvelope(input) {
25672
25951
  const head = {
25673
25952
  version: "1.0.0.1",
25674
25953
  client_id: input.clientId,
25675
- sess_id: input.sessId ?? randomBytes3(2).toString("hex"),
25954
+ sess_id: input.sessId ?? randomBytes5(2).toString("hex"),
25676
25955
  msg_seq: 1,
25677
- seed: input.seed ?? randomBytes3(16).toString("hex"),
25956
+ seed: input.seed ?? randomBytes5(16).toString("hex"),
25678
25957
  timestamp: input.timestamp ?? Math.floor(Date.now() / 1e3),
25679
25958
  cmd_status: 2,
25680
25959
  cmd: 9,
@@ -26135,7 +26414,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
26135
26414
  };
26136
26415
 
26137
26416
  // dist/transport/tuya/account.js
26138
- import { createCipheriv as createCipheriv5 } from "node:crypto";
26417
+ import { createCipheriv as createCipheriv6 } from "node:crypto";
26139
26418
  var TUYA_USERNAME_PREFIX = "eh-";
26140
26419
  var TUYA_PASSWORD_KEY = Buffer.from([
26141
26420
  36,
@@ -26184,7 +26463,7 @@ function zeroPadTo16(s) {
26184
26463
  return Buffer.concat([Buffer.from("0".repeat(16 - rem), "utf-8"), buf]);
26185
26464
  }
26186
26465
  function deriveTuyaPassword(eufyUserId) {
26187
- const cipher = createCipheriv5("aes-128-cbc", TUYA_PASSWORD_KEY, TUYA_PASSWORD_IV);
26466
+ const cipher = createCipheriv6("aes-128-cbc", TUYA_PASSWORD_KEY, TUYA_PASSWORD_IV);
26188
26467
  cipher.setAutoPadding(false);
26189
26468
  const ct = Buffer.concat([cipher.update(zeroPadTo16(tuyaUsername(eufyUserId))), cipher.final()]);
26190
26469
  return ct.toString("hex").toUpperCase();
@@ -26339,10 +26618,10 @@ var HmacSigner = class {
26339
26618
  var TUYA_CHKEY = "7cbfe6d8";
26340
26619
 
26341
26620
  // dist/transport/tuya/client.js
26342
- import { randomBytes as randomBytes5, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants3 } from "node:crypto";
26621
+ import { randomBytes as randomBytes7, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants3 } from "node:crypto";
26343
26622
 
26344
26623
  // dist/transport/tuya/et3.js
26345
- import { createCipheriv as createCipheriv6, createDecipheriv as createDecipheriv8, createHmac as createHmac5, randomBytes as randomBytes4 } from "node:crypto";
26624
+ import { createCipheriv as createCipheriv7, createDecipheriv as createDecipheriv8, createHmac as createHmac5, randomBytes as randomBytes6 } from "node:crypto";
26346
26625
  import { gunzipSync } from "node:zlib";
26347
26626
  function deriveBodyKey(requestId, ecode) {
26348
26627
  const k = resolveSignKey();
@@ -26351,8 +26630,8 @@ function deriveBodyKey(requestId, ecode) {
26351
26630
  return Buffer.from(hex.slice(0, 16), "ascii");
26352
26631
  }
26353
26632
  function encryptBody2(key, json) {
26354
- const nonce = randomBytes4(12);
26355
- const cipher = createCipheriv6("aes-128-gcm", key, nonce);
26633
+ const nonce = randomBytes6(12);
26634
+ const cipher = createCipheriv7("aes-128-gcm", key, nonce);
26356
26635
  const ciphertext = Buffer.concat([cipher.update(Buffer.from(json, "utf-8")), cipher.final()]);
26357
26636
  return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64");
26358
26637
  }
@@ -26490,7 +26769,7 @@ function buildUidTokenCreateAction(countryCode, uid) {
26490
26769
 
26491
26770
  // dist/transport/tuya/client.js
26492
26771
  function genDeviceId() {
26493
- return randomBytes5(22).toString("hex");
26772
+ return randomBytes7(22).toString("hex");
26494
26773
  }
26495
26774
  function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
26496
26775
  const bigintToBuffer = (n) => {
@@ -27612,7 +27891,7 @@ var PushClient = class _PushClient extends EventEmitter13 {
27612
27891
  };
27613
27892
 
27614
27893
  // dist/transport/push/fcm.js
27615
- import { randomBytes as randomBytes6 } from "node:crypto";
27894
+ import { randomBytes as randomBytes8 } from "node:crypto";
27616
27895
  import { setTimeout as sleep3 } from "node:timers/promises";
27617
27896
  var FCM = {
27618
27897
  PROJECT_ID: "batterycam-3250a",
@@ -27625,7 +27904,7 @@ var FCM = {
27625
27904
  SDK_VERSION: "a:16.3.1"
27626
27905
  };
27627
27906
  function generateFid() {
27628
- const b = randomBytes6(17);
27907
+ const b = randomBytes8(17);
27629
27908
  b[0] = 112 + b[0] % 16;
27630
27909
  return b.toString("base64url").slice(0, 22);
27631
27910
  }
@@ -29543,8 +29822,10 @@ var EufyMega = class extends EventEmitter14 {
29543
29822
  * Choose the transport stack for one command — the routing half of {@link commandSinkFor}.
29544
29823
  *
29545
29824
  * The `ff09-*` kinds share ONE frame that rides either transport, so each stack is asked whether it
29546
- * drives this device (`claimsDevice`) rather than inferring from the kind. `registry.require` throws
29547
- * on an unloaded serial, so a routing decision never silently falls through to the wrong transport.
29825
+ * drives this device (`claimsDevice`) rather than inferring from the kind; `keyed-payload-actuate` (the
29826
+ * classic Wi-Fi lock) is routed the same way, a channel-less actuation claimed by the stack that drives
29827
+ * the lock. `registry.require` throws on an unloaded serial, so a routing decision never silently falls
29828
+ * through to the wrong transport.
29548
29829
  * The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
29549
29830
  * Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
29550
29831
  * (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
@@ -29555,7 +29836,7 @@ var EufyMega = class extends EventEmitter14 {
29555
29836
  * media only, and the RTC router refuses a command on it. Everything else is P2P.
29556
29837
  */
29557
29838
  routeCommand(sn, cmd) {
29558
- if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
29839
+ if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle" || cmd.kind === "keyed-payload-actuate") {
29559
29840
  const dev = this.registry.require(sn);
29560
29841
  if (P2PCommandRouter.claimsDevice(dev))
29561
29842
  return this.p2p.dispatchCommand(sn, cmd);