@mega-yfue/eufy-sdk 0.6.0-beta.37 → 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 +220 -171
- package/dist/index.js.map +3 -3
- package/dist/model/capabilities/battery.d.ts +3 -6
- package/dist/transport/p2p/codec.d.ts +6 -0
- package/dist/transport/p2p/command-router.d.ts +3 -0
- package/dist/transport/p2p/live-trace.d.ts +1 -1
- package/dist/transport/p2p/p2p-session.d.ts +14 -2
- package/dist/transport/p2p/video.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4800,11 +4800,20 @@ function cellGated(members) {
|
|
|
4800
4800
|
});
|
|
4801
4801
|
return Object.fromEntries(gated);
|
|
4802
4802
|
}
|
|
4803
|
+
var notSensor = (ctx) => ctx.codec !== "sensor";
|
|
4804
|
+
function notOnSensor(members) {
|
|
4805
|
+
const gated = Object.entries(members).map(([name, m]) => {
|
|
4806
|
+
const own = m.available;
|
|
4807
|
+
const available = own ? (ctx) => notSensor(ctx) && own(ctx) : notSensor;
|
|
4808
|
+
return [name, { ...m, available }];
|
|
4809
|
+
});
|
|
4810
|
+
return Object.fromEntries(gated);
|
|
4811
|
+
}
|
|
4803
4812
|
function isChargingState(v) {
|
|
4804
4813
|
const n = Number(v);
|
|
4805
4814
|
return n !== 0 && n !== 2;
|
|
4806
4815
|
}
|
|
4807
|
-
var
|
|
4816
|
+
var CAMERA_BATTERY_MEMBERS = cellGated({
|
|
4808
4817
|
/**
|
|
4809
4818
|
* The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
|
|
4810
4819
|
* device is battery-powered, which is also the fact `MediaProvider` reads to decide a stream needs a
|
|
@@ -5031,6 +5040,7 @@ var BATTERY_MEMBERS = cellGated({
|
|
|
5031
5040
|
description: "Raw GET_CAMERA_INFO value (1103), meaning unevidenced \u2014 read live as the constant 5 on a T8170 at 92% and a T8171 at 27%, so it is NOT a low-battery flag."
|
|
5032
5041
|
}
|
|
5033
5042
|
});
|
|
5043
|
+
var BATTERY_MEMBERS = notOnSensor(CAMERA_BATTERY_MEMBERS);
|
|
5034
5044
|
var BATTERY = {
|
|
5035
5045
|
capability: "battery",
|
|
5036
5046
|
description: "Battery level, charging, health, temperature, and solar input.",
|
|
@@ -15325,6 +15335,7 @@ __export(codec_exports, {
|
|
|
15325
15335
|
encryptP2PData: () => encryptP2PData,
|
|
15326
15336
|
frameMessage: () => frameMessage,
|
|
15327
15337
|
gatewayInfoCipherId: () => gatewayInfoCipherId,
|
|
15338
|
+
gatewayInfoRefused: () => gatewayInfoRefused,
|
|
15328
15339
|
hasHeader: () => hasHeader,
|
|
15329
15340
|
p2pCommandEncryptionKey: () => p2pCommandEncryptionKey,
|
|
15330
15341
|
p2pDidToBuffer: () => p2pDidToBuffer,
|
|
@@ -15443,6 +15454,10 @@ function decryptP2PData(data, key) {
|
|
|
15443
15454
|
function gatewayInfoCipherId(gwPayload) {
|
|
15444
15455
|
return gwPayload.length >= 2 ? gwPayload.readUInt16LE(0) : 0;
|
|
15445
15456
|
}
|
|
15457
|
+
var GATEWAY_LEVEL2_REFUSED = -103;
|
|
15458
|
+
function gatewayInfoRefused(gwPayload) {
|
|
15459
|
+
return gwPayload.length >= 4 && gwPayload.readInt32LE(0) === GATEWAY_LEVEL2_REFUSED;
|
|
15460
|
+
}
|
|
15446
15461
|
function eufyKdf(shared, outLen) {
|
|
15447
15462
|
const hmac = (k, d) => createHmac2("sha256", k).update(d).digest();
|
|
15448
15463
|
const label = Buffer.from("ECIES");
|
|
@@ -16588,6 +16603,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16588
16603
|
liveStartedChannels = /* @__PURE__ */ new Map();
|
|
16589
16604
|
/** The `cipher_id` this connection's `CMD_GATEWAYINFO` reply named, once its negotiation has read it. */
|
|
16590
16605
|
gatewayCipherId;
|
|
16606
|
+
/** Whether this connection's latest `CMD_GATEWAYINFO` reply was a refusal — see {@link gatewayInfoRefused}. */
|
|
16607
|
+
gatewayRefused = false;
|
|
16591
16608
|
/** The `n` of the last `[0x01, n]` header this connection sent in the clear — see {@link nextClearCommandBytes}. */
|
|
16592
16609
|
clearCommandCount = 0;
|
|
16593
16610
|
unackedLiveStarts = /* @__PURE__ */ new Map();
|
|
@@ -16785,9 +16802,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16785
16802
|
* Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
|
|
16786
16803
|
* left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
|
|
16787
16804
|
* GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
|
|
16788
|
-
* 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.
|
|
16789
16807
|
*/
|
|
16790
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
|
+
}
|
|
16791
16816
|
this.level2Negotiating = true;
|
|
16792
16817
|
const generation = this.connectionGeneration;
|
|
16793
16818
|
const cipherId = gatewayInfoCipherId(gwPayload);
|
|
@@ -16910,6 +16935,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16910
16935
|
this.level2Negotiating = false;
|
|
16911
16936
|
this.level2Reprompted = false;
|
|
16912
16937
|
this.gatewayCipherId = void 0;
|
|
16938
|
+
this.gatewayRefused = false;
|
|
16913
16939
|
this.clearCommandCount = 0;
|
|
16914
16940
|
}
|
|
16915
16941
|
const socket = dgram.createSocket("udp4");
|
|
@@ -17534,6 +17560,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17534
17560
|
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
17535
17561
|
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
17536
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
|
+
}
|
|
17537
17570
|
/**
|
|
17538
17571
|
* Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
|
|
17539
17572
|
* target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
|
|
@@ -17544,10 +17577,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17544
17577
|
* On a session holding only the AES-128-ECB level-2 key of an RSA cipher, the frame is the level-1
|
|
17545
17578
|
* layout ({@link sendRawLevel1Bytes}) sealed under that key at encryption type 2: the encryption-type
|
|
17546
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.
|
|
17547
17584
|
*/
|
|
17548
|
-
sendControlLevel2(cmd, channel, accountId, payload, mValue3 = cmd) {
|
|
17549
|
-
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)) {
|
|
17550
17587
|
return false;
|
|
17588
|
+
}
|
|
17551
17589
|
const value = Buffer.from(JSON.stringify({ account_id: accountId, cmd, mChannel: channel, mValue3, payload }), "utf-8");
|
|
17552
17590
|
let sealed;
|
|
17553
17591
|
if (this.level2Key) {
|
|
@@ -17555,8 +17593,10 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17555
17593
|
if (!body)
|
|
17556
17594
|
return false;
|
|
17557
17595
|
sealed = buildRawCommandPayload(body, channel, 8, [8, 0], 0);
|
|
17558
|
-
} else {
|
|
17596
|
+
} else if (this.level2EcbKey) {
|
|
17559
17597
|
sealed = buildRawCommandPayload(encryptP2PData(paddingP2PData(value), this.level2EcbKey), channel, 2);
|
|
17598
|
+
} else {
|
|
17599
|
+
sealed = buildRawCommandPayload(Buffer.from(JSON.stringify({ cmd, payload }), "utf-8"), channel);
|
|
17560
17600
|
}
|
|
17561
17601
|
const data = Buffer.concat([buildCommandHeader(this.seqNumber, CMD_SET_PAYLOAD), sealed]);
|
|
17562
17602
|
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
@@ -18909,169 +18949,6 @@ import { EventEmitter as EventEmitter3 } from "node:events";
|
|
|
18909
18949
|
|
|
18910
18950
|
// dist/transport/p2p/video.js
|
|
18911
18951
|
import { createDecipheriv as createDecipheriv6 } from "node:crypto";
|
|
18912
|
-
var VIDEO_GCM_AAD = Buffer.from("eufy security");
|
|
18913
|
-
var HEADER_LEN = 22;
|
|
18914
|
-
var ENVELOPE_OFFSET = 22;
|
|
18915
|
-
var ENVELOPE_LEN = 129;
|
|
18916
|
-
var GCM_TAG_OFFSET = 151;
|
|
18917
|
-
var GCM_TAG_LEN = 16;
|
|
18918
|
-
var GCM_NONCE_OFFSET = 167;
|
|
18919
|
-
var GCM_NONCE_LEN = 12;
|
|
18920
|
-
var BODY_OFFSET = 179;
|
|
18921
|
-
function parseVideoFrameHeader(payload) {
|
|
18922
|
-
if (payload.length < HEADER_LEN)
|
|
18923
|
-
return void 0;
|
|
18924
|
-
const flags = payload.readUInt8(4);
|
|
18925
|
-
return {
|
|
18926
|
-
payloadLength: payload.readUInt32LE(0),
|
|
18927
|
-
keyframe: (flags & 1) === 1,
|
|
18928
|
-
flags,
|
|
18929
|
-
sequence: payload.readUInt16LE(6),
|
|
18930
|
-
width: payload.readInt16LE(10),
|
|
18931
|
-
height: payload.readInt16LE(12),
|
|
18932
|
-
timestamp: payload.readUInt32LE(14)
|
|
18933
|
-
};
|
|
18934
|
-
}
|
|
18935
|
-
var VideoFrameDecoder = class {
|
|
18936
|
-
eccPrivateKeyHex;
|
|
18937
|
-
mediaKey;
|
|
18938
|
-
/** @param eccPrivateKey the camera's 32-byte ECC private key (P-256 scalar). */
|
|
18939
|
-
constructor(eccPrivateKey) {
|
|
18940
|
-
if (eccPrivateKey.length !== 32)
|
|
18941
|
-
throw new Error("eccPrivateKey must be 32 bytes");
|
|
18942
|
-
this.eccPrivateKeyHex = eccPrivateKey.toString("hex");
|
|
18943
|
-
}
|
|
18944
|
-
/** The media key recovered from the most recent keyframe, if any. */
|
|
18945
|
-
get currentMediaKey() {
|
|
18946
|
-
return this.mediaKey ? Buffer.from(this.mediaKey) : void 0;
|
|
18947
|
-
}
|
|
18948
|
-
/**
|
|
18949
|
-
* Recover the per-stream media key from a keyframe's 129-byte ECIES envelope: ECIES unwrap
|
|
18950
|
-
* (ECDH → eufyKDF → AES-128-CBC, with the trailing HMAC verified) → 32-byte AES-256-GCM key.
|
|
18951
|
-
*/
|
|
18952
|
-
unwrapMediaKey(envelope) {
|
|
18953
|
-
const key = eciesUnwrap(envelope, this.eccPrivateKeyHex, { verifyHmac: true, pkcs7: true });
|
|
18954
|
-
return key && key.length === 32 ? key : void 0;
|
|
18955
|
-
}
|
|
18956
|
-
/**
|
|
18957
|
-
* Decode one video frame. Returns the keyframe flag and decrypted H.264 bytes, or `undefined`
|
|
18958
|
-
* if the frame is malformed, the envelope/key is wrong, or GCM authentication fails.
|
|
18959
|
-
*/
|
|
18960
|
-
decodeFrame(payload) {
|
|
18961
|
-
const header = parseVideoFrameHeader(payload);
|
|
18962
|
-
if (!header)
|
|
18963
|
-
return void 0;
|
|
18964
|
-
if (header.keyframe) {
|
|
18965
|
-
if (payload.length < ENVELOPE_OFFSET + ENVELOPE_LEN)
|
|
18966
|
-
return void 0;
|
|
18967
|
-
const envelope = payload.subarray(ENVELOPE_OFFSET, ENVELOPE_OFFSET + ENVELOPE_LEN);
|
|
18968
|
-
const key = this.unwrapMediaKey(envelope);
|
|
18969
|
-
if (!key)
|
|
18970
|
-
return void 0;
|
|
18971
|
-
this.mediaKey = key;
|
|
18972
|
-
}
|
|
18973
|
-
const mediaKey = this.mediaKey;
|
|
18974
|
-
if (!mediaKey)
|
|
18975
|
-
return void 0;
|
|
18976
|
-
if (payload.length < BODY_OFFSET)
|
|
18977
|
-
return void 0;
|
|
18978
|
-
const tag2 = payload.subarray(GCM_TAG_OFFSET, GCM_TAG_OFFSET + GCM_TAG_LEN);
|
|
18979
|
-
const nonce = payload.subarray(GCM_NONCE_OFFSET, GCM_NONCE_OFFSET + GCM_NONCE_LEN);
|
|
18980
|
-
const body = payload.subarray(BODY_OFFSET);
|
|
18981
|
-
try {
|
|
18982
|
-
const dec2 = createDecipheriv6("aes-256-gcm", mediaKey, nonce);
|
|
18983
|
-
dec2.setAAD(VIDEO_GCM_AAD);
|
|
18984
|
-
dec2.setAuthTag(tag2);
|
|
18985
|
-
const head = dec2.update(body);
|
|
18986
|
-
const tail = dec2.final();
|
|
18987
|
-
const h264 = tail.length ? Buffer.concat([head, tail]) : head;
|
|
18988
|
-
return { keyframe: header.keyframe, h264, width: header.width, height: header.height };
|
|
18989
|
-
} catch {
|
|
18990
|
-
return void 0;
|
|
18991
|
-
}
|
|
18992
|
-
}
|
|
18993
|
-
};
|
|
18994
|
-
var STATION_CHUNK_BYTES = 64e3;
|
|
18995
|
-
var AccessUnitAssembler = class {
|
|
18996
|
-
onDropped;
|
|
18997
|
-
chunkBytes;
|
|
18998
|
-
open;
|
|
18999
|
-
droppedUnits = 0;
|
|
19000
|
-
/**
|
|
19001
|
-
* @param onDropped called for each incomplete unit discarded, with what had arrived, how many frames it
|
|
19002
|
-
* arrived in, and how many this assembler has dropped in total — a running count, so no caller keeps
|
|
19003
|
-
* its own.
|
|
19004
|
-
* @param chunkBytes the payload size the station fills a frame to before continuing a unit in the next. A
|
|
19005
|
-
* unit is held while its latest frame declares exactly this. {@link STATION_CHUNK_BYTES} when omitted.
|
|
19006
|
-
*/
|
|
19007
|
-
constructor(onDropped, chunkBytes = STATION_CHUNK_BYTES) {
|
|
19008
|
-
this.onDropped = onDropped;
|
|
19009
|
-
this.chunkBytes = chunkBytes;
|
|
19010
|
-
}
|
|
19011
|
-
/**
|
|
19012
|
-
* Feed one raw `CMD_VIDEO_FRAME` payload; returns the access units it completed — none while a unit is
|
|
19013
|
-
* still being filled, one in the ordinary case.
|
|
19014
|
-
*
|
|
19015
|
-
* `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
|
|
19016
|
-
* frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
|
|
19017
|
-
* of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
|
|
19018
|
-
* carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
|
|
19019
|
-
* as a complete unit, even if its repeated header says keyframe.
|
|
19020
|
-
*/
|
|
19021
|
-
push(payload, decode) {
|
|
19022
|
-
const header = parseVideoFrameHeader(payload);
|
|
19023
|
-
const body = header && decode(payload);
|
|
19024
|
-
if (!header || !body?.length) {
|
|
19025
|
-
this.discard();
|
|
19026
|
-
return [];
|
|
19027
|
-
}
|
|
19028
|
-
const full = header.payloadLength === this.chunkBytes;
|
|
19029
|
-
const open2 = this.open;
|
|
19030
|
-
if (open2 && sameUnit(open2.header, header) && !beginsAccessUnit(body)) {
|
|
19031
|
-
open2.chunks.push(body);
|
|
19032
|
-
open2.carried += body.length;
|
|
19033
|
-
if (full)
|
|
19034
|
-
return [];
|
|
19035
|
-
this.open = void 0;
|
|
19036
|
-
return [unitOf(open2.header, Buffer.concat(open2.chunks))];
|
|
19037
|
-
}
|
|
19038
|
-
this.discard();
|
|
19039
|
-
if (!beginsAccessUnit(body))
|
|
19040
|
-
return [];
|
|
19041
|
-
if (full) {
|
|
19042
|
-
this.open = { header, chunks: [body], carried: body.length };
|
|
19043
|
-
return [];
|
|
19044
|
-
}
|
|
19045
|
-
return [unitOf(header, body)];
|
|
19046
|
-
}
|
|
19047
|
-
/** Forget an incomplete unit, counting and reporting it. */
|
|
19048
|
-
discard() {
|
|
19049
|
-
const open2 = this.open;
|
|
19050
|
-
if (!open2)
|
|
19051
|
-
return;
|
|
19052
|
-
this.open = void 0;
|
|
19053
|
-
this.droppedUnits++;
|
|
19054
|
-
this.onDropped?.({ carried: open2.carried, chunks: open2.chunks.length, count: this.droppedUnits });
|
|
19055
|
-
}
|
|
19056
|
-
};
|
|
19057
|
-
var SPLIT_UNIT_STAMP_SPREAD_MS = 100;
|
|
19058
|
-
function sameUnit(open2, next) {
|
|
19059
|
-
return next.sequence === open2.sequence && next.timestamp - open2.timestamp >>> 0 <= SPLIT_UNIT_STAMP_SPREAD_MS;
|
|
19060
|
-
}
|
|
19061
|
-
function beginsAccessUnit(body) {
|
|
19062
|
-
if (body.length < 4 || body[0] !== 0 || body[1] !== 0)
|
|
19063
|
-
return false;
|
|
19064
|
-
return body[2] === 1 || body[2] === 0 && body[3] === 1;
|
|
19065
|
-
}
|
|
19066
|
-
function unitOf(header, data) {
|
|
19067
|
-
return {
|
|
19068
|
-
sourceTimestamp: header.timestamp,
|
|
19069
|
-
keyframe: header.keyframe,
|
|
19070
|
-
width: header.width,
|
|
19071
|
-
height: header.height,
|
|
19072
|
-
data
|
|
19073
|
-
};
|
|
19074
|
-
}
|
|
19075
18952
|
|
|
19076
18953
|
// dist/transport/p2p/annexb.js
|
|
19077
18954
|
var ANNEXB_START = Buffer.from([0, 0, 0, 1]);
|
|
@@ -19389,6 +19266,172 @@ function h265Geometry(sps) {
|
|
|
19389
19266
|
};
|
|
19390
19267
|
}
|
|
19391
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
|
+
|
|
19392
19435
|
// dist/transport/p2p/audio-frame.js
|
|
19393
19436
|
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19394
19437
|
var CMD_AUDIO_FRAME2 = 1301;
|
|
@@ -22419,6 +22462,7 @@ var RTSP_URL_READ_TIMEOUT_MS = 12e3;
|
|
|
22419
22462
|
var TALKBACK_CODEC_WAIT_MS = 3e3;
|
|
22420
22463
|
var OWN_LIVE_SESSION_MODELS = /* @__PURE__ */ new Set(["T8030"]);
|
|
22421
22464
|
var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
|
|
22465
|
+
var CLEAR_FF09_MODELS = /* @__PURE__ */ new Set(["T8506"]);
|
|
22422
22466
|
function speakerCodec(consumer, talk, timeoutMs) {
|
|
22423
22467
|
return new Promise((resolve2) => {
|
|
22424
22468
|
let timer;
|
|
@@ -23764,6 +23808,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23764
23808
|
* sources on the replaced session are discarded before closing it (see {@link tearDownStation}).
|
|
23765
23809
|
* Replaced at most once per resolution, so a station whose replacement is silent too is returned rather
|
|
23766
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}.
|
|
23767
23814
|
*/
|
|
23768
23815
|
async resolveSession(sn, opts = {}, rebuilt = false) {
|
|
23769
23816
|
const dev = await this.deviceFor(sn);
|
|
@@ -23815,8 +23862,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23815
23862
|
if (!ready && session.repromptLevel2Key()) {
|
|
23816
23863
|
ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call", sender), opts.signal);
|
|
23817
23864
|
}
|
|
23818
|
-
|
|
23865
|
+
const sendsClear = opts.clearFallback === true && !homeBaseAttached && CLEAR_FF09_MODELS.has(this.recordFor(sn)?.model ?? "") && session.refusedLevel2;
|
|
23866
|
+
if (!ready && !sendsClear)
|
|
23819
23867
|
throw new StationKeyUnavailableError(parentSn);
|
|
23868
|
+
return { session, parentSn, channel, accountId, homeBaseAttached, sendsClear };
|
|
23820
23869
|
}
|
|
23821
23870
|
return { session, parentSn, channel, accountId, homeBaseAttached };
|
|
23822
23871
|
}
|
|
@@ -23926,7 +23975,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23926
23975
|
});
|
|
23927
23976
|
return;
|
|
23928
23977
|
}
|
|
23929
|
-
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 }));
|
|
23930
23979
|
}
|
|
23931
23980
|
/**
|
|
23932
23981
|
* **`keyed-payload-actuate` intent** — the classic Wi-Fi lock's actuation: the sealed fields from
|
|
@@ -24093,7 +24142,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
24093
24142
|
* bytes and never the routing channel — that's re-resolved here from the device record.
|
|
24094
24143
|
*/
|
|
24095
24144
|
async sendFf09Actuate(sn, cmd) {
|
|
24096
|
-
const resolved = await this.resolveSession(sn, { waitLevel2: true });
|
|
24145
|
+
const resolved = await this.resolveSession(sn, { waitLevel2: true, clearFallback: true });
|
|
24097
24146
|
const frame = buildFf09Frame({
|
|
24098
24147
|
engage: cmd.engage,
|
|
24099
24148
|
adminUserId: cmd.adminUserId,
|