@mega-yfue/eufy-sdk 0.5.0-beta.1 → 0.5.0-beta.11
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 +20 -6
- package/dist/client/types.d.ts +1 -1
- package/dist/core/contracts.d.ts +2 -2
- package/dist/core/store.d.ts +2 -0
- package/dist/index.js +1383 -39
- package/dist/index.js.map +4 -4
- package/dist/model/device-family.d.ts +2 -0
- package/dist/model/registry.d.ts +3 -2
- package/dist/model/types.d.ts +6 -0
- package/dist/transport/http/mega-client.d.ts +17 -0
- package/dist/transport/p2p/command-router.d.ts +8 -5
- package/dist/transport/p2p/live-stream.d.ts +3 -0
- package/dist/transport/p2p/p2p-session.d.ts +7 -2
- package/dist/transport/p2p/video.d.ts +3 -2
- package/dist/transport/rtc/command-router.d.ts +72 -0
- package/dist/transport/rtc/peer.d.ts +93 -0
- package/dist/transport/rtc/portal-packet.d.ts +66 -0
- package/dist/transport/rtc/ptcs-framer.d.ts +85 -0
- package/dist/transport/rtc/scall-sdp.d.ts +42 -0
- package/dist/transport/rtc/session.d.ts +78 -0
- package/dist/transport/rtc/signaling.d.ts +137 -0
- package/package.json +9 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ var __export = (target, all) => {
|
|
|
5
5
|
};
|
|
6
6
|
|
|
7
7
|
// dist/client/eufy-mega.js
|
|
8
|
-
import { EventEmitter as
|
|
8
|
+
import { EventEmitter as EventEmitter12 } from "node:events";
|
|
9
9
|
|
|
10
10
|
// dist/transport/http/mega-client.js
|
|
11
11
|
import { createHash as createHash3 } from "node:crypto";
|
|
@@ -1563,6 +1563,8 @@ function withoutTokenEcho(text2) {
|
|
|
1563
1563
|
var MegaHttpClient = class {
|
|
1564
1564
|
cfg;
|
|
1565
1565
|
region;
|
|
1566
|
+
/** The host `estimate_domain` answered, persisted with the session. */
|
|
1567
|
+
estimatedDomain = "";
|
|
1566
1568
|
bootstrapDomain;
|
|
1567
1569
|
sessionKey;
|
|
1568
1570
|
/** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
|
|
@@ -1643,6 +1645,7 @@ var MegaHttpClient = class {
|
|
|
1643
1645
|
if (!isSessionValid(saved) || !saved)
|
|
1644
1646
|
return void 0;
|
|
1645
1647
|
this.region = saved.region;
|
|
1648
|
+
this.estimatedDomain = saved.estimatedDomain ?? "";
|
|
1646
1649
|
this.auth_ = {
|
|
1647
1650
|
userId: saved.userId,
|
|
1648
1651
|
accountUserId: saved.accountUserId,
|
|
@@ -1667,6 +1670,24 @@ var MegaHttpClient = class {
|
|
|
1667
1670
|
get regionShard() {
|
|
1668
1671
|
return this.region;
|
|
1669
1672
|
}
|
|
1673
|
+
/**
|
|
1674
|
+
* The shard the RTC signalling signs on: the regional prefix of the estimated domain
|
|
1675
|
+
* (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
|
|
1676
|
+
* like `ie-pr`, whose accounts need the IE sign host and cluster.
|
|
1677
|
+
*/
|
|
1678
|
+
get rtcShard() {
|
|
1679
|
+
const m = /-([a-z]{2})(?:[.-]|$)/i.exec(this.estimatedDomain);
|
|
1680
|
+
return m ? `${m[1].toLowerCase()}-pr` : this.region;
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
|
|
1684
|
+
* ("gtoken not equal userid") unless its gtoken is the one every authed HTTP call carries.
|
|
1685
|
+
*/
|
|
1686
|
+
rtcIdentity() {
|
|
1687
|
+
if (!this.auth_)
|
|
1688
|
+
return void 0;
|
|
1689
|
+
return { authToken: this.auth_.authToken, userId: this.auth_.userId, gtoken: gtoken(this.gtokenUserId()) };
|
|
1690
|
+
}
|
|
1670
1691
|
/**
|
|
1671
1692
|
* The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
|
|
1672
1693
|
* pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
|
|
@@ -1764,6 +1785,7 @@ var MegaHttpClient = class {
|
|
|
1764
1785
|
const env = res.data;
|
|
1765
1786
|
const d = env?.data ?? {};
|
|
1766
1787
|
const domain = d.domain ?? d.host ?? d.server_secret_info ?? "";
|
|
1788
|
+
this.estimatedDomain = domain;
|
|
1767
1789
|
const blob = JSON.stringify(d);
|
|
1768
1790
|
if (blob.includes("-eu-") || domain.includes("-eu-"))
|
|
1769
1791
|
this.region = "eu-pr";
|
|
@@ -2422,6 +2444,7 @@ ${this.auth_?.authToken ?? ""}`;
|
|
|
2422
2444
|
authToken: this.auth_.authToken,
|
|
2423
2445
|
geoKey: this.auth_.geoKey,
|
|
2424
2446
|
region: this.region,
|
|
2447
|
+
estimatedDomain: this.estimatedDomain,
|
|
2425
2448
|
openudid: this.openudid,
|
|
2426
2449
|
phoneModel: this.phoneModel,
|
|
2427
2450
|
mediaUserAgent: this.mediaUserAgent,
|
|
@@ -5060,6 +5083,7 @@ var isIndoorCamera = (ctx) => has(INDOOR_CAMERA_TYPES, ctx.deviceType);
|
|
|
5060
5083
|
var isIndoorCamMini = (ctx) => ctx.deviceType === DeviceType.INDOOR_COST_DOWN_CAMERA;
|
|
5061
5084
|
var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
|
|
5062
5085
|
var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
|
|
5086
|
+
var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
|
|
5063
5087
|
var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
|
|
5064
5088
|
var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
|
|
5065
5089
|
|
|
@@ -11179,8 +11203,10 @@ function resolveDevice(rec) {
|
|
|
11179
11203
|
}
|
|
11180
11204
|
function resolveProperties(rec, codec, capabilities) {
|
|
11181
11205
|
const paramIds = /* @__PURE__ */ new Set();
|
|
11182
|
-
|
|
11183
|
-
|
|
11206
|
+
for (const reported of [rec.params, rec.dpParams]) {
|
|
11207
|
+
if (!reported || typeof reported !== "object")
|
|
11208
|
+
continue;
|
|
11209
|
+
for (const k of Object.keys(reported)) {
|
|
11184
11210
|
const n = Number(k);
|
|
11185
11211
|
if (Number.isFinite(n))
|
|
11186
11212
|
paramIds.add(n);
|
|
@@ -15699,8 +15725,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15699
15725
|
return this.connected;
|
|
15700
15726
|
}
|
|
15701
15727
|
/**
|
|
15702
|
-
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and
|
|
15703
|
-
* less or refuses.
|
|
15728
|
+
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and log once per session when it grants
|
|
15729
|
+
* less (debug) or refuses (warn).
|
|
15704
15730
|
*
|
|
15705
15731
|
* The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
|
|
15706
15732
|
* inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
|
|
@@ -15716,7 +15742,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15716
15742
|
if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
|
|
15717
15743
|
return;
|
|
15718
15744
|
this.receiveBufferReported = true;
|
|
15719
|
-
|
|
15745
|
+
const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
|
|
15746
|
+
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}.`);
|
|
15720
15747
|
}
|
|
15721
15748
|
/** Open the socket and start the lookup → hole-punch handshake. */
|
|
15722
15749
|
async connect() {
|
|
@@ -16248,6 +16275,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16248
16275
|
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16249
16276
|
return true;
|
|
16250
16277
|
}
|
|
16278
|
+
/**
|
|
16279
|
+
* The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
|
|
16280
|
+
* level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
|
|
16281
|
+
*/
|
|
16282
|
+
sendRawLevel1Bytes(payload, channel, outerCmd) {
|
|
16283
|
+
if (!this.connectAddress)
|
|
16284
|
+
return false;
|
|
16285
|
+
const data = Buffer.concat([
|
|
16286
|
+
buildCommandHeader(this.seqNumber, outerCmd),
|
|
16287
|
+
buildRawCommandPayload(encryptP2PData(paddingP2PData(payload), this.level1Key), channel, 1)
|
|
16288
|
+
]);
|
|
16289
|
+
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
16290
|
+
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16291
|
+
return true;
|
|
16292
|
+
}
|
|
16251
16293
|
/**
|
|
16252
16294
|
* Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
|
|
16253
16295
|
* its runtime topology takes, as {@link startLiveMedia} selects its start:
|
|
@@ -16809,11 +16851,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16809
16851
|
if (advance > SEQUENCE_LOOKBACK) {
|
|
16810
16852
|
if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
|
|
16811
16853
|
return;
|
|
16812
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16854
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16813
16855
|
this.trace({ phase: "sequence-restart", dataType });
|
|
16814
16856
|
this.clearReorderTimer(dataType);
|
|
16815
16857
|
this.reorderByType.delete(dataType);
|
|
16816
16858
|
this.pendingByDataType.delete(dataType);
|
|
16859
|
+
if (dataType === P2PDataType.VIDEO)
|
|
16860
|
+
this.emit("videoGap");
|
|
16817
16861
|
this.lastSeqByType.set(dataType, seqNo);
|
|
16818
16862
|
this.reassemble(dataType, msg.subarray(8));
|
|
16819
16863
|
return;
|
|
@@ -16879,9 +16923,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16879
16923
|
*/
|
|
16880
16924
|
abandonHole(dataType) {
|
|
16881
16925
|
const held = this.reorderByType.get(dataType).held;
|
|
16882
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16926
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16883
16927
|
this.trace({ phase: "datagram-gap", dataType });
|
|
16884
16928
|
this.pendingByDataType.delete(dataType);
|
|
16929
|
+
if (dataType === P2PDataType.VIDEO)
|
|
16930
|
+
this.emit("videoGap");
|
|
16885
16931
|
const last = this.lastSeqByType.get(dataType);
|
|
16886
16932
|
const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
|
|
16887
16933
|
this.lastSeqByType.set(dataType, earliest - 1 & 65535);
|
|
@@ -17518,7 +17564,8 @@ var AccessUnitAssembler = class {
|
|
|
17518
17564
|
* `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
|
|
17519
17565
|
* frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
|
|
17520
17566
|
* of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
|
|
17521
|
-
* carries).
|
|
17567
|
+
* carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
|
|
17568
|
+
* as a complete unit, even if its repeated header says keyframe.
|
|
17522
17569
|
*/
|
|
17523
17570
|
push(payload, decode) {
|
|
17524
17571
|
const header = parseVideoFrameHeader(payload);
|
|
@@ -17538,6 +17585,8 @@ var AccessUnitAssembler = class {
|
|
|
17538
17585
|
return [unitOf(open2.header, Buffer.concat(open2.chunks))];
|
|
17539
17586
|
}
|
|
17540
17587
|
this.discard();
|
|
17588
|
+
if (!beginsAccessUnit(body))
|
|
17589
|
+
return [];
|
|
17541
17590
|
if (full) {
|
|
17542
17591
|
this.open = { header, chunks: [body], carried: body.length };
|
|
17543
17592
|
return [];
|
|
@@ -17891,6 +17940,12 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17891
17940
|
lastCodec = "h264";
|
|
17892
17941
|
/** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
|
|
17893
17942
|
units = new AccessUnitAssembler((drop) => this.reportDroppedUnit(drop));
|
|
17943
|
+
/** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
|
|
17944
|
+
awaitingKeyframeAfterGap = false;
|
|
17945
|
+
videoGapHandler = () => {
|
|
17946
|
+
this.units.discard();
|
|
17947
|
+
this.awaitingKeyframeAfterGap = true;
|
|
17948
|
+
};
|
|
17894
17949
|
/** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
|
|
17895
17950
|
mediaChannel;
|
|
17896
17951
|
tracedFirstVideoCommand = false;
|
|
@@ -17933,6 +17988,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17933
17988
|
return this;
|
|
17934
17989
|
this.listening = true;
|
|
17935
17990
|
this.session.on("data", this.handler);
|
|
17991
|
+
this.session.on("videoGap", this.videoGapHandler);
|
|
17936
17992
|
this.session.on("liveStartUnacknowledged", this.unackedHandler);
|
|
17937
17993
|
this.sendStart();
|
|
17938
17994
|
const keepAliveMs = this.opts.keepAliveMs ?? DEFAULT_KEEPALIVE_MS;
|
|
@@ -18030,6 +18086,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18030
18086
|
return;
|
|
18031
18087
|
this.listening = false;
|
|
18032
18088
|
this.session.off("data", this.handler);
|
|
18089
|
+
this.session.off("videoGap", this.videoGapHandler);
|
|
18090
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18033
18091
|
this.session.off("liveStartUnacknowledged", this.unackedHandler);
|
|
18034
18092
|
if (this.kaTimer)
|
|
18035
18093
|
clearInterval(this.kaTimer);
|
|
@@ -18065,6 +18123,11 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18065
18123
|
try {
|
|
18066
18124
|
if (f.commandId === CMD_VIDEO_FRAME2) {
|
|
18067
18125
|
for (const unit of this.units.push(f.data, (payload) => this.annexbOf(payload, f.signCode))) {
|
|
18126
|
+
if (this.awaitingKeyframeAfterGap) {
|
|
18127
|
+
if (!unit.keyframe)
|
|
18128
|
+
continue;
|
|
18129
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18130
|
+
}
|
|
18068
18131
|
if (!this.tracedFirstVideoUnit) {
|
|
18069
18132
|
this.tracedFirstVideoUnit = true;
|
|
18070
18133
|
this.trace({ phase: "first-video-unit", keyframe: unit.keyframe });
|
|
@@ -18159,6 +18222,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18159
18222
|
* otherwise silent in both directions: no frame reaches a consumer, and nothing states why.
|
|
18160
18223
|
*/
|
|
18161
18224
|
reportDroppedUnit(drop) {
|
|
18225
|
+
this.awaitingKeyframeAfterGap = true;
|
|
18162
18226
|
const message = `[live] dropped an incomplete access unit (${drop.carried} bytes in ${drop.chunks} frame(s), tail never arrived, ${drop.count} so far)`;
|
|
18163
18227
|
if (drop.count === 1)
|
|
18164
18228
|
this.logger.warn(message);
|
|
@@ -21174,14 +21238,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21174
21238
|
}
|
|
21175
21239
|
}
|
|
21176
21240
|
/**
|
|
21177
|
-
* Restart
|
|
21178
|
-
*
|
|
21179
|
-
*
|
|
21180
|
-
*
|
|
21181
|
-
*
|
|
21241
|
+
* Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
|
|
21242
|
+
* broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
|
|
21243
|
+
* byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
|
|
21244
|
+
*
|
|
21245
|
+
* The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
|
|
21246
|
+
* level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
|
|
21247
|
+
* sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
|
|
21248
|
+
* control here is.
|
|
21182
21249
|
*/
|
|
21183
21250
|
async rebootStation(sn) {
|
|
21184
|
-
await this.
|
|
21251
|
+
await this.sendBySessionLevel(sn, {
|
|
21252
|
+
l2: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8), resolved),
|
|
21253
|
+
l1: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel1Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB), resolved)
|
|
21254
|
+
});
|
|
21185
21255
|
}
|
|
21186
21256
|
/**
|
|
21187
21257
|
* A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
|
|
@@ -22357,6 +22427,1237 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22357
22427
|
}
|
|
22358
22428
|
};
|
|
22359
22429
|
|
|
22430
|
+
// dist/transport/rtc/session.js
|
|
22431
|
+
import { EventEmitter as EventEmitter9 } from "node:events";
|
|
22432
|
+
|
|
22433
|
+
// dist/transport/rtc/peer.js
|
|
22434
|
+
import { EventEmitter as EventEmitter7 } from "node:events";
|
|
22435
|
+
|
|
22436
|
+
// dist/transport/rtc/portal-packet.js
|
|
22437
|
+
var MAGIC2 = Buffer.from("XZYH", "ascii");
|
|
22438
|
+
var PORTAL_HEADER_LENGTH = 16;
|
|
22439
|
+
var PortalLinkType = {
|
|
22440
|
+
/** Command channel: requests out, acknowledgements back. */
|
|
22441
|
+
COMMAND: 1,
|
|
22442
|
+
/** Station-originated frames: pushes, notify payloads, control results. */
|
|
22443
|
+
NOTIFY: 3
|
|
22444
|
+
};
|
|
22445
|
+
function encodeLe(value, width) {
|
|
22446
|
+
const buf = Buffer.alloc(width);
|
|
22447
|
+
let v = value >>> 0;
|
|
22448
|
+
for (let i = 0; i < width; i++) {
|
|
22449
|
+
buf[i] = v & 255;
|
|
22450
|
+
v >>>= 8;
|
|
22451
|
+
}
|
|
22452
|
+
return buf;
|
|
22453
|
+
}
|
|
22454
|
+
function buildPortalHeader(commandId, paramLength, channel, segment, isResponse = 0, devType = 2) {
|
|
22455
|
+
const header = Buffer.alloc(PORTAL_HEADER_LENGTH);
|
|
22456
|
+
MAGIC2.copy(header, 0);
|
|
22457
|
+
encodeLe(commandId, 2).copy(header, 4);
|
|
22458
|
+
encodeLe(paramLength, 4).copy(header, 6);
|
|
22459
|
+
header[10] = 0;
|
|
22460
|
+
header[11] = segment & 255;
|
|
22461
|
+
header[12] = channel & 255;
|
|
22462
|
+
header[13] = 0;
|
|
22463
|
+
header[14] = isResponse & 255;
|
|
22464
|
+
header[15] = devType & 255;
|
|
22465
|
+
return header;
|
|
22466
|
+
}
|
|
22467
|
+
function parsePortalHeader(buf) {
|
|
22468
|
+
if (buf.length < PORTAL_HEADER_LENGTH || buf.subarray(0, 4).compare(MAGIC2) !== 0)
|
|
22469
|
+
return void 0;
|
|
22470
|
+
return {
|
|
22471
|
+
commandId: buf.readUInt16LE(4),
|
|
22472
|
+
paramLength: buf.readUInt32LE(6),
|
|
22473
|
+
segment: buf[11],
|
|
22474
|
+
channel: buf[12],
|
|
22475
|
+
isResponse: buf[14],
|
|
22476
|
+
devType: buf[15]
|
|
22477
|
+
};
|
|
22478
|
+
}
|
|
22479
|
+
function isPortalPacket(buf) {
|
|
22480
|
+
return buf.length >= PORTAL_HEADER_LENGTH && buf.subarray(0, 4).compare(MAGIC2) === 0;
|
|
22481
|
+
}
|
|
22482
|
+
function buildPortalPacket(req) {
|
|
22483
|
+
const body = Buffer.from(JSON.stringify(req.payload), "utf8");
|
|
22484
|
+
const header = buildPortalHeader(req.commandId, body.length, req.channel, req.segment, req.isResponse ?? 0, req.devType ?? 2);
|
|
22485
|
+
return Buffer.concat([header, body]);
|
|
22486
|
+
}
|
|
22487
|
+
function parseJsonBody(buf) {
|
|
22488
|
+
let text2 = buf.toString("utf8");
|
|
22489
|
+
while (text2.endsWith("\0"))
|
|
22490
|
+
text2 = text2.slice(0, -1);
|
|
22491
|
+
try {
|
|
22492
|
+
return JSON.parse(text2);
|
|
22493
|
+
} catch {
|
|
22494
|
+
return text2;
|
|
22495
|
+
}
|
|
22496
|
+
}
|
|
22497
|
+
function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
|
|
22498
|
+
const header = parsePortalHeader(buf);
|
|
22499
|
+
if (!header)
|
|
22500
|
+
return void 0;
|
|
22501
|
+
const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
|
|
22502
|
+
if (linkType === PortalLinkType.NOTIFY) {
|
|
22503
|
+
const data = parseJsonBody(body);
|
|
22504
|
+
const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
|
|
22505
|
+
return {
|
|
22506
|
+
commandId: header.commandId,
|
|
22507
|
+
channel: header.channel,
|
|
22508
|
+
segment: header.segment,
|
|
22509
|
+
isResponse: header.isResponse,
|
|
22510
|
+
linkType,
|
|
22511
|
+
cmd,
|
|
22512
|
+
data
|
|
22513
|
+
};
|
|
22514
|
+
}
|
|
22515
|
+
return {
|
|
22516
|
+
commandId: header.commandId,
|
|
22517
|
+
channel: header.channel,
|
|
22518
|
+
segment: header.segment,
|
|
22519
|
+
isResponse: header.isResponse,
|
|
22520
|
+
linkType,
|
|
22521
|
+
errCode: body.length >= 4 ? body.readInt32LE(0) : -1,
|
|
22522
|
+
data: body.length > 4 ? parseJsonBody(body.subarray(4)) : void 0
|
|
22523
|
+
};
|
|
22524
|
+
}
|
|
22525
|
+
var SegmentCounter = class {
|
|
22526
|
+
current = 0;
|
|
22527
|
+
next() {
|
|
22528
|
+
this.current = this.current + 1 & 255;
|
|
22529
|
+
if (this.current === 0)
|
|
22530
|
+
this.current = 1;
|
|
22531
|
+
return this.current;
|
|
22532
|
+
}
|
|
22533
|
+
};
|
|
22534
|
+
|
|
22535
|
+
// dist/transport/rtc/ptcs-framer.js
|
|
22536
|
+
var MAGIC3 = Buffer.from("PTCS", "ascii");
|
|
22537
|
+
var PTCS_HEADER_LENGTH = 28;
|
|
22538
|
+
var PTCS_DEFAULT_PAYLOAD_BYTES = 1e3;
|
|
22539
|
+
var FLAG_BASE = 16384;
|
|
22540
|
+
var FLAG_LAST = 1024;
|
|
22541
|
+
var LENGTH_MASK = 1023;
|
|
22542
|
+
var PtcsChannel = {
|
|
22543
|
+
COMMAND: 0,
|
|
22544
|
+
NOTIFY: 2
|
|
22545
|
+
};
|
|
22546
|
+
function linkTypeForChannel(channel) {
|
|
22547
|
+
return channel === PtcsChannel.NOTIFY ? PortalLinkType.NOTIFY : PortalLinkType.COMMAND;
|
|
22548
|
+
}
|
|
22549
|
+
var PTCS_STALE_MS = 15e3;
|
|
22550
|
+
function parsePtcsHeader(packet) {
|
|
22551
|
+
if (packet.length < PTCS_HEADER_LENGTH || packet.subarray(0, 4).compare(MAGIC3) !== 0)
|
|
22552
|
+
return void 0;
|
|
22553
|
+
const flags = packet.readUInt16LE(18);
|
|
22554
|
+
return {
|
|
22555
|
+
channel: packet[5],
|
|
22556
|
+
sequence: packet.readUInt16LE(6),
|
|
22557
|
+
frameId: packet.readUInt32LE(8),
|
|
22558
|
+
frameLength: packet.readUInt32LE(12),
|
|
22559
|
+
index: packet.readUInt16LE(16),
|
|
22560
|
+
last: (flags & FLAG_LAST) !== 0,
|
|
22561
|
+
payloadLength: flags & LENGTH_MASK
|
|
22562
|
+
};
|
|
22563
|
+
}
|
|
22564
|
+
function packetize(frame, opts) {
|
|
22565
|
+
const size = opts.payloadBytes ?? PTCS_DEFAULT_PAYLOAD_BYTES;
|
|
22566
|
+
if (size <= 0 || size > LENGTH_MASK)
|
|
22567
|
+
throw new RangeError(`PTCS payload size ${size} out of range`);
|
|
22568
|
+
const channel = opts.channel ?? PtcsChannel.COMMAND;
|
|
22569
|
+
const count2 = Math.max(1, Math.ceil(frame.length / size));
|
|
22570
|
+
const sequence = (opts.sequence ?? 0) & 65535;
|
|
22571
|
+
const packets = [];
|
|
22572
|
+
for (let i = 0; i < count2; i++) {
|
|
22573
|
+
const chunk = frame.subarray(i * size, Math.min(frame.length, (i + 1) * size));
|
|
22574
|
+
const last = i === count2 - 1;
|
|
22575
|
+
const packet = Buffer.alloc(PTCS_HEADER_LENGTH + size);
|
|
22576
|
+
MAGIC3.copy(packet, 0);
|
|
22577
|
+
packet[4] = 3;
|
|
22578
|
+
packet[5] = channel & 255;
|
|
22579
|
+
packet.writeUInt16LE(sequence, 6);
|
|
22580
|
+
packet.writeUInt32LE(opts.frameId >>> 0, 8);
|
|
22581
|
+
packet.writeUInt32LE(frame.length >>> 0, 12);
|
|
22582
|
+
packet.writeUInt16LE(i, 16);
|
|
22583
|
+
packet.writeUInt16LE(FLAG_BASE | (last ? FLAG_LAST : 0) | chunk.length, 18);
|
|
22584
|
+
chunk.copy(packet, PTCS_HEADER_LENGTH);
|
|
22585
|
+
packets.push(packet);
|
|
22586
|
+
}
|
|
22587
|
+
return packets;
|
|
22588
|
+
}
|
|
22589
|
+
var PtcsReassembler = class {
|
|
22590
|
+
onFrame;
|
|
22591
|
+
now;
|
|
22592
|
+
partials = /* @__PURE__ */ new Map();
|
|
22593
|
+
constructor(onFrame, now2 = Date.now) {
|
|
22594
|
+
this.onFrame = onFrame;
|
|
22595
|
+
this.now = now2;
|
|
22596
|
+
}
|
|
22597
|
+
push(packet) {
|
|
22598
|
+
const h = parsePtcsHeader(packet);
|
|
22599
|
+
if (!h)
|
|
22600
|
+
return false;
|
|
22601
|
+
const body = packet.subarray(PTCS_HEADER_LENGTH, PTCS_HEADER_LENGTH + h.payloadLength);
|
|
22602
|
+
if (body.length !== h.payloadLength)
|
|
22603
|
+
return false;
|
|
22604
|
+
const key = `${h.channel}:${h.frameId}`;
|
|
22605
|
+
const now2 = this.now();
|
|
22606
|
+
let p = this.partials.get(key);
|
|
22607
|
+
if (!p) {
|
|
22608
|
+
p = { channel: h.channel, frameLength: h.frameLength, chunks: /* @__PURE__ */ new Map(), touched: now2 };
|
|
22609
|
+
this.partials.set(key, p);
|
|
22610
|
+
}
|
|
22611
|
+
p.touched = now2;
|
|
22612
|
+
p.chunks.set(h.index, Buffer.from(body));
|
|
22613
|
+
if (h.last)
|
|
22614
|
+
p.lastIndex = h.index;
|
|
22615
|
+
if (p.lastIndex === void 0)
|
|
22616
|
+
return true;
|
|
22617
|
+
let total = 0;
|
|
22618
|
+
const parts = [];
|
|
22619
|
+
for (let i = 0; i <= p.lastIndex; i++) {
|
|
22620
|
+
const c = p.chunks.get(i);
|
|
22621
|
+
if (!c)
|
|
22622
|
+
return true;
|
|
22623
|
+
parts.push(c);
|
|
22624
|
+
total += c.length;
|
|
22625
|
+
}
|
|
22626
|
+
this.partials.delete(key);
|
|
22627
|
+
if (total !== p.frameLength)
|
|
22628
|
+
return false;
|
|
22629
|
+
this.onFrame(Buffer.concat(parts, total), p.channel);
|
|
22630
|
+
return true;
|
|
22631
|
+
}
|
|
22632
|
+
/** Drop frames that stopped arriving; call periodically. */
|
|
22633
|
+
expire() {
|
|
22634
|
+
const now2 = this.now();
|
|
22635
|
+
for (const [key, p] of this.partials) {
|
|
22636
|
+
if (now2 - p.touched > PTCS_STALE_MS)
|
|
22637
|
+
this.partials.delete(key);
|
|
22638
|
+
}
|
|
22639
|
+
}
|
|
22640
|
+
};
|
|
22641
|
+
function frameIdClock(now2 = Date.now) {
|
|
22642
|
+
let last = 0;
|
|
22643
|
+
return () => {
|
|
22644
|
+
let id = now2() >>> 0;
|
|
22645
|
+
if (id <= last)
|
|
22646
|
+
id = last + 1 >>> 0;
|
|
22647
|
+
last = id;
|
|
22648
|
+
return id;
|
|
22649
|
+
};
|
|
22650
|
+
}
|
|
22651
|
+
var PtcsFramer = class {
|
|
22652
|
+
onWire;
|
|
22653
|
+
onFrame;
|
|
22654
|
+
reassembler;
|
|
22655
|
+
sweep;
|
|
22656
|
+
ready = false;
|
|
22657
|
+
nextFrameId = frameIdClock();
|
|
22658
|
+
sequence = 0;
|
|
22659
|
+
/** Arm the two callbacks; `sendFrame` may be called from here on. */
|
|
22660
|
+
init(onWirePacket, onFrame) {
|
|
22661
|
+
this.onWire = onWirePacket;
|
|
22662
|
+
this.onFrame = onFrame;
|
|
22663
|
+
this.reassembler = new PtcsReassembler((frame, channel) => this.onFrame?.(frame, linkTypeForChannel(channel)));
|
|
22664
|
+
this.sweep = setInterval(() => this.reassembler?.expire(), 1e3);
|
|
22665
|
+
this.sweep.unref?.();
|
|
22666
|
+
this.ready = true;
|
|
22667
|
+
}
|
|
22668
|
+
isReady() {
|
|
22669
|
+
return this.ready;
|
|
22670
|
+
}
|
|
22671
|
+
sendFrame(portalPacket) {
|
|
22672
|
+
if (!this.ready)
|
|
22673
|
+
throw new Error("PTCS framer not initialised");
|
|
22674
|
+
const packets = packetize(portalPacket, { frameId: this.nextFrameId(), sequence: this.sequence });
|
|
22675
|
+
this.sequence = this.sequence + 1 & 65535;
|
|
22676
|
+
for (const packet of packets)
|
|
22677
|
+
this.onWire?.(packet);
|
|
22678
|
+
}
|
|
22679
|
+
recvPacket(wirePacket) {
|
|
22680
|
+
if (!this.ready)
|
|
22681
|
+
return;
|
|
22682
|
+
if (isPortalPacket(wirePacket)) {
|
|
22683
|
+
this.onFrame?.(wirePacket, PortalLinkType.COMMAND);
|
|
22684
|
+
return;
|
|
22685
|
+
}
|
|
22686
|
+
this.reassembler?.push(wirePacket);
|
|
22687
|
+
}
|
|
22688
|
+
destroy() {
|
|
22689
|
+
this.ready = false;
|
|
22690
|
+
if (this.sweep)
|
|
22691
|
+
clearInterval(this.sweep);
|
|
22692
|
+
this.sweep = void 0;
|
|
22693
|
+
this.reassembler = void 0;
|
|
22694
|
+
this.onWire = void 0;
|
|
22695
|
+
this.onFrame = void 0;
|
|
22696
|
+
}
|
|
22697
|
+
};
|
|
22698
|
+
|
|
22699
|
+
// dist/transport/rtc/scall-sdp.js
|
|
22700
|
+
var HUB_SDP_MID = "2";
|
|
22701
|
+
var ANKER_MAX_MESSAGE_SIZE = 262144;
|
|
22702
|
+
function scallJsonToSdp(json, now2 = Date.now) {
|
|
22703
|
+
let sdp = "";
|
|
22704
|
+
sdp += "v=0\r\n";
|
|
22705
|
+
sdp += `o=- ${Math.floor(now2())} 1 IN IP4 127.0.0.1\r
|
|
22706
|
+
`;
|
|
22707
|
+
sdp += "s=Anker Webrtc Stream\r\n";
|
|
22708
|
+
sdp += "t=0 0\r\n";
|
|
22709
|
+
sdp += `a=group:BUNDLE ${HUB_SDP_MID}\r
|
|
22710
|
+
`;
|
|
22711
|
+
sdp += "a=msid-semantic: WMS\r\n";
|
|
22712
|
+
sdp += "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n";
|
|
22713
|
+
sdp += "c=IN IP4 127.0.0.1\r\n";
|
|
22714
|
+
sdp += `a=mid:${HUB_SDP_MID}\r
|
|
22715
|
+
`;
|
|
22716
|
+
sdp += "a=ice-options:trickle\r\n";
|
|
22717
|
+
if (json.ice?.ufrag)
|
|
22718
|
+
sdp += `a=ice-ufrag:${json.ice.ufrag}\r
|
|
22719
|
+
`;
|
|
22720
|
+
if (json.ice?.pwd)
|
|
22721
|
+
sdp += `a=ice-pwd:${json.ice.pwd}\r
|
|
22722
|
+
`;
|
|
22723
|
+
if (json.ice?.fingerprint) {
|
|
22724
|
+
const fp = json.ice.fingerprint.replace(/(.{2})(?=.)/g, "$1:");
|
|
22725
|
+
sdp += `a=fingerprint:${json.ice.fingerprint_type ?? "sha-256"} ${fp}\r
|
|
22726
|
+
`;
|
|
22727
|
+
}
|
|
22728
|
+
sdp += `a=setup:${json.setup ?? "actpass"}\r
|
|
22729
|
+
`;
|
|
22730
|
+
sdp += "a=sctp-port:5000\r\n";
|
|
22731
|
+
sdp += `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}\r
|
|
22732
|
+
`;
|
|
22733
|
+
for (const c of json.candidate ?? [])
|
|
22734
|
+
sdp += `a=candidate:${c}\r
|
|
22735
|
+
`;
|
|
22736
|
+
return sdp;
|
|
22737
|
+
}
|
|
22738
|
+
function sdpToScallJson(sdp) {
|
|
22739
|
+
const json = { ice: { ufrag: "", pwd: "", fingerprint: "" } };
|
|
22740
|
+
const setup = sdp.match(/a=setup:([^\r\n]+)/)?.[1];
|
|
22741
|
+
if (setup && setup !== "actpass")
|
|
22742
|
+
json.setup = setup;
|
|
22743
|
+
const ufrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)?.[1];
|
|
22744
|
+
if (ufrag)
|
|
22745
|
+
json.ice.ufrag = ufrag;
|
|
22746
|
+
const pwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)?.[1];
|
|
22747
|
+
if (pwd)
|
|
22748
|
+
json.ice.pwd = pwd;
|
|
22749
|
+
const fp = sdp.match(/a=fingerprint:([^\s]+)\s+([^\r\n]+)/);
|
|
22750
|
+
if (fp?.[2]) {
|
|
22751
|
+
json.ice.fingerprint_type = fp[1];
|
|
22752
|
+
json.ice.fingerprint = fp[2].replace(/:/g, "");
|
|
22753
|
+
}
|
|
22754
|
+
const candidates = [];
|
|
22755
|
+
for (const line of sdp.split(/\r\n|\r|\n/)) {
|
|
22756
|
+
const m = line.match(/^a=candidate:(.+)/);
|
|
22757
|
+
if (m?.[1])
|
|
22758
|
+
candidates.push(m[1]);
|
|
22759
|
+
}
|
|
22760
|
+
if (candidates.length)
|
|
22761
|
+
json.candidate = candidates;
|
|
22762
|
+
return json;
|
|
22763
|
+
}
|
|
22764
|
+
function iceCandidateType(candidate) {
|
|
22765
|
+
return candidate.match(/typ (\w+)/)?.[1] ?? "unknown";
|
|
22766
|
+
}
|
|
22767
|
+
function pinMaxMessageSize(sdp) {
|
|
22768
|
+
return sdp.replace(/a=max-message-size:\d+/g, `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}`);
|
|
22769
|
+
}
|
|
22770
|
+
function forceDtlsRole(sdp, role) {
|
|
22771
|
+
if (/a=setup:(active|passive|actpass)/.test(sdp))
|
|
22772
|
+
return sdp.replace(/a=setup:(active|passive|actpass)/g, `a=setup:${role}`);
|
|
22773
|
+
return sdp.replace(/(m=application[^\r\n]*\r?\n)/, `$1a=setup:${role}\r
|
|
22774
|
+
`);
|
|
22775
|
+
}
|
|
22776
|
+
function toWireCandidate(candidate) {
|
|
22777
|
+
return candidate.trim().replace(/^a=/, "");
|
|
22778
|
+
}
|
|
22779
|
+
|
|
22780
|
+
// dist/transport/rtc/peer.js
|
|
22781
|
+
var DATA_CHANNEL_LABELS = ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
|
|
22782
|
+
var COMMAND_CHANNEL = DATA_CHANNEL_LABELS[0];
|
|
22783
|
+
var DATA_CHANNEL_IDS = {
|
|
22784
|
+
WebrtcDataChannel: 0,
|
|
22785
|
+
audio: 2,
|
|
22786
|
+
idr: 4,
|
|
22787
|
+
video: 6,
|
|
22788
|
+
notify: 8,
|
|
22789
|
+
download: 10
|
|
22790
|
+
};
|
|
22791
|
+
function turnServers(turn) {
|
|
22792
|
+
const both = (hostname, port) => [
|
|
22793
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnUdp" },
|
|
22794
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnTcp" }
|
|
22795
|
+
];
|
|
22796
|
+
const servers = both(turn.turn_addr, turn.turn_port);
|
|
22797
|
+
if (turn.alt_turn_addr && turn.alt_turn_port)
|
|
22798
|
+
servers.push(...both(turn.alt_turn_addr, turn.alt_turn_port));
|
|
22799
|
+
return servers;
|
|
22800
|
+
}
|
|
22801
|
+
var UNBUILT_ARCHES = /* @__PURE__ */ new Set(["arm", "ia32"]);
|
|
22802
|
+
async function loadNativePeerFactory() {
|
|
22803
|
+
let ndc;
|
|
22804
|
+
try {
|
|
22805
|
+
ndc = await import("node-datachannel");
|
|
22806
|
+
} catch (err) {
|
|
22807
|
+
const message = UNBUILT_ARCHES.has(process.arch) ? `the WebRTC transport for a HomeBase S1 Pro (T9000) needs 'node-datachannel', which has no build for ${process.arch}; a 64-bit OS is needed to drive a T9000` : "the WebRTC transport for a HomeBase S1 Pro (T9000) needs the optional peer dependency 'node-datachannel' (`npm install node-datachannel`)";
|
|
22808
|
+
throw new Error(message, { cause: err });
|
|
22809
|
+
}
|
|
22810
|
+
return (name, config) => new ndc.PeerConnection(name, config);
|
|
22811
|
+
}
|
|
22812
|
+
var ANSWER_TIMEOUT_MS = 15e3;
|
|
22813
|
+
var RtcPeer = class extends EventEmitter7 {
|
|
22814
|
+
opts;
|
|
22815
|
+
pc;
|
|
22816
|
+
channels = /* @__PURE__ */ new Map();
|
|
22817
|
+
framer;
|
|
22818
|
+
wireTally = /* @__PURE__ */ new Map();
|
|
22819
|
+
commandOpen = false;
|
|
22820
|
+
remoteSet = false;
|
|
22821
|
+
handlingOffer = false;
|
|
22822
|
+
channelsCreated = false;
|
|
22823
|
+
gatheringDone = false;
|
|
22824
|
+
/** Set by the framer's wire callback when the native channel refused a packet mid-send. */
|
|
22825
|
+
wireSendFailed = false;
|
|
22826
|
+
pending = [];
|
|
22827
|
+
localAnswer;
|
|
22828
|
+
logger;
|
|
22829
|
+
constructor(opts = {}) {
|
|
22830
|
+
super();
|
|
22831
|
+
this.opts = opts;
|
|
22832
|
+
this.logger = opts.logger ?? noopLogger;
|
|
22833
|
+
}
|
|
22834
|
+
/** Create the native peer on the relay the hub granted in `scall 100`. */
|
|
22835
|
+
async init(turn) {
|
|
22836
|
+
if (this.pc)
|
|
22837
|
+
return;
|
|
22838
|
+
const createPeer = this.opts.createPeer ?? await loadNativePeerFactory();
|
|
22839
|
+
const config = {
|
|
22840
|
+
iceServers: turnServers(turn),
|
|
22841
|
+
iceTransportPolicy: "relay",
|
|
22842
|
+
maxMessageSize: ANKER_MAX_MESSAGE_SIZE,
|
|
22843
|
+
enableIceTcp: true
|
|
22844
|
+
};
|
|
22845
|
+
const pc = createPeer("eufy-sdk", config);
|
|
22846
|
+
this.pc = pc;
|
|
22847
|
+
pc.onLocalDescription((sdp, type) => {
|
|
22848
|
+
if (String(type).toLowerCase() === "answer" && this.localAnswer) {
|
|
22849
|
+
clearTimeout(this.localAnswer.timer);
|
|
22850
|
+
this.localAnswer.resolve(sdp);
|
|
22851
|
+
this.localAnswer = void 0;
|
|
22852
|
+
}
|
|
22853
|
+
});
|
|
22854
|
+
pc.onLocalCandidate((candidate) => {
|
|
22855
|
+
if (!candidate)
|
|
22856
|
+
return;
|
|
22857
|
+
if (!this.acceptsCandidate(candidate))
|
|
22858
|
+
return;
|
|
22859
|
+
this.emit("iceCandidate", candidate);
|
|
22860
|
+
});
|
|
22861
|
+
pc.onGatheringStateChange((state) => {
|
|
22862
|
+
if (state === "complete" && !this.gatheringDone) {
|
|
22863
|
+
this.gatheringDone = true;
|
|
22864
|
+
this.emit("iceGatheringComplete");
|
|
22865
|
+
}
|
|
22866
|
+
});
|
|
22867
|
+
pc.onStateChange((state) => {
|
|
22868
|
+
this.logger.debug(`[rtc] peer state ${state}`);
|
|
22869
|
+
this.emit("connectionState", state);
|
|
22870
|
+
});
|
|
22871
|
+
pc.onDataChannel((dc) => this.wireChannel(dc.getLabel(), dc));
|
|
22872
|
+
}
|
|
22873
|
+
/**
|
|
22874
|
+
* The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
|
|
22875
|
+
*
|
|
22876
|
+
* The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
|
|
22877
|
+
* `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
|
|
22878
|
+
* only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
|
|
22879
|
+
* a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
|
|
22880
|
+
* The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
|
|
22881
|
+
* is taken as the local description: one read in `have-local-offer` is an offer.
|
|
22882
|
+
*/
|
|
22883
|
+
async handleRemoteOffer(offerSdp) {
|
|
22884
|
+
const pc = this.pc;
|
|
22885
|
+
if (!pc)
|
|
22886
|
+
throw new Error("RTC peer not initialised");
|
|
22887
|
+
if (this.handlingOffer)
|
|
22888
|
+
throw new Error("RTC peer already handling an offer");
|
|
22889
|
+
this.handlingOffer = true;
|
|
22890
|
+
try {
|
|
22891
|
+
const offer = forceDtlsRole(offerSdp, "passive");
|
|
22892
|
+
const answerWait = new Promise((resolve, reject) => {
|
|
22893
|
+
const timer = setTimeout(() => {
|
|
22894
|
+
this.localAnswer = void 0;
|
|
22895
|
+
reject(new Error("timed out waiting for the local SDP answer"));
|
|
22896
|
+
}, ANSWER_TIMEOUT_MS);
|
|
22897
|
+
this.localAnswer = { resolve, reject, timer };
|
|
22898
|
+
});
|
|
22899
|
+
pc.setRemoteDescription(offer, "offer");
|
|
22900
|
+
this.remoteSet = true;
|
|
22901
|
+
this.createChannels();
|
|
22902
|
+
const local = pc.localDescription();
|
|
22903
|
+
let answer = String(local?.type ?? "").toLowerCase() === "answer" ? local?.sdp ?? "" : "";
|
|
22904
|
+
if (!answer)
|
|
22905
|
+
answer = await answerWait;
|
|
22906
|
+
else if (this.localAnswer) {
|
|
22907
|
+
clearTimeout(this.localAnswer.timer);
|
|
22908
|
+
this.localAnswer = void 0;
|
|
22909
|
+
}
|
|
22910
|
+
this.flushPending();
|
|
22911
|
+
return pinMaxMessageSize(answer);
|
|
22912
|
+
} finally {
|
|
22913
|
+
this.handlingOffer = false;
|
|
22914
|
+
}
|
|
22915
|
+
}
|
|
22916
|
+
addRemoteCandidate(candidate) {
|
|
22917
|
+
if (!this.pc)
|
|
22918
|
+
return;
|
|
22919
|
+
if (!this.acceptsCandidate(candidate))
|
|
22920
|
+
return;
|
|
22921
|
+
if (!this.remoteSet || this.handlingOffer) {
|
|
22922
|
+
this.pending.push(candidate);
|
|
22923
|
+
return;
|
|
22924
|
+
}
|
|
22925
|
+
this.addNow(candidate);
|
|
22926
|
+
}
|
|
22927
|
+
/**
|
|
22928
|
+
* Send one portal packet on the command channel, through the framer. False when the channel is not
|
|
22929
|
+
* usable or the native send refused a wire packet, so a dropped frame does not read as success.
|
|
22930
|
+
*/
|
|
22931
|
+
sendCommand(portalPacket) {
|
|
22932
|
+
const dc = this.channels.get(COMMAND_CHANNEL);
|
|
22933
|
+
if (!dc?.isOpen() || !this.commandOpen || !this.framer?.isReady())
|
|
22934
|
+
return false;
|
|
22935
|
+
this.wireSendFailed = false;
|
|
22936
|
+
try {
|
|
22937
|
+
this.framer.sendFrame(portalPacket);
|
|
22938
|
+
} catch (e) {
|
|
22939
|
+
this.logger.warn(`[rtc] command send failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
22940
|
+
return false;
|
|
22941
|
+
}
|
|
22942
|
+
return !this.wireSendFailed;
|
|
22943
|
+
}
|
|
22944
|
+
/** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
|
|
22945
|
+
close() {
|
|
22946
|
+
this.framer?.destroy();
|
|
22947
|
+
this.framer = void 0;
|
|
22948
|
+
if (this.localAnswer) {
|
|
22949
|
+
clearTimeout(this.localAnswer.timer);
|
|
22950
|
+
const pending = this.localAnswer;
|
|
22951
|
+
this.localAnswer = void 0;
|
|
22952
|
+
pending.reject(new Error("RTC peer closed while waiting for the local SDP answer"));
|
|
22953
|
+
}
|
|
22954
|
+
this.commandOpen = false;
|
|
22955
|
+
this.gatheringDone = false;
|
|
22956
|
+
this.pc?.close();
|
|
22957
|
+
this.pc = void 0;
|
|
22958
|
+
this.channels.clear();
|
|
22959
|
+
this.channelsCreated = false;
|
|
22960
|
+
this.remoteSet = false;
|
|
22961
|
+
this.pending.length = 0;
|
|
22962
|
+
}
|
|
22963
|
+
acceptsCandidate(candidate) {
|
|
22964
|
+
return iceCandidateType(candidate) === "relay";
|
|
22965
|
+
}
|
|
22966
|
+
createChannels() {
|
|
22967
|
+
if (!this.pc || this.channelsCreated)
|
|
22968
|
+
return;
|
|
22969
|
+
this.channelsCreated = true;
|
|
22970
|
+
for (const label of DATA_CHANNEL_LABELS) {
|
|
22971
|
+
const dc = this.pc.createDataChannel(label, { id: DATA_CHANNEL_IDS[label], unordered: false });
|
|
22972
|
+
this.wireChannel(label, dc);
|
|
22973
|
+
}
|
|
22974
|
+
}
|
|
22975
|
+
/**
|
|
22976
|
+
* Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
|
|
22977
|
+
* message before that has nothing to reassemble into and is dropped.
|
|
22978
|
+
*/
|
|
22979
|
+
wireChannel(label, dc) {
|
|
22980
|
+
if (this.channels.has(label))
|
|
22981
|
+
return;
|
|
22982
|
+
this.channels.set(label, dc);
|
|
22983
|
+
dc.onOpen(() => {
|
|
22984
|
+
this.logger.debug(`[rtc] data channel open ${label}`);
|
|
22985
|
+
if (label !== COMMAND_CHANNEL)
|
|
22986
|
+
return;
|
|
22987
|
+
if (!this.framer) {
|
|
22988
|
+
const framer = new PtcsFramer();
|
|
22989
|
+
framer.init((packet) => {
|
|
22990
|
+
if (!dc.isOpen() || !dc.sendMessageBinary(packet))
|
|
22991
|
+
this.wireSendFailed = true;
|
|
22992
|
+
}, (frame, linkType) => this.emit("data", frame, linkType));
|
|
22993
|
+
this.framer = framer;
|
|
22994
|
+
}
|
|
22995
|
+
this.commandOpen = true;
|
|
22996
|
+
this.emit("commandChannelOpen");
|
|
22997
|
+
});
|
|
22998
|
+
dc.onClosed(() => {
|
|
22999
|
+
this.logger.debug(`[rtc] data channel closed ${label}`);
|
|
23000
|
+
if (label !== COMMAND_CHANNEL)
|
|
23001
|
+
return;
|
|
23002
|
+
const wasOpen = this.commandOpen;
|
|
23003
|
+
this.commandOpen = false;
|
|
23004
|
+
if (wasOpen)
|
|
23005
|
+
this.emit("commandChannelClosed");
|
|
23006
|
+
});
|
|
23007
|
+
dc.onError((err) => this.emit("error", new Error(`RTC data channel ${label}: ${err}`)));
|
|
23008
|
+
dc.onMessage((msg) => {
|
|
23009
|
+
const buf = typeof msg === "string" ? Buffer.from(msg) : Buffer.isBuffer(msg) ? msg : Buffer.from(msg);
|
|
23010
|
+
const n = (this.wireTally.get(label) ?? 0) + 1;
|
|
23011
|
+
this.wireTally.set(label, n);
|
|
23012
|
+
if (n <= 3 || n % 100 === 0) {
|
|
23013
|
+
this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
|
|
23014
|
+
}
|
|
23015
|
+
this.framer?.recvPacket(buf);
|
|
23016
|
+
});
|
|
23017
|
+
}
|
|
23018
|
+
addNow(candidate) {
|
|
23019
|
+
try {
|
|
23020
|
+
this.pc?.addRemoteCandidate(candidate, HUB_SDP_MID);
|
|
23021
|
+
} catch (e) {
|
|
23022
|
+
this.logger.warn(`[rtc] addRemoteCandidate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23023
|
+
}
|
|
23024
|
+
}
|
|
23025
|
+
flushPending() {
|
|
23026
|
+
const queued = this.pending.splice(0);
|
|
23027
|
+
for (const c of queued)
|
|
23028
|
+
this.addNow(c);
|
|
23029
|
+
}
|
|
23030
|
+
};
|
|
23031
|
+
|
|
23032
|
+
// dist/transport/rtc/signaling.js
|
|
23033
|
+
import { EventEmitter as EventEmitter8 } from "node:events";
|
|
23034
|
+
import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
|
|
23035
|
+
var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
|
|
23036
|
+
var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
|
|
23037
|
+
var PORTAL_ORIGIN = "https://security.eufy.com";
|
|
23038
|
+
var SIGNALING_KEEPALIVE_MS = 25e3;
|
|
23039
|
+
var SIGNALING_CONNECT_TIMEOUT_MS = 15e3;
|
|
23040
|
+
var SOURCE = "WEB";
|
|
23041
|
+
function sessionAccount(channelId, adminUserId, ts, authToken) {
|
|
23042
|
+
return createHmac3("sha256", authToken).update(`${channelId}${adminUserId}${ts}`).digest("hex");
|
|
23043
|
+
}
|
|
23044
|
+
var WS_OPEN = 1;
|
|
23045
|
+
var defaultSocket = (url, init) => new WebSocket(url, {
|
|
23046
|
+
protocols: init.protocols,
|
|
23047
|
+
headers: { Origin: init.origin }
|
|
23048
|
+
});
|
|
23049
|
+
var RtcSignalingClient = class extends EventEmitter8 {
|
|
23050
|
+
opts;
|
|
23051
|
+
ws;
|
|
23052
|
+
sign;
|
|
23053
|
+
keepalive;
|
|
23054
|
+
wsUrl;
|
|
23055
|
+
signUrl;
|
|
23056
|
+
wsRegion;
|
|
23057
|
+
fetchImpl;
|
|
23058
|
+
createSocket;
|
|
23059
|
+
logger;
|
|
23060
|
+
now;
|
|
23061
|
+
makeMsgId;
|
|
23062
|
+
constructor(opts) {
|
|
23063
|
+
super();
|
|
23064
|
+
this.opts = opts;
|
|
23065
|
+
const prefix = (opts.shard.split("-")[0] || "us").toLowerCase();
|
|
23066
|
+
const smartHost = prefix === "us" ? "security-smart.eufylife.com" : `security-smart-${prefix}.eufylife.com`;
|
|
23067
|
+
this.wsUrl = `wss://${smartHost}${RTC_WS_PATH}`;
|
|
23068
|
+
this.signUrl = `https://${smartHost}${RTC_SIGN_PATH}`;
|
|
23069
|
+
this.wsRegion = prefix.toUpperCase();
|
|
23070
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
23071
|
+
this.createSocket = opts.createSocket ?? defaultSocket;
|
|
23072
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23073
|
+
this.now = opts.now ?? Date.now;
|
|
23074
|
+
this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
|
|
23075
|
+
}
|
|
23076
|
+
get isOpen() {
|
|
23077
|
+
return this.ws?.readyState === WS_OPEN;
|
|
23078
|
+
}
|
|
23079
|
+
/** Step 1: the sign blob the socket and every auth message carry. A non-JSON body is reported by status. */
|
|
23080
|
+
async fetchSign() {
|
|
23081
|
+
const res = await this.fetchImpl(this.signUrl, {
|
|
23082
|
+
headers: {
|
|
23083
|
+
"Web-Country": this.opts.country.toUpperCase(),
|
|
23084
|
+
"X-Auth-Token": this.opts.authToken,
|
|
23085
|
+
"App-Name": "eufy_mega",
|
|
23086
|
+
"Model-Type": "WEB",
|
|
23087
|
+
GToken: this.opts.gtoken,
|
|
23088
|
+
Origin: PORTAL_ORIGIN
|
|
23089
|
+
}
|
|
23090
|
+
});
|
|
23091
|
+
const body = await res.json().catch(() => ({})) ?? {};
|
|
23092
|
+
if (!res.ok || body.code !== 0 || !body.data) {
|
|
23093
|
+
throw new Error(`RTC sign for ${this.opts.stationSn} refused: HTTP ${res.status} code ${body.code ?? "?"} ${body.msg ?? ""}`.trim());
|
|
23094
|
+
}
|
|
23095
|
+
this.sign = body.data;
|
|
23096
|
+
return body.data;
|
|
23097
|
+
}
|
|
23098
|
+
/**
|
|
23099
|
+
* Step 2: open the socket and send the first auth; resolves on `open`. A socket that closes or errors
|
|
23100
|
+
* before it ever opened rejects, so the call always settles.
|
|
23101
|
+
*/
|
|
23102
|
+
async connect() {
|
|
23103
|
+
if (this.ws)
|
|
23104
|
+
return;
|
|
23105
|
+
const sign = this.sign ?? await this.fetchSign();
|
|
23106
|
+
const auth = {
|
|
23107
|
+
region: this.wsRegion,
|
|
23108
|
+
type: "NVR",
|
|
23109
|
+
sn: this.opts.stationSn,
|
|
23110
|
+
token: this.opts.authToken,
|
|
23111
|
+
gtoken: this.opts.gtoken,
|
|
23112
|
+
sign,
|
|
23113
|
+
appName: "eufy_mega",
|
|
23114
|
+
modelType: "WEB"
|
|
23115
|
+
};
|
|
23116
|
+
const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
|
|
23117
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
|
|
23118
|
+
await new Promise((resolve, reject) => {
|
|
23119
|
+
const timer = setTimeout(() => {
|
|
23120
|
+
reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
|
|
23121
|
+
this.close();
|
|
23122
|
+
}, SIGNALING_CONNECT_TIMEOUT_MS);
|
|
23123
|
+
const ws = this.createSocket(this.wsUrl, { protocols, origin: PORTAL_ORIGIN });
|
|
23124
|
+
this.ws = ws;
|
|
23125
|
+
let opened = false;
|
|
23126
|
+
ws.addEventListener("open", () => {
|
|
23127
|
+
opened = true;
|
|
23128
|
+
clearTimeout(timer);
|
|
23129
|
+
this.sendAuth(sign);
|
|
23130
|
+
this.startKeepalive();
|
|
23131
|
+
this.emit("open");
|
|
23132
|
+
resolve();
|
|
23133
|
+
});
|
|
23134
|
+
ws.addEventListener("message", (ev) => {
|
|
23135
|
+
void this.handleWireMessage(ev.data);
|
|
23136
|
+
});
|
|
23137
|
+
ws.addEventListener("close", (ev) => {
|
|
23138
|
+
clearTimeout(timer);
|
|
23139
|
+
this.stopKeepalive();
|
|
23140
|
+
this.ws = void 0;
|
|
23141
|
+
const code = ev.code ?? 1006;
|
|
23142
|
+
const reason = ev.reason ?? "";
|
|
23143
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling closed ${code} ${reason}`);
|
|
23144
|
+
if (!opened)
|
|
23145
|
+
reject(new Error(`RTC signalling closed before open: ${code} ${reason}`.trim()));
|
|
23146
|
+
this.emit("close", code, reason);
|
|
23147
|
+
});
|
|
23148
|
+
ws.addEventListener("error", (ev) => {
|
|
23149
|
+
clearTimeout(timer);
|
|
23150
|
+
const detail = ev ?? {};
|
|
23151
|
+
const cause = detail.error?.message ?? detail.error?.code ?? detail.message ?? "";
|
|
23152
|
+
reject(new Error(`RTC signalling socket error${cause ? ": " + cause : ""}`));
|
|
23153
|
+
});
|
|
23154
|
+
});
|
|
23155
|
+
}
|
|
23156
|
+
/** `action 1`, also what keeps the socket alive when re-sent. */
|
|
23157
|
+
sendAuth(sign) {
|
|
23158
|
+
const s = sign ?? this.sign;
|
|
23159
|
+
if (!s)
|
|
23160
|
+
throw new Error("RTC signalling: no sign to authenticate with");
|
|
23161
|
+
this.sendEnvelope("0", {
|
|
23162
|
+
code: 200,
|
|
23163
|
+
action: 1,
|
|
23164
|
+
data: s,
|
|
23165
|
+
sn: this.opts.stationSn,
|
|
23166
|
+
source: SOURCE,
|
|
23167
|
+
ts: Math.floor(this.now() / 1e3)
|
|
23168
|
+
});
|
|
23169
|
+
}
|
|
23170
|
+
/**
|
|
23171
|
+
* `action 3`, a session message on channel 0, the hub's own session; `scall` opens the negotiation.
|
|
23172
|
+
* `subSn` is empty for the hub's session.
|
|
23173
|
+
*/
|
|
23174
|
+
sendSession(dataType, payload = {}) {
|
|
23175
|
+
const channelId = 0;
|
|
23176
|
+
const ts = Math.floor(this.now() / 1e3);
|
|
23177
|
+
const inner = {
|
|
23178
|
+
code: 200,
|
|
23179
|
+
action: 3,
|
|
23180
|
+
sessionId: this.sign,
|
|
23181
|
+
sn: this.opts.stationSn,
|
|
23182
|
+
subSn: "",
|
|
23183
|
+
channelId,
|
|
23184
|
+
isResponse: 0,
|
|
23185
|
+
dataType,
|
|
23186
|
+
source: SOURCE,
|
|
23187
|
+
ts,
|
|
23188
|
+
data: JSON.stringify({
|
|
23189
|
+
timestamp: ts,
|
|
23190
|
+
account: sessionAccount(channelId, this.opts.adminUserId, ts, this.opts.authToken),
|
|
23191
|
+
...payload
|
|
23192
|
+
})
|
|
23193
|
+
};
|
|
23194
|
+
this.sendEnvelope(`${this.opts.authToken}_${this.makeMsgId()}`, inner);
|
|
23195
|
+
}
|
|
23196
|
+
sendCall() {
|
|
23197
|
+
this.sendSession("scall");
|
|
23198
|
+
}
|
|
23199
|
+
sendAck() {
|
|
23200
|
+
this.sendSession("ack");
|
|
23201
|
+
}
|
|
23202
|
+
/** The SDP answer, as scall JSON text, in an `info`. */
|
|
23203
|
+
sendInfoSdp(scallJson) {
|
|
23204
|
+
this.sendSession("info", { sdp: scallJson });
|
|
23205
|
+
}
|
|
23206
|
+
/** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
|
|
23207
|
+
sendInfoCandidate(candidate) {
|
|
23208
|
+
this.sendSession("info", { candidate });
|
|
23209
|
+
}
|
|
23210
|
+
sendHangup() {
|
|
23211
|
+
this.sendSession("hangup");
|
|
23212
|
+
}
|
|
23213
|
+
close() {
|
|
23214
|
+
this.stopKeepalive();
|
|
23215
|
+
const ws = this.ws;
|
|
23216
|
+
this.ws = void 0;
|
|
23217
|
+
try {
|
|
23218
|
+
ws?.close();
|
|
23219
|
+
} catch {
|
|
23220
|
+
}
|
|
23221
|
+
}
|
|
23222
|
+
startKeepalive() {
|
|
23223
|
+
this.stopKeepalive();
|
|
23224
|
+
this.keepalive = setInterval(() => {
|
|
23225
|
+
if (!this.isOpen)
|
|
23226
|
+
return;
|
|
23227
|
+
try {
|
|
23228
|
+
this.sendAuth();
|
|
23229
|
+
} catch (e) {
|
|
23230
|
+
this.logger.warn(`[rtc] ${this.opts.stationSn} signalling keepalive failed`, e);
|
|
23231
|
+
}
|
|
23232
|
+
}, SIGNALING_KEEPALIVE_MS);
|
|
23233
|
+
this.keepalive.unref?.();
|
|
23234
|
+
}
|
|
23235
|
+
stopKeepalive() {
|
|
23236
|
+
if (this.keepalive)
|
|
23237
|
+
clearInterval(this.keepalive);
|
|
23238
|
+
this.keepalive = void 0;
|
|
23239
|
+
}
|
|
23240
|
+
sendEnvelope(msgid, inner) {
|
|
23241
|
+
if (!this.isOpen || !this.ws)
|
|
23242
|
+
throw new Error("RTC signalling not connected");
|
|
23243
|
+
const envelope = { msgid, data: JSON.stringify(inner) };
|
|
23244
|
+
this.ws.send(JSON.stringify(envelope));
|
|
23245
|
+
}
|
|
23246
|
+
async handleWireMessage(raw) {
|
|
23247
|
+
let text2;
|
|
23248
|
+
if (typeof raw === "string")
|
|
23249
|
+
text2 = raw;
|
|
23250
|
+
else if (Buffer.isBuffer(raw))
|
|
23251
|
+
text2 = raw.toString("utf8");
|
|
23252
|
+
else if (raw instanceof ArrayBuffer)
|
|
23253
|
+
text2 = Buffer.from(raw).toString("utf8");
|
|
23254
|
+
else if (typeof raw?.text === "function")
|
|
23255
|
+
text2 = await raw.text();
|
|
23256
|
+
else
|
|
23257
|
+
return;
|
|
23258
|
+
let envelope;
|
|
23259
|
+
let inner;
|
|
23260
|
+
try {
|
|
23261
|
+
envelope = JSON.parse(text2);
|
|
23262
|
+
if (typeof envelope?.data !== "string")
|
|
23263
|
+
return;
|
|
23264
|
+
inner = JSON.parse(envelope.data);
|
|
23265
|
+
} catch {
|
|
23266
|
+
return;
|
|
23267
|
+
}
|
|
23268
|
+
this.emit("message", inner, envelope);
|
|
23269
|
+
}
|
|
23270
|
+
};
|
|
23271
|
+
|
|
23272
|
+
// dist/transport/rtc/session.js
|
|
23273
|
+
var AUTH_TIMEOUT_MS = 15e3;
|
|
23274
|
+
var RtcSession = class extends EventEmitter9 {
|
|
23275
|
+
opts;
|
|
23276
|
+
signaling;
|
|
23277
|
+
peer;
|
|
23278
|
+
logger;
|
|
23279
|
+
turn;
|
|
23280
|
+
authOk = false;
|
|
23281
|
+
authWaiter;
|
|
23282
|
+
connected = false;
|
|
23283
|
+
closed = false;
|
|
23284
|
+
closeAnnounced = false;
|
|
23285
|
+
sdpHandled = false;
|
|
23286
|
+
chain = Promise.resolve();
|
|
23287
|
+
constructor(opts) {
|
|
23288
|
+
super();
|
|
23289
|
+
this.opts = opts;
|
|
23290
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23291
|
+
this.signaling = (opts.createSignaling ?? ((o) => new RtcSignalingClient(o)))(opts);
|
|
23292
|
+
this.peer = (opts.createPeer ?? ((o) => new RtcPeer(o)))({ logger: this.logger, ...opts.peer });
|
|
23293
|
+
this.signaling.on("message", (inner) => {
|
|
23294
|
+
this.chain = this.chain.then(() => this.onSignaling(inner)).catch((e) => {
|
|
23295
|
+
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
23296
|
+
});
|
|
23297
|
+
});
|
|
23298
|
+
this.signaling.on("close", () => {
|
|
23299
|
+
this.connected = false;
|
|
23300
|
+
this.announceClose();
|
|
23301
|
+
});
|
|
23302
|
+
this.signaling.on("error", (e) => this.emit("error", e));
|
|
23303
|
+
this.peer.on("iceCandidate", (c) => this.signaling.sendInfoCandidate(toWireCandidate(c)));
|
|
23304
|
+
this.peer.on("iceGatheringComplete", () => this.signaling.sendInfoCandidate(""));
|
|
23305
|
+
this.peer.on("commandChannelOpen", () => {
|
|
23306
|
+
if (this.connected)
|
|
23307
|
+
return;
|
|
23308
|
+
this.connected = true;
|
|
23309
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel open`);
|
|
23310
|
+
this.emit("connected");
|
|
23311
|
+
});
|
|
23312
|
+
this.peer.on("commandChannelClosed", () => {
|
|
23313
|
+
if (!this.connected)
|
|
23314
|
+
return;
|
|
23315
|
+
this.connected = false;
|
|
23316
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel closed`);
|
|
23317
|
+
this.announceClose();
|
|
23318
|
+
});
|
|
23319
|
+
this.peer.on("connectionState", (state) => {
|
|
23320
|
+
if ((state === "failed" || state === "closed") && this.connected) {
|
|
23321
|
+
this.connected = false;
|
|
23322
|
+
this.announceClose();
|
|
23323
|
+
}
|
|
23324
|
+
});
|
|
23325
|
+
this.peer.on("error", (e) => this.emit("error", e));
|
|
23326
|
+
this.peer.on("data", (frame, linkType) => this.emit("commandData", frame, linkType));
|
|
23327
|
+
}
|
|
23328
|
+
get isConnected() {
|
|
23329
|
+
return this.connected;
|
|
23330
|
+
}
|
|
23331
|
+
/**
|
|
23332
|
+
* Start the sequence; resolves once `scall` is sent. `connected` fires when the channel opens. A session
|
|
23333
|
+
* closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
|
|
23334
|
+
* placing the call.
|
|
23335
|
+
*/
|
|
23336
|
+
async connect() {
|
|
23337
|
+
await this.signaling.fetchSign();
|
|
23338
|
+
if (this.closed)
|
|
23339
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23340
|
+
await this.signaling.connect();
|
|
23341
|
+
await this.waitForAuth();
|
|
23342
|
+
if (this.closed)
|
|
23343
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23344
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 scall`);
|
|
23345
|
+
this.signaling.sendCall();
|
|
23346
|
+
}
|
|
23347
|
+
/** Send one portal packet; false when the command channel isn't open. */
|
|
23348
|
+
sendCommand(portalPacket) {
|
|
23349
|
+
return this.peer.sendCommand(portalPacket);
|
|
23350
|
+
}
|
|
23351
|
+
/**
|
|
23352
|
+
* Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
|
|
23353
|
+
* `connect()` still waiting for the signalling auth is rejected and its timer stopped.
|
|
23354
|
+
*/
|
|
23355
|
+
close() {
|
|
23356
|
+
if (this.closed)
|
|
23357
|
+
return;
|
|
23358
|
+
this.closed = true;
|
|
23359
|
+
this.connected = false;
|
|
23360
|
+
try {
|
|
23361
|
+
if (this.signaling.isOpen)
|
|
23362
|
+
this.signaling.sendHangup();
|
|
23363
|
+
} catch {
|
|
23364
|
+
}
|
|
23365
|
+
this.signaling.close();
|
|
23366
|
+
this.peer.close();
|
|
23367
|
+
this.settleAuth(new Error("RTC session closed while waiting for signalling auth"));
|
|
23368
|
+
this.announceClose();
|
|
23369
|
+
}
|
|
23370
|
+
announceClose() {
|
|
23371
|
+
if (this.closeAnnounced)
|
|
23372
|
+
return;
|
|
23373
|
+
this.closeAnnounced = true;
|
|
23374
|
+
this.emit("close");
|
|
23375
|
+
}
|
|
23376
|
+
waitForAuth() {
|
|
23377
|
+
if (this.authOk)
|
|
23378
|
+
return Promise.resolve();
|
|
23379
|
+
return new Promise((resolve, reject) => {
|
|
23380
|
+
const timer = setTimeout(() => {
|
|
23381
|
+
this.authWaiter = void 0;
|
|
23382
|
+
reject(new Error("RTC signalling auth timeout"));
|
|
23383
|
+
}, AUTH_TIMEOUT_MS);
|
|
23384
|
+
this.authWaiter = { resolve, reject, timer };
|
|
23385
|
+
});
|
|
23386
|
+
}
|
|
23387
|
+
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
23388
|
+
settleAuth(err) {
|
|
23389
|
+
const waiter = this.authWaiter;
|
|
23390
|
+
if (!waiter)
|
|
23391
|
+
return;
|
|
23392
|
+
this.authWaiter = void 0;
|
|
23393
|
+
clearTimeout(waiter.timer);
|
|
23394
|
+
if (err)
|
|
23395
|
+
waiter.reject(err);
|
|
23396
|
+
else
|
|
23397
|
+
waiter.resolve();
|
|
23398
|
+
}
|
|
23399
|
+
async onSignaling(inner) {
|
|
23400
|
+
if (inner.action === 1 && inner.code === 200) {
|
|
23401
|
+
this.authOk = true;
|
|
23402
|
+
this.settleAuth();
|
|
23403
|
+
}
|
|
23404
|
+
if (!inner.data)
|
|
23405
|
+
return;
|
|
23406
|
+
let payload;
|
|
23407
|
+
try {
|
|
23408
|
+
payload = JSON.parse(inner.data);
|
|
23409
|
+
} catch {
|
|
23410
|
+
return;
|
|
23411
|
+
}
|
|
23412
|
+
switch (inner.dataType) {
|
|
23413
|
+
case "scall":
|
|
23414
|
+
case "call":
|
|
23415
|
+
await this.onCall(payload);
|
|
23416
|
+
return;
|
|
23417
|
+
case "info":
|
|
23418
|
+
await this.onInfo(payload);
|
|
23419
|
+
return;
|
|
23420
|
+
case "hangup":
|
|
23421
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} hub hung up`);
|
|
23422
|
+
return;
|
|
23423
|
+
default:
|
|
23424
|
+
return;
|
|
23425
|
+
}
|
|
23426
|
+
}
|
|
23427
|
+
/** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
|
|
23428
|
+
async onCall(payload) {
|
|
23429
|
+
if (this.closed)
|
|
23430
|
+
return;
|
|
23431
|
+
const status = payload.status;
|
|
23432
|
+
if (status === 100 && payload.turn) {
|
|
23433
|
+
this.turn = payload.turn;
|
|
23434
|
+
await this.peer.init(payload.turn);
|
|
23435
|
+
return;
|
|
23436
|
+
}
|
|
23437
|
+
if (status === 200) {
|
|
23438
|
+
this.signaling.sendAck();
|
|
23439
|
+
return;
|
|
23440
|
+
}
|
|
23441
|
+
if (status === 486 || status === 408) {
|
|
23442
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} scall answered ${status}`));
|
|
23443
|
+
this.close();
|
|
23444
|
+
}
|
|
23445
|
+
}
|
|
23446
|
+
/**
|
|
23447
|
+
* An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
|
|
23448
|
+
* is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
|
|
23449
|
+
* is reported and left unanswered.
|
|
23450
|
+
*/
|
|
23451
|
+
async onInfo(payload) {
|
|
23452
|
+
if (this.closed)
|
|
23453
|
+
return;
|
|
23454
|
+
if (payload.format === "CANDIDATE") {
|
|
23455
|
+
if (payload.value)
|
|
23456
|
+
this.peer.addRemoteCandidate(payload.value);
|
|
23457
|
+
return;
|
|
23458
|
+
}
|
|
23459
|
+
if (payload.candidate !== void 0) {
|
|
23460
|
+
if (payload.candidate)
|
|
23461
|
+
this.peer.addRemoteCandidate(payload.candidate);
|
|
23462
|
+
return;
|
|
23463
|
+
}
|
|
23464
|
+
const sdpText = payload.value ?? payload.sdp;
|
|
23465
|
+
if (!sdpText || payload.format !== "SDP" && !payload.sdp)
|
|
23466
|
+
return;
|
|
23467
|
+
if (this.sdpHandled)
|
|
23468
|
+
return;
|
|
23469
|
+
if (!this.turn) {
|
|
23470
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} offered before granting a relay`));
|
|
23471
|
+
return;
|
|
23472
|
+
}
|
|
23473
|
+
this.sdpHandled = true;
|
|
23474
|
+
let offer;
|
|
23475
|
+
try {
|
|
23476
|
+
offer = scallJsonToSdp(JSON.parse(sdpText));
|
|
23477
|
+
} catch {
|
|
23478
|
+
offer = sdpText;
|
|
23479
|
+
}
|
|
23480
|
+
const answer = await this.peer.handleRemoteOffer(offer);
|
|
23481
|
+
this.signaling.sendInfoSdp(JSON.stringify(sdpToScallJson(answer)));
|
|
23482
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
|
|
23483
|
+
}
|
|
23484
|
+
};
|
|
23485
|
+
|
|
23486
|
+
// dist/transport/rtc/command-router.js
|
|
23487
|
+
var PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
23488
|
+
var PORTAL_CMD_CONTROL_PAYLOAD = 1700;
|
|
23489
|
+
var PORTAL_CMD_NOTIFY_PAYLOAD = 1351;
|
|
23490
|
+
var PORTAL_STATION_CHANNEL = 255;
|
|
23491
|
+
var ACK_TIMEOUT_MS = 8e3;
|
|
23492
|
+
var CONNECT_TIMEOUT_MS2 = 12e3;
|
|
23493
|
+
var IDLE_CLOSE_MS = 6e4;
|
|
23494
|
+
var RtcCommandRouter = class {
|
|
23495
|
+
deps;
|
|
23496
|
+
sessions = /* @__PURE__ */ new Map();
|
|
23497
|
+
constructor(deps) {
|
|
23498
|
+
this.deps = deps;
|
|
23499
|
+
}
|
|
23500
|
+
async dispatchCommand(route, cmd) {
|
|
23501
|
+
if (cmd.kind !== "set-payload" && cmd.kind !== "set-json") {
|
|
23502
|
+
throw new Error(`rtc: ${cmd.kind} is not routable over the T9000 control channel (only set-payload or set-json)`);
|
|
23503
|
+
}
|
|
23504
|
+
const identity = this.deps.identity();
|
|
23505
|
+
if (!identity)
|
|
23506
|
+
throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
|
|
23507
|
+
const { stationSn } = route;
|
|
23508
|
+
const adminUserId = route.adminUserId || identity.userId;
|
|
23509
|
+
const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
|
|
23510
|
+
const st = await this.stationSession(stationSn, adminUserId, identity);
|
|
23511
|
+
const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
|
|
23512
|
+
const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
|
|
23513
|
+
const payload = cmd.kind === "set-json" ? { account_id: adminUserId, cmd: cmd.param, commandType: cmd.param, data: cmd.data } : { account_id: adminUserId, cmd: cmd.cmd, mValue3: cmd.mValue3 ?? 0, payload: cmd.payload };
|
|
23514
|
+
const segment = st.seg.next();
|
|
23515
|
+
const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
|
|
23516
|
+
const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
|
|
23517
|
+
st.queue = run.catch(() => void 0);
|
|
23518
|
+
return run;
|
|
23519
|
+
}
|
|
23520
|
+
/** Tear down every station session (logout / shutdown). */
|
|
23521
|
+
close() {
|
|
23522
|
+
for (const [sn, st] of this.sessions)
|
|
23523
|
+
this.drop(sn, st);
|
|
23524
|
+
}
|
|
23525
|
+
/**
|
|
23526
|
+
* Completes on the command ACK or a correlated control-result notification. The notification must
|
|
23527
|
+
* match the request channel, parameter and segment and contain a structured payload. Receipt does
|
|
23528
|
+
* not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
|
|
23529
|
+
*/
|
|
23530
|
+
sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
|
|
23531
|
+
return new Promise((resolve, reject) => {
|
|
23532
|
+
const onData = (frame, linkType) => {
|
|
23533
|
+
const p = parsePortalPacket(frame, linkType);
|
|
23534
|
+
if (!p || p.segment !== segment)
|
|
23535
|
+
return;
|
|
23536
|
+
const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
|
|
23537
|
+
const d = p.data;
|
|
23538
|
+
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;
|
|
23539
|
+
if (!acknowledgement && !notification)
|
|
23540
|
+
return;
|
|
23541
|
+
cleanup();
|
|
23542
|
+
if (acknowledgement && p.errCode !== 0) {
|
|
23543
|
+
reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
|
|
23544
|
+
} else {
|
|
23545
|
+
this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
|
|
23546
|
+
resolve();
|
|
23547
|
+
}
|
|
23548
|
+
};
|
|
23549
|
+
const onClose = () => {
|
|
23550
|
+
cleanup();
|
|
23551
|
+
reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
|
|
23552
|
+
};
|
|
23553
|
+
const timer = setTimeout(() => {
|
|
23554
|
+
cleanup();
|
|
23555
|
+
reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
|
|
23556
|
+
}, ACK_TIMEOUT_MS);
|
|
23557
|
+
const cleanup = () => {
|
|
23558
|
+
clearTimeout(timer);
|
|
23559
|
+
st.session.off("commandData", onData);
|
|
23560
|
+
st.session.off("close", onClose);
|
|
23561
|
+
this.touch(sn, st);
|
|
23562
|
+
};
|
|
23563
|
+
st.session.on("commandData", onData);
|
|
23564
|
+
st.session.on("close", onClose);
|
|
23565
|
+
if (!st.session.sendCommand(packet)) {
|
|
23566
|
+
cleanup();
|
|
23567
|
+
reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
|
|
23568
|
+
}
|
|
23569
|
+
});
|
|
23570
|
+
}
|
|
23571
|
+
async stationSession(sn, adminUserId, identity) {
|
|
23572
|
+
const existing = this.sessions.get(sn);
|
|
23573
|
+
if (existing) {
|
|
23574
|
+
await existing.ready;
|
|
23575
|
+
if (existing.session.isConnected) {
|
|
23576
|
+
this.touch(sn, existing);
|
|
23577
|
+
return existing;
|
|
23578
|
+
}
|
|
23579
|
+
this.drop(sn, existing);
|
|
23580
|
+
}
|
|
23581
|
+
const session = (this.deps.createSession ?? ((o) => new RtcSession(o)))({
|
|
23582
|
+
authToken: identity.authToken,
|
|
23583
|
+
gtoken: identity.gtoken,
|
|
23584
|
+
stationSn: sn,
|
|
23585
|
+
adminUserId,
|
|
23586
|
+
shard: this.deps.shard(),
|
|
23587
|
+
country: this.deps.country ?? "US",
|
|
23588
|
+
logger: this.deps.logger,
|
|
23589
|
+
peer: { logger: this.deps.logger }
|
|
23590
|
+
});
|
|
23591
|
+
const ready = this.bringUp(sn, session);
|
|
23592
|
+
const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve() };
|
|
23593
|
+
session.on("error", (e) => this.deps.onError?.(e));
|
|
23594
|
+
session.on("close", () => {
|
|
23595
|
+
if (this.sessions.get(sn) === st)
|
|
23596
|
+
this.sessions.delete(sn);
|
|
23597
|
+
});
|
|
23598
|
+
this.sessions.set(sn, st);
|
|
23599
|
+
try {
|
|
23600
|
+
await ready;
|
|
23601
|
+
} catch (e) {
|
|
23602
|
+
this.drop(sn, st);
|
|
23603
|
+
throw e;
|
|
23604
|
+
}
|
|
23605
|
+
this.touch(sn, st);
|
|
23606
|
+
return st;
|
|
23607
|
+
}
|
|
23608
|
+
/**
|
|
23609
|
+
* One bounded bring-up: the session is up when its command channel opens, and it fails, with the
|
|
23610
|
+
* session closed and every listener gone, when `connect()` throws, the session closes first, or
|
|
23611
|
+
* {@link CONNECT_TIMEOUT_MS} passes. A single promise, so an early failure cannot leave a second one
|
|
23612
|
+
* rejecting unheard.
|
|
23613
|
+
*/
|
|
23614
|
+
bringUp(sn, session) {
|
|
23615
|
+
return new Promise((resolve, reject) => {
|
|
23616
|
+
let settled = false;
|
|
23617
|
+
const finish = (err) => {
|
|
23618
|
+
if (settled)
|
|
23619
|
+
return;
|
|
23620
|
+
settled = true;
|
|
23621
|
+
clearTimeout(timer);
|
|
23622
|
+
session.off("connected", onConnected);
|
|
23623
|
+
session.off("close", onClose);
|
|
23624
|
+
if (err) {
|
|
23625
|
+
try {
|
|
23626
|
+
session.close();
|
|
23627
|
+
} catch {
|
|
23628
|
+
}
|
|
23629
|
+
reject(err);
|
|
23630
|
+
} else {
|
|
23631
|
+
this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
|
|
23632
|
+
resolve();
|
|
23633
|
+
}
|
|
23634
|
+
};
|
|
23635
|
+
const onConnected = () => finish();
|
|
23636
|
+
const onClose = () => finish(new Error(`rtc: ${sn} session closed before the command channel opened`));
|
|
23637
|
+
const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms`)), CONNECT_TIMEOUT_MS2);
|
|
23638
|
+
session.once("connected", onConnected);
|
|
23639
|
+
session.once("close", onClose);
|
|
23640
|
+
Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
|
|
23641
|
+
});
|
|
23642
|
+
}
|
|
23643
|
+
touch(sn, st) {
|
|
23644
|
+
if (st.idle)
|
|
23645
|
+
clearTimeout(st.idle);
|
|
23646
|
+
st.idle = setTimeout(() => this.drop(sn, st), IDLE_CLOSE_MS);
|
|
23647
|
+
st.idle.unref?.();
|
|
23648
|
+
}
|
|
23649
|
+
drop(sn, st) {
|
|
23650
|
+
if (st.idle)
|
|
23651
|
+
clearTimeout(st.idle);
|
|
23652
|
+
if (this.sessions.get(sn) === st)
|
|
23653
|
+
this.sessions.delete(sn);
|
|
23654
|
+
try {
|
|
23655
|
+
st.session.close();
|
|
23656
|
+
} catch {
|
|
23657
|
+
}
|
|
23658
|
+
}
|
|
23659
|
+
};
|
|
23660
|
+
|
|
22360
23661
|
// dist/transport/mqtt/command-router.js
|
|
22361
23662
|
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
22362
23663
|
|
|
@@ -22660,10 +23961,10 @@ function dpColorFields(spec) {
|
|
|
22660
23961
|
}
|
|
22661
23962
|
|
|
22662
23963
|
// dist/transport/mqtt/clean-codec.js
|
|
22663
|
-
import { randomUUID as
|
|
23964
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
22664
23965
|
function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
|
|
22665
23966
|
const payload = JSON.stringify({
|
|
22666
|
-
t: opts?.uuid ??
|
|
23967
|
+
t: opts?.uuid ?? randomUUID3(),
|
|
22667
23968
|
protocol: 2,
|
|
22668
23969
|
account_id: accountId,
|
|
22669
23970
|
device_sn: deviceSn,
|
|
@@ -23350,7 +24651,7 @@ function deriveTuyaAccount(eufyUserId, phoneCode) {
|
|
|
23350
24651
|
}
|
|
23351
24652
|
|
|
23352
24653
|
// dist/transport/tuya/sign.js
|
|
23353
|
-
import { createHash as createHash5, createHmac as
|
|
24654
|
+
import { createHash as createHash5, createHmac as createHmac4 } from "node:crypto";
|
|
23354
24655
|
var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
23355
24656
|
"a",
|
|
23356
24657
|
"v",
|
|
@@ -23393,7 +24694,7 @@ var HmacSigner = class {
|
|
|
23393
24694
|
this.key = key ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
|
|
23394
24695
|
}
|
|
23395
24696
|
sign(preimage) {
|
|
23396
|
-
return
|
|
24697
|
+
return createHmac4("sha256", this.key).update(preimage, "utf-8").digest("hex");
|
|
23397
24698
|
}
|
|
23398
24699
|
};
|
|
23399
24700
|
var TUYA_CHKEY = "7cbfe6d8";
|
|
@@ -23402,7 +24703,7 @@ var TUYA_CHKEY = "7cbfe6d8";
|
|
|
23402
24703
|
import { randomBytes as randomBytes4, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
|
|
23403
24704
|
|
|
23404
24705
|
// dist/transport/tuya/request.js
|
|
23405
|
-
import { randomUUID as
|
|
24706
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
23406
24707
|
var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
|
|
23407
24708
|
var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
|
|
23408
24709
|
var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
|
|
@@ -23434,7 +24735,7 @@ function buildApiParams(action, opts) {
|
|
|
23434
24735
|
a: action.a,
|
|
23435
24736
|
v: action.v,
|
|
23436
24737
|
time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
|
|
23437
|
-
requestId: opts.requestId ??
|
|
24738
|
+
requestId: opts.requestId ?? randomUUID4(),
|
|
23438
24739
|
sid: opts.session.sid,
|
|
23439
24740
|
deviceId: opts.session.deviceId,
|
|
23440
24741
|
chKey: opts.session.chKey,
|
|
@@ -23971,7 +25272,7 @@ async function resolveLightEffect(mega, lightId) {
|
|
|
23971
25272
|
}
|
|
23972
25273
|
|
|
23973
25274
|
// dist/transport/push/push-client.js
|
|
23974
|
-
import { EventEmitter as
|
|
25275
|
+
import { EventEmitter as EventEmitter11 } from "node:events";
|
|
23975
25276
|
import tls2 from "node:tls";
|
|
23976
25277
|
|
|
23977
25278
|
// dist/transport/push/proto.js
|
|
@@ -24226,7 +25527,7 @@ var MessageTag;
|
|
|
24226
25527
|
})(MessageTag || (MessageTag = {}));
|
|
24227
25528
|
|
|
24228
25529
|
// dist/transport/push/parser.js
|
|
24229
|
-
import { EventEmitter as
|
|
25530
|
+
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
24230
25531
|
var State;
|
|
24231
25532
|
(function(State2) {
|
|
24232
25533
|
State2[State2["VERSION_TAG_AND_SIZE"] = 0] = "VERSION_TAG_AND_SIZE";
|
|
@@ -24244,7 +25545,7 @@ var TAG_TO_TYPE = {
|
|
|
24244
25545
|
[MessageTag.DataMessageStanza]: "mcs_proto.DataMessageStanza",
|
|
24245
25546
|
[MessageTag.StreamErrorStanza]: "mcs_proto.StreamErrorStanza"
|
|
24246
25547
|
};
|
|
24247
|
-
var McsParser = class extends
|
|
25548
|
+
var McsParser = class extends EventEmitter10 {
|
|
24248
25549
|
data = Buffer.alloc(0);
|
|
24249
25550
|
state = State.VERSION_TAG_AND_SIZE;
|
|
24250
25551
|
messageTag = 0;
|
|
@@ -24422,7 +25723,7 @@ function normalizePushEvent(raw) {
|
|
|
24422
25723
|
raw
|
|
24423
25724
|
};
|
|
24424
25725
|
}
|
|
24425
|
-
var PushClient = class _PushClient extends
|
|
25726
|
+
var PushClient = class _PushClient extends EventEmitter11 {
|
|
24426
25727
|
creds;
|
|
24427
25728
|
logger;
|
|
24428
25729
|
/** Consecutive MCS login rejections tolerated (self-healing propagation) before surfacing an error. */
|
|
@@ -25575,7 +26876,7 @@ function readinessSnapshot(readiness, state = readiness.state) {
|
|
|
25575
26876
|
wiredP2p: Object.freeze({ ...readiness.wiredP2p })
|
|
25576
26877
|
});
|
|
25577
26878
|
}
|
|
25578
|
-
var EufyMega = class extends
|
|
26879
|
+
var EufyMega = class extends EventEmitter12 {
|
|
25579
26880
|
opts;
|
|
25580
26881
|
mega;
|
|
25581
26882
|
/**
|
|
@@ -25635,6 +26936,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25635
26936
|
prewarmTiers;
|
|
25636
26937
|
/** Transport-side owner of the P2P sessions + all wire senders. */
|
|
25637
26938
|
p2p;
|
|
26939
|
+
/** Transport-side owner of the T9000 station sessions (sibling of {@link p2p}). */
|
|
26940
|
+
rtc;
|
|
25638
26941
|
/** Transport-side owner of the secure-MQTT ff09 lock/garage command path (sibling of {@link p2p}). */
|
|
25639
26942
|
mqtt;
|
|
25640
26943
|
/** Transport-side owner of the legacy Tuya REST command path for non-AIoT vacuums (G-series). */
|
|
@@ -25695,6 +26998,13 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25695
26998
|
onLevel2Ready: (sn, cipherId) => this.emit("p2pLevel2Ready", { stationSn: sn, cipherId }),
|
|
25696
26999
|
onFrame: (stationSn, f) => this.onP2PFrame(stationSn, f)
|
|
25697
27000
|
});
|
|
27001
|
+
this.rtc = new RtcCommandRouter({
|
|
27002
|
+
identity: () => this.mega.rtcIdentity(),
|
|
27003
|
+
shard: () => this.mega.rtcShard,
|
|
27004
|
+
country: opts.countryCode,
|
|
27005
|
+
logger: opts.logger,
|
|
27006
|
+
onError: (e) => this.reportError(e)
|
|
27007
|
+
});
|
|
25698
27008
|
this.mqtt = new MqttCommandRouter({
|
|
25699
27009
|
mega: this.mega,
|
|
25700
27010
|
logger: opts.logger,
|
|
@@ -25960,13 +27270,22 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25960
27270
|
*
|
|
25961
27271
|
* The evidence set is widened, never replaced: the ids come back through the cloud record, and a
|
|
25962
27272
|
* record that omits a realtime-only id would otherwise un-know it and re-trigger on the next report.
|
|
27273
|
+
*
|
|
27274
|
+
* The device is re-resolved against the fresh record before binding. A property gated on a
|
|
27275
|
+
* realtime-only param enters the schema only once that param is evidence, and the value that made it
|
|
27276
|
+
* evidence was stored before the property existed, so the record's realtime params are applied again
|
|
27277
|
+
* to land it under the property's name.
|
|
25963
27278
|
*/
|
|
25964
27279
|
async rebindReads(sn) {
|
|
25965
27280
|
const dev = this.liveDevices.get(sn)?.deref();
|
|
25966
27281
|
if (!dev)
|
|
25967
27282
|
return;
|
|
25968
27283
|
try {
|
|
25969
|
-
const
|
|
27284
|
+
const rec = await this.registry.record(sn);
|
|
27285
|
+
dev.reresolve(rec);
|
|
27286
|
+
if (rec.dpParams)
|
|
27287
|
+
this.applyAndAnnounce(dev, rec.dpParams);
|
|
27288
|
+
const ctx = await this.commandContext(sn, rec);
|
|
25970
27289
|
dev.bindActions(ctx, this.commandSinkFor(sn), this.mediaProviderFor(sn), this.ff09SettingsReaderFor(sn, ctx), rawDpCodec);
|
|
25971
27290
|
this.boundParamIds.set(sn, /* @__PURE__ */ new Set([...this.boundParamIds.get(sn) ?? [], ...ctx.paramIds]));
|
|
25972
27291
|
this.emit("deviceState", this.deviceState(sn));
|
|
@@ -26026,6 +27345,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26026
27345
|
* only "differs from what was read before", which state already on hand can satisfy spuriously — and the
|
|
26027
27346
|
* caller that has no expectation is the push path, where the signal itself is the news that a re-read is owed.
|
|
26028
27347
|
*
|
|
27348
|
+
* Only a write's observation throws at the deadline; a push with nothing moved answers `false`.
|
|
27349
|
+
*
|
|
26029
27350
|
* The cloud half is asked for through {@link DeviceRegistry.refreshedList}, never by fetching the account
|
|
26030
27351
|
* list outright. The fetch is account-wide — one house list plus one device list per house — so a param that
|
|
26031
27352
|
* never converges would otherwise spend a whole burst of those every iteration of this loop, and concurrent
|
|
@@ -26076,6 +27397,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26076
27397
|
if (refresh.expected !== void 0 && settled())
|
|
26077
27398
|
return true;
|
|
26078
27399
|
}
|
|
27400
|
+
if (refresh.expected === void 0)
|
|
27401
|
+
return false;
|
|
26079
27402
|
throw new StateConvergenceError({
|
|
26080
27403
|
sn,
|
|
26081
27404
|
property: refresh.property,
|
|
@@ -26428,8 +27751,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26428
27751
|
* The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
|
|
26429
27752
|
* Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
|
|
26430
27753
|
* (`eufy_home_tuya` → Tuya, everything else → MQTT). The capability layer emits a single `aiot-dp`
|
|
26431
|
-
* kind and stays transport-agnostic; only the facade sees both sides and decides here.
|
|
26432
|
-
*
|
|
27754
|
+
* kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000
|
|
27755
|
+
* station and the devices attached to it go over RTC, with an attached device's command refused
|
|
27756
|
+
* unless its channel resolves back to it on the station. Everything else is P2P.
|
|
26433
27757
|
*/
|
|
26434
27758
|
routeCommand(sn, cmd) {
|
|
26435
27759
|
if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
|
|
@@ -26449,6 +27773,20 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26449
27773
|
return this.tuya.dispatchCommand(sn, cmd);
|
|
26450
27774
|
return this.mqtt.dispatchCommand(sn, cmd);
|
|
26451
27775
|
}
|
|
27776
|
+
const devices = this.registry.list();
|
|
27777
|
+
const target = devices.find((d) => d.sn === sn);
|
|
27778
|
+
const stationSn = target?.stationSn || sn;
|
|
27779
|
+
const station = devices.find((d) => d.sn === stationSn);
|
|
27780
|
+
const stationRaw = station?.raw ?? {};
|
|
27781
|
+
const deviceType = typeof stationRaw.device_type === "number" ? stationRaw.device_type : void 0;
|
|
27782
|
+
if (target && station && isStation9000({ deviceType, model: station.model })) {
|
|
27783
|
+
const attached = stationSn !== sn;
|
|
27784
|
+
if (attached && this.registry.serialForFrame(stationSn, cmd.channel) !== sn)
|
|
27785
|
+
return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
|
|
27786
|
+
const member = stationRaw.member?.admin_user_id;
|
|
27787
|
+
const adminUserId = typeof member === "string" && member ? member : void 0;
|
|
27788
|
+
return this.rtc.dispatchCommand({ stationSn, adminUserId, attached }, cmd);
|
|
27789
|
+
}
|
|
26452
27790
|
return this.p2p.dispatchCommand(sn, cmd);
|
|
26453
27791
|
}
|
|
26454
27792
|
/**
|
|
@@ -27287,16 +28625,20 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27287
28625
|
await this.commandSinkFor(sn).dispatch(cmd);
|
|
27288
28626
|
}
|
|
27289
28627
|
/**
|
|
27290
|
-
* Restart a HomeBase.
|
|
28628
|
+
* Restart a station: a HomeBase, or a camera-family device that is its own station.
|
|
27291
28629
|
*
|
|
27292
|
-
*
|
|
27293
|
-
*
|
|
27294
|
-
*
|
|
28630
|
+
* The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
|
|
28631
|
+
* P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
|
|
28632
|
+
* A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
|
|
28633
|
+
* throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
|
|
28634
|
+
* and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
|
|
28635
|
+
* Verified on real hardware against a HomeBase and a standalone indoor camera.
|
|
27295
28636
|
*/
|
|
27296
28637
|
async reboot(sn) {
|
|
27297
28638
|
const ctx = await this.commandContext(sn);
|
|
27298
|
-
|
|
27299
|
-
|
|
28639
|
+
const standaloneCamera = ctx.codec === "camera" && this.p2p.stationKeyOf(sn) === sn;
|
|
28640
|
+
if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model }) && !standaloneCamera) {
|
|
28641
|
+
throw new Error(`reboot: ${sn} is neither a HomeBase nor a standalone camera (deviceType ${ctx.deviceType ?? "?"}, model ${ctx.model ?? "?"}) \u2014 restart addresses the station that owns the P2P session`);
|
|
27300
28642
|
}
|
|
27301
28643
|
await this.p2p.rebootStation(sn);
|
|
27302
28644
|
}
|
|
@@ -27328,8 +28670,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27328
28670
|
return EMPTY_DP_CATALOG;
|
|
27329
28671
|
}
|
|
27330
28672
|
}
|
|
27331
|
-
|
|
27332
|
-
|
|
28673
|
+
/** The {@link CommandContext} for a device, built from `known` when that record is already in hand. */
|
|
28674
|
+
async commandContext(sn, known) {
|
|
28675
|
+
const rec = known ?? await this.registry.record(sn);
|
|
27333
28676
|
const dev = this.registry.require(sn);
|
|
27334
28677
|
const raw = dev.raw ?? {};
|
|
27335
28678
|
const rawChannel = raw.device_channel;
|
|
@@ -27542,6 +28885,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27542
28885
|
this.pollTimer.cancel();
|
|
27543
28886
|
this.lastStateAnnounced.clear();
|
|
27544
28887
|
await this.closeMqttTransports();
|
|
28888
|
+
this.rtc.close();
|
|
27545
28889
|
await this.p2p.closeAll();
|
|
27546
28890
|
this.pushClient?.close();
|
|
27547
28891
|
this.pushClient = void 0;
|
|
@@ -28046,7 +29390,7 @@ var SolixClient = class _SolixClient {
|
|
|
28046
29390
|
};
|
|
28047
29391
|
|
|
28048
29392
|
// dist/transport/mqtt/solix-mqtt.js
|
|
28049
|
-
import { EventEmitter as
|
|
29393
|
+
import { EventEmitter as EventEmitter13 } from "node:events";
|
|
28050
29394
|
var SOLIX_METER_FIELD_NAMES = {
|
|
28051
29395
|
168: "meterPowerL1",
|
|
28052
29396
|
// Anker Modbus: primary_phase_1_active_power
|
|
@@ -28224,7 +29568,7 @@ function addSolarbankScalars(frame, out) {
|
|
|
28224
29568
|
out.gridExportLimit = df.readUInt16LE(5);
|
|
28225
29569
|
}
|
|
28226
29570
|
}
|
|
28227
|
-
var SolixMqtt = class extends
|
|
29571
|
+
var SolixMqtt = class extends EventEmitter13 {
|
|
28228
29572
|
transport;
|
|
28229
29573
|
appName;
|
|
28230
29574
|
userId;
|