@mega-yfue/eufy-sdk 0.6.0-beta.14 → 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 +60 -8
- package/dist/client/types.d.ts +3 -3
- package/dist/core/contracts.d.ts +3 -1
- package/dist/core/store.d.ts +12 -1
- package/dist/index.js +704 -259
- package/dist/index.js.map +4 -4
- package/dist/transport/http/mega-client.d.ts +3 -2
- package/dist/transport/p2p/audio-frame.d.ts +13 -0
- package/dist/transport/p2p/codec.d.ts +2 -2
- package/dist/transport/p2p/command-router.d.ts +17 -0
- package/dist/transport/p2p/database-wire.d.ts +30 -0
- package/dist/transport/p2p/live-stream.d.ts +33 -7
- package/dist/transport/p2p/live-trace.d.ts +6 -4
- package/dist/transport/p2p/p2p-session.d.ts +80 -16
- package/dist/transport/p2p/readable-egress.d.ts +3 -1
- package/dist/transport/p2p/recording-download.d.ts +22 -0
- package/dist/transport/p2p/shared-live-source.d.ts +2 -1
- package/dist/transport/p2p/video.d.ts +2 -0
- package/dist/transport/push/store.d.ts +7 -6
- package/dist/transport/rtc/command-router.d.ts +63 -3
- package/dist/transport/rtc/portal-packet.d.ts +3 -13
- package/dist/transport/rtc/session.d.ts +4 -2
- package/dist/transport/stored-image-cache.d.ts +14 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -106,8 +106,9 @@ function encryptLoginPassword(password) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
// dist/core/store.js
|
|
109
|
-
import {
|
|
110
|
-
import {
|
|
109
|
+
import { randomUUID } from "node:crypto";
|
|
110
|
+
import { lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
111
|
+
import { dirname, resolve } from "node:path";
|
|
111
112
|
var MemorySessionStore = class {
|
|
112
113
|
s = null;
|
|
113
114
|
load() {
|
|
@@ -120,6 +121,22 @@ var MemorySessionStore = class {
|
|
|
120
121
|
this.s = null;
|
|
121
122
|
}
|
|
122
123
|
};
|
|
124
|
+
var IN_PLACE_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "EBUSY", "EXDEV", "EROFS"]);
|
|
125
|
+
function realTarget(path) {
|
|
126
|
+
try {
|
|
127
|
+
return realpathSync(path);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
if (e.code !== "ENOENT")
|
|
130
|
+
throw e;
|
|
131
|
+
try {
|
|
132
|
+
if (!lstatSync(path).isSymbolicLink())
|
|
133
|
+
return path;
|
|
134
|
+
} catch {
|
|
135
|
+
return path;
|
|
136
|
+
}
|
|
137
|
+
return realTarget(resolve(dirname(path), readlinkSync(path)));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
123
140
|
var FileSessionStore = class {
|
|
124
141
|
path;
|
|
125
142
|
constructor(path) {
|
|
@@ -133,11 +150,22 @@ var FileSessionStore = class {
|
|
|
133
150
|
}
|
|
134
151
|
}
|
|
135
152
|
save(s) {
|
|
153
|
+
const target = realTarget(this.path);
|
|
136
154
|
try {
|
|
137
|
-
mkdirSync(dirname(
|
|
155
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
138
156
|
} catch {
|
|
139
157
|
}
|
|
140
|
-
|
|
158
|
+
const data = JSON.stringify(s, null, 2);
|
|
159
|
+
const tmp = `${target}.${randomUUID()}.tmp`;
|
|
160
|
+
try {
|
|
161
|
+
writeFileSync(tmp, data, { mode: 384, flag: "wx" });
|
|
162
|
+
renameSync(tmp, target);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
rmSync(tmp, { force: true });
|
|
165
|
+
if (!IN_PLACE_CODES.has(e.code ?? ""))
|
|
166
|
+
throw e;
|
|
167
|
+
writeFileSync(target, data, { mode: 384 });
|
|
168
|
+
}
|
|
141
169
|
}
|
|
142
170
|
clear() {
|
|
143
171
|
try {
|
|
@@ -1973,22 +2001,22 @@ ${this.auth_?.authToken ?? ""}`;
|
|
|
1973
2001
|
return this.postSigned(`app-${service}-${this.region}.eufy.com`, path, body, true, {}, headerOverrides);
|
|
1974
2002
|
}
|
|
1975
2003
|
/**
|
|
1976
|
-
* Fetch the per-station DSK key
|
|
2004
|
+
* Fetch the per-station DSK key using each station's reported cloud category. Mirrors
|
|
1977
2005
|
* the legacy `get_dsk_keys` body shape on the mega devicerelation service.
|
|
1978
2006
|
* The P2P *local* lookup path does not need this; only the cloud path does.
|
|
1979
2007
|
*/
|
|
1980
|
-
async getDskKeys(
|
|
2008
|
+
async getDskKeys(stations, priorDsks = {}) {
|
|
1981
2009
|
const invalid = {};
|
|
1982
2010
|
const deviceDsks = [];
|
|
1983
|
-
for (const sn of
|
|
2011
|
+
for (const { sn, category } of stations) {
|
|
1984
2012
|
const prev = priorDsks[sn] ?? "";
|
|
1985
2013
|
invalid[sn] = prev;
|
|
1986
|
-
deviceDsks.push({ invalid_dsk: prev, device_sn: sn, category: "eufy_security" });
|
|
2014
|
+
deviceDsks.push({ invalid_dsk: prev, device_sn: sn, category: category || "eufy_security" });
|
|
1987
2015
|
}
|
|
1988
2016
|
const res = await this.post("devicerelation", "/app/devicerelation/get_dsk_keys", {
|
|
1989
2017
|
device_dsks: deviceDsks,
|
|
1990
2018
|
invalid_dsks: invalid,
|
|
1991
|
-
station_sns:
|
|
2019
|
+
station_sns: stations.map(({ sn }) => sn)
|
|
1992
2020
|
});
|
|
1993
2021
|
const out = {};
|
|
1994
2022
|
for (const k of res.device_dsks ?? [])
|
|
@@ -2665,7 +2693,7 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
2665
2693
|
const port = c.endpoint_port ?? 8883;
|
|
2666
2694
|
const pinned = this.o.instanceIp;
|
|
2667
2695
|
const reconnectPeriod = this.o.reconnectPeriod ?? 5e3;
|
|
2668
|
-
return await new Promise((
|
|
2696
|
+
return await new Promise((resolve2, reject) => {
|
|
2669
2697
|
const client = pinned ? mqtt.connect({
|
|
2670
2698
|
host: pinned,
|
|
2671
2699
|
port,
|
|
@@ -2697,7 +2725,7 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
2697
2725
|
client.on("connect", () => {
|
|
2698
2726
|
if (!settled) {
|
|
2699
2727
|
settled = true;
|
|
2700
|
-
|
|
2728
|
+
resolve2();
|
|
2701
2729
|
}
|
|
2702
2730
|
this.emit("connect");
|
|
2703
2731
|
});
|
|
@@ -2840,7 +2868,7 @@ function parseStateInfoSignal(topic, raw) {
|
|
|
2840
2868
|
}
|
|
2841
2869
|
|
|
2842
2870
|
// dist/transport/mqtt/dp-codec.js
|
|
2843
|
-
import { randomUUID } from "node:crypto";
|
|
2871
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2844
2872
|
var DP_MAGIC = [255, 9];
|
|
2845
2873
|
var DP_HEADER_LEN = 9;
|
|
2846
2874
|
function xorChecksum(buf) {
|
|
@@ -2878,7 +2906,7 @@ function buildDpEnvelope(opts) {
|
|
|
2878
2906
|
};
|
|
2879
2907
|
const head = {
|
|
2880
2908
|
version: "1.0.0.1",
|
|
2881
|
-
client_id: `ios-eufy_mega-${opts.accountId}-${
|
|
2909
|
+
client_id: `ios-eufy_mega-${opts.accountId}-${randomUUID2()}`,
|
|
2882
2910
|
sess_id: "0000",
|
|
2883
2911
|
msg_seq: 1,
|
|
2884
2912
|
seed: "",
|
|
@@ -15560,7 +15588,7 @@ function readAddress(buf, offset) {
|
|
|
15560
15588
|
};
|
|
15561
15589
|
}
|
|
15562
15590
|
function parseLookupAddr(msg) {
|
|
15563
|
-
return readAddress(msg, 4);
|
|
15591
|
+
return msg.length >= 12 ? readAddress(msg, 4) : void 0;
|
|
15564
15592
|
}
|
|
15565
15593
|
function parseRelayList(msg) {
|
|
15566
15594
|
const out = [];
|
|
@@ -16198,7 +16226,7 @@ import { EventEmitter as EventEmitter2 } from "node:events";
|
|
|
16198
16226
|
import dgram from "node:dgram";
|
|
16199
16227
|
import { resolve4 } from "node:dns/promises";
|
|
16200
16228
|
import { isIP as isIP2 } from "node:net";
|
|
16201
|
-
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";
|
|
16202
16230
|
|
|
16203
16231
|
// dist/transport/p2p/live-trace.js
|
|
16204
16232
|
var LIVE_TRACE_MESSAGE = "[live] start trace";
|
|
@@ -16209,6 +16237,27 @@ function traceLiveStart(logger, trace, source) {
|
|
|
16209
16237
|
// dist/transport/p2p/level2-settle.js
|
|
16210
16238
|
var LEVEL2_SETTLE_MS = 8e3;
|
|
16211
16239
|
|
|
16240
|
+
// dist/transport/p2p/database-wire.js
|
|
16241
|
+
var CMD_DATABASE_IMAGE = 1308;
|
|
16242
|
+
var CMD_DATABASE = 1306;
|
|
16243
|
+
var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
|
|
16244
|
+
var DB_TABLE_TIMEOUT_MS = 15e3;
|
|
16245
|
+
function fullTableQuery(count2 = 2e3) {
|
|
16246
|
+
return {
|
|
16247
|
+
count: count2,
|
|
16248
|
+
start_date: "",
|
|
16249
|
+
end_date: "",
|
|
16250
|
+
start_id: 0,
|
|
16251
|
+
end_id: 1,
|
|
16252
|
+
flag: 0,
|
|
16253
|
+
need_ai: 1,
|
|
16254
|
+
res_unzip: 1,
|
|
16255
|
+
update_time: "0",
|
|
16256
|
+
start_time: "0",
|
|
16257
|
+
alarm_id: ""
|
|
16258
|
+
};
|
|
16259
|
+
}
|
|
16260
|
+
|
|
16212
16261
|
// dist/transport/p2p/p2p-session.js
|
|
16213
16262
|
var LOCAL_LOOKUP_PORT = 32108;
|
|
16214
16263
|
var HEARTBEAT_MS = 5e3;
|
|
@@ -16262,9 +16311,6 @@ var CMD_SET_PAYLOAD = 1350;
|
|
|
16262
16311
|
var CMD_NOTIFY_PAYLOAD = 1351;
|
|
16263
16312
|
var PADDED_RESULT_BYTES = 132;
|
|
16264
16313
|
var CMD_CAMERA_INFO = 1103;
|
|
16265
|
-
var CMD_DATABASE_IMAGE = 1308;
|
|
16266
|
-
var CMD_DATABASE = 1306;
|
|
16267
|
-
var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
|
|
16268
16314
|
var GCM_AAD = Buffer.from("eufy security");
|
|
16269
16315
|
var LEVEL2_SEQ_BASE = 16909056;
|
|
16270
16316
|
function paramReport(payload) {
|
|
@@ -16281,7 +16327,6 @@ function paramReport(payload) {
|
|
|
16281
16327
|
return Object.keys(out).length ? out : void 0;
|
|
16282
16328
|
}
|
|
16283
16329
|
var traceSequence = 0;
|
|
16284
|
-
var DB_TABLE_TIMEOUT_MS = 15e3;
|
|
16285
16330
|
function firstTableRows(text2) {
|
|
16286
16331
|
const decoded = Buffer.from(text2, "latin1").toString("utf8");
|
|
16287
16332
|
for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
|
|
@@ -16408,11 +16453,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16408
16453
|
level2Seq = 0;
|
|
16409
16454
|
rsaPrivateKey;
|
|
16410
16455
|
rsaModulusHex;
|
|
16411
|
-
/**
|
|
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();
|
|
16463
|
+
/**
|
|
16464
|
+
* Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
|
|
16465
|
+
* settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
|
|
16466
|
+
*/
|
|
16412
16467
|
level2Negotiating = false;
|
|
16413
16468
|
/**
|
|
16414
16469
|
* Whether waiting for a level-2 key can still change the answer: `false` once one has been negotiated,
|
|
16415
|
-
* once
|
|
16470
|
+
* once a negotiation concluded without one, and from the start when nothing can negotiate one.
|
|
16416
16471
|
*/
|
|
16417
16472
|
level2Pending;
|
|
16418
16473
|
/** Waiters parked in {@link awaitLevel2Key}, woken the moment the negotiation settles either way. */
|
|
@@ -16517,7 +16572,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16517
16572
|
* The session grace runs from connect, when the station is prompted for `CMD_GATEWAYINFO`. A negotiation
|
|
16518
16573
|
* beginning later does not restart it: best-effort media can proceed without the key, and restarting
|
|
16519
16574
|
* would charge another grace to a source that may already be streaming. A session whose negotiation has
|
|
16520
|
-
* settled answers without waiting at all
|
|
16575
|
+
* settled answers without waiting at all: the answer stands until {@link repromptLevel2Key} re-opens the
|
|
16576
|
+
* wait, since the station is prompted for `CMD_GATEWAYINFO` only on connect and on that re-prompt.
|
|
16521
16577
|
*
|
|
16522
16578
|
* An own-session camera whose grace expires here is NOT thereby a camera that will fail to stream. Measured
|
|
16523
16579
|
* on one account: own-session cameras of two device types negotiated a key, three others never did, and
|
|
@@ -16550,7 +16606,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16550
16606
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
|
|
16551
16607
|
this.trace({ phase: "level2-wait", waitMs: remaining });
|
|
16552
16608
|
const waiters = this.level2Waiters;
|
|
16553
|
-
const outcome = await new Promise((
|
|
16609
|
+
const outcome = await new Promise((resolve2) => {
|
|
16554
16610
|
let deadline;
|
|
16555
16611
|
let settled = false;
|
|
16556
16612
|
const finish = (result) => {
|
|
@@ -16561,7 +16617,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16561
16617
|
const at = waiters.indexOf(wake);
|
|
16562
16618
|
if (at >= 0)
|
|
16563
16619
|
waiters.splice(at, 1);
|
|
16564
|
-
|
|
16620
|
+
resolve2(result);
|
|
16565
16621
|
};
|
|
16566
16622
|
const wake = (result) => finish(result);
|
|
16567
16623
|
deadline = setTimeout(() => finish("timeout"), remaining);
|
|
@@ -16583,12 +16639,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16583
16639
|
/**
|
|
16584
16640
|
* Ask the station for its gateway info a second time, re-opening a negotiation that concluded without a key.
|
|
16585
16641
|
*
|
|
16586
|
-
*
|
|
16587
|
-
*
|
|
16642
|
+
* Without this the station is prompted once on connect, and a negotiation that settles without a key
|
|
16643
|
+
* leaves {@link awaitLevel2Key} answering `false` at once for the rest of that connection. That is the
|
|
16588
16644
|
* right answer for best-effort media, which proceeds at level-1 — but an operation whose ONLY wire is
|
|
16589
16645
|
* level-2 is then refused for the whole life of that connection, while a fresh session over the same
|
|
16590
16646
|
* device negotiates a key normally. Measured: a session that had settled refused every such operation
|
|
16591
|
-
* until it was rebuilt, at which point the station answered with a cipher id straight away.
|
|
16647
|
+
* until it was rebuilt, at which point the station answered with a cipher id straight away. The reply to
|
|
16648
|
+
* this ask runs the negotiation again and settles the re-opened wait either way.
|
|
16592
16649
|
*
|
|
16593
16650
|
* Bounded to one extra ask per connection, so a burst of such operations cannot turn a silent station into a
|
|
16594
16651
|
* flood, and answers whether it asked — `false` when a key is already held, when nothing can negotiate one,
|
|
@@ -16609,6 +16666,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16609
16666
|
/** Record that the level-2 negotiation has finished, with or without a key, and wake every waiter. */
|
|
16610
16667
|
settleLevel2(reason = "terminal") {
|
|
16611
16668
|
this.level2Pending = false;
|
|
16669
|
+
this.level2Negotiating = false;
|
|
16612
16670
|
const outcome = this.level2Key ? "key" : reason;
|
|
16613
16671
|
for (const wake of this.level2Waiters.splice(0))
|
|
16614
16672
|
wake(outcome);
|
|
@@ -16618,13 +16676,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16618
16676
|
* `cipher_id`, resolve that cipher's private keys once (cloud `get_ciphers`, via the configured
|
|
16619
16677
|
* `resolveCipherKey`), run the ECIES unwrap with the ECC key, and `setLevel2Key()`. A cipher with no
|
|
16620
16678
|
* ECC key uses its RSA key instead and holds the RSA-decrypted 16-byte key as the AES-128-ECB level-2
|
|
16621
|
-
* key.
|
|
16679
|
+
* key. Emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
|
|
16622
16680
|
* `level2-set-payload-only` instead for the AES-128-ECB key, and emits `error` when the ECC unwrap
|
|
16623
16681
|
* fails; an RSA unwrap failure is only traced (non-fatal either way — level-1 traffic keeps working).
|
|
16624
16682
|
*
|
|
16625
|
-
* Every outcome settles the wait in {@link awaitLevel2Key},
|
|
16626
|
-
*
|
|
16627
|
-
*
|
|
16683
|
+
* Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
|
|
16684
|
+
* left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
|
|
16685
|
+
* GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
|
|
16686
|
+
* generation ended settles nothing.
|
|
16628
16687
|
*/
|
|
16629
16688
|
negotiateLevel2Key(gwPayload) {
|
|
16630
16689
|
this.level2Negotiating = true;
|
|
@@ -16726,7 +16785,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16726
16785
|
const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
|
|
16727
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}.`);
|
|
16728
16787
|
}
|
|
16729
|
-
/**
|
|
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
|
+
*/
|
|
16730
16797
|
async connect() {
|
|
16731
16798
|
if (this.connecting || this.connected)
|
|
16732
16799
|
return;
|
|
@@ -16734,6 +16801,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16734
16801
|
this.closed = false;
|
|
16735
16802
|
this.connectionGeneration += 1;
|
|
16736
16803
|
this.ackedRounds = void 0;
|
|
16804
|
+
this.attachedStartKeys.clear();
|
|
16737
16805
|
this.resetInboundSequencing();
|
|
16738
16806
|
if (!this.level2KeyServes("set-payload")) {
|
|
16739
16807
|
this.level2Pending = this.cfg.resolveCipherKey !== void 0;
|
|
@@ -16746,16 +16814,25 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16746
16814
|
this.socket = socket;
|
|
16747
16815
|
socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, socket));
|
|
16748
16816
|
socket.on("error", (e) => this.emit("error", e));
|
|
16749
|
-
|
|
16817
|
+
const generation = this.connectionGeneration;
|
|
16818
|
+
await new Promise((resolve2, reject) => {
|
|
16819
|
+
socket.once("close", resolve2);
|
|
16820
|
+
socket.once("error", reject);
|
|
16750
16821
|
socket.bind(() => {
|
|
16822
|
+
socket.off("error", reject);
|
|
16751
16823
|
try {
|
|
16752
16824
|
socket.setBroadcast(true);
|
|
16753
16825
|
} catch {
|
|
16754
16826
|
}
|
|
16755
16827
|
this.requestReceiveBuffer(socket);
|
|
16756
|
-
|
|
16828
|
+
resolve2();
|
|
16757
16829
|
});
|
|
16830
|
+
}).catch(async (error) => {
|
|
16831
|
+
await this.close();
|
|
16832
|
+
throw new StationUnreachableError(this.cfg.stationSn, 0, { cause: error });
|
|
16758
16833
|
});
|
|
16834
|
+
if (generation !== this.connectionGeneration)
|
|
16835
|
+
return;
|
|
16759
16836
|
void _P2PSession.detectLocalIp().then((host) => {
|
|
16760
16837
|
if (host && !this.closed)
|
|
16761
16838
|
this.selfHost = host;
|
|
@@ -16763,7 +16840,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16763
16840
|
const { dskKey, cloudAddresses } = this.cfg;
|
|
16764
16841
|
this.cloudLookup = dskKey && cloudAddresses?.length ? { key: dskKey, addresses: cloudAddresses } : void 0;
|
|
16765
16842
|
if (this.cloudLookup && cloudAddresses) {
|
|
16766
|
-
const generation = this.connectionGeneration;
|
|
16767
16843
|
void resolveLookupServers(cloudAddresses).then((addresses) => {
|
|
16768
16844
|
if (this.cloudLookup && !this.closed && generation === this.connectionGeneration)
|
|
16769
16845
|
this.cloudLookup.addresses = addresses;
|
|
@@ -16816,7 +16892,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16816
16892
|
*/
|
|
16817
16893
|
static detectLocalIp() {
|
|
16818
16894
|
if (!_P2PSession.localIpPromise) {
|
|
16819
|
-
_P2PSession.localIpPromise = new Promise((
|
|
16895
|
+
_P2PSession.localIpPromise = new Promise((resolve2) => {
|
|
16820
16896
|
const probe = dgram.createSocket("udp4");
|
|
16821
16897
|
let settled = false;
|
|
16822
16898
|
const finish = (result) => {
|
|
@@ -16828,7 +16904,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16828
16904
|
probe.close();
|
|
16829
16905
|
} catch {
|
|
16830
16906
|
}
|
|
16831
|
-
|
|
16907
|
+
resolve2(result);
|
|
16832
16908
|
};
|
|
16833
16909
|
const timer = setTimeout(() => finish(void 0), 2e3);
|
|
16834
16910
|
probe.connect(53, "8.8.8.8", () => {
|
|
@@ -16980,9 +17056,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16980
17056
|
} else if (hasHeader(msg, ResponseMessageType.LOOKUP_ADDR)) {
|
|
16981
17057
|
if (!this.connected) {
|
|
16982
17058
|
const addr = parseLookupAddr(msg);
|
|
16983
|
-
|
|
16984
|
-
|
|
16985
|
-
|
|
17059
|
+
if (addr) {
|
|
17060
|
+
this.logger.debug(`[p2p] ${this.cfg.stationSn} LOOKUP_ADDR -> ${addr.host}:${addr.port}`);
|
|
17061
|
+
if (addr.host !== "0.0.0.0")
|
|
17062
|
+
this.beginCheckCam(addr, socket);
|
|
17063
|
+
}
|
|
16986
17064
|
}
|
|
16987
17065
|
} else if (hasHeader(msg, ResponseMessageType.RLY_TO)) {
|
|
16988
17066
|
const to = parseRelayTo(msg);
|
|
@@ -17036,7 +17114,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17036
17114
|
probe.on("error", (error) => this.logger.warn("[p2p] lookup socket error", error.message));
|
|
17037
17115
|
probe.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, probe));
|
|
17038
17116
|
this.probeSockets.push(probe);
|
|
17039
|
-
await new Promise((
|
|
17117
|
+
await new Promise((resolve2) => {
|
|
17040
17118
|
let settled = false;
|
|
17041
17119
|
const finish = (bound) => {
|
|
17042
17120
|
if (settled)
|
|
@@ -17051,7 +17129,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17051
17129
|
} catch {
|
|
17052
17130
|
}
|
|
17053
17131
|
}
|
|
17054
|
-
|
|
17132
|
+
resolve2();
|
|
17055
17133
|
};
|
|
17056
17134
|
const onError = () => finish(false);
|
|
17057
17135
|
const onClose = () => finish(false);
|
|
@@ -17129,13 +17207,15 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17129
17207
|
startLiveMedia(channel = STATION_CHANNEL3, accountId = "", homeBaseAttached = false, opts) {
|
|
17130
17208
|
if (homeBaseAttached) {
|
|
17131
17209
|
this.tracedDatagramGaps = 0;
|
|
17210
|
+
const key = this.connectAddress && this.level2Key ? this.attachedStartKey(channel) : void 0;
|
|
17132
17211
|
this.trace({
|
|
17133
17212
|
phase: "media-command",
|
|
17134
17213
|
topology: "attached",
|
|
17135
17214
|
action: "start",
|
|
17136
|
-
level2: !!this.level2Key
|
|
17215
|
+
level2: !!this.level2Key,
|
|
17216
|
+
key
|
|
17137
17217
|
});
|
|
17138
|
-
this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {});
|
|
17218
|
+
this.sendMediaPayloadLevel2(CMD_START_REALTIME_MEDIA, channel, accountId, {}, key);
|
|
17139
17219
|
return;
|
|
17140
17220
|
}
|
|
17141
17221
|
const want = this.level2Key ? "l2" : this.sendsInTheClear() ? "clear" : "l1";
|
|
@@ -17635,8 +17715,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17635
17715
|
* Send a CMD_SET_PAYLOAD(1350) wrapping `{account_id, cmd:<subCmd>, mChannel, mValue3:<subCmd>,
|
|
17636
17716
|
* payload}`, encrypted level-2 (AES-256-GCM, signCode 8) — the app's media-control path. The
|
|
17637
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"`.
|
|
17638
17722
|
*/
|
|
17639
|
-
sendMediaPayloadLevel2(subCmd, channel, accountId, payload) {
|
|
17723
|
+
sendMediaPayloadLevel2(subCmd, channel, accountId, payload, key) {
|
|
17640
17724
|
if (!this.connectAddress || !this.level2Key) {
|
|
17641
17725
|
this.trace({
|
|
17642
17726
|
phase: "media-command-unsent",
|
|
@@ -17646,11 +17730,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17646
17730
|
}
|
|
17647
17731
|
const isStart = subCmd === CMD_START_REALTIME_MEDIA;
|
|
17648
17732
|
const innerPayload = isStart ? {
|
|
17649
|
-
ClientOS: "Android",
|
|
17733
|
+
ClientOS: key === "ecc" ? "IOS" : "Android",
|
|
17650
17734
|
accountId,
|
|
17651
17735
|
camera_type: 0,
|
|
17652
17736
|
entrytype: 0,
|
|
17653
|
-
key: this.rsaModulus(),
|
|
17737
|
+
key: key === "ecc" ? this.mediaEccPair().point : this.rsaModulus(),
|
|
17654
17738
|
streamtype: 2,
|
|
17655
17739
|
...payload
|
|
17656
17740
|
} : payload;
|
|
@@ -17725,6 +17809,70 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17725
17809
|
return void 0;
|
|
17726
17810
|
}
|
|
17727
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
|
+
}
|
|
17728
17876
|
/**
|
|
17729
17877
|
* The RSA-1024 public-key modulus (128-byte hex) the station uses to wrap the per-stream media
|
|
17730
17878
|
* key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
|
|
@@ -17837,25 +17985,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17837
17985
|
databasePayload(value, channel) {
|
|
17838
17986
|
return !this.level2Key && this.sendsInTheClear() ? buildRawCommandPayload(Buffer.from(value, "utf-8"), channel, 0, this.nextClearCommandBytes(), 0) : buildStringCommandPayload(value, channel, this.level1Key, 1);
|
|
17839
17987
|
}
|
|
17840
|
-
/**
|
|
17841
|
-
* Standard "give me the whole table" query params the eufy app uses for a direct
|
|
17842
|
-
* `CMD_DATABASE` read (inner `cmd 10000`). `count` bounds the row count.
|
|
17843
|
-
*/
|
|
17844
|
-
fullTableQuery(count2 = 2e3) {
|
|
17845
|
-
return {
|
|
17846
|
-
count: count2,
|
|
17847
|
-
start_date: "",
|
|
17848
|
-
end_date: "",
|
|
17849
|
-
start_id: 0,
|
|
17850
|
-
end_id: 1,
|
|
17851
|
-
flag: 0,
|
|
17852
|
-
need_ai: 1,
|
|
17853
|
-
res_unzip: 1,
|
|
17854
|
-
update_time: "0",
|
|
17855
|
-
start_time: "0",
|
|
17856
|
-
alarm_id: ""
|
|
17857
|
-
};
|
|
17858
|
-
}
|
|
17859
17988
|
/**
|
|
17860
17989
|
* Request the on-device edge-AI **face roster** over P2P — the phone-free path.
|
|
17861
17990
|
*
|
|
@@ -17872,7 +18001,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17872
18001
|
accountId: opts.accountId,
|
|
17873
18002
|
channel: opts.channel,
|
|
17874
18003
|
innerCmd: DB_QUERY.FULL_TABLE,
|
|
17875
|
-
query:
|
|
18004
|
+
query: fullTableQuery()
|
|
17876
18005
|
});
|
|
17877
18006
|
}
|
|
17878
18007
|
/**
|
|
@@ -17921,11 +18050,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17921
18050
|
this.queryDatabase(table, {
|
|
17922
18051
|
accountId: opts.accountId,
|
|
17923
18052
|
innerCmd: opts.innerCmd,
|
|
17924
|
-
query: opts.query ??
|
|
18053
|
+
query: opts.query ?? fullTableQuery()
|
|
17925
18054
|
});
|
|
17926
18055
|
this.dbReadInFlight = true;
|
|
17927
18056
|
try {
|
|
17928
|
-
return await new Promise((
|
|
18057
|
+
return await new Promise((resolve2, reject) => {
|
|
17929
18058
|
let text2 = "";
|
|
17930
18059
|
const onChunk = (chunk) => {
|
|
17931
18060
|
text2 += chunk.text;
|
|
@@ -17933,7 +18062,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17933
18062
|
if (result?.error !== void 0)
|
|
17934
18063
|
settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
|
|
17935
18064
|
else if (result?.rows)
|
|
17936
|
-
settle(() =>
|
|
18065
|
+
settle(() => resolve2(result.rows));
|
|
17937
18066
|
};
|
|
17938
18067
|
const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
|
|
17939
18068
|
const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
|
|
@@ -17963,7 +18092,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
17963
18092
|
accountId: opts.accountId,
|
|
17964
18093
|
channel: opts.channel,
|
|
17965
18094
|
innerCmd: DB_QUERY.FULL_TABLE,
|
|
17966
|
-
query:
|
|
18095
|
+
query: fullTableQuery()
|
|
17967
18096
|
});
|
|
17968
18097
|
}
|
|
17969
18098
|
/**
|
|
@@ -18027,8 +18156,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
18027
18156
|
*
|
|
18028
18157
|
* Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
|
|
18029
18158
|
* treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
|
|
18159
|
+
*
|
|
18160
|
+
* A datagram shorter than the 8-byte DATA header carries no sequence number and is dropped unacknowledged.
|
|
18030
18161
|
*/
|
|
18031
18162
|
onData(msg, addr) {
|
|
18163
|
+
if (msg.length < 8)
|
|
18164
|
+
return;
|
|
18032
18165
|
const dataTypeBuffer = msg.subarray(4, 6);
|
|
18033
18166
|
const seqNo = msg.subarray(6, 8).readUInt16BE();
|
|
18034
18167
|
const dataType = dataTypeBuffer[1];
|
|
@@ -18194,6 +18327,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
18194
18327
|
return P2PDataTypeHeader.DATA;
|
|
18195
18328
|
}
|
|
18196
18329
|
}
|
|
18330
|
+
/**
|
|
18331
|
+
* Decrypt and dispatch one inbound data frame.
|
|
18332
|
+
*
|
|
18333
|
+
* A `CMD_GATEWAYINFO` (1100) reply carries the ECIES envelope for the level-2 session key. When a
|
|
18334
|
+
* cipher-key resolver is configured, no key that seals a set-payload is held and no negotiation is in
|
|
18335
|
+
* flight, the reply starts one (async, fire-and-forget); a reply landing while one is in flight is ignored.
|
|
18336
|
+
*/
|
|
18197
18337
|
handleFrame(header, payload, dataType) {
|
|
18198
18338
|
let data = payload;
|
|
18199
18339
|
const isMedia = header.commandId === CMD_VIDEO_FRAME || header.commandId === CMD_AUDIO_FRAME;
|
|
@@ -18299,12 +18439,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
18299
18439
|
this.resetInboundSequencing();
|
|
18300
18440
|
const socket = this.socket;
|
|
18301
18441
|
if (socket && this.connectAddress) {
|
|
18302
|
-
await new Promise((
|
|
18442
|
+
await new Promise((resolve2) => this.send(this.connectAddress, RequestMessageType.END, void 0, socket, resolve2));
|
|
18303
18443
|
}
|
|
18304
|
-
await new Promise((
|
|
18444
|
+
await new Promise((resolve2) => {
|
|
18305
18445
|
if (!socket)
|
|
18306
|
-
return
|
|
18307
|
-
socket.close(() =>
|
|
18446
|
+
return resolve2();
|
|
18447
|
+
socket.close(() => resolve2());
|
|
18308
18448
|
});
|
|
18309
18449
|
this.socket = void 0;
|
|
18310
18450
|
this.emit("close");
|
|
@@ -19138,10 +19278,29 @@ function h265Geometry(sps) {
|
|
|
19138
19278
|
}
|
|
19139
19279
|
|
|
19140
19280
|
// dist/transport/p2p/audio-frame.js
|
|
19281
|
+
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19141
19282
|
var CMD_AUDIO_FRAME2 = 1301;
|
|
19142
19283
|
var AUDIO_HEADER_LEN = 16;
|
|
19143
19284
|
var AUDIO_TYPE_OFFSET = 5;
|
|
19144
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
|
+
}
|
|
19145
19304
|
|
|
19146
19305
|
// dist/transport/p2p/live-stream.js
|
|
19147
19306
|
var CMD_VIDEO_FRAME2 = 1300;
|
|
@@ -19173,6 +19332,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19173
19332
|
tracedFirstVideoUnit = false;
|
|
19174
19333
|
tracedFirstKeyframe = false;
|
|
19175
19334
|
tracedDecodeFailures = 0;
|
|
19335
|
+
/** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
|
|
19336
|
+
unitOpenedBy = "plain";
|
|
19176
19337
|
tracedFirstForeignFrame = false;
|
|
19177
19338
|
stallTimer;
|
|
19178
19339
|
/**
|
|
@@ -19195,8 +19356,9 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19195
19356
|
this.channel = opts.channel ?? STATION_CHANNEL3;
|
|
19196
19357
|
if (opts.homeBaseAttached && opts.channel !== void 0)
|
|
19197
19358
|
this.mediaChannel = opts.channel;
|
|
19198
|
-
|
|
19199
|
-
|
|
19359
|
+
const eccPrivateKey = opts.eccPrivateKey ?? (opts.homeBaseAttached ? session.mediaEccPrivateKey : void 0);
|
|
19360
|
+
if (eccPrivateKey)
|
|
19361
|
+
this.decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
19200
19362
|
}
|
|
19201
19363
|
/** Emit a live trace under its session's handle and the channel this stream pulls. */
|
|
19202
19364
|
trace(trace) {
|
|
@@ -19357,8 +19519,10 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19357
19519
|
this.tracedFirstKeyframe = true;
|
|
19358
19520
|
this.trace({ phase: "first-keyframe" });
|
|
19359
19521
|
}
|
|
19360
|
-
if (unit.keyframe)
|
|
19522
|
+
if (unit.keyframe) {
|
|
19361
19523
|
this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
|
|
19524
|
+
this.settleStartKey();
|
|
19525
|
+
}
|
|
19362
19526
|
this.settleKeepalive();
|
|
19363
19527
|
this.emit("video", {
|
|
19364
19528
|
sourceTimestamp: unit.sourceTimestamp,
|
|
@@ -19370,8 +19534,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19370
19534
|
});
|
|
19371
19535
|
}
|
|
19372
19536
|
} else if (f.commandId === CMD_AUDIO_FRAME2) {
|
|
19373
|
-
const audio =
|
|
19374
|
-
if (audio
|
|
19537
|
+
const audio = this.audioPayload(f.data);
|
|
19538
|
+
if (audio?.length) {
|
|
19375
19539
|
const codecId = f.data.length > AUDIO_TYPE_OFFSET ? f.data.readUInt8(AUDIO_TYPE_OFFSET) : void 0;
|
|
19376
19540
|
const codec = codecId === void 0 ? void 0 : AUDIO_CODECS[codecId];
|
|
19377
19541
|
if (!codec) {
|
|
@@ -19422,19 +19586,54 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
19422
19586
|
return false;
|
|
19423
19587
|
}
|
|
19424
19588
|
/**
|
|
19425
|
-
* The Annex-B payload of a frame that starts an access unit. The
|
|
19426
|
-
*
|
|
19427
|
-
*
|
|
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"`).
|
|
19428
19596
|
*/
|
|
19429
19597
|
annexbOf(payload, signCode) {
|
|
19430
|
-
const annexb = this.session.decodeVideoFrame?.(payload, signCode) ?? this.plaintextAnnexB(payload);
|
|
19431
|
-
if (annexb)
|
|
19432
|
-
return annexb;
|
|
19433
19598
|
const decoded = this.decoder?.decodeFrame(payload)?.h264;
|
|
19434
|
-
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) {
|
|
19435
19606
|
this.trace({ phase: "video-decode-empty", signCode });
|
|
19436
19607
|
}
|
|
19437
|
-
|
|
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;
|
|
19438
19637
|
}
|
|
19439
19638
|
/**
|
|
19440
19639
|
* Report an access unit the transport could not complete.
|
|
@@ -19512,7 +19711,7 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
19512
19711
|
const skip = opts.skipKeyframes ?? (source.state === "live" ? 0 : 1);
|
|
19513
19712
|
let burst;
|
|
19514
19713
|
try {
|
|
19515
|
-
burst = await new Promise((
|
|
19714
|
+
burst = await new Promise((resolve2, reject) => {
|
|
19516
19715
|
const bufs = [];
|
|
19517
19716
|
let sets = source.parameterSets;
|
|
19518
19717
|
let codec = "h264";
|
|
@@ -19535,7 +19734,7 @@ async function captureSnapshotFromShared(source, opts = {}) {
|
|
|
19535
19734
|
if (!settle)
|
|
19536
19735
|
settle = setTimeout(() => {
|
|
19537
19736
|
cleanup();
|
|
19538
|
-
|
|
19737
|
+
resolve2({ h264: Buffer.concat(bufs), codec, sets });
|
|
19539
19738
|
}, collectMs);
|
|
19540
19739
|
};
|
|
19541
19740
|
const onError = (err) => {
|
|
@@ -19579,7 +19778,7 @@ async function recordClip(session, seconds, opts = {}) {
|
|
|
19579
19778
|
const stream = await openLiveStream(session, opts);
|
|
19580
19779
|
let h264;
|
|
19581
19780
|
try {
|
|
19582
|
-
h264 = await new Promise((
|
|
19781
|
+
h264 = await new Promise((resolve2, reject) => {
|
|
19583
19782
|
const bufs = [];
|
|
19584
19783
|
let keyCount = 0, capturing = false;
|
|
19585
19784
|
let sets;
|
|
@@ -19592,7 +19791,7 @@ async function recordClip(session, seconds, opts = {}) {
|
|
|
19592
19791
|
};
|
|
19593
19792
|
const collected = () => {
|
|
19594
19793
|
detach();
|
|
19595
|
-
|
|
19794
|
+
resolve2(primeForDecode(Buffer.concat(bufs), sets, codec2));
|
|
19596
19795
|
};
|
|
19597
19796
|
const failed = (reason, cause) => {
|
|
19598
19797
|
detach();
|
|
@@ -19622,7 +19821,7 @@ async function recordClip(session, seconds, opts = {}) {
|
|
|
19622
19821
|
stream.stop();
|
|
19623
19822
|
}
|
|
19624
19823
|
const codec = annexbFfmpegFormat(h264);
|
|
19625
|
-
return new Promise((
|
|
19824
|
+
return new Promise((resolve2, reject) => {
|
|
19626
19825
|
const ff = spawnFfmpeg([
|
|
19627
19826
|
// prettier-ignore
|
|
19628
19827
|
"-f",
|
|
@@ -19645,7 +19844,7 @@ async function recordClip(session, seconds, opts = {}) {
|
|
|
19645
19844
|
ff.on("close", (code) => {
|
|
19646
19845
|
const mp4 = Buffer.concat(out);
|
|
19647
19846
|
if (mp4.length)
|
|
19648
|
-
|
|
19847
|
+
resolve2(mp4);
|
|
19649
19848
|
else
|
|
19650
19849
|
reject(new Error(`ffmpeg mux failed (code ${code}): ${Buffer.concat(err).toString().slice(0, 200)}`));
|
|
19651
19850
|
});
|
|
@@ -19684,7 +19883,7 @@ function jpegGeometry(jpeg) {
|
|
|
19684
19883
|
}
|
|
19685
19884
|
function annexbToJpeg(annexb, spawn2) {
|
|
19686
19885
|
const codec = annexbFfmpegFormat(annexb);
|
|
19687
|
-
return new Promise((
|
|
19886
|
+
return new Promise((resolve2, reject) => {
|
|
19688
19887
|
const ff = spawnFfmpeg([
|
|
19689
19888
|
// prettier-ignore
|
|
19690
19889
|
"-f",
|
|
@@ -19711,7 +19910,7 @@ function annexbToJpeg(annexb, spawn2) {
|
|
|
19711
19910
|
const encoded = jpeg.length >= 3 && jpeg.subarray(0, 3).toString("hex") === "ffd8ff";
|
|
19712
19911
|
const geometry = encoded ? jpegGeometry(jpeg) : void 0;
|
|
19713
19912
|
if (geometry)
|
|
19714
|
-
return
|
|
19913
|
+
return resolve2({ jpeg, ...geometry });
|
|
19715
19914
|
const diagnostics = Buffer.concat(err).toString().slice(0, 200).trim();
|
|
19716
19915
|
const what = code !== 0 ? `ffmpeg exited ${code}` : encoded ? "encoded image declares no frame header" : "no complete frame in the burst";
|
|
19717
19916
|
reject(new LiveSnapshotUnavailableError("undecodable-burst", diagnostics ? `${what}: ${diagnostics}` : what));
|
|
@@ -19724,12 +19923,8 @@ function annexbToJpeg(annexb, spawn2) {
|
|
|
19724
19923
|
}
|
|
19725
19924
|
|
|
19726
19925
|
// dist/transport/p2p/recording-download.js
|
|
19727
|
-
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
19728
19926
|
var RECORDING_DATA_TYPE = 3;
|
|
19729
19927
|
var VIDEO_HEADER_LEN2 = 22;
|
|
19730
|
-
var AUDIO_HEADER_LEN2 = 16;
|
|
19731
|
-
var AUDIO_NONCE_START = 32;
|
|
19732
|
-
var AUDIO_BODY_START = 44;
|
|
19733
19928
|
var AUDIO_CODEC_AAC_LC = 0;
|
|
19734
19929
|
var FINISH_CHANNEL = 0;
|
|
19735
19930
|
var FIRST_FRAME_WAIT_MS = 2e4;
|
|
@@ -19744,12 +19939,12 @@ function homeBase2RecordingPath(channel, recording) {
|
|
|
19744
19939
|
function receiveRecording(session, request) {
|
|
19745
19940
|
let resolveFrames;
|
|
19746
19941
|
let rejectFrames;
|
|
19747
|
-
const frames = new Promise((
|
|
19748
|
-
resolveFrames =
|
|
19942
|
+
const frames = new Promise((resolve2, reject) => {
|
|
19943
|
+
resolveFrames = resolve2;
|
|
19749
19944
|
rejectFrames = reject;
|
|
19750
19945
|
});
|
|
19751
19946
|
let resolveDrained;
|
|
19752
|
-
const drained = new Promise((
|
|
19947
|
+
const drained = new Promise((resolve2) => resolveDrained = resolve2);
|
|
19753
19948
|
const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
|
|
19754
19949
|
let collected = [];
|
|
19755
19950
|
const started = Date.now();
|
|
@@ -19841,16 +20036,6 @@ function receiveRecording(session, request) {
|
|
|
19841
20036
|
}
|
|
19842
20037
|
return { frames, drained };
|
|
19843
20038
|
}
|
|
19844
|
-
function openAudio(key, nonce, tag2, body) {
|
|
19845
|
-
try {
|
|
19846
|
-
const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
|
|
19847
|
-
decipher.setAAD(VIDEO_GCM_AAD);
|
|
19848
|
-
decipher.setAuthTag(tag2);
|
|
19849
|
-
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
19850
|
-
} catch {
|
|
19851
|
-
return void 0;
|
|
19852
|
-
}
|
|
19853
|
-
}
|
|
19854
20039
|
function decodeRecording(frames, eccPrivateKeyHex) {
|
|
19855
20040
|
const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
|
|
19856
20041
|
if (eccPrivateKey.length !== 32)
|
|
@@ -19879,12 +20064,9 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
19879
20064
|
}
|
|
19880
20065
|
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
19881
20066
|
const mediaKey = decoder.currentMediaKey;
|
|
19882
|
-
if (!mediaKey || raw
|
|
20067
|
+
if (!mediaKey || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
19883
20068
|
continue;
|
|
19884
|
-
const
|
|
19885
|
-
if (raw.length < AUDIO_BODY_START + len)
|
|
19886
|
-
continue;
|
|
19887
|
-
const aac = openAudio(mediaKey, raw.subarray(AUDIO_NONCE_START, AUDIO_BODY_START), raw.subarray(AUDIO_HEADER_LEN2, AUDIO_NONCE_START), raw.subarray(AUDIO_BODY_START, AUDIO_BODY_START + len));
|
|
20069
|
+
const aac = openSealedAudio(raw, mediaKey);
|
|
19888
20070
|
if (aac?.length)
|
|
19889
20071
|
audio.push(aac);
|
|
19890
20072
|
}
|
|
@@ -20287,7 +20469,8 @@ var SharedLiveSource = class {
|
|
|
20287
20469
|
*
|
|
20288
20470
|
* The previous stream is dropped rather than stopped through the state machine: it speaks to a session that
|
|
20289
20471
|
* is gone, and its `stop` would be read as an upstream end. Only a warm-up that asked for a replacement
|
|
20290
|
-
* rewarms, so this is inert on a source that is streaming or has already failed.
|
|
20472
|
+
* rewarms, so this is inert on a source that is streaming or has already failed. Each call replaces the
|
|
20473
|
+
* re-issue ticker rather than adding one, so the start goes out once per interval however often it runs.
|
|
20291
20474
|
*/
|
|
20292
20475
|
rewarm() {
|
|
20293
20476
|
if (this.disposed || !this.sessionReplacementAsked || this._state !== "warming")
|
|
@@ -20299,6 +20482,8 @@ var SharedLiveSource = class {
|
|
|
20299
20482
|
this.openStream();
|
|
20300
20483
|
this.warmAttempts++;
|
|
20301
20484
|
this.logger.debug(`${this.tag} warming again on a replacement session (attempt ${this.warmAttempts})`);
|
|
20485
|
+
if (this.warmRetryTimer)
|
|
20486
|
+
clearInterval(this.warmRetryTimer);
|
|
20302
20487
|
this.warmRetryTimer = setInterval(() => this.reissueStart(), this.warmRetryMs);
|
|
20303
20488
|
}
|
|
20304
20489
|
/**
|
|
@@ -21019,6 +21204,7 @@ function openReadableFromConsumer(consumer, opts = {}) {
|
|
|
21019
21204
|
if (!readable.push(value))
|
|
21020
21205
|
consumer.pause();
|
|
21021
21206
|
});
|
|
21207
|
+
consumer.on("budget", (notice) => readable.emit("budget", notice));
|
|
21022
21208
|
consumer.on("stop", () => readable.push(null));
|
|
21023
21209
|
consumer.on("error", (err) => readable.destroy(err));
|
|
21024
21210
|
return readable;
|
|
@@ -21954,7 +22140,7 @@ var FragmentRecording = class extends EventEmitter6 {
|
|
|
21954
22140
|
throw this.failure;
|
|
21955
22141
|
if (this.ended)
|
|
21956
22142
|
return;
|
|
21957
|
-
await new Promise((
|
|
22143
|
+
await new Promise((resolve2) => this.wake = resolve2);
|
|
21958
22144
|
}
|
|
21959
22145
|
} finally {
|
|
21960
22146
|
this.stop();
|
|
@@ -22070,7 +22256,7 @@ var TALKBACK_CODEC_WAIT_MS = 3e3;
|
|
|
22070
22256
|
var OWN_LIVE_SESSION_MODELS = /* @__PURE__ */ new Set(["T8030"]);
|
|
22071
22257
|
var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
|
|
22072
22258
|
function speakerCodec(consumer, talk, timeoutMs) {
|
|
22073
|
-
return new Promise((
|
|
22259
|
+
return new Promise((resolve2) => {
|
|
22074
22260
|
let timer;
|
|
22075
22261
|
const settle = (codec) => {
|
|
22076
22262
|
clearTimeout(timer);
|
|
@@ -22078,7 +22264,7 @@ function speakerCodec(consumer, talk, timeoutMs) {
|
|
|
22078
22264
|
consumer.off("audio", onAudio);
|
|
22079
22265
|
consumer.off("stop", onStop);
|
|
22080
22266
|
talk.off("stop", onStop);
|
|
22081
|
-
|
|
22267
|
+
resolve2(codec);
|
|
22082
22268
|
};
|
|
22083
22269
|
const onVideo = () => {
|
|
22084
22270
|
timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
|
|
@@ -22130,6 +22316,18 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22130
22316
|
* {@link liveSources} is a claim in flight.
|
|
22131
22317
|
*/
|
|
22132
22318
|
liveSessionKeys = /* @__PURE__ */ new Map();
|
|
22319
|
+
/**
|
|
22320
|
+
* The live source build in flight per `${parentSn}:${channel}`, settled once its connection is opened
|
|
22321
|
+
* or refused.
|
|
22322
|
+
*
|
|
22323
|
+
* A build awaits its media session before it can file the source in {@link liveSources}, so a second
|
|
22324
|
+
* request for the same camera in that window finds nothing cached. Building a second source there
|
|
22325
|
+
* overwrites the first, and the orphan's teardown then closes the connection the cached one streams
|
|
22326
|
+
* on. A request that finds a build here waits for it, until its own signal aborts, then resolves its
|
|
22327
|
+
* session again and reuses what the build filed, or builds its own when that build failed. The entry
|
|
22328
|
+
* is removed when the build settles and its promise never rejects, so each wait ends on a later state.
|
|
22329
|
+
*/
|
|
22330
|
+
liveSourceBuilds = /* @__PURE__ */ new Map();
|
|
22133
22331
|
/**
|
|
22134
22332
|
* The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
|
|
22135
22333
|
* time and the session carries one audio sequence, so this path is exclusive where a live pull is
|
|
@@ -22309,17 +22507,30 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22309
22507
|
* so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
|
|
22310
22508
|
* shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
|
|
22311
22509
|
* carry the start is not a connected session, so this refuses rather than returning one.
|
|
22510
|
+
*
|
|
22511
|
+
* A session refused its key within the grace, or never connected, is closed and dropped from the manager at
|
|
22512
|
+
* once. Nothing else releases it: no source holds it, a wired station's session never idles out, and the
|
|
22513
|
+
* manager hands the same session to every later open of the camera, so the next open dials a fresh
|
|
22514
|
+
* connection instead. A session another open has since replaced is left to its own close.
|
|
22312
22515
|
*/
|
|
22313
22516
|
async openMediaSession(parentSn, channel, signal) {
|
|
22314
|
-
const
|
|
22315
|
-
await this.
|
|
22316
|
-
|
|
22317
|
-
|
|
22318
|
-
ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
|
|
22319
|
-
|
|
22320
|
-
|
|
22321
|
-
|
|
22322
|
-
|
|
22517
|
+
const mediaKey = _P2PCommandRouter.mediaSessionKey(parentSn, channel);
|
|
22518
|
+
const session = await this.openSession(mediaKey, parentSn);
|
|
22519
|
+
try {
|
|
22520
|
+
await this.awaitConnected(session, parentSn, signal);
|
|
22521
|
+
let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
|
|
22522
|
+
if (!ready && session.repromptLevel2Key()) {
|
|
22523
|
+
ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
|
|
22524
|
+
}
|
|
22525
|
+
if (!ready)
|
|
22526
|
+
throw new StationKeyUnavailableError(parentSn);
|
|
22527
|
+
return session;
|
|
22528
|
+
} catch (error) {
|
|
22529
|
+
if (!signal?.aborted && this.manager.get(mediaKey) === session) {
|
|
22530
|
+
void this.manager.close(mediaKey).catch((e) => this.reportError(e instanceof Error ? e : new Error(String(e))));
|
|
22531
|
+
}
|
|
22532
|
+
throw error;
|
|
22533
|
+
}
|
|
22323
22534
|
}
|
|
22324
22535
|
/**
|
|
22325
22536
|
* Wait for `session` to connect, for up to the session's own connect deadline, and throw
|
|
@@ -22357,7 +22568,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22357
22568
|
throw new Error(`no P2P endpoint (p2p_did) for station ${parentSn}`);
|
|
22358
22569
|
let dskKey;
|
|
22359
22570
|
try {
|
|
22360
|
-
dskKey = (await this.deps.mega.getDskKeys([
|
|
22571
|
+
dskKey = (await this.deps.mega.getDskKeys([stationDev]))[parentSn]?.dskKey;
|
|
22361
22572
|
} catch (e) {
|
|
22362
22573
|
this.deps.onError(e instanceof Error ? e : new Error(String(e)));
|
|
22363
22574
|
}
|
|
@@ -22459,7 +22670,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22459
22670
|
const session = await this.openSession(parentSn, parentSn);
|
|
22460
22671
|
if (session.isConnected)
|
|
22461
22672
|
return;
|
|
22462
|
-
await new Promise((
|
|
22673
|
+
await new Promise((resolve2, reject) => {
|
|
22463
22674
|
const cleanup = () => {
|
|
22464
22675
|
signal?.removeEventListener("abort", onAbort);
|
|
22465
22676
|
session.off("connect", onConnect);
|
|
@@ -22468,7 +22679,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22468
22679
|
};
|
|
22469
22680
|
const onConnect = () => {
|
|
22470
22681
|
cleanup();
|
|
22471
|
-
|
|
22682
|
+
resolve2();
|
|
22472
22683
|
};
|
|
22473
22684
|
const onError = (error) => {
|
|
22474
22685
|
cleanup();
|
|
@@ -22828,6 +23039,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22828
23039
|
signal: opts.signal
|
|
22829
23040
|
});
|
|
22830
23041
|
const key = `${parentSn}:${channel}`;
|
|
23042
|
+
const build = this.liveSourceBuilds.get(key);
|
|
23043
|
+
if (build) {
|
|
23044
|
+
await abortable(build, opts.signal);
|
|
23045
|
+
return this.sharedLiveSourceFor(sn, opts, continuous);
|
|
23046
|
+
}
|
|
22831
23047
|
const ownSession = continuous && homeBaseAttached && this.ownsLiveSession(parentSn);
|
|
22832
23048
|
let source = this.liveSources.get(key);
|
|
22833
23049
|
if (source && (source.state === "stopped" || ownSession && source.state !== "idle" && source.consumerCount === 0 && this.liveSessionKeys.get(key) === parentSn)) {
|
|
@@ -22839,6 +23055,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22839
23055
|
const logger = this.deps.logger ?? noopLogger;
|
|
22840
23056
|
const sessionKey = ownSession || continuous && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
|
|
22841
23057
|
this.liveSessionKeys.set(key, sessionKey);
|
|
23058
|
+
const built = Promise.withResolvers();
|
|
23059
|
+
this.liveSourceBuilds.set(key, built.promise);
|
|
22842
23060
|
let held;
|
|
22843
23061
|
try {
|
|
22844
23062
|
held = {
|
|
@@ -22847,6 +23065,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22847
23065
|
} catch (error) {
|
|
22848
23066
|
this.liveSessionKeys.delete(key);
|
|
22849
23067
|
throw error;
|
|
23068
|
+
} finally {
|
|
23069
|
+
this.liveSourceBuilds.delete(key);
|
|
23070
|
+
built.resolve();
|
|
22850
23071
|
}
|
|
22851
23072
|
source = new SharedLiveSource({
|
|
22852
23073
|
makeStream: (ctx) => new LiveStream(held.session, {
|
|
@@ -23151,7 +23372,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23151
23372
|
async p2pQuery(sn, subCmd, opts = {}) {
|
|
23152
23373
|
const { session, channel, accountId } = await this.resolveSession(sn, { waitLevel2: true });
|
|
23153
23374
|
const timeoutMs = opts.timeoutMs ?? 15e3;
|
|
23154
|
-
return await new Promise((
|
|
23375
|
+
return await new Promise((resolve2, reject) => {
|
|
23155
23376
|
const timer = setTimeout(() => {
|
|
23156
23377
|
cleanup();
|
|
23157
23378
|
reject(new Error(`timeout waiting for p2p query ${subCmd} reply from ${sn}`));
|
|
@@ -23160,7 +23381,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23160
23381
|
const j = f.json;
|
|
23161
23382
|
if (f.channel === channel && f.commandId === CommandType.CMD_NOTIFY_PAYLOAD && j?.cmd === subCmd && j.payload && typeof j.payload === "object") {
|
|
23162
23383
|
cleanup();
|
|
23163
|
-
|
|
23384
|
+
resolve2(j.payload);
|
|
23164
23385
|
}
|
|
23165
23386
|
};
|
|
23166
23387
|
const cleanup = () => {
|
|
@@ -23190,7 +23411,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23190
23411
|
async p2pControlQuery(sn, param, data, opts = {}) {
|
|
23191
23412
|
const { session } = await this.resolveSession(sn, { waitLevel2: false });
|
|
23192
23413
|
const timeoutMs = opts.timeoutMs ?? 15e3;
|
|
23193
|
-
return await new Promise((
|
|
23414
|
+
return await new Promise((resolve2, reject) => {
|
|
23194
23415
|
const timer = setTimeout(() => {
|
|
23195
23416
|
cleanup();
|
|
23196
23417
|
reject(new Error(`timeout waiting for p2p control query ${param} reply from ${sn}`));
|
|
@@ -23199,7 +23420,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23199
23420
|
const j = f.json;
|
|
23200
23421
|
if (j?.cmd === param && j.payload && typeof j.payload === "object") {
|
|
23201
23422
|
cleanup();
|
|
23202
|
-
|
|
23423
|
+
resolve2(j.payload);
|
|
23203
23424
|
}
|
|
23204
23425
|
};
|
|
23205
23426
|
const cleanup = () => {
|
|
@@ -23296,12 +23517,12 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23296
23517
|
* is ignored rather than resolving the wrong read.
|
|
23297
23518
|
*/
|
|
23298
23519
|
awaitRtspUrl(session, channel, signal) {
|
|
23299
|
-
return new Promise((
|
|
23520
|
+
return new Promise((resolve2, reject) => {
|
|
23300
23521
|
const onUrl = (ev) => {
|
|
23301
23522
|
if (ev.channel !== channel)
|
|
23302
23523
|
return;
|
|
23303
23524
|
cleanup();
|
|
23304
|
-
|
|
23525
|
+
resolve2(ev.url);
|
|
23305
23526
|
};
|
|
23306
23527
|
const onAbort = () => {
|
|
23307
23528
|
cleanup();
|
|
@@ -23621,7 +23842,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23621
23842
|
* wrapper has to share {@link serializeKeyed}.
|
|
23622
23843
|
*/
|
|
23623
23844
|
awaitKeyedReply(sn, session, channel, engage, send) {
|
|
23624
|
-
return new Promise((
|
|
23845
|
+
return new Promise((resolve2, reject) => {
|
|
23625
23846
|
let refused = false;
|
|
23626
23847
|
let mark;
|
|
23627
23848
|
let timer;
|
|
@@ -23641,7 +23862,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23641
23862
|
if (refused)
|
|
23642
23863
|
return;
|
|
23643
23864
|
refused = true;
|
|
23644
|
-
expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() =>
|
|
23865
|
+
expire(_P2PCommandRouter.KEYED_REFUSAL_TWIN_MS, () => settle(() => resolve2({ code: KEYED_PAYLOAD_SEQ_ERROR, mark })));
|
|
23645
23866
|
};
|
|
23646
23867
|
const onResult = (r) => {
|
|
23647
23868
|
if (r.channel !== channel)
|
|
@@ -23649,7 +23870,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23649
23870
|
if (r.code === KEYED_PAYLOAD_SEQ_ERROR)
|
|
23650
23871
|
refuse();
|
|
23651
23872
|
else if (!refused)
|
|
23652
|
-
settle(() =>
|
|
23873
|
+
settle(() => resolve2({ code: r.code }));
|
|
23653
23874
|
};
|
|
23654
23875
|
const onData = (f) => {
|
|
23655
23876
|
if (f.channel !== channel || f.commandId !== CommandType.CMD_NOTIFY_PAYLOAD)
|
|
@@ -23661,7 +23882,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23661
23882
|
}
|
|
23662
23883
|
const code = keyedPayloadReportCode(f.json, engage);
|
|
23663
23884
|
if (code !== void 0 && !refused)
|
|
23664
|
-
settle(() =>
|
|
23885
|
+
settle(() => resolve2({ code }));
|
|
23665
23886
|
};
|
|
23666
23887
|
session.on("commandResult", onResult);
|
|
23667
23888
|
session.on("data", onData);
|
|
@@ -23766,7 +23987,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23766
23987
|
clearTimeout(timer);
|
|
23767
23988
|
session.off("data", onData);
|
|
23768
23989
|
};
|
|
23769
|
-
const getReplyPromise = new Promise((
|
|
23990
|
+
const getReplyPromise = new Promise((resolve2) => {
|
|
23770
23991
|
onData = (f) => {
|
|
23771
23992
|
const j = f.json;
|
|
23772
23993
|
if (j?.cmd !== CMD_TRANSFER_PAYLOAD || !j.payload)
|
|
@@ -23779,11 +24000,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
23779
24000
|
if (keyTime === void 0 || keyTime !== query.time)
|
|
23780
24001
|
return;
|
|
23781
24002
|
cleanup();
|
|
23782
|
-
|
|
24003
|
+
resolve2({ lockPayload, keyTime });
|
|
23783
24004
|
};
|
|
23784
24005
|
timer = setTimeout(() => {
|
|
23785
24006
|
cleanup();
|
|
23786
|
-
|
|
24007
|
+
resolve2(void 0);
|
|
23787
24008
|
}, _P2PCommandRouter.FF09_SETTINGS_GET_TIMEOUT_MS);
|
|
23788
24009
|
session.on("data", onData);
|
|
23789
24010
|
});
|
|
@@ -23912,7 +24133,9 @@ var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a
|
|
|
23912
24133
|
var PORTAL_KEEPALIVE_MS = 29e3;
|
|
23913
24134
|
|
|
23914
24135
|
// dist/transport/rtc/portal-packet.js
|
|
24136
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
23915
24137
|
var MAGIC2 = Buffer.from("XZYH", "ascii");
|
|
24138
|
+
var ZSTD_MAGIC_LE = 4247762216;
|
|
23916
24139
|
var PORTAL_HEADER_LENGTH = 16;
|
|
23917
24140
|
var PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
23918
24141
|
var PORTAL_STATION_CHANNEL = 255;
|
|
@@ -23976,11 +24199,23 @@ function parseJsonBody(buf) {
|
|
|
23976
24199
|
return text2;
|
|
23977
24200
|
}
|
|
23978
24201
|
}
|
|
24202
|
+
function unwrapZstd(buf) {
|
|
24203
|
+
if (buf.length < 4 || buf.readUInt32LE(0) !== ZSTD_MAGIC_LE)
|
|
24204
|
+
return buf;
|
|
24205
|
+
try {
|
|
24206
|
+
return zstdDecompressSync(buf);
|
|
24207
|
+
} catch {
|
|
24208
|
+
return void 0;
|
|
24209
|
+
}
|
|
24210
|
+
}
|
|
23979
24211
|
function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
|
|
23980
|
-
const
|
|
24212
|
+
const packet = unwrapZstd(buf);
|
|
24213
|
+
if (!packet)
|
|
24214
|
+
return void 0;
|
|
24215
|
+
const header = parsePortalHeader(packet);
|
|
23981
24216
|
if (!header)
|
|
23982
24217
|
return void 0;
|
|
23983
|
-
const body =
|
|
24218
|
+
const body = packet.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
|
|
23984
24219
|
if (linkType === PortalLinkType.NOTIFY) {
|
|
23985
24220
|
const data = parseJsonBody(body);
|
|
23986
24221
|
const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
|
|
@@ -24712,12 +24947,12 @@ var RtcPeer = class extends EventEmitter9 {
|
|
|
24712
24947
|
this.remoteMid = media ? offer.match(/^a=mid:([^\r\n]+)/m)?.[1]?.trim() ?? HUB_SDP_MID : midOf(offer, "application") ?? HUB_SDP_MID;
|
|
24713
24948
|
if (media)
|
|
24714
24949
|
this.videoMid = midOf(offer, "video");
|
|
24715
|
-
const answerWait = new Promise((
|
|
24950
|
+
const answerWait = new Promise((resolve2, reject) => {
|
|
24716
24951
|
const timer = setTimeout(() => {
|
|
24717
24952
|
this.localAnswer = void 0;
|
|
24718
24953
|
reject(new Error("timed out waiting for the local SDP answer"));
|
|
24719
24954
|
}, ANSWER_TIMEOUT_MS);
|
|
24720
|
-
this.localAnswer = { resolve, reject, timer };
|
|
24955
|
+
this.localAnswer = { resolve: resolve2, reject, timer };
|
|
24721
24956
|
});
|
|
24722
24957
|
pc.setRemoteDescription(offer, "offer");
|
|
24723
24958
|
this.remoteSet = true;
|
|
@@ -24879,7 +25114,7 @@ var RtcPeer = class extends EventEmitter9 {
|
|
|
24879
25114
|
|
|
24880
25115
|
// dist/transport/rtc/signaling.js
|
|
24881
25116
|
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
24882
|
-
import { createHmac as createHmac3, randomUUID as
|
|
25117
|
+
import { createHmac as createHmac3, randomUUID as randomUUID3 } from "node:crypto";
|
|
24883
25118
|
var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
|
|
24884
25119
|
var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
|
|
24885
25120
|
var PORTAL_ORIGIN = "https://security.eufy.com";
|
|
@@ -24919,7 +25154,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
|
|
|
24919
25154
|
this.createSocket = opts.createSocket ?? defaultSocket;
|
|
24920
25155
|
this.logger = opts.logger ?? noopLogger;
|
|
24921
25156
|
this.now = opts.now ?? Date.now;
|
|
24922
|
-
this.makeMsgId = opts.makeMsgId ?? (() =>
|
|
25157
|
+
this.makeMsgId = opts.makeMsgId ?? (() => randomUUID3().replace(/-/g, ""));
|
|
24923
25158
|
}
|
|
24924
25159
|
get isOpen() {
|
|
24925
25160
|
return this.ws?.readyState === WS_OPEN;
|
|
@@ -24963,7 +25198,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
|
|
|
24963
25198
|
};
|
|
24964
25199
|
const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
|
|
24965
25200
|
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
|
|
24966
|
-
await new Promise((
|
|
25201
|
+
await new Promise((resolve2, reject) => {
|
|
24967
25202
|
const timer = setTimeout(() => {
|
|
24968
25203
|
reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
|
|
24969
25204
|
this.close();
|
|
@@ -24977,7 +25212,7 @@ var RtcSignalingClient = class extends EventEmitter10 {
|
|
|
24977
25212
|
this.sendAuth(sign);
|
|
24978
25213
|
this.startKeepalive();
|
|
24979
25214
|
this.emit("open");
|
|
24980
|
-
|
|
25215
|
+
resolve2();
|
|
24981
25216
|
});
|
|
24982
25217
|
ws.addEventListener("message", (ev) => {
|
|
24983
25218
|
void this.handleWireMessage(ev.data);
|
|
@@ -25144,10 +25379,7 @@ var RtcSession = class extends EventEmitter11 {
|
|
|
25144
25379
|
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
25145
25380
|
});
|
|
25146
25381
|
});
|
|
25147
|
-
this.signaling.on("close", () =>
|
|
25148
|
-
this.connected = false;
|
|
25149
|
-
this.announceClose();
|
|
25150
|
-
});
|
|
25382
|
+
this.signaling.on("close", () => this.shutdown());
|
|
25151
25383
|
this.signaling.on("error", (e) => this.emit("error", e));
|
|
25152
25384
|
this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
|
|
25153
25385
|
this.peer.on("iceGatheringComplete", () => this.trickle(""));
|
|
@@ -25250,12 +25482,12 @@ var RtcSession = class extends EventEmitter11 {
|
|
|
25250
25482
|
waitForAuth() {
|
|
25251
25483
|
if (this.authOk)
|
|
25252
25484
|
return Promise.resolve();
|
|
25253
|
-
return new Promise((
|
|
25485
|
+
return new Promise((resolve2, reject) => {
|
|
25254
25486
|
const timer = setTimeout(() => {
|
|
25255
25487
|
this.authWaiter = void 0;
|
|
25256
25488
|
reject(new Error("RTC signalling auth timeout"));
|
|
25257
25489
|
}, AUTH_TIMEOUT_MS);
|
|
25258
|
-
this.authWaiter = { resolve, reject, timer };
|
|
25490
|
+
this.authWaiter = { resolve: resolve2, reject, timer };
|
|
25259
25491
|
});
|
|
25260
25492
|
}
|
|
25261
25493
|
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
@@ -25382,6 +25614,7 @@ var RtcCommandRouter = class {
|
|
|
25382
25614
|
lives = /* @__PURE__ */ new Map();
|
|
25383
25615
|
warm = /* @__PURE__ */ new Map();
|
|
25384
25616
|
callSources = /* @__PURE__ */ new Map();
|
|
25617
|
+
lastTransaction = 0;
|
|
25385
25618
|
constructor(deps) {
|
|
25386
25619
|
this.deps = deps;
|
|
25387
25620
|
}
|
|
@@ -25391,21 +25624,80 @@ var RtcCommandRouter = class {
|
|
|
25391
25624
|
}
|
|
25392
25625
|
if (route.mediaTrack)
|
|
25393
25626
|
throw new Error(`rtc: ${route.stationSn} serves media only and takes no portal command`);
|
|
25394
|
-
const identity = this.deps.identity();
|
|
25395
|
-
if (!identity)
|
|
25396
|
-
throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
|
|
25397
|
-
const { stationSn } = route;
|
|
25398
|
-
const adminUserId = route.adminUserId || identity.userId;
|
|
25399
25627
|
const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
|
|
25400
|
-
|
|
25401
|
-
|
|
25402
|
-
|
|
25403
|
-
|
|
25404
|
-
|
|
25405
|
-
|
|
25406
|
-
|
|
25407
|
-
|
|
25408
|
-
|
|
25628
|
+
if (cmd.kind === "set-json") {
|
|
25629
|
+
return this.sendQueued(route, PORTAL_CMD_CONTROL_PAYLOAD, cmd.param, channel, {
|
|
25630
|
+
cmd: cmd.param,
|
|
25631
|
+
commandType: cmd.param,
|
|
25632
|
+
data: cmd.data
|
|
25633
|
+
});
|
|
25634
|
+
}
|
|
25635
|
+
return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, cmd.cmd, channel, {
|
|
25636
|
+
cmd: cmd.cmd,
|
|
25637
|
+
mValue3: cmd.mValue3 ?? 0,
|
|
25638
|
+
payload: cmd.payload
|
|
25639
|
+
});
|
|
25640
|
+
}
|
|
25641
|
+
/**
|
|
25642
|
+
* Query one on-station table over the station's control channel and answer its rows.
|
|
25643
|
+
*
|
|
25644
|
+
* Sends the `1350` envelope with inner `{cmd: 1306, payload: {cmd: 10000, table, payload, transaction}}`,
|
|
25645
|
+
* the whole-table query, on the station channel. The hub ACKs on the command link and answers with one notify frame whose body
|
|
25646
|
+
* carries `{cmd, table, data, mIntRet, msg, transaction, …}`, echoing the read's `transaction`; that echo
|
|
25647
|
+
* is the only thing a reply is attributed by, and every read sends a `transaction` no other read on this
|
|
25648
|
+
* router has sent. Resolves with the reply's `data` rows. Rejects with the hub's `msg` on a non-zero
|
|
25649
|
+
* `mIntRet`, on an ACK refusing the envelope, at once when `signal` aborts (queued or sent), when the
|
|
25650
|
+
* session closes, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
|
|
25651
|
+
*
|
|
25652
|
+
* One read or command at a time per station: the read holds the station's send queue until it settles.
|
|
25653
|
+
*/
|
|
25654
|
+
readTable(route, table, opts = {}) {
|
|
25655
|
+
const transaction = this.nextTransaction();
|
|
25656
|
+
const inner = {
|
|
25657
|
+
cmd: DB_QUERY.FULL_TABLE,
|
|
25658
|
+
table,
|
|
25659
|
+
payload: fullTableQuery(),
|
|
25660
|
+
transaction
|
|
25661
|
+
};
|
|
25662
|
+
return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, CMD_DATABASE, PORTAL_STATION_CHANNEL, { cmd: CMD_DATABASE, mValue3: 0, payload: inner }, {
|
|
25663
|
+
timeoutMs: opts.timeoutMs,
|
|
25664
|
+
signal: opts.signal,
|
|
25665
|
+
pick: (body) => {
|
|
25666
|
+
if (String(body.transaction) !== transaction)
|
|
25667
|
+
return void 0;
|
|
25668
|
+
if (typeof body.mIntRet === "number" && body.mIntRet !== 0) {
|
|
25669
|
+
throw new Error(`rtc: ${route.stationSn} refused ${table}: ${body.msg ?? body.mIntRet} (${body.mIntRet})`);
|
|
25670
|
+
}
|
|
25671
|
+
return Array.isArray(body.data) ? body.data : void 0;
|
|
25672
|
+
}
|
|
25673
|
+
});
|
|
25674
|
+
}
|
|
25675
|
+
/**
|
|
25676
|
+
* Fetch one stored image by its on-station path over the station's control channel.
|
|
25677
|
+
*
|
|
25678
|
+
* Sends the `1350` envelope with inner `{cmd: 1308, payload: [{file}]}` on the station channel. The hub
|
|
25679
|
+
* ACKs on the command link and answers with one notify frame whose body is `{file, content}`, `content`
|
|
25680
|
+
* the image in base64, and no `transaction`; the reply is the one whose `file` equals `filePath`. A reply
|
|
25681
|
+
* arriving after an abandoned read of the same path carries that path's bytes, which are the bytes a
|
|
25682
|
+
* later read of it asks for. Resolves with the decoded bytes. Rejects on an ACK refusing the envelope,
|
|
25683
|
+
* at once when `signal` aborts (queued or sent), when the session closes, and when `timeoutMs` (default
|
|
25684
|
+
* {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
|
|
25685
|
+
*
|
|
25686
|
+
* One read or command at a time per station: the read holds the station's send queue until it settles.
|
|
25687
|
+
*/
|
|
25688
|
+
readImage(route, filePath, opts = {}) {
|
|
25689
|
+
return this.sendQueued(route, PORTAL_CMD_SET_PAYLOAD, CMD_DATABASE_IMAGE, PORTAL_STATION_CHANNEL, { cmd: CMD_DATABASE_IMAGE, mValue3: 0, payload: [{ file: filePath }] }, {
|
|
25690
|
+
...opts,
|
|
25691
|
+
pick: (body) => body.file === filePath && typeof body.content === "string" ? Buffer.from(body.content, "base64") : void 0
|
|
25692
|
+
});
|
|
25693
|
+
}
|
|
25694
|
+
/**
|
|
25695
|
+
* A `transaction` stamp in the vendor app's shape, a decimal epoch-in-milliseconds string, strictly
|
|
25696
|
+
* greater than the last one this router issued, so two reads in the same millisecond never share one.
|
|
25697
|
+
*/
|
|
25698
|
+
nextTransaction() {
|
|
25699
|
+
this.lastTransaction = Math.max(Date.now(), this.lastTransaction + 1);
|
|
25700
|
+
return String(this.lastTransaction);
|
|
25409
25701
|
}
|
|
25410
25702
|
/**
|
|
25411
25703
|
* The media of a camera on a T9000: live video, a readable feed and a live still over the station's
|
|
@@ -25581,45 +25873,109 @@ var RtcCommandRouter = class {
|
|
|
25581
25873
|
return source;
|
|
25582
25874
|
}
|
|
25583
25875
|
/**
|
|
25584
|
-
*
|
|
25585
|
-
*
|
|
25586
|
-
*
|
|
25876
|
+
* Send one envelope on the station's session, queued behind its other sends, and settle as
|
|
25877
|
+
* {@link sendAwaitAck} does. `body` is the inner payload; the station session, the `account_id` it is
|
|
25878
|
+
* stamped with and the packet are resolved here, so a command and a read reach the wire through the same
|
|
25879
|
+
* steps. With `reply.signal`, the caller's wait rejects the moment it aborts, also while the send is still
|
|
25880
|
+
* queued; a send not yet made by then is skipped.
|
|
25881
|
+
*/
|
|
25882
|
+
async sendQueued(route, outerCmd, innerCmd, channel, body, reply) {
|
|
25883
|
+
reply?.signal?.throwIfAborted();
|
|
25884
|
+
const identity = this.deps.identity();
|
|
25885
|
+
if (!identity)
|
|
25886
|
+
throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
|
|
25887
|
+
const adminUserId = route.adminUserId || identity.userId;
|
|
25888
|
+
const st = await abortable(this.stationSession(route, adminUserId, identity), reply?.signal);
|
|
25889
|
+
const segment = st.seg.next();
|
|
25890
|
+
const packet = buildPortalPacket({
|
|
25891
|
+
commandId: outerCmd,
|
|
25892
|
+
channel,
|
|
25893
|
+
segment,
|
|
25894
|
+
payload: { account_id: adminUserId, ...body }
|
|
25895
|
+
});
|
|
25896
|
+
const run = st.queue.then(() => this.sendAwaitAck(route.stationSn, st, packet, outerCmd, innerCmd, channel, segment, reply));
|
|
25897
|
+
st.queue = run.catch(() => void 0);
|
|
25898
|
+
return abortable(run, reply?.signal);
|
|
25899
|
+
}
|
|
25900
|
+
/**
|
|
25901
|
+
* Without `reply`, completes on the command ACK or a correlated control-result notification. The
|
|
25902
|
+
* notification must match the request channel, parameter and segment and contain a structured payload.
|
|
25903
|
+
* Receipt does not establish physical actuation. Bounded by {@link ACK_TIMEOUT_MS}.
|
|
25904
|
+
*
|
|
25905
|
+
* With `reply`, a zero-result ACK is not the answer: the first notify body (an object) for which
|
|
25906
|
+
* `reply.pick` answers a value resolves with it, one `pick` throws on rejects with that error, and every
|
|
25907
|
+
* other notify frame is left alone. Bounded by `reply.timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}),
|
|
25908
|
+
* and ended by `reply.signal`; one already aborted is not sent.
|
|
25909
|
+
*
|
|
25910
|
+
* Either way an ACK with a non-zero result code rejects, and the command is sent once.
|
|
25587
25911
|
*/
|
|
25588
|
-
sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
|
|
25589
|
-
|
|
25912
|
+
sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment, reply) {
|
|
25913
|
+
const timeoutMs = reply ? reply.timeoutMs ?? DB_TABLE_TIMEOUT_MS : ACK_TIMEOUT_MS;
|
|
25914
|
+
const awaited = reply ? "reply" : "ACK";
|
|
25915
|
+
return new Promise((resolve2, reject) => {
|
|
25590
25916
|
const onData = (frame, linkType) => {
|
|
25591
25917
|
const p = parsePortalPacket(frame, linkType);
|
|
25592
|
-
if (!p
|
|
25918
|
+
if (!p)
|
|
25919
|
+
return;
|
|
25920
|
+
if (reply && linkType === PortalLinkType.NOTIFY && typeof p.data === "object" && p.data !== null) {
|
|
25921
|
+
let picked;
|
|
25922
|
+
try {
|
|
25923
|
+
picked = reply.pick(p.data);
|
|
25924
|
+
} catch (e) {
|
|
25925
|
+
cleanup();
|
|
25926
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
25927
|
+
return;
|
|
25928
|
+
}
|
|
25929
|
+
if (picked === void 0)
|
|
25930
|
+
return;
|
|
25931
|
+
cleanup();
|
|
25932
|
+
this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} answered`);
|
|
25933
|
+
resolve2(picked);
|
|
25934
|
+
return;
|
|
25935
|
+
}
|
|
25936
|
+
if (p.segment !== segment)
|
|
25593
25937
|
return;
|
|
25594
25938
|
const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
|
|
25595
25939
|
const d = p.data;
|
|
25596
25940
|
const notification = outerCmd === PORTAL_CMD_CONTROL_PAYLOAD && linkType === PortalLinkType.NOTIFY && p.commandId === PORTAL_CMD_NOTIFY_PAYLOAD && p.isResponse === 0 && p.channel === channel && p.cmd === innerCmd && typeof d?.payload === "object" && d.payload !== null;
|
|
25597
25941
|
if (!acknowledgement && !notification)
|
|
25598
25942
|
return;
|
|
25943
|
+
if (reply && p.errCode === 0)
|
|
25944
|
+
return;
|
|
25599
25945
|
cleanup();
|
|
25600
25946
|
if (acknowledgement && p.errCode !== 0) {
|
|
25601
25947
|
reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
|
|
25602
25948
|
} else {
|
|
25603
25949
|
this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
|
|
25604
|
-
|
|
25950
|
+
resolve2(void 0);
|
|
25605
25951
|
}
|
|
25606
25952
|
};
|
|
25607
25953
|
const onClose = () => {
|
|
25608
25954
|
cleanup();
|
|
25609
|
-
reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd}
|
|
25955
|
+
reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ${awaited}`));
|
|
25956
|
+
};
|
|
25957
|
+
const onAbort = () => {
|
|
25958
|
+
cleanup();
|
|
25959
|
+
reject(reply?.signal?.reason);
|
|
25610
25960
|
};
|
|
25611
25961
|
const timer = setTimeout(() => {
|
|
25612
25962
|
cleanup();
|
|
25613
|
-
reject(new Error(`rtc: ${sn} cmd ${innerCmd}
|
|
25614
|
-
},
|
|
25963
|
+
reject(new Error(`rtc: ${sn} cmd ${innerCmd} ${awaited} timed out after ${timeoutMs}ms`));
|
|
25964
|
+
}, timeoutMs);
|
|
25615
25965
|
const cleanup = () => {
|
|
25616
25966
|
clearTimeout(timer);
|
|
25617
25967
|
st.session.off("commandData", onData);
|
|
25618
25968
|
st.session.off("close", onClose);
|
|
25969
|
+
reply?.signal?.removeEventListener("abort", onAbort);
|
|
25619
25970
|
this.touch(sn, st);
|
|
25620
25971
|
};
|
|
25972
|
+
if (reply?.signal?.aborted) {
|
|
25973
|
+
onAbort();
|
|
25974
|
+
return;
|
|
25975
|
+
}
|
|
25621
25976
|
st.session.on("commandData", onData);
|
|
25622
25977
|
st.session.on("close", onClose);
|
|
25978
|
+
reply?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
25623
25979
|
if (!st.session.sendCommand(packet)) {
|
|
25624
25980
|
cleanup();
|
|
25625
25981
|
reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
|
|
@@ -25675,7 +26031,7 @@ var RtcCommandRouter = class {
|
|
|
25675
26031
|
* promise, so an early failure cannot leave a second one rejecting unheard.
|
|
25676
26032
|
*/
|
|
25677
26033
|
bringUp(sn, session) {
|
|
25678
|
-
return new Promise((
|
|
26034
|
+
return new Promise((resolve2, reject) => {
|
|
25679
26035
|
let settled = false;
|
|
25680
26036
|
const finish = (err) => {
|
|
25681
26037
|
if (settled)
|
|
@@ -25692,7 +26048,7 @@ var RtcCommandRouter = class {
|
|
|
25692
26048
|
reject(err);
|
|
25693
26049
|
} else {
|
|
25694
26050
|
this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
|
|
25695
|
-
|
|
26051
|
+
resolve2();
|
|
25696
26052
|
}
|
|
25697
26053
|
};
|
|
25698
26054
|
const onConnected = () => finish();
|
|
@@ -26121,10 +26477,10 @@ function dpColorFields(spec) {
|
|
|
26121
26477
|
}
|
|
26122
26478
|
|
|
26123
26479
|
// dist/transport/mqtt/clean-codec.js
|
|
26124
|
-
import { randomUUID as
|
|
26480
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
26125
26481
|
function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
|
|
26126
26482
|
const payload = JSON.stringify({
|
|
26127
|
-
t: opts?.uuid ??
|
|
26483
|
+
t: opts?.uuid ?? randomUUID4(),
|
|
26128
26484
|
protocol: 2,
|
|
26129
26485
|
account_id: accountId,
|
|
26130
26486
|
device_sn: deviceSn,
|
|
@@ -26363,13 +26719,13 @@ var MqttCommandRouter = class _MqttCommandRouter {
|
|
|
26363
26719
|
* timeout with no unhandled listener left behind either way.
|
|
26364
26720
|
*/
|
|
26365
26721
|
waitForMqttMessage(mqtt, match, timeoutMs) {
|
|
26366
|
-
return new Promise((
|
|
26722
|
+
return new Promise((resolve2) => {
|
|
26367
26723
|
const onMessage = (m) => {
|
|
26368
26724
|
const result = match(m);
|
|
26369
26725
|
if (result === void 0)
|
|
26370
26726
|
return;
|
|
26371
26727
|
cleanup();
|
|
26372
|
-
|
|
26728
|
+
resolve2(result);
|
|
26373
26729
|
};
|
|
26374
26730
|
const cleanup = () => {
|
|
26375
26731
|
clearTimeout(timer);
|
|
@@ -26377,7 +26733,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
|
|
|
26377
26733
|
};
|
|
26378
26734
|
const timer = setTimeout(() => {
|
|
26379
26735
|
cleanup();
|
|
26380
|
-
|
|
26736
|
+
resolve2(void 0);
|
|
26381
26737
|
}, timeoutMs);
|
|
26382
26738
|
timer.unref?.();
|
|
26383
26739
|
mqtt.on("message", onMessage);
|
|
@@ -26897,7 +27253,7 @@ function decryptReply(key, resultB64) {
|
|
|
26897
27253
|
}
|
|
26898
27254
|
|
|
26899
27255
|
// dist/transport/tuya/request.js
|
|
26900
|
-
import { randomUUID as
|
|
27256
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
26901
27257
|
var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
|
|
26902
27258
|
var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
|
|
26903
27259
|
var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
|
|
@@ -26929,7 +27285,7 @@ function buildApiParams(action, opts) {
|
|
|
26929
27285
|
a: action.a,
|
|
26930
27286
|
v: action.v,
|
|
26931
27287
|
time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
|
|
26932
|
-
requestId: opts.requestId ??
|
|
27288
|
+
requestId: opts.requestId ?? randomUUID5(),
|
|
26933
27289
|
sid: opts.session.sid,
|
|
26934
27290
|
deviceId: opts.session.deviceId,
|
|
26935
27291
|
chKey: opts.session.chKey,
|
|
@@ -28268,8 +28624,6 @@ var FcmRegistrar = class {
|
|
|
28268
28624
|
};
|
|
28269
28625
|
|
|
28270
28626
|
// dist/transport/push/store.js
|
|
28271
|
-
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, rmSync as rmSync2 } from "node:fs";
|
|
28272
|
-
import { dirname as dirname2 } from "node:path";
|
|
28273
28627
|
var MemoryFcmStore = class {
|
|
28274
28628
|
p = null;
|
|
28275
28629
|
load() {
|
|
@@ -28282,31 +28636,7 @@ var MemoryFcmStore = class {
|
|
|
28282
28636
|
this.p = null;
|
|
28283
28637
|
}
|
|
28284
28638
|
};
|
|
28285
|
-
var FileFcmStore = class {
|
|
28286
|
-
path;
|
|
28287
|
-
constructor(path) {
|
|
28288
|
-
this.path = path;
|
|
28289
|
-
}
|
|
28290
|
-
load() {
|
|
28291
|
-
try {
|
|
28292
|
-
return JSON.parse(readFileSync2(this.path, "utf-8"));
|
|
28293
|
-
} catch {
|
|
28294
|
-
return null;
|
|
28295
|
-
}
|
|
28296
|
-
}
|
|
28297
|
-
save(p) {
|
|
28298
|
-
try {
|
|
28299
|
-
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
28300
|
-
} catch {
|
|
28301
|
-
}
|
|
28302
|
-
writeFileSync2(this.path, JSON.stringify(p, null, 2), { mode: 384 });
|
|
28303
|
-
}
|
|
28304
|
-
clear() {
|
|
28305
|
-
try {
|
|
28306
|
-
rmSync2(this.path);
|
|
28307
|
-
} catch {
|
|
28308
|
-
}
|
|
28309
|
-
}
|
|
28639
|
+
var FileFcmStore = class extends FileSessionStore {
|
|
28310
28640
|
};
|
|
28311
28641
|
|
|
28312
28642
|
// dist/transport/media-failure.js
|
|
@@ -28335,6 +28665,12 @@ var StoredImageCache = class {
|
|
|
28335
28665
|
activeDevices = /* @__PURE__ */ new Set();
|
|
28336
28666
|
activeDownloads = 0;
|
|
28337
28667
|
generation = 0;
|
|
28668
|
+
/**
|
|
28669
|
+
* Bumped by {@link clearLifecycleErrors}. A lifecycle failure from a download started under an older
|
|
28670
|
+
* count is not recorded; the candidate is queued again, unless a newer one is already queued, so the
|
|
28671
|
+
* current session fetches it.
|
|
28672
|
+
*/
|
|
28673
|
+
lifecycle = 0;
|
|
28338
28674
|
constructor(downloader, logger, clock = Date.now, isLifecycleError = () => false) {
|
|
28339
28675
|
this.downloader = downloader;
|
|
28340
28676
|
this.logger = logger;
|
|
@@ -28390,6 +28726,18 @@ var StoredImageCache = class {
|
|
|
28390
28726
|
return Promise.resolve(state.retained.image);
|
|
28391
28727
|
return Promise.reject(new StoredSnapshotUnavailableError(reason, "No stored snapshot is available"));
|
|
28392
28728
|
}
|
|
28729
|
+
/**
|
|
28730
|
+
* Forget every device's recorded lifecycle failure, keeping retained bytes and candidate state.
|
|
28731
|
+
*
|
|
28732
|
+
* A lifecycle failure describes the session that was current when the download ran, so it no longer
|
|
28733
|
+
* holds once a login succeeds again; {@link snapshotStored} then answers from the retained image, or
|
|
28734
|
+
* with the reason a stored image is absent. A download in flight across the call cannot record one.
|
|
28735
|
+
*/
|
|
28736
|
+
clearLifecycleErrors() {
|
|
28737
|
+
this.lifecycle += 1;
|
|
28738
|
+
for (const state of this.devices.values())
|
|
28739
|
+
state.lifecycleError = void 0;
|
|
28740
|
+
}
|
|
28393
28741
|
/** Invalidate all retained and candidate state. */
|
|
28394
28742
|
clear() {
|
|
28395
28743
|
this.generation += 1;
|
|
@@ -28405,18 +28753,23 @@ var StoredImageCache = class {
|
|
|
28405
28753
|
state.inFlight = candidate;
|
|
28406
28754
|
this.activeDownloads += 1;
|
|
28407
28755
|
this.activeDevices.add(candidate.deviceKey);
|
|
28408
|
-
|
|
28756
|
+
const lifecycle = this.lifecycle;
|
|
28757
|
+
void this.downloader(candidate.url, candidate.deviceKey).then((image) => this.complete(candidate, lifecycle, image), (error) => this.complete(candidate, lifecycle, void 0, error));
|
|
28409
28758
|
}
|
|
28410
28759
|
}
|
|
28411
|
-
complete(candidate, image, error) {
|
|
28760
|
+
complete(candidate, lifecycle, image, error) {
|
|
28412
28761
|
this.activeDownloads -= 1;
|
|
28413
28762
|
this.activeDevices.delete(candidate.deviceKey);
|
|
28414
28763
|
const state = this.devices.get(candidate.deviceKey);
|
|
28415
28764
|
if (candidate.generation === this.generation && state?.inFlight === candidate) {
|
|
28416
28765
|
state.inFlight = void 0;
|
|
28417
28766
|
if (error !== void 0 && this.isLifecycleError(error)) {
|
|
28418
|
-
|
|
28419
|
-
|
|
28767
|
+
if (lifecycle === this.lifecycle) {
|
|
28768
|
+
state.lifecycleError = error;
|
|
28769
|
+
state.reason = void 0;
|
|
28770
|
+
} else {
|
|
28771
|
+
state.queued ??= candidate;
|
|
28772
|
+
}
|
|
28420
28773
|
} else if (image === void 0 && this.retryLater(state, candidate, error)) {
|
|
28421
28774
|
state.lifecycleError = void 0;
|
|
28422
28775
|
} else if (image === void 0) {
|
|
@@ -28908,8 +29261,8 @@ var DeviceRegistry = class {
|
|
|
28908
29261
|
const waiters = this.stateWaiters.get(sn);
|
|
28909
29262
|
if (waiters) {
|
|
28910
29263
|
this.stateWaiters.delete(sn);
|
|
28911
|
-
for (const
|
|
28912
|
-
|
|
29264
|
+
for (const resolve2 of waiters)
|
|
29265
|
+
resolve2();
|
|
28913
29266
|
}
|
|
28914
29267
|
}
|
|
28915
29268
|
/**
|
|
@@ -28954,15 +29307,15 @@ var DeviceRegistry = class {
|
|
|
28954
29307
|
awaitRealtimeState(sn, timeoutMs) {
|
|
28955
29308
|
if (this.hasRealtimeState(sn) || timeoutMs <= 0)
|
|
28956
29309
|
return Promise.resolve();
|
|
28957
|
-
return new Promise((
|
|
29310
|
+
return new Promise((resolve2) => {
|
|
28958
29311
|
const timer = setTimeout(() => {
|
|
28959
29312
|
this.stateWaiters.get(sn)?.delete(done);
|
|
28960
|
-
|
|
29313
|
+
resolve2();
|
|
28961
29314
|
}, timeoutMs);
|
|
28962
29315
|
timer.unref?.();
|
|
28963
29316
|
const done = () => {
|
|
28964
29317
|
clearTimeout(timer);
|
|
28965
|
-
|
|
29318
|
+
resolve2();
|
|
28966
29319
|
};
|
|
28967
29320
|
const set = this.stateWaiters.get(sn) ?? /* @__PURE__ */ new Set();
|
|
28968
29321
|
set.add(done);
|
|
@@ -29143,6 +29496,12 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29143
29496
|
/** Shared in-flight/settled `startMqtt()` promise PER SCOPE — so realtime bring-up and an on-demand
|
|
29144
29497
|
* publish await the SAME fully-connected transport instead of racing (and never open two). */
|
|
29145
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();
|
|
29146
29505
|
/** Device list/record/capability resolution + the frame→caps cache. */
|
|
29147
29506
|
registry;
|
|
29148
29507
|
pushClient;
|
|
@@ -29666,7 +30025,7 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29666
30025
|
return true;
|
|
29667
30026
|
const delay = Math.min(500, deadline - Date.now());
|
|
29668
30027
|
if (delay > 0)
|
|
29669
|
-
await new Promise((
|
|
30028
|
+
await new Promise((resolve2) => setTimeout(resolve2, delay));
|
|
29670
30029
|
if (refresh.expected !== void 0 && settled())
|
|
29671
30030
|
return true;
|
|
29672
30031
|
}
|
|
@@ -29715,7 +30074,7 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29715
30074
|
* manager's own log, since by then nothing is waiting to receive it.
|
|
29716
30075
|
*/
|
|
29717
30076
|
recycleStandaloneSession(sn) {
|
|
29718
|
-
const releaseWrite = new Promise((
|
|
30077
|
+
const releaseWrite = new Promise((resolve2) => void setTimeout(resolve2, SESSION_RECYCLE_WAIT_MS).unref?.());
|
|
29719
30078
|
return Promise.race([
|
|
29720
30079
|
this.p2p.resetStandaloneSession(sn).finally(() => this.reopenWiredStation(sn)),
|
|
29721
30080
|
releaseWrite.then(() => this.opts.logger?.debug(`[session ${sn}] recycle still waiting on an attached viewer \u2014 releasing the write that asked for it`))
|
|
@@ -29752,11 +30111,11 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29752
30111
|
}
|
|
29753
30112
|
/** Limit waiting on an unabortable dependency operation to the remaining semantic-event refresh window. */
|
|
29754
30113
|
beforeDeadline(operation, timeoutMs) {
|
|
29755
|
-
return new Promise((
|
|
30114
|
+
return new Promise((resolve2, reject) => {
|
|
29756
30115
|
const timeout = setTimeout(() => reject(new RefreshWindowClosedError("refresh window closed")), Math.max(0, timeoutMs));
|
|
29757
30116
|
operation.then((value) => {
|
|
29758
30117
|
clearTimeout(timeout);
|
|
29759
|
-
|
|
30118
|
+
resolve2(value);
|
|
29760
30119
|
}, (error) => {
|
|
29761
30120
|
clearTimeout(timeout);
|
|
29762
30121
|
reject(error);
|
|
@@ -29861,12 +30220,16 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29861
30220
|
/**
|
|
29862
30221
|
* On a successful login, kick off auto-realtime (unless `autoRealtime:false`). Fire-and-forget so
|
|
29863
30222
|
* `login()` returns as soon as the session is ready — realtime channels come up in the background and
|
|
29864
|
-
* surface failures via `error`. Idempotent through the retained generation promise.
|
|
30223
|
+
* surface failures via `error`. Idempotent through the retained generation promise. A login for a
|
|
30224
|
+
* different account drops every stored image; one for the same account drops only the lifecycle
|
|
30225
|
+
* failures recorded against the session it replaces.
|
|
29865
30226
|
*/
|
|
29866
30227
|
afterLogin(result) {
|
|
29867
30228
|
if (result.status === LoginStatus.Ok) {
|
|
29868
30229
|
if (this.storedImageAccount && this.storedImageAccount !== result.session.userId)
|
|
29869
30230
|
this.storedImages?.clear();
|
|
30231
|
+
else
|
|
30232
|
+
this.storedImages?.clearLifecycleErrors();
|
|
29870
30233
|
this.storedImageAccount = result.session.userId;
|
|
29871
30234
|
const auth = this.mega.auth;
|
|
29872
30235
|
if (auth?.userId)
|
|
@@ -29903,12 +30266,12 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29903
30266
|
if (options.timeoutMs === void 0)
|
|
29904
30267
|
return generation.promise;
|
|
29905
30268
|
const timeoutMs = Math.max(0, options.timeoutMs);
|
|
29906
|
-
return new Promise((
|
|
29907
|
-
const timer = setTimeout(() =>
|
|
30269
|
+
return new Promise((resolve2) => {
|
|
30270
|
+
const timer = setTimeout(() => resolve2(readinessSnapshot(generation.readiness, "timed-out")), timeoutMs);
|
|
29908
30271
|
timer.unref?.();
|
|
29909
30272
|
void generation.promise.then((result) => {
|
|
29910
30273
|
clearTimeout(timer);
|
|
29911
|
-
|
|
30274
|
+
resolve2(result);
|
|
29912
30275
|
});
|
|
29913
30276
|
});
|
|
29914
30277
|
}
|
|
@@ -29949,10 +30312,10 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
29949
30312
|
let acknowledge;
|
|
29950
30313
|
let reject;
|
|
29951
30314
|
let acknowledged = false;
|
|
29952
|
-
const acknowledgement = new Promise((
|
|
30315
|
+
const acknowledgement = new Promise((resolve2, rejectPromise) => {
|
|
29953
30316
|
acknowledge = () => {
|
|
29954
30317
|
acknowledged = true;
|
|
29955
|
-
|
|
30318
|
+
resolve2();
|
|
29956
30319
|
};
|
|
29957
30320
|
reject = rejectPromise;
|
|
29958
30321
|
});
|
|
@@ -30319,8 +30682,8 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30319
30682
|
if (this.realtimeGeneration?.epoch === this.realtimeEpoch)
|
|
30320
30683
|
return this.realtimeGeneration;
|
|
30321
30684
|
const epoch = this.realtimeEpoch;
|
|
30322
|
-
let
|
|
30323
|
-
const promise = new Promise((done) =>
|
|
30685
|
+
let resolve2;
|
|
30686
|
+
const promise = new Promise((done) => resolve2 = done);
|
|
30324
30687
|
const generation = {
|
|
30325
30688
|
epoch,
|
|
30326
30689
|
readiness: {
|
|
@@ -30330,7 +30693,7 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30330
30693
|
wiredP2p: createPlaneReadiness()
|
|
30331
30694
|
},
|
|
30332
30695
|
promise,
|
|
30333
|
-
resolve,
|
|
30696
|
+
resolve: resolve2,
|
|
30334
30697
|
abort: new AbortController(),
|
|
30335
30698
|
settled: false
|
|
30336
30699
|
};
|
|
@@ -30481,6 +30844,12 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30481
30844
|
* device absent from the previous pass is skipped — first sight is discovery, not a transition;
|
|
30482
30845
|
* {@link deviceState} answers an initial reading.
|
|
30483
30846
|
*
|
|
30847
|
+
* A device with changed params is re-resolved by {@link widenCapabilities} once per pass, however many
|
|
30848
|
+
* of its params moved, since each re-resolution is a cloud param-list read.
|
|
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
|
+
*
|
|
30484
30853
|
* Then re-reads every Tuya clean-line robot from the Tuya cloud ({@link readTuyaDevices}): its state
|
|
30485
30854
|
* is not in the cloud record this pass diffs and no live channel carries it, so the poll is where it
|
|
30486
30855
|
* stays fresh. That read runs after the `try`, not inside it, so a cloud error on the list diff does
|
|
@@ -30499,8 +30868,9 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30499
30868
|
this.emitSemantic(out.event, out.payload, { refresh: out.refresh });
|
|
30500
30869
|
for (const dev of diff.reported)
|
|
30501
30870
|
this.emit("deviceState", this.stateOf(dev));
|
|
30502
|
-
for (const
|
|
30503
|
-
await this.widenCapabilities(
|
|
30871
|
+
for (const sn of new Set(diff.params.map((c) => c.deviceSn)))
|
|
30872
|
+
await this.widenCapabilities(sn);
|
|
30873
|
+
await this.syncMqttRoster();
|
|
30504
30874
|
} catch (e) {
|
|
30505
30875
|
this.reportError(e);
|
|
30506
30876
|
}
|
|
@@ -30840,12 +31210,21 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30840
31210
|
/**
|
|
30841
31211
|
* Subscribe the devices on one credential scope; a failing subscribe is reported, not fatal (one
|
|
30842
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.
|
|
30843
31216
|
*
|
|
30844
31217
|
* A Tuya clean-line device (`eufy_home_tuya`) is not subscribed: its state lives on the Tuya cloud,
|
|
30845
31218
|
* and the broker refuses its `eufy_home` topics on every credential.
|
|
30846
31219
|
*/
|
|
30847
31220
|
async subscribeMqttDevices(scope, transport) {
|
|
31221
|
+
let subscribed = this.mqttSubscribed.get(transport);
|
|
31222
|
+
if (!subscribed)
|
|
31223
|
+
this.mqttSubscribed.set(transport, subscribed = /* @__PURE__ */ new Set());
|
|
30848
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);
|
|
30849
31228
|
try {
|
|
30850
31229
|
await transport.subscribeDevice(d);
|
|
30851
31230
|
await this.sendRealtimeInit(d.sn);
|
|
@@ -30854,6 +31233,23 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30854
31233
|
}
|
|
30855
31234
|
}
|
|
30856
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
|
+
}
|
|
30857
31253
|
/**
|
|
30858
31254
|
* Send whatever a device's capabilities want sent once its realtime channel is up — for a line whose
|
|
30859
31255
|
* state is pushed on change with no heartbeat, the request that makes its state readable before the
|
|
@@ -30886,31 +31282,72 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
30886
31282
|
return this.p2p.getSessions();
|
|
30887
31283
|
}
|
|
30888
31284
|
/**
|
|
30889
|
-
* The people enrolled on a station, read from its own `person_basic_info` table
|
|
31285
|
+
* The people enrolled on a station, read from its own `person_basic_info` table through
|
|
31286
|
+
* {@link readStationTable}.
|
|
30890
31287
|
*
|
|
30891
31288
|
* The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
|
|
30892
31289
|
* station, because they were never uploaded — the station holds them.
|
|
30893
31290
|
*
|
|
30894
|
-
* Resolves with the rows the station returned
|
|
30895
|
-
*
|
|
30896
|
-
* reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
|
|
31291
|
+
* Resolves with the rows the station returned that are objects. Rejects as {@link readStationTable}
|
|
31292
|
+
* does.
|
|
30897
31293
|
*
|
|
30898
31294
|
* `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
|
|
30899
31295
|
* returned as they arrive; a caller that wants only named people filters them.
|
|
30900
31296
|
*/
|
|
30901
31297
|
async getStationFaces(stationSn, opts = {}) {
|
|
31298
|
+
const rows = await this.readStationTable(stationSn, "person_basic_info", opts);
|
|
31299
|
+
return rows.filter((row) => typeof row === "object" && row !== null);
|
|
31300
|
+
}
|
|
31301
|
+
/**
|
|
31302
|
+
* The rows of one of a station's own tables (`history_record_info`, `person_basic_info`, …), read over
|
|
31303
|
+
* the transport the station speaks: the control channel of a HomeBase S1 Pro (T9000), P2P for any
|
|
31304
|
+
* other station. The query is the table's whole content; the rows are the station's, shaped by it.
|
|
31305
|
+
*
|
|
31306
|
+
* Resolves with the rows the station returned. Rejects on a serial not in the device list, on a record
|
|
31307
|
+
* stating no `admin_user_id`, on a station that cannot be reached, on a query the station refuses, and
|
|
31308
|
+
* on the reply not arriving in time. Assembling the reply is the transport's; this resolves which
|
|
31309
|
+
* station, which transport and which account id.
|
|
31310
|
+
*/
|
|
31311
|
+
async readStationTable(stationSn, table, opts = {}) {
|
|
31312
|
+
const { station, accountId, route } = await this.stationReadTarget("readStationTable", stationSn);
|
|
31313
|
+
if (route)
|
|
31314
|
+
return this.rtc.readTable(route, table, opts);
|
|
31315
|
+
await this.p2p.ensureStation(station, opts.signal);
|
|
31316
|
+
const session = this.p2p.getSessions().get(station);
|
|
31317
|
+
if (!session)
|
|
31318
|
+
throw new Error(`readStationTable: no P2P session for ${station}`);
|
|
31319
|
+
return session.readDatabase(table, { accountId, ...opts });
|
|
31320
|
+
}
|
|
31321
|
+
/**
|
|
31322
|
+
* One image stored on a HomeBase S1 Pro (T9000), by the on-station path a table row names
|
|
31323
|
+
* (`thumb_path`, …), read over the station's control channel.
|
|
31324
|
+
*
|
|
31325
|
+
* Resolves with the image bytes as the station holds them. Rejects on a serial not in the device list,
|
|
31326
|
+
* on a record stating no `admin_user_id`, on a station other than a T9000, on a station that cannot be
|
|
31327
|
+
* reached, and on the reply not arriving in time. Fetching the image is the transport's; this resolves
|
|
31328
|
+
* which station and which account id.
|
|
31329
|
+
*/
|
|
31330
|
+
async readStationImage(stationSn, filePath, opts = {}) {
|
|
31331
|
+
const { station, route } = await this.stationReadTarget("readStationImage", stationSn);
|
|
31332
|
+
if (!route)
|
|
31333
|
+
throw new Error(`readStationImage: ${station} is not a T9000; its stored images are not read here`);
|
|
31334
|
+
return this.rtc.readImage(route, filePath, opts);
|
|
31335
|
+
}
|
|
31336
|
+
/**
|
|
31337
|
+
* The station a database read goes to, its `admin_user_id` and, for a T9000, its RTC route. Throws,
|
|
31338
|
+
* prefixed with `op`, on a serial the device list does not carry and on a record stating no admin id.
|
|
31339
|
+
*/
|
|
31340
|
+
async stationReadTarget(op, stationSn) {
|
|
30902
31341
|
if (!this.registry.list().length)
|
|
30903
31342
|
await this.getDevices();
|
|
30904
31343
|
const station = this.p2p.stationKeyOf(stationSn);
|
|
31344
|
+
const found = this.stationOf(station);
|
|
31345
|
+
if (!found.station)
|
|
31346
|
+
throw new Error(`${op}: ${station} is not in the device list`);
|
|
30905
31347
|
const accountId = this.adminUserIdOf(station);
|
|
30906
31348
|
if (!accountId)
|
|
30907
|
-
throw new Error(
|
|
30908
|
-
|
|
30909
|
-
const session = this.p2p.getSessions().get(station);
|
|
30910
|
-
if (!session)
|
|
30911
|
-
throw new Error(`getStationFaces: no P2P session for ${station}`);
|
|
30912
|
-
const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
|
|
30913
|
-
return rows.filter((row) => typeof row === "object" && row !== null);
|
|
31349
|
+
throw new Error(`${op}: no admin_user_id on record for ${station}`);
|
|
31350
|
+
return { station, accountId, route: found.t9000 ? this.rtcRouteFor(station) : void 0 };
|
|
30914
31351
|
}
|
|
30915
31352
|
/** The station's own `admin_user_id`, which its database refuses the query without. */
|
|
30916
31353
|
adminUserIdOf(stationSn) {
|
|
@@ -31137,6 +31574,9 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31137
31574
|
* Returns the connected client rather than installing it. Registration can outlive a `disconnect()`,
|
|
31138
31575
|
* and {@link ensureRealtime} owns the decision of whether a finished bring-up is still the current
|
|
31139
31576
|
* one — so this never overwrites a channel a later login already brought up.
|
|
31577
|
+
*
|
|
31578
|
+
* Persisting the seen ids after each push reports a failed `save` through `error` rather than throwing:
|
|
31579
|
+
* the push handler runs inside the MCS socket's `data` callback, where a throw is an uncaught exception.
|
|
31140
31580
|
*/
|
|
31141
31581
|
async startPush(signal) {
|
|
31142
31582
|
if (!this.mega.auth)
|
|
@@ -31183,9 +31623,13 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31183
31623
|
if (dsn)
|
|
31184
31624
|
this.prewarmForEvent(out.event, dsn);
|
|
31185
31625
|
}
|
|
31186
|
-
|
|
31626
|
+
try {
|
|
31627
|
+
store.save({ creds: persistedCreds, persistentIds: client.getPersistentIds() });
|
|
31628
|
+
} catch (e) {
|
|
31629
|
+
this.reportError(e);
|
|
31630
|
+
}
|
|
31187
31631
|
});
|
|
31188
|
-
await new Promise((
|
|
31632
|
+
await new Promise((resolve2, reject) => {
|
|
31189
31633
|
const cleanup = () => {
|
|
31190
31634
|
signal?.removeEventListener("abort", onAbort);
|
|
31191
31635
|
client.off("connect", onConnect);
|
|
@@ -31193,7 +31637,7 @@ var EufyMega = class extends EventEmitter14 {
|
|
|
31193
31637
|
};
|
|
31194
31638
|
const onConnect = () => {
|
|
31195
31639
|
cleanup();
|
|
31196
|
-
|
|
31640
|
+
resolve2();
|
|
31197
31641
|
};
|
|
31198
31642
|
const onError = (error) => {
|
|
31199
31643
|
cleanup();
|
|
@@ -32483,6 +32927,7 @@ export {
|
|
|
32483
32927
|
aesKey,
|
|
32484
32928
|
asBool,
|
|
32485
32929
|
assertNever,
|
|
32930
|
+
beginsAccessUnit,
|
|
32486
32931
|
bizChannelName,
|
|
32487
32932
|
buildActions,
|
|
32488
32933
|
buildAppShapedClientId,
|