@mega-yfue/eufy-sdk 0.5.0-beta.2 → 0.5.0-beta.21
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/device-registry.d.ts +24 -2
- package/dist/client/eufy-mega.d.ts +20 -6
- package/dist/client/types.d.ts +1 -1
- package/dist/core/contracts.d.ts +50 -26
- package/dist/core/store.d.ts +2 -0
- package/dist/index.js +1596 -95
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/camera.d.ts +24 -10
- package/dist/model/capabilities/light.d.ts +3 -0
- package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
- 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 +26 -0
- package/dist/transport/p2p/command-router.d.ts +13 -5
- package/dist/transport/p2p/fmp4.d.ts +6 -1
- package/dist/transport/p2p/live-stream.d.ts +3 -0
- package/dist/transport/p2p/p2p-session.d.ts +7 -2
- package/dist/transport/p2p/recording-download.d.ts +4 -3
- package/dist/transport/p2p/talkback.d.ts +21 -10
- 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";
|
|
@@ -1974,6 +1996,17 @@ ${this.auth_?.authToken ?? ""}`;
|
|
|
1974
1996
|
device_sns: [deviceSn]
|
|
1975
1997
|
});
|
|
1976
1998
|
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Fetch the account's devices as the `devicerelation` service lists them, `{attribute: 7, house_id: ""}`.
|
|
2001
|
+
*
|
|
2002
|
+
* Each entry is `{device: {device_sn, dps, …}}`, and `dps` is the cloud's last-known value of every
|
|
2003
|
+
* data point that device has reported, keyed by DP number. Answered for an account a device is shared
|
|
2004
|
+
* with as well as for its owner. Sent with `category: eufy_home`, without which the service answers
|
|
2005
|
+
* `10000`.
|
|
2006
|
+
*/
|
|
2007
|
+
getDeviceRelationList() {
|
|
2008
|
+
return this.post("devicerelation", "/app/devicerelation/get_device_list", { attribute: 7, house_id: "" }, { category: "eufy_home" });
|
|
2009
|
+
}
|
|
1977
2010
|
/**
|
|
1978
2011
|
* Fetch one page of a device's **cleaning history** from the mega `clean` service.
|
|
1979
2012
|
*
|
|
@@ -2422,6 +2455,7 @@ ${this.auth_?.authToken ?? ""}`;
|
|
|
2422
2455
|
authToken: this.auth_.authToken,
|
|
2423
2456
|
geoKey: this.auth_.geoKey,
|
|
2424
2457
|
region: this.region,
|
|
2458
|
+
estimatedDomain: this.estimatedDomain,
|
|
2425
2459
|
openudid: this.openudid,
|
|
2426
2460
|
phoneModel: this.phoneModel,
|
|
2427
2461
|
mediaUserAgent: this.mediaUserAgent,
|
|
@@ -5060,6 +5094,7 @@ var isIndoorCamera = (ctx) => has(INDOOR_CAMERA_TYPES, ctx.deviceType);
|
|
|
5060
5094
|
var isIndoorCamMini = (ctx) => ctx.deviceType === DeviceType.INDOOR_COST_DOWN_CAMERA;
|
|
5061
5095
|
var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
|
|
5062
5096
|
var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
|
|
5097
|
+
var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
|
|
5063
5098
|
var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
|
|
5064
5099
|
var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
|
|
5065
5100
|
|
|
@@ -5205,6 +5240,8 @@ var AUDIO = {
|
|
|
5205
5240
|
|
|
5206
5241
|
// dist/model/capabilities/camera.js
|
|
5207
5242
|
var CAMERA_CMD = {
|
|
5243
|
+
/** Camera-reported Wi-Fi signal (app `CMD_GET_WIFI_RSSI`). */
|
|
5244
|
+
WIFI_RSSI: 1142,
|
|
5208
5245
|
/** Camera on/off. Read and write polarity depend on the family and topology. */
|
|
5209
5246
|
CAMERA_ENABLE: 1035,
|
|
5210
5247
|
/** Wrapped T8410 power switch on HomeBase 3. */
|
|
@@ -5484,6 +5521,14 @@ function refuseWhenDisabled(ctx, read) {
|
|
|
5484
5521
|
throw new CameraDisabledError(ctx.name ?? ctx.serial);
|
|
5485
5522
|
}
|
|
5486
5523
|
var CAMERA_MEMBERS = {
|
|
5524
|
+
/** Reads the latest cached report and exposes no setter. */
|
|
5525
|
+
wifiRssi: {
|
|
5526
|
+
param: CAMERA_CMD.WIFI_RSSI,
|
|
5527
|
+
type: "number",
|
|
5528
|
+
kind: "scalar",
|
|
5529
|
+
provenance: "apk",
|
|
5530
|
+
description: "Camera-reported Wi-Fi signal; physical units and a quality scale are unspecified."
|
|
5531
|
+
},
|
|
5487
5532
|
/**
|
|
5488
5533
|
* The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
|
|
5489
5534
|
* family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
|
|
@@ -5640,16 +5685,14 @@ var CAMERA_MEMBERS = {
|
|
|
5640
5685
|
* Live-view quality, the pair to {@link CAMERA_MEMBERS.recordingQuality} and a different setting on a
|
|
5641
5686
|
* different wire: changing one leaves the other's parameter untouched.
|
|
5642
5687
|
*
|
|
5643
|
-
* The READ is 1020, which reports the resolved tier as a plain integer
|
|
5644
|
-
*
|
|
5645
|
-
*
|
|
5688
|
+
* The READ is 1020, which reports the resolved tier as a plain integer, while the WRITE rides 2730's
|
|
5689
|
+
* `1350` payload. They are two ids for one setting because 2730 carries a per-mode config and 1020
|
|
5690
|
+
* carries the tier in force; the tier is what this property means. 1020 is also the name the param
|
|
5691
|
+
* dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730 under
|
|
5692
|
+
* this name would collide with it on a device reporting both.
|
|
5646
5693
|
*
|
|
5647
|
-
* `
|
|
5648
|
-
*
|
|
5649
|
-
* 2731 write on the same envelope and the same session does land. The one difference the capture
|
|
5650
|
-
* shows is that the app's frame carries no `mChannel`/`mValue3`, which this envelope always emits.
|
|
5651
|
-
* Until a send is confirmed, the setter is absent — a control that silently does nothing is worse
|
|
5652
|
-
* than none — and the read ships ahead of it, which verification per direction allows.
|
|
5694
|
+
* `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
|
|
5695
|
+
* confirmed frame carries no such field; neither is inferred from the other.
|
|
5653
5696
|
*/
|
|
5654
5697
|
streamingQuality: {
|
|
5655
5698
|
param: 1020,
|
|
@@ -5657,8 +5700,13 @@ var CAMERA_MEMBERS = {
|
|
|
5657
5700
|
kind: "enum",
|
|
5658
5701
|
enumValues: STREAMING_QUALITY_TIERS,
|
|
5659
5702
|
provenance: "verified",
|
|
5660
|
-
|
|
5661
|
-
|
|
5703
|
+
args: [{ name: "quality", kind: "enum", description: "A tier, or the name it maps to. 0 is Auto." }],
|
|
5704
|
+
...accepts(),
|
|
5705
|
+
description: "Live-view quality as a tier, 0 = Auto (1020; the write rides 2730 APP_CMD_MULTI_CAM_SET_VIDEO_QUALITY). Distinct from recordingQuality, which is what gets stored. All four tiers wire-verified live on a T8170 and read back on 1020. Sent from this SDK to a standalone T8170 and T8171, tier 1 holds the live stream at 1280x720 and tier 0 restores the camera's own resolution ladder \u2014 see CAMERA_CMD.STREAMING_QUALITY_SET. A value outside the tier set is REJECTED, not clamped: every tier in range is a real quality, so a bad one would silently pick another.",
|
|
5706
|
+
write: (v, ctx) => {
|
|
5707
|
+
const q = resolveStreamingQualityTier(v);
|
|
5708
|
+
return q == null ? void 0 : setPayload(CAMERA_CMD.STREAMING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q, transaction: String(Date.now()) }, ctx, 0, void 0, "auto");
|
|
5709
|
+
}
|
|
5662
5710
|
},
|
|
5663
5711
|
/**
|
|
5664
5712
|
* `type` is how the value is STORED, and 2731 stores the whole config — the ACTIVE tier is lifted out
|
|
@@ -6188,6 +6236,9 @@ var LIGHT_MEMBERS = {
|
|
|
6188
6236
|
* This is the switch a user changes and expects to STAY changed. {@link isOn} is a different fact:
|
|
6189
6237
|
* the lamp being lit right now, driven by whichever
|
|
6190
6238
|
* client is streaming — the vendor app lights it for a live view and drops it on quitting.
|
|
6239
|
+
*
|
|
6240
|
+
* The T8423 write takes the session-chosen level; level-1 int+string was verified live on a standalone
|
|
6241
|
+
* unit.
|
|
6191
6242
|
*/
|
|
6192
6243
|
spotlightEnabled: {
|
|
6193
6244
|
param: LIGHT_CMD.SPOTLIGHT_ENABLE,
|
|
@@ -6195,7 +6246,7 @@ var LIGHT_MEMBERS = {
|
|
|
6195
6246
|
kind: "boolean",
|
|
6196
6247
|
provenance: "verified",
|
|
6197
6248
|
description: "The spotlight master switch, distinct from the on/off above \u2014 whether the spotlight may light at all. Read verified live on a T8170 (param 1403, direct polarity: 1 = enabled).",
|
|
6198
|
-
write: (v, ctx) =>
|
|
6249
|
+
write: (v, ctx) => setScalar(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx, ctx.deviceType === DeviceType.FLOODLIGHT_CAMERA_8423 ? "auto" : "direct-binary"),
|
|
6199
6250
|
writeAs: "setEnabled"
|
|
6200
6251
|
}
|
|
6201
6252
|
};
|
|
@@ -8312,8 +8363,6 @@ var MODE_CTRL_FIELD = {
|
|
|
8312
8363
|
SEQ: 2
|
|
8313
8364
|
};
|
|
8314
8365
|
var ModeCtrlMethod = {
|
|
8315
|
-
/** Live-verified on a T2351. Zero, so it is omitted from the wire per the proto3 default rule. */
|
|
8316
|
-
START_AUTO_CLEAN: 0,
|
|
8317
8366
|
/** Live-verified on a T2351. */
|
|
8318
8367
|
START_GOHOME: 6,
|
|
8319
8368
|
/** Live-verified on a T2351. */
|
|
@@ -8337,6 +8386,8 @@ var ModeCtrlMethod = {
|
|
|
8337
8386
|
START_GLOBAL_CRUISE: 20
|
|
8338
8387
|
};
|
|
8339
8388
|
var ModeCtrlParamMethod = {
|
|
8389
|
+
/** `START_AUTO_CLEAN` with an `AutoClean` payload — clean the whole floor. */
|
|
8390
|
+
AUTO: { method: 0, param: 3 },
|
|
8340
8391
|
/** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
|
|
8341
8392
|
SELECT_ROOMS: { method: 1, param: 4 },
|
|
8342
8393
|
/** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
|
|
@@ -8373,6 +8424,7 @@ var SELECT_ZONES_FIELD = {
|
|
|
8373
8424
|
POINT_Y: 2
|
|
8374
8425
|
};
|
|
8375
8426
|
var SCENE_CLEAN_ID = 1;
|
|
8427
|
+
var AUTO_CLEAN_TIMES = 1;
|
|
8376
8428
|
function encodeModeCtrlParam(method2, paramField, build) {
|
|
8377
8429
|
return rawDp((w) => {
|
|
8378
8430
|
if (method2 !== 0)
|
|
@@ -8419,14 +8471,17 @@ function encodeSceneClean(sceneId) {
|
|
|
8419
8471
|
const { method: method2, param } = ModeCtrlParamMethod.SCENE;
|
|
8420
8472
|
return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
|
|
8421
8473
|
}
|
|
8474
|
+
function encodeAutoClean() {
|
|
8475
|
+
const { method: method2, param } = ModeCtrlParamMethod.AUTO;
|
|
8476
|
+
return encodeModeCtrlParam(method2, param, (p) => p.int(AUTO_CLEAN_TIMES, 1));
|
|
8477
|
+
}
|
|
8422
8478
|
var modeCtrlSeq = 111;
|
|
8423
8479
|
function nextModeCtrlSeq() {
|
|
8424
8480
|
return ++modeCtrlSeq;
|
|
8425
8481
|
}
|
|
8426
8482
|
function encodeModeCtrl(method2, seq) {
|
|
8427
8483
|
return rawDp((w) => {
|
|
8428
|
-
|
|
8429
|
-
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
8484
|
+
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
8430
8485
|
w.int(MODE_CTRL_FIELD.SEQ, seq);
|
|
8431
8486
|
});
|
|
8432
8487
|
}
|
|
@@ -10268,8 +10323,8 @@ var VACUUM_CLEAN_MEMBERS = {
|
|
|
10268
10323
|
provenance: "mega",
|
|
10269
10324
|
description: "Stop smart-follow mode (ModeCtrlRequest method 18 over DP 152). Method number not captured \u2014 unverified."
|
|
10270
10325
|
},
|
|
10271
|
-
/** Start an auto-clean run via ModeCtrlRequest method 0 (DP 152). AIoT only — Tuya write unverified. */
|
|
10272
|
-
startCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL,
|
|
10326
|
+
/** Start an auto-clean run via ModeCtrlRequest method 0 with one `AutoClean` pass (DP 152). AIoT only — Tuya write unverified. */
|
|
10327
|
+
startCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeAutoClean())), "Start an auto-clean run (ModeCtrlRequest method 0, AutoClean clean_times 1, over DP 152).", isAiotVacuum),
|
|
10273
10328
|
/** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
|
|
10274
10329
|
returnToDock: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.START_GOHOME, nextModeCtrlSeq()))), "Return to the dock (ModeCtrlRequest method 6 over DP 152).", isAiotVacuum),
|
|
10275
10330
|
/** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
|
|
@@ -11018,6 +11073,8 @@ var MODEL_REGISTRY = {
|
|
|
11018
11073
|
T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
|
|
11019
11074
|
// Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
|
|
11020
11075
|
T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
|
|
11076
|
+
// Battery Video Doorbell Dual, vendor deviceType 91 (BATTERY_DOORBELL_PLUS), reported by two users.
|
|
11077
|
+
T8213: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
|
|
11021
11078
|
// Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
|
|
11022
11079
|
// member on purpose — this model is wired, so the codec baseline plus inference is the whole
|
|
11023
11080
|
// truth for power. Without this row it classified as a plain camera, so the doorbell capability
|
|
@@ -11179,8 +11236,10 @@ function resolveDevice(rec) {
|
|
|
11179
11236
|
}
|
|
11180
11237
|
function resolveProperties(rec, codec, capabilities) {
|
|
11181
11238
|
const paramIds = /* @__PURE__ */ new Set();
|
|
11182
|
-
|
|
11183
|
-
|
|
11239
|
+
for (const reported of [rec.params, rec.dpParams]) {
|
|
11240
|
+
if (!reported || typeof reported !== "object")
|
|
11241
|
+
continue;
|
|
11242
|
+
for (const k of Object.keys(reported)) {
|
|
11184
11243
|
const n = Number(k);
|
|
11185
11244
|
if (Number.isFinite(n))
|
|
11186
11245
|
paramIds.add(n);
|
|
@@ -15699,8 +15758,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15699
15758
|
return this.connected;
|
|
15700
15759
|
}
|
|
15701
15760
|
/**
|
|
15702
|
-
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and
|
|
15703
|
-
* less or refuses.
|
|
15761
|
+
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and log once per session when it grants
|
|
15762
|
+
* less (debug) or refuses (warn).
|
|
15704
15763
|
*
|
|
15705
15764
|
* The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
|
|
15706
15765
|
* inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
|
|
@@ -15716,7 +15775,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15716
15775
|
if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
|
|
15717
15776
|
return;
|
|
15718
15777
|
this.receiveBufferReported = true;
|
|
15719
|
-
|
|
15778
|
+
const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
|
|
15779
|
+
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
15780
|
}
|
|
15721
15781
|
/** Open the socket and start the lookup → hole-punch handshake. */
|
|
15722
15782
|
async connect() {
|
|
@@ -16248,6 +16308,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16248
16308
|
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16249
16309
|
return true;
|
|
16250
16310
|
}
|
|
16311
|
+
/**
|
|
16312
|
+
* The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
|
|
16313
|
+
* level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
|
|
16314
|
+
*/
|
|
16315
|
+
sendRawLevel1Bytes(payload, channel, outerCmd) {
|
|
16316
|
+
if (!this.connectAddress)
|
|
16317
|
+
return false;
|
|
16318
|
+
const data = Buffer.concat([
|
|
16319
|
+
buildCommandHeader(this.seqNumber, outerCmd),
|
|
16320
|
+
buildRawCommandPayload(encryptP2PData(paddingP2PData(payload), this.level1Key), channel, 1)
|
|
16321
|
+
]);
|
|
16322
|
+
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
16323
|
+
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16324
|
+
return true;
|
|
16325
|
+
}
|
|
16251
16326
|
/**
|
|
16252
16327
|
* Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
|
|
16253
16328
|
* its runtime topology takes, as {@link startLiveMedia} selects its start:
|
|
@@ -16468,7 +16543,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16468
16543
|
camera_type: 0,
|
|
16469
16544
|
entrytype: 0,
|
|
16470
16545
|
key: this.rsaModulus(),
|
|
16471
|
-
streamtype:
|
|
16546
|
+
streamtype: 2,
|
|
16472
16547
|
...payload
|
|
16473
16548
|
} : payload;
|
|
16474
16549
|
const value = JSON.stringify({
|
|
@@ -16809,11 +16884,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16809
16884
|
if (advance > SEQUENCE_LOOKBACK) {
|
|
16810
16885
|
if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
|
|
16811
16886
|
return;
|
|
16812
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16887
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16813
16888
|
this.trace({ phase: "sequence-restart", dataType });
|
|
16814
16889
|
this.clearReorderTimer(dataType);
|
|
16815
16890
|
this.reorderByType.delete(dataType);
|
|
16816
16891
|
this.pendingByDataType.delete(dataType);
|
|
16892
|
+
if (dataType === P2PDataType.VIDEO)
|
|
16893
|
+
this.emit("videoGap");
|
|
16817
16894
|
this.lastSeqByType.set(dataType, seqNo);
|
|
16818
16895
|
this.reassemble(dataType, msg.subarray(8));
|
|
16819
16896
|
return;
|
|
@@ -16879,9 +16956,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16879
16956
|
*/
|
|
16880
16957
|
abandonHole(dataType) {
|
|
16881
16958
|
const held = this.reorderByType.get(dataType).held;
|
|
16882
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16959
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16883
16960
|
this.trace({ phase: "datagram-gap", dataType });
|
|
16884
16961
|
this.pendingByDataType.delete(dataType);
|
|
16962
|
+
if (dataType === P2PDataType.VIDEO)
|
|
16963
|
+
this.emit("videoGap");
|
|
16885
16964
|
const last = this.lastSeqByType.get(dataType);
|
|
16886
16965
|
const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
|
|
16887
16966
|
this.lastSeqByType.set(dataType, earliest - 1 & 65535);
|
|
@@ -17518,7 +17597,8 @@ var AccessUnitAssembler = class {
|
|
|
17518
17597
|
* `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
|
|
17519
17598
|
* frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
|
|
17520
17599
|
* of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
|
|
17521
|
-
* carries).
|
|
17600
|
+
* carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
|
|
17601
|
+
* as a complete unit, even if its repeated header says keyframe.
|
|
17522
17602
|
*/
|
|
17523
17603
|
push(payload, decode) {
|
|
17524
17604
|
const header = parseVideoFrameHeader(payload);
|
|
@@ -17538,6 +17618,8 @@ var AccessUnitAssembler = class {
|
|
|
17538
17618
|
return [unitOf(open2.header, Buffer.concat(open2.chunks))];
|
|
17539
17619
|
}
|
|
17540
17620
|
this.discard();
|
|
17621
|
+
if (!beginsAccessUnit(body))
|
|
17622
|
+
return [];
|
|
17541
17623
|
if (full) {
|
|
17542
17624
|
this.open = { header, chunks: [body], carried: body.length };
|
|
17543
17625
|
return [];
|
|
@@ -17878,6 +17960,7 @@ var AUDIO_HEADER_LEN = 16;
|
|
|
17878
17960
|
var AUDIO_TYPE_OFFSET = 5;
|
|
17879
17961
|
var SC4 = Buffer.from([0, 0, 0, 1]);
|
|
17880
17962
|
var SC3 = Buffer.from([0, 0, 1]);
|
|
17963
|
+
var AAC_ELD_AUDIO_SPECIFIC_CONFIG = Buffer.from("f8f03000", "hex");
|
|
17881
17964
|
var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
|
|
17882
17965
|
var MAX_TRACED_DECODE_FAILURES = 3;
|
|
17883
17966
|
var DEFAULT_KEEPALIVE_MS = 3e3;
|
|
@@ -17891,6 +17974,12 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17891
17974
|
lastCodec = "h264";
|
|
17892
17975
|
/** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
|
|
17893
17976
|
units = new AccessUnitAssembler((drop) => this.reportDroppedUnit(drop));
|
|
17977
|
+
/** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
|
|
17978
|
+
awaitingKeyframeAfterGap = false;
|
|
17979
|
+
videoGapHandler = () => {
|
|
17980
|
+
this.units.discard();
|
|
17981
|
+
this.awaitingKeyframeAfterGap = true;
|
|
17982
|
+
};
|
|
17894
17983
|
/** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
|
|
17895
17984
|
mediaChannel;
|
|
17896
17985
|
tracedFirstVideoCommand = false;
|
|
@@ -17933,6 +18022,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17933
18022
|
return this;
|
|
17934
18023
|
this.listening = true;
|
|
17935
18024
|
this.session.on("data", this.handler);
|
|
18025
|
+
this.session.on("videoGap", this.videoGapHandler);
|
|
17936
18026
|
this.session.on("liveStartUnacknowledged", this.unackedHandler);
|
|
17937
18027
|
this.sendStart();
|
|
17938
18028
|
const keepAliveMs = this.opts.keepAliveMs ?? DEFAULT_KEEPALIVE_MS;
|
|
@@ -18030,6 +18120,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18030
18120
|
return;
|
|
18031
18121
|
this.listening = false;
|
|
18032
18122
|
this.session.off("data", this.handler);
|
|
18123
|
+
this.session.off("videoGap", this.videoGapHandler);
|
|
18124
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18033
18125
|
this.session.off("liveStartUnacknowledged", this.unackedHandler);
|
|
18034
18126
|
if (this.kaTimer)
|
|
18035
18127
|
clearInterval(this.kaTimer);
|
|
@@ -18065,6 +18157,11 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18065
18157
|
try {
|
|
18066
18158
|
if (f.commandId === CMD_VIDEO_FRAME2) {
|
|
18067
18159
|
for (const unit of this.units.push(f.data, (payload) => this.annexbOf(payload, f.signCode))) {
|
|
18160
|
+
if (this.awaitingKeyframeAfterGap) {
|
|
18161
|
+
if (!unit.keyframe)
|
|
18162
|
+
continue;
|
|
18163
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18164
|
+
}
|
|
18068
18165
|
if (!this.tracedFirstVideoUnit) {
|
|
18069
18166
|
this.tracedFirstVideoUnit = true;
|
|
18070
18167
|
this.trace({ phase: "first-video-unit", keyframe: unit.keyframe });
|
|
@@ -18092,7 +18189,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18092
18189
|
if (!codec) {
|
|
18093
18190
|
this.logger.debug(`[live] dropping audio frame: unknown codec id ${codecId ?? "missing"}`);
|
|
18094
18191
|
} else {
|
|
18095
|
-
this.emit("audio", { codec, data: audio });
|
|
18192
|
+
this.emit("audio", codec === "aac-eld" ? { codec, data: audio, config: AAC_ELD_AUDIO_SPECIFIC_CONFIG } : { codec, data: audio });
|
|
18096
18193
|
}
|
|
18097
18194
|
}
|
|
18098
18195
|
}
|
|
@@ -18159,6 +18256,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18159
18256
|
* otherwise silent in both directions: no frame reaches a consumer, and nothing states why.
|
|
18160
18257
|
*/
|
|
18161
18258
|
reportDroppedUnit(drop) {
|
|
18259
|
+
this.awaitingKeyframeAfterGap = true;
|
|
18162
18260
|
const message = `[live] dropped an incomplete access unit (${drop.carried} bytes in ${drop.chunks} frame(s), tail never arrived, ${drop.count} so far)`;
|
|
18163
18261
|
if (drop.count === 1)
|
|
18164
18262
|
this.logger.warn(message);
|
|
@@ -18572,9 +18670,11 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18572
18670
|
if (eccPrivateKey.length !== 32)
|
|
18573
18671
|
throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
|
|
18574
18672
|
const decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
18673
|
+
const assembler = new AccessUnitAssembler();
|
|
18575
18674
|
const video = [];
|
|
18576
18675
|
const audio = [];
|
|
18577
18676
|
const numbers = /* @__PURE__ */ new Set();
|
|
18677
|
+
const receivedNumbers = /* @__PURE__ */ new Set();
|
|
18578
18678
|
let firstStamp;
|
|
18579
18679
|
let lastStamp;
|
|
18580
18680
|
for (const { commandId, raw } of frames) {
|
|
@@ -18582,14 +18682,15 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18582
18682
|
const header = parseVideoFrameHeader(raw);
|
|
18583
18683
|
if (!header)
|
|
18584
18684
|
continue;
|
|
18585
|
-
|
|
18586
|
-
|
|
18587
|
-
|
|
18588
|
-
|
|
18589
|
-
|
|
18590
|
-
|
|
18591
|
-
|
|
18592
|
-
|
|
18685
|
+
receivedNumbers.add(header.sequence);
|
|
18686
|
+
const units = assembler.push(raw, (payload) => header.keyframe ? decoder.decodeFrame(payload)?.h264 : payload.length >= VIDEO_HEADER_LEN2 + header.payloadLength ? payload.subarray(VIDEO_HEADER_LEN2, VIDEO_HEADER_LEN2 + header.payloadLength) : void 0);
|
|
18687
|
+
for (const unit of units) {
|
|
18688
|
+
video.push(unit.data);
|
|
18689
|
+
numbers.add(header.sequence);
|
|
18690
|
+
const stamp = raw.readUIntLE(14, 6);
|
|
18691
|
+
firstStamp ??= stamp;
|
|
18692
|
+
lastStamp = stamp;
|
|
18693
|
+
}
|
|
18593
18694
|
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
18594
18695
|
const mediaKey = decoder.currentMediaKey;
|
|
18595
18696
|
if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
@@ -18605,9 +18706,10 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18605
18706
|
if (!video.length)
|
|
18606
18707
|
throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
|
|
18607
18708
|
const ordered = [...numbers];
|
|
18608
|
-
|
|
18609
|
-
|
|
18610
|
-
|
|
18709
|
+
const received = [...receivedNumbers];
|
|
18710
|
+
let missingFrames = receivedNumbers.size - numbers.size;
|
|
18711
|
+
for (let i = 1; i < received.length; i++) {
|
|
18712
|
+
const step = received[i] - received[i - 1] & 65535;
|
|
18611
18713
|
if (step > 1 && step < 32768)
|
|
18612
18714
|
missingFrames += step - 1;
|
|
18613
18715
|
}
|
|
@@ -19828,7 +19930,8 @@ var AdtsFrameReader = class {
|
|
|
19828
19930
|
};
|
|
19829
19931
|
|
|
19830
19932
|
// dist/transport/p2p/talkback.js
|
|
19831
|
-
var
|
|
19933
|
+
var HIGH_WATER_MS = 2e3;
|
|
19934
|
+
var TALKBACK_ELD_FRAME_MS = 32;
|
|
19832
19935
|
var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
|
|
19833
19936
|
var Talkback = class extends EventEmitter5 {
|
|
19834
19937
|
sink;
|
|
@@ -19836,7 +19939,8 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19836
19939
|
reader = new AdtsFrameReader();
|
|
19837
19940
|
queue = [];
|
|
19838
19941
|
logger;
|
|
19839
|
-
|
|
19942
|
+
/** Playback duration of one queued frame, the pacer's step: 64 ms for `aac-lc`, 32 ms for `aac-eld`. */
|
|
19943
|
+
frameMs = AAC_FRAME_MS;
|
|
19840
19944
|
idleTimeoutMs;
|
|
19841
19945
|
timer;
|
|
19842
19946
|
started = false;
|
|
@@ -19862,9 +19966,14 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19862
19966
|
this.sink = sink;
|
|
19863
19967
|
this.opts = opts;
|
|
19864
19968
|
this.logger = opts.logger ?? noopLogger;
|
|
19865
|
-
this.highWater = opts.highWaterFrames ?? DEFAULT_HIGH_WATER_FRAMES;
|
|
19866
19969
|
this.idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
19867
19970
|
}
|
|
19971
|
+
/** Frames the pacing queue holds before a writable applies backpressure: {@link HIGH_WATER_MS} of this codec. */
|
|
19972
|
+
get highWater() {
|
|
19973
|
+
return this.opts.highWaterFrames ?? Math.ceil(HIGH_WATER_MS / this.frameMs);
|
|
19974
|
+
}
|
|
19975
|
+
/** The codec this talkback sends, set by {@link start}. */
|
|
19976
|
+
codec = "aac-lc";
|
|
19868
19977
|
/** Frames still queued for the wire. */
|
|
19869
19978
|
get pending() {
|
|
19870
19979
|
return this.queue.length;
|
|
@@ -19873,25 +19982,33 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19873
19982
|
* Open the device's talkback path and begin the pacing tick. Throws when the topology's control
|
|
19874
19983
|
* frame could not be sent — for a HomeBase-attached camera that means the level-2 key was never
|
|
19875
19984
|
* negotiated, which would otherwise leave a silent session that accepts audio nobody hears.
|
|
19985
|
+
*
|
|
19986
|
+
* `codec` is the codec the camera's speaker plays; it fixes the input this talkback accepts and the pacing step.
|
|
19876
19987
|
*/
|
|
19877
|
-
start() {
|
|
19988
|
+
start(codec) {
|
|
19878
19989
|
if (this.started)
|
|
19879
19990
|
return this;
|
|
19991
|
+
if (codec === "aac-eld" && this.opts.encoder) {
|
|
19992
|
+
throw new Error("talkback: this camera plays aac-eld; an AacEncoder produces aac-lc");
|
|
19993
|
+
}
|
|
19994
|
+
this.codec = codec;
|
|
19995
|
+
this.frameMs = codec === "aac-eld" ? TALKBACK_ELD_FRAME_MS : AAC_FRAME_MS;
|
|
19880
19996
|
if (!this.sink.startTalkback(this.opts.channel, this.opts.homeBaseAttached)) {
|
|
19881
19997
|
throw new Error(`talkback: could not open the audio path on channel ${this.opts.channel}`);
|
|
19882
19998
|
}
|
|
19883
19999
|
this.started = true;
|
|
19884
20000
|
const now2 = Date.now();
|
|
19885
|
-
this.nextDueAt = now2 +
|
|
20001
|
+
this.nextDueAt = now2 + this.frameMs;
|
|
19886
20002
|
this.lastWriteAt = now2;
|
|
19887
|
-
this.timer = setInterval(() => this.tick(),
|
|
20003
|
+
this.timer = setInterval(() => this.tick(), this.frameMs);
|
|
19888
20004
|
this.timer.unref?.();
|
|
19889
20005
|
this.sink.on?.("audioGap", this.onAudioGap);
|
|
19890
20006
|
return this;
|
|
19891
20007
|
}
|
|
19892
20008
|
/**
|
|
19893
|
-
* Queue audio.
|
|
19894
|
-
*
|
|
20009
|
+
* Queue audio in the codec this talkback sends. For `aac-lc` the bytes are ADTS, or PCM when an encoder was
|
|
20010
|
+
* supplied, read as a stream, so a chunk carrying part of a frame is held until the rest arrives. For `aac-eld`
|
|
20011
|
+
* each call is one whole access unit; a unit over the device's length limit is dropped with an `error`.
|
|
19895
20012
|
*
|
|
19896
20013
|
* Audio written after {@link end} is dropped with an `error` rather than queued: `finished` has
|
|
19897
20014
|
* either already fired or is owed on the frames written before it, so a late chunk would either play
|
|
@@ -19905,6 +20022,15 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19905
20022
|
return;
|
|
19906
20023
|
}
|
|
19907
20024
|
this.lastWriteAt = Date.now();
|
|
20025
|
+
if (this.codec === "aac-eld") {
|
|
20026
|
+
if (chunk.length > MAX_AUDIO_FRAME_BYTES) {
|
|
20027
|
+
this.fail(new Error(`talkback: frame of ${chunk.length} B exceeds the device's ${MAX_AUDIO_FRAME_BYTES} B limit`));
|
|
20028
|
+
return;
|
|
20029
|
+
}
|
|
20030
|
+
if (chunk.length)
|
|
20031
|
+
this.queue.push(chunk);
|
|
20032
|
+
return;
|
|
20033
|
+
}
|
|
19908
20034
|
if (!this.opts.encoder) {
|
|
19909
20035
|
this.enqueue(this.reader.push(chunk));
|
|
19910
20036
|
return;
|
|
@@ -19973,14 +20099,14 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19973
20099
|
return;
|
|
19974
20100
|
const now2 = Date.now();
|
|
19975
20101
|
if (!this.queue.length) {
|
|
19976
|
-
this.nextDueAt = now2 +
|
|
20102
|
+
this.nextDueAt = now2 + this.frameMs;
|
|
19977
20103
|
this.idleCheck(now2);
|
|
19978
20104
|
this.announceFinishIfDone();
|
|
19979
20105
|
return;
|
|
19980
20106
|
}
|
|
19981
20107
|
while (this.queue.length && now2 >= this.nextDueAt) {
|
|
19982
20108
|
const frame = this.queue.shift();
|
|
19983
|
-
this.nextDueAt +=
|
|
20109
|
+
this.nextDueAt += this.frameMs;
|
|
19984
20110
|
try {
|
|
19985
20111
|
this.sink.sendAudioFrame(this.opts.channel, frame);
|
|
19986
20112
|
} catch (e) {
|
|
@@ -20038,9 +20164,11 @@ var Talkback = class extends EventEmitter5 {
|
|
|
20038
20164
|
/**
|
|
20039
20165
|
* A Writable over {@link write} that withholds its callback while the queue is at the high-water
|
|
20040
20166
|
* mark — which is what turns `pipe()` from a file into playback at speed rather than a memory spike.
|
|
20167
|
+
* For `aac-eld` it is object-mode: each write is one access unit.
|
|
20041
20168
|
*/
|
|
20042
20169
|
writable() {
|
|
20043
20170
|
return new Writable({
|
|
20171
|
+
objectMode: this.codec === "aac-eld",
|
|
20044
20172
|
write: (chunk, _enc, cb) => {
|
|
20045
20173
|
this.write(chunk);
|
|
20046
20174
|
if (this.queue.length < this.highWater)
|
|
@@ -20109,7 +20237,7 @@ var CODEC_BOXES = {
|
|
|
20109
20237
|
h264: { sample: "avc1", config: "avcC" },
|
|
20110
20238
|
h265: { sample: "hvc1", config: "hvcC" }
|
|
20111
20239
|
};
|
|
20112
|
-
var AAC_ELD_SAMPLES_PER_FRAME =
|
|
20240
|
+
var AAC_ELD_SAMPLES_PER_FRAME = 480;
|
|
20113
20241
|
var ADTS_FREQUENCY_INDEX_16K = 8;
|
|
20114
20242
|
var ADTS_CHANNELS_MONO = 1;
|
|
20115
20243
|
var Fmp4Muxer = class {
|
|
@@ -20127,6 +20255,8 @@ var Fmp4Muxer = class {
|
|
|
20127
20255
|
lastVideoTimestampMs;
|
|
20128
20256
|
audioRequested;
|
|
20129
20257
|
audioCodec;
|
|
20258
|
+
/** Decoder config of an AAC-ELD track, taken from its frames; absent for AAC-LC, whose `esds` config is fixed. */
|
|
20259
|
+
audioConfig;
|
|
20130
20260
|
audioDisabled = false;
|
|
20131
20261
|
audioSamples = [];
|
|
20132
20262
|
audioBaseDecodeTime = 0;
|
|
@@ -20193,7 +20323,10 @@ var Fmp4Muxer = class {
|
|
|
20193
20323
|
}
|
|
20194
20324
|
return void 0;
|
|
20195
20325
|
}
|
|
20196
|
-
/**
|
|
20326
|
+
/**
|
|
20327
|
+
* Add one station-declared AAC access unit to the audio track: an ADTS frame for AAC-LC, a raw access unit
|
|
20328
|
+
* described by its frame's config for AAC-ELD. G.711 remains available via `live()`.
|
|
20329
|
+
*/
|
|
20197
20330
|
pushAudio(frame, timestampMs = now()) {
|
|
20198
20331
|
if (!this.audioRequested || this.audioDisabled)
|
|
20199
20332
|
return void 0;
|
|
@@ -20209,12 +20342,19 @@ var Fmp4Muxer = class {
|
|
|
20209
20342
|
if (this.audioCodec && this.audioCodec !== frame.codec) {
|
|
20210
20343
|
return this.disableAudio();
|
|
20211
20344
|
}
|
|
20212
|
-
|
|
20213
|
-
if (
|
|
20214
|
-
|
|
20215
|
-
|
|
20216
|
-
|
|
20217
|
-
|
|
20345
|
+
let sample;
|
|
20346
|
+
if (frame.codec === "aac-eld") {
|
|
20347
|
+
this.audioConfig = frame.config;
|
|
20348
|
+
sample = frame.data;
|
|
20349
|
+
} else {
|
|
20350
|
+
const header = parseAdtsHeader(frame.data);
|
|
20351
|
+
if (!header || header.frameLength > frame.data.length) {
|
|
20352
|
+
throw new Error(`fMP4 muxer: ${frame.codec} frame is not a complete ADTS access unit`);
|
|
20353
|
+
}
|
|
20354
|
+
if (header.frequencyIndex !== ADTS_FREQUENCY_INDEX_16K || header.channels !== ADTS_CHANNELS_MONO) {
|
|
20355
|
+
return this.disableAudio();
|
|
20356
|
+
}
|
|
20357
|
+
sample = frame.data.subarray(header.headerLength, header.frameLength);
|
|
20218
20358
|
}
|
|
20219
20359
|
this.audioCodec = frame.codec;
|
|
20220
20360
|
this.firstAudioTimestampMs ??= timestampMs;
|
|
@@ -20226,7 +20366,7 @@ var Fmp4Muxer = class {
|
|
|
20226
20366
|
this.audioSamples[this.audioSamples.length - 1].duration = measured;
|
|
20227
20367
|
}
|
|
20228
20368
|
this.audioSamples.push({
|
|
20229
|
-
data:
|
|
20369
|
+
data: sample,
|
|
20230
20370
|
duration: frame.codec === "aac-eld" ? AAC_ELD_SAMPLES_PER_FRAME : AAC_SAMPLES_PER_FRAME,
|
|
20231
20371
|
keyframe: true
|
|
20232
20372
|
});
|
|
@@ -20499,7 +20639,7 @@ var Fmp4Muxer = class {
|
|
|
20499
20639
|
return box("mp4a", head, this.esds());
|
|
20500
20640
|
}
|
|
20501
20641
|
esds() {
|
|
20502
|
-
const config = this.
|
|
20642
|
+
const config = this.audioConfig ?? Buffer.from([20, 8]);
|
|
20503
20643
|
const specific = descriptor(5, config);
|
|
20504
20644
|
const decoder = descriptor(4, Buffer.concat([Buffer.from([64, 21, 0, 0, 0]), u32(64e3), u32(32e3), specific]));
|
|
20505
20645
|
const sl = descriptor(6, Buffer.from([2]));
|
|
@@ -20756,6 +20896,30 @@ var P2P_STATION_WAITS = {
|
|
|
20756
20896
|
level2Settle: LEVEL2_SETTLE_MS
|
|
20757
20897
|
};
|
|
20758
20898
|
var RTSP_URL_READ_TIMEOUT_MS = 12e3;
|
|
20899
|
+
var TALKBACK_CODEC_WAIT_MS = 3e3;
|
|
20900
|
+
var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
|
|
20901
|
+
function speakerCodec(consumer, talk, timeoutMs) {
|
|
20902
|
+
return new Promise((resolve) => {
|
|
20903
|
+
let timer;
|
|
20904
|
+
const settle = (codec) => {
|
|
20905
|
+
clearTimeout(timer);
|
|
20906
|
+
consumer.off("video", onVideo);
|
|
20907
|
+
consumer.off("audio", onAudio);
|
|
20908
|
+
consumer.off("stop", onStop);
|
|
20909
|
+
talk.off("stop", onStop);
|
|
20910
|
+
resolve(codec);
|
|
20911
|
+
};
|
|
20912
|
+
const onVideo = () => {
|
|
20913
|
+
timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
|
|
20914
|
+
};
|
|
20915
|
+
const onAudio = (frame) => settle(frame.codec === "aac-eld" ? "aac-eld" : "aac-lc");
|
|
20916
|
+
const onStop = () => settle("aac-lc");
|
|
20917
|
+
consumer.on("video", onVideo);
|
|
20918
|
+
consumer.on("audio", onAudio);
|
|
20919
|
+
consumer.on("stop", onStop);
|
|
20920
|
+
talk.once("stop", onStop);
|
|
20921
|
+
});
|
|
20922
|
+
}
|
|
20759
20923
|
var SHARED_LIVE_OPT_KEYS = [
|
|
20760
20924
|
"eccPrivateKey",
|
|
20761
20925
|
"keepAliveMs",
|
|
@@ -21174,14 +21338,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21174
21338
|
}
|
|
21175
21339
|
}
|
|
21176
21340
|
/**
|
|
21177
|
-
* Restart
|
|
21178
|
-
*
|
|
21179
|
-
*
|
|
21180
|
-
*
|
|
21181
|
-
*
|
|
21341
|
+
* Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
|
|
21342
|
+
* broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
|
|
21343
|
+
* byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
|
|
21344
|
+
*
|
|
21345
|
+
* The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
|
|
21346
|
+
* level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
|
|
21347
|
+
* sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
|
|
21348
|
+
* control here is.
|
|
21182
21349
|
*/
|
|
21183
21350
|
async rebootStation(sn) {
|
|
21184
|
-
await this.
|
|
21351
|
+
await this.sendBySessionLevel(sn, {
|
|
21352
|
+
l2: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8), resolved),
|
|
21353
|
+
l1: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel1Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB), resolved)
|
|
21354
|
+
});
|
|
21185
21355
|
}
|
|
21186
21356
|
/**
|
|
21187
21357
|
* A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
|
|
@@ -21303,6 +21473,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21303
21473
|
* stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
|
|
21304
21474
|
* instead of playing into silence.
|
|
21305
21475
|
*
|
|
21476
|
+
* **On a model in {@link ELD_TALKBACK_MODELS}, the speaker plays the codec the camera sends.** So the first
|
|
21477
|
+
* audio frame of the media session this talkback holds decides its codec ({@link speakerCodec}), within
|
|
21478
|
+
* {@link TALKBACK_CODEC_WAIT_MS} of the session's first frame, and the claim is re-checked after that wait as
|
|
21479
|
+
* after the warm-up. Every other model opens `aac-lc` at once.
|
|
21480
|
+
*
|
|
21306
21481
|
* The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
|
|
21307
21482
|
* {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
|
|
21308
21483
|
* own-session camera that legitimately never negotiates one.
|
|
@@ -21372,7 +21547,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21372
21547
|
consumer.on("stop", () => {
|
|
21373
21548
|
void talk.stop().catch((e) => logger.warn?.(`talkback: stop for ${sn} failed: ${String(e)}`));
|
|
21374
21549
|
});
|
|
21375
|
-
|
|
21550
|
+
const codec = ELD_TALKBACK_MODELS.has(this.recordFor(sn)?.model ?? "") ? await speakerCodec(consumer, talk, TALKBACK_CODEC_WAIT_MS) : "aac-lc";
|
|
21551
|
+
if (this.talkbacks.get(key) !== talk) {
|
|
21552
|
+
throw new Error(`talkback: ${sn} was closed while its speaker codec was being read`);
|
|
21553
|
+
}
|
|
21554
|
+
return talk.start(codec);
|
|
21376
21555
|
} catch (e) {
|
|
21377
21556
|
if (this.talkbacks.get(key) === talk)
|
|
21378
21557
|
this.talkbacks.delete(key);
|
|
@@ -22357,6 +22536,1237 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22357
22536
|
}
|
|
22358
22537
|
};
|
|
22359
22538
|
|
|
22539
|
+
// dist/transport/rtc/session.js
|
|
22540
|
+
import { EventEmitter as EventEmitter9 } from "node:events";
|
|
22541
|
+
|
|
22542
|
+
// dist/transport/rtc/peer.js
|
|
22543
|
+
import { EventEmitter as EventEmitter7 } from "node:events";
|
|
22544
|
+
|
|
22545
|
+
// dist/transport/rtc/portal-packet.js
|
|
22546
|
+
var MAGIC2 = Buffer.from("XZYH", "ascii");
|
|
22547
|
+
var PORTAL_HEADER_LENGTH = 16;
|
|
22548
|
+
var PortalLinkType = {
|
|
22549
|
+
/** Command channel: requests out, acknowledgements back. */
|
|
22550
|
+
COMMAND: 1,
|
|
22551
|
+
/** Station-originated frames: pushes, notify payloads, control results. */
|
|
22552
|
+
NOTIFY: 3
|
|
22553
|
+
};
|
|
22554
|
+
function encodeLe(value, width) {
|
|
22555
|
+
const buf = Buffer.alloc(width);
|
|
22556
|
+
let v = value >>> 0;
|
|
22557
|
+
for (let i = 0; i < width; i++) {
|
|
22558
|
+
buf[i] = v & 255;
|
|
22559
|
+
v >>>= 8;
|
|
22560
|
+
}
|
|
22561
|
+
return buf;
|
|
22562
|
+
}
|
|
22563
|
+
function buildPortalHeader(commandId, paramLength, channel, segment, isResponse = 0, devType = 2) {
|
|
22564
|
+
const header = Buffer.alloc(PORTAL_HEADER_LENGTH);
|
|
22565
|
+
MAGIC2.copy(header, 0);
|
|
22566
|
+
encodeLe(commandId, 2).copy(header, 4);
|
|
22567
|
+
encodeLe(paramLength, 4).copy(header, 6);
|
|
22568
|
+
header[10] = 0;
|
|
22569
|
+
header[11] = segment & 255;
|
|
22570
|
+
header[12] = channel & 255;
|
|
22571
|
+
header[13] = 0;
|
|
22572
|
+
header[14] = isResponse & 255;
|
|
22573
|
+
header[15] = devType & 255;
|
|
22574
|
+
return header;
|
|
22575
|
+
}
|
|
22576
|
+
function parsePortalHeader(buf) {
|
|
22577
|
+
if (buf.length < PORTAL_HEADER_LENGTH || buf.subarray(0, 4).compare(MAGIC2) !== 0)
|
|
22578
|
+
return void 0;
|
|
22579
|
+
return {
|
|
22580
|
+
commandId: buf.readUInt16LE(4),
|
|
22581
|
+
paramLength: buf.readUInt32LE(6),
|
|
22582
|
+
segment: buf[11],
|
|
22583
|
+
channel: buf[12],
|
|
22584
|
+
isResponse: buf[14],
|
|
22585
|
+
devType: buf[15]
|
|
22586
|
+
};
|
|
22587
|
+
}
|
|
22588
|
+
function isPortalPacket(buf) {
|
|
22589
|
+
return buf.length >= PORTAL_HEADER_LENGTH && buf.subarray(0, 4).compare(MAGIC2) === 0;
|
|
22590
|
+
}
|
|
22591
|
+
function buildPortalPacket(req) {
|
|
22592
|
+
const body = Buffer.from(JSON.stringify(req.payload), "utf8");
|
|
22593
|
+
const header = buildPortalHeader(req.commandId, body.length, req.channel, req.segment, req.isResponse ?? 0, req.devType ?? 2);
|
|
22594
|
+
return Buffer.concat([header, body]);
|
|
22595
|
+
}
|
|
22596
|
+
function parseJsonBody(buf) {
|
|
22597
|
+
let text2 = buf.toString("utf8");
|
|
22598
|
+
while (text2.endsWith("\0"))
|
|
22599
|
+
text2 = text2.slice(0, -1);
|
|
22600
|
+
try {
|
|
22601
|
+
return JSON.parse(text2);
|
|
22602
|
+
} catch {
|
|
22603
|
+
return text2;
|
|
22604
|
+
}
|
|
22605
|
+
}
|
|
22606
|
+
function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
|
|
22607
|
+
const header = parsePortalHeader(buf);
|
|
22608
|
+
if (!header)
|
|
22609
|
+
return void 0;
|
|
22610
|
+
const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
|
|
22611
|
+
if (linkType === PortalLinkType.NOTIFY) {
|
|
22612
|
+
const data = parseJsonBody(body);
|
|
22613
|
+
const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
|
|
22614
|
+
return {
|
|
22615
|
+
commandId: header.commandId,
|
|
22616
|
+
channel: header.channel,
|
|
22617
|
+
segment: header.segment,
|
|
22618
|
+
isResponse: header.isResponse,
|
|
22619
|
+
linkType,
|
|
22620
|
+
cmd,
|
|
22621
|
+
data
|
|
22622
|
+
};
|
|
22623
|
+
}
|
|
22624
|
+
return {
|
|
22625
|
+
commandId: header.commandId,
|
|
22626
|
+
channel: header.channel,
|
|
22627
|
+
segment: header.segment,
|
|
22628
|
+
isResponse: header.isResponse,
|
|
22629
|
+
linkType,
|
|
22630
|
+
errCode: body.length >= 4 ? body.readInt32LE(0) : -1,
|
|
22631
|
+
data: body.length > 4 ? parseJsonBody(body.subarray(4)) : void 0
|
|
22632
|
+
};
|
|
22633
|
+
}
|
|
22634
|
+
var SegmentCounter = class {
|
|
22635
|
+
current = 0;
|
|
22636
|
+
next() {
|
|
22637
|
+
this.current = this.current + 1 & 255;
|
|
22638
|
+
if (this.current === 0)
|
|
22639
|
+
this.current = 1;
|
|
22640
|
+
return this.current;
|
|
22641
|
+
}
|
|
22642
|
+
};
|
|
22643
|
+
|
|
22644
|
+
// dist/transport/rtc/ptcs-framer.js
|
|
22645
|
+
var MAGIC3 = Buffer.from("PTCS", "ascii");
|
|
22646
|
+
var PTCS_HEADER_LENGTH = 28;
|
|
22647
|
+
var PTCS_DEFAULT_PAYLOAD_BYTES = 1e3;
|
|
22648
|
+
var FLAG_BASE = 16384;
|
|
22649
|
+
var FLAG_LAST = 1024;
|
|
22650
|
+
var LENGTH_MASK = 1023;
|
|
22651
|
+
var PtcsChannel = {
|
|
22652
|
+
COMMAND: 0,
|
|
22653
|
+
NOTIFY: 2
|
|
22654
|
+
};
|
|
22655
|
+
function linkTypeForChannel(channel) {
|
|
22656
|
+
return channel === PtcsChannel.NOTIFY ? PortalLinkType.NOTIFY : PortalLinkType.COMMAND;
|
|
22657
|
+
}
|
|
22658
|
+
var PTCS_STALE_MS = 15e3;
|
|
22659
|
+
function parsePtcsHeader(packet) {
|
|
22660
|
+
if (packet.length < PTCS_HEADER_LENGTH || packet.subarray(0, 4).compare(MAGIC3) !== 0)
|
|
22661
|
+
return void 0;
|
|
22662
|
+
const flags = packet.readUInt16LE(18);
|
|
22663
|
+
return {
|
|
22664
|
+
channel: packet[5],
|
|
22665
|
+
sequence: packet.readUInt16LE(6),
|
|
22666
|
+
frameId: packet.readUInt32LE(8),
|
|
22667
|
+
frameLength: packet.readUInt32LE(12),
|
|
22668
|
+
index: packet.readUInt16LE(16),
|
|
22669
|
+
last: (flags & FLAG_LAST) !== 0,
|
|
22670
|
+
payloadLength: flags & LENGTH_MASK
|
|
22671
|
+
};
|
|
22672
|
+
}
|
|
22673
|
+
function packetize(frame, opts) {
|
|
22674
|
+
const size = opts.payloadBytes ?? PTCS_DEFAULT_PAYLOAD_BYTES;
|
|
22675
|
+
if (size <= 0 || size > LENGTH_MASK)
|
|
22676
|
+
throw new RangeError(`PTCS payload size ${size} out of range`);
|
|
22677
|
+
const channel = opts.channel ?? PtcsChannel.COMMAND;
|
|
22678
|
+
const count2 = Math.max(1, Math.ceil(frame.length / size));
|
|
22679
|
+
const sequence = (opts.sequence ?? 0) & 65535;
|
|
22680
|
+
const packets = [];
|
|
22681
|
+
for (let i = 0; i < count2; i++) {
|
|
22682
|
+
const chunk = frame.subarray(i * size, Math.min(frame.length, (i + 1) * size));
|
|
22683
|
+
const last = i === count2 - 1;
|
|
22684
|
+
const packet = Buffer.alloc(PTCS_HEADER_LENGTH + size);
|
|
22685
|
+
MAGIC3.copy(packet, 0);
|
|
22686
|
+
packet[4] = 3;
|
|
22687
|
+
packet[5] = channel & 255;
|
|
22688
|
+
packet.writeUInt16LE(sequence, 6);
|
|
22689
|
+
packet.writeUInt32LE(opts.frameId >>> 0, 8);
|
|
22690
|
+
packet.writeUInt32LE(frame.length >>> 0, 12);
|
|
22691
|
+
packet.writeUInt16LE(i, 16);
|
|
22692
|
+
packet.writeUInt16LE(FLAG_BASE | (last ? FLAG_LAST : 0) | chunk.length, 18);
|
|
22693
|
+
chunk.copy(packet, PTCS_HEADER_LENGTH);
|
|
22694
|
+
packets.push(packet);
|
|
22695
|
+
}
|
|
22696
|
+
return packets;
|
|
22697
|
+
}
|
|
22698
|
+
var PtcsReassembler = class {
|
|
22699
|
+
onFrame;
|
|
22700
|
+
now;
|
|
22701
|
+
partials = /* @__PURE__ */ new Map();
|
|
22702
|
+
constructor(onFrame, now2 = Date.now) {
|
|
22703
|
+
this.onFrame = onFrame;
|
|
22704
|
+
this.now = now2;
|
|
22705
|
+
}
|
|
22706
|
+
push(packet) {
|
|
22707
|
+
const h = parsePtcsHeader(packet);
|
|
22708
|
+
if (!h)
|
|
22709
|
+
return false;
|
|
22710
|
+
const body = packet.subarray(PTCS_HEADER_LENGTH, PTCS_HEADER_LENGTH + h.payloadLength);
|
|
22711
|
+
if (body.length !== h.payloadLength)
|
|
22712
|
+
return false;
|
|
22713
|
+
const key = `${h.channel}:${h.frameId}`;
|
|
22714
|
+
const now2 = this.now();
|
|
22715
|
+
let p = this.partials.get(key);
|
|
22716
|
+
if (!p) {
|
|
22717
|
+
p = { channel: h.channel, frameLength: h.frameLength, chunks: /* @__PURE__ */ new Map(), touched: now2 };
|
|
22718
|
+
this.partials.set(key, p);
|
|
22719
|
+
}
|
|
22720
|
+
p.touched = now2;
|
|
22721
|
+
p.chunks.set(h.index, Buffer.from(body));
|
|
22722
|
+
if (h.last)
|
|
22723
|
+
p.lastIndex = h.index;
|
|
22724
|
+
if (p.lastIndex === void 0)
|
|
22725
|
+
return true;
|
|
22726
|
+
let total = 0;
|
|
22727
|
+
const parts = [];
|
|
22728
|
+
for (let i = 0; i <= p.lastIndex; i++) {
|
|
22729
|
+
const c = p.chunks.get(i);
|
|
22730
|
+
if (!c)
|
|
22731
|
+
return true;
|
|
22732
|
+
parts.push(c);
|
|
22733
|
+
total += c.length;
|
|
22734
|
+
}
|
|
22735
|
+
this.partials.delete(key);
|
|
22736
|
+
if (total !== p.frameLength)
|
|
22737
|
+
return false;
|
|
22738
|
+
this.onFrame(Buffer.concat(parts, total), p.channel);
|
|
22739
|
+
return true;
|
|
22740
|
+
}
|
|
22741
|
+
/** Drop frames that stopped arriving; call periodically. */
|
|
22742
|
+
expire() {
|
|
22743
|
+
const now2 = this.now();
|
|
22744
|
+
for (const [key, p] of this.partials) {
|
|
22745
|
+
if (now2 - p.touched > PTCS_STALE_MS)
|
|
22746
|
+
this.partials.delete(key);
|
|
22747
|
+
}
|
|
22748
|
+
}
|
|
22749
|
+
};
|
|
22750
|
+
function frameIdClock(now2 = Date.now) {
|
|
22751
|
+
let last = 0;
|
|
22752
|
+
return () => {
|
|
22753
|
+
let id = now2() >>> 0;
|
|
22754
|
+
if (id <= last)
|
|
22755
|
+
id = last + 1 >>> 0;
|
|
22756
|
+
last = id;
|
|
22757
|
+
return id;
|
|
22758
|
+
};
|
|
22759
|
+
}
|
|
22760
|
+
var PtcsFramer = class {
|
|
22761
|
+
onWire;
|
|
22762
|
+
onFrame;
|
|
22763
|
+
reassembler;
|
|
22764
|
+
sweep;
|
|
22765
|
+
ready = false;
|
|
22766
|
+
nextFrameId = frameIdClock();
|
|
22767
|
+
sequence = 0;
|
|
22768
|
+
/** Arm the two callbacks; `sendFrame` may be called from here on. */
|
|
22769
|
+
init(onWirePacket, onFrame) {
|
|
22770
|
+
this.onWire = onWirePacket;
|
|
22771
|
+
this.onFrame = onFrame;
|
|
22772
|
+
this.reassembler = new PtcsReassembler((frame, channel) => this.onFrame?.(frame, linkTypeForChannel(channel)));
|
|
22773
|
+
this.sweep = setInterval(() => this.reassembler?.expire(), 1e3);
|
|
22774
|
+
this.sweep.unref?.();
|
|
22775
|
+
this.ready = true;
|
|
22776
|
+
}
|
|
22777
|
+
isReady() {
|
|
22778
|
+
return this.ready;
|
|
22779
|
+
}
|
|
22780
|
+
sendFrame(portalPacket) {
|
|
22781
|
+
if (!this.ready)
|
|
22782
|
+
throw new Error("PTCS framer not initialised");
|
|
22783
|
+
const packets = packetize(portalPacket, { frameId: this.nextFrameId(), sequence: this.sequence });
|
|
22784
|
+
this.sequence = this.sequence + 1 & 65535;
|
|
22785
|
+
for (const packet of packets)
|
|
22786
|
+
this.onWire?.(packet);
|
|
22787
|
+
}
|
|
22788
|
+
recvPacket(wirePacket) {
|
|
22789
|
+
if (!this.ready)
|
|
22790
|
+
return;
|
|
22791
|
+
if (isPortalPacket(wirePacket)) {
|
|
22792
|
+
this.onFrame?.(wirePacket, PortalLinkType.COMMAND);
|
|
22793
|
+
return;
|
|
22794
|
+
}
|
|
22795
|
+
this.reassembler?.push(wirePacket);
|
|
22796
|
+
}
|
|
22797
|
+
destroy() {
|
|
22798
|
+
this.ready = false;
|
|
22799
|
+
if (this.sweep)
|
|
22800
|
+
clearInterval(this.sweep);
|
|
22801
|
+
this.sweep = void 0;
|
|
22802
|
+
this.reassembler = void 0;
|
|
22803
|
+
this.onWire = void 0;
|
|
22804
|
+
this.onFrame = void 0;
|
|
22805
|
+
}
|
|
22806
|
+
};
|
|
22807
|
+
|
|
22808
|
+
// dist/transport/rtc/scall-sdp.js
|
|
22809
|
+
var HUB_SDP_MID = "2";
|
|
22810
|
+
var ANKER_MAX_MESSAGE_SIZE = 262144;
|
|
22811
|
+
function scallJsonToSdp(json, now2 = Date.now) {
|
|
22812
|
+
let sdp = "";
|
|
22813
|
+
sdp += "v=0\r\n";
|
|
22814
|
+
sdp += `o=- ${Math.floor(now2())} 1 IN IP4 127.0.0.1\r
|
|
22815
|
+
`;
|
|
22816
|
+
sdp += "s=Anker Webrtc Stream\r\n";
|
|
22817
|
+
sdp += "t=0 0\r\n";
|
|
22818
|
+
sdp += `a=group:BUNDLE ${HUB_SDP_MID}\r
|
|
22819
|
+
`;
|
|
22820
|
+
sdp += "a=msid-semantic: WMS\r\n";
|
|
22821
|
+
sdp += "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n";
|
|
22822
|
+
sdp += "c=IN IP4 127.0.0.1\r\n";
|
|
22823
|
+
sdp += `a=mid:${HUB_SDP_MID}\r
|
|
22824
|
+
`;
|
|
22825
|
+
sdp += "a=ice-options:trickle\r\n";
|
|
22826
|
+
if (json.ice?.ufrag)
|
|
22827
|
+
sdp += `a=ice-ufrag:${json.ice.ufrag}\r
|
|
22828
|
+
`;
|
|
22829
|
+
if (json.ice?.pwd)
|
|
22830
|
+
sdp += `a=ice-pwd:${json.ice.pwd}\r
|
|
22831
|
+
`;
|
|
22832
|
+
if (json.ice?.fingerprint) {
|
|
22833
|
+
const fp = json.ice.fingerprint.replace(/(.{2})(?=.)/g, "$1:");
|
|
22834
|
+
sdp += `a=fingerprint:${json.ice.fingerprint_type ?? "sha-256"} ${fp}\r
|
|
22835
|
+
`;
|
|
22836
|
+
}
|
|
22837
|
+
sdp += `a=setup:${json.setup ?? "actpass"}\r
|
|
22838
|
+
`;
|
|
22839
|
+
sdp += "a=sctp-port:5000\r\n";
|
|
22840
|
+
sdp += `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}\r
|
|
22841
|
+
`;
|
|
22842
|
+
for (const c of json.candidate ?? [])
|
|
22843
|
+
sdp += `a=candidate:${c}\r
|
|
22844
|
+
`;
|
|
22845
|
+
return sdp;
|
|
22846
|
+
}
|
|
22847
|
+
function sdpToScallJson(sdp) {
|
|
22848
|
+
const json = { ice: { ufrag: "", pwd: "", fingerprint: "" } };
|
|
22849
|
+
const setup = sdp.match(/a=setup:([^\r\n]+)/)?.[1];
|
|
22850
|
+
if (setup && setup !== "actpass")
|
|
22851
|
+
json.setup = setup;
|
|
22852
|
+
const ufrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)?.[1];
|
|
22853
|
+
if (ufrag)
|
|
22854
|
+
json.ice.ufrag = ufrag;
|
|
22855
|
+
const pwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)?.[1];
|
|
22856
|
+
if (pwd)
|
|
22857
|
+
json.ice.pwd = pwd;
|
|
22858
|
+
const fp = sdp.match(/a=fingerprint:([^\s]+)\s+([^\r\n]+)/);
|
|
22859
|
+
if (fp?.[2]) {
|
|
22860
|
+
json.ice.fingerprint_type = fp[1];
|
|
22861
|
+
json.ice.fingerprint = fp[2].replace(/:/g, "");
|
|
22862
|
+
}
|
|
22863
|
+
const candidates = [];
|
|
22864
|
+
for (const line of sdp.split(/\r\n|\r|\n/)) {
|
|
22865
|
+
const m = line.match(/^a=candidate:(.+)/);
|
|
22866
|
+
if (m?.[1])
|
|
22867
|
+
candidates.push(m[1]);
|
|
22868
|
+
}
|
|
22869
|
+
if (candidates.length)
|
|
22870
|
+
json.candidate = candidates;
|
|
22871
|
+
return json;
|
|
22872
|
+
}
|
|
22873
|
+
function iceCandidateType(candidate) {
|
|
22874
|
+
return candidate.match(/typ (\w+)/)?.[1] ?? "unknown";
|
|
22875
|
+
}
|
|
22876
|
+
function pinMaxMessageSize(sdp) {
|
|
22877
|
+
return sdp.replace(/a=max-message-size:\d+/g, `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}`);
|
|
22878
|
+
}
|
|
22879
|
+
function forceDtlsRole(sdp, role) {
|
|
22880
|
+
if (/a=setup:(active|passive|actpass)/.test(sdp))
|
|
22881
|
+
return sdp.replace(/a=setup:(active|passive|actpass)/g, `a=setup:${role}`);
|
|
22882
|
+
return sdp.replace(/(m=application[^\r\n]*\r?\n)/, `$1a=setup:${role}\r
|
|
22883
|
+
`);
|
|
22884
|
+
}
|
|
22885
|
+
function toWireCandidate(candidate) {
|
|
22886
|
+
return candidate.trim().replace(/^a=/, "");
|
|
22887
|
+
}
|
|
22888
|
+
|
|
22889
|
+
// dist/transport/rtc/peer.js
|
|
22890
|
+
var DATA_CHANNEL_LABELS = ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
|
|
22891
|
+
var COMMAND_CHANNEL = DATA_CHANNEL_LABELS[0];
|
|
22892
|
+
var DATA_CHANNEL_IDS = {
|
|
22893
|
+
WebrtcDataChannel: 0,
|
|
22894
|
+
audio: 2,
|
|
22895
|
+
idr: 4,
|
|
22896
|
+
video: 6,
|
|
22897
|
+
notify: 8,
|
|
22898
|
+
download: 10
|
|
22899
|
+
};
|
|
22900
|
+
function turnServers(turn) {
|
|
22901
|
+
const both = (hostname, port) => [
|
|
22902
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnUdp" },
|
|
22903
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnTcp" }
|
|
22904
|
+
];
|
|
22905
|
+
const servers = both(turn.turn_addr, turn.turn_port);
|
|
22906
|
+
if (turn.alt_turn_addr && turn.alt_turn_port)
|
|
22907
|
+
servers.push(...both(turn.alt_turn_addr, turn.alt_turn_port));
|
|
22908
|
+
return servers;
|
|
22909
|
+
}
|
|
22910
|
+
var UNBUILT_ARCHES = /* @__PURE__ */ new Set(["arm", "ia32"]);
|
|
22911
|
+
async function loadNativePeerFactory() {
|
|
22912
|
+
let ndc;
|
|
22913
|
+
try {
|
|
22914
|
+
ndc = await import("node-datachannel");
|
|
22915
|
+
} catch (err) {
|
|
22916
|
+
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`)";
|
|
22917
|
+
throw new Error(message, { cause: err });
|
|
22918
|
+
}
|
|
22919
|
+
return (name, config) => new ndc.PeerConnection(name, config);
|
|
22920
|
+
}
|
|
22921
|
+
var ANSWER_TIMEOUT_MS = 15e3;
|
|
22922
|
+
var RtcPeer = class extends EventEmitter7 {
|
|
22923
|
+
opts;
|
|
22924
|
+
pc;
|
|
22925
|
+
channels = /* @__PURE__ */ new Map();
|
|
22926
|
+
framer;
|
|
22927
|
+
wireTally = /* @__PURE__ */ new Map();
|
|
22928
|
+
commandOpen = false;
|
|
22929
|
+
remoteSet = false;
|
|
22930
|
+
handlingOffer = false;
|
|
22931
|
+
channelsCreated = false;
|
|
22932
|
+
gatheringDone = false;
|
|
22933
|
+
/** Set by the framer's wire callback when the native channel refused a packet mid-send. */
|
|
22934
|
+
wireSendFailed = false;
|
|
22935
|
+
pending = [];
|
|
22936
|
+
localAnswer;
|
|
22937
|
+
logger;
|
|
22938
|
+
constructor(opts = {}) {
|
|
22939
|
+
super();
|
|
22940
|
+
this.opts = opts;
|
|
22941
|
+
this.logger = opts.logger ?? noopLogger;
|
|
22942
|
+
}
|
|
22943
|
+
/** Create the native peer on the relay the hub granted in `scall 100`. */
|
|
22944
|
+
async init(turn) {
|
|
22945
|
+
if (this.pc)
|
|
22946
|
+
return;
|
|
22947
|
+
const createPeer = this.opts.createPeer ?? await loadNativePeerFactory();
|
|
22948
|
+
const config = {
|
|
22949
|
+
iceServers: turnServers(turn),
|
|
22950
|
+
iceTransportPolicy: "relay",
|
|
22951
|
+
maxMessageSize: ANKER_MAX_MESSAGE_SIZE,
|
|
22952
|
+
enableIceTcp: true
|
|
22953
|
+
};
|
|
22954
|
+
const pc = createPeer("eufy-sdk", config);
|
|
22955
|
+
this.pc = pc;
|
|
22956
|
+
pc.onLocalDescription((sdp, type) => {
|
|
22957
|
+
if (String(type).toLowerCase() === "answer" && this.localAnswer) {
|
|
22958
|
+
clearTimeout(this.localAnswer.timer);
|
|
22959
|
+
this.localAnswer.resolve(sdp);
|
|
22960
|
+
this.localAnswer = void 0;
|
|
22961
|
+
}
|
|
22962
|
+
});
|
|
22963
|
+
pc.onLocalCandidate((candidate) => {
|
|
22964
|
+
if (!candidate)
|
|
22965
|
+
return;
|
|
22966
|
+
if (!this.acceptsCandidate(candidate))
|
|
22967
|
+
return;
|
|
22968
|
+
this.emit("iceCandidate", candidate);
|
|
22969
|
+
});
|
|
22970
|
+
pc.onGatheringStateChange((state) => {
|
|
22971
|
+
if (state === "complete" && !this.gatheringDone) {
|
|
22972
|
+
this.gatheringDone = true;
|
|
22973
|
+
this.emit("iceGatheringComplete");
|
|
22974
|
+
}
|
|
22975
|
+
});
|
|
22976
|
+
pc.onStateChange((state) => {
|
|
22977
|
+
this.logger.debug(`[rtc] peer state ${state}`);
|
|
22978
|
+
this.emit("connectionState", state);
|
|
22979
|
+
});
|
|
22980
|
+
pc.onDataChannel((dc) => this.wireChannel(dc.getLabel(), dc));
|
|
22981
|
+
}
|
|
22982
|
+
/**
|
|
22983
|
+
* The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
|
|
22984
|
+
*
|
|
22985
|
+
* The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
|
|
22986
|
+
* `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
|
|
22987
|
+
* only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
|
|
22988
|
+
* a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
|
|
22989
|
+
* The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
|
|
22990
|
+
* is taken as the local description: one read in `have-local-offer` is an offer.
|
|
22991
|
+
*/
|
|
22992
|
+
async handleRemoteOffer(offerSdp) {
|
|
22993
|
+
const pc = this.pc;
|
|
22994
|
+
if (!pc)
|
|
22995
|
+
throw new Error("RTC peer not initialised");
|
|
22996
|
+
if (this.handlingOffer)
|
|
22997
|
+
throw new Error("RTC peer already handling an offer");
|
|
22998
|
+
this.handlingOffer = true;
|
|
22999
|
+
try {
|
|
23000
|
+
const offer = forceDtlsRole(offerSdp, "passive");
|
|
23001
|
+
const answerWait = new Promise((resolve, reject) => {
|
|
23002
|
+
const timer = setTimeout(() => {
|
|
23003
|
+
this.localAnswer = void 0;
|
|
23004
|
+
reject(new Error("timed out waiting for the local SDP answer"));
|
|
23005
|
+
}, ANSWER_TIMEOUT_MS);
|
|
23006
|
+
this.localAnswer = { resolve, reject, timer };
|
|
23007
|
+
});
|
|
23008
|
+
pc.setRemoteDescription(offer, "offer");
|
|
23009
|
+
this.remoteSet = true;
|
|
23010
|
+
this.createChannels();
|
|
23011
|
+
const local = pc.localDescription();
|
|
23012
|
+
let answer = String(local?.type ?? "").toLowerCase() === "answer" ? local?.sdp ?? "" : "";
|
|
23013
|
+
if (!answer)
|
|
23014
|
+
answer = await answerWait;
|
|
23015
|
+
else if (this.localAnswer) {
|
|
23016
|
+
clearTimeout(this.localAnswer.timer);
|
|
23017
|
+
this.localAnswer = void 0;
|
|
23018
|
+
}
|
|
23019
|
+
this.flushPending();
|
|
23020
|
+
return pinMaxMessageSize(answer);
|
|
23021
|
+
} finally {
|
|
23022
|
+
this.handlingOffer = false;
|
|
23023
|
+
}
|
|
23024
|
+
}
|
|
23025
|
+
addRemoteCandidate(candidate) {
|
|
23026
|
+
if (!this.pc)
|
|
23027
|
+
return;
|
|
23028
|
+
if (!this.acceptsCandidate(candidate))
|
|
23029
|
+
return;
|
|
23030
|
+
if (!this.remoteSet || this.handlingOffer) {
|
|
23031
|
+
this.pending.push(candidate);
|
|
23032
|
+
return;
|
|
23033
|
+
}
|
|
23034
|
+
this.addNow(candidate);
|
|
23035
|
+
}
|
|
23036
|
+
/**
|
|
23037
|
+
* Send one portal packet on the command channel, through the framer. False when the channel is not
|
|
23038
|
+
* usable or the native send refused a wire packet, so a dropped frame does not read as success.
|
|
23039
|
+
*/
|
|
23040
|
+
sendCommand(portalPacket) {
|
|
23041
|
+
const dc = this.channels.get(COMMAND_CHANNEL);
|
|
23042
|
+
if (!dc?.isOpen() || !this.commandOpen || !this.framer?.isReady())
|
|
23043
|
+
return false;
|
|
23044
|
+
this.wireSendFailed = false;
|
|
23045
|
+
try {
|
|
23046
|
+
this.framer.sendFrame(portalPacket);
|
|
23047
|
+
} catch (e) {
|
|
23048
|
+
this.logger.warn(`[rtc] command send failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23049
|
+
return false;
|
|
23050
|
+
}
|
|
23051
|
+
return !this.wireSendFailed;
|
|
23052
|
+
}
|
|
23053
|
+
/** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
|
|
23054
|
+
close() {
|
|
23055
|
+
this.framer?.destroy();
|
|
23056
|
+
this.framer = void 0;
|
|
23057
|
+
if (this.localAnswer) {
|
|
23058
|
+
clearTimeout(this.localAnswer.timer);
|
|
23059
|
+
const pending = this.localAnswer;
|
|
23060
|
+
this.localAnswer = void 0;
|
|
23061
|
+
pending.reject(new Error("RTC peer closed while waiting for the local SDP answer"));
|
|
23062
|
+
}
|
|
23063
|
+
this.commandOpen = false;
|
|
23064
|
+
this.gatheringDone = false;
|
|
23065
|
+
this.pc?.close();
|
|
23066
|
+
this.pc = void 0;
|
|
23067
|
+
this.channels.clear();
|
|
23068
|
+
this.channelsCreated = false;
|
|
23069
|
+
this.remoteSet = false;
|
|
23070
|
+
this.pending.length = 0;
|
|
23071
|
+
}
|
|
23072
|
+
acceptsCandidate(candidate) {
|
|
23073
|
+
return iceCandidateType(candidate) === "relay";
|
|
23074
|
+
}
|
|
23075
|
+
createChannels() {
|
|
23076
|
+
if (!this.pc || this.channelsCreated)
|
|
23077
|
+
return;
|
|
23078
|
+
this.channelsCreated = true;
|
|
23079
|
+
for (const label of DATA_CHANNEL_LABELS) {
|
|
23080
|
+
const dc = this.pc.createDataChannel(label, { id: DATA_CHANNEL_IDS[label], unordered: false });
|
|
23081
|
+
this.wireChannel(label, dc);
|
|
23082
|
+
}
|
|
23083
|
+
}
|
|
23084
|
+
/**
|
|
23085
|
+
* Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
|
|
23086
|
+
* message before that has nothing to reassemble into and is dropped.
|
|
23087
|
+
*/
|
|
23088
|
+
wireChannel(label, dc) {
|
|
23089
|
+
if (this.channels.has(label))
|
|
23090
|
+
return;
|
|
23091
|
+
this.channels.set(label, dc);
|
|
23092
|
+
dc.onOpen(() => {
|
|
23093
|
+
this.logger.debug(`[rtc] data channel open ${label}`);
|
|
23094
|
+
if (label !== COMMAND_CHANNEL)
|
|
23095
|
+
return;
|
|
23096
|
+
if (!this.framer) {
|
|
23097
|
+
const framer = new PtcsFramer();
|
|
23098
|
+
framer.init((packet) => {
|
|
23099
|
+
if (!dc.isOpen() || !dc.sendMessageBinary(packet))
|
|
23100
|
+
this.wireSendFailed = true;
|
|
23101
|
+
}, (frame, linkType) => this.emit("data", frame, linkType));
|
|
23102
|
+
this.framer = framer;
|
|
23103
|
+
}
|
|
23104
|
+
this.commandOpen = true;
|
|
23105
|
+
this.emit("commandChannelOpen");
|
|
23106
|
+
});
|
|
23107
|
+
dc.onClosed(() => {
|
|
23108
|
+
this.logger.debug(`[rtc] data channel closed ${label}`);
|
|
23109
|
+
if (label !== COMMAND_CHANNEL)
|
|
23110
|
+
return;
|
|
23111
|
+
const wasOpen = this.commandOpen;
|
|
23112
|
+
this.commandOpen = false;
|
|
23113
|
+
if (wasOpen)
|
|
23114
|
+
this.emit("commandChannelClosed");
|
|
23115
|
+
});
|
|
23116
|
+
dc.onError((err) => this.emit("error", new Error(`RTC data channel ${label}: ${err}`)));
|
|
23117
|
+
dc.onMessage((msg) => {
|
|
23118
|
+
const buf = typeof msg === "string" ? Buffer.from(msg) : Buffer.isBuffer(msg) ? msg : Buffer.from(msg);
|
|
23119
|
+
const n = (this.wireTally.get(label) ?? 0) + 1;
|
|
23120
|
+
this.wireTally.set(label, n);
|
|
23121
|
+
if (n <= 3 || n % 100 === 0) {
|
|
23122
|
+
this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
|
|
23123
|
+
}
|
|
23124
|
+
this.framer?.recvPacket(buf);
|
|
23125
|
+
});
|
|
23126
|
+
}
|
|
23127
|
+
addNow(candidate) {
|
|
23128
|
+
try {
|
|
23129
|
+
this.pc?.addRemoteCandidate(candidate, HUB_SDP_MID);
|
|
23130
|
+
} catch (e) {
|
|
23131
|
+
this.logger.warn(`[rtc] addRemoteCandidate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23132
|
+
}
|
|
23133
|
+
}
|
|
23134
|
+
flushPending() {
|
|
23135
|
+
const queued = this.pending.splice(0);
|
|
23136
|
+
for (const c of queued)
|
|
23137
|
+
this.addNow(c);
|
|
23138
|
+
}
|
|
23139
|
+
};
|
|
23140
|
+
|
|
23141
|
+
// dist/transport/rtc/signaling.js
|
|
23142
|
+
import { EventEmitter as EventEmitter8 } from "node:events";
|
|
23143
|
+
import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
|
|
23144
|
+
var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
|
|
23145
|
+
var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
|
|
23146
|
+
var PORTAL_ORIGIN = "https://security.eufy.com";
|
|
23147
|
+
var SIGNALING_KEEPALIVE_MS = 25e3;
|
|
23148
|
+
var SIGNALING_CONNECT_TIMEOUT_MS = 15e3;
|
|
23149
|
+
var SOURCE = "WEB";
|
|
23150
|
+
function sessionAccount(channelId, adminUserId, ts, authToken) {
|
|
23151
|
+
return createHmac3("sha256", authToken).update(`${channelId}${adminUserId}${ts}`).digest("hex");
|
|
23152
|
+
}
|
|
23153
|
+
var WS_OPEN = 1;
|
|
23154
|
+
var defaultSocket = (url, init) => new WebSocket(url, {
|
|
23155
|
+
protocols: init.protocols,
|
|
23156
|
+
headers: { Origin: init.origin }
|
|
23157
|
+
});
|
|
23158
|
+
var RtcSignalingClient = class extends EventEmitter8 {
|
|
23159
|
+
opts;
|
|
23160
|
+
ws;
|
|
23161
|
+
sign;
|
|
23162
|
+
keepalive;
|
|
23163
|
+
wsUrl;
|
|
23164
|
+
signUrl;
|
|
23165
|
+
wsRegion;
|
|
23166
|
+
fetchImpl;
|
|
23167
|
+
createSocket;
|
|
23168
|
+
logger;
|
|
23169
|
+
now;
|
|
23170
|
+
makeMsgId;
|
|
23171
|
+
constructor(opts) {
|
|
23172
|
+
super();
|
|
23173
|
+
this.opts = opts;
|
|
23174
|
+
const prefix = (opts.shard.split("-")[0] || "us").toLowerCase();
|
|
23175
|
+
const smartHost = prefix === "us" ? "security-smart.eufylife.com" : `security-smart-${prefix}.eufylife.com`;
|
|
23176
|
+
this.wsUrl = `wss://${smartHost}${RTC_WS_PATH}`;
|
|
23177
|
+
this.signUrl = `https://${smartHost}${RTC_SIGN_PATH}`;
|
|
23178
|
+
this.wsRegion = prefix.toUpperCase();
|
|
23179
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
23180
|
+
this.createSocket = opts.createSocket ?? defaultSocket;
|
|
23181
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23182
|
+
this.now = opts.now ?? Date.now;
|
|
23183
|
+
this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
|
|
23184
|
+
}
|
|
23185
|
+
get isOpen() {
|
|
23186
|
+
return this.ws?.readyState === WS_OPEN;
|
|
23187
|
+
}
|
|
23188
|
+
/** Step 1: the sign blob the socket and every auth message carry. A non-JSON body is reported by status. */
|
|
23189
|
+
async fetchSign() {
|
|
23190
|
+
const res = await this.fetchImpl(this.signUrl, {
|
|
23191
|
+
headers: {
|
|
23192
|
+
"Web-Country": this.opts.country.toUpperCase(),
|
|
23193
|
+
"X-Auth-Token": this.opts.authToken,
|
|
23194
|
+
"App-Name": "eufy_mega",
|
|
23195
|
+
"Model-Type": "WEB",
|
|
23196
|
+
GToken: this.opts.gtoken,
|
|
23197
|
+
Origin: PORTAL_ORIGIN
|
|
23198
|
+
}
|
|
23199
|
+
});
|
|
23200
|
+
const body = await res.json().catch(() => ({})) ?? {};
|
|
23201
|
+
if (!res.ok || body.code !== 0 || !body.data) {
|
|
23202
|
+
throw new Error(`RTC sign for ${this.opts.stationSn} refused: HTTP ${res.status} code ${body.code ?? "?"} ${body.msg ?? ""}`.trim());
|
|
23203
|
+
}
|
|
23204
|
+
this.sign = body.data;
|
|
23205
|
+
return body.data;
|
|
23206
|
+
}
|
|
23207
|
+
/**
|
|
23208
|
+
* Step 2: open the socket and send the first auth; resolves on `open`. A socket that closes or errors
|
|
23209
|
+
* before it ever opened rejects, so the call always settles.
|
|
23210
|
+
*/
|
|
23211
|
+
async connect() {
|
|
23212
|
+
if (this.ws)
|
|
23213
|
+
return;
|
|
23214
|
+
const sign = this.sign ?? await this.fetchSign();
|
|
23215
|
+
const auth = {
|
|
23216
|
+
region: this.wsRegion,
|
|
23217
|
+
type: "NVR",
|
|
23218
|
+
sn: this.opts.stationSn,
|
|
23219
|
+
token: this.opts.authToken,
|
|
23220
|
+
gtoken: this.opts.gtoken,
|
|
23221
|
+
sign,
|
|
23222
|
+
appName: "eufy_mega",
|
|
23223
|
+
modelType: "WEB"
|
|
23224
|
+
};
|
|
23225
|
+
const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
|
|
23226
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
|
|
23227
|
+
await new Promise((resolve, reject) => {
|
|
23228
|
+
const timer = setTimeout(() => {
|
|
23229
|
+
reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
|
|
23230
|
+
this.close();
|
|
23231
|
+
}, SIGNALING_CONNECT_TIMEOUT_MS);
|
|
23232
|
+
const ws = this.createSocket(this.wsUrl, { protocols, origin: PORTAL_ORIGIN });
|
|
23233
|
+
this.ws = ws;
|
|
23234
|
+
let opened = false;
|
|
23235
|
+
ws.addEventListener("open", () => {
|
|
23236
|
+
opened = true;
|
|
23237
|
+
clearTimeout(timer);
|
|
23238
|
+
this.sendAuth(sign);
|
|
23239
|
+
this.startKeepalive();
|
|
23240
|
+
this.emit("open");
|
|
23241
|
+
resolve();
|
|
23242
|
+
});
|
|
23243
|
+
ws.addEventListener("message", (ev) => {
|
|
23244
|
+
void this.handleWireMessage(ev.data);
|
|
23245
|
+
});
|
|
23246
|
+
ws.addEventListener("close", (ev) => {
|
|
23247
|
+
clearTimeout(timer);
|
|
23248
|
+
this.stopKeepalive();
|
|
23249
|
+
this.ws = void 0;
|
|
23250
|
+
const code = ev.code ?? 1006;
|
|
23251
|
+
const reason = ev.reason ?? "";
|
|
23252
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling closed ${code} ${reason}`);
|
|
23253
|
+
if (!opened)
|
|
23254
|
+
reject(new Error(`RTC signalling closed before open: ${code} ${reason}`.trim()));
|
|
23255
|
+
this.emit("close", code, reason);
|
|
23256
|
+
});
|
|
23257
|
+
ws.addEventListener("error", (ev) => {
|
|
23258
|
+
clearTimeout(timer);
|
|
23259
|
+
const detail = ev ?? {};
|
|
23260
|
+
const cause = detail.error?.message ?? detail.error?.code ?? detail.message ?? "";
|
|
23261
|
+
reject(new Error(`RTC signalling socket error${cause ? ": " + cause : ""}`));
|
|
23262
|
+
});
|
|
23263
|
+
});
|
|
23264
|
+
}
|
|
23265
|
+
/** `action 1`, also what keeps the socket alive when re-sent. */
|
|
23266
|
+
sendAuth(sign) {
|
|
23267
|
+
const s = sign ?? this.sign;
|
|
23268
|
+
if (!s)
|
|
23269
|
+
throw new Error("RTC signalling: no sign to authenticate with");
|
|
23270
|
+
this.sendEnvelope("0", {
|
|
23271
|
+
code: 200,
|
|
23272
|
+
action: 1,
|
|
23273
|
+
data: s,
|
|
23274
|
+
sn: this.opts.stationSn,
|
|
23275
|
+
source: SOURCE,
|
|
23276
|
+
ts: Math.floor(this.now() / 1e3)
|
|
23277
|
+
});
|
|
23278
|
+
}
|
|
23279
|
+
/**
|
|
23280
|
+
* `action 3`, a session message on channel 0, the hub's own session; `scall` opens the negotiation.
|
|
23281
|
+
* `subSn` is empty for the hub's session.
|
|
23282
|
+
*/
|
|
23283
|
+
sendSession(dataType, payload = {}) {
|
|
23284
|
+
const channelId = 0;
|
|
23285
|
+
const ts = Math.floor(this.now() / 1e3);
|
|
23286
|
+
const inner = {
|
|
23287
|
+
code: 200,
|
|
23288
|
+
action: 3,
|
|
23289
|
+
sessionId: this.sign,
|
|
23290
|
+
sn: this.opts.stationSn,
|
|
23291
|
+
subSn: "",
|
|
23292
|
+
channelId,
|
|
23293
|
+
isResponse: 0,
|
|
23294
|
+
dataType,
|
|
23295
|
+
source: SOURCE,
|
|
23296
|
+
ts,
|
|
23297
|
+
data: JSON.stringify({
|
|
23298
|
+
timestamp: ts,
|
|
23299
|
+
account: sessionAccount(channelId, this.opts.adminUserId, ts, this.opts.authToken),
|
|
23300
|
+
...payload
|
|
23301
|
+
})
|
|
23302
|
+
};
|
|
23303
|
+
this.sendEnvelope(`${this.opts.authToken}_${this.makeMsgId()}`, inner);
|
|
23304
|
+
}
|
|
23305
|
+
sendCall() {
|
|
23306
|
+
this.sendSession("scall");
|
|
23307
|
+
}
|
|
23308
|
+
sendAck() {
|
|
23309
|
+
this.sendSession("ack");
|
|
23310
|
+
}
|
|
23311
|
+
/** The SDP answer, as scall JSON text, in an `info`. */
|
|
23312
|
+
sendInfoSdp(scallJson) {
|
|
23313
|
+
this.sendSession("info", { sdp: scallJson });
|
|
23314
|
+
}
|
|
23315
|
+
/** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
|
|
23316
|
+
sendInfoCandidate(candidate) {
|
|
23317
|
+
this.sendSession("info", { candidate });
|
|
23318
|
+
}
|
|
23319
|
+
sendHangup() {
|
|
23320
|
+
this.sendSession("hangup");
|
|
23321
|
+
}
|
|
23322
|
+
close() {
|
|
23323
|
+
this.stopKeepalive();
|
|
23324
|
+
const ws = this.ws;
|
|
23325
|
+
this.ws = void 0;
|
|
23326
|
+
try {
|
|
23327
|
+
ws?.close();
|
|
23328
|
+
} catch {
|
|
23329
|
+
}
|
|
23330
|
+
}
|
|
23331
|
+
startKeepalive() {
|
|
23332
|
+
this.stopKeepalive();
|
|
23333
|
+
this.keepalive = setInterval(() => {
|
|
23334
|
+
if (!this.isOpen)
|
|
23335
|
+
return;
|
|
23336
|
+
try {
|
|
23337
|
+
this.sendAuth();
|
|
23338
|
+
} catch (e) {
|
|
23339
|
+
this.logger.warn(`[rtc] ${this.opts.stationSn} signalling keepalive failed`, e);
|
|
23340
|
+
}
|
|
23341
|
+
}, SIGNALING_KEEPALIVE_MS);
|
|
23342
|
+
this.keepalive.unref?.();
|
|
23343
|
+
}
|
|
23344
|
+
stopKeepalive() {
|
|
23345
|
+
if (this.keepalive)
|
|
23346
|
+
clearInterval(this.keepalive);
|
|
23347
|
+
this.keepalive = void 0;
|
|
23348
|
+
}
|
|
23349
|
+
sendEnvelope(msgid, inner) {
|
|
23350
|
+
if (!this.isOpen || !this.ws)
|
|
23351
|
+
throw new Error("RTC signalling not connected");
|
|
23352
|
+
const envelope = { msgid, data: JSON.stringify(inner) };
|
|
23353
|
+
this.ws.send(JSON.stringify(envelope));
|
|
23354
|
+
}
|
|
23355
|
+
async handleWireMessage(raw) {
|
|
23356
|
+
let text2;
|
|
23357
|
+
if (typeof raw === "string")
|
|
23358
|
+
text2 = raw;
|
|
23359
|
+
else if (Buffer.isBuffer(raw))
|
|
23360
|
+
text2 = raw.toString("utf8");
|
|
23361
|
+
else if (raw instanceof ArrayBuffer)
|
|
23362
|
+
text2 = Buffer.from(raw).toString("utf8");
|
|
23363
|
+
else if (typeof raw?.text === "function")
|
|
23364
|
+
text2 = await raw.text();
|
|
23365
|
+
else
|
|
23366
|
+
return;
|
|
23367
|
+
let envelope;
|
|
23368
|
+
let inner;
|
|
23369
|
+
try {
|
|
23370
|
+
envelope = JSON.parse(text2);
|
|
23371
|
+
if (typeof envelope?.data !== "string")
|
|
23372
|
+
return;
|
|
23373
|
+
inner = JSON.parse(envelope.data);
|
|
23374
|
+
} catch {
|
|
23375
|
+
return;
|
|
23376
|
+
}
|
|
23377
|
+
this.emit("message", inner, envelope);
|
|
23378
|
+
}
|
|
23379
|
+
};
|
|
23380
|
+
|
|
23381
|
+
// dist/transport/rtc/session.js
|
|
23382
|
+
var AUTH_TIMEOUT_MS = 15e3;
|
|
23383
|
+
var RtcSession = class extends EventEmitter9 {
|
|
23384
|
+
opts;
|
|
23385
|
+
signaling;
|
|
23386
|
+
peer;
|
|
23387
|
+
logger;
|
|
23388
|
+
turn;
|
|
23389
|
+
authOk = false;
|
|
23390
|
+
authWaiter;
|
|
23391
|
+
connected = false;
|
|
23392
|
+
closed = false;
|
|
23393
|
+
closeAnnounced = false;
|
|
23394
|
+
sdpHandled = false;
|
|
23395
|
+
chain = Promise.resolve();
|
|
23396
|
+
constructor(opts) {
|
|
23397
|
+
super();
|
|
23398
|
+
this.opts = opts;
|
|
23399
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23400
|
+
this.signaling = (opts.createSignaling ?? ((o) => new RtcSignalingClient(o)))(opts);
|
|
23401
|
+
this.peer = (opts.createPeer ?? ((o) => new RtcPeer(o)))({ logger: this.logger, ...opts.peer });
|
|
23402
|
+
this.signaling.on("message", (inner) => {
|
|
23403
|
+
this.chain = this.chain.then(() => this.onSignaling(inner)).catch((e) => {
|
|
23404
|
+
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
23405
|
+
});
|
|
23406
|
+
});
|
|
23407
|
+
this.signaling.on("close", () => {
|
|
23408
|
+
this.connected = false;
|
|
23409
|
+
this.announceClose();
|
|
23410
|
+
});
|
|
23411
|
+
this.signaling.on("error", (e) => this.emit("error", e));
|
|
23412
|
+
this.peer.on("iceCandidate", (c) => this.signaling.sendInfoCandidate(toWireCandidate(c)));
|
|
23413
|
+
this.peer.on("iceGatheringComplete", () => this.signaling.sendInfoCandidate(""));
|
|
23414
|
+
this.peer.on("commandChannelOpen", () => {
|
|
23415
|
+
if (this.connected)
|
|
23416
|
+
return;
|
|
23417
|
+
this.connected = true;
|
|
23418
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel open`);
|
|
23419
|
+
this.emit("connected");
|
|
23420
|
+
});
|
|
23421
|
+
this.peer.on("commandChannelClosed", () => {
|
|
23422
|
+
if (!this.connected)
|
|
23423
|
+
return;
|
|
23424
|
+
this.connected = false;
|
|
23425
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel closed`);
|
|
23426
|
+
this.announceClose();
|
|
23427
|
+
});
|
|
23428
|
+
this.peer.on("connectionState", (state) => {
|
|
23429
|
+
if ((state === "failed" || state === "closed") && this.connected) {
|
|
23430
|
+
this.connected = false;
|
|
23431
|
+
this.announceClose();
|
|
23432
|
+
}
|
|
23433
|
+
});
|
|
23434
|
+
this.peer.on("error", (e) => this.emit("error", e));
|
|
23435
|
+
this.peer.on("data", (frame, linkType) => this.emit("commandData", frame, linkType));
|
|
23436
|
+
}
|
|
23437
|
+
get isConnected() {
|
|
23438
|
+
return this.connected;
|
|
23439
|
+
}
|
|
23440
|
+
/**
|
|
23441
|
+
* Start the sequence; resolves once `scall` is sent. `connected` fires when the channel opens. A session
|
|
23442
|
+
* closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
|
|
23443
|
+
* placing the call.
|
|
23444
|
+
*/
|
|
23445
|
+
async connect() {
|
|
23446
|
+
await this.signaling.fetchSign();
|
|
23447
|
+
if (this.closed)
|
|
23448
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23449
|
+
await this.signaling.connect();
|
|
23450
|
+
await this.waitForAuth();
|
|
23451
|
+
if (this.closed)
|
|
23452
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23453
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 scall`);
|
|
23454
|
+
this.signaling.sendCall();
|
|
23455
|
+
}
|
|
23456
|
+
/** Send one portal packet; false when the command channel isn't open. */
|
|
23457
|
+
sendCommand(portalPacket) {
|
|
23458
|
+
return this.peer.sendCommand(portalPacket);
|
|
23459
|
+
}
|
|
23460
|
+
/**
|
|
23461
|
+
* Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
|
|
23462
|
+
* `connect()` still waiting for the signalling auth is rejected and its timer stopped.
|
|
23463
|
+
*/
|
|
23464
|
+
close() {
|
|
23465
|
+
if (this.closed)
|
|
23466
|
+
return;
|
|
23467
|
+
this.closed = true;
|
|
23468
|
+
this.connected = false;
|
|
23469
|
+
try {
|
|
23470
|
+
if (this.signaling.isOpen)
|
|
23471
|
+
this.signaling.sendHangup();
|
|
23472
|
+
} catch {
|
|
23473
|
+
}
|
|
23474
|
+
this.signaling.close();
|
|
23475
|
+
this.peer.close();
|
|
23476
|
+
this.settleAuth(new Error("RTC session closed while waiting for signalling auth"));
|
|
23477
|
+
this.announceClose();
|
|
23478
|
+
}
|
|
23479
|
+
announceClose() {
|
|
23480
|
+
if (this.closeAnnounced)
|
|
23481
|
+
return;
|
|
23482
|
+
this.closeAnnounced = true;
|
|
23483
|
+
this.emit("close");
|
|
23484
|
+
}
|
|
23485
|
+
waitForAuth() {
|
|
23486
|
+
if (this.authOk)
|
|
23487
|
+
return Promise.resolve();
|
|
23488
|
+
return new Promise((resolve, reject) => {
|
|
23489
|
+
const timer = setTimeout(() => {
|
|
23490
|
+
this.authWaiter = void 0;
|
|
23491
|
+
reject(new Error("RTC signalling auth timeout"));
|
|
23492
|
+
}, AUTH_TIMEOUT_MS);
|
|
23493
|
+
this.authWaiter = { resolve, reject, timer };
|
|
23494
|
+
});
|
|
23495
|
+
}
|
|
23496
|
+
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
23497
|
+
settleAuth(err) {
|
|
23498
|
+
const waiter = this.authWaiter;
|
|
23499
|
+
if (!waiter)
|
|
23500
|
+
return;
|
|
23501
|
+
this.authWaiter = void 0;
|
|
23502
|
+
clearTimeout(waiter.timer);
|
|
23503
|
+
if (err)
|
|
23504
|
+
waiter.reject(err);
|
|
23505
|
+
else
|
|
23506
|
+
waiter.resolve();
|
|
23507
|
+
}
|
|
23508
|
+
async onSignaling(inner) {
|
|
23509
|
+
if (inner.action === 1 && inner.code === 200) {
|
|
23510
|
+
this.authOk = true;
|
|
23511
|
+
this.settleAuth();
|
|
23512
|
+
}
|
|
23513
|
+
if (!inner.data)
|
|
23514
|
+
return;
|
|
23515
|
+
let payload;
|
|
23516
|
+
try {
|
|
23517
|
+
payload = JSON.parse(inner.data);
|
|
23518
|
+
} catch {
|
|
23519
|
+
return;
|
|
23520
|
+
}
|
|
23521
|
+
switch (inner.dataType) {
|
|
23522
|
+
case "scall":
|
|
23523
|
+
case "call":
|
|
23524
|
+
await this.onCall(payload);
|
|
23525
|
+
return;
|
|
23526
|
+
case "info":
|
|
23527
|
+
await this.onInfo(payload);
|
|
23528
|
+
return;
|
|
23529
|
+
case "hangup":
|
|
23530
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} hub hung up`);
|
|
23531
|
+
return;
|
|
23532
|
+
default:
|
|
23533
|
+
return;
|
|
23534
|
+
}
|
|
23535
|
+
}
|
|
23536
|
+
/** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
|
|
23537
|
+
async onCall(payload) {
|
|
23538
|
+
if (this.closed)
|
|
23539
|
+
return;
|
|
23540
|
+
const status = payload.status;
|
|
23541
|
+
if (status === 100 && payload.turn) {
|
|
23542
|
+
this.turn = payload.turn;
|
|
23543
|
+
await this.peer.init(payload.turn);
|
|
23544
|
+
return;
|
|
23545
|
+
}
|
|
23546
|
+
if (status === 200) {
|
|
23547
|
+
this.signaling.sendAck();
|
|
23548
|
+
return;
|
|
23549
|
+
}
|
|
23550
|
+
if (status === 486 || status === 408) {
|
|
23551
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} scall answered ${status}`));
|
|
23552
|
+
this.close();
|
|
23553
|
+
}
|
|
23554
|
+
}
|
|
23555
|
+
/**
|
|
23556
|
+
* An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
|
|
23557
|
+
* is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
|
|
23558
|
+
* is reported and left unanswered.
|
|
23559
|
+
*/
|
|
23560
|
+
async onInfo(payload) {
|
|
23561
|
+
if (this.closed)
|
|
23562
|
+
return;
|
|
23563
|
+
if (payload.format === "CANDIDATE") {
|
|
23564
|
+
if (payload.value)
|
|
23565
|
+
this.peer.addRemoteCandidate(payload.value);
|
|
23566
|
+
return;
|
|
23567
|
+
}
|
|
23568
|
+
if (payload.candidate !== void 0) {
|
|
23569
|
+
if (payload.candidate)
|
|
23570
|
+
this.peer.addRemoteCandidate(payload.candidate);
|
|
23571
|
+
return;
|
|
23572
|
+
}
|
|
23573
|
+
const sdpText = payload.value ?? payload.sdp;
|
|
23574
|
+
if (!sdpText || payload.format !== "SDP" && !payload.sdp)
|
|
23575
|
+
return;
|
|
23576
|
+
if (this.sdpHandled)
|
|
23577
|
+
return;
|
|
23578
|
+
if (!this.turn) {
|
|
23579
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} offered before granting a relay`));
|
|
23580
|
+
return;
|
|
23581
|
+
}
|
|
23582
|
+
this.sdpHandled = true;
|
|
23583
|
+
let offer;
|
|
23584
|
+
try {
|
|
23585
|
+
offer = scallJsonToSdp(JSON.parse(sdpText));
|
|
23586
|
+
} catch {
|
|
23587
|
+
offer = sdpText;
|
|
23588
|
+
}
|
|
23589
|
+
const answer = await this.peer.handleRemoteOffer(offer);
|
|
23590
|
+
this.signaling.sendInfoSdp(JSON.stringify(sdpToScallJson(answer)));
|
|
23591
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
|
|
23592
|
+
}
|
|
23593
|
+
};
|
|
23594
|
+
|
|
23595
|
+
// dist/transport/rtc/command-router.js
|
|
23596
|
+
var PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
23597
|
+
var PORTAL_CMD_CONTROL_PAYLOAD = 1700;
|
|
23598
|
+
var PORTAL_CMD_NOTIFY_PAYLOAD = 1351;
|
|
23599
|
+
var PORTAL_STATION_CHANNEL = 255;
|
|
23600
|
+
var ACK_TIMEOUT_MS = 8e3;
|
|
23601
|
+
var CONNECT_TIMEOUT_MS2 = 12e3;
|
|
23602
|
+
var IDLE_CLOSE_MS = 6e4;
|
|
23603
|
+
var RtcCommandRouter = class {
|
|
23604
|
+
deps;
|
|
23605
|
+
sessions = /* @__PURE__ */ new Map();
|
|
23606
|
+
constructor(deps) {
|
|
23607
|
+
this.deps = deps;
|
|
23608
|
+
}
|
|
23609
|
+
async dispatchCommand(route, cmd) {
|
|
23610
|
+
if (cmd.kind !== "set-payload" && cmd.kind !== "set-json") {
|
|
23611
|
+
throw new Error(`rtc: ${cmd.kind} is not routable over the T9000 control channel (only set-payload or set-json)`);
|
|
23612
|
+
}
|
|
23613
|
+
const identity = this.deps.identity();
|
|
23614
|
+
if (!identity)
|
|
23615
|
+
throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
|
|
23616
|
+
const { stationSn } = route;
|
|
23617
|
+
const adminUserId = route.adminUserId || identity.userId;
|
|
23618
|
+
const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
|
|
23619
|
+
const st = await this.stationSession(stationSn, adminUserId, identity);
|
|
23620
|
+
const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
|
|
23621
|
+
const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
|
|
23622
|
+
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 };
|
|
23623
|
+
const segment = st.seg.next();
|
|
23624
|
+
const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
|
|
23625
|
+
const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
|
|
23626
|
+
st.queue = run.catch(() => void 0);
|
|
23627
|
+
return run;
|
|
23628
|
+
}
|
|
23629
|
+
/** Tear down every station session (logout / shutdown). */
|
|
23630
|
+
close() {
|
|
23631
|
+
for (const [sn, st] of this.sessions)
|
|
23632
|
+
this.drop(sn, st);
|
|
23633
|
+
}
|
|
23634
|
+
/**
|
|
23635
|
+
* Completes on the command ACK or a correlated control-result notification. The notification must
|
|
23636
|
+
* match the request channel, parameter and segment and contain a structured payload. Receipt does
|
|
23637
|
+
* not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
|
|
23638
|
+
*/
|
|
23639
|
+
sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
|
|
23640
|
+
return new Promise((resolve, reject) => {
|
|
23641
|
+
const onData = (frame, linkType) => {
|
|
23642
|
+
const p = parsePortalPacket(frame, linkType);
|
|
23643
|
+
if (!p || p.segment !== segment)
|
|
23644
|
+
return;
|
|
23645
|
+
const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
|
|
23646
|
+
const d = p.data;
|
|
23647
|
+
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;
|
|
23648
|
+
if (!acknowledgement && !notification)
|
|
23649
|
+
return;
|
|
23650
|
+
cleanup();
|
|
23651
|
+
if (acknowledgement && p.errCode !== 0) {
|
|
23652
|
+
reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
|
|
23653
|
+
} else {
|
|
23654
|
+
this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
|
|
23655
|
+
resolve();
|
|
23656
|
+
}
|
|
23657
|
+
};
|
|
23658
|
+
const onClose = () => {
|
|
23659
|
+
cleanup();
|
|
23660
|
+
reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
|
|
23661
|
+
};
|
|
23662
|
+
const timer = setTimeout(() => {
|
|
23663
|
+
cleanup();
|
|
23664
|
+
reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
|
|
23665
|
+
}, ACK_TIMEOUT_MS);
|
|
23666
|
+
const cleanup = () => {
|
|
23667
|
+
clearTimeout(timer);
|
|
23668
|
+
st.session.off("commandData", onData);
|
|
23669
|
+
st.session.off("close", onClose);
|
|
23670
|
+
this.touch(sn, st);
|
|
23671
|
+
};
|
|
23672
|
+
st.session.on("commandData", onData);
|
|
23673
|
+
st.session.on("close", onClose);
|
|
23674
|
+
if (!st.session.sendCommand(packet)) {
|
|
23675
|
+
cleanup();
|
|
23676
|
+
reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
|
|
23677
|
+
}
|
|
23678
|
+
});
|
|
23679
|
+
}
|
|
23680
|
+
async stationSession(sn, adminUserId, identity) {
|
|
23681
|
+
const existing = this.sessions.get(sn);
|
|
23682
|
+
if (existing) {
|
|
23683
|
+
await existing.ready;
|
|
23684
|
+
if (existing.session.isConnected) {
|
|
23685
|
+
this.touch(sn, existing);
|
|
23686
|
+
return existing;
|
|
23687
|
+
}
|
|
23688
|
+
this.drop(sn, existing);
|
|
23689
|
+
}
|
|
23690
|
+
const session = (this.deps.createSession ?? ((o) => new RtcSession(o)))({
|
|
23691
|
+
authToken: identity.authToken,
|
|
23692
|
+
gtoken: identity.gtoken,
|
|
23693
|
+
stationSn: sn,
|
|
23694
|
+
adminUserId,
|
|
23695
|
+
shard: this.deps.shard(),
|
|
23696
|
+
country: this.deps.country ?? "US",
|
|
23697
|
+
logger: this.deps.logger,
|
|
23698
|
+
peer: { logger: this.deps.logger }
|
|
23699
|
+
});
|
|
23700
|
+
const ready = this.bringUp(sn, session);
|
|
23701
|
+
const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve() };
|
|
23702
|
+
session.on("error", (e) => this.deps.onError?.(e));
|
|
23703
|
+
session.on("close", () => {
|
|
23704
|
+
if (this.sessions.get(sn) === st)
|
|
23705
|
+
this.sessions.delete(sn);
|
|
23706
|
+
});
|
|
23707
|
+
this.sessions.set(sn, st);
|
|
23708
|
+
try {
|
|
23709
|
+
await ready;
|
|
23710
|
+
} catch (e) {
|
|
23711
|
+
this.drop(sn, st);
|
|
23712
|
+
throw e;
|
|
23713
|
+
}
|
|
23714
|
+
this.touch(sn, st);
|
|
23715
|
+
return st;
|
|
23716
|
+
}
|
|
23717
|
+
/**
|
|
23718
|
+
* One bounded bring-up: the session is up when its command channel opens, and it fails, with the
|
|
23719
|
+
* session closed and every listener gone, when `connect()` throws, the session closes first, or
|
|
23720
|
+
* {@link CONNECT_TIMEOUT_MS} passes. A single promise, so an early failure cannot leave a second one
|
|
23721
|
+
* rejecting unheard.
|
|
23722
|
+
*/
|
|
23723
|
+
bringUp(sn, session) {
|
|
23724
|
+
return new Promise((resolve, reject) => {
|
|
23725
|
+
let settled = false;
|
|
23726
|
+
const finish = (err) => {
|
|
23727
|
+
if (settled)
|
|
23728
|
+
return;
|
|
23729
|
+
settled = true;
|
|
23730
|
+
clearTimeout(timer);
|
|
23731
|
+
session.off("connected", onConnected);
|
|
23732
|
+
session.off("close", onClose);
|
|
23733
|
+
if (err) {
|
|
23734
|
+
try {
|
|
23735
|
+
session.close();
|
|
23736
|
+
} catch {
|
|
23737
|
+
}
|
|
23738
|
+
reject(err);
|
|
23739
|
+
} else {
|
|
23740
|
+
this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
|
|
23741
|
+
resolve();
|
|
23742
|
+
}
|
|
23743
|
+
};
|
|
23744
|
+
const onConnected = () => finish();
|
|
23745
|
+
const onClose = () => finish(new Error(`rtc: ${sn} session closed before the command channel opened`));
|
|
23746
|
+
const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms`)), CONNECT_TIMEOUT_MS2);
|
|
23747
|
+
session.once("connected", onConnected);
|
|
23748
|
+
session.once("close", onClose);
|
|
23749
|
+
Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
|
|
23750
|
+
});
|
|
23751
|
+
}
|
|
23752
|
+
touch(sn, st) {
|
|
23753
|
+
if (st.idle)
|
|
23754
|
+
clearTimeout(st.idle);
|
|
23755
|
+
st.idle = setTimeout(() => this.drop(sn, st), IDLE_CLOSE_MS);
|
|
23756
|
+
st.idle.unref?.();
|
|
23757
|
+
}
|
|
23758
|
+
drop(sn, st) {
|
|
23759
|
+
if (st.idle)
|
|
23760
|
+
clearTimeout(st.idle);
|
|
23761
|
+
if (this.sessions.get(sn) === st)
|
|
23762
|
+
this.sessions.delete(sn);
|
|
23763
|
+
try {
|
|
23764
|
+
st.session.close();
|
|
23765
|
+
} catch {
|
|
23766
|
+
}
|
|
23767
|
+
}
|
|
23768
|
+
};
|
|
23769
|
+
|
|
22360
23770
|
// dist/transport/mqtt/command-router.js
|
|
22361
23771
|
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
22362
23772
|
|
|
@@ -22660,10 +24070,10 @@ function dpColorFields(spec) {
|
|
|
22660
24070
|
}
|
|
22661
24071
|
|
|
22662
24072
|
// dist/transport/mqtt/clean-codec.js
|
|
22663
|
-
import { randomUUID as
|
|
24073
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
22664
24074
|
function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
|
|
22665
24075
|
const payload = JSON.stringify({
|
|
22666
|
-
t: opts?.uuid ??
|
|
24076
|
+
t: opts?.uuid ?? randomUUID3(),
|
|
22667
24077
|
protocol: 2,
|
|
22668
24078
|
account_id: accountId,
|
|
22669
24079
|
device_sn: deviceSn,
|
|
@@ -23350,7 +24760,7 @@ function deriveTuyaAccount(eufyUserId, phoneCode) {
|
|
|
23350
24760
|
}
|
|
23351
24761
|
|
|
23352
24762
|
// dist/transport/tuya/sign.js
|
|
23353
|
-
import { createHash as createHash5, createHmac as
|
|
24763
|
+
import { createHash as createHash5, createHmac as createHmac4 } from "node:crypto";
|
|
23354
24764
|
var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
23355
24765
|
"a",
|
|
23356
24766
|
"v",
|
|
@@ -23393,7 +24803,7 @@ var HmacSigner = class {
|
|
|
23393
24803
|
this.key = key ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
|
|
23394
24804
|
}
|
|
23395
24805
|
sign(preimage) {
|
|
23396
|
-
return
|
|
24806
|
+
return createHmac4("sha256", this.key).update(preimage, "utf-8").digest("hex");
|
|
23397
24807
|
}
|
|
23398
24808
|
};
|
|
23399
24809
|
var TUYA_CHKEY = "7cbfe6d8";
|
|
@@ -23402,7 +24812,7 @@ var TUYA_CHKEY = "7cbfe6d8";
|
|
|
23402
24812
|
import { randomBytes as randomBytes4, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
|
|
23403
24813
|
|
|
23404
24814
|
// dist/transport/tuya/request.js
|
|
23405
|
-
import { randomUUID as
|
|
24815
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
23406
24816
|
var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
|
|
23407
24817
|
var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
|
|
23408
24818
|
var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
|
|
@@ -23434,7 +24844,7 @@ function buildApiParams(action, opts) {
|
|
|
23434
24844
|
a: action.a,
|
|
23435
24845
|
v: action.v,
|
|
23436
24846
|
time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
|
|
23437
|
-
requestId: opts.requestId ??
|
|
24847
|
+
requestId: opts.requestId ?? randomUUID4(),
|
|
23438
24848
|
sid: opts.session.sid,
|
|
23439
24849
|
deviceId: opts.session.deviceId,
|
|
23440
24850
|
chKey: opts.session.chKey,
|
|
@@ -23971,7 +25381,7 @@ async function resolveLightEffect(mega, lightId) {
|
|
|
23971
25381
|
}
|
|
23972
25382
|
|
|
23973
25383
|
// dist/transport/push/push-client.js
|
|
23974
|
-
import { EventEmitter as
|
|
25384
|
+
import { EventEmitter as EventEmitter11 } from "node:events";
|
|
23975
25385
|
import tls2 from "node:tls";
|
|
23976
25386
|
|
|
23977
25387
|
// dist/transport/push/proto.js
|
|
@@ -24226,7 +25636,7 @@ var MessageTag;
|
|
|
24226
25636
|
})(MessageTag || (MessageTag = {}));
|
|
24227
25637
|
|
|
24228
25638
|
// dist/transport/push/parser.js
|
|
24229
|
-
import { EventEmitter as
|
|
25639
|
+
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
24230
25640
|
var State;
|
|
24231
25641
|
(function(State2) {
|
|
24232
25642
|
State2[State2["VERSION_TAG_AND_SIZE"] = 0] = "VERSION_TAG_AND_SIZE";
|
|
@@ -24244,7 +25654,7 @@ var TAG_TO_TYPE = {
|
|
|
24244
25654
|
[MessageTag.DataMessageStanza]: "mcs_proto.DataMessageStanza",
|
|
24245
25655
|
[MessageTag.StreamErrorStanza]: "mcs_proto.StreamErrorStanza"
|
|
24246
25656
|
};
|
|
24247
|
-
var McsParser = class extends
|
|
25657
|
+
var McsParser = class extends EventEmitter10 {
|
|
24248
25658
|
data = Buffer.alloc(0);
|
|
24249
25659
|
state = State.VERSION_TAG_AND_SIZE;
|
|
24250
25660
|
messageTag = 0;
|
|
@@ -24422,7 +25832,7 @@ function normalizePushEvent(raw) {
|
|
|
24422
25832
|
raw
|
|
24423
25833
|
};
|
|
24424
25834
|
}
|
|
24425
|
-
var PushClient = class _PushClient extends
|
|
25835
|
+
var PushClient = class _PushClient extends EventEmitter11 {
|
|
24426
25836
|
creds;
|
|
24427
25837
|
logger;
|
|
24428
25838
|
/** Consecutive MCS login rejections tolerated (self-healing propagation) before surfacing an error. */
|
|
@@ -25012,7 +26422,14 @@ function deviceClassOf(codec, realtime) {
|
|
|
25012
26422
|
const cls = CLASS_BY_CODEC[codec];
|
|
25013
26423
|
return cls === "camera" && realtime !== "p2p" ? "other" : cls;
|
|
25014
26424
|
}
|
|
26425
|
+
function recordName(raw, deviceClass) {
|
|
26426
|
+
if (deviceClass === "vacuum") {
|
|
26427
|
+
return raw.alias_name || raw.device_alias_name || raw.device_name || void 0;
|
|
26428
|
+
}
|
|
26429
|
+
return raw.device_name ?? raw.device_alias_name ?? raw.alias_name;
|
|
26430
|
+
}
|
|
25015
26431
|
var LIST_REUSE_MS = 5e3;
|
|
26432
|
+
var CLOUD_DPS_MODELS = /* @__PURE__ */ new Set(["T2351"]);
|
|
25016
26433
|
var DeviceRegistry = class {
|
|
25017
26434
|
mega;
|
|
25018
26435
|
onError;
|
|
@@ -25037,6 +26454,8 @@ var DeviceRegistry = class {
|
|
|
25037
26454
|
overlayRefusalReported = false;
|
|
25038
26455
|
/** Per-serial capability cache for {@link capabilitiesForDevice}; `null` = negative hit. */
|
|
25039
26456
|
deviceCapsCache = /* @__PURE__ */ new Map();
|
|
26457
|
+
/** Per-serial cloud data points of an AIoT robot, joined beneath the params in {@link record} — see {@link loadCloudDps}. */
|
|
26458
|
+
cloudDps = /* @__PURE__ */ new Map();
|
|
25040
26459
|
/** Per-serial realtime state, keyed by param id in the device's own namespace (see {@link DeviceRecord.dpParams}). */
|
|
25041
26460
|
dpParams = /* @__PURE__ */ new Map();
|
|
25042
26461
|
/** Callers blocked in {@link awaitRealtimeState}, released by the device's first report. */
|
|
@@ -25131,9 +26550,10 @@ var DeviceRegistry = class {
|
|
|
25131
26550
|
category: raw.category,
|
|
25132
26551
|
params
|
|
25133
26552
|
}).codec;
|
|
26553
|
+
const deviceClass = deviceClassOf(codec, c.realtime);
|
|
25134
26554
|
seen.set(raw.device_sn, {
|
|
25135
26555
|
sn: raw.device_sn,
|
|
25136
|
-
name: raw
|
|
26556
|
+
name: recordName(raw, deviceClass),
|
|
25137
26557
|
model: raw.device_model,
|
|
25138
26558
|
stationSn: resolvedStationSn(raw, raw.device_sn),
|
|
25139
26559
|
p2pDid: raw.p2p_did,
|
|
@@ -25141,11 +26561,12 @@ var DeviceRegistry = class {
|
|
|
25141
26561
|
paramUpdatedAt,
|
|
25142
26562
|
lastSeenMs: lastSeenMsOf(paramUpdatedAt),
|
|
25143
26563
|
raw,
|
|
25144
|
-
deviceClass
|
|
26564
|
+
deviceClass,
|
|
25145
26565
|
...c
|
|
25146
26566
|
});
|
|
25147
26567
|
}
|
|
25148
26568
|
}
|
|
26569
|
+
await this.loadCloudDps(seen);
|
|
25149
26570
|
this.lastRefreshPartial = partial;
|
|
25150
26571
|
if (partial) {
|
|
25151
26572
|
for (const prev of this.devices)
|
|
@@ -25236,9 +26657,46 @@ var DeviceRegistry = class {
|
|
|
25236
26657
|
}
|
|
25237
26658
|
return dev;
|
|
25238
26659
|
}
|
|
26660
|
+
/**
|
|
26661
|
+
* Hold an AIoT robot's cloud data points: the cloud's last-known value of each one it has reported,
|
|
26662
|
+
* from `getDeviceRelationList`'s per-device `dps`.
|
|
26663
|
+
*
|
|
26664
|
+
* The device list carries only some of a robot's data points, so a point the robot publishes on change
|
|
26665
|
+
* alone (its scene list, its schedules, its consumables) has no value until it next reports. The
|
|
26666
|
+
* relation list holds every one, for an account the robot is shared with as well as its owner.
|
|
26667
|
+
*
|
|
26668
|
+
* Kept apart from the device list's params rather than merged into them: those are what a poll diffs,
|
|
26669
|
+
* and a diff retires the realtime report for that id, so a cloud value lagging the robot's last report
|
|
26670
|
+
* would replace it. {@link record} joins this beneath both halves. A failure keeps what was held.
|
|
26671
|
+
*
|
|
26672
|
+
* Limited to {@link CLOUD_DPS_MODELS}, the models whose relation-list `dps` are checked to carry the
|
|
26673
|
+
* DP numbers their realtime reports use.
|
|
26674
|
+
*/
|
|
26675
|
+
async loadCloudDps(seen) {
|
|
26676
|
+
const robots = [...seen.values()].filter((d) => CLOUD_DPS_MODELS.has(d.model ?? ""));
|
|
26677
|
+
if (!robots.length)
|
|
26678
|
+
return;
|
|
26679
|
+
let res;
|
|
26680
|
+
try {
|
|
26681
|
+
res = await this.mega.getDeviceRelationList();
|
|
26682
|
+
} catch (e) {
|
|
26683
|
+
if (e instanceof SessionExpiredError)
|
|
26684
|
+
throw e;
|
|
26685
|
+
this.logger.debug(`[registry] device relation list unavailable: ${e?.message ?? e}`);
|
|
26686
|
+
return;
|
|
26687
|
+
}
|
|
26688
|
+
const wanted = new Set(robots.map((d) => d.sn));
|
|
26689
|
+
for (const entry of res.devices ?? []) {
|
|
26690
|
+
const sn = entry?.device?.device_sn;
|
|
26691
|
+
const dps = parseAiotDpReport({ payload: entry?.device?.dps });
|
|
26692
|
+
if (sn && wanted.has(sn) && dps)
|
|
26693
|
+
this.cloudDps.set(sn, dps);
|
|
26694
|
+
}
|
|
26695
|
+
}
|
|
25239
26696
|
/**
|
|
25240
26697
|
* Resolve a serial to a {@link DeviceRecord} with current params: starts from the device-list params, then
|
|
25241
|
-
* overlays a fresh `get_device_param_list` when that call is available to this account.
|
|
26698
|
+
* overlays a fresh `get_device_param_list` when that call is available to this account. An AIoT robot's
|
|
26699
|
+
* cloud data points ({@link loadCloudDps}) sit beneath both, filling only ids neither carries.
|
|
25242
26700
|
*
|
|
25243
26701
|
* The overlay is **owner-gated** — a shared or member account is refused it for every device, permanently,
|
|
25244
26702
|
* which {@link OWNER_ONLY_CODE} identifies — so when it is unavailable the device list is the source
|
|
@@ -25267,7 +26725,7 @@ var DeviceRegistry = class {
|
|
|
25267
26725
|
let dev = this.devices.find((d) => d.sn === sn);
|
|
25268
26726
|
if (!dev)
|
|
25269
26727
|
throw new Error(`device ${sn} not found (have: ${this.devices.map((d) => d.sn).join(", ")})`);
|
|
25270
|
-
const params = { ...dev.params ?? {} };
|
|
26728
|
+
const params = { ...this.cloudDps.get(sn), ...dev.params ?? {} };
|
|
25271
26729
|
const paramUpdatedAt = { ...dev.paramUpdatedAt ?? {} };
|
|
25272
26730
|
if (!this.overlayRefused.has(sn)) {
|
|
25273
26731
|
try {
|
|
@@ -25575,7 +27033,7 @@ function readinessSnapshot(readiness, state = readiness.state) {
|
|
|
25575
27033
|
wiredP2p: Object.freeze({ ...readiness.wiredP2p })
|
|
25576
27034
|
});
|
|
25577
27035
|
}
|
|
25578
|
-
var EufyMega = class extends
|
|
27036
|
+
var EufyMega = class extends EventEmitter12 {
|
|
25579
27037
|
opts;
|
|
25580
27038
|
mega;
|
|
25581
27039
|
/**
|
|
@@ -25635,6 +27093,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25635
27093
|
prewarmTiers;
|
|
25636
27094
|
/** Transport-side owner of the P2P sessions + all wire senders. */
|
|
25637
27095
|
p2p;
|
|
27096
|
+
/** Transport-side owner of the T9000 station sessions (sibling of {@link p2p}). */
|
|
27097
|
+
rtc;
|
|
25638
27098
|
/** Transport-side owner of the secure-MQTT ff09 lock/garage command path (sibling of {@link p2p}). */
|
|
25639
27099
|
mqtt;
|
|
25640
27100
|
/** Transport-side owner of the legacy Tuya REST command path for non-AIoT vacuums (G-series). */
|
|
@@ -25695,6 +27155,13 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25695
27155
|
onLevel2Ready: (sn, cipherId) => this.emit("p2pLevel2Ready", { stationSn: sn, cipherId }),
|
|
25696
27156
|
onFrame: (stationSn, f) => this.onP2PFrame(stationSn, f)
|
|
25697
27157
|
});
|
|
27158
|
+
this.rtc = new RtcCommandRouter({
|
|
27159
|
+
identity: () => this.mega.rtcIdentity(),
|
|
27160
|
+
shard: () => this.mega.rtcShard,
|
|
27161
|
+
country: opts.countryCode,
|
|
27162
|
+
logger: opts.logger,
|
|
27163
|
+
onError: (e) => this.reportError(e)
|
|
27164
|
+
});
|
|
25698
27165
|
this.mqtt = new MqttCommandRouter({
|
|
25699
27166
|
mega: this.mega,
|
|
25700
27167
|
logger: opts.logger,
|
|
@@ -25960,13 +27427,22 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25960
27427
|
*
|
|
25961
27428
|
* The evidence set is widened, never replaced: the ids come back through the cloud record, and a
|
|
25962
27429
|
* record that omits a realtime-only id would otherwise un-know it and re-trigger on the next report.
|
|
27430
|
+
*
|
|
27431
|
+
* The device is re-resolved against the fresh record before binding. A property gated on a
|
|
27432
|
+
* realtime-only param enters the schema only once that param is evidence, and the value that made it
|
|
27433
|
+
* evidence was stored before the property existed, so the record's realtime params are applied again
|
|
27434
|
+
* to land it under the property's name.
|
|
25963
27435
|
*/
|
|
25964
27436
|
async rebindReads(sn) {
|
|
25965
27437
|
const dev = this.liveDevices.get(sn)?.deref();
|
|
25966
27438
|
if (!dev)
|
|
25967
27439
|
return;
|
|
25968
27440
|
try {
|
|
25969
|
-
const
|
|
27441
|
+
const rec = await this.registry.record(sn);
|
|
27442
|
+
dev.reresolve(rec);
|
|
27443
|
+
if (rec.dpParams)
|
|
27444
|
+
this.applyAndAnnounce(dev, rec.dpParams);
|
|
27445
|
+
const ctx = await this.commandContext(sn, rec);
|
|
25970
27446
|
dev.bindActions(ctx, this.commandSinkFor(sn), this.mediaProviderFor(sn), this.ff09SettingsReaderFor(sn, ctx), rawDpCodec);
|
|
25971
27447
|
this.boundParamIds.set(sn, /* @__PURE__ */ new Set([...this.boundParamIds.get(sn) ?? [], ...ctx.paramIds]));
|
|
25972
27448
|
this.emit("deviceState", this.deviceState(sn));
|
|
@@ -26026,6 +27502,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26026
27502
|
* only "differs from what was read before", which state already on hand can satisfy spuriously — and the
|
|
26027
27503
|
* caller that has no expectation is the push path, where the signal itself is the news that a re-read is owed.
|
|
26028
27504
|
*
|
|
27505
|
+
* Only a write's observation throws at the deadline; a push with nothing moved answers `false`.
|
|
27506
|
+
*
|
|
26029
27507
|
* The cloud half is asked for through {@link DeviceRegistry.refreshedList}, never by fetching the account
|
|
26030
27508
|
* list outright. The fetch is account-wide — one house list plus one device list per house — so a param that
|
|
26031
27509
|
* never converges would otherwise spend a whole burst of those every iteration of this loop, and concurrent
|
|
@@ -26076,6 +27554,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26076
27554
|
if (refresh.expected !== void 0 && settled())
|
|
26077
27555
|
return true;
|
|
26078
27556
|
}
|
|
27557
|
+
if (refresh.expected === void 0)
|
|
27558
|
+
return false;
|
|
26079
27559
|
throw new StateConvergenceError({
|
|
26080
27560
|
sn,
|
|
26081
27561
|
property: refresh.property,
|
|
@@ -26428,8 +27908,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26428
27908
|
* The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
|
|
26429
27909
|
* Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
|
|
26430
27910
|
* (`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
|
-
*
|
|
27911
|
+
* kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000
|
|
27912
|
+
* station and the devices attached to it go over RTC, with an attached device's command refused
|
|
27913
|
+
* unless its channel resolves back to it on the station. Everything else is P2P.
|
|
26433
27914
|
*/
|
|
26434
27915
|
routeCommand(sn, cmd) {
|
|
26435
27916
|
if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
|
|
@@ -26449,6 +27930,20 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26449
27930
|
return this.tuya.dispatchCommand(sn, cmd);
|
|
26450
27931
|
return this.mqtt.dispatchCommand(sn, cmd);
|
|
26451
27932
|
}
|
|
27933
|
+
const devices = this.registry.list();
|
|
27934
|
+
const target = devices.find((d) => d.sn === sn);
|
|
27935
|
+
const stationSn = target?.stationSn || sn;
|
|
27936
|
+
const station = devices.find((d) => d.sn === stationSn);
|
|
27937
|
+
const stationRaw = station?.raw ?? {};
|
|
27938
|
+
const deviceType = typeof stationRaw.device_type === "number" ? stationRaw.device_type : void 0;
|
|
27939
|
+
if (target && station && isStation9000({ deviceType, model: station.model })) {
|
|
27940
|
+
const attached = stationSn !== sn;
|
|
27941
|
+
if (attached && this.registry.serialForFrame(stationSn, cmd.channel) !== sn)
|
|
27942
|
+
return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
|
|
27943
|
+
const member = stationRaw.member?.admin_user_id;
|
|
27944
|
+
const adminUserId = typeof member === "string" && member ? member : void 0;
|
|
27945
|
+
return this.rtc.dispatchCommand({ stationSn, adminUserId, attached }, cmd);
|
|
27946
|
+
}
|
|
26452
27947
|
return this.p2p.dispatchCommand(sn, cmd);
|
|
26453
27948
|
}
|
|
26454
27949
|
/**
|
|
@@ -27287,16 +28782,20 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27287
28782
|
await this.commandSinkFor(sn).dispatch(cmd);
|
|
27288
28783
|
}
|
|
27289
28784
|
/**
|
|
27290
|
-
* Restart a HomeBase.
|
|
28785
|
+
* Restart a station: a HomeBase, or a camera-family device that is its own station.
|
|
27291
28786
|
*
|
|
27292
|
-
*
|
|
27293
|
-
*
|
|
27294
|
-
*
|
|
28787
|
+
* The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
|
|
28788
|
+
* P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
|
|
28789
|
+
* A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
|
|
28790
|
+
* throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
|
|
28791
|
+
* and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
|
|
28792
|
+
* Verified on real hardware against a HomeBase and a standalone indoor camera.
|
|
27295
28793
|
*/
|
|
27296
28794
|
async reboot(sn) {
|
|
27297
28795
|
const ctx = await this.commandContext(sn);
|
|
27298
|
-
|
|
27299
|
-
|
|
28796
|
+
const standaloneCamera = ctx.codec === "camera" && this.p2p.stationKeyOf(sn) === sn;
|
|
28797
|
+
if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model }) && !standaloneCamera) {
|
|
28798
|
+
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
28799
|
}
|
|
27301
28800
|
await this.p2p.rebootStation(sn);
|
|
27302
28801
|
}
|
|
@@ -27328,8 +28827,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27328
28827
|
return EMPTY_DP_CATALOG;
|
|
27329
28828
|
}
|
|
27330
28829
|
}
|
|
27331
|
-
|
|
27332
|
-
|
|
28830
|
+
/** The {@link CommandContext} for a device, built from `known` when that record is already in hand. */
|
|
28831
|
+
async commandContext(sn, known) {
|
|
28832
|
+
const rec = known ?? await this.registry.record(sn);
|
|
27333
28833
|
const dev = this.registry.require(sn);
|
|
27334
28834
|
const raw = dev.raw ?? {};
|
|
27335
28835
|
const rawChannel = raw.device_channel;
|
|
@@ -27542,6 +29042,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27542
29042
|
this.pollTimer.cancel();
|
|
27543
29043
|
this.lastStateAnnounced.clear();
|
|
27544
29044
|
await this.closeMqttTransports();
|
|
29045
|
+
this.rtc.close();
|
|
27545
29046
|
await this.p2p.closeAll();
|
|
27546
29047
|
this.pushClient?.close();
|
|
27547
29048
|
this.pushClient = void 0;
|
|
@@ -28046,7 +29547,7 @@ var SolixClient = class _SolixClient {
|
|
|
28046
29547
|
};
|
|
28047
29548
|
|
|
28048
29549
|
// dist/transport/mqtt/solix-mqtt.js
|
|
28049
|
-
import { EventEmitter as
|
|
29550
|
+
import { EventEmitter as EventEmitter13 } from "node:events";
|
|
28050
29551
|
var SOLIX_METER_FIELD_NAMES = {
|
|
28051
29552
|
168: "meterPowerL1",
|
|
28052
29553
|
// Anker Modbus: primary_phase_1_active_power
|
|
@@ -28224,7 +29725,7 @@ function addSolarbankScalars(frame, out) {
|
|
|
28224
29725
|
out.gridExportLimit = df.readUInt16LE(5);
|
|
28225
29726
|
}
|
|
28226
29727
|
}
|
|
28227
|
-
var SolixMqtt = class extends
|
|
29728
|
+
var SolixMqtt = class extends EventEmitter13 {
|
|
28228
29729
|
transport;
|
|
28229
29730
|
appName;
|
|
28230
29731
|
userId;
|