@mega-yfue/eufy-sdk 0.6.0-beta.38 → 0.6.0-beta.39

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
@@ -15335,6 +15335,7 @@ __export(codec_exports, {
15335
15335
  encryptP2PData: () => encryptP2PData,
15336
15336
  frameMessage: () => frameMessage,
15337
15337
  gatewayInfoCipherId: () => gatewayInfoCipherId,
15338
+ gatewayInfoRefused: () => gatewayInfoRefused,
15338
15339
  hasHeader: () => hasHeader,
15339
15340
  p2pCommandEncryptionKey: () => p2pCommandEncryptionKey,
15340
15341
  p2pDidToBuffer: () => p2pDidToBuffer,
@@ -15453,6 +15454,10 @@ function decryptP2PData(data, key) {
15453
15454
  function gatewayInfoCipherId(gwPayload) {
15454
15455
  return gwPayload.length >= 2 ? gwPayload.readUInt16LE(0) : 0;
15455
15456
  }
15457
+ var GATEWAY_LEVEL2_REFUSED = -103;
15458
+ function gatewayInfoRefused(gwPayload) {
15459
+ return gwPayload.length >= 4 && gwPayload.readInt32LE(0) === GATEWAY_LEVEL2_REFUSED;
15460
+ }
15456
15461
  function eufyKdf(shared, outLen) {
15457
15462
  const hmac = (k, d) => createHmac2("sha256", k).update(d).digest();
15458
15463
  const label = Buffer.from("ECIES");
@@ -16598,6 +16603,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16598
16603
  liveStartedChannels = /* @__PURE__ */ new Map();
16599
16604
  /** The `cipher_id` this connection's `CMD_GATEWAYINFO` reply named, once its negotiation has read it. */
16600
16605
  gatewayCipherId;
16606
+ /** Whether this connection's latest `CMD_GATEWAYINFO` reply was a refusal — see {@link gatewayInfoRefused}. */
16607
+ gatewayRefused = false;
16601
16608
  /** The `n` of the last `[0x01, n]` header this connection sent in the clear — see {@link nextClearCommandBytes}. */
16602
16609
  clearCommandCount = 0;
16603
16610
  unackedLiveStarts = /* @__PURE__ */ new Map();
@@ -16795,9 +16802,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16795
16802
  * Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
16796
16803
  * left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
16797
16804
  * GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
16798
- * generation ended settles nothing.
16805
+ * generation ended settles nothing. A {@link gatewayInfoRefused} reply names no cipher, so it settles at once
16806
+ * with no cloud lookup, and a later reply naming a cipher withdraws the refusal.
16799
16807
  */
16800
16808
  negotiateLevel2Key(gwPayload) {
16809
+ this.gatewayRefused = gatewayInfoRefused(gwPayload);
16810
+ if (this.gatewayRefused) {
16811
+ this.logger.debug(`[p2p] ${this.cfg.stationSn} gateway info refused level-2`);
16812
+ this.trace({ phase: "level2-unavailable", reason: "gateway-refused" });
16813
+ this.settleLevel2();
16814
+ return;
16815
+ }
16801
16816
  this.level2Negotiating = true;
16802
16817
  const generation = this.connectionGeneration;
16803
16818
  const cipherId = gatewayInfoCipherId(gwPayload);
@@ -16920,6 +16935,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16920
16935
  this.level2Negotiating = false;
16921
16936
  this.level2Reprompted = false;
16922
16937
  this.gatewayCipherId = void 0;
16938
+ this.gatewayRefused = false;
16923
16939
  this.clearCommandCount = 0;
16924
16940
  }
16925
16941
  const socket = dgram.createSocket("udp4");
@@ -17544,6 +17560,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17544
17560
  this.seqNumber = this.seqNumber + 1 & 65535;
17545
17561
  this.send(this.connectAddress, RequestMessageType.DATA, data);
17546
17562
  }
17563
+ /**
17564
+ * Whether this connection's station answered `CMD_GATEWAYINFO` with a refusal ({@link gatewayInfoRefused}) and
17565
+ * no key that frames a set-payload is held.
17566
+ */
17567
+ get refusedLevel2() {
17568
+ return this.gatewayRefused && !this.level2KeyServes("set-payload");
17569
+ }
17547
17570
  /**
17548
17571
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
17549
17572
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -17554,10 +17577,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17554
17577
  * On a session holding only the AES-128-ECB level-2 key of an RSA cipher, the frame is the level-1
17555
17578
  * layout ({@link sendRawLevel1Bytes}) sealed under that key at encryption type 2: the encryption-type
17556
17579
  * byte names the key the body is sealed with (1 = level-1 key, 2 = the negotiated key).
17580
+ *
17581
+ * With `clearOnRefusal` on a session that {@link refusedLevel2}, the frame goes in the clear in the vendor
17582
+ * app's form for such a device: header bytes `[01 00]`, signCode 0, and a body of `{cmd, payload}` alone.
17583
+ * Without it, a refused session answers `false` and sends nothing.
17557
17584
  */
17558
- sendControlLevel2(cmd, channel, accountId, payload, mValue3 = cmd) {
17559
- if (!this.connectAddress || !this.level2KeyServes("set-payload"))
17585
+ sendControlLevel2(cmd, channel, accountId, payload, mValue3 = cmd, clearOnRefusal = false) {
17586
+ if (!this.connectAddress || !(this.level2KeyServes("set-payload") || clearOnRefusal && this.refusedLevel2)) {
17560
17587
  return false;
17588
+ }
17561
17589
  const value = Buffer.from(JSON.stringify({ account_id: accountId, cmd, mChannel: channel, mValue3, payload }), "utf-8");
17562
17590
  let sealed;
17563
17591
  if (this.level2Key) {
@@ -17565,8 +17593,10 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
17565
17593
  if (!body)
17566
17594
  return false;
17567
17595
  sealed = buildRawCommandPayload(body, channel, 8, [8, 0], 0);
17568
- } else {
17596
+ } else if (this.level2EcbKey) {
17569
17597
  sealed = buildRawCommandPayload(encryptP2PData(paddingP2PData(value), this.level2EcbKey), channel, 2);
17598
+ } else {
17599
+ sealed = buildRawCommandPayload(Buffer.from(JSON.stringify({ cmd, payload }), "utf-8"), channel);
17570
17600
  }
17571
17601
  const data = Buffer.concat([buildCommandHeader(this.seqNumber, CMD_SET_PAYLOAD), sealed]);
17572
17602
  this.seqNumber = this.seqNumber + 1 & 65535;
@@ -18919,169 +18949,6 @@ import { EventEmitter as EventEmitter3 } from "node:events";
18919
18949
 
18920
18950
  // dist/transport/p2p/video.js
18921
18951
  import { createDecipheriv as createDecipheriv6 } from "node:crypto";
18922
- var VIDEO_GCM_AAD = Buffer.from("eufy security");
18923
- var HEADER_LEN = 22;
18924
- var ENVELOPE_OFFSET = 22;
18925
- var ENVELOPE_LEN = 129;
18926
- var GCM_TAG_OFFSET = 151;
18927
- var GCM_TAG_LEN = 16;
18928
- var GCM_NONCE_OFFSET = 167;
18929
- var GCM_NONCE_LEN = 12;
18930
- var BODY_OFFSET = 179;
18931
- function parseVideoFrameHeader(payload) {
18932
- if (payload.length < HEADER_LEN)
18933
- return void 0;
18934
- const flags = payload.readUInt8(4);
18935
- return {
18936
- payloadLength: payload.readUInt32LE(0),
18937
- keyframe: (flags & 1) === 1,
18938
- flags,
18939
- sequence: payload.readUInt16LE(6),
18940
- width: payload.readInt16LE(10),
18941
- height: payload.readInt16LE(12),
18942
- timestamp: payload.readUInt32LE(14)
18943
- };
18944
- }
18945
- var VideoFrameDecoder = class {
18946
- eccPrivateKeyHex;
18947
- mediaKey;
18948
- /** @param eccPrivateKey the camera's 32-byte ECC private key (P-256 scalar). */
18949
- constructor(eccPrivateKey) {
18950
- if (eccPrivateKey.length !== 32)
18951
- throw new Error("eccPrivateKey must be 32 bytes");
18952
- this.eccPrivateKeyHex = eccPrivateKey.toString("hex");
18953
- }
18954
- /** The media key recovered from the most recent keyframe, if any. */
18955
- get currentMediaKey() {
18956
- return this.mediaKey ? Buffer.from(this.mediaKey) : void 0;
18957
- }
18958
- /**
18959
- * Recover the per-stream media key from a keyframe's 129-byte ECIES envelope: ECIES unwrap
18960
- * (ECDH → eufyKDF → AES-128-CBC, with the trailing HMAC verified) → 32-byte AES-256-GCM key.
18961
- */
18962
- unwrapMediaKey(envelope) {
18963
- const key = eciesUnwrap(envelope, this.eccPrivateKeyHex, { verifyHmac: true, pkcs7: true });
18964
- return key && key.length === 32 ? key : void 0;
18965
- }
18966
- /**
18967
- * Decode one video frame. Returns the keyframe flag and decrypted H.264 bytes, or `undefined`
18968
- * if the frame is malformed, the envelope/key is wrong, or GCM authentication fails.
18969
- */
18970
- decodeFrame(payload) {
18971
- const header = parseVideoFrameHeader(payload);
18972
- if (!header)
18973
- return void 0;
18974
- if (header.keyframe) {
18975
- if (payload.length < ENVELOPE_OFFSET + ENVELOPE_LEN)
18976
- return void 0;
18977
- const envelope = payload.subarray(ENVELOPE_OFFSET, ENVELOPE_OFFSET + ENVELOPE_LEN);
18978
- const key = this.unwrapMediaKey(envelope);
18979
- if (!key)
18980
- return void 0;
18981
- this.mediaKey = key;
18982
- }
18983
- const mediaKey = this.mediaKey;
18984
- if (!mediaKey)
18985
- return void 0;
18986
- if (payload.length < BODY_OFFSET)
18987
- return void 0;
18988
- const tag2 = payload.subarray(GCM_TAG_OFFSET, GCM_TAG_OFFSET + GCM_TAG_LEN);
18989
- const nonce = payload.subarray(GCM_NONCE_OFFSET, GCM_NONCE_OFFSET + GCM_NONCE_LEN);
18990
- const body = payload.subarray(BODY_OFFSET);
18991
- try {
18992
- const dec2 = createDecipheriv6("aes-256-gcm", mediaKey, nonce);
18993
- dec2.setAAD(VIDEO_GCM_AAD);
18994
- dec2.setAuthTag(tag2);
18995
- const head = dec2.update(body);
18996
- const tail = dec2.final();
18997
- const h264 = tail.length ? Buffer.concat([head, tail]) : head;
18998
- return { keyframe: header.keyframe, h264, width: header.width, height: header.height };
18999
- } catch {
19000
- return void 0;
19001
- }
19002
- }
19003
- };
19004
- var STATION_CHUNK_BYTES = 64e3;
19005
- var AccessUnitAssembler = class {
19006
- onDropped;
19007
- chunkBytes;
19008
- open;
19009
- droppedUnits = 0;
19010
- /**
19011
- * @param onDropped called for each incomplete unit discarded, with what had arrived, how many frames it
19012
- * arrived in, and how many this assembler has dropped in total — a running count, so no caller keeps
19013
- * its own.
19014
- * @param chunkBytes the payload size the station fills a frame to before continuing a unit in the next. A
19015
- * unit is held while its latest frame declares exactly this. {@link STATION_CHUNK_BYTES} when omitted.
19016
- */
19017
- constructor(onDropped, chunkBytes = STATION_CHUNK_BYTES) {
19018
- this.onDropped = onDropped;
19019
- this.chunkBytes = chunkBytes;
19020
- }
19021
- /**
19022
- * Feed one raw `CMD_VIDEO_FRAME` payload; returns the access units it completed — none while a unit is
19023
- * still being filled, one in the ordinary case.
19024
- *
19025
- * `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
19026
- * frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
19027
- * of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
19028
- * carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
19029
- * as a complete unit, even if its repeated header says keyframe.
19030
- */
19031
- push(payload, decode) {
19032
- const header = parseVideoFrameHeader(payload);
19033
- const body = header && decode(payload);
19034
- if (!header || !body?.length) {
19035
- this.discard();
19036
- return [];
19037
- }
19038
- const full = header.payloadLength === this.chunkBytes;
19039
- const open2 = this.open;
19040
- if (open2 && sameUnit(open2.header, header) && !beginsAccessUnit(body)) {
19041
- open2.chunks.push(body);
19042
- open2.carried += body.length;
19043
- if (full)
19044
- return [];
19045
- this.open = void 0;
19046
- return [unitOf(open2.header, Buffer.concat(open2.chunks))];
19047
- }
19048
- this.discard();
19049
- if (!beginsAccessUnit(body))
19050
- return [];
19051
- if (full) {
19052
- this.open = { header, chunks: [body], carried: body.length };
19053
- return [];
19054
- }
19055
- return [unitOf(header, body)];
19056
- }
19057
- /** Forget an incomplete unit, counting and reporting it. */
19058
- discard() {
19059
- const open2 = this.open;
19060
- if (!open2)
19061
- return;
19062
- this.open = void 0;
19063
- this.droppedUnits++;
19064
- this.onDropped?.({ carried: open2.carried, chunks: open2.chunks.length, count: this.droppedUnits });
19065
- }
19066
- };
19067
- var SPLIT_UNIT_STAMP_SPREAD_MS = 100;
19068
- function sameUnit(open2, next) {
19069
- return next.sequence === open2.sequence && next.timestamp - open2.timestamp >>> 0 <= SPLIT_UNIT_STAMP_SPREAD_MS;
19070
- }
19071
- function beginsAccessUnit(body) {
19072
- if (body.length < 4 || body[0] !== 0 || body[1] !== 0)
19073
- return false;
19074
- return body[2] === 1 || body[2] === 0 && body[3] === 1;
19075
- }
19076
- function unitOf(header, data) {
19077
- return {
19078
- sourceTimestamp: header.timestamp,
19079
- keyframe: header.keyframe,
19080
- width: header.width,
19081
- height: header.height,
19082
- data
19083
- };
19084
- }
19085
18952
 
19086
18953
  // dist/transport/p2p/annexb.js
19087
18954
  var ANNEXB_START = Buffer.from([0, 0, 0, 1]);
@@ -19399,6 +19266,172 @@ function h265Geometry(sps) {
19399
19266
  };
19400
19267
  }
19401
19268
 
19269
+ // dist/transport/p2p/video.js
19270
+ var VIDEO_GCM_AAD = Buffer.from("eufy security");
19271
+ var HEADER_LEN = 22;
19272
+ var ENVELOPE_OFFSET = 22;
19273
+ var ENVELOPE_LEN = 129;
19274
+ var GCM_TAG_OFFSET = 151;
19275
+ var GCM_TAG_LEN = 16;
19276
+ var GCM_NONCE_OFFSET = 167;
19277
+ var GCM_NONCE_LEN = 12;
19278
+ var BODY_OFFSET = 179;
19279
+ function parseVideoFrameHeader(payload) {
19280
+ if (payload.length < HEADER_LEN)
19281
+ return void 0;
19282
+ const flags = payload.readUInt8(4);
19283
+ return {
19284
+ payloadLength: payload.readUInt32LE(0),
19285
+ keyframe: (flags & 1) === 1,
19286
+ flags,
19287
+ sequence: payload.readUInt16LE(6),
19288
+ width: payload.readInt16LE(10),
19289
+ height: payload.readInt16LE(12),
19290
+ timestamp: payload.readUInt32LE(14)
19291
+ };
19292
+ }
19293
+ var VideoFrameDecoder = class {
19294
+ eccPrivateKeyHex;
19295
+ mediaKey;
19296
+ /** @param eccPrivateKey the camera's 32-byte ECC private key (P-256 scalar). */
19297
+ constructor(eccPrivateKey) {
19298
+ if (eccPrivateKey.length !== 32)
19299
+ throw new Error("eccPrivateKey must be 32 bytes");
19300
+ this.eccPrivateKeyHex = eccPrivateKey.toString("hex");
19301
+ }
19302
+ /** The media key recovered from the most recent keyframe, if any. */
19303
+ get currentMediaKey() {
19304
+ return this.mediaKey ? Buffer.from(this.mediaKey) : void 0;
19305
+ }
19306
+ /**
19307
+ * Recover the per-stream media key from a keyframe's 129-byte ECIES envelope: ECIES unwrap
19308
+ * (ECDH → eufyKDF → AES-128-CBC, with the trailing HMAC verified) → 32-byte AES-256-GCM key.
19309
+ */
19310
+ unwrapMediaKey(envelope) {
19311
+ const key = eciesUnwrap(envelope, this.eccPrivateKeyHex, { verifyHmac: true, pkcs7: true });
19312
+ return key && key.length === 32 ? key : void 0;
19313
+ }
19314
+ /**
19315
+ * Decode one video frame. Returns the keyframe flag and decrypted H.264 bytes, or `undefined`
19316
+ * if the frame is malformed, the envelope/key is wrong, or GCM authentication fails.
19317
+ */
19318
+ decodeFrame(payload) {
19319
+ const header = parseVideoFrameHeader(payload);
19320
+ if (!header)
19321
+ return void 0;
19322
+ if (header.keyframe) {
19323
+ if (payload.length < ENVELOPE_OFFSET + ENVELOPE_LEN)
19324
+ return void 0;
19325
+ const envelope = payload.subarray(ENVELOPE_OFFSET, ENVELOPE_OFFSET + ENVELOPE_LEN);
19326
+ const key = this.unwrapMediaKey(envelope);
19327
+ if (!key)
19328
+ return void 0;
19329
+ this.mediaKey = key;
19330
+ }
19331
+ const mediaKey = this.mediaKey;
19332
+ if (!mediaKey)
19333
+ return void 0;
19334
+ if (payload.length < BODY_OFFSET)
19335
+ return void 0;
19336
+ const tag2 = payload.subarray(GCM_TAG_OFFSET, GCM_TAG_OFFSET + GCM_TAG_LEN);
19337
+ const nonce = payload.subarray(GCM_NONCE_OFFSET, GCM_NONCE_OFFSET + GCM_NONCE_LEN);
19338
+ const body = payload.subarray(BODY_OFFSET);
19339
+ try {
19340
+ const dec2 = createDecipheriv6("aes-256-gcm", mediaKey, nonce);
19341
+ dec2.setAAD(VIDEO_GCM_AAD);
19342
+ dec2.setAuthTag(tag2);
19343
+ const head = dec2.update(body);
19344
+ const tail = dec2.final();
19345
+ const h264 = tail.length ? Buffer.concat([head, tail]) : head;
19346
+ return { keyframe: header.keyframe, h264, width: header.width, height: header.height };
19347
+ } catch {
19348
+ return void 0;
19349
+ }
19350
+ }
19351
+ };
19352
+ var STATION_CHUNK_BYTES = 64e3;
19353
+ var AccessUnitAssembler = class {
19354
+ onDropped;
19355
+ chunkBytes;
19356
+ open;
19357
+ droppedUnits = 0;
19358
+ /**
19359
+ * @param onDropped called for each incomplete unit discarded, with what had arrived, how many frames it
19360
+ * arrived in, and how many this assembler has dropped in total — a running count, so no caller keeps
19361
+ * its own.
19362
+ * @param chunkBytes the payload size the station fills a frame to before continuing a unit in the next. A
19363
+ * unit is held while its latest frame declares exactly this. {@link STATION_CHUNK_BYTES} when omitted.
19364
+ */
19365
+ constructor(onDropped, chunkBytes = STATION_CHUNK_BYTES) {
19366
+ this.onDropped = onDropped;
19367
+ this.chunkBytes = chunkBytes;
19368
+ }
19369
+ /**
19370
+ * Feed one raw `CMD_VIDEO_FRAME` payload; returns the access units it completed — none while a unit is
19371
+ * still being filled, one in the ordinary case.
19372
+ *
19373
+ * `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
19374
+ * frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
19375
+ * of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
19376
+ * carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
19377
+ * as a complete unit, even if its repeated header says keyframe.
19378
+ */
19379
+ push(payload, decode) {
19380
+ const header = parseVideoFrameHeader(payload);
19381
+ const body = header && decode(payload);
19382
+ if (!header || !body?.length) {
19383
+ this.discard();
19384
+ return [];
19385
+ }
19386
+ const full = header.payloadLength === this.chunkBytes;
19387
+ const open2 = this.open;
19388
+ if (open2 && sameUnit(open2.header, header) && !beginsAccessUnit(body)) {
19389
+ open2.chunks.push(body);
19390
+ open2.carried += body.length;
19391
+ if (full)
19392
+ return [];
19393
+ this.open = void 0;
19394
+ return [unitOf(open2.header, Buffer.concat(open2.chunks))];
19395
+ }
19396
+ this.discard();
19397
+ if (!beginsAccessUnit(body))
19398
+ return [];
19399
+ if (full) {
19400
+ this.open = { header, chunks: [body], carried: body.length };
19401
+ return [];
19402
+ }
19403
+ return [unitOf(header, body)];
19404
+ }
19405
+ /** Forget an incomplete unit, counting and reporting it. */
19406
+ discard() {
19407
+ const open2 = this.open;
19408
+ if (!open2)
19409
+ return;
19410
+ this.open = void 0;
19411
+ this.droppedUnits++;
19412
+ this.onDropped?.({ carried: open2.carried, chunks: open2.chunks.length, count: this.droppedUnits });
19413
+ }
19414
+ };
19415
+ var SPLIT_UNIT_STAMP_SPREAD_MS = 100;
19416
+ function sameUnit(open2, next) {
19417
+ return next.sequence === open2.sequence && next.timestamp - open2.timestamp >>> 0 <= SPLIT_UNIT_STAMP_SPREAD_MS;
19418
+ }
19419
+ function beginsAccessUnit(body) {
19420
+ if (body.length < 4 || body[0] !== 0 || body[1] !== 0)
19421
+ return false;
19422
+ return body[2] === 1 || body[2] === 0 && body[3] === 1;
19423
+ }
19424
+ function unitOf(header, data) {
19425
+ const codec = header.keyframe ? void 0 : sniffAnnexbCodec(data);
19426
+ return {
19427
+ sourceTimestamp: header.timestamp,
19428
+ keyframe: header.keyframe || codec !== void 0 && hasIdr(data, codec),
19429
+ width: header.width,
19430
+ height: header.height,
19431
+ data
19432
+ };
19433
+ }
19434
+
19402
19435
  // dist/transport/p2p/audio-frame.js
19403
19436
  import { createDecipheriv as createDecipheriv7 } from "node:crypto";
19404
19437
  var CMD_AUDIO_FRAME2 = 1301;
@@ -22429,6 +22462,7 @@ var RTSP_URL_READ_TIMEOUT_MS = 12e3;
22429
22462
  var TALKBACK_CODEC_WAIT_MS = 3e3;
22430
22463
  var OWN_LIVE_SESSION_MODELS = /* @__PURE__ */ new Set(["T8030"]);
22431
22464
  var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
22465
+ var CLEAR_FF09_MODELS = /* @__PURE__ */ new Set(["T8506"]);
22432
22466
  function speakerCodec(consumer, talk, timeoutMs) {
22433
22467
  return new Promise((resolve2) => {
22434
22468
  let timer;
@@ -23774,6 +23808,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
23774
23808
  * sources on the replaced session are discarded before closing it (see {@link tearDownStation}).
23775
23809
  * Replaced at most once per resolution, so a station whose replacement is silent too is returned rather
23776
23810
  * than closed again.
23811
+ *
23812
+ * `clearFallback` hands over a session whose station {@link P2PSession.refusedLevel2} instead of throwing, for
23813
+ * a standalone device of a model in {@link CLEAR_FF09_MODELS}.
23777
23814
  */
23778
23815
  async resolveSession(sn, opts = {}, rebuilt = false) {
23779
23816
  const dev = await this.deviceFor(sn);
@@ -23825,8 +23862,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
23825
23862
  if (!ready && session.repromptLevel2Key()) {
23826
23863
  ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call", sender), opts.signal);
23827
23864
  }
23828
- if (!ready)
23865
+ const sendsClear = opts.clearFallback === true && !homeBaseAttached && CLEAR_FF09_MODELS.has(this.recordFor(sn)?.model ?? "") && session.refusedLevel2;
23866
+ if (!ready && !sendsClear)
23829
23867
  throw new StationKeyUnavailableError(parentSn);
23868
+ return { session, parentSn, channel, accountId, homeBaseAttached, sendsClear };
23830
23869
  }
23831
23870
  return { session, parentSn, channel, accountId, homeBaseAttached };
23832
23871
  }
@@ -23936,7 +23975,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
23936
23975
  });
23937
23976
  return;
23938
23977
  }
23939
- 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 }));
23978
+ await this.replayLevel2Send(sn, `set-payload cmd ${cmd}`, ({ session, accountId, sendsClear }) => session.sendControlLevel2(cmd, channel, accountId, payload, mValue3 ?? cmd, sendsClear), resolved ?? await this.resolveSession(sn, { waitLevel2: true, level2Sender }));
23940
23979
  }
23941
23980
  /**
23942
23981
  * **`keyed-payload-actuate` intent** — the classic Wi-Fi lock's actuation: the sealed fields from
@@ -24103,7 +24142,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
24103
24142
  * bytes and never the routing channel — that's re-resolved here from the device record.
24104
24143
  */
24105
24144
  async sendFf09Actuate(sn, cmd) {
24106
- const resolved = await this.resolveSession(sn, { waitLevel2: true });
24145
+ const resolved = await this.resolveSession(sn, { waitLevel2: true, clearFallback: true });
24107
24146
  const frame = buildFf09Frame({
24108
24147
  engage: cmd.engage,
24109
24148
  adminUserId: cmd.adminUserId,