@mega-yfue/eufy-sdk 0.5.0-beta.88 → 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.
@@ -435,8 +435,10 @@ export declare class EufyMega extends EventEmitter {
435
435
  * Choose the transport stack for one command — the routing half of {@link commandSinkFor}.
436
436
  *
437
437
  * The `ff09-*` kinds share ONE frame that rides either transport, so each stack is asked whether it
438
- * drives this device (`claimsDevice`) rather than inferring from the kind. `registry.require` throws
439
- * on an unloaded serial, so a routing decision never silently falls through to the wrong transport.
438
+ * drives this device (`claimsDevice`) rather than inferring from the kind; `keyed-payload-actuate` (the
439
+ * classic Wi-Fi lock) is routed the same way, a channel-less actuation claimed by the stack that drives
440
+ * the lock. `registry.require` throws on an unloaded serial, so a routing decision never silently falls
441
+ * through to the wrong transport.
440
442
  * The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
441
443
  * Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
442
444
  * (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
@@ -264,10 +264,11 @@ export declare class StateConvergenceError extends Error {
264
264
  */
265
265
  export type ScalarForm = "auto" | "int-string" | "direct-binary";
266
266
  /**
267
- * The identity fields the `ff09` frame is built from — the AES key/IV inputs (`adminUserId`/`deviceSn`)
268
- * plus the frame's own direction bit and user-attribution fields (`A3`/`A4`/`A5`). Shared by the
269
- * actuate intent; the settings intents carry a narrower subset (the GET/SET frames omit the
270
- * user-attribution fields).
267
+ * The identity fields an actuation frame is built from — for `ff09`, the AES key/IV inputs
268
+ * (`adminUserId`/`deviceSn`) plus the frame's own direction bit and user-attribution fields
269
+ * (`A3`/`A4`/`A5`); for the keyed-payload envelope, the same five fields, sealed into its inner JSON.
270
+ * Shared by both actuate intents; the `ff09` settings intents carry a narrower subset (the GET/SET frames
271
+ * omit the user-attribution fields).
271
272
  */
272
273
  export interface Ff09Identity {
273
274
  /** Actuation direction: `true` = engage (lock / close), `false` = release (unlock / open) — the frame's `A3` byte. */
@@ -353,6 +354,14 @@ export type Command = {
353
354
  channel: number;
354
355
  } | ({
355
356
  kind: "ff09-actuate";
357
+ } & Ff09Identity)
358
+ /**
359
+ * `keyed-payload-actuate` — a `SET_PAYLOAD` envelope whose inner JSON is sealed with a fresh AES key
360
+ * that the envelope itself carries, wrapped for the device's own public key. P2P-only; the router
361
+ * resolves the device key and the member display name from the device record.
362
+ */
363
+ | ({
364
+ kind: "keyed-payload-actuate";
356
365
  } & Ff09Identity) | {
357
366
  kind: "ff09-autolock";
358
367
  adminUserId: string;
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 = {
@@ -7535,7 +7562,7 @@ function actuate(engage, ctx, sink) {
7535
7562
  });
7536
7563
  }
7537
7564
  return sink.dispatch({
7538
- kind: "ff09-actuate",
7565
+ kind: isClassicWifiLock(ctx) ? "keyed-payload-actuate" : "ff09-actuate",
7539
7566
  engage,
7540
7567
  adminUserId: ctx.adminUserId,
7541
7568
  username: ctx.accountName ?? "",
@@ -7580,6 +7607,8 @@ function nativeReport(signal) {
7580
7607
  const report = signal.json.payload;
7581
7608
  return report?.code === 0 ? report : void 0;
7582
7609
  }
7610
+ var speaksFf09Settings = (ctx) => !isClassicWifiLock(ctx);
7611
+ var rainModeAvailable = (ctx) => overP2p(ctx) && speaksFf09Settings(ctx);
7583
7612
  var LOCK_MEMBERS = {
7584
7613
  /**
7585
7614
  * The lock's own state, verified on param 6000: "4" = locked, "3" = unlocked.
@@ -7610,8 +7639,8 @@ var LOCK_MEMBERS = {
7610
7639
  description: "Lock battery level 0-100 (verified: param 1101, alias 6001)."
7611
7640
  },
7612
7641
  /**
7613
- * Link quality in dBm as the lock measures it, on the shared param 1141. Both actuation methods here
7614
- * 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.
7615
7644
  */
7616
7645
  rssi: {
7617
7646
  param: 1141,
@@ -7621,9 +7650,15 @@ var LOCK_MEMBERS = {
7621
7650
  provenance: "verified",
7622
7651
  description: "Lock signal strength (verified: param 1141 = RSSI)."
7623
7652
  },
7624
- /** 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
+ */
7625
7657
  lock: method(({ ctx, sink }) => () => actuate(true, ctx, sink), "Lock the deadbolt/door.", actuationAvailable),
7626
- /** 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
+ */
7627
7662
  unlock: method(({ ctx, sink }) => () => actuate(false, ctx, sink), "Unlock the deadbolt/door.", actuationAvailable),
7628
7663
  /**
7629
7664
  * Read-modify-write the auto-lock setting: the transport GETs the device's current settings, changes
@@ -7645,13 +7680,13 @@ var LOCK_MEMBERS = {
7645
7680
  enabled,
7646
7681
  delaySeconds
7647
7682
  });
7648
- }, "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)),
7649
7684
  /**
7650
7685
  * Toggle Rain Mode — a pure blind write, unlike `setAutoLock`: the compact frame carries only this one
7651
7686
  * field, so there is no GET pass and it is fire-and-forget. Confirmed on-device end-to-end in both
7652
7687
  * directions, with the app UI reflecting the new state afterward. P2P video lock only.
7653
7688
  */
7654
- 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),
7655
7690
  /**
7656
7691
  * Lock the door by a single touch on the pad, with no code. First of the six compact toggles: all
7657
7692
  * `unverified` (frame shape read out of the app's JS, never captured), so no setter is installed and
@@ -15140,13 +15175,8 @@ function abortable(work, signal) {
15140
15175
  ]);
15141
15176
  }
15142
15177
 
15143
- // dist/transport/p2p/command-router.js
15144
- import { setTimeout as sleep2 } from "node:timers/promises";
15145
-
15146
- // dist/transport/p2p/p2p-session.js
15147
- import { EventEmitter as EventEmitter2 } from "node:events";
15148
- import dgram from "node:dgram";
15149
- 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";
15150
15180
 
15151
15181
  // dist/transport/p2p/codec.js
15152
15182
  var codec_exports = {};
@@ -15173,6 +15203,7 @@ __export(codec_exports, {
15173
15203
  deriveLevel2KeyFromGatewayInfo: () => deriveLevel2KeyFromGatewayInfo,
15174
15204
  deriveLevel2KeyFromRsaGatewayInfo: () => deriveLevel2KeyFromRsaGatewayInfo,
15175
15205
  eciesUnwrap: () => eciesUnwrap,
15206
+ eciesWrap: () => eciesWrap,
15176
15207
  encryptP2PData: () => encryptP2PData,
15177
15208
  frameMessage: () => frameMessage,
15178
15209
  gatewayInfoCipherId: () => gatewayInfoCipherId,
@@ -15185,7 +15216,7 @@ __export(codec_exports, {
15185
15216
  readNullTerminatedString: () => readNullTerminatedString,
15186
15217
  stringWithLength: () => stringWithLength
15187
15218
  });
15188
- 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";
15189
15220
  var MAGIC_WORD = "XZYH";
15190
15221
  var RequestMessageType = {
15191
15222
  LOCAL_LOOKUP: Buffer.from([241, 48]),
@@ -15322,6 +15353,19 @@ function eciesUnwrap(envelope, eccPrivateKeyHex, options = {}) {
15322
15353
  return void 0;
15323
15354
  }
15324
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
+ }
15325
15369
  function deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivateKeyHex) {
15326
15370
  const plain = eciesUnwrap(gwPayload.subarray(4, 4 + 129), eccPrivateKeyHex);
15327
15371
  if (!plain)
@@ -16041,6 +16085,65 @@ function commandName(id) {
16041
16085
  return COMMAND_NAMES[id] ?? `CMD_${id}`;
16042
16086
  }
16043
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
+
16044
16147
  // dist/transport/p2p/live-trace.js
16045
16148
  var LIVE_TRACE_MESSAGE = "[live] start trace";
16046
16149
  function traceLiveStart(logger, trace, source) {
@@ -17449,8 +17552,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17449
17552
  encryptLevel2(plaintext) {
17450
17553
  if (!this.level2Key)
17451
17554
  return void 0;
17452
- const nonce = randomBytes2(12);
17453
- const c = createCipheriv3("aes-256-gcm", this.level2Key, nonce);
17555
+ const nonce = randomBytes4(12);
17556
+ const c = createCipheriv4("aes-256-gcm", this.level2Key, nonce);
17454
17557
  c.setAAD(GCM_AAD);
17455
17558
  const ct = Buffer.concat([c.update(plaintext), c.final()]);
17456
17559
  const sub2 = Buffer.alloc(4);
@@ -17660,22 +17763,28 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17660
17763
  * Low-level: send a `CMD_SET_PAYLOAD` (1350) wrapping `{account_id, cmd:<subCmd>, mChannel,
17661
17764
  * payload, transaction}` over the level-1 channel. The reply arrives as a `NOTIFY_PAYLOAD`
17662
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.
17663
17770
  */
17664
17771
  sendSetPayload(subCmd, payload = {}, opts = {}) {
17665
17772
  if (!this.connectAddress)
17666
17773
  throw new Error("not connected");
17667
17774
  const channel = opts.channel ?? 0;
17775
+ const keyed = opts.key !== void 0;
17668
17776
  const inner = opts.rawValue ?? {
17777
+ ...keyed ? { key: opts.key } : {},
17669
17778
  account_id: opts.accountId ?? "",
17670
17779
  cmd: subCmd,
17671
17780
  mChannel: channel,
17672
17781
  mValue3: 0,
17673
17782
  ...opts.omitPayload ? {} : { payload }
17674
17783
  };
17675
- const value = JSON.stringify(inner);
17784
+ const value = keyed ? JSON.stringify(inner).replace(/=/g, "\\u003d") : JSON.stringify(inner);
17676
17785
  const body = Buffer.concat([
17677
17786
  buildCommandHeader(this.seqNumber, opts.wrapCmd ?? CMD_SET_PAYLOAD),
17678
- buildStringCommandPayload(value, channel, this.level1Key, 1)
17787
+ buildStringCommandPayload(value, channel, keyed ? void 0 : this.level1Key, 1)
17679
17788
  ]);
17680
17789
  this.seqNumber = this.seqNumber + 1 & 65535;
17681
17790
  this.logger.debug(`[p2p] ${this.cfg.stationSn} sendSetPayload subCmd=${subCmd}`);
@@ -18026,7 +18135,7 @@ function buildDeviceNameBody(channel, name, accountId) {
18026
18135
  }
18027
18136
 
18028
18137
  // dist/transport/ff09.js
18029
- import { createCipheriv as createCipheriv4, createDecipheriv as createDecipheriv5 } from "node:crypto";
18138
+ import { createCipheriv as createCipheriv5, createDecipheriv as createDecipheriv5 } from "node:crypto";
18030
18139
  var LOCK_API_COMMAND = {
18031
18140
  /** Lock / unlock the deadbolt — video lock AND garage door, both directions (see above). */
18032
18141
  ON_OFF_LOCK: 6018,
@@ -18106,7 +18215,7 @@ function assembleFf09Frame(commandCode, apiCommand, plain, adminUserId, deviceSn
18106
18215
  const key = Buffer.concat([Buffer.from(adminUserId.slice(-12), "ascii"), u32be(keyTime)]);
18107
18216
  const iv = Buffer.alloc(16);
18108
18217
  Buffer.from(deviceSn, "ascii").copy(iv);
18109
- const cipher = createCipheriv4("aes-128-cbc", key, iv);
18218
+ const cipher = createCipheriv5("aes-128-cbc", key, iv);
18110
18219
  const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
18111
18220
  const body = Buffer.concat([Buffer.from([3, 0, 2]), u16be(16384 | commandCode), ct]);
18112
18221
  const size = 2 + 2 + body.length + 1;
@@ -21814,6 +21923,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
21814
21923
  cipherKeyCache = /* @__PURE__ */ new Map();
21815
21924
  /** The recording download in flight per station; a station serves one at a time. */
21816
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();
21817
21937
  constructor(deps) {
21818
21938
  this.deps = deps;
21819
21939
  this.manager = new SessionManager({
@@ -22169,6 +22289,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
22169
22289
  case "ff09-actuate":
22170
22290
  await this.sendFf09Actuate(sn, cmd);
22171
22291
  return;
22292
+ case "keyed-payload-actuate":
22293
+ await this.sendKeyedPayloadActuate(sn, cmd);
22294
+ return;
22172
22295
  case "ff09-autolock":
22173
22296
  await this.sendFf09Autolock(sn, cmd);
22174
22297
  return;
@@ -23151,6 +23274,163 @@ var P2PCommandRouter = class _P2PCommandRouter {
23151
23274
  }
23152
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 }));
23153
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;
23154
23434
  /**
23155
23435
  * **`ff09-actuate` intent** — build the `ff09` AES-128-CBC frame ({@link buildFf09Frame}, shared with the
23156
23436
  * MQTT transport — see `transport/ff09.ts`) and dispatch it in the `1940` TRANSFER_PAYLOAD envelope
@@ -25288,7 +25568,7 @@ var RtcCommandRouter = class {
25288
25568
  };
25289
25569
 
25290
25570
  // dist/transport/mqtt/command-router.js
25291
- import { randomBytes as randomBytes3 } from "node:crypto";
25571
+ import { randomBytes as randomBytes5 } from "node:crypto";
25292
25572
 
25293
25573
  // dist/transport/mqtt/broker-discovery.js
25294
25574
  import { resolve4 } from "node:dns/promises";
@@ -25671,9 +25951,9 @@ function buildFf09MqttEnvelope(input) {
25671
25951
  const head = {
25672
25952
  version: "1.0.0.1",
25673
25953
  client_id: input.clientId,
25674
- sess_id: input.sessId ?? randomBytes3(2).toString("hex"),
25954
+ sess_id: input.sessId ?? randomBytes5(2).toString("hex"),
25675
25955
  msg_seq: 1,
25676
- seed: input.seed ?? randomBytes3(16).toString("hex"),
25956
+ seed: input.seed ?? randomBytes5(16).toString("hex"),
25677
25957
  timestamp: input.timestamp ?? Math.floor(Date.now() / 1e3),
25678
25958
  cmd_status: 2,
25679
25959
  cmd: 9,
@@ -26134,7 +26414,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
26134
26414
  };
26135
26415
 
26136
26416
  // dist/transport/tuya/account.js
26137
- import { createCipheriv as createCipheriv5 } from "node:crypto";
26417
+ import { createCipheriv as createCipheriv6 } from "node:crypto";
26138
26418
  var TUYA_USERNAME_PREFIX = "eh-";
26139
26419
  var TUYA_PASSWORD_KEY = Buffer.from([
26140
26420
  36,
@@ -26183,7 +26463,7 @@ function zeroPadTo16(s) {
26183
26463
  return Buffer.concat([Buffer.from("0".repeat(16 - rem), "utf-8"), buf]);
26184
26464
  }
26185
26465
  function deriveTuyaPassword(eufyUserId) {
26186
- 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);
26187
26467
  cipher.setAutoPadding(false);
26188
26468
  const ct = Buffer.concat([cipher.update(zeroPadTo16(tuyaUsername(eufyUserId))), cipher.final()]);
26189
26469
  return ct.toString("hex").toUpperCase();
@@ -26338,10 +26618,10 @@ var HmacSigner = class {
26338
26618
  var TUYA_CHKEY = "7cbfe6d8";
26339
26619
 
26340
26620
  // dist/transport/tuya/client.js
26341
- 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";
26342
26622
 
26343
26623
  // dist/transport/tuya/et3.js
26344
- 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";
26345
26625
  import { gunzipSync } from "node:zlib";
26346
26626
  function deriveBodyKey(requestId, ecode) {
26347
26627
  const k = resolveSignKey();
@@ -26350,8 +26630,8 @@ function deriveBodyKey(requestId, ecode) {
26350
26630
  return Buffer.from(hex.slice(0, 16), "ascii");
26351
26631
  }
26352
26632
  function encryptBody2(key, json) {
26353
- const nonce = randomBytes4(12);
26354
- const cipher = createCipheriv6("aes-128-gcm", key, nonce);
26633
+ const nonce = randomBytes6(12);
26634
+ const cipher = createCipheriv7("aes-128-gcm", key, nonce);
26355
26635
  const ciphertext = Buffer.concat([cipher.update(Buffer.from(json, "utf-8")), cipher.final()]);
26356
26636
  return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64");
26357
26637
  }
@@ -26489,7 +26769,7 @@ function buildUidTokenCreateAction(countryCode, uid) {
26489
26769
 
26490
26770
  // dist/transport/tuya/client.js
26491
26771
  function genDeviceId() {
26492
- return randomBytes5(22).toString("hex");
26772
+ return randomBytes7(22).toString("hex");
26493
26773
  }
26494
26774
  function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
26495
26775
  const bigintToBuffer = (n) => {
@@ -27611,7 +27891,7 @@ var PushClient = class _PushClient extends EventEmitter13 {
27611
27891
  };
27612
27892
 
27613
27893
  // dist/transport/push/fcm.js
27614
- import { randomBytes as randomBytes6 } from "node:crypto";
27894
+ import { randomBytes as randomBytes8 } from "node:crypto";
27615
27895
  import { setTimeout as sleep3 } from "node:timers/promises";
27616
27896
  var FCM = {
27617
27897
  PROJECT_ID: "batterycam-3250a",
@@ -27624,7 +27904,7 @@ var FCM = {
27624
27904
  SDK_VERSION: "a:16.3.1"
27625
27905
  };
27626
27906
  function generateFid() {
27627
- const b = randomBytes6(17);
27907
+ const b = randomBytes8(17);
27628
27908
  b[0] = 112 + b[0] % 16;
27629
27909
  return b.toString("base64url").slice(0, 22);
27630
27910
  }
@@ -29542,8 +29822,10 @@ var EufyMega = class extends EventEmitter14 {
29542
29822
  * Choose the transport stack for one command — the routing half of {@link commandSinkFor}.
29543
29823
  *
29544
29824
  * The `ff09-*` kinds share ONE frame that rides either transport, so each stack is asked whether it
29545
- * drives this device (`claimsDevice`) rather than inferring from the kind. `registry.require` throws
29546
- * 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.
29547
29829
  * The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
29548
29830
  * Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
29549
29831
  * (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
@@ -29554,7 +29836,7 @@ var EufyMega = class extends EventEmitter14 {
29554
29836
  * media only, and the RTC router refuses a command on it. Everything else is P2P.
29555
29837
  */
29556
29838
  routeCommand(sn, cmd) {
29557
- 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") {
29558
29840
  const dev = this.registry.require(sn);
29559
29841
  if (P2PCommandRouter.claimsDevice(dev))
29560
29842
  return this.p2p.dispatchCommand(sn, cmd);