@mega-yfue/eufy-sdk 0.5.0-beta.3 → 0.5.0-beta.31
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 +50 -6
- package/dist/client/types.d.ts +1 -1
- package/dist/core/contracts.d.ts +52 -26
- package/dist/core/store.d.ts +2 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/index.js +2271 -259
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/arming.d.ts +31 -4
- package/dist/model/capabilities/camera.d.ts +24 -10
- package/dist/model/capabilities/dp-catalog.d.ts +4 -3
- package/dist/model/capabilities/light.d.ts +3 -0
- package/dist/model/capabilities/motion.d.ts +5 -0
- package/dist/model/capabilities/suction.d.ts +5 -5
- package/dist/model/capabilities/types.d.ts +6 -0
- package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
- package/dist/model/classify.d.ts +1 -2
- package/dist/model/device-family.d.ts +4 -0
- package/dist/model/registry.d.ts +3 -2
- package/dist/model/types.d.ts +6 -0
- package/dist/transport/abortable.d.ts +8 -0
- package/dist/transport/http/mega-client.d.ts +26 -0
- package/dist/transport/p2p/command-router.d.ts +14 -26
- 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 +22 -3
- package/dist/transport/p2p/recording-download.d.ts +4 -3
- package/dist/transport/p2p/shared-live-source.d.ts +9 -0
- package/dist/transport/p2p/talkback.d.ts +21 -10
- package/dist/transport/p2p/video.d.ts +5 -2
- package/dist/transport/rtc/command-router.d.ts +109 -0
- package/dist/transport/rtc/live.d.ts +69 -0
- package/dist/transport/rtc/peer.d.ts +101 -0
- package/dist/transport/rtc/portal-packet.d.ts +72 -0
- package/dist/transport/rtc/ptcs-framer.d.ts +86 -0
- package/dist/transport/rtc/scall-sdp.d.ts +42 -0
- package/dist/transport/rtc/session.d.ts +88 -0
- package/dist/transport/rtc/signaling.d.ts +139 -0
- package/package.json +2 -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 EventEmitter13 } 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,
|
|
@@ -3034,8 +3068,7 @@ var STATION_TYPES = /* @__PURE__ */ new Set([
|
|
|
3034
3068
|
DeviceType.STATION_9000,
|
|
3035
3069
|
DeviceType.MINIBASE_CHIME,
|
|
3036
3070
|
DeviceType.HOMEBASE_MINI,
|
|
3037
|
-
DeviceType.NVR_S4_MAX
|
|
3038
|
-
DeviceType.CAMERA_POE_S4
|
|
3071
|
+
DeviceType.NVR_S4_MAX
|
|
3039
3072
|
]);
|
|
3040
3073
|
var LOCK_TYPES = /* @__PURE__ */ new Set([
|
|
3041
3074
|
DeviceType.LOCK_BLE,
|
|
@@ -3109,7 +3142,7 @@ function codecFromModel(model) {
|
|
|
3109
3142
|
return "vacuum";
|
|
3110
3143
|
if (/^T8(00[0-9]|010|023|025|030)/.test(m))
|
|
3111
3144
|
return "station";
|
|
3112
|
-
if (/^T8N0/.test(m)
|
|
3145
|
+
if (/^T8N0/.test(m))
|
|
3113
3146
|
return "station";
|
|
3114
3147
|
if (/^T9000/.test(m))
|
|
3115
3148
|
return "station";
|
|
@@ -4219,6 +4252,10 @@ var MOTION_MEMBERS = {
|
|
|
4219
4252
|
* The raw sensitivity an indoor camera reports, on the one id whose write takes a `1700` control
|
|
4220
4253
|
* payload carrying an index rather than the `1350` envelope its siblings use — a frame-shape
|
|
4221
4254
|
* difference `sensitivityCommand` resolves from the scale. `unexposed` like its three siblings.
|
|
4255
|
+
*
|
|
4256
|
+
* Some devices report 6041 as a base64 JSON list of per-sensor entries
|
|
4257
|
+
* (`{"list":[{"sensor_id":…,"sensitivity":…}]}`) rather than a scalar. That value is stored verbatim
|
|
4258
|
+
* and this getter answers `undefined` for it: only the scalar form is confirmed to be this step.
|
|
4222
4259
|
*/
|
|
4223
4260
|
indoorSensitivity: {
|
|
4224
4261
|
param: MOTION_CMD.INDOOR_SENSITIVITY_INDEX,
|
|
@@ -4226,7 +4263,8 @@ var MOTION_MEMBERS = {
|
|
|
4226
4263
|
kind: "scalar",
|
|
4227
4264
|
provenance: "verified",
|
|
4228
4265
|
unexposed: true,
|
|
4229
|
-
description: "Raw sensitivity an indoor camera reports (verified: param 6041)."
|
|
4266
|
+
description: "Raw sensitivity an indoor camera reports (verified: param 6041).",
|
|
4267
|
+
decode: (raw) => typeof raw === "number" ? raw : void 0
|
|
4230
4268
|
},
|
|
4231
4269
|
/**
|
|
4232
4270
|
* The raw sensitivity on the INVERTED seven-step camera scale — a HIGHER number is LESS sensitive, so
|
|
@@ -5060,6 +5098,8 @@ var isIndoorCamera = (ctx) => has(INDOOR_CAMERA_TYPES, ctx.deviceType);
|
|
|
5060
5098
|
var isIndoorCamMini = (ctx) => ctx.deviceType === DeviceType.INDOOR_COST_DOWN_CAMERA;
|
|
5061
5099
|
var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
|
|
5062
5100
|
var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
|
|
5101
|
+
var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
|
|
5102
|
+
var isNvrT8N00 = (ctx) => ctx.model === "T8N00";
|
|
5063
5103
|
var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
|
|
5064
5104
|
var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
|
|
5065
5105
|
|
|
@@ -5205,6 +5245,8 @@ var AUDIO = {
|
|
|
5205
5245
|
|
|
5206
5246
|
// dist/model/capabilities/camera.js
|
|
5207
5247
|
var CAMERA_CMD = {
|
|
5248
|
+
/** Camera-reported Wi-Fi signal (app `CMD_GET_WIFI_RSSI`). */
|
|
5249
|
+
WIFI_RSSI: 1142,
|
|
5208
5250
|
/** Camera on/off. Read and write polarity depend on the family and topology. */
|
|
5209
5251
|
CAMERA_ENABLE: 1035,
|
|
5210
5252
|
/** Wrapped T8410 power switch on HomeBase 3. */
|
|
@@ -5484,6 +5526,14 @@ function refuseWhenDisabled(ctx, read) {
|
|
|
5484
5526
|
throw new CameraDisabledError(ctx.name ?? ctx.serial);
|
|
5485
5527
|
}
|
|
5486
5528
|
var CAMERA_MEMBERS = {
|
|
5529
|
+
/** Reads the latest cached report and exposes no setter. */
|
|
5530
|
+
wifiRssi: {
|
|
5531
|
+
param: CAMERA_CMD.WIFI_RSSI,
|
|
5532
|
+
type: "number",
|
|
5533
|
+
kind: "scalar",
|
|
5534
|
+
provenance: "apk",
|
|
5535
|
+
description: "Camera-reported Wi-Fi signal; physical units and a quality scale are unspecified."
|
|
5536
|
+
},
|
|
5487
5537
|
/**
|
|
5488
5538
|
* The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
|
|
5489
5539
|
* family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
|
|
@@ -5640,16 +5690,14 @@ var CAMERA_MEMBERS = {
|
|
|
5640
5690
|
* Live-view quality, the pair to {@link CAMERA_MEMBERS.recordingQuality} and a different setting on a
|
|
5641
5691
|
* different wire: changing one leaves the other's parameter untouched.
|
|
5642
5692
|
*
|
|
5643
|
-
* The READ is 1020, which reports the resolved tier as a plain integer
|
|
5644
|
-
*
|
|
5645
|
-
*
|
|
5693
|
+
* The READ is 1020, which reports the resolved tier as a plain integer, while the WRITE rides 2730's
|
|
5694
|
+
* `1350` payload. They are two ids for one setting because 2730 carries a per-mode config and 1020
|
|
5695
|
+
* carries the tier in force; the tier is what this property means. 1020 is also the name the param
|
|
5696
|
+
* dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730 under
|
|
5697
|
+
* this name would collide with it on a device reporting both.
|
|
5646
5698
|
*
|
|
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.
|
|
5699
|
+
* `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
|
|
5700
|
+
* confirmed frame carries no such field; neither is inferred from the other.
|
|
5653
5701
|
*/
|
|
5654
5702
|
streamingQuality: {
|
|
5655
5703
|
param: 1020,
|
|
@@ -5657,8 +5705,13 @@ var CAMERA_MEMBERS = {
|
|
|
5657
5705
|
kind: "enum",
|
|
5658
5706
|
enumValues: STREAMING_QUALITY_TIERS,
|
|
5659
5707
|
provenance: "verified",
|
|
5660
|
-
|
|
5661
|
-
|
|
5708
|
+
args: [{ name: "quality", kind: "enum", description: "A tier, or the name it maps to. 0 is Auto." }],
|
|
5709
|
+
...accepts(),
|
|
5710
|
+
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.",
|
|
5711
|
+
write: (v, ctx) => {
|
|
5712
|
+
const q = resolveStreamingQualityTier(v);
|
|
5713
|
+
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");
|
|
5714
|
+
}
|
|
5662
5715
|
},
|
|
5663
5716
|
/**
|
|
5664
5717
|
* `type` is how the value is STORED, and 2731 stores the whole config — the ACTIVE tier is lifted out
|
|
@@ -5829,8 +5882,9 @@ function isStationAlarmOutput(ctx) {
|
|
|
5829
5882
|
function isEvidencedStationAlarm(ctx) {
|
|
5830
5883
|
return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
|
|
5831
5884
|
}
|
|
5885
|
+
var S1_PRO_ALARM_MODELS = /* @__PURE__ */ new Set(["T8134"]);
|
|
5832
5886
|
function isEvidencedAttachedCamera(ctx) {
|
|
5833
|
-
return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx);
|
|
5887
|
+
return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx) && (!isStation9000({ deviceType: ctx.stationDeviceType }) || S1_PRO_ALARM_MODELS.has(ctx.model ?? ""));
|
|
5834
5888
|
}
|
|
5835
5889
|
function reportsParam(source, param) {
|
|
5836
5890
|
return source.paramIds?.has(param) === true || Object.hasOwn(source.params ?? {}, param);
|
|
@@ -5849,7 +5903,18 @@ function homeBaseAlarm(seconds, ctx) {
|
|
|
5849
5903
|
throw new Error("station-family HomeBase alarm output requires the acting account name");
|
|
5850
5904
|
return setPayload(SIREN_CMD.HOMEBASE_TONE, { time_out: seconds, user_name: ctx.accountName }, ctx, 0, STATION_CHANNEL);
|
|
5851
5905
|
}
|
|
5906
|
+
var PORTAL_MANUAL_ALARM_TYPE = 10;
|
|
5852
5907
|
function cameraAlarm(seconds, ctx) {
|
|
5908
|
+
if (isStation9000({ deviceType: ctx.stationDeviceType })) {
|
|
5909
|
+
if (!ctx.accountName)
|
|
5910
|
+
throw new Error("a camera alarm behind a HomeBase S1 Pro requires the acting account name");
|
|
5911
|
+
return setPayload(SIREN_CMD.CAMERA_TONE, {
|
|
5912
|
+
time_out: seconds,
|
|
5913
|
+
type: PORTAL_MANUAL_ALARM_TYPE,
|
|
5914
|
+
channel: ctx.channel,
|
|
5915
|
+
username: ctx.accountName
|
|
5916
|
+
}, ctx);
|
|
5917
|
+
}
|
|
5853
5918
|
return {
|
|
5854
5919
|
kind: "p2p-int-string",
|
|
5855
5920
|
cmd: SIREN_CMD.CAMERA_TONE,
|
|
@@ -6188,6 +6253,9 @@ var LIGHT_MEMBERS = {
|
|
|
6188
6253
|
* This is the switch a user changes and expects to STAY changed. {@link isOn} is a different fact:
|
|
6189
6254
|
* the lamp being lit right now, driven by whichever
|
|
6190
6255
|
* client is streaming — the vendor app lights it for a live view and drops it on quitting.
|
|
6256
|
+
*
|
|
6257
|
+
* The T8423 write takes the session-chosen level; level-1 int+string was verified live on a standalone
|
|
6258
|
+
* unit.
|
|
6191
6259
|
*/
|
|
6192
6260
|
spotlightEnabled: {
|
|
6193
6261
|
param: LIGHT_CMD.SPOTLIGHT_ENABLE,
|
|
@@ -6195,7 +6263,7 @@ var LIGHT_MEMBERS = {
|
|
|
6195
6263
|
kind: "boolean",
|
|
6196
6264
|
provenance: "verified",
|
|
6197
6265
|
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) =>
|
|
6266
|
+
write: (v, ctx) => setScalar(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx, ctx.deviceType === DeviceType.FLOODLIGHT_CAMERA_8423 ? "auto" : "direct-binary"),
|
|
6199
6267
|
writeAs: "setEnabled"
|
|
6200
6268
|
}
|
|
6201
6269
|
};
|
|
@@ -7632,7 +7700,8 @@ var ARMING_CMD = {
|
|
|
7632
7700
|
*/
|
|
7633
7701
|
SET_ARMING: 1224,
|
|
7634
7702
|
/**
|
|
7635
|
-
*
|
|
7703
|
+
* One guard mode's per-device action table (app `CMD_SET_ALL_ACTION`), which carries the alarm delays
|
|
7704
|
+
* and each device's actions in that mode. ✅ WIRE CAPTURED live on a T8030 (
|
|
7636
7705
|
* 2026-07-23, both directions): a **bare
|
|
7637
7706
|
* JSON frame, no `1350`/`1700` envelope** — outer P2P cmd IS `1255` itself, station channel 255,
|
|
7638
7707
|
* plaintext `{account_id, count_down_alarm:{channel_list,delay_time},
|
|
@@ -7649,15 +7718,26 @@ var ARMING_CMD = {
|
|
|
7649
7718
|
* corresponding GET command (`1310`/inner cmd `40003`, sent by the app right before editing) always
|
|
7650
7719
|
* replied `{count:0,data:null}` in every capture — genuinely empty, not a decrypt failure (confirmed
|
|
7651
7720
|
* via the same bidirectional decrypt this finding used) — so the app does NOT read the current
|
|
7652
|
-
* config this way;
|
|
7653
|
-
* channel in or out of an existing list isn't possible without risking
|
|
7721
|
+
* config this way; a T8010 reports it on its own params instead, see below. Without a working GET,
|
|
7722
|
+
* safely PATCHING just one channel in or out of an existing list isn't possible without risking
|
|
7723
|
+
* clobbering the rest — so
|
|
7654
7724
|
* this ships as a caller-supplies-everything write instead of guessing a merge.
|
|
7655
7725
|
* `devices`/`siren_sensor_action` are even less understood (raw per-device action codes, meaning
|
|
7656
7726
|
* unconfirmed) and MUST come from a value independently read/captured for the target mode —
|
|
7657
7727
|
* see `AlarmDelayConfig`'s field docs.
|
|
7728
|
+
*
|
|
7729
|
+
* ✅ On a T8010 the app writes the same command for the Device Control screen: header channel 0, the
|
|
7730
|
+
* whole table of the mode `{mode_id, devices, count_down_alarm, count_down_arm}`, acked by an all-zero
|
|
7731
|
+
* 1255 reply. The T8010 reports each table on its own params, Home on 1157 and Away on 1158, and the
|
|
7732
|
+
* reported table follows a write within 15 s. See {@link DEVICE_NOTIFY_FLAG}.
|
|
7658
7733
|
*/
|
|
7659
|
-
|
|
7734
|
+
SET_ALL_ACTION: 1255
|
|
7660
7735
|
};
|
|
7736
|
+
var DEVICE_NOTIFY_FLAG = 8;
|
|
7737
|
+
function actionTable(read, mode) {
|
|
7738
|
+
const value = read(`${mode}ActionTable`)?.value;
|
|
7739
|
+
return value && typeof value === "object" && Array.isArray(value.devices) ? value : void 0;
|
|
7740
|
+
}
|
|
7661
7741
|
var ARMING_MODE_WIRE = {
|
|
7662
7742
|
away: 0,
|
|
7663
7743
|
home: 1,
|
|
@@ -7694,7 +7774,15 @@ function alarmDelayCommand(mode, config, ctx) {
|
|
|
7694
7774
|
devices: config.devices.map((d) => ({ action: d.action, device_channel: d.deviceChannel })),
|
|
7695
7775
|
siren_sensor_action: config.sirenSensorAction.map((d) => ({ action: d.action, device_channel: d.deviceChannel }))
|
|
7696
7776
|
};
|
|
7697
|
-
return setJsonRaw(ARMING_CMD.
|
|
7777
|
+
return setJsonRaw(ARMING_CMD.SET_ALL_ACTION, data, ctx, STATION_CHANNEL2);
|
|
7778
|
+
}
|
|
7779
|
+
var ACTION_TABLE_LAG_MS = 15e3;
|
|
7780
|
+
function withDeviceNotification(table, channel, on) {
|
|
7781
|
+
if (!table?.devices.some((d) => d.device_channel === channel))
|
|
7782
|
+
return void 0;
|
|
7783
|
+
const { account_id: _account, ...rest } = table;
|
|
7784
|
+
const devices = table.devices.map((d) => d.device_channel === channel ? { ...d, action: on ? d.action | DEVICE_NOTIFY_FLAG : d.action & ~DEVICE_NOTIFY_FLAG } : d);
|
|
7785
|
+
return { ...rest, devices };
|
|
7698
7786
|
}
|
|
7699
7787
|
var ARMING_MEMBERS = {
|
|
7700
7788
|
/**
|
|
@@ -7752,7 +7840,45 @@ var ARMING_MEMBERS = {
|
|
|
7752
7840
|
} catch (e) {
|
|
7753
7841
|
return Promise.reject(e);
|
|
7754
7842
|
}
|
|
7755
|
-
}, "Write the full per-mode alarm/arm-delay configuration.")
|
|
7843
|
+
}, "Write the full per-mode alarm/arm-delay configuration."),
|
|
7844
|
+
/**
|
|
7845
|
+
* Whether the device on `channel` sends a push notification in `mode`: its {@link DEVICE_NOTIFY_FLAG}
|
|
7846
|
+
* in the action table the station reports for that mode. `undefined` where the station reports no table
|
|
7847
|
+
* or the table does not list `channel`. A device's channel is `EufyDevice.channel`.
|
|
7848
|
+
*/
|
|
7849
|
+
deviceNotification: method(({ read }) => (mode, channel) => {
|
|
7850
|
+
const action = actionTable(read, mode)?.devices.find((d) => d.device_channel === channel)?.action;
|
|
7851
|
+
return typeof action === "number" ? (action & DEVICE_NOTIFY_FLAG) !== 0 : void 0;
|
|
7852
|
+
}, "Whether the device on a channel sends a push notification in a guard mode (home/away).", (ctx) => ctx.model === "T8010"),
|
|
7853
|
+
/**
|
|
7854
|
+
* Turn the push notification of the device on `channel` on or off in `mode` (cmd
|
|
7855
|
+
* {@link ARMING_CMD.SET_ALL_ACTION}): the station's reported table written back with that one flag
|
|
7856
|
+
* changed. Rejects, sending nothing, where the table does not list `channel`. A write builds on the last
|
|
7857
|
+
* one sent for that mode until the station reports a table observed 15 s after it, so a second write does
|
|
7858
|
+
* not revert it; a write whose dispatch rejects is not built on.
|
|
7859
|
+
*/
|
|
7860
|
+
setDeviceNotification: method(({ ctx, sink, read }) => {
|
|
7861
|
+
const sent = /* @__PURE__ */ new Map();
|
|
7862
|
+
return (mode, channel, on) => {
|
|
7863
|
+
const last = sent.get(mode);
|
|
7864
|
+
const observed = read(`${mode}ActionTable`)?.ts ?? 0;
|
|
7865
|
+
const base = last && observed < last.at + ACTION_TABLE_LAG_MS ? last.table : actionTable(read, mode);
|
|
7866
|
+
const table = withDeviceNotification(base, channel, on);
|
|
7867
|
+
if (!table) {
|
|
7868
|
+
return Promise.reject(new Error(`arming: no ${mode} action table lists channel ${channel} [${describeDevice(ctx)}]`));
|
|
7869
|
+
}
|
|
7870
|
+
sent.set(mode, { table, at: Date.now() });
|
|
7871
|
+
return sink.dispatch(setJsonRaw(ARMING_CMD.SET_ALL_ACTION, table, ctx, 0)).catch((err) => {
|
|
7872
|
+
if (sent.get(mode)?.table === table) {
|
|
7873
|
+
if (last)
|
|
7874
|
+
sent.set(mode, last);
|
|
7875
|
+
else
|
|
7876
|
+
sent.delete(mode);
|
|
7877
|
+
}
|
|
7878
|
+
throw err;
|
|
7879
|
+
});
|
|
7880
|
+
};
|
|
7881
|
+
}, "Turn the push notification of the device on a channel on or off in a guard mode (home/away).", (ctx) => ctx.model === "T8010")
|
|
7756
7882
|
};
|
|
7757
7883
|
var ARMING = {
|
|
7758
7884
|
capability: "arming",
|
|
@@ -8312,8 +8438,6 @@ var MODE_CTRL_FIELD = {
|
|
|
8312
8438
|
SEQ: 2
|
|
8313
8439
|
};
|
|
8314
8440
|
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
8441
|
/** Live-verified on a T2351. */
|
|
8318
8442
|
START_GOHOME: 6,
|
|
8319
8443
|
/** Live-verified on a T2351. */
|
|
@@ -8337,6 +8461,8 @@ var ModeCtrlMethod = {
|
|
|
8337
8461
|
START_GLOBAL_CRUISE: 20
|
|
8338
8462
|
};
|
|
8339
8463
|
var ModeCtrlParamMethod = {
|
|
8464
|
+
/** `START_AUTO_CLEAN` with an `AutoClean` payload — clean the whole floor. */
|
|
8465
|
+
AUTO: { method: 0, param: 3 },
|
|
8340
8466
|
/** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
|
|
8341
8467
|
SELECT_ROOMS: { method: 1, param: 4 },
|
|
8342
8468
|
/** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
|
|
@@ -8373,6 +8499,7 @@ var SELECT_ZONES_FIELD = {
|
|
|
8373
8499
|
POINT_Y: 2
|
|
8374
8500
|
};
|
|
8375
8501
|
var SCENE_CLEAN_ID = 1;
|
|
8502
|
+
var AUTO_CLEAN_TIMES = 1;
|
|
8376
8503
|
function encodeModeCtrlParam(method2, paramField, build) {
|
|
8377
8504
|
return rawDp((w) => {
|
|
8378
8505
|
if (method2 !== 0)
|
|
@@ -8419,14 +8546,17 @@ function encodeSceneClean(sceneId) {
|
|
|
8419
8546
|
const { method: method2, param } = ModeCtrlParamMethod.SCENE;
|
|
8420
8547
|
return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
|
|
8421
8548
|
}
|
|
8549
|
+
function encodeAutoClean() {
|
|
8550
|
+
const { method: method2, param } = ModeCtrlParamMethod.AUTO;
|
|
8551
|
+
return encodeModeCtrlParam(method2, param, (p) => p.int(AUTO_CLEAN_TIMES, 1));
|
|
8552
|
+
}
|
|
8422
8553
|
var modeCtrlSeq = 111;
|
|
8423
8554
|
function nextModeCtrlSeq() {
|
|
8424
8555
|
return ++modeCtrlSeq;
|
|
8425
8556
|
}
|
|
8426
8557
|
function encodeModeCtrl(method2, seq) {
|
|
8427
8558
|
return rawDp((w) => {
|
|
8428
|
-
|
|
8429
|
-
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
8559
|
+
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
8430
8560
|
w.int(MODE_CTRL_FIELD.SEQ, seq);
|
|
8431
8561
|
});
|
|
8432
8562
|
}
|
|
@@ -10268,8 +10398,8 @@ var VACUUM_CLEAN_MEMBERS = {
|
|
|
10268
10398
|
provenance: "mega",
|
|
10269
10399
|
description: "Stop smart-follow mode (ModeCtrlRequest method 18 over DP 152). Method number not captured \u2014 unverified."
|
|
10270
10400
|
},
|
|
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,
|
|
10401
|
+
/** Start an auto-clean run via ModeCtrlRequest method 0 with one `AutoClean` pass (DP 152). AIoT only — Tuya write unverified. */
|
|
10402
|
+
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
10403
|
/** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
|
|
10274
10404
|
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
10405
|
/** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
|
|
@@ -10392,6 +10522,11 @@ var SuctionLevel = {
|
|
|
10392
10522
|
};
|
|
10393
10523
|
var SUCTION_LABEL = enumLabels(SuctionLevel);
|
|
10394
10524
|
var ALL_SUCTION_LEVELS = [0, 1, 2, 3, 4, 5];
|
|
10525
|
+
function levelFromCatalog(member) {
|
|
10526
|
+
if (Object.hasOwn(SuctionLevel, member))
|
|
10527
|
+
return SuctionLevel[member];
|
|
10528
|
+
return Object.hasOwn(SUCTION_LABEL, member) ? Number(member) : void 0;
|
|
10529
|
+
}
|
|
10395
10530
|
function suctionLevelName(value) {
|
|
10396
10531
|
return SUCTION_LABEL[value];
|
|
10397
10532
|
}
|
|
@@ -10452,13 +10587,15 @@ var SUCTION = {
|
|
|
10452
10587
|
},
|
|
10453
10588
|
/**
|
|
10454
10589
|
* The per-model level range — sourced from the SKU's `get_product_data_point` catalog for DP 158
|
|
10455
|
-
* when available. `undefined` when no catalog entry exists for DP 158
|
|
10590
|
+
* when available. `undefined` when no catalog entry exists for DP 158, or when no listed member maps
|
|
10591
|
+
* to a known level (so a caller never reads `[]` as "nothing selectable"). `setSuctionLevel` is only
|
|
10456
10592
|
* installed on AIoT devices (gated by `available: isAiotVacuum` on the `level` entry), so
|
|
10457
10593
|
* `supportedLevels` is harmless data on a non-AIoT device.
|
|
10458
10594
|
*/
|
|
10459
10595
|
actions({ ctx }) {
|
|
10460
10596
|
const catalogRange = ctx.dpCatalog?.enumRanges.get(SUCTION_DP.SUCTION);
|
|
10461
|
-
const
|
|
10597
|
+
const levels = catalogRange?.flatMap((m) => levelFromCatalog(m) ?? []);
|
|
10598
|
+
const supportedLevels = levels?.length ? levels : void 0;
|
|
10462
10599
|
return { supportedLevels };
|
|
10463
10600
|
}
|
|
10464
10601
|
};
|
|
@@ -10500,28 +10637,29 @@ function parseEnumRange(values) {
|
|
|
10500
10637
|
if (values === null || values === void 0)
|
|
10501
10638
|
return [];
|
|
10502
10639
|
if (Array.isArray(values))
|
|
10503
|
-
return
|
|
10640
|
+
return toMemberArray(values);
|
|
10504
10641
|
if (typeof values === "string") {
|
|
10505
10642
|
try {
|
|
10506
10643
|
const parsed = JSON.parse(values);
|
|
10507
10644
|
if (Array.isArray(parsed))
|
|
10508
|
-
return
|
|
10645
|
+
return toMemberArray(parsed);
|
|
10509
10646
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
10510
10647
|
const obj = parsed;
|
|
10511
10648
|
if (Array.isArray(obj.range))
|
|
10512
|
-
return
|
|
10649
|
+
return toMemberArray(obj.range);
|
|
10513
10650
|
}
|
|
10514
10651
|
} catch {
|
|
10515
10652
|
}
|
|
10516
10653
|
}
|
|
10517
10654
|
return [];
|
|
10518
10655
|
}
|
|
10519
|
-
function
|
|
10656
|
+
function toMemberArray(arr) {
|
|
10520
10657
|
const result = [];
|
|
10521
10658
|
for (const v of arr) {
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10659
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
10660
|
+
result.push(String(v));
|
|
10661
|
+
else if (typeof v === "string" && v !== "")
|
|
10662
|
+
result.push(v);
|
|
10525
10663
|
}
|
|
10526
10664
|
return result;
|
|
10527
10665
|
}
|
|
@@ -11016,8 +11154,12 @@ var MODEL_REGISTRY = {
|
|
|
11016
11154
|
// Cameras with curated extras that aren't always provable from a fresh device's params.
|
|
11017
11155
|
T8423: { codec: "camera", caps: ["light", "battery"], name: "Floodlight Cam" },
|
|
11018
11156
|
T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
|
|
11157
|
+
// T8210 hardware variant, reported as its own code; shares the T8210 row.
|
|
11158
|
+
T8210C: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
|
|
11019
11159
|
// Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
|
|
11020
11160
|
T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
|
|
11161
|
+
// Battery Video Doorbell Dual, vendor deviceType 91 (BATTERY_DOORBELL_PLUS), reported by two users.
|
|
11162
|
+
T8213: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
|
|
11021
11163
|
// Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
|
|
11022
11164
|
// member on purpose — this model is wired, so the codec baseline plus inference is the whole
|
|
11023
11165
|
// truth for power. Without this row it classified as a plain camera, so the doorbell capability
|
|
@@ -11179,8 +11321,10 @@ function resolveDevice(rec) {
|
|
|
11179
11321
|
}
|
|
11180
11322
|
function resolveProperties(rec, codec, capabilities) {
|
|
11181
11323
|
const paramIds = /* @__PURE__ */ new Set();
|
|
11182
|
-
|
|
11183
|
-
|
|
11324
|
+
for (const reported of [rec.params, rec.dpParams]) {
|
|
11325
|
+
if (!reported || typeof reported !== "object")
|
|
11326
|
+
continue;
|
|
11327
|
+
for (const k of Object.keys(reported)) {
|
|
11184
11328
|
const n = Number(k);
|
|
11185
11329
|
if (Number.isFinite(n))
|
|
11186
11330
|
paramIds.add(n);
|
|
@@ -11380,9 +11524,15 @@ var SECURITY_PARAMS = {
|
|
|
11380
11524
|
provenance: "guessed"
|
|
11381
11525
|
},
|
|
11382
11526
|
1157: {
|
|
11383
|
-
name: "
|
|
11527
|
+
name: "homeActionTable",
|
|
11384
11528
|
type: "string",
|
|
11385
|
-
provenance: "
|
|
11529
|
+
provenance: "verified",
|
|
11530
|
+
encoding: "base64+json"
|
|
11531
|
+
},
|
|
11532
|
+
1158: {
|
|
11533
|
+
name: "awayActionTable",
|
|
11534
|
+
type: "string",
|
|
11535
|
+
provenance: "verified",
|
|
11386
11536
|
encoding: "base64+json"
|
|
11387
11537
|
},
|
|
11388
11538
|
1159: {
|
|
@@ -14383,6 +14533,19 @@ function decodeMapFrame(frame, codec) {
|
|
|
14383
14533
|
return READERS.get(frame.channelId)?.(frame.payload, codec);
|
|
14384
14534
|
}
|
|
14385
14535
|
|
|
14536
|
+
// dist/transport/abortable.js
|
|
14537
|
+
function abortable(work, signal) {
|
|
14538
|
+
if (!signal)
|
|
14539
|
+
return work;
|
|
14540
|
+
signal.throwIfAborted();
|
|
14541
|
+
return Promise.race([
|
|
14542
|
+
work,
|
|
14543
|
+
new Promise((_resolve, reject) => {
|
|
14544
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
14545
|
+
})
|
|
14546
|
+
]);
|
|
14547
|
+
}
|
|
14548
|
+
|
|
14386
14549
|
// dist/transport/p2p/command-router.js
|
|
14387
14550
|
import { setTimeout as sleep2 } from "node:timers/promises";
|
|
14388
14551
|
|
|
@@ -15308,7 +15471,7 @@ var PADDED_RESULT_BYTES = 132;
|
|
|
15308
15471
|
var CMD_CAMERA_INFO = 1103;
|
|
15309
15472
|
var CMD_DATABASE_IMAGE = 1308;
|
|
15310
15473
|
var CMD_DATABASE = 1306;
|
|
15311
|
-
var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
|
|
15474
|
+
var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
|
|
15312
15475
|
var GCM_AAD = Buffer.from("eufy security");
|
|
15313
15476
|
var LEVEL2_SEQ_BASE = 16909056;
|
|
15314
15477
|
function paramReport(payload) {
|
|
@@ -15338,9 +15501,13 @@ function firstTableRows(text2) {
|
|
|
15338
15501
|
} catch {
|
|
15339
15502
|
continue;
|
|
15340
15503
|
}
|
|
15341
|
-
const
|
|
15342
|
-
if (
|
|
15343
|
-
return
|
|
15504
|
+
const reply = parsed;
|
|
15505
|
+
if (typeof reply?.mIntRet === "number" && reply.mIntRet !== 0)
|
|
15506
|
+
return { error: reply.mIntRet };
|
|
15507
|
+
if (reply?.data === "[]")
|
|
15508
|
+
return { rows: [] };
|
|
15509
|
+
if (Array.isArray(reply?.data))
|
|
15510
|
+
return { rows: reply.data };
|
|
15344
15511
|
}
|
|
15345
15512
|
return void 0;
|
|
15346
15513
|
}
|
|
@@ -15699,8 +15866,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15699
15866
|
return this.connected;
|
|
15700
15867
|
}
|
|
15701
15868
|
/**
|
|
15702
|
-
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and
|
|
15703
|
-
* less or refuses.
|
|
15869
|
+
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and log once per session when it grants
|
|
15870
|
+
* less (debug) or refuses (warn).
|
|
15704
15871
|
*
|
|
15705
15872
|
* The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
|
|
15706
15873
|
* inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
|
|
@@ -15716,7 +15883,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15716
15883
|
if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
|
|
15717
15884
|
return;
|
|
15718
15885
|
this.receiveBufferReported = true;
|
|
15719
|
-
|
|
15886
|
+
const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
|
|
15887
|
+
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
15888
|
}
|
|
15721
15889
|
/** Open the socket and start the lookup → hole-punch handshake. */
|
|
15722
15890
|
async connect() {
|
|
@@ -15961,6 +16129,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15961
16129
|
socket.removeAllListeners("error");
|
|
15962
16130
|
socket.on("error", (error) => this.emit("error", error));
|
|
15963
16131
|
this.socket = socket;
|
|
16132
|
+
this.requestReceiveBuffer(socket);
|
|
15964
16133
|
}
|
|
15965
16134
|
this.connected = true;
|
|
15966
16135
|
this.connectedAtMs = Date.now();
|
|
@@ -16248,6 +16417,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16248
16417
|
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16249
16418
|
return true;
|
|
16250
16419
|
}
|
|
16420
|
+
/**
|
|
16421
|
+
* The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
|
|
16422
|
+
* level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
|
|
16423
|
+
*/
|
|
16424
|
+
sendRawLevel1Bytes(payload, channel, outerCmd) {
|
|
16425
|
+
if (!this.connectAddress)
|
|
16426
|
+
return false;
|
|
16427
|
+
const data = Buffer.concat([
|
|
16428
|
+
buildCommandHeader(this.seqNumber, outerCmd),
|
|
16429
|
+
buildRawCommandPayload(encryptP2PData(paddingP2PData(payload), this.level1Key), channel, 1)
|
|
16430
|
+
]);
|
|
16431
|
+
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
16432
|
+
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16433
|
+
return true;
|
|
16434
|
+
}
|
|
16251
16435
|
/**
|
|
16252
16436
|
* Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
|
|
16253
16437
|
* its runtime topology takes, as {@link startLiveMedia} selects its start:
|
|
@@ -16468,7 +16652,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16468
16652
|
camera_type: 0,
|
|
16469
16653
|
entrytype: 0,
|
|
16470
16654
|
key: this.rsaModulus(),
|
|
16471
|
-
streamtype:
|
|
16655
|
+
streamtype: 2,
|
|
16472
16656
|
...payload
|
|
16473
16657
|
} : payload;
|
|
16474
16658
|
const value = JSON.stringify({
|
|
@@ -16684,6 +16868,29 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16684
16868
|
query: this.fullTableQuery()
|
|
16685
16869
|
});
|
|
16686
16870
|
}
|
|
16871
|
+
/**
|
|
16872
|
+
* Read a date-ranged page of `history_record_info` from the station. The reply's `data` array groups
|
|
16873
|
+
* recording rows with companion tables; each group retains its `table_name` and `payload`.
|
|
16874
|
+
* `startTime` is `"0"` for the newest page or the oldest row's `yyyyMMddHHmmss` cursor for older rows.
|
|
16875
|
+
*/
|
|
16876
|
+
queryRecordPage(opts) {
|
|
16877
|
+
return this.readDatabase("history_record_info", {
|
|
16878
|
+
accountId: opts.accountId,
|
|
16879
|
+
innerCmd: DB_QUERY.QUERY_LOCAL,
|
|
16880
|
+
query: {
|
|
16881
|
+
count: opts.count ?? 20,
|
|
16882
|
+
start_date: opts.startDate,
|
|
16883
|
+
end_date: opts.endDate,
|
|
16884
|
+
start_time: opts.startTime ?? "0",
|
|
16885
|
+
event_type: 0,
|
|
16886
|
+
ai_type: 0,
|
|
16887
|
+
storage_cloud: -1,
|
|
16888
|
+
trigger_type: 0,
|
|
16889
|
+
detection_type: 0,
|
|
16890
|
+
flag: 0
|
|
16891
|
+
}
|
|
16892
|
+
});
|
|
16893
|
+
}
|
|
16687
16894
|
/**
|
|
16688
16895
|
* Query one on-station table and answer its rows, once the reply is whole.
|
|
16689
16896
|
*
|
|
@@ -16691,7 +16898,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16691
16898
|
* several frames whose decrypted text is a fragment of one document, so the fragments are
|
|
16692
16899
|
* accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
|
|
16693
16900
|
* `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
|
|
16694
|
-
* complete reply
|
|
16901
|
+
* complete reply, and when the station answers a non-zero `mIntRet`.
|
|
16695
16902
|
*
|
|
16696
16903
|
* One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
|
|
16697
16904
|
* session throws, so a second reply cannot land in this buffer.
|
|
@@ -16699,16 +16906,22 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16699
16906
|
async readDatabase(table, opts = {}) {
|
|
16700
16907
|
if (opts.signal?.aborted)
|
|
16701
16908
|
throw new Error("readDatabase: aborted");
|
|
16702
|
-
this.queryDatabase(table, {
|
|
16909
|
+
this.queryDatabase(table, {
|
|
16910
|
+
accountId: opts.accountId,
|
|
16911
|
+
innerCmd: opts.innerCmd,
|
|
16912
|
+
query: opts.query ?? this.fullTableQuery()
|
|
16913
|
+
});
|
|
16703
16914
|
this.dbReadInFlight = true;
|
|
16704
16915
|
try {
|
|
16705
16916
|
return await new Promise((resolve, reject) => {
|
|
16706
16917
|
let text2 = "";
|
|
16707
16918
|
const onChunk = (chunk) => {
|
|
16708
16919
|
text2 += chunk.text;
|
|
16709
|
-
const
|
|
16710
|
-
if (
|
|
16711
|
-
settle(() =>
|
|
16920
|
+
const result = firstTableRows(text2);
|
|
16921
|
+
if (result?.error !== void 0)
|
|
16922
|
+
settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
|
|
16923
|
+
else if (result?.rows)
|
|
16924
|
+
settle(() => resolve(result.rows));
|
|
16712
16925
|
};
|
|
16713
16926
|
const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
|
|
16714
16927
|
const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
|
|
@@ -16809,11 +17022,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16809
17022
|
if (advance > SEQUENCE_LOOKBACK) {
|
|
16810
17023
|
if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
|
|
16811
17024
|
return;
|
|
16812
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
17025
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16813
17026
|
this.trace({ phase: "sequence-restart", dataType });
|
|
16814
17027
|
this.clearReorderTimer(dataType);
|
|
16815
17028
|
this.reorderByType.delete(dataType);
|
|
16816
17029
|
this.pendingByDataType.delete(dataType);
|
|
17030
|
+
if (dataType === P2PDataType.VIDEO)
|
|
17031
|
+
this.emit("videoGap");
|
|
16817
17032
|
this.lastSeqByType.set(dataType, seqNo);
|
|
16818
17033
|
this.reassemble(dataType, msg.subarray(8));
|
|
16819
17034
|
return;
|
|
@@ -16879,9 +17094,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16879
17094
|
*/
|
|
16880
17095
|
abandonHole(dataType) {
|
|
16881
17096
|
const held = this.reorderByType.get(dataType).held;
|
|
16882
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
17097
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16883
17098
|
this.trace({ phase: "datagram-gap", dataType });
|
|
16884
17099
|
this.pendingByDataType.delete(dataType);
|
|
17100
|
+
if (dataType === P2PDataType.VIDEO)
|
|
17101
|
+
this.emit("videoGap");
|
|
16885
17102
|
const last = this.lastSeqByType.get(dataType);
|
|
16886
17103
|
const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
|
|
16887
17104
|
this.lastSeqByType.set(dataType, earliest - 1 & 65535);
|
|
@@ -17518,7 +17735,8 @@ var AccessUnitAssembler = class {
|
|
|
17518
17735
|
* `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
|
|
17519
17736
|
* frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
|
|
17520
17737
|
* of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
|
|
17521
|
-
* carries).
|
|
17738
|
+
* carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
|
|
17739
|
+
* as a complete unit, even if its repeated header says keyframe.
|
|
17522
17740
|
*/
|
|
17523
17741
|
push(payload, decode) {
|
|
17524
17742
|
const header = parseVideoFrameHeader(payload);
|
|
@@ -17538,6 +17756,8 @@ var AccessUnitAssembler = class {
|
|
|
17538
17756
|
return [unitOf(open2.header, Buffer.concat(open2.chunks))];
|
|
17539
17757
|
}
|
|
17540
17758
|
this.discard();
|
|
17759
|
+
if (!beginsAccessUnit(body))
|
|
17760
|
+
return [];
|
|
17541
17761
|
if (full) {
|
|
17542
17762
|
this.open = { header, chunks: [body], carried: body.length };
|
|
17543
17763
|
return [];
|
|
@@ -17563,7 +17783,13 @@ function beginsAccessUnit(body) {
|
|
|
17563
17783
|
return body[2] === 1 || body[2] === 0 && body[3] === 1;
|
|
17564
17784
|
}
|
|
17565
17785
|
function unitOf(header, data) {
|
|
17566
|
-
return {
|
|
17786
|
+
return {
|
|
17787
|
+
sourceTimestamp: header.timestamp,
|
|
17788
|
+
keyframe: header.keyframe,
|
|
17789
|
+
width: header.width,
|
|
17790
|
+
height: header.height,
|
|
17791
|
+
data
|
|
17792
|
+
};
|
|
17567
17793
|
}
|
|
17568
17794
|
|
|
17569
17795
|
// dist/transport/p2p/annexb.js
|
|
@@ -17666,6 +17892,18 @@ function startCodeBegin(buf, payloadOffset) {
|
|
|
17666
17892
|
return threeByte - 1;
|
|
17667
17893
|
return threeByte;
|
|
17668
17894
|
}
|
|
17895
|
+
function hasIdr(buf, codec) {
|
|
17896
|
+
for (const p of nalStarts(buf)) {
|
|
17897
|
+
const b = buf[p];
|
|
17898
|
+
if (codec === "h265") {
|
|
17899
|
+
const t = b >> 1 & 63;
|
|
17900
|
+
if (t === 19 || t === 20)
|
|
17901
|
+
return true;
|
|
17902
|
+
} else if ((b & 31) === 5)
|
|
17903
|
+
return true;
|
|
17904
|
+
}
|
|
17905
|
+
return false;
|
|
17906
|
+
}
|
|
17669
17907
|
var MAX_CODED_DIMENSION = 32768;
|
|
17670
17908
|
var H264_CHROMA_PROFILES = /* @__PURE__ */ new Set([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135, 144]);
|
|
17671
17909
|
var CHROMA_SUBSAMPLING = {
|
|
@@ -17878,6 +18116,7 @@ var AUDIO_HEADER_LEN = 16;
|
|
|
17878
18116
|
var AUDIO_TYPE_OFFSET = 5;
|
|
17879
18117
|
var SC4 = Buffer.from([0, 0, 0, 1]);
|
|
17880
18118
|
var SC3 = Buffer.from([0, 0, 1]);
|
|
18119
|
+
var AAC_ELD_AUDIO_SPECIFIC_CONFIG = Buffer.from("f8f03000", "hex");
|
|
17881
18120
|
var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
|
|
17882
18121
|
var MAX_TRACED_DECODE_FAILURES = 3;
|
|
17883
18122
|
var DEFAULT_KEEPALIVE_MS = 3e3;
|
|
@@ -17891,6 +18130,12 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17891
18130
|
lastCodec = "h264";
|
|
17892
18131
|
/** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
|
|
17893
18132
|
units = new AccessUnitAssembler((drop) => this.reportDroppedUnit(drop));
|
|
18133
|
+
/** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
|
|
18134
|
+
awaitingKeyframeAfterGap = false;
|
|
18135
|
+
videoGapHandler = () => {
|
|
18136
|
+
this.units.discard();
|
|
18137
|
+
this.awaitingKeyframeAfterGap = true;
|
|
18138
|
+
};
|
|
17894
18139
|
/** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
|
|
17895
18140
|
mediaChannel;
|
|
17896
18141
|
tracedFirstVideoCommand = false;
|
|
@@ -17933,6 +18178,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17933
18178
|
return this;
|
|
17934
18179
|
this.listening = true;
|
|
17935
18180
|
this.session.on("data", this.handler);
|
|
18181
|
+
this.session.on("videoGap", this.videoGapHandler);
|
|
17936
18182
|
this.session.on("liveStartUnacknowledged", this.unackedHandler);
|
|
17937
18183
|
this.sendStart();
|
|
17938
18184
|
const keepAliveMs = this.opts.keepAliveMs ?? DEFAULT_KEEPALIVE_MS;
|
|
@@ -18030,6 +18276,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18030
18276
|
return;
|
|
18031
18277
|
this.listening = false;
|
|
18032
18278
|
this.session.off("data", this.handler);
|
|
18279
|
+
this.session.off("videoGap", this.videoGapHandler);
|
|
18280
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18033
18281
|
this.session.off("liveStartUnacknowledged", this.unackedHandler);
|
|
18034
18282
|
if (this.kaTimer)
|
|
18035
18283
|
clearInterval(this.kaTimer);
|
|
@@ -18065,6 +18313,11 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18065
18313
|
try {
|
|
18066
18314
|
if (f.commandId === CMD_VIDEO_FRAME2) {
|
|
18067
18315
|
for (const unit of this.units.push(f.data, (payload) => this.annexbOf(payload, f.signCode))) {
|
|
18316
|
+
if (this.awaitingKeyframeAfterGap) {
|
|
18317
|
+
if (!unit.keyframe)
|
|
18318
|
+
continue;
|
|
18319
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18320
|
+
}
|
|
18068
18321
|
if (!this.tracedFirstVideoUnit) {
|
|
18069
18322
|
this.tracedFirstVideoUnit = true;
|
|
18070
18323
|
this.trace({ phase: "first-video-unit", keyframe: unit.keyframe });
|
|
@@ -18077,6 +18330,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18077
18330
|
this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
|
|
18078
18331
|
this.settleKeepalive();
|
|
18079
18332
|
this.emit("video", {
|
|
18333
|
+
sourceTimestamp: unit.sourceTimestamp,
|
|
18080
18334
|
keyframe: unit.keyframe,
|
|
18081
18335
|
width: unit.width,
|
|
18082
18336
|
height: unit.height,
|
|
@@ -18092,7 +18346,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18092
18346
|
if (!codec) {
|
|
18093
18347
|
this.logger.debug(`[live] dropping audio frame: unknown codec id ${codecId ?? "missing"}`);
|
|
18094
18348
|
} else {
|
|
18095
|
-
this.emit("audio", { codec, data: audio });
|
|
18349
|
+
this.emit("audio", codec === "aac-eld" ? { codec, data: audio, config: AAC_ELD_AUDIO_SPECIFIC_CONFIG } : { codec, data: audio });
|
|
18096
18350
|
}
|
|
18097
18351
|
}
|
|
18098
18352
|
}
|
|
@@ -18159,6 +18413,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18159
18413
|
* otherwise silent in both directions: no frame reaches a consumer, and nothing states why.
|
|
18160
18414
|
*/
|
|
18161
18415
|
reportDroppedUnit(drop) {
|
|
18416
|
+
this.awaitingKeyframeAfterGap = true;
|
|
18162
18417
|
const message = `[live] dropped an incomplete access unit (${drop.carried} bytes in ${drop.chunks} frame(s), tail never arrived, ${drop.count} so far)`;
|
|
18163
18418
|
if (drop.count === 1)
|
|
18164
18419
|
this.logger.warn(message);
|
|
@@ -18572,9 +18827,11 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18572
18827
|
if (eccPrivateKey.length !== 32)
|
|
18573
18828
|
throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
|
|
18574
18829
|
const decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
18830
|
+
const assembler = new AccessUnitAssembler();
|
|
18575
18831
|
const video = [];
|
|
18576
18832
|
const audio = [];
|
|
18577
18833
|
const numbers = /* @__PURE__ */ new Set();
|
|
18834
|
+
const receivedNumbers = /* @__PURE__ */ new Set();
|
|
18578
18835
|
let firstStamp;
|
|
18579
18836
|
let lastStamp;
|
|
18580
18837
|
for (const { commandId, raw } of frames) {
|
|
@@ -18582,14 +18839,15 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18582
18839
|
const header = parseVideoFrameHeader(raw);
|
|
18583
18840
|
if (!header)
|
|
18584
18841
|
continue;
|
|
18585
|
-
|
|
18586
|
-
|
|
18587
|
-
|
|
18588
|
-
|
|
18589
|
-
|
|
18590
|
-
|
|
18591
|
-
|
|
18592
|
-
|
|
18842
|
+
receivedNumbers.add(header.sequence);
|
|
18843
|
+
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);
|
|
18844
|
+
for (const unit of units) {
|
|
18845
|
+
video.push(unit.data);
|
|
18846
|
+
numbers.add(header.sequence);
|
|
18847
|
+
const stamp = raw.readUIntLE(14, 6);
|
|
18848
|
+
firstStamp ??= stamp;
|
|
18849
|
+
lastStamp = stamp;
|
|
18850
|
+
}
|
|
18593
18851
|
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
18594
18852
|
const mediaKey = decoder.currentMediaKey;
|
|
18595
18853
|
if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
@@ -18605,9 +18863,10 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18605
18863
|
if (!video.length)
|
|
18606
18864
|
throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
|
|
18607
18865
|
const ordered = [...numbers];
|
|
18608
|
-
|
|
18609
|
-
|
|
18610
|
-
|
|
18866
|
+
const received = [...receivedNumbers];
|
|
18867
|
+
let missingFrames = receivedNumbers.size - numbers.size;
|
|
18868
|
+
for (let i = 1; i < received.length; i++) {
|
|
18869
|
+
const step = received[i] - received[i - 1] & 65535;
|
|
18611
18870
|
if (step > 1 && step < 32768)
|
|
18612
18871
|
missingFrames += step - 1;
|
|
18613
18872
|
}
|
|
@@ -19415,6 +19674,14 @@ var SharedLiveSource = class {
|
|
|
19415
19674
|
this.opts.onIdle?.();
|
|
19416
19675
|
}
|
|
19417
19676
|
};
|
|
19677
|
+
function attachUnlessAborted(source, signal) {
|
|
19678
|
+
const consumer = source.attach();
|
|
19679
|
+
if (signal?.aborted) {
|
|
19680
|
+
consumer.detach();
|
|
19681
|
+
signal.throwIfAborted();
|
|
19682
|
+
}
|
|
19683
|
+
return consumer;
|
|
19684
|
+
}
|
|
19418
19685
|
|
|
19419
19686
|
// dist/transport/p2p/session-manager.js
|
|
19420
19687
|
var SessionSupersededError = class extends Error {
|
|
@@ -19828,7 +20095,8 @@ var AdtsFrameReader = class {
|
|
|
19828
20095
|
};
|
|
19829
20096
|
|
|
19830
20097
|
// dist/transport/p2p/talkback.js
|
|
19831
|
-
var
|
|
20098
|
+
var HIGH_WATER_MS = 2e3;
|
|
20099
|
+
var TALKBACK_ELD_FRAME_MS = 32;
|
|
19832
20100
|
var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
|
|
19833
20101
|
var Talkback = class extends EventEmitter5 {
|
|
19834
20102
|
sink;
|
|
@@ -19836,7 +20104,8 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19836
20104
|
reader = new AdtsFrameReader();
|
|
19837
20105
|
queue = [];
|
|
19838
20106
|
logger;
|
|
19839
|
-
|
|
20107
|
+
/** Playback duration of one queued frame, the pacer's step: 64 ms for `aac-lc`, 32 ms for `aac-eld`. */
|
|
20108
|
+
frameMs = AAC_FRAME_MS;
|
|
19840
20109
|
idleTimeoutMs;
|
|
19841
20110
|
timer;
|
|
19842
20111
|
started = false;
|
|
@@ -19862,9 +20131,14 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19862
20131
|
this.sink = sink;
|
|
19863
20132
|
this.opts = opts;
|
|
19864
20133
|
this.logger = opts.logger ?? noopLogger;
|
|
19865
|
-
this.highWater = opts.highWaterFrames ?? DEFAULT_HIGH_WATER_FRAMES;
|
|
19866
20134
|
this.idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
19867
20135
|
}
|
|
20136
|
+
/** Frames the pacing queue holds before a writable applies backpressure: {@link HIGH_WATER_MS} of this codec. */
|
|
20137
|
+
get highWater() {
|
|
20138
|
+
return this.opts.highWaterFrames ?? Math.ceil(HIGH_WATER_MS / this.frameMs);
|
|
20139
|
+
}
|
|
20140
|
+
/** The codec this talkback sends, set by {@link start}. */
|
|
20141
|
+
codec = "aac-lc";
|
|
19868
20142
|
/** Frames still queued for the wire. */
|
|
19869
20143
|
get pending() {
|
|
19870
20144
|
return this.queue.length;
|
|
@@ -19873,25 +20147,33 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19873
20147
|
* Open the device's talkback path and begin the pacing tick. Throws when the topology's control
|
|
19874
20148
|
* frame could not be sent — for a HomeBase-attached camera that means the level-2 key was never
|
|
19875
20149
|
* negotiated, which would otherwise leave a silent session that accepts audio nobody hears.
|
|
20150
|
+
*
|
|
20151
|
+
* `codec` is the codec the camera's speaker plays; it fixes the input this talkback accepts and the pacing step.
|
|
19876
20152
|
*/
|
|
19877
|
-
start() {
|
|
20153
|
+
start(codec) {
|
|
19878
20154
|
if (this.started)
|
|
19879
20155
|
return this;
|
|
20156
|
+
if (codec === "aac-eld" && this.opts.encoder) {
|
|
20157
|
+
throw new Error("talkback: this camera plays aac-eld; an AacEncoder produces aac-lc");
|
|
20158
|
+
}
|
|
20159
|
+
this.codec = codec;
|
|
20160
|
+
this.frameMs = codec === "aac-eld" ? TALKBACK_ELD_FRAME_MS : AAC_FRAME_MS;
|
|
19880
20161
|
if (!this.sink.startTalkback(this.opts.channel, this.opts.homeBaseAttached)) {
|
|
19881
20162
|
throw new Error(`talkback: could not open the audio path on channel ${this.opts.channel}`);
|
|
19882
20163
|
}
|
|
19883
20164
|
this.started = true;
|
|
19884
20165
|
const now2 = Date.now();
|
|
19885
|
-
this.nextDueAt = now2 +
|
|
20166
|
+
this.nextDueAt = now2 + this.frameMs;
|
|
19886
20167
|
this.lastWriteAt = now2;
|
|
19887
|
-
this.timer = setInterval(() => this.tick(),
|
|
20168
|
+
this.timer = setInterval(() => this.tick(), this.frameMs);
|
|
19888
20169
|
this.timer.unref?.();
|
|
19889
20170
|
this.sink.on?.("audioGap", this.onAudioGap);
|
|
19890
20171
|
return this;
|
|
19891
20172
|
}
|
|
19892
20173
|
/**
|
|
19893
|
-
* Queue audio.
|
|
19894
|
-
*
|
|
20174
|
+
* Queue audio in the codec this talkback sends. For `aac-lc` the bytes are ADTS, or PCM when an encoder was
|
|
20175
|
+
* supplied, read as a stream, so a chunk carrying part of a frame is held until the rest arrives. For `aac-eld`
|
|
20176
|
+
* each call is one whole access unit; a unit over the device's length limit is dropped with an `error`.
|
|
19895
20177
|
*
|
|
19896
20178
|
* Audio written after {@link end} is dropped with an `error` rather than queued: `finished` has
|
|
19897
20179
|
* either already fired or is owed on the frames written before it, so a late chunk would either play
|
|
@@ -19905,6 +20187,15 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19905
20187
|
return;
|
|
19906
20188
|
}
|
|
19907
20189
|
this.lastWriteAt = Date.now();
|
|
20190
|
+
if (this.codec === "aac-eld") {
|
|
20191
|
+
if (chunk.length > MAX_AUDIO_FRAME_BYTES) {
|
|
20192
|
+
this.fail(new Error(`talkback: frame of ${chunk.length} B exceeds the device's ${MAX_AUDIO_FRAME_BYTES} B limit`));
|
|
20193
|
+
return;
|
|
20194
|
+
}
|
|
20195
|
+
if (chunk.length)
|
|
20196
|
+
this.queue.push(chunk);
|
|
20197
|
+
return;
|
|
20198
|
+
}
|
|
19908
20199
|
if (!this.opts.encoder) {
|
|
19909
20200
|
this.enqueue(this.reader.push(chunk));
|
|
19910
20201
|
return;
|
|
@@ -19973,14 +20264,14 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19973
20264
|
return;
|
|
19974
20265
|
const now2 = Date.now();
|
|
19975
20266
|
if (!this.queue.length) {
|
|
19976
|
-
this.nextDueAt = now2 +
|
|
20267
|
+
this.nextDueAt = now2 + this.frameMs;
|
|
19977
20268
|
this.idleCheck(now2);
|
|
19978
20269
|
this.announceFinishIfDone();
|
|
19979
20270
|
return;
|
|
19980
20271
|
}
|
|
19981
20272
|
while (this.queue.length && now2 >= this.nextDueAt) {
|
|
19982
20273
|
const frame = this.queue.shift();
|
|
19983
|
-
this.nextDueAt +=
|
|
20274
|
+
this.nextDueAt += this.frameMs;
|
|
19984
20275
|
try {
|
|
19985
20276
|
this.sink.sendAudioFrame(this.opts.channel, frame);
|
|
19986
20277
|
} catch (e) {
|
|
@@ -20038,9 +20329,11 @@ var Talkback = class extends EventEmitter5 {
|
|
|
20038
20329
|
/**
|
|
20039
20330
|
* A Writable over {@link write} that withholds its callback while the queue is at the high-water
|
|
20040
20331
|
* mark — which is what turns `pipe()` from a file into playback at speed rather than a memory spike.
|
|
20332
|
+
* For `aac-eld` it is object-mode: each write is one access unit.
|
|
20041
20333
|
*/
|
|
20042
20334
|
writable() {
|
|
20043
20335
|
return new Writable({
|
|
20336
|
+
objectMode: this.codec === "aac-eld",
|
|
20044
20337
|
write: (chunk, _enc, cb) => {
|
|
20045
20338
|
this.write(chunk);
|
|
20046
20339
|
if (this.queue.length < this.highWater)
|
|
@@ -20109,7 +20402,7 @@ var CODEC_BOXES = {
|
|
|
20109
20402
|
h264: { sample: "avc1", config: "avcC" },
|
|
20110
20403
|
h265: { sample: "hvc1", config: "hvcC" }
|
|
20111
20404
|
};
|
|
20112
|
-
var AAC_ELD_SAMPLES_PER_FRAME =
|
|
20405
|
+
var AAC_ELD_SAMPLES_PER_FRAME = 480;
|
|
20113
20406
|
var ADTS_FREQUENCY_INDEX_16K = 8;
|
|
20114
20407
|
var ADTS_CHANNELS_MONO = 1;
|
|
20115
20408
|
var Fmp4Muxer = class {
|
|
@@ -20127,6 +20420,8 @@ var Fmp4Muxer = class {
|
|
|
20127
20420
|
lastVideoTimestampMs;
|
|
20128
20421
|
audioRequested;
|
|
20129
20422
|
audioCodec;
|
|
20423
|
+
/** Decoder config of an AAC-ELD track, taken from its frames; absent for AAC-LC, whose `esds` config is fixed. */
|
|
20424
|
+
audioConfig;
|
|
20130
20425
|
audioDisabled = false;
|
|
20131
20426
|
audioSamples = [];
|
|
20132
20427
|
audioBaseDecodeTime = 0;
|
|
@@ -20193,7 +20488,10 @@ var Fmp4Muxer = class {
|
|
|
20193
20488
|
}
|
|
20194
20489
|
return void 0;
|
|
20195
20490
|
}
|
|
20196
|
-
/**
|
|
20491
|
+
/**
|
|
20492
|
+
* Add one station-declared AAC access unit to the audio track: an ADTS frame for AAC-LC, a raw access unit
|
|
20493
|
+
* described by its frame's config for AAC-ELD. G.711 remains available via `live()`.
|
|
20494
|
+
*/
|
|
20197
20495
|
pushAudio(frame, timestampMs = now()) {
|
|
20198
20496
|
if (!this.audioRequested || this.audioDisabled)
|
|
20199
20497
|
return void 0;
|
|
@@ -20209,12 +20507,19 @@ var Fmp4Muxer = class {
|
|
|
20209
20507
|
if (this.audioCodec && this.audioCodec !== frame.codec) {
|
|
20210
20508
|
return this.disableAudio();
|
|
20211
20509
|
}
|
|
20212
|
-
|
|
20213
|
-
if (
|
|
20214
|
-
|
|
20215
|
-
|
|
20216
|
-
|
|
20217
|
-
|
|
20510
|
+
let sample;
|
|
20511
|
+
if (frame.codec === "aac-eld") {
|
|
20512
|
+
this.audioConfig = frame.config;
|
|
20513
|
+
sample = frame.data;
|
|
20514
|
+
} else {
|
|
20515
|
+
const header = parseAdtsHeader(frame.data);
|
|
20516
|
+
if (!header || header.frameLength > frame.data.length) {
|
|
20517
|
+
throw new Error(`fMP4 muxer: ${frame.codec} frame is not a complete ADTS access unit`);
|
|
20518
|
+
}
|
|
20519
|
+
if (header.frequencyIndex !== ADTS_FREQUENCY_INDEX_16K || header.channels !== ADTS_CHANNELS_MONO) {
|
|
20520
|
+
return this.disableAudio();
|
|
20521
|
+
}
|
|
20522
|
+
sample = frame.data.subarray(header.headerLength, header.frameLength);
|
|
20218
20523
|
}
|
|
20219
20524
|
this.audioCodec = frame.codec;
|
|
20220
20525
|
this.firstAudioTimestampMs ??= timestampMs;
|
|
@@ -20226,7 +20531,7 @@ var Fmp4Muxer = class {
|
|
|
20226
20531
|
this.audioSamples[this.audioSamples.length - 1].duration = measured;
|
|
20227
20532
|
}
|
|
20228
20533
|
this.audioSamples.push({
|
|
20229
|
-
data:
|
|
20534
|
+
data: sample,
|
|
20230
20535
|
duration: frame.codec === "aac-eld" ? AAC_ELD_SAMPLES_PER_FRAME : AAC_SAMPLES_PER_FRAME,
|
|
20231
20536
|
keyframe: true
|
|
20232
20537
|
});
|
|
@@ -20499,7 +20804,7 @@ var Fmp4Muxer = class {
|
|
|
20499
20804
|
return box("mp4a", head, this.esds());
|
|
20500
20805
|
}
|
|
20501
20806
|
esds() {
|
|
20502
|
-
const config = this.
|
|
20807
|
+
const config = this.audioConfig ?? Buffer.from([20, 8]);
|
|
20503
20808
|
const specific = descriptor(5, config);
|
|
20504
20809
|
const decoder = descriptor(4, Buffer.concat([Buffer.from([64, 21, 0, 0, 0]), u32(64e3), u32(32e3), specific]));
|
|
20505
20810
|
const sl = descriptor(6, Buffer.from([2]));
|
|
@@ -20704,16 +21009,12 @@ function stationOf(dev) {
|
|
|
20704
21009
|
const parent = typeof raw.parent_sn === "string" && raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : void 0;
|
|
20705
21010
|
return parent ?? dev.stationSn ?? resolvedStationSn(raw, dev.sn);
|
|
20706
21011
|
}
|
|
20707
|
-
function statedChannel(dev) {
|
|
20708
|
-
const v = dev.raw?.device_channel;
|
|
20709
|
-
return typeof v === "number" ? v : void 0;
|
|
20710
|
-
}
|
|
20711
21012
|
function stationChannels(devices) {
|
|
20712
21013
|
const out = /* @__PURE__ */ new Map();
|
|
20713
21014
|
const claimants = /* @__PURE__ */ new Map();
|
|
20714
21015
|
for (const d of devices) {
|
|
20715
21016
|
const station = stationOf(d);
|
|
20716
|
-
const stated =
|
|
21017
|
+
const stated = d.channel;
|
|
20717
21018
|
if (d.sn === station || stated === void 0)
|
|
20718
21019
|
continue;
|
|
20719
21020
|
const counts = claimants.get(station) ?? /* @__PURE__ */ new Map();
|
|
@@ -20722,7 +21023,7 @@ function stationChannels(devices) {
|
|
|
20722
21023
|
}
|
|
20723
21024
|
for (const d of devices) {
|
|
20724
21025
|
const station = stationOf(d);
|
|
20725
|
-
const stated =
|
|
21026
|
+
const stated = d.channel;
|
|
20726
21027
|
if (d.sn === station)
|
|
20727
21028
|
out.set(d.sn, { channel: stated ?? 0 });
|
|
20728
21029
|
else if (stated === void 0)
|
|
@@ -20737,17 +21038,6 @@ function stationChannels(devices) {
|
|
|
20737
21038
|
|
|
20738
21039
|
// dist/transport/p2p/command-router.js
|
|
20739
21040
|
var DIRECT_CMD_SENDS = 5;
|
|
20740
|
-
function abortable(work, signal) {
|
|
20741
|
-
if (!signal)
|
|
20742
|
-
return work;
|
|
20743
|
-
signal.throwIfAborted();
|
|
20744
|
-
return Promise.race([
|
|
20745
|
-
work,
|
|
20746
|
-
new Promise((_resolve, reject) => {
|
|
20747
|
-
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
20748
|
-
})
|
|
20749
|
-
]);
|
|
20750
|
-
}
|
|
20751
21041
|
var LEVEL2_GRACE_MS = 25e3;
|
|
20752
21042
|
var LEVEL2_SETTLE_MS = 8e3;
|
|
20753
21043
|
var P2P_STATION_WAITS = {
|
|
@@ -20756,6 +21046,30 @@ var P2P_STATION_WAITS = {
|
|
|
20756
21046
|
level2Settle: LEVEL2_SETTLE_MS
|
|
20757
21047
|
};
|
|
20758
21048
|
var RTSP_URL_READ_TIMEOUT_MS = 12e3;
|
|
21049
|
+
var TALKBACK_CODEC_WAIT_MS = 3e3;
|
|
21050
|
+
var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
|
|
21051
|
+
function speakerCodec(consumer, talk, timeoutMs) {
|
|
21052
|
+
return new Promise((resolve) => {
|
|
21053
|
+
let timer;
|
|
21054
|
+
const settle = (codec) => {
|
|
21055
|
+
clearTimeout(timer);
|
|
21056
|
+
consumer.off("video", onVideo);
|
|
21057
|
+
consumer.off("audio", onAudio);
|
|
21058
|
+
consumer.off("stop", onStop);
|
|
21059
|
+
talk.off("stop", onStop);
|
|
21060
|
+
resolve(codec);
|
|
21061
|
+
};
|
|
21062
|
+
const onVideo = () => {
|
|
21063
|
+
timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
|
|
21064
|
+
};
|
|
21065
|
+
const onAudio = (frame) => settle(frame.codec === "aac-eld" ? "aac-eld" : "aac-lc");
|
|
21066
|
+
const onStop = () => settle("aac-lc");
|
|
21067
|
+
consumer.on("video", onVideo);
|
|
21068
|
+
consumer.on("audio", onAudio);
|
|
21069
|
+
consumer.on("stop", onStop);
|
|
21070
|
+
talk.once("stop", onStop);
|
|
21071
|
+
});
|
|
21072
|
+
}
|
|
20759
21073
|
var SHARED_LIVE_OPT_KEYS = [
|
|
20760
21074
|
"eccPrivateKey",
|
|
20761
21075
|
"keepAliveMs",
|
|
@@ -21174,14 +21488,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21174
21488
|
}
|
|
21175
21489
|
}
|
|
21176
21490
|
/**
|
|
21177
|
-
* Restart
|
|
21178
|
-
*
|
|
21179
|
-
*
|
|
21180
|
-
*
|
|
21181
|
-
*
|
|
21491
|
+
* Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
|
|
21492
|
+
* broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
|
|
21493
|
+
* byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
|
|
21494
|
+
*
|
|
21495
|
+
* The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
|
|
21496
|
+
* level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
|
|
21497
|
+
* sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
|
|
21498
|
+
* control here is.
|
|
21182
21499
|
*/
|
|
21183
21500
|
async rebootStation(sn) {
|
|
21184
|
-
await this.
|
|
21501
|
+
await this.sendBySessionLevel(sn, {
|
|
21502
|
+
l2: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8), resolved),
|
|
21503
|
+
l1: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel1Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB), resolved)
|
|
21504
|
+
});
|
|
21185
21505
|
}
|
|
21186
21506
|
/**
|
|
21187
21507
|
* A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
|
|
@@ -21209,11 +21529,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21209
21529
|
},
|
|
21210
21530
|
live: async (opts) => {
|
|
21211
21531
|
const source = await this.sharedLiveSourceFor(sn, opts, true);
|
|
21212
|
-
return
|
|
21532
|
+
return attachUnlessAborted(source, opts?.signal);
|
|
21213
21533
|
},
|
|
21214
21534
|
openReadable: async (opts) => {
|
|
21215
21535
|
const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
|
|
21216
|
-
return openReadableFromConsumer(
|
|
21536
|
+
return openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts);
|
|
21217
21537
|
},
|
|
21218
21538
|
recordFragments: (opts) => this.recordFragments(sn, opts),
|
|
21219
21539
|
talkback: (opts) => this.openTalkback(sn, opts),
|
|
@@ -21303,6 +21623,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21303
21623
|
* stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
|
|
21304
21624
|
* instead of playing into silence.
|
|
21305
21625
|
*
|
|
21626
|
+
* **On a model in {@link ELD_TALKBACK_MODELS}, the speaker plays the codec the camera sends.** So the first
|
|
21627
|
+
* audio frame of the media session this talkback holds decides its codec ({@link speakerCodec}), within
|
|
21628
|
+
* {@link TALKBACK_CODEC_WAIT_MS} of the session's first frame, and the claim is re-checked after that wait as
|
|
21629
|
+
* after the warm-up. Every other model opens `aac-lc` at once.
|
|
21630
|
+
*
|
|
21306
21631
|
* The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
|
|
21307
21632
|
* {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
|
|
21308
21633
|
* own-session camera that legitimately never negotiates one.
|
|
@@ -21372,7 +21697,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21372
21697
|
consumer.on("stop", () => {
|
|
21373
21698
|
void talk.stop().catch((e) => logger.warn?.(`talkback: stop for ${sn} failed: ${String(e)}`));
|
|
21374
21699
|
});
|
|
21375
|
-
|
|
21700
|
+
const codec = ELD_TALKBACK_MODELS.has(this.recordFor(sn)?.model ?? "") ? await speakerCodec(consumer, talk, TALKBACK_CODEC_WAIT_MS) : "aac-lc";
|
|
21701
|
+
if (this.talkbacks.get(key) !== talk) {
|
|
21702
|
+
throw new Error(`talkback: ${sn} was closed while its speaker codec was being read`);
|
|
21703
|
+
}
|
|
21704
|
+
return talk.start(codec);
|
|
21376
21705
|
} catch (e) {
|
|
21377
21706
|
if (this.talkbacks.get(key) === talk)
|
|
21378
21707
|
this.talkbacks.delete(key);
|
|
@@ -21572,22 +21901,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21572
21901
|
* A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
|
|
21573
21902
|
* unaffected.
|
|
21574
21903
|
*/
|
|
21575
|
-
/**
|
|
21576
|
-
* Attach a consumer, unless the caller has already abandoned the call.
|
|
21577
|
-
*
|
|
21578
|
-
* The acquisition it just waited through can outlast the caller's interest, and a consumer attached for
|
|
21579
|
-
* somebody who has gone keeps the pull warm for nobody. Detaching immediately gives the pull back, which
|
|
21580
|
-
* lets it linger and fall away if this was the only thing holding it, and leaves it untouched if it was
|
|
21581
|
-
* not.
|
|
21582
|
-
*/
|
|
21583
|
-
attachUnlessAborted(source, signal) {
|
|
21584
|
-
const consumer = source.attach();
|
|
21585
|
-
if (signal?.aborted) {
|
|
21586
|
-
consumer.detach();
|
|
21587
|
-
signal.throwIfAborted();
|
|
21588
|
-
}
|
|
21589
|
-
return consumer;
|
|
21590
|
-
}
|
|
21591
21904
|
/**
|
|
21592
21905
|
* Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
|
|
21593
21906
|
* on a session goes with it, and a media session opened for this source alone is closed: nothing else
|
|
@@ -22085,7 +22398,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22085
22398
|
* **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
|
|
22086
22399
|
* exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
|
|
22087
22400
|
* capture of the app's own SET_SNOOZE_TIME (1271) frame — see `param-dictionary.ts`'s `1271` entry
|
|
22088
|
-
* (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.
|
|
22401
|
+
* (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.SET_ALL_ACTION`) reuses
|
|
22089
22402
|
* the same bare-JSON shape.
|
|
22090
22403
|
*/
|
|
22091
22404
|
async sendJsonRaw(sn, outerCmd, data, channel) {
|
|
@@ -22357,110 +22670,1642 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22357
22670
|
}
|
|
22358
22671
|
};
|
|
22359
22672
|
|
|
22360
|
-
// dist/transport/
|
|
22361
|
-
import {
|
|
22673
|
+
// dist/transport/rtc/live.js
|
|
22674
|
+
import { EventEmitter as EventEmitter7 } from "node:events";
|
|
22362
22675
|
|
|
22363
|
-
// dist/transport/
|
|
22364
|
-
|
|
22365
|
-
|
|
22676
|
+
// dist/transport/rtc/portal-packet.js
|
|
22677
|
+
var MAGIC2 = Buffer.from("XZYH", "ascii");
|
|
22678
|
+
var PORTAL_HEADER_LENGTH = 16;
|
|
22679
|
+
var PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
22680
|
+
var PORTAL_STATION_CHANNEL = 255;
|
|
22681
|
+
var PortalLinkType = {
|
|
22682
|
+
/** Command channel: requests out, acknowledgements back. */
|
|
22683
|
+
COMMAND: 1,
|
|
22684
|
+
/** Station-originated frames: pushes, notify payloads, control results. */
|
|
22685
|
+
NOTIFY: 3,
|
|
22686
|
+
/** Live video. */
|
|
22687
|
+
LIVE: 5
|
|
22688
|
+
};
|
|
22689
|
+
function encodeLe(value, width) {
|
|
22690
|
+
const buf = Buffer.alloc(width);
|
|
22691
|
+
let v = value >>> 0;
|
|
22692
|
+
for (let i = 0; i < width; i++) {
|
|
22693
|
+
buf[i] = v & 255;
|
|
22694
|
+
v >>>= 8;
|
|
22695
|
+
}
|
|
22696
|
+
return buf;
|
|
22697
|
+
}
|
|
22698
|
+
function buildPortalHeader(commandId, paramLength, channel, segment, isResponse = 0, devType = 2) {
|
|
22699
|
+
const header = Buffer.alloc(PORTAL_HEADER_LENGTH);
|
|
22700
|
+
MAGIC2.copy(header, 0);
|
|
22701
|
+
encodeLe(commandId, 2).copy(header, 4);
|
|
22702
|
+
encodeLe(paramLength, 4).copy(header, 6);
|
|
22703
|
+
header[10] = 0;
|
|
22704
|
+
header[11] = segment & 255;
|
|
22705
|
+
header[12] = channel & 255;
|
|
22706
|
+
header[13] = 0;
|
|
22707
|
+
header[14] = isResponse & 255;
|
|
22708
|
+
header[15] = devType & 255;
|
|
22709
|
+
return header;
|
|
22710
|
+
}
|
|
22711
|
+
function parsePortalHeader(buf) {
|
|
22712
|
+
if (buf.length < PORTAL_HEADER_LENGTH || buf.subarray(0, 4).compare(MAGIC2) !== 0)
|
|
22713
|
+
return void 0;
|
|
22714
|
+
return {
|
|
22715
|
+
commandId: buf.readUInt16LE(4),
|
|
22716
|
+
paramLength: buf.readUInt32LE(6),
|
|
22717
|
+
segment: buf[11],
|
|
22718
|
+
channel: buf[12],
|
|
22719
|
+
isResponse: buf[14],
|
|
22720
|
+
devType: buf[15]
|
|
22721
|
+
};
|
|
22722
|
+
}
|
|
22723
|
+
function isPortalPacket(buf) {
|
|
22724
|
+
return buf.length >= PORTAL_HEADER_LENGTH && buf.subarray(0, 4).compare(MAGIC2) === 0;
|
|
22725
|
+
}
|
|
22726
|
+
function buildPortalPacket(req) {
|
|
22727
|
+
const body = Buffer.from(JSON.stringify(req.payload), "utf8");
|
|
22728
|
+
const header = buildPortalHeader(req.commandId, body.length, req.channel, req.segment, req.isResponse ?? 0, req.devType ?? 2);
|
|
22729
|
+
return Buffer.concat([header, body]);
|
|
22730
|
+
}
|
|
22731
|
+
function parseJsonBody(buf) {
|
|
22732
|
+
let text2 = buf.toString("utf8");
|
|
22733
|
+
while (text2.endsWith("\0"))
|
|
22734
|
+
text2 = text2.slice(0, -1);
|
|
22366
22735
|
try {
|
|
22367
|
-
return
|
|
22736
|
+
return JSON.parse(text2);
|
|
22368
22737
|
} catch {
|
|
22369
|
-
return
|
|
22738
|
+
return text2;
|
|
22370
22739
|
}
|
|
22371
22740
|
}
|
|
22372
|
-
function
|
|
22373
|
-
|
|
22374
|
-
|
|
22375
|
-
|
|
22376
|
-
|
|
22377
|
-
|
|
22378
|
-
|
|
22379
|
-
|
|
22380
|
-
|
|
22381
|
-
|
|
22382
|
-
|
|
22383
|
-
|
|
22384
|
-
|
|
22385
|
-
|
|
22386
|
-
|
|
22387
|
-
|
|
22388
|
-
clearTimeout(timer);
|
|
22389
|
-
try {
|
|
22390
|
-
client.end(true);
|
|
22391
|
-
} catch {
|
|
22392
|
-
}
|
|
22393
|
-
resolvePromise({ ip, ms: Date.now() - start, ...r });
|
|
22741
|
+
function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
|
|
22742
|
+
const header = parsePortalHeader(buf);
|
|
22743
|
+
if (!header)
|
|
22744
|
+
return void 0;
|
|
22745
|
+
const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
|
|
22746
|
+
if (linkType === PortalLinkType.NOTIFY) {
|
|
22747
|
+
const data = parseJsonBody(body);
|
|
22748
|
+
const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
|
|
22749
|
+
return {
|
|
22750
|
+
commandId: header.commandId,
|
|
22751
|
+
channel: header.channel,
|
|
22752
|
+
segment: header.segment,
|
|
22753
|
+
isResponse: header.isResponse,
|
|
22754
|
+
linkType,
|
|
22755
|
+
cmd,
|
|
22756
|
+
data
|
|
22394
22757
|
};
|
|
22395
|
-
const client = mqtt.connect({
|
|
22396
|
-
host: ip,
|
|
22397
|
-
port: creds.port ?? 8883,
|
|
22398
|
-
protocol: "mqtts",
|
|
22399
|
-
...bareIpTlsOptions({
|
|
22400
|
-
hostname: creds.hostname,
|
|
22401
|
-
cert: creds.certificate_pem,
|
|
22402
|
-
key: creds.private_key,
|
|
22403
|
-
ca: creds.aws_root_ca1_pem
|
|
22404
|
-
}),
|
|
22405
|
-
clientId: opts.clientId,
|
|
22406
|
-
protocolVersion: 4,
|
|
22407
|
-
keepalive: 60,
|
|
22408
|
-
clean: true,
|
|
22409
|
-
reconnectPeriod: 0,
|
|
22410
|
-
connectTimeout: timeoutMs
|
|
22411
|
-
});
|
|
22412
|
-
const timer = setTimeout(() => finish({ granted: false, error: "timeout" }), timeoutMs);
|
|
22413
|
-
client.on("connect", () => {
|
|
22414
|
-
client.subscribe(opts.topic, { qos: 1 }, (err, granted) => {
|
|
22415
|
-
if (err)
|
|
22416
|
-
return finish({ granted: false, error: err.message });
|
|
22417
|
-
const g = granted?.[0];
|
|
22418
|
-
const denied = !g || g.qos === 128;
|
|
22419
|
-
finish({ granted: !denied, grantedQos: g?.qos });
|
|
22420
|
-
});
|
|
22421
|
-
});
|
|
22422
|
-
client.on("error", (err) => finish({ granted: false, error: err.message }));
|
|
22423
|
-
});
|
|
22424
|
-
}
|
|
22425
|
-
async function discoverReachableInstance(creds, opts) {
|
|
22426
|
-
const dnsIps = await resolveBrokerIps(creds.hostname);
|
|
22427
|
-
const all = mergeCandidateIps(dnsIps, opts.candidateIps);
|
|
22428
|
-
const results = [];
|
|
22429
|
-
for (const ip of all) {
|
|
22430
|
-
const r = await probeBrokerInstance(ip, creds, {
|
|
22431
|
-
clientId: opts.clientIdFor(ip),
|
|
22432
|
-
topic: opts.topic,
|
|
22433
|
-
timeoutMs: opts.perAttemptTimeoutMs
|
|
22434
|
-
});
|
|
22435
|
-
results.push(r);
|
|
22436
|
-
if (r.granted && opts.stopOnFirstGrant)
|
|
22437
|
-
break;
|
|
22438
22758
|
}
|
|
22439
|
-
return
|
|
22759
|
+
return {
|
|
22760
|
+
commandId: header.commandId,
|
|
22761
|
+
channel: header.channel,
|
|
22762
|
+
segment: header.segment,
|
|
22763
|
+
isResponse: header.isResponse,
|
|
22764
|
+
linkType,
|
|
22765
|
+
errCode: body.length >= 4 ? body.readInt32LE(0) : -1,
|
|
22766
|
+
data: body.length > 4 ? parseJsonBody(body.subarray(4)) : void 0
|
|
22767
|
+
};
|
|
22440
22768
|
}
|
|
22441
|
-
|
|
22442
|
-
|
|
22443
|
-
|
|
22444
|
-
|
|
22445
|
-
|
|
22446
|
-
|
|
22447
|
-
|
|
22448
|
-
|
|
22769
|
+
var SegmentCounter = class {
|
|
22770
|
+
current = 0;
|
|
22771
|
+
next() {
|
|
22772
|
+
this.current = this.current + 1 & 255;
|
|
22773
|
+
if (this.current === 0)
|
|
22774
|
+
this.current = 1;
|
|
22775
|
+
return this.current;
|
|
22776
|
+
}
|
|
22449
22777
|
};
|
|
22450
|
-
|
|
22451
|
-
|
|
22452
|
-
var
|
|
22453
|
-
var
|
|
22454
|
-
var
|
|
22455
|
-
var
|
|
22456
|
-
|
|
22457
|
-
|
|
22458
|
-
|
|
22459
|
-
|
|
22460
|
-
|
|
22461
|
-
|
|
22462
|
-
|
|
22463
|
-
|
|
22778
|
+
|
|
22779
|
+
// dist/transport/rtc/live.js
|
|
22780
|
+
var LIVE_START = 1003;
|
|
22781
|
+
var LIVE_STOP = 1004;
|
|
22782
|
+
var LIVE_MEDIA = 1300;
|
|
22783
|
+
var LIVE_MEDIA_CHANNEL = 101;
|
|
22784
|
+
var LIVE_STREAM_ID = 1;
|
|
22785
|
+
var KEEPALIVE_MS = 29e3;
|
|
22786
|
+
var KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
|
|
22787
|
+
var MEDIA_HEADER_LENGTH = 22;
|
|
22788
|
+
var STREAM_TYPE_HEVC = 1;
|
|
22789
|
+
function parseMediaBody(body) {
|
|
22790
|
+
if (body.length < MEDIA_HEADER_LENGTH || body[5] !== STREAM_TYPE_HEVC)
|
|
22791
|
+
return void 0;
|
|
22792
|
+
const length = body.readUInt32LE(0);
|
|
22793
|
+
if (body.length < MEDIA_HEADER_LENGTH + length)
|
|
22794
|
+
return void 0;
|
|
22795
|
+
return {
|
|
22796
|
+
data: body.subarray(MEDIA_HEADER_LENGTH, MEDIA_HEADER_LENGTH + length),
|
|
22797
|
+
width: body.readUInt16LE(10),
|
|
22798
|
+
height: body.readUInt16LE(12)
|
|
22799
|
+
};
|
|
22800
|
+
}
|
|
22801
|
+
var RtcLiveStream = class extends EventEmitter7 {
|
|
22802
|
+
opts;
|
|
22803
|
+
running = false;
|
|
22804
|
+
startSegment = -1;
|
|
22805
|
+
keepalive;
|
|
22806
|
+
width = 0;
|
|
22807
|
+
height = 0;
|
|
22808
|
+
onMedia = (frame) => this.onMediaFrame(frame);
|
|
22809
|
+
onCommand = (frame, linkType) => this.onCommandFrame(frame, linkType);
|
|
22810
|
+
onClose = () => this.fail(new Error(`rtc live ${this.tag}: session closed`));
|
|
22811
|
+
constructor(opts) {
|
|
22812
|
+
super();
|
|
22813
|
+
this.opts = opts;
|
|
22814
|
+
}
|
|
22815
|
+
get tag() {
|
|
22816
|
+
return `${this.opts.stationSn}#${this.opts.channel}`;
|
|
22817
|
+
}
|
|
22818
|
+
start() {
|
|
22819
|
+
if (this.running)
|
|
22820
|
+
return this;
|
|
22821
|
+
const { session, seg, accountId } = this.opts;
|
|
22822
|
+
this.running = true;
|
|
22823
|
+
session.on("mediaData", this.onMedia);
|
|
22824
|
+
session.on("commandData", this.onCommand);
|
|
22825
|
+
session.on("close", this.onClose);
|
|
22826
|
+
this.startSegment = seg.next();
|
|
22827
|
+
const sent = session.sendCommand(buildPortalPacket({
|
|
22828
|
+
commandId: PORTAL_CMD_SET_PAYLOAD,
|
|
22829
|
+
channel: PORTAL_STATION_CHANNEL,
|
|
22830
|
+
segment: this.startSegment,
|
|
22831
|
+
isResponse: LIVE_STREAM_ID,
|
|
22832
|
+
payload: {
|
|
22833
|
+
account_id: accountId,
|
|
22834
|
+
cmd: LIVE_START,
|
|
22835
|
+
payload: {
|
|
22836
|
+
ClientOS: "WEB",
|
|
22837
|
+
entrytype: 0,
|
|
22838
|
+
camera_type: 0,
|
|
22839
|
+
key: "",
|
|
22840
|
+
msg_id: 116,
|
|
22841
|
+
audio_chn: -1,
|
|
22842
|
+
streamtype: 2,
|
|
22843
|
+
stitch_mode: 1,
|
|
22844
|
+
chn_list: [{ index: 0, chn: this.opts.channel, sensor: 0, isUps: 0, isClicked: true }],
|
|
22845
|
+
pip_cord: { x1: 0, y1: 0, x2: 0, y2: 0 },
|
|
22846
|
+
station_video_type: 6,
|
|
22847
|
+
play_id: 1
|
|
22848
|
+
}
|
|
22849
|
+
}
|
|
22850
|
+
}));
|
|
22851
|
+
if (!sent) {
|
|
22852
|
+
queueMicrotask(() => this.fail(new Error(`rtc live ${this.tag}: command channel not open`)));
|
|
22853
|
+
return this;
|
|
22854
|
+
}
|
|
22855
|
+
this.keepalive = setInterval(() => {
|
|
22856
|
+
if (!session.sendRaw(KEEPALIVE))
|
|
22857
|
+
this.opts.logger?.warn?.(`[rtc] ${this.tag} keepalive not sent`);
|
|
22858
|
+
}, KEEPALIVE_MS);
|
|
22859
|
+
this.keepalive.unref?.();
|
|
22860
|
+
this.opts.logger?.debug?.(`[rtc] ${this.tag} live start sent`);
|
|
22861
|
+
return this;
|
|
22862
|
+
}
|
|
22863
|
+
stop() {
|
|
22864
|
+
if (!this.running)
|
|
22865
|
+
return;
|
|
22866
|
+
this.teardown();
|
|
22867
|
+
const { session, seg, accountId } = this.opts;
|
|
22868
|
+
session.sendCommand(buildPortalPacket({
|
|
22869
|
+
commandId: PORTAL_CMD_SET_PAYLOAD,
|
|
22870
|
+
channel: PORTAL_STATION_CHANNEL,
|
|
22871
|
+
segment: seg.next(),
|
|
22872
|
+
isResponse: LIVE_STREAM_ID,
|
|
22873
|
+
payload: { account_id: accountId, cmd: LIVE_STOP, payload: {} }
|
|
22874
|
+
}));
|
|
22875
|
+
this.emit("stop");
|
|
22876
|
+
}
|
|
22877
|
+
teardown() {
|
|
22878
|
+
this.running = false;
|
|
22879
|
+
if (this.keepalive)
|
|
22880
|
+
clearInterval(this.keepalive);
|
|
22881
|
+
this.keepalive = void 0;
|
|
22882
|
+
const { session } = this.opts;
|
|
22883
|
+
session.off("mediaData", this.onMedia);
|
|
22884
|
+
session.off("commandData", this.onCommand);
|
|
22885
|
+
session.off("close", this.onClose);
|
|
22886
|
+
}
|
|
22887
|
+
fail(err) {
|
|
22888
|
+
if (!this.running)
|
|
22889
|
+
return;
|
|
22890
|
+
this.teardown();
|
|
22891
|
+
this.emit("error", err);
|
|
22892
|
+
}
|
|
22893
|
+
/** The start's ACK: a non-zero result code ends the pull. */
|
|
22894
|
+
onCommandFrame(frame, linkType) {
|
|
22895
|
+
const p = parsePortalPacket(frame, linkType);
|
|
22896
|
+
if (!p?.isResponse || p.segment !== this.startSegment || p.commandId !== PORTAL_CMD_SET_PAYLOAD)
|
|
22897
|
+
return;
|
|
22898
|
+
if (p.errCode !== 0)
|
|
22899
|
+
this.fail(new Error(`rtc live ${this.tag}: start refused (err ${p.errCode})`));
|
|
22900
|
+
}
|
|
22901
|
+
onMediaFrame(frame) {
|
|
22902
|
+
const h = parsePortalHeader(frame);
|
|
22903
|
+
if (!h || h.commandId !== LIVE_MEDIA || h.channel !== LIVE_MEDIA_CHANNEL)
|
|
22904
|
+
return;
|
|
22905
|
+
if (h.paramLength > frame.length - PORTAL_HEADER_LENGTH)
|
|
22906
|
+
return;
|
|
22907
|
+
const media = parseMediaBody(frame.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + h.paramLength));
|
|
22908
|
+
if (!media)
|
|
22909
|
+
return;
|
|
22910
|
+
const { data, width, height } = media;
|
|
22911
|
+
if (width && height) {
|
|
22912
|
+
this.width = width;
|
|
22913
|
+
this.height = height;
|
|
22914
|
+
}
|
|
22915
|
+
const out = {
|
|
22916
|
+
codec: "h265",
|
|
22917
|
+
data,
|
|
22918
|
+
keyframe: hasIdr(data, "h265"),
|
|
22919
|
+
width: this.width,
|
|
22920
|
+
height: this.height
|
|
22921
|
+
};
|
|
22922
|
+
this.emit("video", out);
|
|
22923
|
+
}
|
|
22924
|
+
};
|
|
22925
|
+
|
|
22926
|
+
// dist/transport/rtc/session.js
|
|
22927
|
+
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
22928
|
+
|
|
22929
|
+
// dist/transport/rtc/peer.js
|
|
22930
|
+
import { EventEmitter as EventEmitter8 } from "node:events";
|
|
22931
|
+
|
|
22932
|
+
// dist/transport/rtc/ptcs-framer.js
|
|
22933
|
+
var MAGIC3 = Buffer.from("PTCS", "ascii");
|
|
22934
|
+
var PTCS_HEADER_LENGTH = 28;
|
|
22935
|
+
var PTCS_DEFAULT_PAYLOAD_BYTES = 1e3;
|
|
22936
|
+
var FLAG_BASE = 16384;
|
|
22937
|
+
var FLAG_LAST = 1024;
|
|
22938
|
+
var LENGTH_MASK = 1023;
|
|
22939
|
+
var PtcsChannel = {
|
|
22940
|
+
COMMAND: 0,
|
|
22941
|
+
NOTIFY: 2,
|
|
22942
|
+
LIVE: 5
|
|
22943
|
+
};
|
|
22944
|
+
function linkTypeForChannel(channel) {
|
|
22945
|
+
if (channel === PtcsChannel.NOTIFY)
|
|
22946
|
+
return PortalLinkType.NOTIFY;
|
|
22947
|
+
if (channel === PtcsChannel.LIVE)
|
|
22948
|
+
return PortalLinkType.LIVE;
|
|
22949
|
+
return PortalLinkType.COMMAND;
|
|
22950
|
+
}
|
|
22951
|
+
var PTCS_STALE_MS = 15e3;
|
|
22952
|
+
function parsePtcsHeader(packet) {
|
|
22953
|
+
if (packet.length < PTCS_HEADER_LENGTH || packet.subarray(0, 4).compare(MAGIC3) !== 0)
|
|
22954
|
+
return void 0;
|
|
22955
|
+
const flags = packet.readUInt16LE(18);
|
|
22956
|
+
return {
|
|
22957
|
+
channel: packet[5],
|
|
22958
|
+
sequence: packet.readUInt16LE(6),
|
|
22959
|
+
frameId: packet.readUInt32LE(8),
|
|
22960
|
+
frameLength: packet.readUInt32LE(12),
|
|
22961
|
+
index: packet.readUInt16LE(16),
|
|
22962
|
+
last: (flags & FLAG_LAST) !== 0,
|
|
22963
|
+
payloadLength: flags & LENGTH_MASK
|
|
22964
|
+
};
|
|
22965
|
+
}
|
|
22966
|
+
function packetize(frame, opts) {
|
|
22967
|
+
const size = opts.payloadBytes ?? PTCS_DEFAULT_PAYLOAD_BYTES;
|
|
22968
|
+
if (size <= 0 || size > LENGTH_MASK)
|
|
22969
|
+
throw new RangeError(`PTCS payload size ${size} out of range`);
|
|
22970
|
+
const channel = opts.channel ?? PtcsChannel.COMMAND;
|
|
22971
|
+
const count2 = Math.max(1, Math.ceil(frame.length / size));
|
|
22972
|
+
const sequence = (opts.sequence ?? 0) & 65535;
|
|
22973
|
+
const packets = [];
|
|
22974
|
+
for (let i = 0; i < count2; i++) {
|
|
22975
|
+
const chunk = frame.subarray(i * size, Math.min(frame.length, (i + 1) * size));
|
|
22976
|
+
const last = i === count2 - 1;
|
|
22977
|
+
const packet = Buffer.alloc(PTCS_HEADER_LENGTH + size);
|
|
22978
|
+
MAGIC3.copy(packet, 0);
|
|
22979
|
+
packet[4] = 3;
|
|
22980
|
+
packet[5] = channel & 255;
|
|
22981
|
+
packet.writeUInt16LE(sequence, 6);
|
|
22982
|
+
packet.writeUInt32LE(opts.frameId >>> 0, 8);
|
|
22983
|
+
packet.writeUInt32LE(frame.length >>> 0, 12);
|
|
22984
|
+
packet.writeUInt16LE(i, 16);
|
|
22985
|
+
packet.writeUInt16LE(FLAG_BASE | (last ? FLAG_LAST : 0) | chunk.length, 18);
|
|
22986
|
+
chunk.copy(packet, PTCS_HEADER_LENGTH);
|
|
22987
|
+
packets.push(packet);
|
|
22988
|
+
}
|
|
22989
|
+
return packets;
|
|
22990
|
+
}
|
|
22991
|
+
var PtcsReassembler = class {
|
|
22992
|
+
onFrame;
|
|
22993
|
+
now;
|
|
22994
|
+
partials = /* @__PURE__ */ new Map();
|
|
22995
|
+
constructor(onFrame, now2 = Date.now) {
|
|
22996
|
+
this.onFrame = onFrame;
|
|
22997
|
+
this.now = now2;
|
|
22998
|
+
}
|
|
22999
|
+
push(packet) {
|
|
23000
|
+
const h = parsePtcsHeader(packet);
|
|
23001
|
+
if (!h)
|
|
23002
|
+
return false;
|
|
23003
|
+
const body = packet.subarray(PTCS_HEADER_LENGTH, PTCS_HEADER_LENGTH + h.payloadLength);
|
|
23004
|
+
if (body.length !== h.payloadLength)
|
|
23005
|
+
return false;
|
|
23006
|
+
const key = `${h.channel}:${h.frameId}`;
|
|
23007
|
+
const now2 = this.now();
|
|
23008
|
+
let p = this.partials.get(key);
|
|
23009
|
+
if (!p) {
|
|
23010
|
+
p = { channel: h.channel, frameLength: h.frameLength, chunks: /* @__PURE__ */ new Map(), touched: now2 };
|
|
23011
|
+
this.partials.set(key, p);
|
|
23012
|
+
}
|
|
23013
|
+
p.touched = now2;
|
|
23014
|
+
p.chunks.set(h.index, Buffer.from(body));
|
|
23015
|
+
if (h.last)
|
|
23016
|
+
p.lastIndex = h.index;
|
|
23017
|
+
if (p.lastIndex === void 0)
|
|
23018
|
+
return true;
|
|
23019
|
+
let total = 0;
|
|
23020
|
+
const parts = [];
|
|
23021
|
+
for (let i = 0; i <= p.lastIndex; i++) {
|
|
23022
|
+
const c = p.chunks.get(i);
|
|
23023
|
+
if (!c)
|
|
23024
|
+
return true;
|
|
23025
|
+
parts.push(c);
|
|
23026
|
+
total += c.length;
|
|
23027
|
+
}
|
|
23028
|
+
this.partials.delete(key);
|
|
23029
|
+
if (total !== p.frameLength)
|
|
23030
|
+
return false;
|
|
23031
|
+
this.onFrame(Buffer.concat(parts, total), p.channel);
|
|
23032
|
+
return true;
|
|
23033
|
+
}
|
|
23034
|
+
/** Drop frames that stopped arriving; call periodically. */
|
|
23035
|
+
expire() {
|
|
23036
|
+
const now2 = this.now();
|
|
23037
|
+
for (const [key, p] of this.partials) {
|
|
23038
|
+
if (now2 - p.touched > PTCS_STALE_MS)
|
|
23039
|
+
this.partials.delete(key);
|
|
23040
|
+
}
|
|
23041
|
+
}
|
|
23042
|
+
};
|
|
23043
|
+
function frameIdClock(now2 = Date.now) {
|
|
23044
|
+
let last = 0;
|
|
23045
|
+
return () => {
|
|
23046
|
+
let id = now2() >>> 0;
|
|
23047
|
+
if (id <= last)
|
|
23048
|
+
id = last + 1 >>> 0;
|
|
23049
|
+
last = id;
|
|
23050
|
+
return id;
|
|
23051
|
+
};
|
|
23052
|
+
}
|
|
23053
|
+
var PtcsFramer = class {
|
|
23054
|
+
onWire;
|
|
23055
|
+
onFrame;
|
|
23056
|
+
reassembler;
|
|
23057
|
+
sweep;
|
|
23058
|
+
ready = false;
|
|
23059
|
+
nextFrameId = frameIdClock();
|
|
23060
|
+
sequence = 0;
|
|
23061
|
+
/** Arm the two callbacks; `sendFrame` may be called from here on. */
|
|
23062
|
+
init(onWirePacket, onFrame) {
|
|
23063
|
+
this.onWire = onWirePacket;
|
|
23064
|
+
this.onFrame = onFrame;
|
|
23065
|
+
this.reassembler = new PtcsReassembler((frame, channel) => this.onFrame?.(frame, linkTypeForChannel(channel)));
|
|
23066
|
+
this.sweep = setInterval(() => this.reassembler?.expire(), 1e3);
|
|
23067
|
+
this.sweep.unref?.();
|
|
23068
|
+
this.ready = true;
|
|
23069
|
+
}
|
|
23070
|
+
isReady() {
|
|
23071
|
+
return this.ready;
|
|
23072
|
+
}
|
|
23073
|
+
sendFrame(portalPacket) {
|
|
23074
|
+
if (!this.ready)
|
|
23075
|
+
throw new Error("PTCS framer not initialised");
|
|
23076
|
+
const packets = packetize(portalPacket, { frameId: this.nextFrameId(), sequence: this.sequence });
|
|
23077
|
+
this.sequence = this.sequence + 1 & 65535;
|
|
23078
|
+
for (const packet of packets)
|
|
23079
|
+
this.onWire?.(packet);
|
|
23080
|
+
}
|
|
23081
|
+
recvPacket(wirePacket) {
|
|
23082
|
+
if (!this.ready)
|
|
23083
|
+
return;
|
|
23084
|
+
if (isPortalPacket(wirePacket)) {
|
|
23085
|
+
this.onFrame?.(wirePacket, PortalLinkType.COMMAND);
|
|
23086
|
+
return;
|
|
23087
|
+
}
|
|
23088
|
+
this.reassembler?.push(wirePacket);
|
|
23089
|
+
}
|
|
23090
|
+
destroy() {
|
|
23091
|
+
this.ready = false;
|
|
23092
|
+
if (this.sweep)
|
|
23093
|
+
clearInterval(this.sweep);
|
|
23094
|
+
this.sweep = void 0;
|
|
23095
|
+
this.reassembler = void 0;
|
|
23096
|
+
this.onWire = void 0;
|
|
23097
|
+
this.onFrame = void 0;
|
|
23098
|
+
}
|
|
23099
|
+
};
|
|
23100
|
+
|
|
23101
|
+
// dist/transport/rtc/scall-sdp.js
|
|
23102
|
+
var HUB_SDP_MID = "2";
|
|
23103
|
+
var ANKER_MAX_MESSAGE_SIZE = 262144;
|
|
23104
|
+
function scallJsonToSdp(json, now2 = Date.now) {
|
|
23105
|
+
let sdp = "";
|
|
23106
|
+
sdp += "v=0\r\n";
|
|
23107
|
+
sdp += `o=- ${Math.floor(now2())} 1 IN IP4 127.0.0.1\r
|
|
23108
|
+
`;
|
|
23109
|
+
sdp += "s=Anker Webrtc Stream\r\n";
|
|
23110
|
+
sdp += "t=0 0\r\n";
|
|
23111
|
+
sdp += `a=group:BUNDLE ${HUB_SDP_MID}\r
|
|
23112
|
+
`;
|
|
23113
|
+
sdp += "a=msid-semantic: WMS\r\n";
|
|
23114
|
+
sdp += "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n";
|
|
23115
|
+
sdp += "c=IN IP4 127.0.0.1\r\n";
|
|
23116
|
+
sdp += `a=mid:${HUB_SDP_MID}\r
|
|
23117
|
+
`;
|
|
23118
|
+
sdp += "a=ice-options:trickle\r\n";
|
|
23119
|
+
if (json.ice?.ufrag)
|
|
23120
|
+
sdp += `a=ice-ufrag:${json.ice.ufrag}\r
|
|
23121
|
+
`;
|
|
23122
|
+
if (json.ice?.pwd)
|
|
23123
|
+
sdp += `a=ice-pwd:${json.ice.pwd}\r
|
|
23124
|
+
`;
|
|
23125
|
+
if (json.ice?.fingerprint) {
|
|
23126
|
+
const fp = json.ice.fingerprint.replace(/(.{2})(?=.)/g, "$1:");
|
|
23127
|
+
sdp += `a=fingerprint:${json.ice.fingerprint_type ?? "sha-256"} ${fp}\r
|
|
23128
|
+
`;
|
|
23129
|
+
}
|
|
23130
|
+
sdp += `a=setup:${json.setup ?? "actpass"}\r
|
|
23131
|
+
`;
|
|
23132
|
+
sdp += "a=sctp-port:5000\r\n";
|
|
23133
|
+
sdp += `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}\r
|
|
23134
|
+
`;
|
|
23135
|
+
for (const c of json.candidate ?? [])
|
|
23136
|
+
sdp += `a=candidate:${c}\r
|
|
23137
|
+
`;
|
|
23138
|
+
return sdp;
|
|
23139
|
+
}
|
|
23140
|
+
function sdpToScallJson(sdp) {
|
|
23141
|
+
const json = { ice: { ufrag: "", pwd: "", fingerprint: "" } };
|
|
23142
|
+
const setup = sdp.match(/a=setup:([^\r\n]+)/)?.[1];
|
|
23143
|
+
if (setup && setup !== "actpass")
|
|
23144
|
+
json.setup = setup;
|
|
23145
|
+
const ufrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)?.[1];
|
|
23146
|
+
if (ufrag)
|
|
23147
|
+
json.ice.ufrag = ufrag;
|
|
23148
|
+
const pwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)?.[1];
|
|
23149
|
+
if (pwd)
|
|
23150
|
+
json.ice.pwd = pwd;
|
|
23151
|
+
const fp = sdp.match(/a=fingerprint:([^\s]+)\s+([^\r\n]+)/);
|
|
23152
|
+
if (fp?.[2]) {
|
|
23153
|
+
json.ice.fingerprint_type = fp[1];
|
|
23154
|
+
json.ice.fingerprint = fp[2].replace(/:/g, "");
|
|
23155
|
+
}
|
|
23156
|
+
const candidates = [];
|
|
23157
|
+
for (const line of sdp.split(/\r\n|\r|\n/)) {
|
|
23158
|
+
const m = line.match(/^a=candidate:(.+)/);
|
|
23159
|
+
if (m?.[1])
|
|
23160
|
+
candidates.push(m[1]);
|
|
23161
|
+
}
|
|
23162
|
+
if (candidates.length)
|
|
23163
|
+
json.candidate = candidates;
|
|
23164
|
+
return json;
|
|
23165
|
+
}
|
|
23166
|
+
function iceCandidateType(candidate) {
|
|
23167
|
+
return candidate.match(/typ (\w+)/)?.[1] ?? "unknown";
|
|
23168
|
+
}
|
|
23169
|
+
function pinMaxMessageSize(sdp) {
|
|
23170
|
+
return sdp.replace(/a=max-message-size:\d+/g, `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}`);
|
|
23171
|
+
}
|
|
23172
|
+
function forceDtlsRole(sdp, role) {
|
|
23173
|
+
if (/a=setup:(active|passive|actpass)/.test(sdp))
|
|
23174
|
+
return sdp.replace(/a=setup:(active|passive|actpass)/g, `a=setup:${role}`);
|
|
23175
|
+
return sdp.replace(/(m=application[^\r\n]*\r?\n)/, `$1a=setup:${role}\r
|
|
23176
|
+
`);
|
|
23177
|
+
}
|
|
23178
|
+
function toWireCandidate(candidate) {
|
|
23179
|
+
return candidate.trim().replace(/^a=/, "");
|
|
23180
|
+
}
|
|
23181
|
+
|
|
23182
|
+
// dist/transport/rtc/peer.js
|
|
23183
|
+
var DATA_CHANNEL_LABELS = ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
|
|
23184
|
+
var COMMAND_CHANNEL = DATA_CHANNEL_LABELS[0];
|
|
23185
|
+
var DATA_CHANNEL_IDS = {
|
|
23186
|
+
WebrtcDataChannel: 0,
|
|
23187
|
+
audio: 2,
|
|
23188
|
+
idr: 4,
|
|
23189
|
+
video: 6,
|
|
23190
|
+
notify: 8,
|
|
23191
|
+
download: 10
|
|
23192
|
+
};
|
|
23193
|
+
function turnServers(turn) {
|
|
23194
|
+
const both = (hostname, port) => [
|
|
23195
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnUdp" },
|
|
23196
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnTcp" }
|
|
23197
|
+
];
|
|
23198
|
+
const servers = both(turn.turn_addr, turn.turn_port);
|
|
23199
|
+
if (turn.alt_turn_addr && turn.alt_turn_port)
|
|
23200
|
+
servers.push(...both(turn.alt_turn_addr, turn.alt_turn_port));
|
|
23201
|
+
return servers;
|
|
23202
|
+
}
|
|
23203
|
+
var UNBUILT_ARCHES = /* @__PURE__ */ new Set(["arm", "ia32"]);
|
|
23204
|
+
async function loadNativePeerFactory() {
|
|
23205
|
+
let ndc;
|
|
23206
|
+
try {
|
|
23207
|
+
ndc = await import("node-datachannel");
|
|
23208
|
+
} catch (err) {
|
|
23209
|
+
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) could not load 'node-datachannel'";
|
|
23210
|
+
throw new Error(message, { cause: err });
|
|
23211
|
+
}
|
|
23212
|
+
return (name, config) => new ndc.PeerConnection(name, config);
|
|
23213
|
+
}
|
|
23214
|
+
var ANSWER_TIMEOUT_MS = 15e3;
|
|
23215
|
+
var RtcPeer = class extends EventEmitter8 {
|
|
23216
|
+
opts;
|
|
23217
|
+
pc;
|
|
23218
|
+
channels = /* @__PURE__ */ new Map();
|
|
23219
|
+
framer;
|
|
23220
|
+
wireTally = /* @__PURE__ */ new Map();
|
|
23221
|
+
commandOpen = false;
|
|
23222
|
+
remoteSet = false;
|
|
23223
|
+
remoteMid = HUB_SDP_MID;
|
|
23224
|
+
handlingOffer = false;
|
|
23225
|
+
channelsCreated = false;
|
|
23226
|
+
gatheringDone = false;
|
|
23227
|
+
/** Set by the framer's wire callback when the native channel refused a packet mid-send. */
|
|
23228
|
+
wireSendFailed = false;
|
|
23229
|
+
pending = [];
|
|
23230
|
+
localAnswer;
|
|
23231
|
+
logger;
|
|
23232
|
+
constructor(opts = {}) {
|
|
23233
|
+
super();
|
|
23234
|
+
this.opts = opts;
|
|
23235
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23236
|
+
}
|
|
23237
|
+
/** Create the native peer on the relay the hub granted in `scall 100`. */
|
|
23238
|
+
async init(turn) {
|
|
23239
|
+
if (this.pc)
|
|
23240
|
+
return;
|
|
23241
|
+
const createPeer = this.opts.createPeer ?? await loadNativePeerFactory();
|
|
23242
|
+
const config = {
|
|
23243
|
+
iceServers: turnServers(turn),
|
|
23244
|
+
iceTransportPolicy: this.opts.iceTransportPolicy ?? "relay",
|
|
23245
|
+
maxMessageSize: ANKER_MAX_MESSAGE_SIZE,
|
|
23246
|
+
enableIceTcp: true
|
|
23247
|
+
};
|
|
23248
|
+
const pc = createPeer("eufy-sdk", config);
|
|
23249
|
+
this.pc = pc;
|
|
23250
|
+
pc.onLocalDescription((sdp, type) => {
|
|
23251
|
+
if (String(type).toLowerCase() === "answer" && this.localAnswer) {
|
|
23252
|
+
clearTimeout(this.localAnswer.timer);
|
|
23253
|
+
this.localAnswer.resolve(sdp);
|
|
23254
|
+
this.localAnswer = void 0;
|
|
23255
|
+
}
|
|
23256
|
+
});
|
|
23257
|
+
pc.onLocalCandidate((candidate) => {
|
|
23258
|
+
if (!candidate)
|
|
23259
|
+
return;
|
|
23260
|
+
if (!this.acceptsCandidate(candidate))
|
|
23261
|
+
return;
|
|
23262
|
+
this.emit("iceCandidate", candidate);
|
|
23263
|
+
});
|
|
23264
|
+
pc.onGatheringStateChange((state) => {
|
|
23265
|
+
if (state === "complete" && !this.gatheringDone) {
|
|
23266
|
+
this.gatheringDone = true;
|
|
23267
|
+
this.emit("iceGatheringComplete");
|
|
23268
|
+
}
|
|
23269
|
+
});
|
|
23270
|
+
pc.onStateChange((state) => {
|
|
23271
|
+
this.logger.debug(`[rtc] peer state ${state}`);
|
|
23272
|
+
this.emit("connectionState", state);
|
|
23273
|
+
});
|
|
23274
|
+
pc.onDataChannel((dc) => this.wireChannel(dc.getLabel(), dc));
|
|
23275
|
+
}
|
|
23276
|
+
/**
|
|
23277
|
+
* The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
|
|
23278
|
+
*
|
|
23279
|
+
* The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
|
|
23280
|
+
* `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
|
|
23281
|
+
* only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
|
|
23282
|
+
* a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
|
|
23283
|
+
* The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
|
|
23284
|
+
* is taken as the local description: one read in `have-local-offer` is an offer.
|
|
23285
|
+
*/
|
|
23286
|
+
async handleRemoteOffer(offerSdp) {
|
|
23287
|
+
const pc = this.pc;
|
|
23288
|
+
if (!pc)
|
|
23289
|
+
throw new Error("RTC peer not initialised");
|
|
23290
|
+
if (this.handlingOffer)
|
|
23291
|
+
throw new Error("RTC peer already handling an offer");
|
|
23292
|
+
this.handlingOffer = true;
|
|
23293
|
+
try {
|
|
23294
|
+
const offer = forceDtlsRole(offerSdp, "passive");
|
|
23295
|
+
const application = offer.split(/(?=^m=)/m).find((section) => section.startsWith("m=application "));
|
|
23296
|
+
this.remoteMid = application?.match(/^a=mid:([^\r\n]+)/m)?.[1] ?? HUB_SDP_MID;
|
|
23297
|
+
const answerWait = new Promise((resolve, reject) => {
|
|
23298
|
+
const timer = setTimeout(() => {
|
|
23299
|
+
this.localAnswer = void 0;
|
|
23300
|
+
reject(new Error("timed out waiting for the local SDP answer"));
|
|
23301
|
+
}, ANSWER_TIMEOUT_MS);
|
|
23302
|
+
this.localAnswer = { resolve, reject, timer };
|
|
23303
|
+
});
|
|
23304
|
+
pc.setRemoteDescription(offer, "offer");
|
|
23305
|
+
this.remoteSet = true;
|
|
23306
|
+
this.createChannels();
|
|
23307
|
+
const local = pc.localDescription();
|
|
23308
|
+
let answer = String(local?.type ?? "").toLowerCase() === "answer" ? local?.sdp ?? "" : "";
|
|
23309
|
+
if (!answer)
|
|
23310
|
+
answer = await answerWait;
|
|
23311
|
+
else if (this.localAnswer) {
|
|
23312
|
+
clearTimeout(this.localAnswer.timer);
|
|
23313
|
+
this.localAnswer = void 0;
|
|
23314
|
+
}
|
|
23315
|
+
this.flushPending();
|
|
23316
|
+
return pinMaxMessageSize(answer);
|
|
23317
|
+
} finally {
|
|
23318
|
+
this.handlingOffer = false;
|
|
23319
|
+
}
|
|
23320
|
+
}
|
|
23321
|
+
addRemoteCandidate(candidate) {
|
|
23322
|
+
if (!this.pc)
|
|
23323
|
+
return;
|
|
23324
|
+
if (!this.acceptsCandidate(candidate))
|
|
23325
|
+
return;
|
|
23326
|
+
if (!this.remoteSet || this.handlingOffer) {
|
|
23327
|
+
this.pending.push(candidate);
|
|
23328
|
+
return;
|
|
23329
|
+
}
|
|
23330
|
+
this.addNow(candidate);
|
|
23331
|
+
}
|
|
23332
|
+
/**
|
|
23333
|
+
* Send bytes on the command channel as they are, without PTCS framing; false when it isn't open. The
|
|
23334
|
+
* portal's data-channel keepalive rides the wire unframed.
|
|
23335
|
+
*/
|
|
23336
|
+
sendRaw(bytes2) {
|
|
23337
|
+
const dc = this.channels.get(COMMAND_CHANNEL);
|
|
23338
|
+
if (!dc?.isOpen())
|
|
23339
|
+
return false;
|
|
23340
|
+
return dc.sendMessageBinary(bytes2);
|
|
23341
|
+
}
|
|
23342
|
+
/**
|
|
23343
|
+
* Send one portal packet on the command channel, through the framer. False when the channel is not
|
|
23344
|
+
* usable or the native send refused a wire packet, so a dropped frame does not read as success.
|
|
23345
|
+
*/
|
|
23346
|
+
sendCommand(portalPacket) {
|
|
23347
|
+
const dc = this.channels.get(COMMAND_CHANNEL);
|
|
23348
|
+
if (!dc?.isOpen() || !this.commandOpen || !this.framer?.isReady())
|
|
23349
|
+
return false;
|
|
23350
|
+
this.wireSendFailed = false;
|
|
23351
|
+
try {
|
|
23352
|
+
this.framer.sendFrame(portalPacket);
|
|
23353
|
+
} catch (e) {
|
|
23354
|
+
this.logger.warn(`[rtc] command send failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23355
|
+
return false;
|
|
23356
|
+
}
|
|
23357
|
+
return !this.wireSendFailed;
|
|
23358
|
+
}
|
|
23359
|
+
/** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
|
|
23360
|
+
close() {
|
|
23361
|
+
this.framer?.destroy();
|
|
23362
|
+
this.framer = void 0;
|
|
23363
|
+
if (this.localAnswer) {
|
|
23364
|
+
clearTimeout(this.localAnswer.timer);
|
|
23365
|
+
const pending = this.localAnswer;
|
|
23366
|
+
this.localAnswer = void 0;
|
|
23367
|
+
pending.reject(new Error("RTC peer closed while waiting for the local SDP answer"));
|
|
23368
|
+
}
|
|
23369
|
+
this.commandOpen = false;
|
|
23370
|
+
this.gatheringDone = false;
|
|
23371
|
+
this.pc?.close();
|
|
23372
|
+
this.pc = void 0;
|
|
23373
|
+
this.channels.clear();
|
|
23374
|
+
this.channelsCreated = false;
|
|
23375
|
+
this.remoteSet = false;
|
|
23376
|
+
this.pending.length = 0;
|
|
23377
|
+
}
|
|
23378
|
+
acceptsCandidate(candidate) {
|
|
23379
|
+
return this.opts.iceTransportPolicy === "all" || iceCandidateType(candidate) === "relay";
|
|
23380
|
+
}
|
|
23381
|
+
createChannels() {
|
|
23382
|
+
if (!this.pc || this.channelsCreated)
|
|
23383
|
+
return;
|
|
23384
|
+
this.channelsCreated = true;
|
|
23385
|
+
for (const label of DATA_CHANNEL_LABELS) {
|
|
23386
|
+
const dc = this.pc.createDataChannel(label, { id: DATA_CHANNEL_IDS[label], unordered: false });
|
|
23387
|
+
this.wireChannel(label, dc);
|
|
23388
|
+
}
|
|
23389
|
+
}
|
|
23390
|
+
/**
|
|
23391
|
+
* Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
|
|
23392
|
+
* message before that has nothing to reassemble into and is dropped.
|
|
23393
|
+
*/
|
|
23394
|
+
wireChannel(label, dc) {
|
|
23395
|
+
if (this.channels.has(label))
|
|
23396
|
+
return;
|
|
23397
|
+
this.channels.set(label, dc);
|
|
23398
|
+
dc.onOpen(() => {
|
|
23399
|
+
this.logger.debug(`[rtc] data channel open ${label}`);
|
|
23400
|
+
if (label !== COMMAND_CHANNEL)
|
|
23401
|
+
return;
|
|
23402
|
+
if (!this.framer) {
|
|
23403
|
+
const framer = new PtcsFramer();
|
|
23404
|
+
framer.init((packet) => {
|
|
23405
|
+
if (!dc.isOpen() || !dc.sendMessageBinary(packet))
|
|
23406
|
+
this.wireSendFailed = true;
|
|
23407
|
+
}, (frame, linkType) => this.emit("data", frame, linkType));
|
|
23408
|
+
this.framer = framer;
|
|
23409
|
+
}
|
|
23410
|
+
this.commandOpen = true;
|
|
23411
|
+
this.emit("commandChannelOpen");
|
|
23412
|
+
});
|
|
23413
|
+
dc.onClosed(() => {
|
|
23414
|
+
this.logger.debug(`[rtc] data channel closed ${label}`);
|
|
23415
|
+
if (label !== COMMAND_CHANNEL)
|
|
23416
|
+
return;
|
|
23417
|
+
const wasOpen = this.commandOpen;
|
|
23418
|
+
this.commandOpen = false;
|
|
23419
|
+
if (wasOpen)
|
|
23420
|
+
this.emit("commandChannelClosed");
|
|
23421
|
+
});
|
|
23422
|
+
dc.onError((err) => this.emit("error", new Error(`RTC data channel ${label}: ${err}`)));
|
|
23423
|
+
dc.onMessage((msg) => {
|
|
23424
|
+
const buf = typeof msg === "string" ? Buffer.from(msg) : Buffer.isBuffer(msg) ? msg : Buffer.from(msg);
|
|
23425
|
+
const n = (this.wireTally.get(label) ?? 0) + 1;
|
|
23426
|
+
this.wireTally.set(label, n);
|
|
23427
|
+
if (n <= 3 || n % 100 === 0) {
|
|
23428
|
+
this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
|
|
23429
|
+
}
|
|
23430
|
+
this.framer?.recvPacket(buf);
|
|
23431
|
+
});
|
|
23432
|
+
}
|
|
23433
|
+
addNow(candidate) {
|
|
23434
|
+
try {
|
|
23435
|
+
this.pc?.addRemoteCandidate(candidate, this.remoteMid);
|
|
23436
|
+
} catch (e) {
|
|
23437
|
+
this.logger.warn(`[rtc] addRemoteCandidate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23438
|
+
}
|
|
23439
|
+
}
|
|
23440
|
+
flushPending() {
|
|
23441
|
+
const queued = this.pending.splice(0);
|
|
23442
|
+
for (const c of queued)
|
|
23443
|
+
this.addNow(c);
|
|
23444
|
+
}
|
|
23445
|
+
};
|
|
23446
|
+
|
|
23447
|
+
// dist/transport/rtc/signaling.js
|
|
23448
|
+
import { EventEmitter as EventEmitter9 } from "node:events";
|
|
23449
|
+
import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
|
|
23450
|
+
var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
|
|
23451
|
+
var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
|
|
23452
|
+
var PORTAL_ORIGIN = "https://security.eufy.com";
|
|
23453
|
+
var SIGNALING_KEEPALIVE_MS = 25e3;
|
|
23454
|
+
var SIGNALING_CONNECT_TIMEOUT_MS = 15e3;
|
|
23455
|
+
var SOURCE = "WEB";
|
|
23456
|
+
function sessionAccount(channelId, adminUserId, ts, authToken) {
|
|
23457
|
+
return createHmac3("sha256", authToken).update(`${channelId}${adminUserId}${ts}`).digest("hex");
|
|
23458
|
+
}
|
|
23459
|
+
var WS_OPEN = 1;
|
|
23460
|
+
var defaultSocket = (url, init) => new WebSocket(url, {
|
|
23461
|
+
protocols: init.protocols,
|
|
23462
|
+
headers: { Origin: init.origin }
|
|
23463
|
+
});
|
|
23464
|
+
var RtcSignalingClient = class extends EventEmitter9 {
|
|
23465
|
+
opts;
|
|
23466
|
+
ws;
|
|
23467
|
+
sign;
|
|
23468
|
+
keepalive;
|
|
23469
|
+
wsUrl;
|
|
23470
|
+
signUrl;
|
|
23471
|
+
wsRegion;
|
|
23472
|
+
fetchImpl;
|
|
23473
|
+
createSocket;
|
|
23474
|
+
logger;
|
|
23475
|
+
now;
|
|
23476
|
+
makeMsgId;
|
|
23477
|
+
constructor(opts) {
|
|
23478
|
+
super();
|
|
23479
|
+
this.opts = opts;
|
|
23480
|
+
const prefix = (opts.shard.split("-")[0] || "us").toLowerCase();
|
|
23481
|
+
const smartHost = prefix === "us" ? "security-smart.eufylife.com" : `security-smart-${prefix}.eufylife.com`;
|
|
23482
|
+
this.wsUrl = `wss://${smartHost}${RTC_WS_PATH}`;
|
|
23483
|
+
this.signUrl = `https://${smartHost}${RTC_SIGN_PATH}`;
|
|
23484
|
+
this.wsRegion = prefix.toUpperCase();
|
|
23485
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
23486
|
+
this.createSocket = opts.createSocket ?? defaultSocket;
|
|
23487
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23488
|
+
this.now = opts.now ?? Date.now;
|
|
23489
|
+
this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
|
|
23490
|
+
}
|
|
23491
|
+
get isOpen() {
|
|
23492
|
+
return this.ws?.readyState === WS_OPEN;
|
|
23493
|
+
}
|
|
23494
|
+
/** Step 1: the sign blob the socket and every auth message carry. A non-JSON body is reported by status. */
|
|
23495
|
+
async fetchSign() {
|
|
23496
|
+
const res = await this.fetchImpl(this.signUrl, {
|
|
23497
|
+
headers: {
|
|
23498
|
+
"Web-Country": this.opts.country.toUpperCase(),
|
|
23499
|
+
"X-Auth-Token": this.opts.authToken,
|
|
23500
|
+
"App-Name": "eufy_mega",
|
|
23501
|
+
"Model-Type": "WEB",
|
|
23502
|
+
GToken: this.opts.gtoken,
|
|
23503
|
+
Origin: PORTAL_ORIGIN
|
|
23504
|
+
}
|
|
23505
|
+
});
|
|
23506
|
+
const body = await res.json().catch(() => ({})) ?? {};
|
|
23507
|
+
if (!res.ok || body.code !== 0 || !body.data) {
|
|
23508
|
+
throw new Error(`RTC sign for ${this.opts.stationSn} refused: HTTP ${res.status} code ${body.code ?? "?"} ${body.msg ?? ""}`.trim());
|
|
23509
|
+
}
|
|
23510
|
+
this.sign = body.data;
|
|
23511
|
+
return body.data;
|
|
23512
|
+
}
|
|
23513
|
+
/**
|
|
23514
|
+
* Step 2: open the socket and send the first auth; resolves on `open`. A socket that closes or errors
|
|
23515
|
+
* before it ever opened rejects, so the call always settles.
|
|
23516
|
+
*/
|
|
23517
|
+
async connect() {
|
|
23518
|
+
if (this.ws)
|
|
23519
|
+
return;
|
|
23520
|
+
const sign = this.sign ?? await this.fetchSign();
|
|
23521
|
+
const auth = {
|
|
23522
|
+
region: this.wsRegion,
|
|
23523
|
+
type: "NVR",
|
|
23524
|
+
sn: this.opts.stationSn,
|
|
23525
|
+
token: this.opts.authToken,
|
|
23526
|
+
gtoken: this.opts.gtoken,
|
|
23527
|
+
sign,
|
|
23528
|
+
appName: "eufy_mega",
|
|
23529
|
+
modelType: "WEB"
|
|
23530
|
+
};
|
|
23531
|
+
const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
|
|
23532
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
|
|
23533
|
+
await new Promise((resolve, reject) => {
|
|
23534
|
+
const timer = setTimeout(() => {
|
|
23535
|
+
reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
|
|
23536
|
+
this.close();
|
|
23537
|
+
}, SIGNALING_CONNECT_TIMEOUT_MS);
|
|
23538
|
+
const ws = this.createSocket(this.wsUrl, { protocols, origin: PORTAL_ORIGIN });
|
|
23539
|
+
this.ws = ws;
|
|
23540
|
+
let opened = false;
|
|
23541
|
+
ws.addEventListener("open", () => {
|
|
23542
|
+
opened = true;
|
|
23543
|
+
clearTimeout(timer);
|
|
23544
|
+
this.sendAuth(sign);
|
|
23545
|
+
this.startKeepalive();
|
|
23546
|
+
this.emit("open");
|
|
23547
|
+
resolve();
|
|
23548
|
+
});
|
|
23549
|
+
ws.addEventListener("message", (ev) => {
|
|
23550
|
+
void this.handleWireMessage(ev.data);
|
|
23551
|
+
});
|
|
23552
|
+
ws.addEventListener("close", (ev) => {
|
|
23553
|
+
clearTimeout(timer);
|
|
23554
|
+
this.stopKeepalive();
|
|
23555
|
+
this.ws = void 0;
|
|
23556
|
+
const code = ev.code ?? 1006;
|
|
23557
|
+
const reason = ev.reason ?? "";
|
|
23558
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling closed ${code} ${reason}`);
|
|
23559
|
+
if (!opened)
|
|
23560
|
+
reject(new Error(`RTC signalling closed before open: ${code} ${reason}`.trim()));
|
|
23561
|
+
this.emit("close", code, reason);
|
|
23562
|
+
});
|
|
23563
|
+
ws.addEventListener("error", (ev) => {
|
|
23564
|
+
clearTimeout(timer);
|
|
23565
|
+
const detail = ev ?? {};
|
|
23566
|
+
const cause = detail.error?.message ?? detail.error?.code ?? detail.message ?? "";
|
|
23567
|
+
reject(new Error(`RTC signalling socket error${cause ? ": " + cause : ""}`));
|
|
23568
|
+
});
|
|
23569
|
+
});
|
|
23570
|
+
}
|
|
23571
|
+
/** `action 1`, also what keeps the socket alive when re-sent. */
|
|
23572
|
+
sendAuth(sign) {
|
|
23573
|
+
const s = sign ?? this.sign;
|
|
23574
|
+
if (!s)
|
|
23575
|
+
throw new Error("RTC signalling: no sign to authenticate with");
|
|
23576
|
+
this.sendEnvelope("0", {
|
|
23577
|
+
code: 200,
|
|
23578
|
+
action: 1,
|
|
23579
|
+
data: s,
|
|
23580
|
+
sn: this.opts.stationSn,
|
|
23581
|
+
source: SOURCE,
|
|
23582
|
+
ts: Math.floor(this.now() / 1e3)
|
|
23583
|
+
});
|
|
23584
|
+
}
|
|
23585
|
+
/**
|
|
23586
|
+
* `action 3`, a session message on channel 0, the hub's own session; `scall` opens the negotiation.
|
|
23587
|
+
* `subSn` is empty for the hub's session.
|
|
23588
|
+
*/
|
|
23589
|
+
sendSession(dataType, payload = {}) {
|
|
23590
|
+
const channelId = 0;
|
|
23591
|
+
const ts = Math.floor(this.now() / 1e3);
|
|
23592
|
+
const inner = {
|
|
23593
|
+
code: 200,
|
|
23594
|
+
action: 3,
|
|
23595
|
+
sessionId: this.sign,
|
|
23596
|
+
sn: this.opts.stationSn,
|
|
23597
|
+
subSn: "",
|
|
23598
|
+
channelId,
|
|
23599
|
+
isResponse: 0,
|
|
23600
|
+
dataType,
|
|
23601
|
+
source: SOURCE,
|
|
23602
|
+
ts,
|
|
23603
|
+
data: JSON.stringify({
|
|
23604
|
+
timestamp: ts,
|
|
23605
|
+
account: sessionAccount(channelId, this.opts.adminUserId, ts, this.opts.authToken),
|
|
23606
|
+
...payload
|
|
23607
|
+
})
|
|
23608
|
+
};
|
|
23609
|
+
this.sendEnvelope(`${this.opts.authToken}_${this.makeMsgId()}`, inner);
|
|
23610
|
+
}
|
|
23611
|
+
sendCall() {
|
|
23612
|
+
this.sendSession(this.opts.signalingMode ?? "scall");
|
|
23613
|
+
}
|
|
23614
|
+
sendAck() {
|
|
23615
|
+
this.sendSession("ack");
|
|
23616
|
+
}
|
|
23617
|
+
/** The SDP answer, encoded for the negotiated call format, in an `info`. */
|
|
23618
|
+
sendInfoSdp(sdp) {
|
|
23619
|
+
this.sendSession("info", { sdp });
|
|
23620
|
+
}
|
|
23621
|
+
/** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
|
|
23622
|
+
sendInfoCandidate(candidate) {
|
|
23623
|
+
this.sendSession("info", { candidate });
|
|
23624
|
+
}
|
|
23625
|
+
sendHangup() {
|
|
23626
|
+
this.sendSession("hangup");
|
|
23627
|
+
}
|
|
23628
|
+
close() {
|
|
23629
|
+
this.stopKeepalive();
|
|
23630
|
+
const ws = this.ws;
|
|
23631
|
+
this.ws = void 0;
|
|
23632
|
+
try {
|
|
23633
|
+
ws?.close();
|
|
23634
|
+
} catch {
|
|
23635
|
+
}
|
|
23636
|
+
}
|
|
23637
|
+
startKeepalive() {
|
|
23638
|
+
this.stopKeepalive();
|
|
23639
|
+
this.keepalive = setInterval(() => {
|
|
23640
|
+
if (!this.isOpen)
|
|
23641
|
+
return;
|
|
23642
|
+
try {
|
|
23643
|
+
this.sendAuth();
|
|
23644
|
+
} catch (e) {
|
|
23645
|
+
this.logger.warn(`[rtc] ${this.opts.stationSn} signalling keepalive failed`, e);
|
|
23646
|
+
}
|
|
23647
|
+
}, SIGNALING_KEEPALIVE_MS);
|
|
23648
|
+
this.keepalive.unref?.();
|
|
23649
|
+
}
|
|
23650
|
+
stopKeepalive() {
|
|
23651
|
+
if (this.keepalive)
|
|
23652
|
+
clearInterval(this.keepalive);
|
|
23653
|
+
this.keepalive = void 0;
|
|
23654
|
+
}
|
|
23655
|
+
sendEnvelope(msgid, inner) {
|
|
23656
|
+
if (!this.isOpen || !this.ws)
|
|
23657
|
+
throw new Error("RTC signalling not connected");
|
|
23658
|
+
const envelope = { msgid, data: JSON.stringify(inner) };
|
|
23659
|
+
this.ws.send(JSON.stringify(envelope));
|
|
23660
|
+
}
|
|
23661
|
+
async handleWireMessage(raw) {
|
|
23662
|
+
let text2;
|
|
23663
|
+
if (typeof raw === "string")
|
|
23664
|
+
text2 = raw;
|
|
23665
|
+
else if (Buffer.isBuffer(raw))
|
|
23666
|
+
text2 = raw.toString("utf8");
|
|
23667
|
+
else if (raw instanceof ArrayBuffer)
|
|
23668
|
+
text2 = Buffer.from(raw).toString("utf8");
|
|
23669
|
+
else if (typeof raw?.text === "function")
|
|
23670
|
+
text2 = await raw.text();
|
|
23671
|
+
else
|
|
23672
|
+
return;
|
|
23673
|
+
let envelope;
|
|
23674
|
+
let inner;
|
|
23675
|
+
try {
|
|
23676
|
+
envelope = JSON.parse(text2);
|
|
23677
|
+
if (typeof envelope?.data !== "string")
|
|
23678
|
+
return;
|
|
23679
|
+
inner = JSON.parse(envelope.data);
|
|
23680
|
+
} catch {
|
|
23681
|
+
return;
|
|
23682
|
+
}
|
|
23683
|
+
this.emit("message", inner, envelope);
|
|
23684
|
+
}
|
|
23685
|
+
};
|
|
23686
|
+
|
|
23687
|
+
// dist/transport/rtc/session.js
|
|
23688
|
+
var AUTH_TIMEOUT_MS = 15e3;
|
|
23689
|
+
var RtcSession = class extends EventEmitter10 {
|
|
23690
|
+
opts;
|
|
23691
|
+
signaling;
|
|
23692
|
+
peer;
|
|
23693
|
+
logger;
|
|
23694
|
+
turn;
|
|
23695
|
+
authOk = false;
|
|
23696
|
+
authWaiter;
|
|
23697
|
+
connected = false;
|
|
23698
|
+
closed = false;
|
|
23699
|
+
closeAnnounced = false;
|
|
23700
|
+
sdpHandled = false;
|
|
23701
|
+
chain = Promise.resolve();
|
|
23702
|
+
constructor(opts) {
|
|
23703
|
+
super();
|
|
23704
|
+
this.opts = opts;
|
|
23705
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23706
|
+
this.signaling = (opts.createSignaling ?? ((o) => new RtcSignalingClient(o)))(opts);
|
|
23707
|
+
this.peer = (opts.createPeer ?? ((o) => new RtcPeer(o)))({ logger: this.logger, ...opts.peer });
|
|
23708
|
+
this.signaling.on("message", (inner) => {
|
|
23709
|
+
this.chain = this.chain.then(() => this.onSignaling(inner)).catch((e) => {
|
|
23710
|
+
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
23711
|
+
});
|
|
23712
|
+
});
|
|
23713
|
+
this.signaling.on("close", () => {
|
|
23714
|
+
this.connected = false;
|
|
23715
|
+
this.announceClose();
|
|
23716
|
+
});
|
|
23717
|
+
this.signaling.on("error", (e) => this.emit("error", e));
|
|
23718
|
+
this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
|
|
23719
|
+
this.peer.on("iceGatheringComplete", () => this.trickle(""));
|
|
23720
|
+
this.peer.on("commandChannelOpen", () => {
|
|
23721
|
+
if (this.connected)
|
|
23722
|
+
return;
|
|
23723
|
+
this.connected = true;
|
|
23724
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel open`);
|
|
23725
|
+
this.emit("connected");
|
|
23726
|
+
});
|
|
23727
|
+
this.peer.on("commandChannelClosed", () => {
|
|
23728
|
+
if (!this.connected)
|
|
23729
|
+
return;
|
|
23730
|
+
this.connected = false;
|
|
23731
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel closed`);
|
|
23732
|
+
this.announceClose();
|
|
23733
|
+
});
|
|
23734
|
+
this.peer.on("connectionState", (state) => {
|
|
23735
|
+
if ((state === "failed" || state === "closed") && this.connected) {
|
|
23736
|
+
this.connected = false;
|
|
23737
|
+
this.announceClose();
|
|
23738
|
+
}
|
|
23739
|
+
});
|
|
23740
|
+
this.peer.on("error", (e) => this.emit("error", e));
|
|
23741
|
+
this.peer.on("data", (frame, linkType) => {
|
|
23742
|
+
if (linkType === PortalLinkType.LIVE)
|
|
23743
|
+
this.emit("mediaData", frame);
|
|
23744
|
+
else
|
|
23745
|
+
this.emit("commandData", frame, linkType);
|
|
23746
|
+
});
|
|
23747
|
+
}
|
|
23748
|
+
get isConnected() {
|
|
23749
|
+
return this.connected;
|
|
23750
|
+
}
|
|
23751
|
+
/**
|
|
23752
|
+
* Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
|
|
23753
|
+
* closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
|
|
23754
|
+
* placing the call.
|
|
23755
|
+
*/
|
|
23756
|
+
async connect() {
|
|
23757
|
+
await this.signaling.fetchSign();
|
|
23758
|
+
if (this.closed)
|
|
23759
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23760
|
+
await this.signaling.connect();
|
|
23761
|
+
await this.waitForAuth();
|
|
23762
|
+
if (this.closed)
|
|
23763
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23764
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 ${this.opts.signalingMode ?? "scall"}`);
|
|
23765
|
+
this.signaling.sendCall();
|
|
23766
|
+
}
|
|
23767
|
+
/** Send one portal packet; false when the command channel isn't open. */
|
|
23768
|
+
sendCommand(portalPacket) {
|
|
23769
|
+
return this.peer.sendCommand(portalPacket);
|
|
23770
|
+
}
|
|
23771
|
+
/** Send unframed bytes on the command channel; false when it isn't open. */
|
|
23772
|
+
sendRaw(bytes2) {
|
|
23773
|
+
return this.peer.sendRaw(bytes2);
|
|
23774
|
+
}
|
|
23775
|
+
/**
|
|
23776
|
+
* Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
|
|
23777
|
+
* `connect()` still waiting for the signalling auth is rejected and its timer stopped.
|
|
23778
|
+
*/
|
|
23779
|
+
close() {
|
|
23780
|
+
if (this.closed)
|
|
23781
|
+
return;
|
|
23782
|
+
this.closed = true;
|
|
23783
|
+
this.connected = false;
|
|
23784
|
+
try {
|
|
23785
|
+
if (this.signaling.isOpen)
|
|
23786
|
+
this.signaling.sendHangup();
|
|
23787
|
+
} catch {
|
|
23788
|
+
}
|
|
23789
|
+
this.signaling.close();
|
|
23790
|
+
this.peer.close();
|
|
23791
|
+
this.settleAuth(new Error("RTC session closed while waiting for signalling auth"));
|
|
23792
|
+
this.announceClose();
|
|
23793
|
+
}
|
|
23794
|
+
/**
|
|
23795
|
+
* Trickle one local candidate, or the empty end-of-candidates. The native peer can report one after the
|
|
23796
|
+
* signalling socket has closed, and sending then throws inside a native callback, where the throw can't
|
|
23797
|
+
* be caught and aborts the process. A candidate with no open socket is dropped.
|
|
23798
|
+
*/
|
|
23799
|
+
trickle(candidate) {
|
|
23800
|
+
if (!this.signaling.isOpen)
|
|
23801
|
+
return;
|
|
23802
|
+
this.signaling.sendInfoCandidate(candidate);
|
|
23803
|
+
}
|
|
23804
|
+
announceClose() {
|
|
23805
|
+
if (this.closeAnnounced)
|
|
23806
|
+
return;
|
|
23807
|
+
this.closeAnnounced = true;
|
|
23808
|
+
this.emit("close");
|
|
23809
|
+
}
|
|
23810
|
+
waitForAuth() {
|
|
23811
|
+
if (this.authOk)
|
|
23812
|
+
return Promise.resolve();
|
|
23813
|
+
return new Promise((resolve, reject) => {
|
|
23814
|
+
const timer = setTimeout(() => {
|
|
23815
|
+
this.authWaiter = void 0;
|
|
23816
|
+
reject(new Error("RTC signalling auth timeout"));
|
|
23817
|
+
}, AUTH_TIMEOUT_MS);
|
|
23818
|
+
this.authWaiter = { resolve, reject, timer };
|
|
23819
|
+
});
|
|
23820
|
+
}
|
|
23821
|
+
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
23822
|
+
settleAuth(err) {
|
|
23823
|
+
const waiter = this.authWaiter;
|
|
23824
|
+
if (!waiter)
|
|
23825
|
+
return;
|
|
23826
|
+
this.authWaiter = void 0;
|
|
23827
|
+
clearTimeout(waiter.timer);
|
|
23828
|
+
if (err)
|
|
23829
|
+
waiter.reject(err);
|
|
23830
|
+
else
|
|
23831
|
+
waiter.resolve();
|
|
23832
|
+
}
|
|
23833
|
+
async onSignaling(inner) {
|
|
23834
|
+
if (inner.action === 1 && inner.code === 200) {
|
|
23835
|
+
this.authOk = true;
|
|
23836
|
+
this.settleAuth();
|
|
23837
|
+
}
|
|
23838
|
+
if (!inner.data)
|
|
23839
|
+
return;
|
|
23840
|
+
let payload;
|
|
23841
|
+
try {
|
|
23842
|
+
payload = JSON.parse(inner.data);
|
|
23843
|
+
} catch {
|
|
23844
|
+
return;
|
|
23845
|
+
}
|
|
23846
|
+
switch (inner.dataType) {
|
|
23847
|
+
case "scall":
|
|
23848
|
+
case "call":
|
|
23849
|
+
await this.onCall(payload);
|
|
23850
|
+
return;
|
|
23851
|
+
case "info":
|
|
23852
|
+
await this.onInfo(payload);
|
|
23853
|
+
return;
|
|
23854
|
+
case "hangup":
|
|
23855
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} hub hung up`);
|
|
23856
|
+
return;
|
|
23857
|
+
default:
|
|
23858
|
+
return;
|
|
23859
|
+
}
|
|
23860
|
+
}
|
|
23861
|
+
/** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
|
|
23862
|
+
async onCall(payload) {
|
|
23863
|
+
if (this.closed)
|
|
23864
|
+
return;
|
|
23865
|
+
const status = payload.status;
|
|
23866
|
+
if (status === 100 && payload.turn) {
|
|
23867
|
+
this.turn = payload.turn;
|
|
23868
|
+
await this.peer.init(payload.turn);
|
|
23869
|
+
return;
|
|
23870
|
+
}
|
|
23871
|
+
if (status === 200) {
|
|
23872
|
+
this.signaling.sendAck();
|
|
23873
|
+
return;
|
|
23874
|
+
}
|
|
23875
|
+
if (status === 486 || status === 408) {
|
|
23876
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} scall answered ${status}`));
|
|
23877
|
+
this.close();
|
|
23878
|
+
}
|
|
23879
|
+
}
|
|
23880
|
+
/**
|
|
23881
|
+
* An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
|
|
23882
|
+
* is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
|
|
23883
|
+
* is reported and left unanswered.
|
|
23884
|
+
*/
|
|
23885
|
+
async onInfo(payload) {
|
|
23886
|
+
if (this.closed)
|
|
23887
|
+
return;
|
|
23888
|
+
if (payload.format === "CANDIDATE") {
|
|
23889
|
+
if (payload.value)
|
|
23890
|
+
this.peer.addRemoteCandidate(payload.value);
|
|
23891
|
+
return;
|
|
23892
|
+
}
|
|
23893
|
+
if (payload.candidate !== void 0) {
|
|
23894
|
+
if (payload.candidate)
|
|
23895
|
+
this.peer.addRemoteCandidate(payload.candidate);
|
|
23896
|
+
return;
|
|
23897
|
+
}
|
|
23898
|
+
const sdpText = payload.value ?? payload.sdp;
|
|
23899
|
+
if (!sdpText || payload.format !== "SDP" && !payload.sdp)
|
|
23900
|
+
return;
|
|
23901
|
+
if (this.sdpHandled)
|
|
23902
|
+
return;
|
|
23903
|
+
if (!this.turn) {
|
|
23904
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} offered before granting a relay`));
|
|
23905
|
+
return;
|
|
23906
|
+
}
|
|
23907
|
+
this.sdpHandled = true;
|
|
23908
|
+
let offer;
|
|
23909
|
+
try {
|
|
23910
|
+
offer = scallJsonToSdp(JSON.parse(sdpText));
|
|
23911
|
+
} catch {
|
|
23912
|
+
offer = sdpText;
|
|
23913
|
+
}
|
|
23914
|
+
const answer = await this.peer.handleRemoteOffer(offer);
|
|
23915
|
+
this.signaling.sendInfoSdp(this.opts.signalingMode === "call" ? answer : JSON.stringify(sdpToScallJson(answer)));
|
|
23916
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
|
|
23917
|
+
}
|
|
23918
|
+
};
|
|
23919
|
+
|
|
23920
|
+
// dist/transport/rtc/command-router.js
|
|
23921
|
+
var PORTAL_CMD_CONTROL_PAYLOAD = 1700;
|
|
23922
|
+
var PORTAL_CMD_NOTIFY_PAYLOAD = 1351;
|
|
23923
|
+
var ACK_TIMEOUT_MS = 8e3;
|
|
23924
|
+
var CONNECT_TIMEOUT_MS2 = 12e3;
|
|
23925
|
+
var IDLE_CLOSE_MS = 6e4;
|
|
23926
|
+
var StationStreamBusyError = class extends Error {
|
|
23927
|
+
constructor(stationSn, servingSn) {
|
|
23928
|
+
super(`rtc live: ${stationSn} is serving ${servingSn}; the station streams one camera at a time`);
|
|
23929
|
+
this.name = "StationStreamBusyError";
|
|
23930
|
+
}
|
|
23931
|
+
};
|
|
23932
|
+
var RtcCommandRouter = class {
|
|
23933
|
+
deps;
|
|
23934
|
+
sessions = /* @__PURE__ */ new Map();
|
|
23935
|
+
lives = /* @__PURE__ */ new Map();
|
|
23936
|
+
constructor(deps) {
|
|
23937
|
+
this.deps = deps;
|
|
23938
|
+
}
|
|
23939
|
+
async dispatchCommand(route, cmd) {
|
|
23940
|
+
if (cmd.kind !== "set-payload" && cmd.kind !== "set-json") {
|
|
23941
|
+
throw new Error(`rtc: ${cmd.kind} is not routable over the RTC control channel (only set-payload or set-json)`);
|
|
23942
|
+
}
|
|
23943
|
+
const identity = this.deps.identity();
|
|
23944
|
+
if (!identity)
|
|
23945
|
+
throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
|
|
23946
|
+
const { stationSn } = route;
|
|
23947
|
+
const adminUserId = route.adminUserId || identity.userId;
|
|
23948
|
+
const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
|
|
23949
|
+
const st = await this.stationSession(route, adminUserId, identity);
|
|
23950
|
+
const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
|
|
23951
|
+
const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
|
|
23952
|
+
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 };
|
|
23953
|
+
const segment = st.seg.next();
|
|
23954
|
+
const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
|
|
23955
|
+
const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
|
|
23956
|
+
st.queue = run.catch(() => void 0);
|
|
23957
|
+
return run;
|
|
23958
|
+
}
|
|
23959
|
+
/**
|
|
23960
|
+
* The media of a camera on a T9000: live video, a readable feed and a live still over the station's
|
|
23961
|
+
* session. Recording has no wire here and is refused; the optional members it has no wire for are absent.
|
|
23962
|
+
*/
|
|
23963
|
+
mediaProviderFor(route) {
|
|
23964
|
+
return {
|
|
23965
|
+
live: (opts) => this.withLiveSource(route, opts ?? {}, (source) => attachUnlessAborted(source, opts?.signal)),
|
|
23966
|
+
openReadable: (opts) => this.withLiveSource(route, opts ?? {}, (source) => openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts)),
|
|
23967
|
+
snapshotLive: (opts) => this.withLiveSource(route, opts ?? {}, (source) => captureSnapshotFromShared(source, { ...opts, logger: this.deps.logger, ffmpegPath: this.deps.ffmpegPath })).catch((e) => {
|
|
23968
|
+
if (!(e instanceof StationStreamBusyError))
|
|
23969
|
+
throw e;
|
|
23970
|
+
throw new LiveSnapshotUnavailableError("source-failed", e.message, { cause: e });
|
|
23971
|
+
}),
|
|
23972
|
+
record: async () => {
|
|
23973
|
+
throw new Error(`record is not available for ${route.cameraSn} over the T9000 control channel`);
|
|
23974
|
+
}
|
|
23975
|
+
};
|
|
23976
|
+
}
|
|
23977
|
+
/**
|
|
23978
|
+
* Tear down every station session (logout / shutdown). Each live source is disposed before its session
|
|
23979
|
+
* closes, so its consumers end cleanly and its stop still rides the session.
|
|
23980
|
+
*/
|
|
23981
|
+
close() {
|
|
23982
|
+
for (const [sn, live] of this.lives) {
|
|
23983
|
+
this.lives.delete(sn);
|
|
23984
|
+
live.source.dispose();
|
|
23985
|
+
}
|
|
23986
|
+
for (const [sn, st] of this.sessions)
|
|
23987
|
+
this.drop(sn, st);
|
|
23988
|
+
}
|
|
23989
|
+
/**
|
|
23990
|
+
* Hand `use` the station's live source for this camera, in the same turn the source is created or reused,
|
|
23991
|
+
* so a consumer it attaches is counted before any other caller looks.
|
|
23992
|
+
*
|
|
23993
|
+
* The station streams one camera at a time. Another camera whose source has consumers refuses this one,
|
|
23994
|
+
* before the session is awaited and again after; one with none (never attached or lingering)
|
|
23995
|
+
* holds nothing a viewer needs, so it is disposed and the slot taken. The first caller's
|
|
23996
|
+
* {@link SharedSourceHints} configure a new source.
|
|
23997
|
+
*/
|
|
23998
|
+
async withLiveSource(route, opts, use) {
|
|
23999
|
+
opts.signal?.throwIfAborted();
|
|
24000
|
+
this.refuseIfWatched(route);
|
|
24001
|
+
const identity = this.deps.identity();
|
|
24002
|
+
if (!identity)
|
|
24003
|
+
throw new Error(`rtc: not logged in, cannot open ${route.stationSn}`);
|
|
24004
|
+
const accountId = route.adminUserId || identity.userId;
|
|
24005
|
+
const st = await abortable(this.stationSession(route, accountId, identity), opts.signal);
|
|
24006
|
+
this.refuseIfWatched(route);
|
|
24007
|
+
const current = this.lives.get(route.stationSn);
|
|
24008
|
+
if (current && current.cameraSn !== route.cameraSn) {
|
|
24009
|
+
this.deps.logger?.debug?.(`[rtc] ${route.stationSn} releasing the unwatched source of ${current.cameraSn} for ${route.cameraSn}`);
|
|
24010
|
+
this.lives.delete(route.stationSn);
|
|
24011
|
+
current.source.dispose();
|
|
24012
|
+
}
|
|
24013
|
+
let live = this.lives.get(route.stationSn);
|
|
24014
|
+
if (!live) {
|
|
24015
|
+
live = { cameraSn: route.cameraSn, source: this.newLiveSource(route, st, accountId, opts) };
|
|
24016
|
+
this.lives.set(route.stationSn, live);
|
|
24017
|
+
}
|
|
24018
|
+
return await use(live.source);
|
|
24019
|
+
}
|
|
24020
|
+
/** Refuse a camera while another camera on its station has consumers. */
|
|
24021
|
+
refuseIfWatched(route) {
|
|
24022
|
+
const current = this.lives.get(route.stationSn);
|
|
24023
|
+
if (current && current.cameraSn !== route.cameraSn && current.source.consumerCount > 0) {
|
|
24024
|
+
throw new StationStreamBusyError(route.stationSn, current.cameraSn);
|
|
24025
|
+
}
|
|
24026
|
+
}
|
|
24027
|
+
newLiveSource(route, st, accountId, hints) {
|
|
24028
|
+
let leased = false;
|
|
24029
|
+
const release = () => {
|
|
24030
|
+
if (!leased)
|
|
24031
|
+
return;
|
|
24032
|
+
leased = false;
|
|
24033
|
+
st.leases = Math.max(0, st.leases - 1);
|
|
24034
|
+
this.touch(route.stationSn, st);
|
|
24035
|
+
};
|
|
24036
|
+
const source = new SharedLiveSource({
|
|
24037
|
+
makeStream: () => new RtcLiveStream({
|
|
24038
|
+
session: st.session,
|
|
24039
|
+
seg: st.seg,
|
|
24040
|
+
stationSn: route.stationSn,
|
|
24041
|
+
channel: route.channel,
|
|
24042
|
+
accountId,
|
|
24043
|
+
logger: this.deps.logger
|
|
24044
|
+
}),
|
|
24045
|
+
label: `${route.stationSn}#${route.channel}`,
|
|
24046
|
+
logger: this.deps.logger,
|
|
24047
|
+
powered: hints.powered,
|
|
24048
|
+
preBufferSeconds: hints.preBufferSeconds,
|
|
24049
|
+
onActive: () => {
|
|
24050
|
+
if (leased)
|
|
24051
|
+
return;
|
|
24052
|
+
leased = true;
|
|
24053
|
+
st.leases++;
|
|
24054
|
+
if (st.idle)
|
|
24055
|
+
clearTimeout(st.idle);
|
|
24056
|
+
},
|
|
24057
|
+
onStopped: () => {
|
|
24058
|
+
release();
|
|
24059
|
+
if (this.lives.get(route.stationSn)?.source === source)
|
|
24060
|
+
this.lives.delete(route.stationSn);
|
|
24061
|
+
}
|
|
24062
|
+
});
|
|
24063
|
+
return source;
|
|
24064
|
+
}
|
|
24065
|
+
/**
|
|
24066
|
+
* Completes on the command ACK or a correlated control-result notification. The notification must
|
|
24067
|
+
* match the request channel, parameter and segment and contain a structured payload. Receipt does
|
|
24068
|
+
* not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
|
|
24069
|
+
*/
|
|
24070
|
+
sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
|
|
24071
|
+
return new Promise((resolve, reject) => {
|
|
24072
|
+
const onData = (frame, linkType) => {
|
|
24073
|
+
const p = parsePortalPacket(frame, linkType);
|
|
24074
|
+
if (!p || p.segment !== segment)
|
|
24075
|
+
return;
|
|
24076
|
+
const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
|
|
24077
|
+
const d = p.data;
|
|
24078
|
+
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;
|
|
24079
|
+
if (!acknowledgement && !notification)
|
|
24080
|
+
return;
|
|
24081
|
+
cleanup();
|
|
24082
|
+
if (acknowledgement && p.errCode !== 0) {
|
|
24083
|
+
reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
|
|
24084
|
+
} else {
|
|
24085
|
+
this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
|
|
24086
|
+
resolve();
|
|
24087
|
+
}
|
|
24088
|
+
};
|
|
24089
|
+
const onClose = () => {
|
|
24090
|
+
cleanup();
|
|
24091
|
+
reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
|
|
24092
|
+
};
|
|
24093
|
+
const timer = setTimeout(() => {
|
|
24094
|
+
cleanup();
|
|
24095
|
+
reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
|
|
24096
|
+
}, ACK_TIMEOUT_MS);
|
|
24097
|
+
const cleanup = () => {
|
|
24098
|
+
clearTimeout(timer);
|
|
24099
|
+
st.session.off("commandData", onData);
|
|
24100
|
+
st.session.off("close", onClose);
|
|
24101
|
+
this.touch(sn, st);
|
|
24102
|
+
};
|
|
24103
|
+
st.session.on("commandData", onData);
|
|
24104
|
+
st.session.on("close", onClose);
|
|
24105
|
+
if (!st.session.sendCommand(packet)) {
|
|
24106
|
+
cleanup();
|
|
24107
|
+
reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
|
|
24108
|
+
}
|
|
24109
|
+
});
|
|
24110
|
+
}
|
|
24111
|
+
async stationSession(route, adminUserId, identity) {
|
|
24112
|
+
const sn = route.stationSn;
|
|
24113
|
+
const existing = this.sessions.get(sn);
|
|
24114
|
+
if (existing) {
|
|
24115
|
+
await existing.ready;
|
|
24116
|
+
if (existing.session.isConnected) {
|
|
24117
|
+
this.touch(sn, existing);
|
|
24118
|
+
return existing;
|
|
24119
|
+
}
|
|
24120
|
+
this.drop(sn, existing);
|
|
24121
|
+
}
|
|
24122
|
+
const session = (this.deps.createSession ?? ((o) => new RtcSession(o)))({
|
|
24123
|
+
authToken: identity.authToken,
|
|
24124
|
+
gtoken: identity.gtoken,
|
|
24125
|
+
stationSn: sn,
|
|
24126
|
+
adminUserId,
|
|
24127
|
+
shard: this.deps.shard(),
|
|
24128
|
+
country: this.deps.country ?? "US",
|
|
24129
|
+
logger: this.deps.logger,
|
|
24130
|
+
signalingMode: route.signalingMode,
|
|
24131
|
+
peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy }
|
|
24132
|
+
});
|
|
24133
|
+
const ready = this.bringUp(sn, session);
|
|
24134
|
+
const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve(), leases: 0 };
|
|
24135
|
+
session.on("error", (e) => this.deps.onError?.(e));
|
|
24136
|
+
session.on("close", () => {
|
|
24137
|
+
if (this.sessions.get(sn) === st)
|
|
24138
|
+
this.sessions.delete(sn);
|
|
24139
|
+
});
|
|
24140
|
+
this.sessions.set(sn, st);
|
|
24141
|
+
try {
|
|
24142
|
+
await ready;
|
|
24143
|
+
} catch (e) {
|
|
24144
|
+
this.drop(sn, st);
|
|
24145
|
+
throw e;
|
|
24146
|
+
}
|
|
24147
|
+
this.touch(sn, st);
|
|
24148
|
+
return st;
|
|
24149
|
+
}
|
|
24150
|
+
/**
|
|
24151
|
+
* One bounded bring-up: the session is up when its command channel opens, and it fails, with the
|
|
24152
|
+
* session closed and every listener gone, when `connect()` throws, the session closes first, or
|
|
24153
|
+
* {@link CONNECT_TIMEOUT_MS} passes. A single promise, so an early failure cannot leave a second one
|
|
24154
|
+
* rejecting unheard.
|
|
24155
|
+
*/
|
|
24156
|
+
bringUp(sn, session) {
|
|
24157
|
+
return new Promise((resolve, reject) => {
|
|
24158
|
+
let settled = false;
|
|
24159
|
+
const finish = (err) => {
|
|
24160
|
+
if (settled)
|
|
24161
|
+
return;
|
|
24162
|
+
settled = true;
|
|
24163
|
+
clearTimeout(timer);
|
|
24164
|
+
session.off("connected", onConnected);
|
|
24165
|
+
session.off("close", onClose);
|
|
24166
|
+
if (err) {
|
|
24167
|
+
try {
|
|
24168
|
+
session.close();
|
|
24169
|
+
} catch {
|
|
24170
|
+
}
|
|
24171
|
+
reject(err);
|
|
24172
|
+
} else {
|
|
24173
|
+
this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
|
|
24174
|
+
resolve();
|
|
24175
|
+
}
|
|
24176
|
+
};
|
|
24177
|
+
const onConnected = () => finish();
|
|
24178
|
+
const onClose = () => finish(new Error(`rtc: ${sn} session closed before the command channel opened`));
|
|
24179
|
+
const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms`)), CONNECT_TIMEOUT_MS2);
|
|
24180
|
+
session.once("connected", onConnected);
|
|
24181
|
+
session.once("close", onClose);
|
|
24182
|
+
Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
|
|
24183
|
+
});
|
|
24184
|
+
}
|
|
24185
|
+
touch(sn, st) {
|
|
24186
|
+
if (st.idle)
|
|
24187
|
+
clearTimeout(st.idle);
|
|
24188
|
+
if (st.leases > 0)
|
|
24189
|
+
return;
|
|
24190
|
+
st.idle = setTimeout(() => this.drop(sn, st), IDLE_CLOSE_MS);
|
|
24191
|
+
st.idle.unref?.();
|
|
24192
|
+
}
|
|
24193
|
+
drop(sn, st) {
|
|
24194
|
+
if (st.idle)
|
|
24195
|
+
clearTimeout(st.idle);
|
|
24196
|
+
if (this.sessions.get(sn) === st)
|
|
24197
|
+
this.sessions.delete(sn);
|
|
24198
|
+
try {
|
|
24199
|
+
st.session.close();
|
|
24200
|
+
} catch {
|
|
24201
|
+
}
|
|
24202
|
+
}
|
|
24203
|
+
};
|
|
24204
|
+
|
|
24205
|
+
// dist/transport/mqtt/command-router.js
|
|
24206
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
24207
|
+
|
|
24208
|
+
// dist/transport/mqtt/broker-discovery.js
|
|
24209
|
+
import { resolve4 } from "node:dns/promises";
|
|
24210
|
+
async function resolveBrokerIps(hostname) {
|
|
24211
|
+
try {
|
|
24212
|
+
return await resolve4(hostname);
|
|
24213
|
+
} catch {
|
|
24214
|
+
return [];
|
|
24215
|
+
}
|
|
24216
|
+
}
|
|
24217
|
+
function mergeCandidateIps(dnsIps, extraIps = []) {
|
|
24218
|
+
return Array.from(/* @__PURE__ */ new Set([...dnsIps, ...extraIps]));
|
|
24219
|
+
}
|
|
24220
|
+
function rankResults(results) {
|
|
24221
|
+
return [...results].sort((a, b) => Number(b.granted) - Number(a.granted));
|
|
24222
|
+
}
|
|
24223
|
+
async function probeBrokerInstance(ip, creds, opts) {
|
|
24224
|
+
const mqtt = await loadMqtt();
|
|
24225
|
+
const start = Date.now();
|
|
24226
|
+
const timeoutMs = opts.timeoutMs ?? 8e3;
|
|
24227
|
+
return await new Promise((resolvePromise) => {
|
|
24228
|
+
let settled = false;
|
|
24229
|
+
const finish = (r) => {
|
|
24230
|
+
if (settled)
|
|
24231
|
+
return;
|
|
24232
|
+
settled = true;
|
|
24233
|
+
clearTimeout(timer);
|
|
24234
|
+
try {
|
|
24235
|
+
client.end(true);
|
|
24236
|
+
} catch {
|
|
24237
|
+
}
|
|
24238
|
+
resolvePromise({ ip, ms: Date.now() - start, ...r });
|
|
24239
|
+
};
|
|
24240
|
+
const client = mqtt.connect({
|
|
24241
|
+
host: ip,
|
|
24242
|
+
port: creds.port ?? 8883,
|
|
24243
|
+
protocol: "mqtts",
|
|
24244
|
+
...bareIpTlsOptions({
|
|
24245
|
+
hostname: creds.hostname,
|
|
24246
|
+
cert: creds.certificate_pem,
|
|
24247
|
+
key: creds.private_key,
|
|
24248
|
+
ca: creds.aws_root_ca1_pem
|
|
24249
|
+
}),
|
|
24250
|
+
clientId: opts.clientId,
|
|
24251
|
+
protocolVersion: 4,
|
|
24252
|
+
keepalive: 60,
|
|
24253
|
+
clean: true,
|
|
24254
|
+
reconnectPeriod: 0,
|
|
24255
|
+
connectTimeout: timeoutMs
|
|
24256
|
+
});
|
|
24257
|
+
const timer = setTimeout(() => finish({ granted: false, error: "timeout" }), timeoutMs);
|
|
24258
|
+
client.on("connect", () => {
|
|
24259
|
+
client.subscribe(opts.topic, { qos: 1 }, (err, granted) => {
|
|
24260
|
+
if (err)
|
|
24261
|
+
return finish({ granted: false, error: err.message });
|
|
24262
|
+
const g = granted?.[0];
|
|
24263
|
+
const denied = !g || g.qos === 128;
|
|
24264
|
+
finish({ granted: !denied, grantedQos: g?.qos });
|
|
24265
|
+
});
|
|
24266
|
+
});
|
|
24267
|
+
client.on("error", (err) => finish({ granted: false, error: err.message }));
|
|
24268
|
+
});
|
|
24269
|
+
}
|
|
24270
|
+
async function discoverReachableInstance(creds, opts) {
|
|
24271
|
+
const dnsIps = await resolveBrokerIps(creds.hostname);
|
|
24272
|
+
const all = mergeCandidateIps(dnsIps, opts.candidateIps);
|
|
24273
|
+
const results = [];
|
|
24274
|
+
for (const ip of all) {
|
|
24275
|
+
const r = await probeBrokerInstance(ip, creds, {
|
|
24276
|
+
clientId: opts.clientIdFor(ip),
|
|
24277
|
+
topic: opts.topic,
|
|
24278
|
+
timeoutMs: opts.perAttemptTimeoutMs
|
|
24279
|
+
});
|
|
24280
|
+
results.push(r);
|
|
24281
|
+
if (r.granted && opts.stopOnFirstGrant)
|
|
24282
|
+
break;
|
|
24283
|
+
}
|
|
24284
|
+
return rankResults(results);
|
|
24285
|
+
}
|
|
24286
|
+
|
|
24287
|
+
// dist/transport/dp-preset.js
|
|
24288
|
+
var RGBCW_CH = {
|
|
24289
|
+
R: [15.932, 0.7035, 0.294],
|
|
24290
|
+
G: [39.83, 0.1629, 0.7208],
|
|
24291
|
+
B: [2.2404, 0.1472, 0.0309],
|
|
24292
|
+
W: [66.28, 0.4681, 0.4182],
|
|
24293
|
+
C: [73.58, 0.3166, 0.3397]
|
|
24294
|
+
};
|
|
24295
|
+
var RGBCW_X = Object.fromEntries(Object.entries(RGBCW_CH).map(([k, [Y, x, y]]) => [k, [x * Y / y, Y, (1 - x - y) * Y / y]]));
|
|
24296
|
+
var RGB_HEX = /^[0-9a-fA-F]{6}$/;
|
|
24297
|
+
var RGBCW_GAMMA = 2.2;
|
|
24298
|
+
var RGBCW_SAT = 1.65;
|
|
24299
|
+
var dec = (b) => Math.pow(b / 255, RGBCW_GAMMA);
|
|
24300
|
+
var enc = (v) => Math.round(255 * Math.pow(Math.max(0, Math.min(1, v)), 1 / RGBCW_GAMMA));
|
|
24301
|
+
function mul3(m, v) {
|
|
24302
|
+
return [
|
|
24303
|
+
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
|
|
24304
|
+
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
|
|
24305
|
+
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2]
|
|
24306
|
+
];
|
|
24307
|
+
}
|
|
24308
|
+
function inv3(m) {
|
|
22464
24309
|
const [a, b, c, d, e, f, g, h, i] = [m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]];
|
|
22465
24310
|
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g, det = a * A + b * B + c * C;
|
|
22466
24311
|
return [
|
|
@@ -22660,10 +24505,10 @@ function dpColorFields(spec) {
|
|
|
22660
24505
|
}
|
|
22661
24506
|
|
|
22662
24507
|
// dist/transport/mqtt/clean-codec.js
|
|
22663
|
-
import { randomUUID as
|
|
24508
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
22664
24509
|
function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
|
|
22665
24510
|
const payload = JSON.stringify({
|
|
22666
|
-
t: opts?.uuid ??
|
|
24511
|
+
t: opts?.uuid ?? randomUUID3(),
|
|
22667
24512
|
protocol: 2,
|
|
22668
24513
|
account_id: accountId,
|
|
22669
24514
|
device_sn: deviceSn,
|
|
@@ -23350,7 +25195,7 @@ function deriveTuyaAccount(eufyUserId, phoneCode) {
|
|
|
23350
25195
|
}
|
|
23351
25196
|
|
|
23352
25197
|
// dist/transport/tuya/sign.js
|
|
23353
|
-
import { createHash as createHash5, createHmac as
|
|
25198
|
+
import { createHash as createHash5, createHmac as createHmac4 } from "node:crypto";
|
|
23354
25199
|
var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
23355
25200
|
"a",
|
|
23356
25201
|
"v",
|
|
@@ -23393,7 +25238,7 @@ var HmacSigner = class {
|
|
|
23393
25238
|
this.key = key ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
|
|
23394
25239
|
}
|
|
23395
25240
|
sign(preimage) {
|
|
23396
|
-
return
|
|
25241
|
+
return createHmac4("sha256", this.key).update(preimage, "utf-8").digest("hex");
|
|
23397
25242
|
}
|
|
23398
25243
|
};
|
|
23399
25244
|
var TUYA_CHKEY = "7cbfe6d8";
|
|
@@ -23402,7 +25247,7 @@ var TUYA_CHKEY = "7cbfe6d8";
|
|
|
23402
25247
|
import { randomBytes as randomBytes4, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
|
|
23403
25248
|
|
|
23404
25249
|
// dist/transport/tuya/request.js
|
|
23405
|
-
import { randomUUID as
|
|
25250
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
23406
25251
|
var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
|
|
23407
25252
|
var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
|
|
23408
25253
|
var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
|
|
@@ -23434,7 +25279,7 @@ function buildApiParams(action, opts) {
|
|
|
23434
25279
|
a: action.a,
|
|
23435
25280
|
v: action.v,
|
|
23436
25281
|
time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
|
|
23437
|
-
requestId: opts.requestId ??
|
|
25282
|
+
requestId: opts.requestId ?? randomUUID4(),
|
|
23438
25283
|
sid: opts.session.sid,
|
|
23439
25284
|
deviceId: opts.session.deviceId,
|
|
23440
25285
|
chKey: opts.session.chKey,
|
|
@@ -23971,7 +25816,7 @@ async function resolveLightEffect(mega, lightId) {
|
|
|
23971
25816
|
}
|
|
23972
25817
|
|
|
23973
25818
|
// dist/transport/push/push-client.js
|
|
23974
|
-
import { EventEmitter as
|
|
25819
|
+
import { EventEmitter as EventEmitter12 } from "node:events";
|
|
23975
25820
|
import tls2 from "node:tls";
|
|
23976
25821
|
|
|
23977
25822
|
// dist/transport/push/proto.js
|
|
@@ -24226,7 +26071,7 @@ var MessageTag;
|
|
|
24226
26071
|
})(MessageTag || (MessageTag = {}));
|
|
24227
26072
|
|
|
24228
26073
|
// dist/transport/push/parser.js
|
|
24229
|
-
import { EventEmitter as
|
|
26074
|
+
import { EventEmitter as EventEmitter11 } from "node:events";
|
|
24230
26075
|
var State;
|
|
24231
26076
|
(function(State2) {
|
|
24232
26077
|
State2[State2["VERSION_TAG_AND_SIZE"] = 0] = "VERSION_TAG_AND_SIZE";
|
|
@@ -24244,7 +26089,7 @@ var TAG_TO_TYPE = {
|
|
|
24244
26089
|
[MessageTag.DataMessageStanza]: "mcs_proto.DataMessageStanza",
|
|
24245
26090
|
[MessageTag.StreamErrorStanza]: "mcs_proto.StreamErrorStanza"
|
|
24246
26091
|
};
|
|
24247
|
-
var McsParser = class extends
|
|
26092
|
+
var McsParser = class extends EventEmitter11 {
|
|
24248
26093
|
data = Buffer.alloc(0);
|
|
24249
26094
|
state = State.VERSION_TAG_AND_SIZE;
|
|
24250
26095
|
messageTag = 0;
|
|
@@ -24422,7 +26267,7 @@ function normalizePushEvent(raw) {
|
|
|
24422
26267
|
raw
|
|
24423
26268
|
};
|
|
24424
26269
|
}
|
|
24425
|
-
var PushClient = class _PushClient extends
|
|
26270
|
+
var PushClient = class _PushClient extends EventEmitter12 {
|
|
24426
26271
|
creds;
|
|
24427
26272
|
logger;
|
|
24428
26273
|
/** Consecutive MCS login rejections tolerated (self-healing propagation) before surfacing an error. */
|
|
@@ -25012,7 +26857,14 @@ function deviceClassOf(codec, realtime) {
|
|
|
25012
26857
|
const cls = CLASS_BY_CODEC[codec];
|
|
25013
26858
|
return cls === "camera" && realtime !== "p2p" ? "other" : cls;
|
|
25014
26859
|
}
|
|
26860
|
+
function recordName(raw, deviceClass) {
|
|
26861
|
+
if (deviceClass === "vacuum") {
|
|
26862
|
+
return raw.alias_name || raw.device_alias_name || raw.device_name || void 0;
|
|
26863
|
+
}
|
|
26864
|
+
return raw.device_name ?? raw.device_alias_name ?? raw.alias_name;
|
|
26865
|
+
}
|
|
25015
26866
|
var LIST_REUSE_MS = 5e3;
|
|
26867
|
+
var CLOUD_DPS_MODELS = /* @__PURE__ */ new Set(["T2351"]);
|
|
25016
26868
|
var DeviceRegistry = class {
|
|
25017
26869
|
mega;
|
|
25018
26870
|
onError;
|
|
@@ -25037,6 +26889,8 @@ var DeviceRegistry = class {
|
|
|
25037
26889
|
overlayRefusalReported = false;
|
|
25038
26890
|
/** Per-serial capability cache for {@link capabilitiesForDevice}; `null` = negative hit. */
|
|
25039
26891
|
deviceCapsCache = /* @__PURE__ */ new Map();
|
|
26892
|
+
/** Per-serial cloud data points of an AIoT robot, joined beneath the params in {@link record} — see {@link loadCloudDps}. */
|
|
26893
|
+
cloudDps = /* @__PURE__ */ new Map();
|
|
25040
26894
|
/** Per-serial realtime state, keyed by param id in the device's own namespace (see {@link DeviceRecord.dpParams}). */
|
|
25041
26895
|
dpParams = /* @__PURE__ */ new Map();
|
|
25042
26896
|
/** Callers blocked in {@link awaitRealtimeState}, released by the device's first report. */
|
|
@@ -25131,21 +26985,24 @@ var DeviceRegistry = class {
|
|
|
25131
26985
|
category: raw.category,
|
|
25132
26986
|
params
|
|
25133
26987
|
}).codec;
|
|
26988
|
+
const deviceClass = deviceClassOf(codec, c.realtime);
|
|
25134
26989
|
seen.set(raw.device_sn, {
|
|
25135
26990
|
sn: raw.device_sn,
|
|
25136
|
-
name: raw
|
|
26991
|
+
name: recordName(raw, deviceClass),
|
|
25137
26992
|
model: raw.device_model,
|
|
25138
26993
|
stationSn: resolvedStationSn(raw, raw.device_sn),
|
|
26994
|
+
...typeof raw.device_channel === "number" ? { channel: raw.device_channel } : {},
|
|
25139
26995
|
p2pDid: raw.p2p_did,
|
|
25140
26996
|
params,
|
|
25141
26997
|
paramUpdatedAt,
|
|
25142
26998
|
lastSeenMs: lastSeenMsOf(paramUpdatedAt),
|
|
25143
26999
|
raw,
|
|
25144
|
-
deviceClass
|
|
27000
|
+
deviceClass,
|
|
25145
27001
|
...c
|
|
25146
27002
|
});
|
|
25147
27003
|
}
|
|
25148
27004
|
}
|
|
27005
|
+
await this.loadCloudDps(seen);
|
|
25149
27006
|
this.lastRefreshPartial = partial;
|
|
25150
27007
|
if (partial) {
|
|
25151
27008
|
for (const prev of this.devices)
|
|
@@ -25236,9 +27093,46 @@ var DeviceRegistry = class {
|
|
|
25236
27093
|
}
|
|
25237
27094
|
return dev;
|
|
25238
27095
|
}
|
|
27096
|
+
/**
|
|
27097
|
+
* Hold an AIoT robot's cloud data points: the cloud's last-known value of each one it has reported,
|
|
27098
|
+
* from `getDeviceRelationList`'s per-device `dps`.
|
|
27099
|
+
*
|
|
27100
|
+
* The device list carries only some of a robot's data points, so a point the robot publishes on change
|
|
27101
|
+
* alone (its scene list, its schedules, its consumables) has no value until it next reports. The
|
|
27102
|
+
* relation list holds every one, for an account the robot is shared with as well as its owner.
|
|
27103
|
+
*
|
|
27104
|
+
* Kept apart from the device list's params rather than merged into them: those are what a poll diffs,
|
|
27105
|
+
* and a diff retires the realtime report for that id, so a cloud value lagging the robot's last report
|
|
27106
|
+
* would replace it. {@link record} joins this beneath both halves. A failure keeps what was held.
|
|
27107
|
+
*
|
|
27108
|
+
* Limited to {@link CLOUD_DPS_MODELS}, the models whose relation-list `dps` are checked to carry the
|
|
27109
|
+
* DP numbers their realtime reports use.
|
|
27110
|
+
*/
|
|
27111
|
+
async loadCloudDps(seen) {
|
|
27112
|
+
const robots = [...seen.values()].filter((d) => CLOUD_DPS_MODELS.has(d.model ?? ""));
|
|
27113
|
+
if (!robots.length)
|
|
27114
|
+
return;
|
|
27115
|
+
let res;
|
|
27116
|
+
try {
|
|
27117
|
+
res = await this.mega.getDeviceRelationList();
|
|
27118
|
+
} catch (e) {
|
|
27119
|
+
if (e instanceof SessionExpiredError)
|
|
27120
|
+
throw e;
|
|
27121
|
+
this.logger.debug(`[registry] device relation list unavailable: ${e?.message ?? e}`);
|
|
27122
|
+
return;
|
|
27123
|
+
}
|
|
27124
|
+
const wanted = new Set(robots.map((d) => d.sn));
|
|
27125
|
+
for (const entry of res.devices ?? []) {
|
|
27126
|
+
const sn = entry?.device?.device_sn;
|
|
27127
|
+
const dps = parseAiotDpReport({ payload: entry?.device?.dps });
|
|
27128
|
+
if (sn && wanted.has(sn) && dps)
|
|
27129
|
+
this.cloudDps.set(sn, dps);
|
|
27130
|
+
}
|
|
27131
|
+
}
|
|
25239
27132
|
/**
|
|
25240
27133
|
* 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.
|
|
27134
|
+
* overlays a fresh `get_device_param_list` when that call is available to this account. An AIoT robot's
|
|
27135
|
+
* cloud data points ({@link loadCloudDps}) sit beneath both, filling only ids neither carries.
|
|
25242
27136
|
*
|
|
25243
27137
|
* The overlay is **owner-gated** — a shared or member account is refused it for every device, permanently,
|
|
25244
27138
|
* which {@link OWNER_ONLY_CODE} identifies — so when it is unavailable the device list is the source
|
|
@@ -25267,7 +27161,7 @@ var DeviceRegistry = class {
|
|
|
25267
27161
|
let dev = this.devices.find((d) => d.sn === sn);
|
|
25268
27162
|
if (!dev)
|
|
25269
27163
|
throw new Error(`device ${sn} not found (have: ${this.devices.map((d) => d.sn).join(", ")})`);
|
|
25270
|
-
const params = { ...dev.params ?? {} };
|
|
27164
|
+
const params = { ...this.cloudDps.get(sn), ...dev.params ?? {} };
|
|
25271
27165
|
const paramUpdatedAt = { ...dev.paramUpdatedAt ?? {} };
|
|
25272
27166
|
if (!this.overlayRefused.has(sn)) {
|
|
25273
27167
|
try {
|
|
@@ -25560,6 +27454,8 @@ function tuyaDevIdFrom(raw) {
|
|
|
25560
27454
|
var DEFAULT_POLL_MS = 6e5;
|
|
25561
27455
|
var DEFAULT_PREWARM_TIERS = ["wired", "battery"];
|
|
25562
27456
|
var SESSION_RECYCLE_WAIT_MS = 5e3;
|
|
27457
|
+
var PUSH_RETRY_MIN_MS = 3e4;
|
|
27458
|
+
var PUSH_RETRY_MAX_MS = 6e5;
|
|
25563
27459
|
var RealtimeStartupSupersededError = class extends Error {
|
|
25564
27460
|
};
|
|
25565
27461
|
var RefreshWindowClosedError = class extends Error {
|
|
@@ -25575,7 +27471,7 @@ function readinessSnapshot(readiness, state = readiness.state) {
|
|
|
25575
27471
|
wiredP2p: Object.freeze({ ...readiness.wiredP2p })
|
|
25576
27472
|
});
|
|
25577
27473
|
}
|
|
25578
|
-
var EufyMega = class extends
|
|
27474
|
+
var EufyMega = class extends EventEmitter13 {
|
|
25579
27475
|
opts;
|
|
25580
27476
|
mega;
|
|
25581
27477
|
/**
|
|
@@ -25635,6 +27531,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25635
27531
|
prewarmTiers;
|
|
25636
27532
|
/** Transport-side owner of the P2P sessions + all wire senders. */
|
|
25637
27533
|
p2p;
|
|
27534
|
+
/** Transport-side owner of the T9000 station sessions (sibling of {@link p2p}). */
|
|
27535
|
+
rtc;
|
|
25638
27536
|
/** Transport-side owner of the secure-MQTT ff09 lock/garage command path (sibling of {@link p2p}). */
|
|
25639
27537
|
mqtt;
|
|
25640
27538
|
/** Transport-side owner of the legacy Tuya REST command path for non-AIoT vacuums (G-series). */
|
|
@@ -25656,6 +27554,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25656
27554
|
mapStores = /* @__PURE__ */ new Map();
|
|
25657
27555
|
/** Re-armed after each cloud-param poll; cancelled by {@link disconnect}. */
|
|
25658
27556
|
pollTimer = new Timer();
|
|
27557
|
+
/** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
|
|
27558
|
+
pushRetryTimer = new Timer();
|
|
25659
27559
|
constructor(opts) {
|
|
25660
27560
|
super();
|
|
25661
27561
|
this.opts = opts;
|
|
@@ -25695,6 +27595,14 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25695
27595
|
onLevel2Ready: (sn, cipherId) => this.emit("p2pLevel2Ready", { stationSn: sn, cipherId }),
|
|
25696
27596
|
onFrame: (stationSn, f) => this.onP2PFrame(stationSn, f)
|
|
25697
27597
|
});
|
|
27598
|
+
this.rtc = new RtcCommandRouter({
|
|
27599
|
+
identity: () => this.mega.rtcIdentity(),
|
|
27600
|
+
shard: () => this.mega.rtcShard,
|
|
27601
|
+
country: opts.countryCode,
|
|
27602
|
+
logger: opts.logger,
|
|
27603
|
+
ffmpegPath: opts.ffmpegPath,
|
|
27604
|
+
onError: (e) => this.reportError(e)
|
|
27605
|
+
});
|
|
25698
27606
|
this.mqtt = new MqttCommandRouter({
|
|
25699
27607
|
mega: this.mega,
|
|
25700
27608
|
logger: opts.logger,
|
|
@@ -25960,13 +27868,22 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25960
27868
|
*
|
|
25961
27869
|
* The evidence set is widened, never replaced: the ids come back through the cloud record, and a
|
|
25962
27870
|
* record that omits a realtime-only id would otherwise un-know it and re-trigger on the next report.
|
|
27871
|
+
*
|
|
27872
|
+
* The device is re-resolved against the fresh record before binding. A property gated on a
|
|
27873
|
+
* realtime-only param enters the schema only once that param is evidence, and the value that made it
|
|
27874
|
+
* evidence was stored before the property existed, so the record's realtime params are applied again
|
|
27875
|
+
* to land it under the property's name.
|
|
25963
27876
|
*/
|
|
25964
27877
|
async rebindReads(sn) {
|
|
25965
27878
|
const dev = this.liveDevices.get(sn)?.deref();
|
|
25966
27879
|
if (!dev)
|
|
25967
27880
|
return;
|
|
25968
27881
|
try {
|
|
25969
|
-
const
|
|
27882
|
+
const rec = await this.registry.record(sn);
|
|
27883
|
+
dev.reresolve(rec);
|
|
27884
|
+
if (rec.dpParams)
|
|
27885
|
+
this.applyAndAnnounce(dev, rec.dpParams);
|
|
27886
|
+
const ctx = await this.commandContext(sn, rec);
|
|
25970
27887
|
dev.bindActions(ctx, this.commandSinkFor(sn), this.mediaProviderFor(sn), this.ff09SettingsReaderFor(sn, ctx), rawDpCodec);
|
|
25971
27888
|
this.boundParamIds.set(sn, /* @__PURE__ */ new Set([...this.boundParamIds.get(sn) ?? [], ...ctx.paramIds]));
|
|
25972
27889
|
this.emit("deviceState", this.deviceState(sn));
|
|
@@ -26026,6 +27943,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26026
27943
|
* only "differs from what was read before", which state already on hand can satisfy spuriously — and the
|
|
26027
27944
|
* caller that has no expectation is the push path, where the signal itself is the news that a re-read is owed.
|
|
26028
27945
|
*
|
|
27946
|
+
* Only a write's observation throws at the deadline; a push with nothing moved answers `false`.
|
|
27947
|
+
*
|
|
26029
27948
|
* The cloud half is asked for through {@link DeviceRegistry.refreshedList}, never by fetching the account
|
|
26030
27949
|
* list outright. The fetch is account-wide — one house list plus one device list per house — so a param that
|
|
26031
27950
|
* never converges would otherwise spend a whole burst of those every iteration of this loop, and concurrent
|
|
@@ -26076,6 +27995,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26076
27995
|
if (refresh.expected !== void 0 && settled())
|
|
26077
27996
|
return true;
|
|
26078
27997
|
}
|
|
27998
|
+
if (refresh.expected === void 0)
|
|
27999
|
+
return false;
|
|
26079
28000
|
throw new StateConvergenceError({
|
|
26080
28001
|
sn,
|
|
26081
28002
|
property: refresh.property,
|
|
@@ -26381,6 +28302,28 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26381
28302
|
}
|
|
26382
28303
|
};
|
|
26383
28304
|
}
|
|
28305
|
+
/**
|
|
28306
|
+
* The station a device's commands go through: the device itself, or the HomeBase the registry links it
|
|
28307
|
+
* to. Answers the target record, the station record and serial, the station's `device_type`, and the
|
|
28308
|
+
* `t9000` / `t8n00` family flags.
|
|
28309
|
+
*/
|
|
28310
|
+
stationOf(sn) {
|
|
28311
|
+
const devices = this.registry.list();
|
|
28312
|
+
const target = devices.find((d) => d.sn === sn);
|
|
28313
|
+
const stationSn = target?.stationSn || sn;
|
|
28314
|
+
const station = devices.find((d) => d.sn === stationSn);
|
|
28315
|
+
const raw = station?.raw ?? {};
|
|
28316
|
+
const deviceType = typeof raw.device_type === "number" ? raw.device_type : void 0;
|
|
28317
|
+
const family = { deviceType, model: station?.model };
|
|
28318
|
+
return { target, station, stationSn, raw, deviceType, t9000: isStation9000(family), t8n00: isNvrT8N00(family) };
|
|
28319
|
+
}
|
|
28320
|
+
/** The vendor DeviceType of the station a device hangs off; `undefined` for a station itself. */
|
|
28321
|
+
stationDeviceTypeOf(sn) {
|
|
28322
|
+
const st = this.stationOf(sn);
|
|
28323
|
+
if (!st.station || st.stationSn === sn)
|
|
28324
|
+
return void 0;
|
|
28325
|
+
return st.deviceType;
|
|
28326
|
+
}
|
|
26384
28327
|
/**
|
|
26385
28328
|
* Combine explicit P2P media with the optional passive push-thumbnail provider.
|
|
26386
28329
|
*
|
|
@@ -26391,7 +28334,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26391
28334
|
* current. With nothing retained the refusal stands.
|
|
26392
28335
|
*/
|
|
26393
28336
|
mediaProviderFor(sn) {
|
|
26394
|
-
const media = this.p2p.mediaProviderFor(sn);
|
|
28337
|
+
const media = this.rtcMediaFor(sn) ?? this.p2p.mediaProviderFor(sn);
|
|
26395
28338
|
const cache = this.storedImages;
|
|
26396
28339
|
if (!cache)
|
|
26397
28340
|
return media;
|
|
@@ -26428,8 +28371,10 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26428
28371
|
* The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
|
|
26429
28372
|
* Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
|
|
26430
28373
|
* (`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
|
-
*
|
|
28374
|
+
* kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000 or T8N00
|
|
28375
|
+
* station and the devices attached to it go over RTC, with an attached device's command refused
|
|
28376
|
+
* unless its channel resolves back to it on the station. T8N00 uses full SDP and admits direct and
|
|
28377
|
+
* relay candidates; T9000 retains compact SDP and relay-only candidates. Everything else is P2P.
|
|
26433
28378
|
*/
|
|
26434
28379
|
routeCommand(sn, cmd) {
|
|
26435
28380
|
if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
|
|
@@ -26449,8 +28394,42 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26449
28394
|
return this.tuya.dispatchCommand(sn, cmd);
|
|
26450
28395
|
return this.mqtt.dispatchCommand(sn, cmd);
|
|
26451
28396
|
}
|
|
28397
|
+
const route = this.rtcRouteFor(sn);
|
|
28398
|
+
if (route) {
|
|
28399
|
+
if (route.attached && this.registry.serialForFrame(route.stationSn, cmd.channel) !== sn)
|
|
28400
|
+
return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
|
|
28401
|
+
return this.rtc.dispatchCommand(route, cmd);
|
|
28402
|
+
}
|
|
26452
28403
|
return this.p2p.dispatchCommand(sn, cmd);
|
|
26453
28404
|
}
|
|
28405
|
+
/**
|
|
28406
|
+
* The RTC route for a T9000 or T8N00 station or a device attached to one, decided by the station's model
|
|
28407
|
+
* classification; `undefined` for any other device. A T8N00 route carries its native `call` signalling. The admin id is the station's
|
|
28408
|
+
* `member.admin_user_id`, else the logged-in user.
|
|
28409
|
+
*/
|
|
28410
|
+
rtcRouteFor(sn) {
|
|
28411
|
+
const { target, station, stationSn, raw, t9000, t8n00: native } = this.stationOf(sn);
|
|
28412
|
+
if (!target || !station || !(t9000 || native))
|
|
28413
|
+
return void 0;
|
|
28414
|
+
const member = raw.member?.admin_user_id;
|
|
28415
|
+
const adminUserId = typeof member === "string" && member ? member : void 0;
|
|
28416
|
+
const base = { stationSn, adminUserId, attached: stationSn !== sn };
|
|
28417
|
+
return native ? { ...base, signalingMode: "call", iceTransportPolicy: "all" } : base;
|
|
28418
|
+
}
|
|
28419
|
+
/**
|
|
28420
|
+
* The RTC media provider for a camera attached to a T9000 whose `device_channel` resolves back to it on
|
|
28421
|
+
* the station; `undefined` otherwise, including a camera behind a T8N00, whose route carries commands only.
|
|
28422
|
+
*/
|
|
28423
|
+
rtcMediaFor(sn) {
|
|
28424
|
+
const route = this.rtcRouteFor(sn);
|
|
28425
|
+
if (!route?.attached || route.signalingMode === "call")
|
|
28426
|
+
return void 0;
|
|
28427
|
+
const raw = this.registry.list().find((d) => d.sn === sn)?.raw ?? {};
|
|
28428
|
+
const channel = typeof raw.device_channel === "number" ? raw.device_channel : void 0;
|
|
28429
|
+
if (channel === void 0 || this.registry.serialForFrame(route.stationSn, channel) !== sn)
|
|
28430
|
+
return void 0;
|
|
28431
|
+
return this.rtc.mediaProviderFor({ ...route, cameraSn: sn, channel });
|
|
28432
|
+
}
|
|
26454
28433
|
/**
|
|
26455
28434
|
* List + classify devices across all houses (mega API). Each device is tagged with its API backend
|
|
26456
28435
|
* + realtime transport. Camera/HomeBase records still appear here for inventory; driving them is
|
|
@@ -26683,13 +28662,38 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26683
28662
|
this.schedulePoll();
|
|
26684
28663
|
const failures = readiness.push.failed + readiness.mqtt.failed + readiness.wiredP2p.failed;
|
|
26685
28664
|
this.settleRealtimeGeneration(generation, failures || startupFailed ? "partial" : "ready");
|
|
28665
|
+
if (readiness.push.failed)
|
|
28666
|
+
this.retryPush(generation);
|
|
26686
28667
|
} catch (e) {
|
|
26687
28668
|
this.reportError(e);
|
|
26688
28669
|
readiness.push.failed += readiness.push.pending;
|
|
26689
28670
|
readiness.push.pending = 0;
|
|
26690
28671
|
this.settleRealtimeGeneration(generation, epoch === this.realtimeEpoch ? "partial" : "superseded");
|
|
28672
|
+
if (epoch === this.realtimeEpoch)
|
|
28673
|
+
this.retryPush(generation);
|
|
26691
28674
|
}
|
|
26692
28675
|
}
|
|
28676
|
+
/**
|
|
28677
|
+
* Bring push up again after the generation's startup attempt failed, backing off between attempts
|
|
28678
|
+
* until one connects, the generation is superseded, or it is aborted by {@link disconnect}.
|
|
28679
|
+
*
|
|
28680
|
+
* The MCS socket reconnects on its own only once it has connected. The settled
|
|
28681
|
+
* {@link RealtimeReadiness} is a startup snapshot and is not revised when a retry connects.
|
|
28682
|
+
*/
|
|
28683
|
+
retryPush(generation, delayMs = PUSH_RETRY_MIN_MS) {
|
|
28684
|
+
this.pushRetryTimer.arm(delayMs, () => {
|
|
28685
|
+
this.startPush(generation.abort.signal).then((client) => {
|
|
28686
|
+
if (generation.epoch !== this.realtimeEpoch)
|
|
28687
|
+
return client.close();
|
|
28688
|
+
this.pushClient = client;
|
|
28689
|
+
}, (error) => {
|
|
28690
|
+
if (generation.epoch !== this.realtimeEpoch)
|
|
28691
|
+
return;
|
|
28692
|
+
this.reportError(error);
|
|
28693
|
+
this.retryPush(generation, Math.min(delayMs * 2, PUSH_RETRY_MAX_MS));
|
|
28694
|
+
});
|
|
28695
|
+
});
|
|
28696
|
+
}
|
|
26693
28697
|
/** Resolve a generation with an immutable count snapshot; later transport completions are ignored. */
|
|
26694
28698
|
settleRealtimeGeneration(generation, state) {
|
|
26695
28699
|
if (generation.settled)
|
|
@@ -27287,16 +29291,20 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27287
29291
|
await this.commandSinkFor(sn).dispatch(cmd);
|
|
27288
29292
|
}
|
|
27289
29293
|
/**
|
|
27290
|
-
* Restart a HomeBase.
|
|
29294
|
+
* Restart a station: a HomeBase, or a camera-family device that is its own station.
|
|
27291
29295
|
*
|
|
27292
|
-
*
|
|
27293
|
-
*
|
|
27294
|
-
*
|
|
29296
|
+
* The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
|
|
29297
|
+
* P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
|
|
29298
|
+
* A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
|
|
29299
|
+
* throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
|
|
29300
|
+
* and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
|
|
29301
|
+
* Verified on real hardware against a HomeBase and a standalone indoor camera.
|
|
27295
29302
|
*/
|
|
27296
29303
|
async reboot(sn) {
|
|
27297
29304
|
const ctx = await this.commandContext(sn);
|
|
27298
|
-
|
|
27299
|
-
|
|
29305
|
+
const standaloneCamera = ctx.codec === "camera" && this.p2p.stationKeyOf(sn) === sn;
|
|
29306
|
+
if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model }) && !standaloneCamera) {
|
|
29307
|
+
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
29308
|
}
|
|
27301
29309
|
await this.p2p.rebootStation(sn);
|
|
27302
29310
|
}
|
|
@@ -27328,8 +29336,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27328
29336
|
return EMPTY_DP_CATALOG;
|
|
27329
29337
|
}
|
|
27330
29338
|
}
|
|
27331
|
-
|
|
27332
|
-
|
|
29339
|
+
/** The {@link CommandContext} for a device, built from `known` when that record is already in hand. */
|
|
29340
|
+
async commandContext(sn, known) {
|
|
29341
|
+
const rec = known ?? await this.registry.record(sn);
|
|
27333
29342
|
const dev = this.registry.require(sn);
|
|
27334
29343
|
const raw = dev.raw ?? {};
|
|
27335
29344
|
const rawChannel = raw.device_channel;
|
|
@@ -27366,6 +29375,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27366
29375
|
hasP2p: P2PCommandRouter.claimsDevice(dev),
|
|
27367
29376
|
// Topology as the record states it: a parent that isn't the device itself means HomeBase-attached.
|
|
27368
29377
|
homeBaseAttached: !!raw.parent_sn && raw.parent_sn !== dev.sn,
|
|
29378
|
+
stationDeviceType: this.stationDeviceTypeOf(sn),
|
|
27369
29379
|
dpCatalog
|
|
27370
29380
|
};
|
|
27371
29381
|
}
|
|
@@ -27540,8 +29550,10 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27540
29550
|
*/
|
|
27541
29551
|
async teardownRealtime() {
|
|
27542
29552
|
this.pollTimer.cancel();
|
|
29553
|
+
this.pushRetryTimer.cancel();
|
|
27543
29554
|
this.lastStateAnnounced.clear();
|
|
27544
29555
|
await this.closeMqttTransports();
|
|
29556
|
+
this.rtc.close();
|
|
27545
29557
|
await this.p2p.closeAll();
|
|
27546
29558
|
this.pushClient?.close();
|
|
27547
29559
|
this.pushClient = void 0;
|
|
@@ -28046,7 +30058,7 @@ var SolixClient = class _SolixClient {
|
|
|
28046
30058
|
};
|
|
28047
30059
|
|
|
28048
30060
|
// dist/transport/mqtt/solix-mqtt.js
|
|
28049
|
-
import { EventEmitter as
|
|
30061
|
+
import { EventEmitter as EventEmitter14 } from "node:events";
|
|
28050
30062
|
var SOLIX_METER_FIELD_NAMES = {
|
|
28051
30063
|
168: "meterPowerL1",
|
|
28052
30064
|
// Anker Modbus: primary_phase_1_active_power
|
|
@@ -28224,7 +30236,7 @@ function addSolarbankScalars(frame, out) {
|
|
|
28224
30236
|
out.gridExportLimit = df.readUInt16LE(5);
|
|
28225
30237
|
}
|
|
28226
30238
|
}
|
|
28227
|
-
var SolixMqtt = class extends
|
|
30239
|
+
var SolixMqtt = class extends EventEmitter14 {
|
|
28228
30240
|
transport;
|
|
28229
30241
|
appName;
|
|
28230
30242
|
userId;
|