@mega-yfue/eufy-sdk 0.6.0-beta.15 → 0.6.0-beta.17
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/client/eufy-mega.d.ts +18 -0
- package/dist/index.js +217 -46
- package/dist/index.js.map +3 -3
- package/dist/transport/p2p/audio-frame.d.ts +13 -0
- package/dist/transport/p2p/live-stream.d.ts +33 -7
- package/dist/transport/p2p/live-trace.d.ts +2 -0
- package/dist/transport/p2p/p2p-session.d.ts +55 -1
- package/dist/transport/p2p/recording-download.d.ts +22 -0
- package/dist/transport/p2p/video.d.ts +4 -2
- package/dist/transport/rtc/session.d.ts +4 -2
- package/package.json +1 -1
|
@@ -54,6 +54,12 @@ export declare class EufyMega extends EventEmitter {
|
|
|
54
54
|
/** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
|
|
55
55
|
* publish await the SAME fully-connected transport instead of racing (and never open two). */
|
|
56
56
|
private readonly mqttReady;
|
|
57
|
+
/**
|
|
58
|
+
* Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
|
|
59
|
+
* subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
|
|
60
|
+
* starts empty.
|
|
61
|
+
*/
|
|
62
|
+
private readonly mqttSubscribed;
|
|
57
63
|
/** Device list/record/capability resolution + the frame→caps cache. */
|
|
58
64
|
private readonly registry;
|
|
59
65
|
private pushClient?;
|
|
@@ -632,6 +638,9 @@ export declare class EufyMega extends EventEmitter {
|
|
|
632
638
|
* A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
|
|
633
639
|
* of its params moved, since each re-resolution is a cloud param-list read.
|
|
634
640
|
*
|
|
641
|
+
* Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
|
|
642
|
+
* sized it from the roster at login, which is empty when the cloud was unreachable then.
|
|
643
|
+
*
|
|
635
644
|
* Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
|
|
636
645
|
* is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
|
|
637
646
|
* stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
|
|
@@ -818,11 +827,20 @@ export declare class EufyMega extends EventEmitter {
|
|
|
818
827
|
/**
|
|
819
828
|
* Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
|
|
820
829
|
* unreachable device must not stop the rest of the roster from coming up).
|
|
830
|
+
* A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
|
|
831
|
+
* whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
|
|
832
|
+
* per transport.
|
|
821
833
|
*
|
|
822
834
|
* A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
|
|
823
835
|
* and the broker refuses its `eufy_home` topics on every credential.
|
|
824
836
|
*/
|
|
825
837
|
private subscribeMqttDevices;
|
|
838
|
+
/**
|
|
839
|
+
* Start every credential scope the roster now needs and has no transport for, and subscribe the
|
|
840
|
+
* devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
|
|
841
|
+
* the realtime generation is superseded or was never started.
|
|
842
|
+
*/
|
|
843
|
+
private syncMqttRoster;
|
|
826
844
|
/**
|
|
827
845
|
* Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
|
|
828
846
|
* state is pushed on change with no heartbeat, the request that makes its state readable before the
|
package/dist/index.js
CHANGED
|
@@ -16226,7 +16226,7 @@ import { EventEmitter as EventEmitter2 } from "node:events";
|
|
|
16226
16226
|
import dgram from "node:dgram";
|
|
16227
16227
|
import { resolve4 } from "node:dns/promises";
|
|
16228
16228
|
import { isIP as isIP2 } from "node:net";
|
|
16229
|
-
import { constants as constants2, createCipheriv as createCipheriv4, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt as privateDecrypt2, randomBytes as randomBytes4 } from "node:crypto";
|
|
16229
|
+
import { constants as constants2, createCipheriv as createCipheriv4, createECDH as createECDH3, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt as privateDecrypt2, randomBytes as randomBytes4 } from "node:crypto";
|
|
16230
16230
|
|
|
16231
16231
|
// dist/transport/p2p/live-trace.js
|
|
16232
16232
|
var LIVE_TRACE_MESSAGE = "[live] start trace";
|
|
@@ -16453,6 +16453,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16453
16453
|
level2Seq = 0;
|
|
16454
16454
|
rsaPrivateKey;
|
|
16455
16455
|
rsaModulusHex;
|
|
16456
|
+
/** The P-256 pair an attached camera's media start offers; see {@link mediaEccPrivateKey}. */
|
|
16457
|
+
mediaEcc;
|
|
16458
|
+
/**
|
|
16459
|
+
* Per attached channel, the media-key type its latest start offered on this connection, or that last answered,
|
|
16460
|
+
* and whether a keyframe has answered it. See {@link attachedStartKey} and {@link settleAttachedKey}.
|
|
16461
|
+
*/
|
|
16462
|
+
attachedStartKeys = /* @__PURE__ */ new Map();
|
|
16456
16463
|
/**
|
|
16457
16464
|
* Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
|
|
16458
16465
|
* settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
|
|
@@ -16778,7 +16785,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16778
16785
|
const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
|
|
16779
16786
|
this.logger[level](`[p2p] ${this.cfg.stationSn} UDP receive buffer below the ${RECEIVE_BUFFER_BYTES} bytes requested` + outcome + `; live video can lose keyframes. Raise the OS limit (net.core.rmem_max on Linux, kern.ipc.maxsockbuf on BSD) to at least ${RECEIVE_BUFFER_BYTES}.`);
|
|
16780
16787
|
}
|
|
16781
|
-
/**
|
|
16788
|
+
/**
|
|
16789
|
+
* Open the socket and start the lookup → hole-punch handshake.
|
|
16790
|
+
*
|
|
16791
|
+
* A socket closed or failing while binding never invokes its bind callback, so the bind wait also
|
|
16792
|
+
* settles on the socket's `close` and `error`. On `close`, a connect whose session was closed meanwhile
|
|
16793
|
+
* returns without starting the handshake, so it never sends a lookup or arms a timer on a closed
|
|
16794
|
+
* session. On `error`, connect closes the session and rejects with {@link StationUnreachableError}
|
|
16795
|
+
* carrying the bind error as its `cause`.
|
|
16796
|
+
*/
|
|
16782
16797
|
async connect() {
|
|
16783
16798
|
if (this.connecting || this.connected)
|
|
16784
16799
|
return;
|
|
@@ -16786,6 +16801,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16786
16801
|
this.closed = false;
|
|
16787
16802
|
this.connectionGeneration += 1;
|
|
16788
16803
|
this.ackedRounds = void 0;
|
|
16804
|
+
this.attachedStartKeys.clear();
|
|
16789
16805
|
this.resetInboundSequencing();
|
|
16790
16806
|
if (!this.level2KeyServes("set-payload")) {
|
|
16791
16807
|
this.level2Pending = this.cfg.resolveCipherKey !== void 0;
|
|
@@ -16798,8 +16814,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16798
16814
|
this.socket = socket;
|
|
16799
16815
|
socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, socket));
|
|
16800
16816
|
socket.on("error", (e) => this.emit("error", e));
|
|
16801
|
-
|
|
16817
|
+
const generation = this.connectionGeneration;
|
|
16818
|
+
await new Promise((resolve2, reject) => {
|
|
16819
|
+
socket.once("close", resolve2);
|
|
16820
|
+
socket.once("error", reject);
|
|
16802
16821
|
socket.bind(() => {
|
|
16822
|
+
socket.off("error", reject);
|
|
16803
16823
|
try {
|
|
16804
16824
|
socket.setBroadcast(true);
|
|
16805
16825
|
} catch {
|
|
@@ -16807,7 +16827,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16807
16827
|
this.requestReceiveBuffer(socket);
|
|
16808
16828
|
resolve2();
|
|
16809
16829
|
});
|
|
16830
|
+
}).catch(async (error) => {
|
|
16831
|
+
await this.close();
|
|
16832
|
+
throw new StationUnreachableError(this.cfg.stationSn, 0, { cause: error });
|
|
16810
16833
|
});
|
|
16834
|
+
if (generation !== this.connectionGeneration)
|
|
16835
|
+
return;
|
|
16811
16836
|
void _P2PSession.detectLocalIp().then((host) => {
|
|
16812
16837
|
if (host && !this.closed)
|
|
16813
16838
|
this.selfHost = host;
|
|
@@ -16815,7 +16840,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16815
16840
|
const { dskKey, cloudAddresses } = this.cfg;
|
|
16816
16841
|
this.cloudLookup = dskKey && cloudAddresses?.length ? { key: dskKey, addresses: cloudAddresses } : void 0;
|
|
16817
16842
|
if (this.cloudLookup && cloudAddresses) {
|
|
16818
|
-
const generation = this.connectionGeneration;
|
|
16819
16843
|
void resolveLookupServers(cloudAddresses).then((addresses) => {
|
|
16820
16844
|
if (this.cloudLookup && !this.closed && generation === this.connectionGeneration)
|
|
16821
16845
|
this.cloudLookup.addresses = addresses;
|
|
@@ -17183,13 +17207,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17183
17207
|
startLiveMedia(channel = STATION_CHANNEL3, accountId = "", homeBaseAttached = false, opts) {
|
|
17184
17208
|
if (homeBaseAttached) {
|
|
17185
17209
|
this.tracedDatagramGaps = 0;
|
|
17210
|
+
const key = this.connectAddress && this.level2Key ? this.attachedStartKey(channel) : void 0;
|
|
17186
17211
|
this.trace({
|
|
17187
17212
|
phase: "media-command",
|
|
17188
17213
|
topology: "attached",
|
|
17189
17214
|
action: "start",
|
|
17190
|
-
level2: !!this.level2Key
|
|
17215
|
+
level2: !!this.level2Key,
|
|
17216
|
+
key
|
|
17191
17217
|
});
|
|
17192
|
-
this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {});
|
|
17218
|
+
this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {}, key);
|
|
17193
17219
|
return;
|
|
17194
17220
|
}
|
|
17195
17221
|
const want = this.level2Key ? "l2" : this.sendsInTheClear() ? "clear" : "l1";
|
|
@@ -17689,8 +17715,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17689
17715
|
* Send a CMD_SET_PAYLOAD(1350) wrapping `{account_id, cmd:<subCmd>, mChannel, mValue3:<subCmd>,
|
|
17690
17716
|
* payload}`, encrypted level-2 (AES-256-GCM, signCode 8) — the app's media-control path. The
|
|
17691
17717
|
* camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
|
|
17718
|
+
* A start (HomeBase-controlled camera path, the V6 app's media-start envelope) also carries an
|
|
17719
|
+
* `accountId` inside its payload and the media key `key` offers (see {@link attachedStartKey}). A start
|
|
17720
|
+
* offering the P-256 point labels itself `ClientOS: "IOS"`, as every simple attached start in the app
|
|
17721
|
+
* captures that carry that key form does; the RSA start keeps `"Android"`.
|
|
17692
17722
|
*/
|
|
17693
|
-
sendMediaPayloadLevel2(subCmd, channel, accountId, payload) {
|
|
17723
|
+
sendMediaPayloadLevel2(subCmd, channel, accountId, payload, key) {
|
|
17694
17724
|
if (!this.connectAddress || !this.level2Key) {
|
|
17695
17725
|
this.trace({
|
|
17696
17726
|
phase: "media-command-unsent",
|
|
@@ -17700,11 +17730,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17700
17730
|
}
|
|
17701
17731
|
const isStart = subCmd === CMD_START_REALTIME_MEDIA;
|
|
17702
17732
|
const innerPayload = isStart ? {
|
|
17703
|
-
ClientOS: "Android",
|
|
17733
|
+
ClientOS: key === "ecc" ? "IOS" : "Android",
|
|
17704
17734
|
accountId,
|
|
17705
17735
|
camera_type: 0,
|
|
17706
17736
|
entrytype: 0,
|
|
17707
|
-
key: this.rsaModulus(),
|
|
17737
|
+
key: key === "ecc" ? this.mediaEccPair().point : this.rsaModulus(),
|
|
17708
17738
|
streamtype: 2,
|
|
17709
17739
|
...payload
|
|
17710
17740
|
} : payload;
|
|
@@ -17779,6 +17809,70 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17779
17809
|
return void 0;
|
|
17780
17810
|
}
|
|
17781
17811
|
}
|
|
17812
|
+
/**
|
|
17813
|
+
* The media-key type the next attached start sent on `channel` offers, recorded as offered: ECC, the current
|
|
17814
|
+
* app's form, for the first start this connection sends there; RSA for every start after one that no keyframe
|
|
17815
|
+
* answered before it was re-issued; and, once a keyframe has answered, the type that opened it, until
|
|
17816
|
+
* {@link settleAttachedKey} records another. Only an opened keyframe answers: frames no key opens, and deltas,
|
|
17817
|
+
* do not. A re-issue is a full restart on an attached camera, so a camera that cannot read the point is asked
|
|
17818
|
+
* with RSA, the start every station answers, and is not asked with the point again while that goes unanswered.
|
|
17819
|
+
* A stream decoding with the session's pair reads either answer. A station without {@link mediaEccPrivateKey}
|
|
17820
|
+
* is offered RSA only. A start that cannot be sent offers nothing and records nothing.
|
|
17821
|
+
*/
|
|
17822
|
+
attachedStartKey(channel) {
|
|
17823
|
+
if (!this.mediaEccPrivateKey)
|
|
17824
|
+
return "rsa";
|
|
17825
|
+
const last = this.attachedStartKeys.get(channel);
|
|
17826
|
+
if (last?.answered)
|
|
17827
|
+
return last.kind;
|
|
17828
|
+
const kind = last ? "rsa" : "ecc";
|
|
17829
|
+
this.attachedStartKeys.set(channel, { kind, answered: false });
|
|
17830
|
+
return kind;
|
|
17831
|
+
}
|
|
17832
|
+
/**
|
|
17833
|
+
* Record how a keyframe of `channel` was opened, which answers the starts there. A keyframe opened under a
|
|
17834
|
+
* key type is an answer of that type, whichever type the latest start offered: a station can serve the
|
|
17835
|
+
* P-256 start after the RSA re-issue went out. A plaintext keyframe
|
|
17836
|
+
* answering an unanswered P-256 offer is a camera that did not use the point, and the next start there offers
|
|
17837
|
+
* RSA; once a P-256 answer has been opened it changes nothing, and answering RSA, it settles RSA. Answers
|
|
17838
|
+
* whether a new start is needed. A channel no attached start has offered a key on is left as it is.
|
|
17839
|
+
*/
|
|
17840
|
+
settleAttachedKey(channel, opened) {
|
|
17841
|
+
const last = this.attachedStartKeys.get(channel);
|
|
17842
|
+
if (!last)
|
|
17843
|
+
return false;
|
|
17844
|
+
if (opened === "plain" && last.kind === "ecc")
|
|
17845
|
+
return !last.answered;
|
|
17846
|
+
this.attachedStartKeys.set(channel, { kind: opened === "ecc" ? "ecc" : "rsa", answered: true });
|
|
17847
|
+
return false;
|
|
17848
|
+
}
|
|
17849
|
+
/**
|
|
17850
|
+
* The 32-byte P-256 private scalar paired with the point an attached camera's media start offers as `key`.
|
|
17851
|
+
* A camera answering that start wraps each keyframe's AES-256-GCM media key in an ECIES envelope for this
|
|
17852
|
+
* key ({@link VideoFrameDecoder}). Generated once per session, like the RSA pair.
|
|
17853
|
+
*
|
|
17854
|
+
* `undefined` unless the station is a HomeBase 3 (`T8030`): the app's P-256 start and the device runs
|
|
17855
|
+
* that answered it were both observed on that station only.
|
|
17856
|
+
*/
|
|
17857
|
+
get mediaEccPrivateKey() {
|
|
17858
|
+
return this.cfg.stationSn.startsWith("T8030") ? this.mediaEccPair().privateKey : void 0;
|
|
17859
|
+
}
|
|
17860
|
+
/**
|
|
17861
|
+
* The session's P-256 pair: the private scalar as 32 bytes, and the public point as 128 hex characters,
|
|
17862
|
+
* `x ‖ y` without the uncompressed-point prefix, the form the current app's attached-camera start carries in
|
|
17863
|
+
* `key`. `ECDH.getPrivateKey()` drops a scalar's leading zero bytes, so the scalar is padded back to 32.
|
|
17864
|
+
*/
|
|
17865
|
+
mediaEccPair() {
|
|
17866
|
+
if (!this.mediaEcc) {
|
|
17867
|
+
const ecdh = createECDH3("prime256v1");
|
|
17868
|
+
ecdh.generateKeys();
|
|
17869
|
+
this.mediaEcc = {
|
|
17870
|
+
privateKey: Buffer.from(ecdh.getPrivateKey("hex").padStart(64, "0"), "hex"),
|
|
17871
|
+
point: ecdh.getPublicKey().subarray(1).toString("hex")
|
|
17872
|
+
};
|
|
17873
|
+
}
|
|
17874
|
+
return this.mediaEcc;
|
|
17875
|
+
}
|
|
17782
17876
|
/**
|
|
17783
17877
|
* The RSA-1024 public-key modulus (128-byte hex) the station uses to wrap the per-stream media
|
|
17784
17878
|
* key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
|
|
@@ -18849,8 +18943,9 @@ var AccessUnitAssembler = class {
|
|
|
18849
18943
|
this.onDropped?.({ carried: open2.carried, chunks: open2.chunks.length, count: this.droppedUnits });
|
|
18850
18944
|
}
|
|
18851
18945
|
};
|
|
18946
|
+
var SPLIT_UNIT_STAMP_SPREAD_MS = 100;
|
|
18852
18947
|
function sameUnit(open2, next) {
|
|
18853
|
-
return next.
|
|
18948
|
+
return next.sequence === open2.sequence && next.timestamp - open2.timestamp >>> 0 <= SPLIT_UNIT_STAMP_SPREAD_MS;
|
|
18854
18949
|
}
|
|
18855
18950
|
function beginsAccessUnit(body) {
|
|
18856
18951
|
if (body.length < 4 || body[0] !== 0 || body[1] !== 0)
|
|
@@ -19184,10 +19279,29 @@ function h265Geometry(sps) {
|
|
|
19184
19279
|
}
|
|
19185
19280
|
|
|
19186
19281
|
// dist/transport/p2p/audio-frame.js
|
|
19282
|
+
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19187
19283
|
var CMD_AUDIO_FRAME2 = 1301;
|
|
19188
19284
|
var AUDIO_HEADER_LEN = 16;
|
|
19189
19285
|
var AUDIO_TYPE_OFFSET = 5;
|
|
19190
19286
|
var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
|
|
19287
|
+
var AUDIO_NONCE_START = 32;
|
|
19288
|
+
var AUDIO_BODY_START = 44;
|
|
19289
|
+
function isSealedAudio(frame) {
|
|
19290
|
+
return frame.length >= AUDIO_BODY_START && frame.length >= AUDIO_BODY_START + frame.readUInt32LE(0);
|
|
19291
|
+
}
|
|
19292
|
+
function openSealedAudio(frame, mediaKey) {
|
|
19293
|
+
if (!isSealedAudio(frame))
|
|
19294
|
+
return void 0;
|
|
19295
|
+
try {
|
|
19296
|
+
const decipher = createDecipheriv7("aes-256-gcm", mediaKey, frame.subarray(AUDIO_NONCE_START, AUDIO_BODY_START));
|
|
19297
|
+
decipher.setAAD(VIDEO_GCM_AAD);
|
|
19298
|
+
decipher.setAuthTag(frame.subarray(AUDIO_HEADER_LEN, AUDIO_NONCE_START));
|
|
19299
|
+
const body = frame.subarray(AUDIO_BODY_START, AUDIO_BODY_START + frame.readUInt32LE(0));
|
|
19300
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
19301
|
+
} catch {
|
|
19302
|
+
return void 0;
|
|
19303
|
+
}
|
|
19304
|
+
}
|
|
19191
19305
|
|
|
19192
19306
|
// dist/transport/p2p/live-stream.js
|
|
19193
19307
|
var CMD_VIDEO_FRAME2 = 1300;
|
|
@@ -19219,6 +19333,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19219
19333
|
tracedFirstVideoUnit = false;
|
|
19220
19334
|
tracedFirstKeyframe = false;
|
|
19221
19335
|
tracedDecodeFailures = 0;
|
|
19336
|
+
/** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
|
|
19337
|
+
unitOpenedBy = "plain";
|
|
19222
19338
|
tracedFirstForeignFrame = false;
|
|
19223
19339
|
stallTimer;
|
|
19224
19340
|
/**
|
|
@@ -19241,8 +19357,9 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19241
19357
|
this.channel = opts.channel ?? STATION_CHANNEL3;
|
|
19242
19358
|
if (opts.homeBaseAttached && opts.channel !== void 0)
|
|
19243
19359
|
this.mediaChannel = opts.channel;
|
|
19244
|
-
|
|
19245
|
-
|
|
19360
|
+
const eccPrivateKey = opts.eccPrivateKey ?? (opts.homeBaseAttached ? session.mediaEccPrivateKey : void 0);
|
|
19361
|
+
if (eccPrivateKey)
|
|
19362
|
+
this.decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
19246
19363
|
}
|
|
19247
19364
|
/** Emit a live trace under its session's handle and the channel this stream pulls. */
|
|
19248
19365
|
trace(trace) {
|
|
@@ -19403,8 +19520,10 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19403
19520
|
this.tracedFirstKeyframe = true;
|
|
19404
19521
|
this.trace({ phase: "first-keyframe" });
|
|
19405
19522
|
}
|
|
19406
|
-
if (unit.keyframe)
|
|
19523
|
+
if (unit.keyframe) {
|
|
19407
19524
|
this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
|
|
19525
|
+
this.settleStartKey();
|
|
19526
|
+
}
|
|
19408
19527
|
this.settleKeepalive();
|
|
19409
19528
|
this.emit("video", {
|
|
19410
19529
|
sourceTimestamp: unit.sourceTimestamp,
|
|
@@ -19416,8 +19535,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19416
19535
|
});
|
|
19417
19536
|
}
|
|
19418
19537
|
} else if (f.commandId === CMD_AUDIO_FRAME2) {
|
|
19419
|
-
const audio =
|
|
19420
|
-
if (audio
|
|
19538
|
+
const audio = this.audioPayload(f.data);
|
|
19539
|
+
if (audio?.length) {
|
|
19421
19540
|
const codecId = f.data.length > AUDIO_TYPE_OFFSET ? f.data.readUInt8(AUDIO_TYPE_OFFSET) : void 0;
|
|
19422
19541
|
const codec = codecId === void 0 ? void 0 : AUDIO_CODECS[codecId];
|
|
19423
19542
|
if (!codec) {
|
|
@@ -19468,19 +19587,54 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19468
19587
|
return false;
|
|
19469
19588
|
}
|
|
19470
19589
|
/**
|
|
19471
|
-
* The Annex-B payload of a frame that starts an access unit. The
|
|
19472
|
-
*
|
|
19473
|
-
*
|
|
19590
|
+
* The Annex-B payload of a frame that starts an access unit. The authenticated ECIES/GCM
|
|
19591
|
+
* {@link VideoFrameDecoder} goes first: its HMAC and GCM checks fail closed on a frame not sealed for its
|
|
19592
|
+
* key. The session's RSA path cannot: on Node ≥ 24.5, PKCS#1 v1.5 decryption of a key not wrapped for it
|
|
19593
|
+
* returns pseudo-random bytes instead of throwing (implicit rejection), which would decode an ECC
|
|
19594
|
+
* keyframe into noise. The session's decode, which also passes plaintext frames, and the header strip
|
|
19595
|
+
* follow. A payload that opens an access unit records how it was read: by the ECIES path, or otherwise by
|
|
19596
|
+
* its sign code, sealed (`"rsa"`) or in the clear (`"plain"`).
|
|
19474
19597
|
*/
|
|
19475
19598
|
annexbOf(payload, signCode) {
|
|
19476
|
-
const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
|
|
19477
|
-
if (annexb)
|
|
19478
|
-
return annexb;
|
|
19479
19599
|
const decoded = this.decoder?.decodeFrame(payload)?.h264;
|
|
19480
|
-
if (
|
|
19600
|
+
if (decoded) {
|
|
19601
|
+
if (beginsAccessUnit(decoded))
|
|
19602
|
+
this.unitOpenedBy = "ecc";
|
|
19603
|
+
return decoded;
|
|
19604
|
+
}
|
|
19605
|
+
const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
|
|
19606
|
+
if (!annexb && this.tracedDecodeFailures++ < MAX_TRACED_DECODE_FAILURES) {
|
|
19481
19607
|
this.trace({ phase: "video-decode-empty", signCode });
|
|
19482
19608
|
}
|
|
19483
|
-
|
|
19609
|
+
if (annexb && beginsAccessUnit(annexb))
|
|
19610
|
+
this.unitOpenedBy = signCode > 0 ? "rsa" : "plain";
|
|
19611
|
+
return annexb;
|
|
19612
|
+
}
|
|
19613
|
+
/**
|
|
19614
|
+
* Tell the session how this keyframe was opened, which answers the attached starts on this channel
|
|
19615
|
+
* ({@link P2PSession.settleAttachedKey}), and re-issue the start when the session asks for one: a plaintext
|
|
19616
|
+
* keyframe answering the session's P-256 offer, from a camera that did not use the point, which the next
|
|
19617
|
+
* start asks with RSA. Only a delivered keyframe answers: a frame no key opens, or a continuation without the
|
|
19618
|
+
* frame that opened its unit, delivers nothing.
|
|
19619
|
+
*/
|
|
19620
|
+
settleStartKey() {
|
|
19621
|
+
if (!this.session.settleAttachedKey?.(this.channel, this.unitOpenedBy))
|
|
19622
|
+
return;
|
|
19623
|
+
this.logger.debug(`[live ch${this.channel}] plaintext keyframe answered the P-256 offer \u2014 re-starting with RSA`);
|
|
19624
|
+
this.sendStart();
|
|
19625
|
+
}
|
|
19626
|
+
/**
|
|
19627
|
+
* The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
|
|
19628
|
+
* opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
|
|
19629
|
+
* before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
|
|
19630
|
+
* its header.
|
|
19631
|
+
*/
|
|
19632
|
+
audioPayload(frame) {
|
|
19633
|
+
if (!this.decoder || !isSealedAudio(frame)) {
|
|
19634
|
+
return frame.length > AUDIO_HEADER_LEN ? frame.subarray(AUDIO_HEADER_LEN) : frame;
|
|
19635
|
+
}
|
|
19636
|
+
const mediaKey = this.decoder.currentMediaKey;
|
|
19637
|
+
return mediaKey ? openSealedAudio(frame, mediaKey) : void 0;
|
|
19484
19638
|
}
|
|
19485
19639
|
/**
|
|
19486
19640
|
* Report an access unit the transport could not complete.
|
|
@@ -19770,12 +19924,8 @@ function annexbToJpeg(annexb, spawn2) {
|
|
|
19770
19924
|
}
|
|
19771
19925
|
|
|
19772
19926
|
// dist/transport/p2p/recording-download.js
|
|
19773
|
-
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19774
19927
|
var RECORDING_DATA_TYPE = 3;
|
|
19775
19928
|
var VIDEO_HEADER_LEN2 = 22;
|
|
19776
|
-
var AUDIO_HEADER_LEN2 = 16;
|
|
19777
|
-
var AUDIO_NONCE_START = 32;
|
|
19778
|
-
var AUDIO_BODY_START = 44;
|
|
19779
19929
|
var AUDIO_CODEC_AAC_LC = 0;
|
|
19780
19930
|
var FINISH_CHANNEL = 0;
|
|
19781
19931
|
var FIRST_FRAME_WAIT_MS = 2e4;
|
|
@@ -19887,16 +20037,6 @@ function receiveRecording(session, request) {
|
|
|
19887
20037
|
}
|
|
19888
20038
|
return { frames, drained };
|
|
19889
20039
|
}
|
|
19890
|
-
function openAudio(key, nonce, tag2, body) {
|
|
19891
|
-
try {
|
|
19892
|
-
const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
|
|
19893
|
-
decipher.setAAD(VIDEO_GCM_AAD);
|
|
19894
|
-
decipher.setAuthTag(tag2);
|
|
19895
|
-
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
19896
|
-
} catch {
|
|
19897
|
-
return void 0;
|
|
19898
|
-
}
|
|
19899
|
-
}
|
|
19900
20040
|
function decodeRecording(frames, eccPrivateKeyHex) {
|
|
19901
20041
|
const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
|
|
19902
20042
|
if (eccPrivateKey.length !== 32)
|
|
@@ -19925,12 +20065,9 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
19925
20065
|
}
|
|
19926
20066
|
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
19927
20067
|
const mediaKey = decoder.currentMediaKey;
|
|
19928
|
-
if (!mediaKey || raw
|
|
19929
|
-
continue;
|
|
19930
|
-
const len = raw.readUInt32LE(0);
|
|
19931
|
-
if (raw.length < AUDIO_BODY_START + len)
|
|
20068
|
+
if (!mediaKey || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
19932
20069
|
continue;
|
|
19933
|
-
const aac =
|
|
20070
|
+
const aac = openSealedAudio(raw, mediaKey);
|
|
19934
20071
|
if (aac?.length)
|
|
19935
20072
|
audio.push(aac);
|
|
19936
20073
|
}
|
|
@@ -25243,10 +25380,7 @@ var RtcSession = class extends EventEmitter11 {
|
|
|
25243
25380
|
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
25244
25381
|
});
|
|
25245
25382
|
});
|
|
25246
|
-
this.signaling.on("close", () =>
|
|
25247
|
-
this.connected = false;
|
|
25248
|
-
this.announceClose();
|
|
25249
|
-
});
|
|
25383
|
+
this.signaling.on("close", () => this.shutdown());
|
|
25250
25384
|
this.signaling.on("error", (e) => this.emit("error", e));
|
|
25251
25385
|
this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
|
|
25252
25386
|
this.peer.on("iceGatheringComplete", () => this.trickle(""));
|
|
@@ -29363,6 +29497,12 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29363
29497
|
/** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
|
|
29364
29498
|
* publish await the SAME fully-connected transport instead of racing (and never open two). */
|
|
29365
29499
|
mqttReady = /* @__PURE__ */ new Map();
|
|
29500
|
+
/**
|
|
29501
|
+
* Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
|
|
29502
|
+
* subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
|
|
29503
|
+
* starts empty.
|
|
29504
|
+
*/
|
|
29505
|
+
mqttSubscribed = /* @__PURE__ */ new WeakMap();
|
|
29366
29506
|
/** Device list/record/capability resolution + the frame→caps cache. */
|
|
29367
29507
|
registry;
|
|
29368
29508
|
pushClient;
|
|
@@ -30708,6 +30848,9 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30708
30848
|
* A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
|
|
30709
30849
|
* of its params moved, since each re-resolution is a cloud param-list read.
|
|
30710
30850
|
*
|
|
30851
|
+
* Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
|
|
30852
|
+
* sized it from the roster at login, which is empty when the cloud was unreachable then.
|
|
30853
|
+
*
|
|
30711
30854
|
* Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
|
|
30712
30855
|
* is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
|
|
30713
30856
|
* stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
|
|
@@ -30728,6 +30871,7 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30728
30871
|
this.emit("deviceState", this.stateOf(dev));
|
|
30729
30872
|
for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
|
|
30730
30873
|
await this.widenCapabilities(sn);
|
|
30874
|
+
await this.syncMqttRoster();
|
|
30731
30875
|
} catch (e) {
|
|
30732
30876
|
this.reportError(e);
|
|
30733
30877
|
}
|
|
@@ -31067,12 +31211,21 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31067
31211
|
/**
|
|
31068
31212
|
* Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
|
|
31069
31213
|
* unreachable device must not stop the rest of the roster from coming up).
|
|
31214
|
+
* A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
|
|
31215
|
+
* whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
|
|
31216
|
+
* per transport.
|
|
31070
31217
|
*
|
|
31071
31218
|
* A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
|
|
31072
31219
|
* and the broker refuses its `eufy_home` topics on every credential.
|
|
31073
31220
|
*/
|
|
31074
31221
|
async subscribeMqttDevices(scope, transport) {
|
|
31222
|
+
let subscribed = this.mqttSubscribed.get(transport);
|
|
31223
|
+
if (!subscribed)
|
|
31224
|
+
this.mqttSubscribed.set(transport, subscribed = /* @__PURE__ */ new Set());
|
|
31075
31225
|
for (const d of this.getMqttDevices().filter((dev) => !isTuyaVacuum(dev) && mqttScopeFor(dev) === scope)) {
|
|
31226
|
+
if (subscribed.has(d.sn))
|
|
31227
|
+
continue;
|
|
31228
|
+
subscribed.add(d.sn);
|
|
31076
31229
|
try {
|
|
31077
31230
|
await transport.subscribeDevice(d);
|
|
31078
31231
|
await this.sendRealtimeInit(d.sn);
|
|
@@ -31081,6 +31234,23 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31081
31234
|
}
|
|
31082
31235
|
}
|
|
31083
31236
|
}
|
|
31237
|
+
/**
|
|
31238
|
+
* Start every credential scope the roster now needs and has no transport for, and subscribe the
|
|
31239
|
+
* devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
|
|
31240
|
+
* the realtime generation is superseded or was never started.
|
|
31241
|
+
*/
|
|
31242
|
+
async syncMqttRoster() {
|
|
31243
|
+
if (this.realtimeGeneration?.epoch !== this.realtimeEpoch)
|
|
31244
|
+
return;
|
|
31245
|
+
await Promise.all(this.mqttScopesInUse().map(async (scope) => {
|
|
31246
|
+
const transport = this.transports.get(scope);
|
|
31247
|
+
try {
|
|
31248
|
+
await (transport ? this.subscribeMqttDevices(scope, transport) : this.ensureMqttStarted(scope));
|
|
31249
|
+
} catch (e) {
|
|
31250
|
+
this.reportError(e);
|
|
31251
|
+
}
|
|
31252
|
+
}));
|
|
31253
|
+
}
|
|
31084
31254
|
/**
|
|
31085
31255
|
* Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
|
|
31086
31256
|
* state is pushed on change with no heartbeat, the request that makes its state readable before the
|
|
@@ -32758,6 +32928,7 @@ export {
|
|
|
32758
32928
|
aesKey,
|
|
32759
32929
|
asBool,
|
|
32760
32930
|
assertNever,
|
|
32931
|
+
beginsAccessUnit,
|
|
32761
32932
|
bizChannelName,
|
|
32762
32933
|
buildActions,
|
|
32763
32934
|
buildAppShapedClientId,
|