@mega-yfue/eufy-sdk 0.6.0-beta.15 → 0.6.0-beta.16
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 +215 -45
- 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 +2 -0
- 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.
|
|
@@ -19184,10 +19278,29 @@ function h265Geometry(sps) {
|
|
|
19184
19278
|
}
|
|
19185
19279
|
|
|
19186
19280
|
// dist/transport/p2p/audio-frame.js
|
|
19281
|
+
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19187
19282
|
var CMD_AUDIO_FRAME2 = 1301;
|
|
19188
19283
|
var AUDIO_HEADER_LEN = 16;
|
|
19189
19284
|
var AUDIO_TYPE_OFFSET = 5;
|
|
19190
19285
|
var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
|
|
19286
|
+
var AUDIO_NONCE_START = 32;
|
|
19287
|
+
var AUDIO_BODY_START = 44;
|
|
19288
|
+
function isSealedAudio(frame) {
|
|
19289
|
+
return frame.length >= AUDIO_BODY_START && frame.length >= AUDIO_BODY_START + frame.readUInt32LE(0);
|
|
19290
|
+
}
|
|
19291
|
+
function openSealedAudio(frame, mediaKey) {
|
|
19292
|
+
if (!isSealedAudio(frame))
|
|
19293
|
+
return void 0;
|
|
19294
|
+
try {
|
|
19295
|
+
const decipher = createDecipheriv7("aes-256-gcm", mediaKey, frame.subarray(AUDIO_NONCE_START, AUDIO_BODY_START));
|
|
19296
|
+
decipher.setAAD(VIDEO_GCM_AAD);
|
|
19297
|
+
decipher.setAuthTag(frame.subarray(AUDIO_HEADER_LEN, AUDIO_NONCE_START));
|
|
19298
|
+
const body = frame.subarray(AUDIO_BODY_START, AUDIO_BODY_START + frame.readUInt32LE(0));
|
|
19299
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
19300
|
+
} catch {
|
|
19301
|
+
return void 0;
|
|
19302
|
+
}
|
|
19303
|
+
}
|
|
19191
19304
|
|
|
19192
19305
|
// dist/transport/p2p/live-stream.js
|
|
19193
19306
|
var CMD_VIDEO_FRAME2 = 1300;
|
|
@@ -19219,6 +19332,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19219
19332
|
tracedFirstVideoUnit = false;
|
|
19220
19333
|
tracedFirstKeyframe = false;
|
|
19221
19334
|
tracedDecodeFailures = 0;
|
|
19335
|
+
/** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
|
|
19336
|
+
unitOpenedBy = "plain";
|
|
19222
19337
|
tracedFirstForeignFrame = false;
|
|
19223
19338
|
stallTimer;
|
|
19224
19339
|
/**
|
|
@@ -19241,8 +19356,9 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19241
19356
|
this.channel = opts.channel ?? STATION_CHANNEL3;
|
|
19242
19357
|
if (opts.homeBaseAttached && opts.channel !== void 0)
|
|
19243
19358
|
this.mediaChannel = opts.channel;
|
|
19244
|
-
|
|
19245
|
-
|
|
19359
|
+
const eccPrivateKey = opts.eccPrivateKey ?? (opts.homeBaseAttached ? session.mediaEccPrivateKey : void 0);
|
|
19360
|
+
if (eccPrivateKey)
|
|
19361
|
+
this.decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
19246
19362
|
}
|
|
19247
19363
|
/** Emit a live trace under its session's handle and the channel this stream pulls. */
|
|
19248
19364
|
trace(trace) {
|
|
@@ -19403,8 +19519,10 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19403
19519
|
this.tracedFirstKeyframe = true;
|
|
19404
19520
|
this.trace({ phase: "first-keyframe" });
|
|
19405
19521
|
}
|
|
19406
|
-
if (unit.keyframe)
|
|
19522
|
+
if (unit.keyframe) {
|
|
19407
19523
|
this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
|
|
19524
|
+
this.settleStartKey();
|
|
19525
|
+
}
|
|
19408
19526
|
this.settleKeepalive();
|
|
19409
19527
|
this.emit("video", {
|
|
19410
19528
|
sourceTimestamp: unit.sourceTimestamp,
|
|
@@ -19416,8 +19534,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19416
19534
|
});
|
|
19417
19535
|
}
|
|
19418
19536
|
} else if (f.commandId === CMD_AUDIO_FRAME2) {
|
|
19419
|
-
const audio =
|
|
19420
|
-
if (audio
|
|
19537
|
+
const audio = this.audioPayload(f.data);
|
|
19538
|
+
if (audio?.length) {
|
|
19421
19539
|
const codecId = f.data.length > AUDIO_TYPE_OFFSET ? f.data.readUInt8(AUDIO_TYPE_OFFSET) : void 0;
|
|
19422
19540
|
const codec = codecId === void 0 ? void 0 : AUDIO_CODECS[codecId];
|
|
19423
19541
|
if (!codec) {
|
|
@@ -19468,19 +19586,54 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19468
19586
|
return false;
|
|
19469
19587
|
}
|
|
19470
19588
|
/**
|
|
19471
|
-
* The Annex-B payload of a frame that starts an access unit. The
|
|
19472
|
-
*
|
|
19473
|
-
*
|
|
19589
|
+
* The Annex-B payload of a frame that starts an access unit. The authenticated ECIES/GCM
|
|
19590
|
+
* {@link VideoFrameDecoder} goes first: its HMAC and GCM checks fail closed on a frame not sealed for its
|
|
19591
|
+
* key. The session's RSA path cannot: on Node ≥ 24.5, PKCS#1 v1.5 decryption of a key not wrapped for it
|
|
19592
|
+
* returns pseudo-random bytes instead of throwing (implicit rejection), which would decode an ECC
|
|
19593
|
+
* keyframe into noise. The session's decode, which also passes plaintext frames, and the header strip
|
|
19594
|
+
* follow. A payload that opens an access unit records how it was read: by the ECIES path, or otherwise by
|
|
19595
|
+
* its sign code, sealed (`"rsa"`) or in the clear (`"plain"`).
|
|
19474
19596
|
*/
|
|
19475
19597
|
annexbOf(payload, signCode) {
|
|
19476
|
-
const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
|
|
19477
|
-
if (annexb)
|
|
19478
|
-
return annexb;
|
|
19479
19598
|
const decoded = this.decoder?.decodeFrame(payload)?.h264;
|
|
19480
|
-
if (
|
|
19599
|
+
if (decoded) {
|
|
19600
|
+
if (beginsAccessUnit(decoded))
|
|
19601
|
+
this.unitOpenedBy = "ecc";
|
|
19602
|
+
return decoded;
|
|
19603
|
+
}
|
|
19604
|
+
const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
|
|
19605
|
+
if (!annexb && this.tracedDecodeFailures++ < MAX_TRACED_DECODE_FAILURES) {
|
|
19481
19606
|
this.trace({ phase: "video-decode-empty", signCode });
|
|
19482
19607
|
}
|
|
19483
|
-
|
|
19608
|
+
if (annexb && beginsAccessUnit(annexb))
|
|
19609
|
+
this.unitOpenedBy = signCode > 0 ? "rsa" : "plain";
|
|
19610
|
+
return annexb;
|
|
19611
|
+
}
|
|
19612
|
+
/**
|
|
19613
|
+
* Tell the session how this keyframe was opened, which answers the attached starts on this channel
|
|
19614
|
+
* ({@link P2PSession.settleAttachedKey}), and re-issue the start when the session asks for one: a plaintext
|
|
19615
|
+
* keyframe answering the session's P-256 offer, from a camera that did not use the point, which the next
|
|
19616
|
+
* start asks with RSA. Only a delivered keyframe answers: a frame no key opens, or a continuation without the
|
|
19617
|
+
* frame that opened its unit, delivers nothing.
|
|
19618
|
+
*/
|
|
19619
|
+
settleStartKey() {
|
|
19620
|
+
if (!this.session.settleAttachedKey?.(this.channel, this.unitOpenedBy))
|
|
19621
|
+
return;
|
|
19622
|
+
this.logger.debug(`[live ch${this.channel}] plaintext keyframe answered the P-256 offer \u2014 re-starting with RSA`);
|
|
19623
|
+
this.sendStart();
|
|
19624
|
+
}
|
|
19625
|
+
/**
|
|
19626
|
+
* The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
|
|
19627
|
+
* opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
|
|
19628
|
+
* before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
|
|
19629
|
+
* its header.
|
|
19630
|
+
*/
|
|
19631
|
+
audioPayload(frame) {
|
|
19632
|
+
if (!this.decoder || !isSealedAudio(frame)) {
|
|
19633
|
+
return frame.length > AUDIO_HEADER_LEN ? frame.subarray(AUDIO_HEADER_LEN) : frame;
|
|
19634
|
+
}
|
|
19635
|
+
const mediaKey = this.decoder.currentMediaKey;
|
|
19636
|
+
return mediaKey ? openSealedAudio(frame, mediaKey) : void 0;
|
|
19484
19637
|
}
|
|
19485
19638
|
/**
|
|
19486
19639
|
* Report an access unit the transport could not complete.
|
|
@@ -19770,12 +19923,8 @@ function annexbToJpeg(annexb, spawn2) {
|
|
|
19770
19923
|
}
|
|
19771
19924
|
|
|
19772
19925
|
// dist/transport/p2p/recording-download.js
|
|
19773
|
-
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19774
19926
|
var RECORDING_DATA_TYPE = 3;
|
|
19775
19927
|
var VIDEO_HEADER_LEN2 = 22;
|
|
19776
|
-
var AUDIO_HEADER_LEN2 = 16;
|
|
19777
|
-
var AUDIO_NONCE_START = 32;
|
|
19778
|
-
var AUDIO_BODY_START = 44;
|
|
19779
19928
|
var AUDIO_CODEC_AAC_LC = 0;
|
|
19780
19929
|
var FINISH_CHANNEL = 0;
|
|
19781
19930
|
var FIRST_FRAME_WAIT_MS = 2e4;
|
|
@@ -19887,16 +20036,6 @@ function receiveRecording(session, request) {
|
|
|
19887
20036
|
}
|
|
19888
20037
|
return { frames, drained };
|
|
19889
20038
|
}
|
|
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
20039
|
function decodeRecording(frames, eccPrivateKeyHex) {
|
|
19901
20040
|
const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
|
|
19902
20041
|
if (eccPrivateKey.length !== 32)
|
|
@@ -19925,12 +20064,9 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
19925
20064
|
}
|
|
19926
20065
|
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
19927
20066
|
const mediaKey = decoder.currentMediaKey;
|
|
19928
|
-
if (!mediaKey || raw
|
|
19929
|
-
continue;
|
|
19930
|
-
const len = raw.readUInt32LE(0);
|
|
19931
|
-
if (raw.length < AUDIO_BODY_START + len)
|
|
20067
|
+
if (!mediaKey || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
19932
20068
|
continue;
|
|
19933
|
-
const aac =
|
|
20069
|
+
const aac = openSealedAudio(raw, mediaKey);
|
|
19934
20070
|
if (aac?.length)
|
|
19935
20071
|
audio.push(aac);
|
|
19936
20072
|
}
|
|
@@ -25243,10 +25379,7 @@ var RtcSession = class extends EventEmitter11 {
|
|
|
25243
25379
|
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
25244
25380
|
});
|
|
25245
25381
|
});
|
|
25246
|
-
this.signaling.on("close", () =>
|
|
25247
|
-
this.connected = false;
|
|
25248
|
-
this.announceClose();
|
|
25249
|
-
});
|
|
25382
|
+
this.signaling.on("close", () => this.shutdown());
|
|
25250
25383
|
this.signaling.on("error", (e) => this.emit("error", e));
|
|
25251
25384
|
this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
|
|
25252
25385
|
this.peer.on("iceGatheringComplete", () => this.trickle(""));
|
|
@@ -29363,6 +29496,12 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29363
29496
|
/** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
|
|
29364
29497
|
* publish await the SAME fully-connected transport instead of racing (and never open two). */
|
|
29365
29498
|
mqttReady = /* @__PURE__ */ new Map();
|
|
29499
|
+
/**
|
|
29500
|
+
* Serials {@link subscribeMqttDevices} has subscribed or is subscribing, per transport, so a later pass
|
|
29501
|
+
* subscribes only devices that transport has not carried yet. Keyed by transport, so a new connection
|
|
29502
|
+
* starts empty.
|
|
29503
|
+
*/
|
|
29504
|
+
mqttSubscribed = /* @__PURE__ */ new WeakMap();
|
|
29366
29505
|
/** Device list/record/capability resolution + the frame→caps cache. */
|
|
29367
29506
|
registry;
|
|
29368
29507
|
pushClient;
|
|
@@ -30708,6 +30847,9 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30708
30847
|
* A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
|
|
30709
30848
|
* of its params moved, since each re-resolution is a cloud param-list read.
|
|
30710
30849
|
*
|
|
30850
|
+
* Then brings the secure-MQTT plane up to the roster ({@link syncMqttRoster}): the startup bring-up
|
|
30851
|
+
* sized it from the roster at login, which is empty when the cloud was unreachable then.
|
|
30852
|
+
*
|
|
30711
30853
|
* Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
|
|
30712
30854
|
* is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
|
|
30713
30855
|
* stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
|
|
@@ -30728,6 +30870,7 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30728
30870
|
this.emit("deviceState", this.stateOf(dev));
|
|
30729
30871
|
for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
|
|
30730
30872
|
await this.widenCapabilities(sn);
|
|
30873
|
+
await this.syncMqttRoster();
|
|
30731
30874
|
} catch (e) {
|
|
30732
30875
|
this.reportError(e);
|
|
30733
30876
|
}
|
|
@@ -31067,12 +31210,21 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31067
31210
|
/**
|
|
31068
31211
|
* Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
|
|
31069
31212
|
* unreachable device must not stop the rest of the roster from coming up).
|
|
31213
|
+
* A device is recorded in that transport's {@link mqttSubscribed} entry before its subscribe is awaited,
|
|
31214
|
+
* whatever the outcome, so a pass running alongside or after skips it: each device is attempted once
|
|
31215
|
+
* per transport.
|
|
31070
31216
|
*
|
|
31071
31217
|
* A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
|
|
31072
31218
|
* and the broker refuses its `eufy_home` topics on every credential.
|
|
31073
31219
|
*/
|
|
31074
31220
|
async subscribeMqttDevices(scope, transport) {
|
|
31221
|
+
let subscribed = this.mqttSubscribed.get(transport);
|
|
31222
|
+
if (!subscribed)
|
|
31223
|
+
this.mqttSubscribed.set(transport, subscribed = /* @__PURE__ */ new Set());
|
|
31075
31224
|
for (const d of this.getMqttDevices().filter((dev) => !isTuyaVacuum(dev) && mqttScopeFor(dev) === scope)) {
|
|
31225
|
+
if (subscribed.has(d.sn))
|
|
31226
|
+
continue;
|
|
31227
|
+
subscribed.add(d.sn);
|
|
31076
31228
|
try {
|
|
31077
31229
|
await transport.subscribeDevice(d);
|
|
31078
31230
|
await this.sendRealtimeInit(d.sn);
|
|
@@ -31081,6 +31233,23 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31081
31233
|
}
|
|
31082
31234
|
}
|
|
31083
31235
|
}
|
|
31236
|
+
/**
|
|
31237
|
+
* Start every credential scope the roster now needs and has no transport for, and subscribe the
|
|
31238
|
+
* devices that joined a scope already up — each scope's failure reported, not thrown. A no-op once
|
|
31239
|
+
* the realtime generation is superseded or was never started.
|
|
31240
|
+
*/
|
|
31241
|
+
async syncMqttRoster() {
|
|
31242
|
+
if (this.realtimeGeneration?.epoch !== this.realtimeEpoch)
|
|
31243
|
+
return;
|
|
31244
|
+
await Promise.all(this.mqttScopesInUse().map(async (scope) => {
|
|
31245
|
+
const transport = this.transports.get(scope);
|
|
31246
|
+
try {
|
|
31247
|
+
await (transport ? this.subscribeMqttDevices(scope, transport) : this.ensureMqttStarted(scope));
|
|
31248
|
+
} catch (e) {
|
|
31249
|
+
this.reportError(e);
|
|
31250
|
+
}
|
|
31251
|
+
}));
|
|
31252
|
+
}
|
|
31084
31253
|
/**
|
|
31085
31254
|
* Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
|
|
31086
31255
|
* state is pushed on change with no heartbeat, the request that makes its state readable before the
|
|
@@ -32758,6 +32927,7 @@ export {
|
|
|
32758
32927
|
aesKey,
|
|
32759
32928
|
asBool,
|
|
32760
32929
|
assertNever,
|
|
32930
|
+
beginsAccessUnit,
|
|
32761
32931
|
bizChannelName,
|
|
32762
32932
|
buildActions,
|
|
32763
32933
|
buildAppShapedClientId,
|