@mega-yfue/eufy-sdk 0.5.0-beta.5 → 0.5.0-beta.51
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 +57 -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 +2571 -305
- 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/index.d.ts +7 -2
- 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 +11 -0
- package/dist/transport/abortable.d.ts +8 -0
- package/dist/transport/http/mega-client.d.ts +26 -0
- package/dist/transport/mqtt/broker-discovery.d.ts +2 -1
- package/dist/transport/mqtt/command-router.d.ts +3 -2
- package/dist/transport/p2p/command-router.d.ts +14 -26
- package/dist/transport/p2p/fmp4.d.ts +12 -2
- package/dist/transport/p2p/live-stream.d.ts +3 -0
- package/dist/transport/p2p/p2p-session.d.ts +23 -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 +128 -0
- package/dist/transport/rtc/live.d.ts +69 -0
- package/dist/transport/rtc/peer.d.ts +104 -0
- package/dist/transport/rtc/portal-keepalive.d.ts +11 -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 +90 -0
- package/dist/transport/rtc/signaling.d.ts +139 -0
- package/dist/transport/token-echo.d.ts +8 -0
- package/dist/transport/tuya/account.d.ts +8 -5
- package/dist/transport/tuya/client.d.ts +16 -3
- package/dist/transport/tuya/command-router.d.ts +1 -1
- package/dist/transport/tuya/et3.d.ts +22 -0
- package/dist/transport/tuya/request.d.ts +16 -6
- package/dist/transport/tuya/sign.d.ts +7 -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";
|
|
@@ -196,6 +196,11 @@ var ConsoleLogger = class {
|
|
|
196
196
|
}
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
// dist/transport/token-echo.js
|
|
200
|
+
function withoutTokenEcho(text2) {
|
|
201
|
+
return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
|
|
202
|
+
}
|
|
203
|
+
|
|
199
204
|
// dist/transport/http/media-download.js
|
|
200
205
|
import { lookup } from "node:dns/promises";
|
|
201
206
|
import { BlockList, isIP } from "node:net";
|
|
@@ -615,7 +620,8 @@ function scanEntropy(tail, subsampling, extraTables) {
|
|
|
615
620
|
// dist/transport/http/decodeImageV2.js
|
|
616
621
|
import { decode as jpegDecode } from "jpeg-js";
|
|
617
622
|
var V2_PREFIX = "v2_eufysecurity:";
|
|
618
|
-
var
|
|
623
|
+
var DRI_SEGMENT = Buffer.from([255, 221, 0, 4]);
|
|
624
|
+
var SOS_HEADER = Buffer.from([255, 218, 0, 12, 3]);
|
|
619
625
|
var ZIGZAG = [
|
|
620
626
|
0,
|
|
621
627
|
1,
|
|
@@ -816,6 +822,9 @@ var QUANT_CHROMA = [
|
|
|
816
822
|
];
|
|
817
823
|
var DHT_DC_LUMA = Buffer.from("ffc4001f0000010501010101010100000000000000000102030405060708090a0b", "hex");
|
|
818
824
|
var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9fa", "hex");
|
|
825
|
+
var DHT_DC_CHROMA = Buffer.from("ffc4001f0100030101010101010101010000000000000102030405060708090a0b", "hex");
|
|
826
|
+
var DC_CHROMA = DHT_DC_CHROMA.subarray(0, 5);
|
|
827
|
+
var DHT_AC_CHROMA = Buffer.from("ffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9fa", "hex");
|
|
819
828
|
var SOI = Buffer.from([255, 216]);
|
|
820
829
|
var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
|
|
821
830
|
var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
|
|
@@ -968,16 +977,31 @@ function findGeometry(tail) {
|
|
|
968
977
|
}
|
|
969
978
|
return best;
|
|
970
979
|
}
|
|
980
|
+
function findScanCut(data) {
|
|
981
|
+
const sos = data.lastIndexOf(SOS_HEADER);
|
|
982
|
+
if (sos < 0)
|
|
983
|
+
return -1;
|
|
984
|
+
const dri = sos - DRI_SEGMENT.length - 2;
|
|
985
|
+
return dri >= 0 && data.subarray(dri, dri + DRI_SEGMENT.length).equals(DRI_SEGMENT) ? dri : -1;
|
|
986
|
+
}
|
|
987
|
+
function plaintextTail(data) {
|
|
988
|
+
const cut = data.indexOf(DC_CHROMA);
|
|
989
|
+
if (cut >= 0)
|
|
990
|
+
return data.subarray(cut);
|
|
991
|
+
const scan = findScanCut(data);
|
|
992
|
+
if (scan < 0)
|
|
993
|
+
return null;
|
|
994
|
+
return Buffer.concat([DHT_DC_CHROMA, DHT_AC_CHROMA, data.subarray(scan)]);
|
|
995
|
+
}
|
|
971
996
|
function isV2Image(data) {
|
|
972
997
|
return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
|
|
973
998
|
}
|
|
974
999
|
function decodeImageV2(data) {
|
|
975
1000
|
if (!isV2Image(data))
|
|
976
1001
|
return null;
|
|
977
|
-
const
|
|
978
|
-
if (
|
|
1002
|
+
const tail = plaintextTail(data);
|
|
1003
|
+
if (!tail)
|
|
979
1004
|
return null;
|
|
980
|
-
const tail = data.subarray(cut);
|
|
981
1005
|
const geometry = findGeometry(tail);
|
|
982
1006
|
if (!geometry)
|
|
983
1007
|
return null;
|
|
@@ -1557,12 +1581,11 @@ var CONTENDED_SESSION_HINT = "the token was rejected again soon after it was las
|
|
|
1557
1581
|
function tokenRejected(code, msg) {
|
|
1558
1582
|
return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
|
|
1559
1583
|
}
|
|
1560
|
-
function withoutTokenEcho(text2) {
|
|
1561
|
-
return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
|
|
1562
|
-
}
|
|
1563
1584
|
var MegaHttpClient = class {
|
|
1564
1585
|
cfg;
|
|
1565
1586
|
region;
|
|
1587
|
+
/** The host `estimate_domain` answered, persisted with the session. */
|
|
1588
|
+
estimatedDomain = "";
|
|
1566
1589
|
bootstrapDomain;
|
|
1567
1590
|
sessionKey;
|
|
1568
1591
|
/** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
|
|
@@ -1643,6 +1666,7 @@ var MegaHttpClient = class {
|
|
|
1643
1666
|
if (!isSessionValid(saved) || !saved)
|
|
1644
1667
|
return void 0;
|
|
1645
1668
|
this.region = saved.region;
|
|
1669
|
+
this.estimatedDomain = saved.estimatedDomain ?? "";
|
|
1646
1670
|
this.auth_ = {
|
|
1647
1671
|
userId: saved.userId,
|
|
1648
1672
|
accountUserId: saved.accountUserId,
|
|
@@ -1667,6 +1691,24 @@ var MegaHttpClient = class {
|
|
|
1667
1691
|
get regionShard() {
|
|
1668
1692
|
return this.region;
|
|
1669
1693
|
}
|
|
1694
|
+
/**
|
|
1695
|
+
* The shard the RTC signalling signs on: the regional prefix of the estimated domain
|
|
1696
|
+
* (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
|
|
1697
|
+
* like `ie-pr`, whose accounts need the IE sign host and cluster.
|
|
1698
|
+
*/
|
|
1699
|
+
get rtcShard() {
|
|
1700
|
+
const m = /-([a-z]{2})(?:[.-]|$)/i.exec(this.estimatedDomain);
|
|
1701
|
+
return m ? `${m[1].toLowerCase()}-pr` : this.region;
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
|
|
1705
|
+
* ("gtoken not equal userid") unless its gtoken is the one every authed HTTP call carries.
|
|
1706
|
+
*/
|
|
1707
|
+
rtcIdentity() {
|
|
1708
|
+
if (!this.auth_)
|
|
1709
|
+
return void 0;
|
|
1710
|
+
return { authToken: this.auth_.authToken, userId: this.auth_.userId, gtoken: gtoken(this.gtokenUserId()) };
|
|
1711
|
+
}
|
|
1670
1712
|
/**
|
|
1671
1713
|
* The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
|
|
1672
1714
|
* pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
|
|
@@ -1764,6 +1806,7 @@ var MegaHttpClient = class {
|
|
|
1764
1806
|
const env = res.data;
|
|
1765
1807
|
const d = env?.data ?? {};
|
|
1766
1808
|
const domain = d.domain ?? d.host ?? d.server_secret_info ?? "";
|
|
1809
|
+
this.estimatedDomain = domain;
|
|
1767
1810
|
const blob = JSON.stringify(d);
|
|
1768
1811
|
if (blob.includes("-eu-") || domain.includes("-eu-"))
|
|
1769
1812
|
this.region = "eu-pr";
|
|
@@ -1974,6 +2017,17 @@ ${this.auth_?.authToken ?? ""}`;
|
|
|
1974
2017
|
device_sns: [deviceSn]
|
|
1975
2018
|
});
|
|
1976
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Fetch the account's devices as the `devicerelation` service lists them, `{attribute: 7, house_id: ""}`.
|
|
2022
|
+
*
|
|
2023
|
+
* Each entry is `{device: {device_sn, dps, …}}`, and `dps` is the cloud's last-known value of every
|
|
2024
|
+
* data point that device has reported, keyed by DP number. Answered for an account a device is shared
|
|
2025
|
+
* with as well as for its owner. Sent with `category: eufy_home`, without which the service answers
|
|
2026
|
+
* `10000`.
|
|
2027
|
+
*/
|
|
2028
|
+
getDeviceRelationList() {
|
|
2029
|
+
return this.post("devicerelation", "/app/devicerelation/get_device_list", { attribute: 7, house_id: "" }, { category: "eufy_home" });
|
|
2030
|
+
}
|
|
1977
2031
|
/**
|
|
1978
2032
|
* Fetch one page of a device's **cleaning history** from the mega `clean` service.
|
|
1979
2033
|
*
|
|
@@ -2422,6 +2476,7 @@ ${this.auth_?.authToken ?? ""}`;
|
|
|
2422
2476
|
authToken: this.auth_.authToken,
|
|
2423
2477
|
geoKey: this.auth_.geoKey,
|
|
2424
2478
|
region: this.region,
|
|
2479
|
+
estimatedDomain: this.estimatedDomain,
|
|
2425
2480
|
openudid: this.openudid,
|
|
2426
2481
|
phoneModel: this.phoneModel,
|
|
2427
2482
|
mediaUserAgent: this.mediaUserAgent,
|
|
@@ -3034,8 +3089,7 @@ var STATION_TYPES = /* @__PURE__ */ new Set([
|
|
|
3034
3089
|
DeviceType.STATION_9000,
|
|
3035
3090
|
DeviceType.MINIBASE_CHIME,
|
|
3036
3091
|
DeviceType.HOMEBASE_MINI,
|
|
3037
|
-
DeviceType.NVR_S4_MAX
|
|
3038
|
-
DeviceType.CAMERA_POE_S4
|
|
3092
|
+
DeviceType.NVR_S4_MAX
|
|
3039
3093
|
]);
|
|
3040
3094
|
var LOCK_TYPES = /* @__PURE__ */ new Set([
|
|
3041
3095
|
DeviceType.LOCK_BLE,
|
|
@@ -3109,7 +3163,7 @@ function codecFromModel(model) {
|
|
|
3109
3163
|
return "vacuum";
|
|
3110
3164
|
if (/^T8(00[0-9]|010|023|025|030)/.test(m))
|
|
3111
3165
|
return "station";
|
|
3112
|
-
if (/^T8N0/.test(m)
|
|
3166
|
+
if (/^T8N0/.test(m))
|
|
3113
3167
|
return "station";
|
|
3114
3168
|
if (/^T9000/.test(m))
|
|
3115
3169
|
return "station";
|
|
@@ -3280,6 +3334,7 @@ function propertiesOf(members, ctx) {
|
|
|
3280
3334
|
raw: m.decode ? true : void 0,
|
|
3281
3335
|
readAliases: aliases?.slice(promoted ? 1 : 0).map(({ paramType, invert }) => ({ paramType, invert })),
|
|
3282
3336
|
writable: m.write !== void 0 || m.writtenElsewhere === true,
|
|
3337
|
+
reported: ctx?.paramIds ? wasReported(m, borrowedBy(m, members), { ...ctx, paramIds: ctx.paramIds }) : void 0,
|
|
3283
3338
|
description: m.description
|
|
3284
3339
|
}
|
|
3285
3340
|
];
|
|
@@ -3355,12 +3410,16 @@ function narrow(type, read, prop) {
|
|
|
3355
3410
|
return readNum(read, prop);
|
|
3356
3411
|
}
|
|
3357
3412
|
function reads(m, ctx) {
|
|
3358
|
-
|
|
3359
|
-
|
|
3413
|
+
return m.realtime === true || sent(m, ctx);
|
|
3414
|
+
}
|
|
3415
|
+
function sent(m, ctx) {
|
|
3360
3416
|
if ((!m.readAvailable || m.readAvailable(ctx)) && m.param !== void 0 && ctx.paramIds.has(m.param))
|
|
3361
3417
|
return true;
|
|
3362
3418
|
return m.readAliases?.some((a) => (!a.available || a.available(ctx)) && ctx.paramIds.has(a.paramType)) === true;
|
|
3363
3419
|
}
|
|
3420
|
+
function wasReported(m, from, ctx) {
|
|
3421
|
+
return sent(m, ctx) || from !== void 0 && ctx.paramIds.has(from.param);
|
|
3422
|
+
}
|
|
3364
3423
|
function installs(m, ctx) {
|
|
3365
3424
|
return reports(m.requires, ctx) && (!m.requiresRead || reads(m, ctx)) && (!m.available || m.available(ctx));
|
|
3366
3425
|
}
|
|
@@ -3397,7 +3456,7 @@ function bindMembers(members, deps) {
|
|
|
3397
3456
|
const from = borrowedBy(m, members);
|
|
3398
3457
|
const borrowed = from?.property;
|
|
3399
3458
|
const available = !m.available || m.available(ctx);
|
|
3400
|
-
const reported = available && (
|
|
3459
|
+
const reported = available && (m.realtime === true || wasReported(m, from, ctx));
|
|
3401
3460
|
if (reported && !m.writeOnly && !m.unexposed) {
|
|
3402
3461
|
const decode = m.decode;
|
|
3403
3462
|
const raw = () => read(prop)?.value ?? (borrowed === void 0 ? void 0 : read(borrowed)?.value);
|
|
@@ -4219,6 +4278,10 @@ var MOTION_MEMBERS = {
|
|
|
4219
4278
|
* The raw sensitivity an indoor camera reports, on the one id whose write takes a `1700` control
|
|
4220
4279
|
* payload carrying an index rather than the `1350` envelope its siblings use — a frame-shape
|
|
4221
4280
|
* difference `sensitivityCommand` resolves from the scale. `unexposed` like its three siblings.
|
|
4281
|
+
*
|
|
4282
|
+
* Some devices report 6041 as a base64 JSON list of per-sensor entries
|
|
4283
|
+
* (`{"list":[{"sensor_id":…,"sensitivity":…}]}`) rather than a scalar. That value is stored verbatim
|
|
4284
|
+
* and this getter answers `undefined` for it: only the scalar form is confirmed to be this step.
|
|
4222
4285
|
*/
|
|
4223
4286
|
indoorSensitivity: {
|
|
4224
4287
|
param: MOTION_CMD.INDOOR_SENSITIVITY_INDEX,
|
|
@@ -4226,7 +4289,8 @@ var MOTION_MEMBERS = {
|
|
|
4226
4289
|
kind: "scalar",
|
|
4227
4290
|
provenance: "verified",
|
|
4228
4291
|
unexposed: true,
|
|
4229
|
-
description: "Raw sensitivity an indoor camera reports (verified: param 6041)."
|
|
4292
|
+
description: "Raw sensitivity an indoor camera reports (verified: param 6041).",
|
|
4293
|
+
decode: (raw) => typeof raw === "number" ? raw : void 0
|
|
4230
4294
|
},
|
|
4231
4295
|
/**
|
|
4232
4296
|
* The raw sensitivity on the INVERTED seven-step camera scale — a HIGHER number is LESS sensitive, so
|
|
@@ -5060,6 +5124,8 @@ var isIndoorCamera = (ctx) => has(INDOOR_CAMERA_TYPES, ctx.deviceType);
|
|
|
5060
5124
|
var isIndoorCamMini = (ctx) => ctx.deviceType === DeviceType.INDOOR_COST_DOWN_CAMERA;
|
|
5061
5125
|
var isIndoorPanTiltS350 = (ctx) => has(INDOOR_PT_S350_TYPES, ctx.deviceType);
|
|
5062
5126
|
var isHomeBase = (ctx) => has(HOMEBASE_TYPES, ctx.deviceType);
|
|
5127
|
+
var isStation9000 = (ctx) => ctx.deviceType === DeviceType.STATION_9000;
|
|
5128
|
+
var isNvrT8N00 = (ctx) => ctx.model === "T8N00";
|
|
5063
5129
|
var isAiotVacuum = (ctx) => ctx.category !== "eufy_home_tuya";
|
|
5064
5130
|
var isTuyaVacuum = (ctx) => ctx.category === "eufy_home_tuya";
|
|
5065
5131
|
|
|
@@ -5205,6 +5271,8 @@ var AUDIO = {
|
|
|
5205
5271
|
|
|
5206
5272
|
// dist/model/capabilities/camera.js
|
|
5207
5273
|
var CAMERA_CMD = {
|
|
5274
|
+
/** Camera-reported Wi-Fi signal (app `CMD_GET_WIFI_RSSI`). */
|
|
5275
|
+
WIFI_RSSI: 1142,
|
|
5208
5276
|
/** Camera on/off. Read and write polarity depend on the family and topology. */
|
|
5209
5277
|
CAMERA_ENABLE: 1035,
|
|
5210
5278
|
/** Wrapped T8410 power switch on HomeBase 3. */
|
|
@@ -5448,7 +5516,12 @@ function usesHomeBasePowerPayload(ctx) {
|
|
|
5448
5516
|
function hasUnreflectedHomeBasePower(ctx) {
|
|
5449
5517
|
return usesHomeBasePowerPayload(ctx) && !ctx.paramIds.has(2001);
|
|
5450
5518
|
}
|
|
5519
|
+
function hasPrivacyPower(ctx) {
|
|
5520
|
+
return ctx.deviceType === DeviceType.INDOOR_PT_CAMERA_S350 && ctx.homeBaseAttached === true;
|
|
5521
|
+
}
|
|
5451
5522
|
function powerCommand(on, ctx) {
|
|
5523
|
+
if (hasPrivacyPower(ctx))
|
|
5524
|
+
return privacyCommand(!on, ctx.channel);
|
|
5452
5525
|
if (usesHomeBasePowerPayload(ctx))
|
|
5453
5526
|
return setPayload(CAMERA_CMD.PRIVACY_MODE, { switch: on ? 0 : 1 }, ctx, 0);
|
|
5454
5527
|
return setScalar(CAMERA_CMD.CAMERA_ENABLE, powerValue(on, ctx), ctx, "auto");
|
|
@@ -5465,11 +5538,11 @@ function poweredOf(ctx) {
|
|
|
5465
5538
|
return cameraPowerTier(ctx.model, ctx.capabilities ?? /* @__PURE__ */ new Set());
|
|
5466
5539
|
}
|
|
5467
5540
|
function enablementReflection(on, ctx) {
|
|
5541
|
+
if (!CAMERA_MEMBERS.enabled.readReflectsWrite(ctx))
|
|
5542
|
+
return void 0;
|
|
5468
5543
|
const alias = CAMERA_MEMBERS.enabled.readAliases[0].paramType;
|
|
5469
5544
|
if (ctx.paramIds.has(alias))
|
|
5470
5545
|
return { param: alias, expected: on, observed: on };
|
|
5471
|
-
if (hasUnreflectedHomeBasePower(ctx))
|
|
5472
|
-
return void 0;
|
|
5473
5546
|
if (ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE)) {
|
|
5474
5547
|
return {
|
|
5475
5548
|
param: CAMERA_CMD.CAMERA_ENABLE,
|
|
@@ -5480,10 +5553,18 @@ function enablementReflection(on, ctx) {
|
|
|
5480
5553
|
return void 0;
|
|
5481
5554
|
}
|
|
5482
5555
|
function refuseWhenDisabled(ctx, read) {
|
|
5483
|
-
if (
|
|
5556
|
+
if (CAMERA_MEMBERS.enabled.readReflectsWrite(ctx) && read("enabled")?.value === false)
|
|
5484
5557
|
throw new CameraDisabledError(ctx.name ?? ctx.serial);
|
|
5485
5558
|
}
|
|
5486
5559
|
var CAMERA_MEMBERS = {
|
|
5560
|
+
/** Reads the latest cached report and exposes no setter. */
|
|
5561
|
+
wifiRssi: {
|
|
5562
|
+
param: CAMERA_CMD.WIFI_RSSI,
|
|
5563
|
+
type: "number",
|
|
5564
|
+
kind: "scalar",
|
|
5565
|
+
provenance: "apk",
|
|
5566
|
+
description: "Camera-reported Wi-Fi signal; physical units and a quality scale are unspecified."
|
|
5567
|
+
},
|
|
5487
5568
|
/**
|
|
5488
5569
|
* The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
|
|
5489
5570
|
* family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
|
|
@@ -5506,7 +5587,7 @@ var CAMERA_MEMBERS = {
|
|
|
5506
5587
|
invert: true,
|
|
5507
5588
|
invertFor: (ctx) => isT8410HomeBase3(ctx) ? false : void 0,
|
|
5508
5589
|
readAliases: [{ paramType: 2001, invert: false }],
|
|
5509
|
-
readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx),
|
|
5590
|
+
readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx) && !hasPrivacyPower(ctx),
|
|
5510
5591
|
description: "Camera enabled. Family-dependent wire param: 1035 CMD_DEVS_SWITCH (enable bit on T8410/HomeBase 3, disable bit on battery/solo cams) or 2001 OPEN_DEVICE (standalone indoor/outdoor). Reported on/off state; unreflectedMembers identifies commands whose effect this report does not reliably reflect.",
|
|
5511
5592
|
observation: {
|
|
5512
5593
|
event: "cameraEnabledChanged",
|
|
@@ -5640,16 +5721,14 @@ var CAMERA_MEMBERS = {
|
|
|
5640
5721
|
* Live-view quality, the pair to {@link CAMERA_MEMBERS.recordingQuality} and a different setting on a
|
|
5641
5722
|
* different wire: changing one leaves the other's parameter untouched.
|
|
5642
5723
|
*
|
|
5643
|
-
* The READ is 1020, which reports the resolved tier as a plain integer
|
|
5644
|
-
*
|
|
5645
|
-
*
|
|
5724
|
+
* The READ is 1020, which reports the resolved tier as a plain integer, while the WRITE rides 2730's
|
|
5725
|
+
* `1350` payload. They are two ids for one setting because 2730 carries a per-mode config and 1020
|
|
5726
|
+
* carries the tier in force; the tier is what this property means. 1020 is also the name the param
|
|
5727
|
+
* dictionary already gives that id, and `Device` keys state by NAME — a member claiming 2730 under
|
|
5728
|
+
* this name would collide with it on a device reporting both.
|
|
5646
5729
|
*
|
|
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.
|
|
5730
|
+
* `transaction` is part of the frame that was captured, so it is sent. The recording sibling's own
|
|
5731
|
+
* confirmed frame carries no such field; neither is inferred from the other.
|
|
5653
5732
|
*/
|
|
5654
5733
|
streamingQuality: {
|
|
5655
5734
|
param: 1020,
|
|
@@ -5657,8 +5736,13 @@ var CAMERA_MEMBERS = {
|
|
|
5657
5736
|
kind: "enum",
|
|
5658
5737
|
enumValues: STREAMING_QUALITY_TIERS,
|
|
5659
5738
|
provenance: "verified",
|
|
5660
|
-
|
|
5661
|
-
|
|
5739
|
+
args: [{ name: "quality", kind: "enum", description: "A tier, or the name it maps to. 0 is Auto." }],
|
|
5740
|
+
...accepts(),
|
|
5741
|
+
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.",
|
|
5742
|
+
write: (v, ctx) => {
|
|
5743
|
+
const q = resolveStreamingQualityTier(v);
|
|
5744
|
+
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");
|
|
5745
|
+
}
|
|
5662
5746
|
},
|
|
5663
5747
|
/**
|
|
5664
5748
|
* `type` is how the value is STORED, and 2731 stores the whole config — the ACTIVE tier is lifted out
|
|
@@ -5829,8 +5913,9 @@ function isStationAlarmOutput(ctx) {
|
|
|
5829
5913
|
function isEvidencedStationAlarm(ctx) {
|
|
5830
5914
|
return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
|
|
5831
5915
|
}
|
|
5916
|
+
var S1_PRO_ALARM_MODELS = /* @__PURE__ */ new Set(["T8134"]);
|
|
5832
5917
|
function isEvidencedAttachedCamera(ctx) {
|
|
5833
|
-
return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx);
|
|
5918
|
+
return ctx.codec === "camera" && ctx.homeBaseAttached === true && hasReportedEasSwitch(ctx) && (!isStation9000({ deviceType: ctx.stationDeviceType }) || S1_PRO_ALARM_MODELS.has(ctx.model ?? ""));
|
|
5834
5919
|
}
|
|
5835
5920
|
function reportsParam(source, param) {
|
|
5836
5921
|
return source.paramIds?.has(param) === true || Object.hasOwn(source.params ?? {}, param);
|
|
@@ -5849,7 +5934,18 @@ function homeBaseAlarm(seconds, ctx) {
|
|
|
5849
5934
|
throw new Error("station-family HomeBase alarm output requires the acting account name");
|
|
5850
5935
|
return setPayload(SIREN_CMD.HOMEBASE_TONE, { time_out: seconds, user_name: ctx.accountName }, ctx, 0, STATION_CHANNEL);
|
|
5851
5936
|
}
|
|
5937
|
+
var PORTAL_MANUAL_ALARM_TYPE = 10;
|
|
5852
5938
|
function cameraAlarm(seconds, ctx) {
|
|
5939
|
+
if (isStation9000({ deviceType: ctx.stationDeviceType })) {
|
|
5940
|
+
if (!ctx.accountName)
|
|
5941
|
+
throw new Error("a camera alarm behind a HomeBase S1 Pro requires the acting account name");
|
|
5942
|
+
return setPayload(SIREN_CMD.CAMERA_TONE, {
|
|
5943
|
+
time_out: seconds,
|
|
5944
|
+
type: PORTAL_MANUAL_ALARM_TYPE,
|
|
5945
|
+
channel: ctx.channel,
|
|
5946
|
+
username: ctx.accountName
|
|
5947
|
+
}, ctx);
|
|
5948
|
+
}
|
|
5853
5949
|
return {
|
|
5854
5950
|
kind: "p2p-int-string",
|
|
5855
5951
|
cmd: SIREN_CMD.CAMERA_TONE,
|
|
@@ -6093,7 +6189,8 @@ var LIGHT_SWITCH_INT_STRING_TYPES = /* @__PURE__ */ new Set([
|
|
|
6093
6189
|
DeviceType.CAMERA2_PRO,
|
|
6094
6190
|
DeviceType.CAMERA2C_PRO,
|
|
6095
6191
|
DeviceType.CAMERA3,
|
|
6096
|
-
DeviceType.CAMERA3C
|
|
6192
|
+
DeviceType.CAMERA3C,
|
|
6193
|
+
DeviceType.PROFESSIONAL_247
|
|
6097
6194
|
]);
|
|
6098
6195
|
function switchCommand(on, ctx) {
|
|
6099
6196
|
switch (lightSwitchWire(ctx)) {
|
|
@@ -6188,6 +6285,9 @@ var LIGHT_MEMBERS = {
|
|
|
6188
6285
|
* This is the switch a user changes and expects to STAY changed. {@link isOn} is a different fact:
|
|
6189
6286
|
* the lamp being lit right now, driven by whichever
|
|
6190
6287
|
* client is streaming — the vendor app lights it for a live view and drops it on quitting.
|
|
6288
|
+
*
|
|
6289
|
+
* The T8423 write takes the session-chosen level; level-1 int+string was verified live on a standalone
|
|
6290
|
+
* unit.
|
|
6191
6291
|
*/
|
|
6192
6292
|
spotlightEnabled: {
|
|
6193
6293
|
param: LIGHT_CMD.SPOTLIGHT_ENABLE,
|
|
@@ -6195,7 +6295,7 @@ var LIGHT_MEMBERS = {
|
|
|
6195
6295
|
kind: "boolean",
|
|
6196
6296
|
provenance: "verified",
|
|
6197
6297
|
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) =>
|
|
6298
|
+
write: (v, ctx) => setScalar(LIGHT_CMD.SPOTLIGHT_ENABLE, asBool(v) ? 1 : 0, ctx, ctx.deviceType === DeviceType.FLOODLIGHT_CAMERA_8423 ? "auto" : "direct-binary"),
|
|
6199
6299
|
writeAs: "setEnabled"
|
|
6200
6300
|
}
|
|
6201
6301
|
};
|
|
@@ -6588,6 +6688,8 @@ var ZOOM_MODEL_HINT = /^T8170\b|^T8425\b|S340/i;
|
|
|
6588
6688
|
function hasZoomEvidence(ctx) {
|
|
6589
6689
|
return ZOOM_EVIDENCE_PARAMS.some((p) => ctx.paramIds.has(p)) || !!ctx.model && ZOOM_MODEL_HINT.test(ctx.model);
|
|
6590
6690
|
}
|
|
6691
|
+
var PRESET_EVIDENCE_PARAMS = [1029, 6090, 6091, 6092, 6210];
|
|
6692
|
+
var FIXED_LENS_MODELS = /* @__PURE__ */ new Set(["T8134"]);
|
|
6591
6693
|
function rotateCommand(direction, ctx, zoom = 1) {
|
|
6592
6694
|
return setJson(PTZ_CMD.PTZ_ROTATE, { cmd_type: 1, rotate_type: PTZ_ROTATE[direction], zoom }, ctx);
|
|
6593
6695
|
}
|
|
@@ -6752,8 +6854,9 @@ var PTZ = {
|
|
|
6752
6854
|
members: PTZ_MEMBERS,
|
|
6753
6855
|
properties: propertiesOf(PTZ_MEMBERS),
|
|
6754
6856
|
// SoloCam proves PT via reported preset params (6090/6091/6092/6210) and a rotate command surface
|
|
6755
|
-
// (1029); Indoor-PT (deviceType 31/35/111 + C220 range
|
|
6756
|
-
// vendor deviceType is the only honest signal; explicit
|
|
6857
|
+
// (1029), except on a FIXED_LENS_MODELS model; Indoor-PT (deviceType 31/35/111 + C220 range
|
|
6858
|
+
// 10008-10011) reports NO PT param, so the vendor deviceType is the only honest signal; explicit
|
|
6859
|
+
// PT/S340/S350 model names also prove it.
|
|
6757
6860
|
// OUTDOOR_PT_CAMERA (48, e.g. SoloCam S340 / T8170) is pan-tilt by definition but a live unit can
|
|
6758
6861
|
// report none of the preset params — trust the deviceType. The rotating SoloCam solar variant
|
|
6759
6862
|
// T8124R shares deviceType 62 with the FIXED T8124 base, so its model code is the only honest
|
|
@@ -6761,7 +6864,7 @@ var PTZ = {
|
|
|
6761
6864
|
// a `T81\d\dR` wildcard: the "R = rotating" reading is unverified for other models, and an
|
|
6762
6865
|
// over-broad hint would false-positive a future non-PT model, the same class of bug as a mislabel.
|
|
6763
6866
|
detection: {
|
|
6764
|
-
|
|
6867
|
+
detect: (record) => !FIXED_LENS_MODELS.has(record.model ?? "") && PRESET_EVIDENCE_PARAMS.some((p) => Object.hasOwn(record.params ?? {}, p)),
|
|
6765
6868
|
deviceTypes: [
|
|
6766
6869
|
DeviceType.OUTDOOR_PT_CAMERA,
|
|
6767
6870
|
DeviceType.INDOOR_PT_CAMERA,
|
|
@@ -6777,7 +6880,7 @@ var PTZ = {
|
|
|
6777
6880
|
// no PTZ capability without this, on a real owned unit.
|
|
6778
6881
|
DeviceType.FLOODLIGHT_CAMERA_8425
|
|
6779
6882
|
],
|
|
6780
|
-
modelHints: [/pan.?tilt|\bpt\b|S3[45]0|indoor.?pt/i]
|
|
6883
|
+
modelHints: [/pan.?tilt|\bpt\b|S3[45]0|indoor.?pt/i, /\bT8E00\b/i]
|
|
6781
6884
|
},
|
|
6782
6885
|
emits: ["ptzNotify"],
|
|
6783
6886
|
/**
|
|
@@ -7632,7 +7735,8 @@ var ARMING_CMD = {
|
|
|
7632
7735
|
*/
|
|
7633
7736
|
SET_ARMING: 1224,
|
|
7634
7737
|
/**
|
|
7635
|
-
*
|
|
7738
|
+
* One guard mode's per-device action table (app `CMD_SET_ALL_ACTION`), which carries the alarm delays
|
|
7739
|
+
* and each device's actions in that mode. ✅ WIRE CAPTURED live on a T8030 (
|
|
7636
7740
|
* 2026-07-23, both directions): a **bare
|
|
7637
7741
|
* JSON frame, no `1350`/`1700` envelope** — outer P2P cmd IS `1255` itself, station channel 255,
|
|
7638
7742
|
* plaintext `{account_id, count_down_alarm:{channel_list,delay_time},
|
|
@@ -7649,15 +7753,26 @@ var ARMING_CMD = {
|
|
|
7649
7753
|
* corresponding GET command (`1310`/inner cmd `40003`, sent by the app right before editing) always
|
|
7650
7754
|
* replied `{count:0,data:null}` in every capture — genuinely empty, not a decrypt failure (confirmed
|
|
7651
7755
|
* 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
|
|
7756
|
+
* config this way; a T8010 reports it on its own params instead, see below. Without a working GET,
|
|
7757
|
+
* safely PATCHING just one channel in or out of an existing list isn't possible without risking
|
|
7758
|
+
* clobbering the rest — so
|
|
7654
7759
|
* this ships as a caller-supplies-everything write instead of guessing a merge.
|
|
7655
7760
|
* `devices`/`siren_sensor_action` are even less understood (raw per-device action codes, meaning
|
|
7656
7761
|
* unconfirmed) and MUST come from a value independently read/captured for the target mode —
|
|
7657
7762
|
* see `AlarmDelayConfig`'s field docs.
|
|
7763
|
+
*
|
|
7764
|
+
* ✅ On a T8010 the app writes the same command for the Device Control screen: header channel 0, the
|
|
7765
|
+
* whole table of the mode `{mode_id, devices, count_down_alarm, count_down_arm}`, acked by an all-zero
|
|
7766
|
+
* 1255 reply. The T8010 reports each table on its own params, Home on 1157 and Away on 1158, and the
|
|
7767
|
+
* reported table follows a write within 15 s. See {@link DEVICE_NOTIFY_FLAG}.
|
|
7658
7768
|
*/
|
|
7659
|
-
|
|
7769
|
+
SET_ALL_ACTION: 1255
|
|
7660
7770
|
};
|
|
7771
|
+
var DEVICE_NOTIFY_FLAG = 8;
|
|
7772
|
+
function actionTable(read, mode) {
|
|
7773
|
+
const value = read(`${mode}ActionTable`)?.value;
|
|
7774
|
+
return value && typeof value === "object" && Array.isArray(value.devices) ? value : void 0;
|
|
7775
|
+
}
|
|
7661
7776
|
var ARMING_MODE_WIRE = {
|
|
7662
7777
|
away: 0,
|
|
7663
7778
|
home: 1,
|
|
@@ -7694,7 +7809,15 @@ function alarmDelayCommand(mode, config, ctx) {
|
|
|
7694
7809
|
devices: config.devices.map((d) => ({ action: d.action, device_channel: d.deviceChannel })),
|
|
7695
7810
|
siren_sensor_action: config.sirenSensorAction.map((d) => ({ action: d.action, device_channel: d.deviceChannel }))
|
|
7696
7811
|
};
|
|
7697
|
-
return setJsonRaw(ARMING_CMD.
|
|
7812
|
+
return setJsonRaw(ARMING_CMD.SET_ALL_ACTION, data, ctx, STATION_CHANNEL2);
|
|
7813
|
+
}
|
|
7814
|
+
var ACTION_TABLE_LAG_MS = 15e3;
|
|
7815
|
+
function withDeviceNotification(table, channel, on) {
|
|
7816
|
+
if (!table?.devices.some((d) => d.device_channel === channel))
|
|
7817
|
+
return void 0;
|
|
7818
|
+
const { account_id: _account, ...rest } = table;
|
|
7819
|
+
const devices = table.devices.map((d) => d.device_channel === channel ? { ...d, action: on ? d.action | DEVICE_NOTIFY_FLAG : d.action & ~DEVICE_NOTIFY_FLAG } : d);
|
|
7820
|
+
return { ...rest, devices };
|
|
7698
7821
|
}
|
|
7699
7822
|
var ARMING_MEMBERS = {
|
|
7700
7823
|
/**
|
|
@@ -7752,8 +7875,50 @@ var ARMING_MEMBERS = {
|
|
|
7752
7875
|
} catch (e) {
|
|
7753
7876
|
return Promise.reject(e);
|
|
7754
7877
|
}
|
|
7755
|
-
}, "Write the full per-mode alarm/arm-delay configuration.")
|
|
7878
|
+
}, "Write the full per-mode alarm/arm-delay configuration."),
|
|
7879
|
+
/**
|
|
7880
|
+
* Whether the device on `channel` sends a push notification in `mode`: its {@link DEVICE_NOTIFY_FLAG}
|
|
7881
|
+
* in the action table the station reports for that mode. `undefined` where the station reports no table
|
|
7882
|
+
* or the table does not list `channel`. A device's channel is `EufyDevice.channel`.
|
|
7883
|
+
*/
|
|
7884
|
+
deviceNotification: method(({ read }) => (mode, channel) => {
|
|
7885
|
+
const action = actionTable(read, mode)?.devices.find((d) => d.device_channel === channel)?.action;
|
|
7886
|
+
return typeof action === "number" ? (action & DEVICE_NOTIFY_FLAG) !== 0 : void 0;
|
|
7887
|
+
}, "Whether the device on a channel sends a push notification in a guard mode (home/away).", (ctx) => ctx.model === "T8010"),
|
|
7888
|
+
/**
|
|
7889
|
+
* Turn the push notification of the device on `channel` on or off in `mode` (cmd
|
|
7890
|
+
* {@link ARMING_CMD.SET_ALL_ACTION}): the station's reported table written back with that one flag
|
|
7891
|
+
* changed. Rejects, sending nothing, where the table does not list `channel`. A write builds on the last
|
|
7892
|
+
* one sent for that mode until the station reports a table observed 15 s after it, so a second write does
|
|
7893
|
+
* not revert it; a write whose dispatch rejects is not built on.
|
|
7894
|
+
*/
|
|
7895
|
+
setDeviceNotification: method(({ ctx, sink, read }) => {
|
|
7896
|
+
const sent2 = /* @__PURE__ */ new Map();
|
|
7897
|
+
return (mode, channel, on) => {
|
|
7898
|
+
const last = sent2.get(mode);
|
|
7899
|
+
const observed = read(`${mode}ActionTable`)?.ts ?? 0;
|
|
7900
|
+
const base = last && observed < last.at + ACTION_TABLE_LAG_MS ? last.table : actionTable(read, mode);
|
|
7901
|
+
const table = withDeviceNotification(base, channel, on);
|
|
7902
|
+
if (!table) {
|
|
7903
|
+
return Promise.reject(new Error(`arming: no ${mode} action table lists channel ${channel} [${describeDevice(ctx)}]`));
|
|
7904
|
+
}
|
|
7905
|
+
sent2.set(mode, { table, at: Date.now() });
|
|
7906
|
+
return sink.dispatch(setJsonRaw(ARMING_CMD.SET_ALL_ACTION, table, ctx, 0)).catch((err) => {
|
|
7907
|
+
if (sent2.get(mode)?.table === table) {
|
|
7908
|
+
if (last)
|
|
7909
|
+
sent2.set(mode, last);
|
|
7910
|
+
else
|
|
7911
|
+
sent2.delete(mode);
|
|
7912
|
+
}
|
|
7913
|
+
throw err;
|
|
7914
|
+
});
|
|
7915
|
+
};
|
|
7916
|
+
}, "Turn the push notification of the device on a channel on or off in a guard mode (home/away).", (ctx) => ctx.model === "T8010")
|
|
7756
7917
|
};
|
|
7918
|
+
function decodeAlarmPhase(signal) {
|
|
7919
|
+
const type = signal.source === "push" ? signal.payload?.["type"] : void 0;
|
|
7920
|
+
return type === CusPushAlarmType.HUB_STOP_BY_APP ? { phase: "stopped" } : {};
|
|
7921
|
+
}
|
|
7757
7922
|
var ARMING = {
|
|
7758
7923
|
capability: "arming",
|
|
7759
7924
|
description: "Station/device guard (arming) mode.",
|
|
@@ -7768,8 +7933,9 @@ var ARMING = {
|
|
|
7768
7933
|
* generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
|
|
7769
7934
|
* capability (see the barrel's event index).
|
|
7770
7935
|
*
|
|
7771
|
-
* The alarm ids carry a
|
|
7772
|
-
* `
|
|
7936
|
+
* The alarm ids carry a `phase` so one event name covers the whole lifecycle: the countdown push a
|
|
7937
|
+
* static `"delayed"`, the alarm push a `"triggered"` or `"stopped"` read from its alarm type (see
|
|
7938
|
+
* {@link decodeAlarmPhase}). The mode-switch push is known to identify WHICH mode and
|
|
7773
7939
|
* what triggered the change, but neither has been observed on the wire here, so this emits the bare
|
|
7774
7940
|
* transition and refreshes the `mode` read rather than trusting a decoded field.
|
|
7775
7941
|
*/
|
|
@@ -7780,7 +7946,13 @@ var ARMING = {
|
|
|
7780
7946
|
emit: "armingModeChanged",
|
|
7781
7947
|
refresh: { member: "mode" }
|
|
7782
7948
|
},
|
|
7783
|
-
{
|
|
7949
|
+
{
|
|
7950
|
+
source: "push",
|
|
7951
|
+
match: CusPushEvent.ALARM,
|
|
7952
|
+
emit: "alarm",
|
|
7953
|
+
payload: { phase: "triggered" },
|
|
7954
|
+
derive: decodeAlarmPhase
|
|
7955
|
+
},
|
|
7784
7956
|
{ source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
|
|
7785
7957
|
]
|
|
7786
7958
|
};
|
|
@@ -8312,8 +8484,6 @@ var MODE_CTRL_FIELD = {
|
|
|
8312
8484
|
SEQ: 2
|
|
8313
8485
|
};
|
|
8314
8486
|
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
8487
|
/** Live-verified on a T2351. */
|
|
8318
8488
|
START_GOHOME: 6,
|
|
8319
8489
|
/** Live-verified on a T2351. */
|
|
@@ -8337,6 +8507,8 @@ var ModeCtrlMethod = {
|
|
|
8337
8507
|
START_GLOBAL_CRUISE: 20
|
|
8338
8508
|
};
|
|
8339
8509
|
var ModeCtrlParamMethod = {
|
|
8510
|
+
/** `START_AUTO_CLEAN` with an `AutoClean` payload — clean the whole floor. */
|
|
8511
|
+
AUTO: { method: 0, param: 3 },
|
|
8340
8512
|
/** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
|
|
8341
8513
|
SELECT_ROOMS: { method: 1, param: 4 },
|
|
8342
8514
|
/** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
|
|
@@ -8373,6 +8545,7 @@ var SELECT_ZONES_FIELD = {
|
|
|
8373
8545
|
POINT_Y: 2
|
|
8374
8546
|
};
|
|
8375
8547
|
var SCENE_CLEAN_ID = 1;
|
|
8548
|
+
var AUTO_CLEAN_TIMES = 1;
|
|
8376
8549
|
function encodeModeCtrlParam(method2, paramField, build) {
|
|
8377
8550
|
return rawDp((w) => {
|
|
8378
8551
|
if (method2 !== 0)
|
|
@@ -8419,14 +8592,17 @@ function encodeSceneClean(sceneId) {
|
|
|
8419
8592
|
const { method: method2, param } = ModeCtrlParamMethod.SCENE;
|
|
8420
8593
|
return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
|
|
8421
8594
|
}
|
|
8595
|
+
function encodeAutoClean() {
|
|
8596
|
+
const { method: method2, param } = ModeCtrlParamMethod.AUTO;
|
|
8597
|
+
return encodeModeCtrlParam(method2, param, (p) => p.int(AUTO_CLEAN_TIMES, 1));
|
|
8598
|
+
}
|
|
8422
8599
|
var modeCtrlSeq = 111;
|
|
8423
8600
|
function nextModeCtrlSeq() {
|
|
8424
8601
|
return ++modeCtrlSeq;
|
|
8425
8602
|
}
|
|
8426
8603
|
function encodeModeCtrl(method2, seq) {
|
|
8427
8604
|
return rawDp((w) => {
|
|
8428
|
-
|
|
8429
|
-
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
8605
|
+
w.int(MODE_CTRL_FIELD.METHOD, method2);
|
|
8430
8606
|
w.int(MODE_CTRL_FIELD.SEQ, seq);
|
|
8431
8607
|
});
|
|
8432
8608
|
}
|
|
@@ -10268,8 +10444,8 @@ var VACUUM_CLEAN_MEMBERS = {
|
|
|
10268
10444
|
provenance: "mega",
|
|
10269
10445
|
description: "Stop smart-follow mode (ModeCtrlRequest method 18 over DP 152). Method number not captured \u2014 unverified."
|
|
10270
10446
|
},
|
|
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,
|
|
10447
|
+
/** Start an auto-clean run via ModeCtrlRequest method 0 with one `AutoClean` pass (DP 152). AIoT only — Tuya write unverified. */
|
|
10448
|
+
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
10449
|
/** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
|
|
10274
10450
|
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
10451
|
/** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
|
|
@@ -10392,6 +10568,11 @@ var SuctionLevel = {
|
|
|
10392
10568
|
};
|
|
10393
10569
|
var SUCTION_LABEL = enumLabels(SuctionLevel);
|
|
10394
10570
|
var ALL_SUCTION_LEVELS = [0, 1, 2, 3, 4, 5];
|
|
10571
|
+
function levelFromCatalog(member) {
|
|
10572
|
+
if (Object.hasOwn(SuctionLevel, member))
|
|
10573
|
+
return SuctionLevel[member];
|
|
10574
|
+
return Object.hasOwn(SUCTION_LABEL, member) ? Number(member) : void 0;
|
|
10575
|
+
}
|
|
10395
10576
|
function suctionLevelName(value) {
|
|
10396
10577
|
return SUCTION_LABEL[value];
|
|
10397
10578
|
}
|
|
@@ -10452,13 +10633,15 @@ var SUCTION = {
|
|
|
10452
10633
|
},
|
|
10453
10634
|
/**
|
|
10454
10635
|
* 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
|
|
10636
|
+
* when available. `undefined` when no catalog entry exists for DP 158, or when no listed member maps
|
|
10637
|
+
* to a known level (so a caller never reads `[]` as "nothing selectable"). `setSuctionLevel` is only
|
|
10456
10638
|
* installed on AIoT devices (gated by `available: isAiotVacuum` on the `level` entry), so
|
|
10457
10639
|
* `supportedLevels` is harmless data on a non-AIoT device.
|
|
10458
10640
|
*/
|
|
10459
10641
|
actions({ ctx }) {
|
|
10460
10642
|
const catalogRange = ctx.dpCatalog?.enumRanges.get(SUCTION_DP.SUCTION);
|
|
10461
|
-
const
|
|
10643
|
+
const levels = catalogRange?.flatMap((m) => levelFromCatalog(m) ?? []);
|
|
10644
|
+
const supportedLevels = levels?.length ? levels : void 0;
|
|
10462
10645
|
return { supportedLevels };
|
|
10463
10646
|
}
|
|
10464
10647
|
};
|
|
@@ -10500,28 +10683,29 @@ function parseEnumRange(values) {
|
|
|
10500
10683
|
if (values === null || values === void 0)
|
|
10501
10684
|
return [];
|
|
10502
10685
|
if (Array.isArray(values))
|
|
10503
|
-
return
|
|
10686
|
+
return toMemberArray(values);
|
|
10504
10687
|
if (typeof values === "string") {
|
|
10505
10688
|
try {
|
|
10506
10689
|
const parsed = JSON.parse(values);
|
|
10507
10690
|
if (Array.isArray(parsed))
|
|
10508
|
-
return
|
|
10691
|
+
return toMemberArray(parsed);
|
|
10509
10692
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
10510
10693
|
const obj = parsed;
|
|
10511
10694
|
if (Array.isArray(obj.range))
|
|
10512
|
-
return
|
|
10695
|
+
return toMemberArray(obj.range);
|
|
10513
10696
|
}
|
|
10514
10697
|
} catch {
|
|
10515
10698
|
}
|
|
10516
10699
|
}
|
|
10517
10700
|
return [];
|
|
10518
10701
|
}
|
|
10519
|
-
function
|
|
10702
|
+
function toMemberArray(arr) {
|
|
10520
10703
|
const result = [];
|
|
10521
10704
|
for (const v of arr) {
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10705
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
10706
|
+
result.push(String(v));
|
|
10707
|
+
else if (typeof v === "string" && v !== "")
|
|
10708
|
+
result.push(v);
|
|
10525
10709
|
}
|
|
10526
10710
|
return result;
|
|
10527
10711
|
}
|
|
@@ -11016,8 +11200,12 @@ var MODEL_REGISTRY = {
|
|
|
11016
11200
|
// Cameras with curated extras that aren't always provable from a fresh device's params.
|
|
11017
11201
|
T8423: { codec: "camera", caps: ["light", "battery"], name: "Floodlight Cam" },
|
|
11018
11202
|
T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
|
|
11203
|
+
// T8210 hardware variant, reported as its own code; shares the T8210 row.
|
|
11204
|
+
T8210C: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
|
|
11019
11205
|
// Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
|
|
11020
11206
|
T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
|
|
11207
|
+
// Battery Video Doorbell Dual, vendor deviceType 91 (BATTERY_DOORBELL_PLUS), reported by two users.
|
|
11208
|
+
T8213: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
|
|
11021
11209
|
// Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
|
|
11022
11210
|
// member on purpose — this model is wired, so the codec baseline plus inference is the whole
|
|
11023
11211
|
// truth for power. Without this row it classified as a plain camera, so the doorbell capability
|
|
@@ -11038,6 +11226,8 @@ var MODEL_REGISTRY = {
|
|
|
11038
11226
|
// shows a parent, so an attached cam never advertises the hub's guard mode.
|
|
11039
11227
|
T8170: { codec: "camera", caps: ["battery", "arming"], name: "SoloCam" },
|
|
11040
11228
|
T8171: { codec: "camera", caps: ["battery", "arming"], name: "T8171" },
|
|
11229
|
+
// Baby Monitor E21, vendor deviceType 86 (category eufy_care), standalone.
|
|
11230
|
+
T8354: { codec: "camera", name: "Baby Monitor E21" },
|
|
11041
11231
|
T8400: { codec: "camera", caps: ["arming"], name: "Indoor Cam" },
|
|
11042
11232
|
T8410: { codec: "camera", caps: ["arming"], name: "Indoor Cam Pan & Tilt" },
|
|
11043
11233
|
T8442: { codec: "camera", caps: ["arming"], name: "Indoor/Outdoor Cam 1080p" },
|
|
@@ -11179,8 +11369,10 @@ function resolveDevice(rec) {
|
|
|
11179
11369
|
}
|
|
11180
11370
|
function resolveProperties(rec, codec, capabilities) {
|
|
11181
11371
|
const paramIds = /* @__PURE__ */ new Set();
|
|
11182
|
-
|
|
11183
|
-
|
|
11372
|
+
for (const reported of [rec.params, rec.dpParams]) {
|
|
11373
|
+
if (!reported || typeof reported !== "object")
|
|
11374
|
+
continue;
|
|
11375
|
+
for (const k of Object.keys(reported)) {
|
|
11184
11376
|
const n = Number(k);
|
|
11185
11377
|
if (Number.isFinite(n))
|
|
11186
11378
|
paramIds.add(n);
|
|
@@ -11380,9 +11572,15 @@ var SECURITY_PARAMS = {
|
|
|
11380
11572
|
provenance: "guessed"
|
|
11381
11573
|
},
|
|
11382
11574
|
1157: {
|
|
11383
|
-
name: "
|
|
11575
|
+
name: "homeActionTable",
|
|
11384
11576
|
type: "string",
|
|
11385
|
-
provenance: "
|
|
11577
|
+
provenance: "verified",
|
|
11578
|
+
encoding: "base64+json"
|
|
11579
|
+
},
|
|
11580
|
+
1158: {
|
|
11581
|
+
name: "awayActionTable",
|
|
11582
|
+
type: "string",
|
|
11583
|
+
provenance: "verified",
|
|
11386
11584
|
encoding: "base64+json"
|
|
11387
11585
|
},
|
|
11388
11586
|
1159: {
|
|
@@ -13006,7 +13204,7 @@ function coerce(spec, raw, invert = false, logger = noopLogger) {
|
|
|
13006
13204
|
function sameManifest(a, b) {
|
|
13007
13205
|
if (a.length !== b.length)
|
|
13008
13206
|
return false;
|
|
13009
|
-
const sig = (p) => JSON.stringify([p.name, p.writable, p.enumValues ?? null]);
|
|
13207
|
+
const sig = (p) => JSON.stringify([p.name, p.writable, p.reported ?? null, p.enumValues ?? null]);
|
|
13010
13208
|
const bSigs = new Set(b.map(sig));
|
|
13011
13209
|
return a.every((p) => bSigs.has(sig(p)));
|
|
13012
13210
|
}
|
|
@@ -14383,6 +14581,19 @@ function decodeMapFrame(frame, codec) {
|
|
|
14383
14581
|
return READERS.get(frame.channelId)?.(frame.payload, codec);
|
|
14384
14582
|
}
|
|
14385
14583
|
|
|
14584
|
+
// dist/transport/abortable.js
|
|
14585
|
+
function abortable(work, signal) {
|
|
14586
|
+
if (!signal)
|
|
14587
|
+
return work;
|
|
14588
|
+
signal.throwIfAborted();
|
|
14589
|
+
return Promise.race([
|
|
14590
|
+
work,
|
|
14591
|
+
new Promise((_resolve, reject) => {
|
|
14592
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
14593
|
+
})
|
|
14594
|
+
]);
|
|
14595
|
+
}
|
|
14596
|
+
|
|
14386
14597
|
// dist/transport/p2p/command-router.js
|
|
14387
14598
|
import { setTimeout as sleep2 } from "node:timers/promises";
|
|
14388
14599
|
|
|
@@ -15308,7 +15519,7 @@ var PADDED_RESULT_BYTES = 132;
|
|
|
15308
15519
|
var CMD_CAMERA_INFO = 1103;
|
|
15309
15520
|
var CMD_DATABASE_IMAGE = 1308;
|
|
15310
15521
|
var CMD_DATABASE = 1306;
|
|
15311
|
-
var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
|
|
15522
|
+
var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011, QUERY_LOCAL: 10017 };
|
|
15312
15523
|
var GCM_AAD = Buffer.from("eufy security");
|
|
15313
15524
|
var LEVEL2_SEQ_BASE = 16909056;
|
|
15314
15525
|
function paramReport(payload) {
|
|
@@ -15338,9 +15549,13 @@ function firstTableRows(text2) {
|
|
|
15338
15549
|
} catch {
|
|
15339
15550
|
continue;
|
|
15340
15551
|
}
|
|
15341
|
-
const
|
|
15342
|
-
if (
|
|
15343
|
-
return
|
|
15552
|
+
const reply = parsed;
|
|
15553
|
+
if (typeof reply?.mIntRet === "number" && reply.mIntRet !== 0)
|
|
15554
|
+
return { error: reply.mIntRet };
|
|
15555
|
+
if (reply?.data === "[]")
|
|
15556
|
+
return { rows: [] };
|
|
15557
|
+
if (Array.isArray(reply?.data))
|
|
15558
|
+
return { rows: reply.data };
|
|
15344
15559
|
}
|
|
15345
15560
|
return void 0;
|
|
15346
15561
|
}
|
|
@@ -15699,8 +15914,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15699
15914
|
return this.connected;
|
|
15700
15915
|
}
|
|
15701
15916
|
/**
|
|
15702
|
-
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and
|
|
15703
|
-
* less or refuses.
|
|
15917
|
+
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and log once per session when it grants
|
|
15918
|
+
* less (debug) or refuses (warn).
|
|
15704
15919
|
*
|
|
15705
15920
|
* The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
|
|
15706
15921
|
* inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
|
|
@@ -15716,7 +15931,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15716
15931
|
if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
|
|
15717
15932
|
return;
|
|
15718
15933
|
this.receiveBufferReported = true;
|
|
15719
|
-
|
|
15934
|
+
const [level, outcome] = granted > 0 ? ["debug", ` (granted ${granted})`] : ["warn", " (request refused)"];
|
|
15935
|
+
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
15936
|
}
|
|
15721
15937
|
/** Open the socket and start the lookup → hole-punch handshake. */
|
|
15722
15938
|
async connect() {
|
|
@@ -15888,6 +16104,9 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15888
16104
|
}
|
|
15889
16105
|
} else if (hasHeader(msg, ResponseMessageType.CAM_ID) || hasHeader(msg, ResponseMessageType.TURN_SERVER_CAM_ID)) {
|
|
15890
16106
|
this.onConnected({ host: rinfo.address, port: rinfo.port }, socket);
|
|
16107
|
+
} else if (hasHeader(msg, ResponseMessageType.END)) {
|
|
16108
|
+
if (fromConnectedPeer)
|
|
16109
|
+
void this.close().catch((error) => this.emit("error", error));
|
|
15891
16110
|
} else if (hasHeader(msg, ResponseMessageType.PONG)) {
|
|
15892
16111
|
this.lastPongData = msg.length > 4 ? msg.subarray(4) : void 0;
|
|
15893
16112
|
} else if (hasHeader(msg, ResponseMessageType.PING)) {
|
|
@@ -15961,6 +16180,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15961
16180
|
socket.removeAllListeners("error");
|
|
15962
16181
|
socket.on("error", (error) => this.emit("error", error));
|
|
15963
16182
|
this.socket = socket;
|
|
16183
|
+
this.requestReceiveBuffer(socket);
|
|
15964
16184
|
}
|
|
15965
16185
|
this.connected = true;
|
|
15966
16186
|
this.connectedAtMs = Date.now();
|
|
@@ -16248,6 +16468,21 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16248
16468
|
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16249
16469
|
return true;
|
|
16250
16470
|
}
|
|
16471
|
+
/**
|
|
16472
|
+
* The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
|
|
16473
|
+
* level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
|
|
16474
|
+
*/
|
|
16475
|
+
sendRawLevel1Bytes(payload, channel, outerCmd) {
|
|
16476
|
+
if (!this.connectAddress)
|
|
16477
|
+
return false;
|
|
16478
|
+
const data = Buffer.concat([
|
|
16479
|
+
buildCommandHeader(this.seqNumber, outerCmd),
|
|
16480
|
+
buildRawCommandPayload(encryptP2PData(paddingP2PData(payload), this.level1Key), channel, 1)
|
|
16481
|
+
]);
|
|
16482
|
+
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
16483
|
+
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16484
|
+
return true;
|
|
16485
|
+
}
|
|
16251
16486
|
/**
|
|
16252
16487
|
* Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
|
|
16253
16488
|
* its runtime topology takes, as {@link startLiveMedia} selects its start:
|
|
@@ -16468,7 +16703,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16468
16703
|
camera_type: 0,
|
|
16469
16704
|
entrytype: 0,
|
|
16470
16705
|
key: this.rsaModulus(),
|
|
16471
|
-
streamtype:
|
|
16706
|
+
streamtype: 2,
|
|
16472
16707
|
...payload
|
|
16473
16708
|
} : payload;
|
|
16474
16709
|
const value = JSON.stringify({
|
|
@@ -16684,6 +16919,29 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16684
16919
|
query: this.fullTableQuery()
|
|
16685
16920
|
});
|
|
16686
16921
|
}
|
|
16922
|
+
/**
|
|
16923
|
+
* Read a date-ranged page of `history_record_info` from the station. The reply's `data` array groups
|
|
16924
|
+
* recording rows with companion tables; each group retains its `table_name` and `payload`.
|
|
16925
|
+
* `startTime` is `"0"` for the newest page or the oldest row's `yyyyMMddHHmmss` cursor for older rows.
|
|
16926
|
+
*/
|
|
16927
|
+
queryRecordPage(opts) {
|
|
16928
|
+
return this.readDatabase("history_record_info", {
|
|
16929
|
+
accountId: opts.accountId,
|
|
16930
|
+
innerCmd: DB_QUERY.QUERY_LOCAL,
|
|
16931
|
+
query: {
|
|
16932
|
+
count: opts.count ?? 20,
|
|
16933
|
+
start_date: opts.startDate,
|
|
16934
|
+
end_date: opts.endDate,
|
|
16935
|
+
start_time: opts.startTime ?? "0",
|
|
16936
|
+
event_type: 0,
|
|
16937
|
+
ai_type: 0,
|
|
16938
|
+
storage_cloud: -1,
|
|
16939
|
+
trigger_type: 0,
|
|
16940
|
+
detection_type: 0,
|
|
16941
|
+
flag: 0
|
|
16942
|
+
}
|
|
16943
|
+
});
|
|
16944
|
+
}
|
|
16687
16945
|
/**
|
|
16688
16946
|
* Query one on-station table and answer its rows, once the reply is whole.
|
|
16689
16947
|
*
|
|
@@ -16691,7 +16949,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16691
16949
|
* several frames whose decrypted text is a fragment of one document, so the fragments are
|
|
16692
16950
|
* accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
|
|
16693
16951
|
* `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
|
|
16694
|
-
* complete reply
|
|
16952
|
+
* complete reply, and when the station answers a non-zero `mIntRet`.
|
|
16695
16953
|
*
|
|
16696
16954
|
* One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
|
|
16697
16955
|
* session throws, so a second reply cannot land in this buffer.
|
|
@@ -16699,16 +16957,22 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16699
16957
|
async readDatabase(table, opts = {}) {
|
|
16700
16958
|
if (opts.signal?.aborted)
|
|
16701
16959
|
throw new Error("readDatabase: aborted");
|
|
16702
|
-
this.queryDatabase(table, {
|
|
16960
|
+
this.queryDatabase(table, {
|
|
16961
|
+
accountId: opts.accountId,
|
|
16962
|
+
innerCmd: opts.innerCmd,
|
|
16963
|
+
query: opts.query ?? this.fullTableQuery()
|
|
16964
|
+
});
|
|
16703
16965
|
this.dbReadInFlight = true;
|
|
16704
16966
|
try {
|
|
16705
16967
|
return await new Promise((resolve, reject) => {
|
|
16706
16968
|
let text2 = "";
|
|
16707
16969
|
const onChunk = (chunk) => {
|
|
16708
16970
|
text2 += chunk.text;
|
|
16709
|
-
const
|
|
16710
|
-
if (
|
|
16711
|
-
settle(() =>
|
|
16971
|
+
const result = firstTableRows(text2);
|
|
16972
|
+
if (result?.error !== void 0)
|
|
16973
|
+
settle(() => reject(new Error(`readDatabase: ${table} refused: ${result.error}`)));
|
|
16974
|
+
else if (result?.rows)
|
|
16975
|
+
settle(() => resolve(result.rows));
|
|
16712
16976
|
};
|
|
16713
16977
|
const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
|
|
16714
16978
|
const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
|
|
@@ -16809,11 +17073,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16809
17073
|
if (advance > SEQUENCE_LOOKBACK) {
|
|
16810
17074
|
if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
|
|
16811
17075
|
return;
|
|
16812
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
17076
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16813
17077
|
this.trace({ phase: "sequence-restart", dataType });
|
|
16814
17078
|
this.clearReorderTimer(dataType);
|
|
16815
17079
|
this.reorderByType.delete(dataType);
|
|
16816
17080
|
this.pendingByDataType.delete(dataType);
|
|
17081
|
+
if (dataType === P2PDataType.VIDEO)
|
|
17082
|
+
this.emit("videoGap");
|
|
16817
17083
|
this.lastSeqByType.set(dataType, seqNo);
|
|
16818
17084
|
this.reassemble(dataType, msg.subarray(8));
|
|
16819
17085
|
return;
|
|
@@ -16879,9 +17145,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16879
17145
|
*/
|
|
16880
17146
|
abandonHole(dataType) {
|
|
16881
17147
|
const held = this.reorderByType.get(dataType).held;
|
|
16882
|
-
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
17148
|
+
if ((dataType === P2PDataType.VIDEO || this.pendingByDataType.has(dataType)) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16883
17149
|
this.trace({ phase: "datagram-gap", dataType });
|
|
16884
17150
|
this.pendingByDataType.delete(dataType);
|
|
17151
|
+
if (dataType === P2PDataType.VIDEO)
|
|
17152
|
+
this.emit("videoGap");
|
|
16885
17153
|
const last = this.lastSeqByType.get(dataType);
|
|
16886
17154
|
const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
|
|
16887
17155
|
this.lastSeqByType.set(dataType, earliest - 1 & 65535);
|
|
@@ -16900,32 +17168,35 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16900
17168
|
* Frames are packed back to back, so a frame header can itself be cut by a datagram boundary. The start
|
|
16901
17169
|
* of a header left at the end of a datagram is carried into the next one rather than discarded; dropping
|
|
16902
17170
|
* it would lose that frame and every frame after it until a datagram happened to begin on a header.
|
|
17171
|
+
* Payload fragments retain their original slices and are copied once, when the complete frame is delivered.
|
|
16903
17172
|
*/
|
|
16904
17173
|
reassemble(dataType, datagramBody) {
|
|
16905
17174
|
const pending = this.pendingByDataType.get(dataType);
|
|
16906
|
-
let body = pending ? Buffer.concat([pending.buf, datagramBody]) : datagramBody;
|
|
16907
|
-
const carryHeader = pending?.header;
|
|
16908
17175
|
this.pendingByDataType.delete(dataType);
|
|
16909
|
-
|
|
16910
|
-
|
|
16911
|
-
|
|
17176
|
+
let body = pending && !pending.header ? Buffer.concat([...pending.chunks, datagramBody]) : datagramBody;
|
|
17177
|
+
if (pending?.header) {
|
|
17178
|
+
const taken = Math.min(pending.header.bytesToRead - pending.byteLength, body.length);
|
|
17179
|
+
pending.chunks.push(body.subarray(0, taken));
|
|
17180
|
+
pending.byteLength += taken;
|
|
17181
|
+
body = body.subarray(taken);
|
|
17182
|
+
if (pending.byteLength < pending.header.bytesToRead) {
|
|
17183
|
+
this.pendingByDataType.set(dataType, pending);
|
|
16912
17184
|
return;
|
|
16913
17185
|
}
|
|
16914
|
-
this.handleFrame(
|
|
16915
|
-
body = body.subarray(carryHeader.bytesToRead);
|
|
17186
|
+
this.handleFrame(pending.header, Buffer.concat(pending.chunks, pending.byteLength), dataType);
|
|
16916
17187
|
}
|
|
16917
17188
|
while (body.length >= P2P_DATA_HEADER_BYTES && body.subarray(0, 4).toString() === MAGIC_WORD) {
|
|
16918
17189
|
const header = parseDataFrameHeader(body);
|
|
16919
17190
|
const payload = body.subarray(P2P_DATA_HEADER_BYTES);
|
|
16920
17191
|
if (payload.length < header.bytesToRead) {
|
|
16921
|
-
this.pendingByDataType.set(dataType, { header,
|
|
17192
|
+
this.pendingByDataType.set(dataType, { header, chunks: [payload], byteLength: payload.length });
|
|
16922
17193
|
return;
|
|
16923
17194
|
}
|
|
16924
17195
|
this.handleFrame(header, payload.subarray(0, header.bytesToRead), dataType);
|
|
16925
17196
|
body = body.subarray(P2P_DATA_HEADER_BYTES + header.bytesToRead);
|
|
16926
17197
|
}
|
|
16927
17198
|
if (body.length > 0 && body.length < P2P_DATA_HEADER_BYTES && MAGIC_WORD.startsWith(body.subarray(0, 4).toString())) {
|
|
16928
|
-
this.pendingByDataType.set(dataType, {
|
|
17199
|
+
this.pendingByDataType.set(dataType, { chunks: [body], byteLength: body.length });
|
|
16929
17200
|
}
|
|
16930
17201
|
}
|
|
16931
17202
|
/**
|
|
@@ -17518,7 +17789,8 @@ var AccessUnitAssembler = class {
|
|
|
17518
17789
|
* `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
|
|
17519
17790
|
* frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
|
|
17520
17791
|
* of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
|
|
17521
|
-
* carries).
|
|
17792
|
+
* carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
|
|
17793
|
+
* as a complete unit, even if its repeated header says keyframe.
|
|
17522
17794
|
*/
|
|
17523
17795
|
push(payload, decode) {
|
|
17524
17796
|
const header = parseVideoFrameHeader(payload);
|
|
@@ -17538,6 +17810,8 @@ var AccessUnitAssembler = class {
|
|
|
17538
17810
|
return [unitOf(open2.header, Buffer.concat(open2.chunks))];
|
|
17539
17811
|
}
|
|
17540
17812
|
this.discard();
|
|
17813
|
+
if (!beginsAccessUnit(body))
|
|
17814
|
+
return [];
|
|
17541
17815
|
if (full) {
|
|
17542
17816
|
this.open = { header, chunks: [body], carried: body.length };
|
|
17543
17817
|
return [];
|
|
@@ -17563,7 +17837,13 @@ function beginsAccessUnit(body) {
|
|
|
17563
17837
|
return body[2] === 1 || body[2] === 0 && body[3] === 1;
|
|
17564
17838
|
}
|
|
17565
17839
|
function unitOf(header, data) {
|
|
17566
|
-
return {
|
|
17840
|
+
return {
|
|
17841
|
+
sourceTimestamp: header.timestamp,
|
|
17842
|
+
keyframe: header.keyframe,
|
|
17843
|
+
width: header.width,
|
|
17844
|
+
height: header.height,
|
|
17845
|
+
data
|
|
17846
|
+
};
|
|
17567
17847
|
}
|
|
17568
17848
|
|
|
17569
17849
|
// dist/transport/p2p/annexb.js
|
|
@@ -17666,6 +17946,18 @@ function startCodeBegin(buf, payloadOffset) {
|
|
|
17666
17946
|
return threeByte - 1;
|
|
17667
17947
|
return threeByte;
|
|
17668
17948
|
}
|
|
17949
|
+
function hasIdr(buf, codec) {
|
|
17950
|
+
for (const p of nalStarts(buf)) {
|
|
17951
|
+
const b = buf[p];
|
|
17952
|
+
if (codec === "h265") {
|
|
17953
|
+
const t = b >> 1 & 63;
|
|
17954
|
+
if (t === 19 || t === 20)
|
|
17955
|
+
return true;
|
|
17956
|
+
} else if ((b & 31) === 5)
|
|
17957
|
+
return true;
|
|
17958
|
+
}
|
|
17959
|
+
return false;
|
|
17960
|
+
}
|
|
17669
17961
|
var MAX_CODED_DIMENSION = 32768;
|
|
17670
17962
|
var H264_CHROMA_PROFILES = /* @__PURE__ */ new Set([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135, 144]);
|
|
17671
17963
|
var CHROMA_SUBSAMPLING = {
|
|
@@ -17878,6 +18170,7 @@ var AUDIO_HEADER_LEN = 16;
|
|
|
17878
18170
|
var AUDIO_TYPE_OFFSET = 5;
|
|
17879
18171
|
var SC4 = Buffer.from([0, 0, 0, 1]);
|
|
17880
18172
|
var SC3 = Buffer.from([0, 0, 1]);
|
|
18173
|
+
var AAC_ELD_AUDIO_SPECIFIC_CONFIG = Buffer.from("f8f03000", "hex");
|
|
17881
18174
|
var AUDIO_CODECS = { 0: "aac-lc", 2: "g711a", 7: "aac-eld" };
|
|
17882
18175
|
var MAX_TRACED_DECODE_FAILURES = 3;
|
|
17883
18176
|
var DEFAULT_KEEPALIVE_MS = 3e3;
|
|
@@ -17891,6 +18184,12 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17891
18184
|
lastCodec = "h264";
|
|
17892
18185
|
/** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
|
|
17893
18186
|
units = new AccessUnitAssembler((drop) => this.reportDroppedUnit(drop));
|
|
18187
|
+
/** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
|
|
18188
|
+
awaitingKeyframeAfterGap = false;
|
|
18189
|
+
videoGapHandler = () => {
|
|
18190
|
+
this.units.discard();
|
|
18191
|
+
this.awaitingKeyframeAfterGap = true;
|
|
18192
|
+
};
|
|
17894
18193
|
/** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
|
|
17895
18194
|
mediaChannel;
|
|
17896
18195
|
tracedFirstVideoCommand = false;
|
|
@@ -17933,6 +18232,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
17933
18232
|
return this;
|
|
17934
18233
|
this.listening = true;
|
|
17935
18234
|
this.session.on("data", this.handler);
|
|
18235
|
+
this.session.on("videoGap", this.videoGapHandler);
|
|
17936
18236
|
this.session.on("liveStartUnacknowledged", this.unackedHandler);
|
|
17937
18237
|
this.sendStart();
|
|
17938
18238
|
const keepAliveMs = this.opts.keepAliveMs ?? DEFAULT_KEEPALIVE_MS;
|
|
@@ -18030,6 +18330,8 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18030
18330
|
return;
|
|
18031
18331
|
this.listening = false;
|
|
18032
18332
|
this.session.off("data", this.handler);
|
|
18333
|
+
this.session.off("videoGap", this.videoGapHandler);
|
|
18334
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18033
18335
|
this.session.off("liveStartUnacknowledged", this.unackedHandler);
|
|
18034
18336
|
if (this.kaTimer)
|
|
18035
18337
|
clearInterval(this.kaTimer);
|
|
@@ -18065,6 +18367,11 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18065
18367
|
try {
|
|
18066
18368
|
if (f.commandId === CMD_VIDEO_FRAME2) {
|
|
18067
18369
|
for (const unit of this.units.push(f.data, (payload) => this.annexbOf(payload, f.signCode))) {
|
|
18370
|
+
if (this.awaitingKeyframeAfterGap) {
|
|
18371
|
+
if (!unit.keyframe)
|
|
18372
|
+
continue;
|
|
18373
|
+
this.awaitingKeyframeAfterGap = false;
|
|
18374
|
+
}
|
|
18068
18375
|
if (!this.tracedFirstVideoUnit) {
|
|
18069
18376
|
this.tracedFirstVideoUnit = true;
|
|
18070
18377
|
this.trace({ phase: "first-video-unit", keyframe: unit.keyframe });
|
|
@@ -18077,6 +18384,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18077
18384
|
this.lastCodec = sniffAnnexbCodec(unit.data) ?? this.lastCodec;
|
|
18078
18385
|
this.settleKeepalive();
|
|
18079
18386
|
this.emit("video", {
|
|
18387
|
+
sourceTimestamp: unit.sourceTimestamp,
|
|
18080
18388
|
keyframe: unit.keyframe,
|
|
18081
18389
|
width: unit.width,
|
|
18082
18390
|
height: unit.height,
|
|
@@ -18092,7 +18400,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18092
18400
|
if (!codec) {
|
|
18093
18401
|
this.logger.debug(`[live] dropping audio frame: unknown codec id ${codecId ?? "missing"}`);
|
|
18094
18402
|
} else {
|
|
18095
|
-
this.emit("audio", { codec, data: audio });
|
|
18403
|
+
this.emit("audio", codec === "aac-eld" ? { codec, data: audio, config: AAC_ELD_AUDIO_SPECIFIC_CONFIG } : { codec, data: audio });
|
|
18096
18404
|
}
|
|
18097
18405
|
}
|
|
18098
18406
|
}
|
|
@@ -18159,6 +18467,7 @@ var LiveStream = class extends EventEmitter3 {
|
|
|
18159
18467
|
* otherwise silent in both directions: no frame reaches a consumer, and nothing states why.
|
|
18160
18468
|
*/
|
|
18161
18469
|
reportDroppedUnit(drop) {
|
|
18470
|
+
this.awaitingKeyframeAfterGap = true;
|
|
18162
18471
|
const message = `[live] dropped an incomplete access unit (${drop.carried} bytes in ${drop.chunks} frame(s), tail never arrived, ${drop.count} so far)`;
|
|
18163
18472
|
if (drop.count === 1)
|
|
18164
18473
|
this.logger.warn(message);
|
|
@@ -18572,9 +18881,11 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18572
18881
|
if (eccPrivateKey.length !== 32)
|
|
18573
18882
|
throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
|
|
18574
18883
|
const decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
18884
|
+
const assembler = new AccessUnitAssembler();
|
|
18575
18885
|
const video = [];
|
|
18576
18886
|
const audio = [];
|
|
18577
18887
|
const numbers = /* @__PURE__ */ new Set();
|
|
18888
|
+
const receivedNumbers = /* @__PURE__ */ new Set();
|
|
18578
18889
|
let firstStamp;
|
|
18579
18890
|
let lastStamp;
|
|
18580
18891
|
for (const { commandId, raw } of frames) {
|
|
@@ -18582,14 +18893,15 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18582
18893
|
const header = parseVideoFrameHeader(raw);
|
|
18583
18894
|
if (!header)
|
|
18584
18895
|
continue;
|
|
18585
|
-
|
|
18586
|
-
|
|
18587
|
-
|
|
18588
|
-
|
|
18589
|
-
|
|
18590
|
-
|
|
18591
|
-
|
|
18592
|
-
|
|
18896
|
+
receivedNumbers.add(header.sequence);
|
|
18897
|
+
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);
|
|
18898
|
+
for (const unit of units) {
|
|
18899
|
+
video.push(unit.data);
|
|
18900
|
+
numbers.add(header.sequence);
|
|
18901
|
+
const stamp = raw.readUIntLE(14, 6);
|
|
18902
|
+
firstStamp ??= stamp;
|
|
18903
|
+
lastStamp = stamp;
|
|
18904
|
+
}
|
|
18593
18905
|
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
18594
18906
|
const mediaKey = decoder.currentMediaKey;
|
|
18595
18907
|
if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
@@ -18605,9 +18917,10 @@ function decodeRecording(frames, eccPrivateKeyHex) {
|
|
|
18605
18917
|
if (!video.length)
|
|
18606
18918
|
throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
|
|
18607
18919
|
const ordered = [...numbers];
|
|
18608
|
-
|
|
18609
|
-
|
|
18610
|
-
|
|
18920
|
+
const received = [...receivedNumbers];
|
|
18921
|
+
let missingFrames = receivedNumbers.size - numbers.size;
|
|
18922
|
+
for (let i = 1; i < received.length; i++) {
|
|
18923
|
+
const step = received[i] - received[i - 1] & 65535;
|
|
18611
18924
|
if (step > 1 && step < 32768)
|
|
18612
18925
|
missingFrames += step - 1;
|
|
18613
18926
|
}
|
|
@@ -19415,6 +19728,14 @@ var SharedLiveSource = class {
|
|
|
19415
19728
|
this.opts.onIdle?.();
|
|
19416
19729
|
}
|
|
19417
19730
|
};
|
|
19731
|
+
function attachUnlessAborted(source, signal) {
|
|
19732
|
+
const consumer = source.attach();
|
|
19733
|
+
if (signal?.aborted) {
|
|
19734
|
+
consumer.detach();
|
|
19735
|
+
signal.throwIfAborted();
|
|
19736
|
+
}
|
|
19737
|
+
return consumer;
|
|
19738
|
+
}
|
|
19418
19739
|
|
|
19419
19740
|
// dist/transport/p2p/session-manager.js
|
|
19420
19741
|
var SessionSupersededError = class extends Error {
|
|
@@ -19828,7 +20149,8 @@ var AdtsFrameReader = class {
|
|
|
19828
20149
|
};
|
|
19829
20150
|
|
|
19830
20151
|
// dist/transport/p2p/talkback.js
|
|
19831
|
-
var
|
|
20152
|
+
var HIGH_WATER_MS = 2e3;
|
|
20153
|
+
var TALKBACK_ELD_FRAME_MS = 32;
|
|
19832
20154
|
var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
|
|
19833
20155
|
var Talkback = class extends EventEmitter5 {
|
|
19834
20156
|
sink;
|
|
@@ -19836,7 +20158,8 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19836
20158
|
reader = new AdtsFrameReader();
|
|
19837
20159
|
queue = [];
|
|
19838
20160
|
logger;
|
|
19839
|
-
|
|
20161
|
+
/** Playback duration of one queued frame, the pacer's step: 64 ms for `aac-lc`, 32 ms for `aac-eld`. */
|
|
20162
|
+
frameMs = AAC_FRAME_MS;
|
|
19840
20163
|
idleTimeoutMs;
|
|
19841
20164
|
timer;
|
|
19842
20165
|
started = false;
|
|
@@ -19862,9 +20185,14 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19862
20185
|
this.sink = sink;
|
|
19863
20186
|
this.opts = opts;
|
|
19864
20187
|
this.logger = opts.logger ?? noopLogger;
|
|
19865
|
-
this.highWater = opts.highWaterFrames ?? DEFAULT_HIGH_WATER_FRAMES;
|
|
19866
20188
|
this.idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
19867
20189
|
}
|
|
20190
|
+
/** Frames the pacing queue holds before a writable applies backpressure: {@link HIGH_WATER_MS} of this codec. */
|
|
20191
|
+
get highWater() {
|
|
20192
|
+
return this.opts.highWaterFrames ?? Math.ceil(HIGH_WATER_MS / this.frameMs);
|
|
20193
|
+
}
|
|
20194
|
+
/** The codec this talkback sends, set by {@link start}. */
|
|
20195
|
+
codec = "aac-lc";
|
|
19868
20196
|
/** Frames still queued for the wire. */
|
|
19869
20197
|
get pending() {
|
|
19870
20198
|
return this.queue.length;
|
|
@@ -19873,25 +20201,33 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19873
20201
|
* Open the device's talkback path and begin the pacing tick. Throws when the topology's control
|
|
19874
20202
|
* frame could not be sent — for a HomeBase-attached camera that means the level-2 key was never
|
|
19875
20203
|
* negotiated, which would otherwise leave a silent session that accepts audio nobody hears.
|
|
20204
|
+
*
|
|
20205
|
+
* `codec` is the codec the camera's speaker plays; it fixes the input this talkback accepts and the pacing step.
|
|
19876
20206
|
*/
|
|
19877
|
-
start() {
|
|
20207
|
+
start(codec) {
|
|
19878
20208
|
if (this.started)
|
|
19879
20209
|
return this;
|
|
20210
|
+
if (codec === "aac-eld" && this.opts.encoder) {
|
|
20211
|
+
throw new Error("talkback: this camera plays aac-eld; an AacEncoder produces aac-lc");
|
|
20212
|
+
}
|
|
20213
|
+
this.codec = codec;
|
|
20214
|
+
this.frameMs = codec === "aac-eld" ? TALKBACK_ELD_FRAME_MS : AAC_FRAME_MS;
|
|
19880
20215
|
if (!this.sink.startTalkback(this.opts.channel, this.opts.homeBaseAttached)) {
|
|
19881
20216
|
throw new Error(`talkback: could not open the audio path on channel ${this.opts.channel}`);
|
|
19882
20217
|
}
|
|
19883
20218
|
this.started = true;
|
|
19884
20219
|
const now2 = Date.now();
|
|
19885
|
-
this.nextDueAt = now2 +
|
|
20220
|
+
this.nextDueAt = now2 + this.frameMs;
|
|
19886
20221
|
this.lastWriteAt = now2;
|
|
19887
|
-
this.timer = setInterval(() => this.tick(),
|
|
20222
|
+
this.timer = setInterval(() => this.tick(), this.frameMs);
|
|
19888
20223
|
this.timer.unref?.();
|
|
19889
20224
|
this.sink.on?.("audioGap", this.onAudioGap);
|
|
19890
20225
|
return this;
|
|
19891
20226
|
}
|
|
19892
20227
|
/**
|
|
19893
|
-
* Queue audio.
|
|
19894
|
-
*
|
|
20228
|
+
* Queue audio in the codec this talkback sends. For `aac-lc` the bytes are ADTS, or PCM when an encoder was
|
|
20229
|
+
* supplied, read as a stream, so a chunk carrying part of a frame is held until the rest arrives. For `aac-eld`
|
|
20230
|
+
* each call is one whole access unit; a unit over the device's length limit is dropped with an `error`.
|
|
19895
20231
|
*
|
|
19896
20232
|
* Audio written after {@link end} is dropped with an `error` rather than queued: `finished` has
|
|
19897
20233
|
* either already fired or is owed on the frames written before it, so a late chunk would either play
|
|
@@ -19905,6 +20241,15 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19905
20241
|
return;
|
|
19906
20242
|
}
|
|
19907
20243
|
this.lastWriteAt = Date.now();
|
|
20244
|
+
if (this.codec === "aac-eld") {
|
|
20245
|
+
if (chunk.length > MAX_AUDIO_FRAME_BYTES) {
|
|
20246
|
+
this.fail(new Error(`talkback: frame of ${chunk.length} B exceeds the device's ${MAX_AUDIO_FRAME_BYTES} B limit`));
|
|
20247
|
+
return;
|
|
20248
|
+
}
|
|
20249
|
+
if (chunk.length)
|
|
20250
|
+
this.queue.push(chunk);
|
|
20251
|
+
return;
|
|
20252
|
+
}
|
|
19908
20253
|
if (!this.opts.encoder) {
|
|
19909
20254
|
this.enqueue(this.reader.push(chunk));
|
|
19910
20255
|
return;
|
|
@@ -19973,14 +20318,14 @@ var Talkback = class extends EventEmitter5 {
|
|
|
19973
20318
|
return;
|
|
19974
20319
|
const now2 = Date.now();
|
|
19975
20320
|
if (!this.queue.length) {
|
|
19976
|
-
this.nextDueAt = now2 +
|
|
20321
|
+
this.nextDueAt = now2 + this.frameMs;
|
|
19977
20322
|
this.idleCheck(now2);
|
|
19978
20323
|
this.announceFinishIfDone();
|
|
19979
20324
|
return;
|
|
19980
20325
|
}
|
|
19981
20326
|
while (this.queue.length && now2 >= this.nextDueAt) {
|
|
19982
20327
|
const frame = this.queue.shift();
|
|
19983
|
-
this.nextDueAt +=
|
|
20328
|
+
this.nextDueAt += this.frameMs;
|
|
19984
20329
|
try {
|
|
19985
20330
|
this.sink.sendAudioFrame(this.opts.channel, frame);
|
|
19986
20331
|
} catch (e) {
|
|
@@ -20038,9 +20383,11 @@ var Talkback = class extends EventEmitter5 {
|
|
|
20038
20383
|
/**
|
|
20039
20384
|
* A Writable over {@link write} that withholds its callback while the queue is at the high-water
|
|
20040
20385
|
* mark — which is what turns `pipe()` from a file into playback at speed rather than a memory spike.
|
|
20386
|
+
* For `aac-eld` it is object-mode: each write is one access unit.
|
|
20041
20387
|
*/
|
|
20042
20388
|
writable() {
|
|
20043
20389
|
return new Writable({
|
|
20390
|
+
objectMode: this.codec === "aac-eld",
|
|
20044
20391
|
write: (chunk, _enc, cb) => {
|
|
20045
20392
|
this.write(chunk);
|
|
20046
20393
|
if (this.queue.length < this.highWater)
|
|
@@ -20109,7 +20456,7 @@ var CODEC_BOXES = {
|
|
|
20109
20456
|
h264: { sample: "avc1", config: "avcC" },
|
|
20110
20457
|
h265: { sample: "hvc1", config: "hvcC" }
|
|
20111
20458
|
};
|
|
20112
|
-
var AAC_ELD_SAMPLES_PER_FRAME =
|
|
20459
|
+
var AAC_ELD_SAMPLES_PER_FRAME = 480;
|
|
20113
20460
|
var ADTS_FREQUENCY_INDEX_16K = 8;
|
|
20114
20461
|
var ADTS_CHANNELS_MONO = 1;
|
|
20115
20462
|
var Fmp4Muxer = class {
|
|
@@ -20124,9 +20471,12 @@ var Fmp4Muxer = class {
|
|
|
20124
20471
|
fragTicks = 0;
|
|
20125
20472
|
// ticks accumulated in the open fragment
|
|
20126
20473
|
samples = [];
|
|
20127
|
-
|
|
20474
|
+
/** Ticks from the first video sample's start to the newest sample's end, at its provisional duration. */
|
|
20475
|
+
videoTicks = 0;
|
|
20128
20476
|
audioRequested;
|
|
20129
20477
|
audioCodec;
|
|
20478
|
+
/** Decoder config of an AAC-ELD track, taken from its frames; absent for AAC-LC, whose `esds` config is fixed. */
|
|
20479
|
+
audioConfig;
|
|
20130
20480
|
audioDisabled = false;
|
|
20131
20481
|
audioSamples = [];
|
|
20132
20482
|
audioBaseDecodeTime = 0;
|
|
@@ -20146,6 +20496,10 @@ var Fmp4Muxer = class {
|
|
|
20146
20496
|
* Push one access unit. Returns any completed segments: the `init` on the first keyframe, and/or a
|
|
20147
20497
|
* media fragment when this frame closed the open one. Returns `undefined` if nothing is emitted yet
|
|
20148
20498
|
* (e.g. delta frames before the first keyframe).
|
|
20499
|
+
*
|
|
20500
|
+
* A sample lasts until the next frame's `timestampMs`, measured from the first keyframe's, so the video
|
|
20501
|
+
* timeline follows the arrival clock; a frame that shares the previous one's timestamp leaves that
|
|
20502
|
+
* sample one tick long, and the next frame's duration absorbs the difference.
|
|
20149
20503
|
*/
|
|
20150
20504
|
push(frame, timestampMs = now()) {
|
|
20151
20505
|
if (frame.codec === "av1")
|
|
@@ -20164,15 +20518,14 @@ var Fmp4Muxer = class {
|
|
|
20164
20518
|
this.height = declared?.height ?? frame.height;
|
|
20165
20519
|
this.firstVideoTimestampMs = timestampMs;
|
|
20166
20520
|
}
|
|
20167
|
-
if (this.
|
|
20168
|
-
const
|
|
20169
|
-
|
|
20170
|
-
|
|
20171
|
-
|
|
20172
|
-
|
|
20173
|
-
|
|
20521
|
+
if (this.firstVideoTimestampMs !== void 0 && this.samples.length) {
|
|
20522
|
+
const previous = this.samples[this.samples.length - 1];
|
|
20523
|
+
const arrival = Math.round((timestampMs - this.firstVideoTimestampMs) * (TIMESCALE / 1e3));
|
|
20524
|
+
const duration = Math.max(1, arrival - (this.videoTicks - previous.duration));
|
|
20525
|
+
this.fragTicks += duration - previous.duration;
|
|
20526
|
+
this.videoTicks += duration - previous.duration;
|
|
20527
|
+
previous.duration = duration;
|
|
20174
20528
|
}
|
|
20175
|
-
this.lastVideoTimestampMs = timestampMs;
|
|
20176
20529
|
let fragment;
|
|
20177
20530
|
if (frame.keyframe && this.samples.length > 0 && this.fragTicks >= this.fragmentTicks) {
|
|
20178
20531
|
if (!this.initSent) {
|
|
@@ -20184,6 +20537,7 @@ var Fmp4Muxer = class {
|
|
|
20184
20537
|
}
|
|
20185
20538
|
this.samples.push({ data: annexbToAvcc(frame.data), duration: this.firstDuration, keyframe: frame.keyframe });
|
|
20186
20539
|
this.fragTicks += this.firstDuration;
|
|
20540
|
+
this.videoTicks += this.firstDuration;
|
|
20187
20541
|
if (!this.initSent && (!this.audioRequested || this.audioCodec || this.audioDisabled)) {
|
|
20188
20542
|
init = this.buildInit();
|
|
20189
20543
|
this.initSent = true;
|
|
@@ -20193,7 +20547,10 @@ var Fmp4Muxer = class {
|
|
|
20193
20547
|
}
|
|
20194
20548
|
return void 0;
|
|
20195
20549
|
}
|
|
20196
|
-
/**
|
|
20550
|
+
/**
|
|
20551
|
+
* Add one station-declared AAC access unit to the audio track: an ADTS frame for AAC-LC, a raw access unit
|
|
20552
|
+
* described by its frame's config for AAC-ELD. G.711 remains available via `live()`.
|
|
20553
|
+
*/
|
|
20197
20554
|
pushAudio(frame, timestampMs = now()) {
|
|
20198
20555
|
if (!this.audioRequested || this.audioDisabled)
|
|
20199
20556
|
return void 0;
|
|
@@ -20209,12 +20566,19 @@ var Fmp4Muxer = class {
|
|
|
20209
20566
|
if (this.audioCodec && this.audioCodec !== frame.codec) {
|
|
20210
20567
|
return this.disableAudio();
|
|
20211
20568
|
}
|
|
20212
|
-
|
|
20213
|
-
if (
|
|
20214
|
-
|
|
20215
|
-
|
|
20216
|
-
|
|
20217
|
-
|
|
20569
|
+
let sample;
|
|
20570
|
+
if (frame.codec === "aac-eld") {
|
|
20571
|
+
this.audioConfig = frame.config;
|
|
20572
|
+
sample = frame.data;
|
|
20573
|
+
} else {
|
|
20574
|
+
const header = parseAdtsHeader(frame.data);
|
|
20575
|
+
if (!header || header.frameLength > frame.data.length) {
|
|
20576
|
+
throw new Error(`fMP4 muxer: ${frame.codec} frame is not a complete ADTS access unit`);
|
|
20577
|
+
}
|
|
20578
|
+
if (header.frequencyIndex !== ADTS_FREQUENCY_INDEX_16K || header.channels !== ADTS_CHANNELS_MONO) {
|
|
20579
|
+
return this.disableAudio();
|
|
20580
|
+
}
|
|
20581
|
+
sample = frame.data.subarray(header.headerLength, header.frameLength);
|
|
20218
20582
|
}
|
|
20219
20583
|
this.audioCodec = frame.codec;
|
|
20220
20584
|
this.firstAudioTimestampMs ??= timestampMs;
|
|
@@ -20226,7 +20590,7 @@ var Fmp4Muxer = class {
|
|
|
20226
20590
|
this.audioSamples[this.audioSamples.length - 1].duration = measured;
|
|
20227
20591
|
}
|
|
20228
20592
|
this.audioSamples.push({
|
|
20229
|
-
data:
|
|
20593
|
+
data: sample,
|
|
20230
20594
|
duration: frame.codec === "aac-eld" ? AAC_ELD_SAMPLES_PER_FRAME : AAC_SAMPLES_PER_FRAME,
|
|
20231
20595
|
keyframe: true
|
|
20232
20596
|
});
|
|
@@ -20499,7 +20863,7 @@ var Fmp4Muxer = class {
|
|
|
20499
20863
|
return box("mp4a", head, this.esds());
|
|
20500
20864
|
}
|
|
20501
20865
|
esds() {
|
|
20502
|
-
const config = this.
|
|
20866
|
+
const config = this.audioConfig ?? Buffer.from([20, 8]);
|
|
20503
20867
|
const specific = descriptor(5, config);
|
|
20504
20868
|
const decoder = descriptor(4, Buffer.concat([Buffer.from([64, 21, 0, 0, 0]), u32(64e3), u32(32e3), specific]));
|
|
20505
20869
|
const sl = descriptor(6, Buffer.from([2]));
|
|
@@ -20704,16 +21068,12 @@ function stationOf(dev) {
|
|
|
20704
21068
|
const parent = typeof raw.parent_sn === "string" && raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : void 0;
|
|
20705
21069
|
return parent ?? dev.stationSn ?? resolvedStationSn(raw, dev.sn);
|
|
20706
21070
|
}
|
|
20707
|
-
function statedChannel(dev) {
|
|
20708
|
-
const v = dev.raw?.device_channel;
|
|
20709
|
-
return typeof v === "number" ? v : void 0;
|
|
20710
|
-
}
|
|
20711
21071
|
function stationChannels(devices) {
|
|
20712
21072
|
const out = /* @__PURE__ */ new Map();
|
|
20713
21073
|
const claimants = /* @__PURE__ */ new Map();
|
|
20714
21074
|
for (const d of devices) {
|
|
20715
21075
|
const station = stationOf(d);
|
|
20716
|
-
const stated =
|
|
21076
|
+
const stated = d.channel;
|
|
20717
21077
|
if (d.sn === station || stated === void 0)
|
|
20718
21078
|
continue;
|
|
20719
21079
|
const counts = claimants.get(station) ?? /* @__PURE__ */ new Map();
|
|
@@ -20722,7 +21082,7 @@ function stationChannels(devices) {
|
|
|
20722
21082
|
}
|
|
20723
21083
|
for (const d of devices) {
|
|
20724
21084
|
const station = stationOf(d);
|
|
20725
|
-
const stated =
|
|
21085
|
+
const stated = d.channel;
|
|
20726
21086
|
if (d.sn === station)
|
|
20727
21087
|
out.set(d.sn, { channel: stated ?? 0 });
|
|
20728
21088
|
else if (stated === void 0)
|
|
@@ -20737,17 +21097,6 @@ function stationChannels(devices) {
|
|
|
20737
21097
|
|
|
20738
21098
|
// dist/transport/p2p/command-router.js
|
|
20739
21099
|
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
21100
|
var LEVEL2_GRACE_MS = 25e3;
|
|
20752
21101
|
var LEVEL2_SETTLE_MS = 8e3;
|
|
20753
21102
|
var P2P_STATION_WAITS = {
|
|
@@ -20756,6 +21105,30 @@ var P2P_STATION_WAITS = {
|
|
|
20756
21105
|
level2Settle: LEVEL2_SETTLE_MS
|
|
20757
21106
|
};
|
|
20758
21107
|
var RTSP_URL_READ_TIMEOUT_MS = 12e3;
|
|
21108
|
+
var TALKBACK_CODEC_WAIT_MS = 3e3;
|
|
21109
|
+
var ELD_TALKBACK_MODELS = /* @__PURE__ */ new Set(["T8410C"]);
|
|
21110
|
+
function speakerCodec(consumer, talk, timeoutMs) {
|
|
21111
|
+
return new Promise((resolve) => {
|
|
21112
|
+
let timer;
|
|
21113
|
+
const settle = (codec) => {
|
|
21114
|
+
clearTimeout(timer);
|
|
21115
|
+
consumer.off("video", onVideo);
|
|
21116
|
+
consumer.off("audio", onAudio);
|
|
21117
|
+
consumer.off("stop", onStop);
|
|
21118
|
+
talk.off("stop", onStop);
|
|
21119
|
+
resolve(codec);
|
|
21120
|
+
};
|
|
21121
|
+
const onVideo = () => {
|
|
21122
|
+
timer ??= setTimeout(() => settle("aac-lc"), timeoutMs);
|
|
21123
|
+
};
|
|
21124
|
+
const onAudio = (frame) => settle(frame.codec === "aac-eld" ? "aac-eld" : "aac-lc");
|
|
21125
|
+
const onStop = () => settle("aac-lc");
|
|
21126
|
+
consumer.on("video", onVideo);
|
|
21127
|
+
consumer.on("audio", onAudio);
|
|
21128
|
+
consumer.on("stop", onStop);
|
|
21129
|
+
talk.once("stop", onStop);
|
|
21130
|
+
});
|
|
21131
|
+
}
|
|
20759
21132
|
var SHARED_LIVE_OPT_KEYS = [
|
|
20760
21133
|
"eccPrivateKey",
|
|
20761
21134
|
"keepAliveMs",
|
|
@@ -21174,14 +21547,20 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21174
21547
|
}
|
|
21175
21548
|
}
|
|
21176
21549
|
/**
|
|
21177
|
-
* Restart
|
|
21178
|
-
*
|
|
21179
|
-
*
|
|
21180
|
-
*
|
|
21181
|
-
*
|
|
21550
|
+
* Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
|
|
21551
|
+
* broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
|
|
21552
|
+
* byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
|
|
21553
|
+
*
|
|
21554
|
+
* The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
|
|
21555
|
+
* level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
|
|
21556
|
+
* sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
|
|
21557
|
+
* control here is.
|
|
21182
21558
|
*/
|
|
21183
21559
|
async rebootStation(sn) {
|
|
21184
|
-
await this.
|
|
21560
|
+
await this.sendBySessionLevel(sn, {
|
|
21561
|
+
l2: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel2Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB, 8), resolved),
|
|
21562
|
+
l1: (resolved) => this.replayLevel2Send(sn, `reboot ${sn}`, ({ session, accountId }) => session.sendRawLevel1Bytes(buildDirectBinaryBody(0, accountId), 255, P2P_ENVELOPE.RESTART_HUB), resolved)
|
|
21563
|
+
});
|
|
21185
21564
|
}
|
|
21186
21565
|
/**
|
|
21187
21566
|
* A {@link MediaProvider} bound to one serial — resolves the session then calls `p2p/media`.
|
|
@@ -21209,11 +21588,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21209
21588
|
},
|
|
21210
21589
|
live: async (opts) => {
|
|
21211
21590
|
const source = await this.sharedLiveSourceFor(sn, opts, true);
|
|
21212
|
-
return
|
|
21591
|
+
return attachUnlessAborted(source, opts?.signal);
|
|
21213
21592
|
},
|
|
21214
21593
|
openReadable: async (opts) => {
|
|
21215
21594
|
const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
|
|
21216
|
-
return openReadableFromConsumer(
|
|
21595
|
+
return openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts);
|
|
21217
21596
|
},
|
|
21218
21597
|
recordFragments: (opts) => this.recordFragments(sn, opts),
|
|
21219
21598
|
talkback: (opts) => this.openTalkback(sn, opts),
|
|
@@ -21303,6 +21682,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21303
21682
|
* stream costs nothing extra and a talkback on an otherwise idle camera opens the session it needs
|
|
21304
21683
|
* instead of playing into silence.
|
|
21305
21684
|
*
|
|
21685
|
+
* **On a model in {@link ELD_TALKBACK_MODELS}, the speaker plays the codec the camera sends.** So the first
|
|
21686
|
+
* audio frame of the media session this talkback holds decides its codec ({@link speakerCodec}), within
|
|
21687
|
+
* {@link TALKBACK_CODEC_WAIT_MS} of the session's first frame, and the claim is re-checked after that wait as
|
|
21688
|
+
* after the warm-up. Every other model opens `aac-lc` at once.
|
|
21689
|
+
*
|
|
21306
21690
|
* The level-2 key is waited for softly: only the HomeBase-attached path requires it, and
|
|
21307
21691
|
* {@link Talkback.start} reports that failure precisely, so a hard wait here would reject an
|
|
21308
21692
|
* own-session camera that legitimately never negotiates one.
|
|
@@ -21372,7 +21756,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21372
21756
|
consumer.on("stop", () => {
|
|
21373
21757
|
void talk.stop().catch((e) => logger.warn?.(`talkback: stop for ${sn} failed: ${String(e)}`));
|
|
21374
21758
|
});
|
|
21375
|
-
|
|
21759
|
+
const codec = ELD_TALKBACK_MODELS.has(this.recordFor(sn)?.model ?? "") ? await speakerCodec(consumer, talk, TALKBACK_CODEC_WAIT_MS) : "aac-lc";
|
|
21760
|
+
if (this.talkbacks.get(key) !== talk) {
|
|
21761
|
+
throw new Error(`talkback: ${sn} was closed while its speaker codec was being read`);
|
|
21762
|
+
}
|
|
21763
|
+
return talk.start(codec);
|
|
21376
21764
|
} catch (e) {
|
|
21377
21765
|
if (this.talkbacks.get(key) === talk)
|
|
21378
21766
|
this.talkbacks.delete(key);
|
|
@@ -21572,22 +21960,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21572
21960
|
* A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
|
|
21573
21961
|
* unaffected.
|
|
21574
21962
|
*/
|
|
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
21963
|
/**
|
|
21592
21964
|
* Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
|
|
21593
21965
|
* on a session goes with it, and a media session opened for this source alone is closed: nothing else
|
|
@@ -21648,8 +22020,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
21648
22020
|
tearDownStation(stationSn) {
|
|
21649
22021
|
this.manager.remove(stationSn);
|
|
21650
22022
|
for (const key of [...this.liveSources.keys()]) {
|
|
21651
|
-
|
|
22023
|
+
const owner = this.liveSessionKeys.get(key);
|
|
22024
|
+
if (key.startsWith(`${stationSn}:`) && !(owner && _P2PCommandRouter.isMediaSessionKey(owner))) {
|
|
21652
22025
|
this.dropLiveSource(key);
|
|
22026
|
+
}
|
|
21653
22027
|
}
|
|
21654
22028
|
for (const [key, talk] of this.talkbacks) {
|
|
21655
22029
|
if (key.startsWith(`${stationSn}:`)) {
|
|
@@ -22048,13 +22422,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22048
22422
|
*/
|
|
22049
22423
|
async replayLevel2Send(sn, describe2, send, resolved) {
|
|
22050
22424
|
resolved ??= await this.resolveSession(sn, { waitLevel2: true });
|
|
22051
|
-
let
|
|
22425
|
+
let sent2 = false;
|
|
22052
22426
|
for (let i = 0; i < DIRECT_CMD_SENDS; i++) {
|
|
22053
22427
|
if (send(resolved))
|
|
22054
|
-
|
|
22428
|
+
sent2 = true;
|
|
22055
22429
|
await sleep2(200);
|
|
22056
22430
|
}
|
|
22057
|
-
if (!
|
|
22431
|
+
if (!sent2) {
|
|
22058
22432
|
throw new Error(`${describe2} for ${sn} was never sent (no level-2 key / session not connected)`);
|
|
22059
22433
|
}
|
|
22060
22434
|
}
|
|
@@ -22085,7 +22459,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22085
22459
|
* **`set-json-raw` intent** — bare JSON, no envelope: outer P2P cmd = `outerCmd` itself, plaintext
|
|
22086
22460
|
* exactly `{account_id,...data}` (`session.sendRawLevel2` with no wrapper). Reversed from a live
|
|
22087
22461
|
* 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.
|
|
22462
|
+
* (`snoozeTime`); the alarm-delay config (1255, `arming.ts`'s `ARMING_CMD.SET_ALL_ACTION`) reuses
|
|
22089
22463
|
* the same bare-JSON shape.
|
|
22090
22464
|
*/
|
|
22091
22465
|
async sendJsonRaw(sn, outerCmd, data, channel) {
|
|
@@ -22357,86 +22731,1722 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
22357
22731
|
}
|
|
22358
22732
|
};
|
|
22359
22733
|
|
|
22360
|
-
// dist/transport/
|
|
22361
|
-
import {
|
|
22734
|
+
// dist/transport/rtc/live.js
|
|
22735
|
+
import { EventEmitter as EventEmitter7 } from "node:events";
|
|
22362
22736
|
|
|
22363
|
-
// dist/transport/
|
|
22364
|
-
|
|
22365
|
-
|
|
22737
|
+
// dist/transport/rtc/portal-keepalive.js
|
|
22738
|
+
var PORTAL_KEEPALIVE = Buffer.from("0009000010000000000000006300000000000000585a5948730400000000000000000002", "hex");
|
|
22739
|
+
var PORTAL_KEEPALIVE_MS = 29e3;
|
|
22740
|
+
|
|
22741
|
+
// dist/transport/rtc/portal-packet.js
|
|
22742
|
+
var MAGIC2 = Buffer.from("XZYH", "ascii");
|
|
22743
|
+
var PORTAL_HEADER_LENGTH = 16;
|
|
22744
|
+
var PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
22745
|
+
var PORTAL_STATION_CHANNEL = 255;
|
|
22746
|
+
var PortalLinkType = {
|
|
22747
|
+
/** Command channel: requests out, acknowledgements back. */
|
|
22748
|
+
COMMAND: 1,
|
|
22749
|
+
/** Station-originated frames: pushes, notify payloads, control results. */
|
|
22750
|
+
NOTIFY: 3,
|
|
22751
|
+
/** Live video. */
|
|
22752
|
+
LIVE: 5
|
|
22753
|
+
};
|
|
22754
|
+
function encodeLe(value, width) {
|
|
22755
|
+
const buf = Buffer.alloc(width);
|
|
22756
|
+
let v = value >>> 0;
|
|
22757
|
+
for (let i = 0; i < width; i++) {
|
|
22758
|
+
buf[i] = v & 255;
|
|
22759
|
+
v >>>= 8;
|
|
22760
|
+
}
|
|
22761
|
+
return buf;
|
|
22762
|
+
}
|
|
22763
|
+
function buildPortalHeader(commandId, paramLength, channel, segment, isResponse = 0, devType = 2) {
|
|
22764
|
+
const header = Buffer.alloc(PORTAL_HEADER_LENGTH);
|
|
22765
|
+
MAGIC2.copy(header, 0);
|
|
22766
|
+
encodeLe(commandId, 2).copy(header, 4);
|
|
22767
|
+
encodeLe(paramLength, 4).copy(header, 6);
|
|
22768
|
+
header[10] = 0;
|
|
22769
|
+
header[11] = segment & 255;
|
|
22770
|
+
header[12] = channel & 255;
|
|
22771
|
+
header[13] = 0;
|
|
22772
|
+
header[14] = isResponse & 255;
|
|
22773
|
+
header[15] = devType & 255;
|
|
22774
|
+
return header;
|
|
22775
|
+
}
|
|
22776
|
+
function parsePortalHeader(buf) {
|
|
22777
|
+
if (buf.length < PORTAL_HEADER_LENGTH || buf.subarray(0, 4).compare(MAGIC2) !== 0)
|
|
22778
|
+
return void 0;
|
|
22779
|
+
return {
|
|
22780
|
+
commandId: buf.readUInt16LE(4),
|
|
22781
|
+
paramLength: buf.readUInt32LE(6),
|
|
22782
|
+
segment: buf[11],
|
|
22783
|
+
channel: buf[12],
|
|
22784
|
+
isResponse: buf[14],
|
|
22785
|
+
devType: buf[15]
|
|
22786
|
+
};
|
|
22787
|
+
}
|
|
22788
|
+
function isPortalPacket(buf) {
|
|
22789
|
+
return buf.length >= PORTAL_HEADER_LENGTH && buf.subarray(0, 4).compare(MAGIC2) === 0;
|
|
22790
|
+
}
|
|
22791
|
+
function buildPortalPacket(req) {
|
|
22792
|
+
const body = Buffer.from(JSON.stringify(req.payload), "utf8");
|
|
22793
|
+
const header = buildPortalHeader(req.commandId, body.length, req.channel, req.segment, req.isResponse ?? 0, req.devType ?? 2);
|
|
22794
|
+
return Buffer.concat([header, body]);
|
|
22795
|
+
}
|
|
22796
|
+
function parseJsonBody(buf) {
|
|
22797
|
+
let text2 = buf.toString("utf8");
|
|
22798
|
+
while (text2.endsWith("\0"))
|
|
22799
|
+
text2 = text2.slice(0, -1);
|
|
22366
22800
|
try {
|
|
22367
|
-
return
|
|
22801
|
+
return JSON.parse(text2);
|
|
22368
22802
|
} catch {
|
|
22369
|
-
return
|
|
22803
|
+
return text2;
|
|
22370
22804
|
}
|
|
22371
22805
|
}
|
|
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 });
|
|
22806
|
+
function parsePortalPacket(buf, linkType = PortalLinkType.COMMAND) {
|
|
22807
|
+
const header = parsePortalHeader(buf);
|
|
22808
|
+
if (!header)
|
|
22809
|
+
return void 0;
|
|
22810
|
+
const body = buf.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + header.paramLength);
|
|
22811
|
+
if (linkType === PortalLinkType.NOTIFY) {
|
|
22812
|
+
const data = parseJsonBody(body);
|
|
22813
|
+
const cmd = typeof data === "object" && data !== null && "cmd" in data ? Number(data.cmd) : void 0;
|
|
22814
|
+
return {
|
|
22815
|
+
commandId: header.commandId,
|
|
22816
|
+
channel: header.channel,
|
|
22817
|
+
segment: header.segment,
|
|
22818
|
+
isResponse: header.isResponse,
|
|
22819
|
+
linkType,
|
|
22820
|
+
cmd,
|
|
22821
|
+
data
|
|
22394
22822
|
};
|
|
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
22823
|
}
|
|
22439
|
-
return
|
|
22824
|
+
return {
|
|
22825
|
+
commandId: header.commandId,
|
|
22826
|
+
channel: header.channel,
|
|
22827
|
+
segment: header.segment,
|
|
22828
|
+
isResponse: header.isResponse,
|
|
22829
|
+
linkType,
|
|
22830
|
+
errCode: body.length >= 4 ? body.readInt32LE(0) : -1,
|
|
22831
|
+
data: body.length > 4 ? parseJsonBody(body.subarray(4)) : void 0
|
|
22832
|
+
};
|
|
22833
|
+
}
|
|
22834
|
+
var SegmentCounter = class {
|
|
22835
|
+
current = 0;
|
|
22836
|
+
next() {
|
|
22837
|
+
this.current = this.current + 1 & 255;
|
|
22838
|
+
if (this.current === 0)
|
|
22839
|
+
this.current = 1;
|
|
22840
|
+
return this.current;
|
|
22841
|
+
}
|
|
22842
|
+
};
|
|
22843
|
+
|
|
22844
|
+
// dist/transport/rtc/live.js
|
|
22845
|
+
var LIVE_START = 1003;
|
|
22846
|
+
var LIVE_STOP = 1004;
|
|
22847
|
+
var LIVE_MEDIA = 1300;
|
|
22848
|
+
var LIVE_MEDIA_CHANNEL = 101;
|
|
22849
|
+
var LIVE_STREAM_ID = 1;
|
|
22850
|
+
var MEDIA_HEADER_LENGTH = 22;
|
|
22851
|
+
var STREAM_TYPE_HEVC = 1;
|
|
22852
|
+
function parseMediaBody(body) {
|
|
22853
|
+
if (body.length < MEDIA_HEADER_LENGTH || body[5] !== STREAM_TYPE_HEVC)
|
|
22854
|
+
return void 0;
|
|
22855
|
+
const length = body.readUInt32LE(0);
|
|
22856
|
+
if (body.length < MEDIA_HEADER_LENGTH + length)
|
|
22857
|
+
return void 0;
|
|
22858
|
+
return {
|
|
22859
|
+
data: body.subarray(MEDIA_HEADER_LENGTH, MEDIA_HEADER_LENGTH + length),
|
|
22860
|
+
width: body.readUInt16LE(10),
|
|
22861
|
+
height: body.readUInt16LE(12)
|
|
22862
|
+
};
|
|
22863
|
+
}
|
|
22864
|
+
var RtcLiveStream = class extends EventEmitter7 {
|
|
22865
|
+
opts;
|
|
22866
|
+
running = false;
|
|
22867
|
+
startSegment = -1;
|
|
22868
|
+
keepalive;
|
|
22869
|
+
width = 0;
|
|
22870
|
+
height = 0;
|
|
22871
|
+
onMedia = (frame) => this.onMediaFrame(frame);
|
|
22872
|
+
onCommand = (frame, linkType) => this.onCommandFrame(frame, linkType);
|
|
22873
|
+
onClose = () => this.fail(new Error(`rtc live ${this.tag}: session closed`));
|
|
22874
|
+
constructor(opts) {
|
|
22875
|
+
super();
|
|
22876
|
+
this.opts = opts;
|
|
22877
|
+
}
|
|
22878
|
+
get tag() {
|
|
22879
|
+
return `${this.opts.stationSn}#${this.opts.channel}`;
|
|
22880
|
+
}
|
|
22881
|
+
start() {
|
|
22882
|
+
if (this.running)
|
|
22883
|
+
return this;
|
|
22884
|
+
const { session, seg, accountId } = this.opts;
|
|
22885
|
+
this.running = true;
|
|
22886
|
+
session.on("mediaData", this.onMedia);
|
|
22887
|
+
session.on("commandData", this.onCommand);
|
|
22888
|
+
session.on("close", this.onClose);
|
|
22889
|
+
this.startSegment = seg.next();
|
|
22890
|
+
const sent2 = session.sendCommand(buildPortalPacket({
|
|
22891
|
+
commandId: PORTAL_CMD_SET_PAYLOAD,
|
|
22892
|
+
channel: PORTAL_STATION_CHANNEL,
|
|
22893
|
+
segment: this.startSegment,
|
|
22894
|
+
isResponse: LIVE_STREAM_ID,
|
|
22895
|
+
payload: {
|
|
22896
|
+
account_id: accountId,
|
|
22897
|
+
cmd: LIVE_START,
|
|
22898
|
+
payload: {
|
|
22899
|
+
ClientOS: "WEB",
|
|
22900
|
+
entrytype: 0,
|
|
22901
|
+
camera_type: 0,
|
|
22902
|
+
key: "",
|
|
22903
|
+
msg_id: 116,
|
|
22904
|
+
audio_chn: -1,
|
|
22905
|
+
streamtype: 2,
|
|
22906
|
+
stitch_mode: 1,
|
|
22907
|
+
chn_list: [{ index: 0, chn: this.opts.channel, sensor: 0, isUps: 0, isClicked: true }],
|
|
22908
|
+
pip_cord: { x1: 0, y1: 0, x2: 0, y2: 0 },
|
|
22909
|
+
station_video_type: 6,
|
|
22910
|
+
play_id: 1
|
|
22911
|
+
}
|
|
22912
|
+
}
|
|
22913
|
+
}));
|
|
22914
|
+
if (!sent2) {
|
|
22915
|
+
queueMicrotask(() => this.fail(new Error(`rtc live ${this.tag}: command channel not open`)));
|
|
22916
|
+
return this;
|
|
22917
|
+
}
|
|
22918
|
+
this.keepalive = setInterval(() => {
|
|
22919
|
+
if (!session.sendRaw(PORTAL_KEEPALIVE))
|
|
22920
|
+
this.opts.logger?.warn?.(`[rtc] ${this.tag} keepalive not sent`);
|
|
22921
|
+
}, PORTAL_KEEPALIVE_MS);
|
|
22922
|
+
this.keepalive.unref?.();
|
|
22923
|
+
this.opts.logger?.debug?.(`[rtc] ${this.tag} live start sent`);
|
|
22924
|
+
return this;
|
|
22925
|
+
}
|
|
22926
|
+
stop() {
|
|
22927
|
+
if (!this.running)
|
|
22928
|
+
return;
|
|
22929
|
+
this.teardown();
|
|
22930
|
+
const { session, seg, accountId } = this.opts;
|
|
22931
|
+
session.sendCommand(buildPortalPacket({
|
|
22932
|
+
commandId: PORTAL_CMD_SET_PAYLOAD,
|
|
22933
|
+
channel: PORTAL_STATION_CHANNEL,
|
|
22934
|
+
segment: seg.next(),
|
|
22935
|
+
isResponse: LIVE_STREAM_ID,
|
|
22936
|
+
payload: { account_id: accountId, cmd: LIVE_STOP, payload: {} }
|
|
22937
|
+
}));
|
|
22938
|
+
this.emit("stop");
|
|
22939
|
+
}
|
|
22940
|
+
teardown() {
|
|
22941
|
+
this.running = false;
|
|
22942
|
+
if (this.keepalive)
|
|
22943
|
+
clearInterval(this.keepalive);
|
|
22944
|
+
this.keepalive = void 0;
|
|
22945
|
+
const { session } = this.opts;
|
|
22946
|
+
session.off("mediaData", this.onMedia);
|
|
22947
|
+
session.off("commandData", this.onCommand);
|
|
22948
|
+
session.off("close", this.onClose);
|
|
22949
|
+
}
|
|
22950
|
+
fail(err) {
|
|
22951
|
+
if (!this.running)
|
|
22952
|
+
return;
|
|
22953
|
+
this.teardown();
|
|
22954
|
+
this.emit("error", err);
|
|
22955
|
+
}
|
|
22956
|
+
/** The start's ACK: a non-zero result code ends the pull. */
|
|
22957
|
+
onCommandFrame(frame, linkType) {
|
|
22958
|
+
const p = parsePortalPacket(frame, linkType);
|
|
22959
|
+
if (!p?.isResponse || p.segment !== this.startSegment || p.commandId !== PORTAL_CMD_SET_PAYLOAD)
|
|
22960
|
+
return;
|
|
22961
|
+
if (p.errCode !== 0)
|
|
22962
|
+
this.fail(new Error(`rtc live ${this.tag}: start refused (err ${p.errCode})`));
|
|
22963
|
+
}
|
|
22964
|
+
onMediaFrame(frame) {
|
|
22965
|
+
const h = parsePortalHeader(frame);
|
|
22966
|
+
if (!h || h.commandId !== LIVE_MEDIA || h.channel !== LIVE_MEDIA_CHANNEL)
|
|
22967
|
+
return;
|
|
22968
|
+
if (h.paramLength > frame.length - PORTAL_HEADER_LENGTH)
|
|
22969
|
+
return;
|
|
22970
|
+
const media = parseMediaBody(frame.subarray(PORTAL_HEADER_LENGTH, PORTAL_HEADER_LENGTH + h.paramLength));
|
|
22971
|
+
if (!media)
|
|
22972
|
+
return;
|
|
22973
|
+
const { data, width, height } = media;
|
|
22974
|
+
if (width && height) {
|
|
22975
|
+
this.width = width;
|
|
22976
|
+
this.height = height;
|
|
22977
|
+
}
|
|
22978
|
+
const out = {
|
|
22979
|
+
codec: "h265",
|
|
22980
|
+
data,
|
|
22981
|
+
keyframe: hasIdr(data, "h265"),
|
|
22982
|
+
width: this.width,
|
|
22983
|
+
height: this.height
|
|
22984
|
+
};
|
|
22985
|
+
this.emit("video", out);
|
|
22986
|
+
}
|
|
22987
|
+
};
|
|
22988
|
+
|
|
22989
|
+
// dist/transport/rtc/session.js
|
|
22990
|
+
import { EventEmitter as EventEmitter10 } from "node:events";
|
|
22991
|
+
|
|
22992
|
+
// dist/transport/rtc/peer.js
|
|
22993
|
+
import { EventEmitter as EventEmitter8 } from "node:events";
|
|
22994
|
+
|
|
22995
|
+
// dist/transport/rtc/ptcs-framer.js
|
|
22996
|
+
var MAGIC3 = Buffer.from("PTCS", "ascii");
|
|
22997
|
+
var PTCS_HEADER_LENGTH = 28;
|
|
22998
|
+
var PTCS_DEFAULT_PAYLOAD_BYTES = 1e3;
|
|
22999
|
+
var FLAG_BASE = 16384;
|
|
23000
|
+
var FLAG_LAST = 1024;
|
|
23001
|
+
var LENGTH_MASK = 1023;
|
|
23002
|
+
var PtcsChannel = {
|
|
23003
|
+
COMMAND: 0,
|
|
23004
|
+
NOTIFY: 2,
|
|
23005
|
+
LIVE: 5
|
|
23006
|
+
};
|
|
23007
|
+
function linkTypeForChannel(channel) {
|
|
23008
|
+
if (channel === PtcsChannel.NOTIFY)
|
|
23009
|
+
return PortalLinkType.NOTIFY;
|
|
23010
|
+
if (channel === PtcsChannel.LIVE)
|
|
23011
|
+
return PortalLinkType.LIVE;
|
|
23012
|
+
return PortalLinkType.COMMAND;
|
|
23013
|
+
}
|
|
23014
|
+
var PTCS_STALE_MS = 15e3;
|
|
23015
|
+
function parsePtcsHeader(packet) {
|
|
23016
|
+
if (packet.length < PTCS_HEADER_LENGTH || packet.subarray(0, 4).compare(MAGIC3) !== 0)
|
|
23017
|
+
return void 0;
|
|
23018
|
+
const flags = packet.readUInt16LE(18);
|
|
23019
|
+
return {
|
|
23020
|
+
channel: packet[5],
|
|
23021
|
+
sequence: packet.readUInt16LE(6),
|
|
23022
|
+
frameId: packet.readUInt32LE(8),
|
|
23023
|
+
frameLength: packet.readUInt32LE(12),
|
|
23024
|
+
index: packet.readUInt16LE(16),
|
|
23025
|
+
last: (flags & FLAG_LAST) !== 0,
|
|
23026
|
+
payloadLength: flags & LENGTH_MASK
|
|
23027
|
+
};
|
|
23028
|
+
}
|
|
23029
|
+
function packetize(frame, opts) {
|
|
23030
|
+
const size = opts.payloadBytes ?? PTCS_DEFAULT_PAYLOAD_BYTES;
|
|
23031
|
+
if (size <= 0 || size > LENGTH_MASK)
|
|
23032
|
+
throw new RangeError(`PTCS payload size ${size} out of range`);
|
|
23033
|
+
const channel = opts.channel ?? PtcsChannel.COMMAND;
|
|
23034
|
+
const count2 = Math.max(1, Math.ceil(frame.length / size));
|
|
23035
|
+
const sequence = (opts.sequence ?? 0) & 65535;
|
|
23036
|
+
const packets = [];
|
|
23037
|
+
for (let i = 0; i < count2; i++) {
|
|
23038
|
+
const chunk = frame.subarray(i * size, Math.min(frame.length, (i + 1) * size));
|
|
23039
|
+
const last = i === count2 - 1;
|
|
23040
|
+
const packet = Buffer.alloc(PTCS_HEADER_LENGTH + size);
|
|
23041
|
+
MAGIC3.copy(packet, 0);
|
|
23042
|
+
packet[4] = 3;
|
|
23043
|
+
packet[5] = channel & 255;
|
|
23044
|
+
packet.writeUInt16LE(sequence, 6);
|
|
23045
|
+
packet.writeUInt32LE(opts.frameId >>> 0, 8);
|
|
23046
|
+
packet.writeUInt32LE(frame.length >>> 0, 12);
|
|
23047
|
+
packet.writeUInt16LE(i, 16);
|
|
23048
|
+
packet.writeUInt16LE(FLAG_BASE | (last ? FLAG_LAST : 0) | chunk.length, 18);
|
|
23049
|
+
chunk.copy(packet, PTCS_HEADER_LENGTH);
|
|
23050
|
+
packets.push(packet);
|
|
23051
|
+
}
|
|
23052
|
+
return packets;
|
|
23053
|
+
}
|
|
23054
|
+
var PtcsReassembler = class {
|
|
23055
|
+
onFrame;
|
|
23056
|
+
now;
|
|
23057
|
+
partials = /* @__PURE__ */ new Map();
|
|
23058
|
+
constructor(onFrame, now2 = Date.now) {
|
|
23059
|
+
this.onFrame = onFrame;
|
|
23060
|
+
this.now = now2;
|
|
23061
|
+
}
|
|
23062
|
+
push(packet) {
|
|
23063
|
+
const h = parsePtcsHeader(packet);
|
|
23064
|
+
if (!h)
|
|
23065
|
+
return false;
|
|
23066
|
+
const body = packet.subarray(PTCS_HEADER_LENGTH, PTCS_HEADER_LENGTH + h.payloadLength);
|
|
23067
|
+
if (body.length !== h.payloadLength)
|
|
23068
|
+
return false;
|
|
23069
|
+
const key = `${h.channel}:${h.frameId}`;
|
|
23070
|
+
const now2 = this.now();
|
|
23071
|
+
let p = this.partials.get(key);
|
|
23072
|
+
if (!p) {
|
|
23073
|
+
p = { channel: h.channel, frameLength: h.frameLength, chunks: /* @__PURE__ */ new Map(), touched: now2 };
|
|
23074
|
+
this.partials.set(key, p);
|
|
23075
|
+
}
|
|
23076
|
+
p.touched = now2;
|
|
23077
|
+
p.chunks.set(h.index, Buffer.from(body));
|
|
23078
|
+
if (h.last)
|
|
23079
|
+
p.lastIndex = h.index;
|
|
23080
|
+
if (p.lastIndex === void 0)
|
|
23081
|
+
return true;
|
|
23082
|
+
let total = 0;
|
|
23083
|
+
const parts = [];
|
|
23084
|
+
for (let i = 0; i <= p.lastIndex; i++) {
|
|
23085
|
+
const c = p.chunks.get(i);
|
|
23086
|
+
if (!c)
|
|
23087
|
+
return true;
|
|
23088
|
+
parts.push(c);
|
|
23089
|
+
total += c.length;
|
|
23090
|
+
}
|
|
23091
|
+
this.partials.delete(key);
|
|
23092
|
+
if (total !== p.frameLength)
|
|
23093
|
+
return false;
|
|
23094
|
+
this.onFrame(Buffer.concat(parts, total), p.channel);
|
|
23095
|
+
return true;
|
|
23096
|
+
}
|
|
23097
|
+
/** Drop frames that stopped arriving; call periodically. */
|
|
23098
|
+
expire() {
|
|
23099
|
+
const now2 = this.now();
|
|
23100
|
+
for (const [key, p] of this.partials) {
|
|
23101
|
+
if (now2 - p.touched > PTCS_STALE_MS)
|
|
23102
|
+
this.partials.delete(key);
|
|
23103
|
+
}
|
|
23104
|
+
}
|
|
23105
|
+
};
|
|
23106
|
+
function frameIdClock(now2 = Date.now) {
|
|
23107
|
+
let last = 0;
|
|
23108
|
+
return () => {
|
|
23109
|
+
let id = now2() >>> 0;
|
|
23110
|
+
if (id <= last)
|
|
23111
|
+
id = last + 1 >>> 0;
|
|
23112
|
+
last = id;
|
|
23113
|
+
return id;
|
|
23114
|
+
};
|
|
23115
|
+
}
|
|
23116
|
+
var PtcsFramer = class {
|
|
23117
|
+
onWire;
|
|
23118
|
+
onFrame;
|
|
23119
|
+
reassembler;
|
|
23120
|
+
sweep;
|
|
23121
|
+
ready = false;
|
|
23122
|
+
nextFrameId = frameIdClock();
|
|
23123
|
+
sequence = 0;
|
|
23124
|
+
/** Arm the two callbacks; `sendFrame` may be called from here on. */
|
|
23125
|
+
init(onWirePacket, onFrame) {
|
|
23126
|
+
this.onWire = onWirePacket;
|
|
23127
|
+
this.onFrame = onFrame;
|
|
23128
|
+
this.reassembler = new PtcsReassembler((frame, channel) => this.onFrame?.(frame, linkTypeForChannel(channel)));
|
|
23129
|
+
this.sweep = setInterval(() => this.reassembler?.expire(), 1e3);
|
|
23130
|
+
this.sweep.unref?.();
|
|
23131
|
+
this.ready = true;
|
|
23132
|
+
}
|
|
23133
|
+
isReady() {
|
|
23134
|
+
return this.ready;
|
|
23135
|
+
}
|
|
23136
|
+
sendFrame(portalPacket) {
|
|
23137
|
+
if (!this.ready)
|
|
23138
|
+
throw new Error("PTCS framer not initialised");
|
|
23139
|
+
const packets = packetize(portalPacket, { frameId: this.nextFrameId(), sequence: this.sequence });
|
|
23140
|
+
this.sequence = this.sequence + 1 & 65535;
|
|
23141
|
+
for (const packet of packets)
|
|
23142
|
+
this.onWire?.(packet);
|
|
23143
|
+
}
|
|
23144
|
+
recvPacket(wirePacket) {
|
|
23145
|
+
if (!this.ready)
|
|
23146
|
+
return;
|
|
23147
|
+
if (isPortalPacket(wirePacket)) {
|
|
23148
|
+
this.onFrame?.(wirePacket, PortalLinkType.COMMAND);
|
|
23149
|
+
return;
|
|
23150
|
+
}
|
|
23151
|
+
this.reassembler?.push(wirePacket);
|
|
23152
|
+
}
|
|
23153
|
+
destroy() {
|
|
23154
|
+
this.ready = false;
|
|
23155
|
+
if (this.sweep)
|
|
23156
|
+
clearInterval(this.sweep);
|
|
23157
|
+
this.sweep = void 0;
|
|
23158
|
+
this.reassembler = void 0;
|
|
23159
|
+
this.onWire = void 0;
|
|
23160
|
+
this.onFrame = void 0;
|
|
23161
|
+
}
|
|
23162
|
+
};
|
|
23163
|
+
|
|
23164
|
+
// dist/transport/rtc/scall-sdp.js
|
|
23165
|
+
var HUB_SDP_MID = "2";
|
|
23166
|
+
var ANKER_MAX_MESSAGE_SIZE = 262144;
|
|
23167
|
+
function scallJsonToSdp(json, now2 = Date.now) {
|
|
23168
|
+
let sdp = "";
|
|
23169
|
+
sdp += "v=0\r\n";
|
|
23170
|
+
sdp += `o=- ${Math.floor(now2())} 1 IN IP4 127.0.0.1\r
|
|
23171
|
+
`;
|
|
23172
|
+
sdp += "s=Anker Webrtc Stream\r\n";
|
|
23173
|
+
sdp += "t=0 0\r\n";
|
|
23174
|
+
sdp += `a=group:BUNDLE ${HUB_SDP_MID}\r
|
|
23175
|
+
`;
|
|
23176
|
+
sdp += "a=msid-semantic: WMS\r\n";
|
|
23177
|
+
sdp += "m=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n";
|
|
23178
|
+
sdp += "c=IN IP4 127.0.0.1\r\n";
|
|
23179
|
+
sdp += `a=mid:${HUB_SDP_MID}\r
|
|
23180
|
+
`;
|
|
23181
|
+
sdp += "a=ice-options:trickle\r\n";
|
|
23182
|
+
if (json.ice?.ufrag)
|
|
23183
|
+
sdp += `a=ice-ufrag:${json.ice.ufrag}\r
|
|
23184
|
+
`;
|
|
23185
|
+
if (json.ice?.pwd)
|
|
23186
|
+
sdp += `a=ice-pwd:${json.ice.pwd}\r
|
|
23187
|
+
`;
|
|
23188
|
+
if (json.ice?.fingerprint) {
|
|
23189
|
+
const fp = json.ice.fingerprint.replace(/(.{2})(?=.)/g, "$1:");
|
|
23190
|
+
sdp += `a=fingerprint:${json.ice.fingerprint_type ?? "sha-256"} ${fp}\r
|
|
23191
|
+
`;
|
|
23192
|
+
}
|
|
23193
|
+
sdp += `a=setup:${json.setup ?? "actpass"}\r
|
|
23194
|
+
`;
|
|
23195
|
+
sdp += "a=sctp-port:5000\r\n";
|
|
23196
|
+
sdp += `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}\r
|
|
23197
|
+
`;
|
|
23198
|
+
for (const c of json.candidate ?? [])
|
|
23199
|
+
sdp += `a=candidate:${c}\r
|
|
23200
|
+
`;
|
|
23201
|
+
return sdp;
|
|
23202
|
+
}
|
|
23203
|
+
function sdpToScallJson(sdp) {
|
|
23204
|
+
const json = { ice: { ufrag: "", pwd: "", fingerprint: "" } };
|
|
23205
|
+
const setup = sdp.match(/a=setup:([^\r\n]+)/)?.[1];
|
|
23206
|
+
if (setup && setup !== "actpass")
|
|
23207
|
+
json.setup = setup;
|
|
23208
|
+
const ufrag = sdp.match(/a=ice-ufrag:([^\r\n]+)/)?.[1];
|
|
23209
|
+
if (ufrag)
|
|
23210
|
+
json.ice.ufrag = ufrag;
|
|
23211
|
+
const pwd = sdp.match(/a=ice-pwd:([^\r\n]+)/)?.[1];
|
|
23212
|
+
if (pwd)
|
|
23213
|
+
json.ice.pwd = pwd;
|
|
23214
|
+
const fp = sdp.match(/a=fingerprint:([^\s]+)\s+([^\r\n]+)/);
|
|
23215
|
+
if (fp?.[2]) {
|
|
23216
|
+
json.ice.fingerprint_type = fp[1];
|
|
23217
|
+
json.ice.fingerprint = fp[2].replace(/:/g, "");
|
|
23218
|
+
}
|
|
23219
|
+
const candidates = [];
|
|
23220
|
+
for (const line of sdp.split(/\r\n|\r|\n/)) {
|
|
23221
|
+
const m = line.match(/^a=candidate:(.+)/);
|
|
23222
|
+
if (m?.[1])
|
|
23223
|
+
candidates.push(m[1]);
|
|
23224
|
+
}
|
|
23225
|
+
if (candidates.length)
|
|
23226
|
+
json.candidate = candidates;
|
|
23227
|
+
return json;
|
|
23228
|
+
}
|
|
23229
|
+
function iceCandidateType(candidate) {
|
|
23230
|
+
return candidate.match(/typ (\w+)/)?.[1] ?? "unknown";
|
|
23231
|
+
}
|
|
23232
|
+
function pinMaxMessageSize(sdp) {
|
|
23233
|
+
return sdp.replace(/a=max-message-size:\d+/g, `a=max-message-size:${ANKER_MAX_MESSAGE_SIZE}`);
|
|
23234
|
+
}
|
|
23235
|
+
function forceDtlsRole(sdp, role) {
|
|
23236
|
+
if (/a=setup:(active|passive|actpass)/.test(sdp))
|
|
23237
|
+
return sdp.replace(/a=setup:(active|passive|actpass)/g, `a=setup:${role}`);
|
|
23238
|
+
return sdp.replace(/(m=application[^\r\n]*\r?\n)/, `$1a=setup:${role}\r
|
|
23239
|
+
`);
|
|
23240
|
+
}
|
|
23241
|
+
function toWireCandidate(candidate) {
|
|
23242
|
+
return candidate.trim().replace(/^a=/, "");
|
|
23243
|
+
}
|
|
23244
|
+
|
|
23245
|
+
// dist/transport/rtc/peer.js
|
|
23246
|
+
var DATA_CHANNEL_LABELS = ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
|
|
23247
|
+
var COMMAND_CHANNEL = DATA_CHANNEL_LABELS[0];
|
|
23248
|
+
var DATA_CHANNEL_IDS = {
|
|
23249
|
+
WebrtcDataChannel: 0,
|
|
23250
|
+
audio: 2,
|
|
23251
|
+
idr: 4,
|
|
23252
|
+
video: 6,
|
|
23253
|
+
notify: 8,
|
|
23254
|
+
download: 10
|
|
23255
|
+
};
|
|
23256
|
+
function turnServers(turn) {
|
|
23257
|
+
const both = (hostname, port) => [
|
|
23258
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnUdp" },
|
|
23259
|
+
{ hostname, port, username: turn.turn_user, password: turn.turn_password, relayType: "TurnTcp" }
|
|
23260
|
+
];
|
|
23261
|
+
const servers = both(turn.turn_addr, turn.turn_port);
|
|
23262
|
+
if (turn.alt_turn_addr && turn.alt_turn_port)
|
|
23263
|
+
servers.push(...both(turn.alt_turn_addr, turn.alt_turn_port));
|
|
23264
|
+
return servers;
|
|
23265
|
+
}
|
|
23266
|
+
var UNBUILT_ARCHES = /* @__PURE__ */ new Set(["arm", "ia32"]);
|
|
23267
|
+
async function loadNativePeerFactory() {
|
|
23268
|
+
let ndc;
|
|
23269
|
+
try {
|
|
23270
|
+
ndc = await import("node-datachannel");
|
|
23271
|
+
} catch (err) {
|
|
23272
|
+
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'";
|
|
23273
|
+
throw new Error(message, { cause: err });
|
|
23274
|
+
}
|
|
23275
|
+
return (name, config) => new ndc.PeerConnection(name, config);
|
|
23276
|
+
}
|
|
23277
|
+
var ANSWER_TIMEOUT_MS = 15e3;
|
|
23278
|
+
var RtcPeer = class extends EventEmitter8 {
|
|
23279
|
+
opts;
|
|
23280
|
+
pc;
|
|
23281
|
+
channels = /* @__PURE__ */ new Map();
|
|
23282
|
+
framer;
|
|
23283
|
+
wireTally = /* @__PURE__ */ new Map();
|
|
23284
|
+
commandOpen = false;
|
|
23285
|
+
remoteSet = false;
|
|
23286
|
+
remoteMid = HUB_SDP_MID;
|
|
23287
|
+
handlingOffer = false;
|
|
23288
|
+
channelsCreated = false;
|
|
23289
|
+
gatheringDone = false;
|
|
23290
|
+
/** Set by the framer's wire callback when the native channel refused a packet mid-send. */
|
|
23291
|
+
wireSendFailed = false;
|
|
23292
|
+
pending = [];
|
|
23293
|
+
localAnswer;
|
|
23294
|
+
logger;
|
|
23295
|
+
constructor(opts = {}) {
|
|
23296
|
+
super();
|
|
23297
|
+
this.opts = opts;
|
|
23298
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23299
|
+
}
|
|
23300
|
+
/** Create the native peer on the relay the hub granted in `scall 100`. */
|
|
23301
|
+
async init(turn) {
|
|
23302
|
+
if (this.pc)
|
|
23303
|
+
return;
|
|
23304
|
+
const createPeer = this.opts.createPeer ?? await loadNativePeerFactory();
|
|
23305
|
+
const config = {
|
|
23306
|
+
iceServers: turnServers(turn),
|
|
23307
|
+
iceTransportPolicy: this.opts.iceTransportPolicy ?? "relay",
|
|
23308
|
+
maxMessageSize: ANKER_MAX_MESSAGE_SIZE,
|
|
23309
|
+
enableIceTcp: true
|
|
23310
|
+
};
|
|
23311
|
+
const pc = createPeer("eufy-sdk", config);
|
|
23312
|
+
this.pc = pc;
|
|
23313
|
+
pc.onLocalDescription((sdp, type) => {
|
|
23314
|
+
if (String(type).toLowerCase() === "answer" && this.localAnswer) {
|
|
23315
|
+
clearTimeout(this.localAnswer.timer);
|
|
23316
|
+
this.localAnswer.resolve(sdp);
|
|
23317
|
+
this.localAnswer = void 0;
|
|
23318
|
+
}
|
|
23319
|
+
});
|
|
23320
|
+
pc.onLocalCandidate((candidate) => {
|
|
23321
|
+
if (!candidate)
|
|
23322
|
+
return;
|
|
23323
|
+
if (!this.acceptsCandidate(candidate))
|
|
23324
|
+
return;
|
|
23325
|
+
this.emit("iceCandidate", candidate);
|
|
23326
|
+
});
|
|
23327
|
+
pc.onGatheringStateChange((state) => {
|
|
23328
|
+
if (state === "complete" && !this.gatheringDone) {
|
|
23329
|
+
this.gatheringDone = true;
|
|
23330
|
+
this.emit("iceGatheringComplete");
|
|
23331
|
+
}
|
|
23332
|
+
});
|
|
23333
|
+
pc.onStateChange((state) => {
|
|
23334
|
+
this.logger.debug(`[rtc] peer state ${state}`);
|
|
23335
|
+
this.emit("connectionState", state);
|
|
23336
|
+
});
|
|
23337
|
+
pc.onDataChannel((dc) => this.wireChannel(dc.getLabel(), dc));
|
|
23338
|
+
}
|
|
23339
|
+
/**
|
|
23340
|
+
* The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
|
|
23341
|
+
*
|
|
23342
|
+
* The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
|
|
23343
|
+
* `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
|
|
23344
|
+
* only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
|
|
23345
|
+
* a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
|
|
23346
|
+
* The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
|
|
23347
|
+
* is taken as the local description: one read in `have-local-offer` is an offer.
|
|
23348
|
+
*/
|
|
23349
|
+
async handleRemoteOffer(offerSdp) {
|
|
23350
|
+
const pc = this.pc;
|
|
23351
|
+
if (!pc)
|
|
23352
|
+
throw new Error("RTC peer not initialised");
|
|
23353
|
+
if (this.handlingOffer)
|
|
23354
|
+
throw new Error("RTC peer already handling an offer");
|
|
23355
|
+
this.handlingOffer = true;
|
|
23356
|
+
try {
|
|
23357
|
+
const offer = forceDtlsRole(offerSdp, "passive");
|
|
23358
|
+
const application = offer.split(/(?=^m=)/m).find((section) => section.startsWith("m=application "));
|
|
23359
|
+
this.remoteMid = application?.match(/^a=mid:([^\r\n]+)/m)?.[1] ?? HUB_SDP_MID;
|
|
23360
|
+
const answerWait = new Promise((resolve, reject) => {
|
|
23361
|
+
const timer = setTimeout(() => {
|
|
23362
|
+
this.localAnswer = void 0;
|
|
23363
|
+
reject(new Error("timed out waiting for the local SDP answer"));
|
|
23364
|
+
}, ANSWER_TIMEOUT_MS);
|
|
23365
|
+
this.localAnswer = { resolve, reject, timer };
|
|
23366
|
+
});
|
|
23367
|
+
pc.setRemoteDescription(offer, "offer");
|
|
23368
|
+
this.remoteSet = true;
|
|
23369
|
+
this.createChannels();
|
|
23370
|
+
const local = pc.localDescription();
|
|
23371
|
+
let answer = String(local?.type ?? "").toLowerCase() === "answer" ? local?.sdp ?? "" : "";
|
|
23372
|
+
if (!answer)
|
|
23373
|
+
answer = await answerWait;
|
|
23374
|
+
else if (this.localAnswer) {
|
|
23375
|
+
clearTimeout(this.localAnswer.timer);
|
|
23376
|
+
this.localAnswer = void 0;
|
|
23377
|
+
}
|
|
23378
|
+
this.flushPending();
|
|
23379
|
+
return pinMaxMessageSize(answer);
|
|
23380
|
+
} finally {
|
|
23381
|
+
this.handlingOffer = false;
|
|
23382
|
+
}
|
|
23383
|
+
}
|
|
23384
|
+
addRemoteCandidate(candidate) {
|
|
23385
|
+
if (!this.pc)
|
|
23386
|
+
return;
|
|
23387
|
+
if (!this.acceptsCandidate(candidate))
|
|
23388
|
+
return;
|
|
23389
|
+
if (!this.remoteSet || this.handlingOffer) {
|
|
23390
|
+
this.pending.push(candidate);
|
|
23391
|
+
return;
|
|
23392
|
+
}
|
|
23393
|
+
this.addNow(candidate);
|
|
23394
|
+
}
|
|
23395
|
+
/**
|
|
23396
|
+
* Send bytes on the command channel as they are, without PTCS framing; false when it isn't open. The
|
|
23397
|
+
* portal's data-channel keepalive rides the wire unframed.
|
|
23398
|
+
*/
|
|
23399
|
+
sendRaw(bytes2) {
|
|
23400
|
+
const dc = this.channels.get(COMMAND_CHANNEL);
|
|
23401
|
+
if (!dc?.isOpen())
|
|
23402
|
+
return false;
|
|
23403
|
+
return dc.sendMessageBinary(bytes2);
|
|
23404
|
+
}
|
|
23405
|
+
/**
|
|
23406
|
+
* Send one portal packet on the command channel, through the framer. False when the channel is not
|
|
23407
|
+
* usable or the native send refused a wire packet, so a dropped frame does not read as success.
|
|
23408
|
+
*/
|
|
23409
|
+
sendCommand(portalPacket) {
|
|
23410
|
+
const dc = this.channels.get(COMMAND_CHANNEL);
|
|
23411
|
+
if (!dc?.isOpen() || !this.commandOpen || !this.framer?.isReady())
|
|
23412
|
+
return false;
|
|
23413
|
+
this.wireSendFailed = false;
|
|
23414
|
+
try {
|
|
23415
|
+
this.framer.sendFrame(portalPacket);
|
|
23416
|
+
} catch (e) {
|
|
23417
|
+
this.logger.warn(`[rtc] command send failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23418
|
+
return false;
|
|
23419
|
+
}
|
|
23420
|
+
return !this.wireSendFailed;
|
|
23421
|
+
}
|
|
23422
|
+
/** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
|
|
23423
|
+
close() {
|
|
23424
|
+
this.framer?.destroy();
|
|
23425
|
+
this.framer = void 0;
|
|
23426
|
+
if (this.localAnswer) {
|
|
23427
|
+
clearTimeout(this.localAnswer.timer);
|
|
23428
|
+
const pending = this.localAnswer;
|
|
23429
|
+
this.localAnswer = void 0;
|
|
23430
|
+
pending.reject(new Error("RTC peer closed while waiting for the local SDP answer"));
|
|
23431
|
+
}
|
|
23432
|
+
this.commandOpen = false;
|
|
23433
|
+
this.gatheringDone = false;
|
|
23434
|
+
this.pc?.close();
|
|
23435
|
+
this.pc = void 0;
|
|
23436
|
+
this.channels.clear();
|
|
23437
|
+
this.channelsCreated = false;
|
|
23438
|
+
this.remoteSet = false;
|
|
23439
|
+
this.pending.length = 0;
|
|
23440
|
+
}
|
|
23441
|
+
acceptsCandidate(candidate) {
|
|
23442
|
+
return this.opts.iceTransportPolicy === "all" || iceCandidateType(candidate) === "relay";
|
|
23443
|
+
}
|
|
23444
|
+
createChannels() {
|
|
23445
|
+
if (!this.pc || this.channelsCreated)
|
|
23446
|
+
return;
|
|
23447
|
+
this.channelsCreated = true;
|
|
23448
|
+
for (const label of DATA_CHANNEL_LABELS) {
|
|
23449
|
+
const dc = this.pc.createDataChannel(label, { id: DATA_CHANNEL_IDS[label], unordered: false });
|
|
23450
|
+
this.wireChannel(label, dc);
|
|
23451
|
+
}
|
|
23452
|
+
}
|
|
23453
|
+
/**
|
|
23454
|
+
* Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
|
|
23455
|
+
* message before that has nothing to reassemble into and is dropped. The hub's echo of the portal
|
|
23456
|
+
* keepalive on the command channel is unframed and surfaces as `keepaliveEcho` instead.
|
|
23457
|
+
*/
|
|
23458
|
+
wireChannel(label, dc) {
|
|
23459
|
+
if (this.channels.has(label))
|
|
23460
|
+
return;
|
|
23461
|
+
this.channels.set(label, dc);
|
|
23462
|
+
dc.onOpen(() => {
|
|
23463
|
+
this.logger.debug(`[rtc] data channel open ${label}`);
|
|
23464
|
+
if (label !== COMMAND_CHANNEL)
|
|
23465
|
+
return;
|
|
23466
|
+
if (!this.framer) {
|
|
23467
|
+
const framer = new PtcsFramer();
|
|
23468
|
+
framer.init((packet) => {
|
|
23469
|
+
if (!dc.isOpen() || !dc.sendMessageBinary(packet))
|
|
23470
|
+
this.wireSendFailed = true;
|
|
23471
|
+
}, (frame, linkType) => this.emit("data", frame, linkType));
|
|
23472
|
+
this.framer = framer;
|
|
23473
|
+
}
|
|
23474
|
+
this.commandOpen = true;
|
|
23475
|
+
this.emit("commandChannelOpen");
|
|
23476
|
+
});
|
|
23477
|
+
dc.onClosed(() => {
|
|
23478
|
+
this.logger.debug(`[rtc] data channel closed ${label}`);
|
|
23479
|
+
if (label !== COMMAND_CHANNEL)
|
|
23480
|
+
return;
|
|
23481
|
+
const wasOpen = this.commandOpen;
|
|
23482
|
+
this.commandOpen = false;
|
|
23483
|
+
if (wasOpen)
|
|
23484
|
+
this.emit("commandChannelClosed");
|
|
23485
|
+
});
|
|
23486
|
+
dc.onError((err) => this.emit("error", new Error(`RTC data channel ${label}: ${err}`)));
|
|
23487
|
+
dc.onMessage((msg) => {
|
|
23488
|
+
const buf = typeof msg === "string" ? Buffer.from(msg) : Buffer.isBuffer(msg) ? msg : Buffer.from(msg);
|
|
23489
|
+
const n = (this.wireTally.get(label) ?? 0) + 1;
|
|
23490
|
+
this.wireTally.set(label, n);
|
|
23491
|
+
if (n <= 3 || n % 100 === 0) {
|
|
23492
|
+
this.logger.debug(`[rtc] wire ${label} #${n} ${buf.length}B ${buf.subarray(0, 8).toString("hex")}`);
|
|
23493
|
+
}
|
|
23494
|
+
if (label === COMMAND_CHANNEL && buf.equals(PORTAL_KEEPALIVE)) {
|
|
23495
|
+
this.emit("keepaliveEcho");
|
|
23496
|
+
return;
|
|
23497
|
+
}
|
|
23498
|
+
this.framer?.recvPacket(buf);
|
|
23499
|
+
});
|
|
23500
|
+
}
|
|
23501
|
+
addNow(candidate) {
|
|
23502
|
+
try {
|
|
23503
|
+
this.pc?.addRemoteCandidate(candidate, this.remoteMid);
|
|
23504
|
+
} catch (e) {
|
|
23505
|
+
this.logger.warn(`[rtc] addRemoteCandidate failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
23506
|
+
}
|
|
23507
|
+
}
|
|
23508
|
+
flushPending() {
|
|
23509
|
+
const queued = this.pending.splice(0);
|
|
23510
|
+
for (const c of queued)
|
|
23511
|
+
this.addNow(c);
|
|
23512
|
+
}
|
|
23513
|
+
};
|
|
23514
|
+
|
|
23515
|
+
// dist/transport/rtc/signaling.js
|
|
23516
|
+
import { EventEmitter as EventEmitter9 } from "node:events";
|
|
23517
|
+
import { createHmac as createHmac3, randomUUID as randomUUID2 } from "node:crypto";
|
|
23518
|
+
var RTC_WS_PATH = "/v1/rtc/ws/join?reqtype=nvr";
|
|
23519
|
+
var RTC_SIGN_PATH = "/v1/smart/nvr/ws/sign";
|
|
23520
|
+
var PORTAL_ORIGIN = "https://security.eufy.com";
|
|
23521
|
+
var SIGNALING_KEEPALIVE_MS = 25e3;
|
|
23522
|
+
var SIGNALING_CONNECT_TIMEOUT_MS = 15e3;
|
|
23523
|
+
var SOURCE = "WEB";
|
|
23524
|
+
function sessionAccount(channelId, adminUserId, ts, authToken) {
|
|
23525
|
+
return createHmac3("sha256", authToken).update(`${channelId}${adminUserId}${ts}`).digest("hex");
|
|
23526
|
+
}
|
|
23527
|
+
var WS_OPEN = 1;
|
|
23528
|
+
var defaultSocket = (url, init) => new WebSocket(url, {
|
|
23529
|
+
protocols: init.protocols,
|
|
23530
|
+
headers: { Origin: init.origin }
|
|
23531
|
+
});
|
|
23532
|
+
var RtcSignalingClient = class extends EventEmitter9 {
|
|
23533
|
+
opts;
|
|
23534
|
+
ws;
|
|
23535
|
+
sign;
|
|
23536
|
+
keepalive;
|
|
23537
|
+
wsUrl;
|
|
23538
|
+
signUrl;
|
|
23539
|
+
wsRegion;
|
|
23540
|
+
fetchImpl;
|
|
23541
|
+
createSocket;
|
|
23542
|
+
logger;
|
|
23543
|
+
now;
|
|
23544
|
+
makeMsgId;
|
|
23545
|
+
constructor(opts) {
|
|
23546
|
+
super();
|
|
23547
|
+
this.opts = opts;
|
|
23548
|
+
const prefix = (opts.shard.split("-")[0] || "us").toLowerCase();
|
|
23549
|
+
const smartHost = prefix === "us" ? "security-smart.eufylife.com" : `security-smart-${prefix}.eufylife.com`;
|
|
23550
|
+
this.wsUrl = `wss://${smartHost}${RTC_WS_PATH}`;
|
|
23551
|
+
this.signUrl = `https://${smartHost}${RTC_SIGN_PATH}`;
|
|
23552
|
+
this.wsRegion = prefix.toUpperCase();
|
|
23553
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
23554
|
+
this.createSocket = opts.createSocket ?? defaultSocket;
|
|
23555
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23556
|
+
this.now = opts.now ?? Date.now;
|
|
23557
|
+
this.makeMsgId = opts.makeMsgId ?? (() => randomUUID2().replace(/-/g, ""));
|
|
23558
|
+
}
|
|
23559
|
+
get isOpen() {
|
|
23560
|
+
return this.ws?.readyState === WS_OPEN;
|
|
23561
|
+
}
|
|
23562
|
+
/** Step 1: the sign blob the socket and every auth message carry. A non-JSON body is reported by status. */
|
|
23563
|
+
async fetchSign() {
|
|
23564
|
+
const res = await this.fetchImpl(this.signUrl, {
|
|
23565
|
+
headers: {
|
|
23566
|
+
"Web-Country": this.opts.country.toUpperCase(),
|
|
23567
|
+
"X-Auth-Token": this.opts.authToken,
|
|
23568
|
+
"App-Name": "eufy_mega",
|
|
23569
|
+
"Model-Type": "WEB",
|
|
23570
|
+
GToken: this.opts.gtoken,
|
|
23571
|
+
Origin: PORTAL_ORIGIN
|
|
23572
|
+
}
|
|
23573
|
+
});
|
|
23574
|
+
const body = await res.json().catch(() => ({})) ?? {};
|
|
23575
|
+
if (!res.ok || body.code !== 0 || !body.data) {
|
|
23576
|
+
throw new Error(`RTC sign for ${this.opts.stationSn} refused: HTTP ${res.status} code ${body.code ?? "?"} ${withoutTokenEcho(body.msg ?? "")}`.trim());
|
|
23577
|
+
}
|
|
23578
|
+
this.sign = body.data;
|
|
23579
|
+
return body.data;
|
|
23580
|
+
}
|
|
23581
|
+
/**
|
|
23582
|
+
* Step 2: open the socket and send the first auth; resolves on `open`. A socket that closes or errors
|
|
23583
|
+
* before it ever opened rejects, so the call always settles.
|
|
23584
|
+
*/
|
|
23585
|
+
async connect() {
|
|
23586
|
+
if (this.ws)
|
|
23587
|
+
return;
|
|
23588
|
+
const sign = this.sign ?? await this.fetchSign();
|
|
23589
|
+
const auth = {
|
|
23590
|
+
region: this.wsRegion,
|
|
23591
|
+
type: "NVR",
|
|
23592
|
+
sn: this.opts.stationSn,
|
|
23593
|
+
token: this.opts.authToken,
|
|
23594
|
+
gtoken: this.opts.gtoken,
|
|
23595
|
+
sign,
|
|
23596
|
+
appName: "eufy_mega",
|
|
23597
|
+
modelType: "WEB"
|
|
23598
|
+
};
|
|
23599
|
+
const protocols = ["v1", Buffer.from(JSON.stringify(auth)).toString("base64url")];
|
|
23600
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling connect ${this.wsUrl}`);
|
|
23601
|
+
await new Promise((resolve, reject) => {
|
|
23602
|
+
const timer = setTimeout(() => {
|
|
23603
|
+
reject(new Error(`RTC signalling connect timeout after ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
|
|
23604
|
+
this.close();
|
|
23605
|
+
}, SIGNALING_CONNECT_TIMEOUT_MS);
|
|
23606
|
+
const ws = this.createSocket(this.wsUrl, { protocols, origin: PORTAL_ORIGIN });
|
|
23607
|
+
this.ws = ws;
|
|
23608
|
+
let opened = false;
|
|
23609
|
+
ws.addEventListener("open", () => {
|
|
23610
|
+
opened = true;
|
|
23611
|
+
clearTimeout(timer);
|
|
23612
|
+
this.sendAuth(sign);
|
|
23613
|
+
this.startKeepalive();
|
|
23614
|
+
this.emit("open");
|
|
23615
|
+
resolve();
|
|
23616
|
+
});
|
|
23617
|
+
ws.addEventListener("message", (ev) => {
|
|
23618
|
+
void this.handleWireMessage(ev.data);
|
|
23619
|
+
});
|
|
23620
|
+
ws.addEventListener("close", (ev) => {
|
|
23621
|
+
clearTimeout(timer);
|
|
23622
|
+
this.stopKeepalive();
|
|
23623
|
+
this.ws = void 0;
|
|
23624
|
+
const code = ev.code ?? 1006;
|
|
23625
|
+
const reason = ev.reason ?? "";
|
|
23626
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} signalling closed ${code} ${reason}`);
|
|
23627
|
+
if (!opened)
|
|
23628
|
+
reject(new Error(`RTC signalling closed before open: ${code} ${reason}`.trim()));
|
|
23629
|
+
this.emit("close", code, reason);
|
|
23630
|
+
});
|
|
23631
|
+
ws.addEventListener("error", (ev) => {
|
|
23632
|
+
clearTimeout(timer);
|
|
23633
|
+
const detail = ev ?? {};
|
|
23634
|
+
const cause = detail.error?.message ?? detail.error?.code ?? detail.message ?? "";
|
|
23635
|
+
reject(new Error(`RTC signalling socket error${cause ? ": " + cause : ""}`));
|
|
23636
|
+
});
|
|
23637
|
+
});
|
|
23638
|
+
}
|
|
23639
|
+
/** `action 1`, also what keeps the socket alive when re-sent. */
|
|
23640
|
+
sendAuth(sign) {
|
|
23641
|
+
const s = sign ?? this.sign;
|
|
23642
|
+
if (!s)
|
|
23643
|
+
throw new Error("RTC signalling: no sign to authenticate with");
|
|
23644
|
+
this.sendEnvelope("0", {
|
|
23645
|
+
code: 200,
|
|
23646
|
+
action: 1,
|
|
23647
|
+
data: s,
|
|
23648
|
+
sn: this.opts.stationSn,
|
|
23649
|
+
source: SOURCE,
|
|
23650
|
+
ts: Math.floor(this.now() / 1e3)
|
|
23651
|
+
});
|
|
23652
|
+
}
|
|
23653
|
+
/**
|
|
23654
|
+
* `action 3`, a session message on channel 0, the hub's own session; `scall` opens the negotiation.
|
|
23655
|
+
* `subSn` is empty for the hub's session.
|
|
23656
|
+
*/
|
|
23657
|
+
sendSession(dataType, payload = {}) {
|
|
23658
|
+
const channelId = 0;
|
|
23659
|
+
const ts = Math.floor(this.now() / 1e3);
|
|
23660
|
+
const inner = {
|
|
23661
|
+
code: 200,
|
|
23662
|
+
action: 3,
|
|
23663
|
+
sessionId: this.sign,
|
|
23664
|
+
sn: this.opts.stationSn,
|
|
23665
|
+
subSn: "",
|
|
23666
|
+
channelId,
|
|
23667
|
+
isResponse: 0,
|
|
23668
|
+
dataType,
|
|
23669
|
+
source: SOURCE,
|
|
23670
|
+
ts,
|
|
23671
|
+
data: JSON.stringify({
|
|
23672
|
+
timestamp: ts,
|
|
23673
|
+
account: sessionAccount(channelId, this.opts.adminUserId, ts, this.opts.authToken),
|
|
23674
|
+
...payload
|
|
23675
|
+
})
|
|
23676
|
+
};
|
|
23677
|
+
this.sendEnvelope(`${this.opts.authToken}_${this.makeMsgId()}`, inner);
|
|
23678
|
+
}
|
|
23679
|
+
sendCall() {
|
|
23680
|
+
this.sendSession(this.opts.signalingMode ?? "scall");
|
|
23681
|
+
}
|
|
23682
|
+
sendAck() {
|
|
23683
|
+
this.sendSession("ack");
|
|
23684
|
+
}
|
|
23685
|
+
/** The SDP answer, encoded for the negotiated call format, in an `info`. */
|
|
23686
|
+
sendInfoSdp(sdp) {
|
|
23687
|
+
this.sendSession("info", { sdp });
|
|
23688
|
+
}
|
|
23689
|
+
/** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
|
|
23690
|
+
sendInfoCandidate(candidate) {
|
|
23691
|
+
this.sendSession("info", { candidate });
|
|
23692
|
+
}
|
|
23693
|
+
sendHangup() {
|
|
23694
|
+
this.sendSession("hangup");
|
|
23695
|
+
}
|
|
23696
|
+
close() {
|
|
23697
|
+
this.stopKeepalive();
|
|
23698
|
+
const ws = this.ws;
|
|
23699
|
+
this.ws = void 0;
|
|
23700
|
+
try {
|
|
23701
|
+
ws?.close();
|
|
23702
|
+
} catch {
|
|
23703
|
+
}
|
|
23704
|
+
}
|
|
23705
|
+
startKeepalive() {
|
|
23706
|
+
this.stopKeepalive();
|
|
23707
|
+
this.keepalive = setInterval(() => {
|
|
23708
|
+
if (!this.isOpen)
|
|
23709
|
+
return;
|
|
23710
|
+
try {
|
|
23711
|
+
this.sendAuth();
|
|
23712
|
+
} catch (e) {
|
|
23713
|
+
this.logger.warn(`[rtc] ${this.opts.stationSn} signalling keepalive failed`, e);
|
|
23714
|
+
}
|
|
23715
|
+
}, SIGNALING_KEEPALIVE_MS);
|
|
23716
|
+
this.keepalive.unref?.();
|
|
23717
|
+
}
|
|
23718
|
+
stopKeepalive() {
|
|
23719
|
+
if (this.keepalive)
|
|
23720
|
+
clearInterval(this.keepalive);
|
|
23721
|
+
this.keepalive = void 0;
|
|
23722
|
+
}
|
|
23723
|
+
sendEnvelope(msgid, inner) {
|
|
23724
|
+
if (!this.isOpen || !this.ws)
|
|
23725
|
+
throw new Error("RTC signalling not connected");
|
|
23726
|
+
const envelope = { msgid, data: JSON.stringify(inner) };
|
|
23727
|
+
this.ws.send(JSON.stringify(envelope));
|
|
23728
|
+
}
|
|
23729
|
+
async handleWireMessage(raw) {
|
|
23730
|
+
let text2;
|
|
23731
|
+
if (typeof raw === "string")
|
|
23732
|
+
text2 = raw;
|
|
23733
|
+
else if (Buffer.isBuffer(raw))
|
|
23734
|
+
text2 = raw.toString("utf8");
|
|
23735
|
+
else if (raw instanceof ArrayBuffer)
|
|
23736
|
+
text2 = Buffer.from(raw).toString("utf8");
|
|
23737
|
+
else if (typeof raw?.text === "function")
|
|
23738
|
+
text2 = await raw.text();
|
|
23739
|
+
else
|
|
23740
|
+
return;
|
|
23741
|
+
let envelope;
|
|
23742
|
+
let inner;
|
|
23743
|
+
try {
|
|
23744
|
+
envelope = JSON.parse(text2);
|
|
23745
|
+
if (typeof envelope?.data !== "string")
|
|
23746
|
+
return;
|
|
23747
|
+
inner = JSON.parse(envelope.data);
|
|
23748
|
+
} catch {
|
|
23749
|
+
return;
|
|
23750
|
+
}
|
|
23751
|
+
this.emit("message", inner, envelope);
|
|
23752
|
+
}
|
|
23753
|
+
};
|
|
23754
|
+
|
|
23755
|
+
// dist/transport/rtc/session.js
|
|
23756
|
+
var AUTH_TIMEOUT_MS = 15e3;
|
|
23757
|
+
var RtcSession = class extends EventEmitter10 {
|
|
23758
|
+
opts;
|
|
23759
|
+
signaling;
|
|
23760
|
+
peer;
|
|
23761
|
+
logger;
|
|
23762
|
+
turn;
|
|
23763
|
+
authOk = false;
|
|
23764
|
+
authWaiter;
|
|
23765
|
+
connected = false;
|
|
23766
|
+
closed = false;
|
|
23767
|
+
closeAnnounced = false;
|
|
23768
|
+
sdpHandled = false;
|
|
23769
|
+
chain = Promise.resolve();
|
|
23770
|
+
constructor(opts) {
|
|
23771
|
+
super();
|
|
23772
|
+
this.opts = opts;
|
|
23773
|
+
this.logger = opts.logger ?? noopLogger;
|
|
23774
|
+
this.signaling = (opts.createSignaling ?? ((o) => new RtcSignalingClient(o)))(opts);
|
|
23775
|
+
this.peer = (opts.createPeer ?? ((o) => new RtcPeer(o)))({ logger: this.logger, ...opts.peer });
|
|
23776
|
+
this.signaling.on("message", (inner) => {
|
|
23777
|
+
this.chain = this.chain.then(() => this.onSignaling(inner)).catch((e) => {
|
|
23778
|
+
this.emit("error", e instanceof Error ? e : new Error(String(e)));
|
|
23779
|
+
});
|
|
23780
|
+
});
|
|
23781
|
+
this.signaling.on("close", () => {
|
|
23782
|
+
this.connected = false;
|
|
23783
|
+
this.announceClose();
|
|
23784
|
+
});
|
|
23785
|
+
this.signaling.on("error", (e) => this.emit("error", e));
|
|
23786
|
+
this.peer.on("iceCandidate", (c) => this.trickle(toWireCandidate(c)));
|
|
23787
|
+
this.peer.on("iceGatheringComplete", () => this.trickle(""));
|
|
23788
|
+
this.peer.on("commandChannelOpen", () => {
|
|
23789
|
+
if (this.connected)
|
|
23790
|
+
return;
|
|
23791
|
+
this.connected = true;
|
|
23792
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel open`);
|
|
23793
|
+
this.emit("connected");
|
|
23794
|
+
});
|
|
23795
|
+
this.peer.on("commandChannelClosed", () => {
|
|
23796
|
+
if (!this.connected)
|
|
23797
|
+
return;
|
|
23798
|
+
this.connected = false;
|
|
23799
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} command channel closed`);
|
|
23800
|
+
this.announceClose();
|
|
23801
|
+
});
|
|
23802
|
+
this.peer.on("connectionState", (state) => {
|
|
23803
|
+
if ((state === "failed" || state === "closed") && this.connected) {
|
|
23804
|
+
this.connected = false;
|
|
23805
|
+
this.announceClose();
|
|
23806
|
+
}
|
|
23807
|
+
});
|
|
23808
|
+
this.peer.on("error", (e) => this.emit("error", e));
|
|
23809
|
+
this.peer.on("keepaliveEcho", () => this.emit("keepaliveEcho"));
|
|
23810
|
+
this.peer.on("data", (frame, linkType) => {
|
|
23811
|
+
if (linkType === PortalLinkType.LIVE)
|
|
23812
|
+
this.emit("mediaData", frame);
|
|
23813
|
+
else
|
|
23814
|
+
this.emit("commandData", frame, linkType);
|
|
23815
|
+
});
|
|
23816
|
+
}
|
|
23817
|
+
get isConnected() {
|
|
23818
|
+
return this.connected;
|
|
23819
|
+
}
|
|
23820
|
+
/**
|
|
23821
|
+
* Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
|
|
23822
|
+
* closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
|
|
23823
|
+
* placing the call.
|
|
23824
|
+
*/
|
|
23825
|
+
async connect() {
|
|
23826
|
+
await this.signaling.fetchSign();
|
|
23827
|
+
if (this.closed)
|
|
23828
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23829
|
+
await this.signaling.connect();
|
|
23830
|
+
await this.waitForAuth();
|
|
23831
|
+
if (this.closed)
|
|
23832
|
+
throw new Error("RTC session closed before the call was placed");
|
|
23833
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} authenticated \u2014 ${this.opts.signalingMode ?? "scall"}`);
|
|
23834
|
+
this.signaling.sendCall();
|
|
23835
|
+
}
|
|
23836
|
+
/** Send one portal packet; false when the command channel isn't open. */
|
|
23837
|
+
sendCommand(portalPacket) {
|
|
23838
|
+
return this.peer.sendCommand(portalPacket);
|
|
23839
|
+
}
|
|
23840
|
+
/** Send unframed bytes on the command channel; false when it isn't open. */
|
|
23841
|
+
sendRaw(bytes2) {
|
|
23842
|
+
return this.peer.sendRaw(bytes2);
|
|
23843
|
+
}
|
|
23844
|
+
/**
|
|
23845
|
+
* Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
|
|
23846
|
+
* `connect()` still waiting for the signalling auth is rejected and its timer stopped.
|
|
23847
|
+
*/
|
|
23848
|
+
close() {
|
|
23849
|
+
if (this.closed)
|
|
23850
|
+
return;
|
|
23851
|
+
this.closed = true;
|
|
23852
|
+
this.connected = false;
|
|
23853
|
+
try {
|
|
23854
|
+
if (this.signaling.isOpen)
|
|
23855
|
+
this.signaling.sendHangup();
|
|
23856
|
+
} catch {
|
|
23857
|
+
}
|
|
23858
|
+
this.signaling.close();
|
|
23859
|
+
this.peer.close();
|
|
23860
|
+
this.settleAuth(new Error("RTC session closed while waiting for signalling auth"));
|
|
23861
|
+
this.announceClose();
|
|
23862
|
+
}
|
|
23863
|
+
/**
|
|
23864
|
+
* Trickle one local candidate, or the empty end-of-candidates. The native peer can report one after the
|
|
23865
|
+
* signalling socket has closed, and sending then throws inside a native callback, where the throw can't
|
|
23866
|
+
* be caught and aborts the process. A candidate with no open socket is dropped.
|
|
23867
|
+
*/
|
|
23868
|
+
trickle(candidate) {
|
|
23869
|
+
if (!this.signaling.isOpen)
|
|
23870
|
+
return;
|
|
23871
|
+
this.signaling.sendInfoCandidate(candidate);
|
|
23872
|
+
}
|
|
23873
|
+
announceClose() {
|
|
23874
|
+
if (this.closeAnnounced)
|
|
23875
|
+
return;
|
|
23876
|
+
this.closeAnnounced = true;
|
|
23877
|
+
this.emit("close");
|
|
23878
|
+
}
|
|
23879
|
+
waitForAuth() {
|
|
23880
|
+
if (this.authOk)
|
|
23881
|
+
return Promise.resolve();
|
|
23882
|
+
return new Promise((resolve, reject) => {
|
|
23883
|
+
const timer = setTimeout(() => {
|
|
23884
|
+
this.authWaiter = void 0;
|
|
23885
|
+
reject(new Error("RTC signalling auth timeout"));
|
|
23886
|
+
}, AUTH_TIMEOUT_MS);
|
|
23887
|
+
this.authWaiter = { resolve, reject, timer };
|
|
23888
|
+
});
|
|
23889
|
+
}
|
|
23890
|
+
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
23891
|
+
settleAuth(err) {
|
|
23892
|
+
const waiter = this.authWaiter;
|
|
23893
|
+
if (!waiter)
|
|
23894
|
+
return;
|
|
23895
|
+
this.authWaiter = void 0;
|
|
23896
|
+
clearTimeout(waiter.timer);
|
|
23897
|
+
if (err)
|
|
23898
|
+
waiter.reject(err);
|
|
23899
|
+
else
|
|
23900
|
+
waiter.resolve();
|
|
23901
|
+
}
|
|
23902
|
+
async onSignaling(inner) {
|
|
23903
|
+
if (inner.action === 1 && inner.code === 200) {
|
|
23904
|
+
this.authOk = true;
|
|
23905
|
+
this.settleAuth();
|
|
23906
|
+
}
|
|
23907
|
+
if (!inner.data)
|
|
23908
|
+
return;
|
|
23909
|
+
let payload;
|
|
23910
|
+
try {
|
|
23911
|
+
payload = JSON.parse(inner.data);
|
|
23912
|
+
} catch {
|
|
23913
|
+
return;
|
|
23914
|
+
}
|
|
23915
|
+
switch (inner.dataType) {
|
|
23916
|
+
case "scall":
|
|
23917
|
+
case "call":
|
|
23918
|
+
await this.onCall(payload);
|
|
23919
|
+
return;
|
|
23920
|
+
case "info":
|
|
23921
|
+
await this.onInfo(payload);
|
|
23922
|
+
return;
|
|
23923
|
+
case "hangup":
|
|
23924
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} hub hung up`);
|
|
23925
|
+
return;
|
|
23926
|
+
default:
|
|
23927
|
+
return;
|
|
23928
|
+
}
|
|
23929
|
+
}
|
|
23930
|
+
/** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
|
|
23931
|
+
async onCall(payload) {
|
|
23932
|
+
if (this.closed)
|
|
23933
|
+
return;
|
|
23934
|
+
const status = payload.status;
|
|
23935
|
+
if (status === 100 && payload.turn) {
|
|
23936
|
+
this.turn = payload.turn;
|
|
23937
|
+
await this.peer.init(payload.turn);
|
|
23938
|
+
return;
|
|
23939
|
+
}
|
|
23940
|
+
if (status === 200) {
|
|
23941
|
+
this.signaling.sendAck();
|
|
23942
|
+
return;
|
|
23943
|
+
}
|
|
23944
|
+
if (status === 486 || status === 408) {
|
|
23945
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} scall answered ${status}`));
|
|
23946
|
+
this.close();
|
|
23947
|
+
}
|
|
23948
|
+
}
|
|
23949
|
+
/**
|
|
23950
|
+
* An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
|
|
23951
|
+
* is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
|
|
23952
|
+
* is reported and left unanswered.
|
|
23953
|
+
*/
|
|
23954
|
+
async onInfo(payload) {
|
|
23955
|
+
if (this.closed)
|
|
23956
|
+
return;
|
|
23957
|
+
if (payload.format === "CANDIDATE") {
|
|
23958
|
+
if (payload.value)
|
|
23959
|
+
this.peer.addRemoteCandidate(payload.value);
|
|
23960
|
+
return;
|
|
23961
|
+
}
|
|
23962
|
+
if (payload.candidate !== void 0) {
|
|
23963
|
+
if (payload.candidate)
|
|
23964
|
+
this.peer.addRemoteCandidate(payload.candidate);
|
|
23965
|
+
return;
|
|
23966
|
+
}
|
|
23967
|
+
const sdpText = payload.value ?? payload.sdp;
|
|
23968
|
+
if (!sdpText || payload.format !== "SDP" && !payload.sdp)
|
|
23969
|
+
return;
|
|
23970
|
+
if (this.sdpHandled)
|
|
23971
|
+
return;
|
|
23972
|
+
if (!this.turn) {
|
|
23973
|
+
this.emit("error", new Error(`RTC ${this.opts.stationSn} offered before granting a relay`));
|
|
23974
|
+
return;
|
|
23975
|
+
}
|
|
23976
|
+
this.sdpHandled = true;
|
|
23977
|
+
let offer;
|
|
23978
|
+
try {
|
|
23979
|
+
offer = scallJsonToSdp(JSON.parse(sdpText));
|
|
23980
|
+
} catch {
|
|
23981
|
+
offer = sdpText;
|
|
23982
|
+
}
|
|
23983
|
+
const answer = await this.peer.handleRemoteOffer(offer);
|
|
23984
|
+
this.signaling.sendInfoSdp(this.opts.signalingMode === "call" ? answer : JSON.stringify(sdpToScallJson(answer)));
|
|
23985
|
+
this.logger.debug(`[rtc] ${this.opts.stationSn} answered the hub's offer`);
|
|
23986
|
+
}
|
|
23987
|
+
};
|
|
23988
|
+
|
|
23989
|
+
// dist/transport/rtc/command-router.js
|
|
23990
|
+
var PORTAL_CMD_CONTROL_PAYLOAD = 1700;
|
|
23991
|
+
var PORTAL_CMD_NOTIFY_PAYLOAD = 1351;
|
|
23992
|
+
var ACK_TIMEOUT_MS = 8e3;
|
|
23993
|
+
var CONNECT_TIMEOUT_MS2 = 12e3;
|
|
23994
|
+
var IDLE_CLOSE_MS = 6e4;
|
|
23995
|
+
var StationStreamBusyError = class extends Error {
|
|
23996
|
+
constructor(stationSn, servingSn) {
|
|
23997
|
+
super(`rtc live: ${stationSn} is serving ${servingSn}; the station streams one camera at a time`);
|
|
23998
|
+
this.name = "StationStreamBusyError";
|
|
23999
|
+
}
|
|
24000
|
+
};
|
|
24001
|
+
var RtcCommandRouter = class {
|
|
24002
|
+
deps;
|
|
24003
|
+
sessions = /* @__PURE__ */ new Map();
|
|
24004
|
+
lives = /* @__PURE__ */ new Map();
|
|
24005
|
+
warm = /* @__PURE__ */ new Map();
|
|
24006
|
+
constructor(deps) {
|
|
24007
|
+
this.deps = deps;
|
|
24008
|
+
}
|
|
24009
|
+
async dispatchCommand(route, cmd) {
|
|
24010
|
+
if (cmd.kind !== "set-payload" && cmd.kind !== "set-json") {
|
|
24011
|
+
throw new Error(`rtc: ${cmd.kind} is not routable over the RTC control channel (only set-payload or set-json)`);
|
|
24012
|
+
}
|
|
24013
|
+
const identity = this.deps.identity();
|
|
24014
|
+
if (!identity)
|
|
24015
|
+
throw new Error(`rtc: not logged in, cannot drive ${route.stationSn}`);
|
|
24016
|
+
const { stationSn } = route;
|
|
24017
|
+
const adminUserId = route.adminUserId || identity.userId;
|
|
24018
|
+
const channel = route.attached ? cmd.channel : PORTAL_STATION_CHANNEL;
|
|
24019
|
+
const st = await this.stationSession(route, adminUserId, identity);
|
|
24020
|
+
const outerCmd = cmd.kind === "set-json" ? PORTAL_CMD_CONTROL_PAYLOAD : PORTAL_CMD_SET_PAYLOAD;
|
|
24021
|
+
const innerCmd = cmd.kind === "set-json" ? cmd.param : cmd.cmd;
|
|
24022
|
+
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 };
|
|
24023
|
+
const segment = st.seg.next();
|
|
24024
|
+
const packet = buildPortalPacket({ commandId: outerCmd, channel, segment, payload });
|
|
24025
|
+
const run = st.queue.then(() => this.sendAwaitAck(stationSn, st, packet, outerCmd, innerCmd, channel, segment));
|
|
24026
|
+
st.queue = run.catch(() => void 0);
|
|
24027
|
+
return run;
|
|
24028
|
+
}
|
|
24029
|
+
/**
|
|
24030
|
+
* The media of a camera on a T9000: live video, a readable feed and a live still over the station's
|
|
24031
|
+
* session. Recording has no wire here and is refused; the optional members it has no wire for are absent.
|
|
24032
|
+
*/
|
|
24033
|
+
mediaProviderFor(route) {
|
|
24034
|
+
return {
|
|
24035
|
+
live: (opts) => this.withLiveSource(route, opts ?? {}, (source) => attachUnlessAborted(source, opts?.signal)),
|
|
24036
|
+
openReadable: (opts) => this.withLiveSource(route, opts ?? {}, (source) => openReadableFromConsumer(attachUnlessAborted(source, opts?.signal), opts)),
|
|
24037
|
+
snapshotLive: (opts) => this.withLiveSource(route, opts ?? {}, (source) => captureSnapshotFromShared(source, { ...opts, logger: this.deps.logger, ffmpegPath: this.deps.ffmpegPath })).catch((e) => {
|
|
24038
|
+
if (!(e instanceof StationStreamBusyError))
|
|
24039
|
+
throw e;
|
|
24040
|
+
throw new LiveSnapshotUnavailableError("source-failed", e.message, { cause: e });
|
|
24041
|
+
}),
|
|
24042
|
+
record: async () => {
|
|
24043
|
+
throw new Error(`record is not available for ${route.cameraSn} over the T9000 control channel`);
|
|
24044
|
+
}
|
|
24045
|
+
};
|
|
24046
|
+
}
|
|
24047
|
+
/**
|
|
24048
|
+
* Tear down every station session (logout / shutdown). Each live source is disposed before its session
|
|
24049
|
+
* closes, so its consumers end cleanly and its stop still rides the session.
|
|
24050
|
+
*/
|
|
24051
|
+
close() {
|
|
24052
|
+
for (const [sn, live] of this.lives) {
|
|
24053
|
+
this.lives.delete(sn);
|
|
24054
|
+
live.source.dispose();
|
|
24055
|
+
}
|
|
24056
|
+
for (const [sn, st] of this.sessions)
|
|
24057
|
+
this.drop(sn, st);
|
|
24058
|
+
}
|
|
24059
|
+
/**
|
|
24060
|
+
* Hand `use` the station's live source for this camera, in the same turn the source is created or reused,
|
|
24061
|
+
* so a consumer it attaches is counted before any other caller looks.
|
|
24062
|
+
*
|
|
24063
|
+
* The station streams one camera at a time. Another camera whose source has consumers refuses this one,
|
|
24064
|
+
* before the session is awaited and again after; one with none (never attached or lingering)
|
|
24065
|
+
* holds nothing a viewer needs, so it is disposed and the slot taken. The first caller's
|
|
24066
|
+
* {@link SharedSourceHints} configure a new source.
|
|
24067
|
+
*/
|
|
24068
|
+
async withLiveSource(route, opts, use) {
|
|
24069
|
+
opts.signal?.throwIfAborted();
|
|
24070
|
+
this.refuseIfWatched(route);
|
|
24071
|
+
const identity = this.deps.identity();
|
|
24072
|
+
if (!identity)
|
|
24073
|
+
throw new Error(`rtc: not logged in, cannot open ${route.stationSn}`);
|
|
24074
|
+
const accountId = route.adminUserId || identity.userId;
|
|
24075
|
+
const st = await abortable(this.stationSession(route, accountId, identity), opts.signal);
|
|
24076
|
+
this.refuseIfWatched(route);
|
|
24077
|
+
const current = this.lives.get(route.stationSn);
|
|
24078
|
+
if (current && current.cameraSn !== route.cameraSn) {
|
|
24079
|
+
this.deps.logger?.debug?.(`[rtc] ${route.stationSn} releasing the unwatched source of ${current.cameraSn} for ${route.cameraSn}`);
|
|
24080
|
+
this.lives.delete(route.stationSn);
|
|
24081
|
+
current.source.dispose();
|
|
24082
|
+
}
|
|
24083
|
+
let live = this.lives.get(route.stationSn);
|
|
24084
|
+
if (!live) {
|
|
24085
|
+
live = { cameraSn: route.cameraSn, source: this.newLiveSource(route, st, accountId, opts) };
|
|
24086
|
+
this.lives.set(route.stationSn, live);
|
|
24087
|
+
}
|
|
24088
|
+
return await use(live.source);
|
|
24089
|
+
}
|
|
24090
|
+
/** Refuse a camera while another camera on its station has consumers. */
|
|
24091
|
+
refuseIfWatched(route) {
|
|
24092
|
+
const current = this.lives.get(route.stationSn);
|
|
24093
|
+
if (current && current.cameraSn !== route.cameraSn && current.source.consumerCount > 0) {
|
|
24094
|
+
throw new StationStreamBusyError(route.stationSn, current.cameraSn);
|
|
24095
|
+
}
|
|
24096
|
+
}
|
|
24097
|
+
newLiveSource(route, st, accountId, hints) {
|
|
24098
|
+
let leased = false;
|
|
24099
|
+
const release = () => {
|
|
24100
|
+
if (!leased)
|
|
24101
|
+
return;
|
|
24102
|
+
leased = false;
|
|
24103
|
+
st.leases = Math.max(0, st.leases - 1);
|
|
24104
|
+
this.touch(route.stationSn, st);
|
|
24105
|
+
};
|
|
24106
|
+
const source = new SharedLiveSource({
|
|
24107
|
+
makeStream: () => new RtcLiveStream({
|
|
24108
|
+
session: st.session,
|
|
24109
|
+
seg: st.seg,
|
|
24110
|
+
stationSn: route.stationSn,
|
|
24111
|
+
channel: route.channel,
|
|
24112
|
+
accountId,
|
|
24113
|
+
logger: this.deps.logger
|
|
24114
|
+
}),
|
|
24115
|
+
label: `${route.stationSn}#${route.channel}`,
|
|
24116
|
+
logger: this.deps.logger,
|
|
24117
|
+
powered: hints.powered,
|
|
24118
|
+
preBufferSeconds: hints.preBufferSeconds,
|
|
24119
|
+
onActive: () => {
|
|
24120
|
+
if (leased)
|
|
24121
|
+
return;
|
|
24122
|
+
leased = true;
|
|
24123
|
+
st.leases++;
|
|
24124
|
+
if (st.idle)
|
|
24125
|
+
clearTimeout(st.idle);
|
|
24126
|
+
},
|
|
24127
|
+
onStopped: () => {
|
|
24128
|
+
release();
|
|
24129
|
+
if (this.lives.get(route.stationSn)?.source === source)
|
|
24130
|
+
this.lives.delete(route.stationSn);
|
|
24131
|
+
}
|
|
24132
|
+
});
|
|
24133
|
+
return source;
|
|
24134
|
+
}
|
|
24135
|
+
/**
|
|
24136
|
+
* Completes on the command ACK or a correlated control-result notification. The notification must
|
|
24137
|
+
* match the request channel, parameter and segment and contain a structured payload. Receipt does
|
|
24138
|
+
* not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
|
|
24139
|
+
*/
|
|
24140
|
+
sendAwaitAck(sn, st, packet, outerCmd, innerCmd, channel, segment) {
|
|
24141
|
+
return new Promise((resolve, reject) => {
|
|
24142
|
+
const onData = (frame, linkType) => {
|
|
24143
|
+
const p = parsePortalPacket(frame, linkType);
|
|
24144
|
+
if (!p || p.segment !== segment)
|
|
24145
|
+
return;
|
|
24146
|
+
const acknowledgement = linkType === PortalLinkType.COMMAND && p.commandId === outerCmd && !!p.isResponse;
|
|
24147
|
+
const d = p.data;
|
|
24148
|
+
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;
|
|
24149
|
+
if (!acknowledgement && !notification)
|
|
24150
|
+
return;
|
|
24151
|
+
cleanup();
|
|
24152
|
+
if (acknowledgement && p.errCode !== 0) {
|
|
24153
|
+
reject(new Error(`rtc: ${sn} rejected cmd ${innerCmd} (err ${p.errCode})`));
|
|
24154
|
+
} else {
|
|
24155
|
+
this.deps.logger?.debug?.(`[rtc] ${sn} cmd ${innerCmd} acked`);
|
|
24156
|
+
resolve();
|
|
24157
|
+
}
|
|
24158
|
+
};
|
|
24159
|
+
const onClose = () => {
|
|
24160
|
+
cleanup();
|
|
24161
|
+
reject(new Error(`rtc: ${sn} session closed while waiting for cmd ${innerCmd} ACK`));
|
|
24162
|
+
};
|
|
24163
|
+
const timer = setTimeout(() => {
|
|
24164
|
+
cleanup();
|
|
24165
|
+
reject(new Error(`rtc: ${sn} cmd ${innerCmd} ACK timed out after ${ACK_TIMEOUT_MS}ms`));
|
|
24166
|
+
}, ACK_TIMEOUT_MS);
|
|
24167
|
+
const cleanup = () => {
|
|
24168
|
+
clearTimeout(timer);
|
|
24169
|
+
st.session.off("commandData", onData);
|
|
24170
|
+
st.session.off("close", onClose);
|
|
24171
|
+
this.touch(sn, st);
|
|
24172
|
+
};
|
|
24173
|
+
st.session.on("commandData", onData);
|
|
24174
|
+
st.session.on("close", onClose);
|
|
24175
|
+
if (!st.session.sendCommand(packet)) {
|
|
24176
|
+
cleanup();
|
|
24177
|
+
reject(new Error(`rtc: ${sn} command channel not open, cmd ${innerCmd} not sent`));
|
|
24178
|
+
}
|
|
24179
|
+
});
|
|
24180
|
+
}
|
|
24181
|
+
async stationSession(route, adminUserId, identity) {
|
|
24182
|
+
const sn = route.stationSn;
|
|
24183
|
+
const existing = this.sessions.get(sn);
|
|
24184
|
+
if (existing) {
|
|
24185
|
+
await existing.ready;
|
|
24186
|
+
if (existing.session.isConnected) {
|
|
24187
|
+
this.touch(sn, existing);
|
|
24188
|
+
return existing;
|
|
24189
|
+
}
|
|
24190
|
+
this.drop(sn, existing);
|
|
24191
|
+
}
|
|
24192
|
+
const session = (this.deps.createSession ?? ((o) => new RtcSession(o)))({
|
|
24193
|
+
authToken: identity.authToken,
|
|
24194
|
+
gtoken: identity.gtoken,
|
|
24195
|
+
stationSn: sn,
|
|
24196
|
+
adminUserId,
|
|
24197
|
+
shard: this.deps.shard(),
|
|
24198
|
+
country: this.deps.country ?? "US",
|
|
24199
|
+
logger: this.deps.logger,
|
|
24200
|
+
signalingMode: route.signalingMode,
|
|
24201
|
+
peer: { logger: this.deps.logger, iceTransportPolicy: route.iceTransportPolicy }
|
|
24202
|
+
});
|
|
24203
|
+
const ready = this.bringUp(sn, session);
|
|
24204
|
+
const st = { session, seg: new SegmentCounter(), ready, queue: Promise.resolve(), leases: 0 };
|
|
24205
|
+
session.on("error", (e) => this.deps.onError?.(e));
|
|
24206
|
+
session.on("close", () => {
|
|
24207
|
+
if (this.sessions.get(sn) === st)
|
|
24208
|
+
this.sessions.delete(sn);
|
|
24209
|
+
});
|
|
24210
|
+
this.sessions.set(sn, st);
|
|
24211
|
+
try {
|
|
24212
|
+
await ready;
|
|
24213
|
+
} catch (e) {
|
|
24214
|
+
this.drop(sn, st);
|
|
24215
|
+
throw e;
|
|
24216
|
+
}
|
|
24217
|
+
this.touch(sn, st);
|
|
24218
|
+
return st;
|
|
24219
|
+
}
|
|
24220
|
+
/**
|
|
24221
|
+
* One bounded bring-up: the session is up when its command channel opens, and it fails, with the
|
|
24222
|
+
* session closed and every listener gone, when `connect()` throws, the session closes first, or
|
|
24223
|
+
* {@link CONNECT_TIMEOUT_MS} passes. A single promise, so an early failure cannot leave a second one
|
|
24224
|
+
* rejecting unheard.
|
|
24225
|
+
*/
|
|
24226
|
+
bringUp(sn, session) {
|
|
24227
|
+
return new Promise((resolve, reject) => {
|
|
24228
|
+
let settled = false;
|
|
24229
|
+
const finish = (err) => {
|
|
24230
|
+
if (settled)
|
|
24231
|
+
return;
|
|
24232
|
+
settled = true;
|
|
24233
|
+
clearTimeout(timer);
|
|
24234
|
+
session.off("connected", onConnected);
|
|
24235
|
+
session.off("close", onClose);
|
|
24236
|
+
if (err) {
|
|
24237
|
+
try {
|
|
24238
|
+
session.close();
|
|
24239
|
+
} catch {
|
|
24240
|
+
}
|
|
24241
|
+
reject(err);
|
|
24242
|
+
} else {
|
|
24243
|
+
this.deps.logger?.info?.(`[rtc] ${sn} command channel up`);
|
|
24244
|
+
resolve();
|
|
24245
|
+
}
|
|
24246
|
+
};
|
|
24247
|
+
const onConnected = () => finish();
|
|
24248
|
+
const onClose = () => finish(new Error(`rtc: ${sn} session closed before the command channel opened`));
|
|
24249
|
+
const timer = setTimeout(() => finish(new Error(`rtc: ${sn} did not come up within ${CONNECT_TIMEOUT_MS2}ms`)), CONNECT_TIMEOUT_MS2);
|
|
24250
|
+
session.once("connected", onConnected);
|
|
24251
|
+
session.once("close", onClose);
|
|
24252
|
+
Promise.resolve().then(() => session.connect()).catch((e) => finish(e instanceof Error ? e : new Error(String(e))));
|
|
24253
|
+
});
|
|
24254
|
+
}
|
|
24255
|
+
/**
|
|
24256
|
+
* Hold a station's command session open: one hold per station, its idle-close ({@link IDLE_CLOSE_MS})
|
|
24257
|
+
* suppressed, {@link PORTAL_KEEPALIVE} sent every {@link PORTAL_KEEPALIVE_MS}, and the session dropped
|
|
24258
|
+
* on a tick that finds the previous keepalive unechoed. A closed session ends the hold.
|
|
24259
|
+
*
|
|
24260
|
+
* Answers an idempotent disposer that releases the hold. Throws when no account is logged in.
|
|
24261
|
+
*/
|
|
24262
|
+
keepWarm(route) {
|
|
24263
|
+
const sn = route.stationSn;
|
|
24264
|
+
const existing = this.warm.get(sn);
|
|
24265
|
+
if (existing)
|
|
24266
|
+
return existing.dispose;
|
|
24267
|
+
const identity = this.deps.identity();
|
|
24268
|
+
if (!identity)
|
|
24269
|
+
throw new Error(`rtc: not logged in, cannot warm ${sn}`);
|
|
24270
|
+
const hold = {
|
|
24271
|
+
awaitingEcho: false,
|
|
24272
|
+
dispose: () => {
|
|
24273
|
+
if (this.warm.get(sn) !== hold)
|
|
24274
|
+
return;
|
|
24275
|
+
this.warm.delete(sn);
|
|
24276
|
+
const st = hold.watch?.st;
|
|
24277
|
+
this.unwatch(hold);
|
|
24278
|
+
if (st) {
|
|
24279
|
+
st.leases = Math.max(0, st.leases - 1);
|
|
24280
|
+
this.touch(sn, st);
|
|
24281
|
+
}
|
|
24282
|
+
}
|
|
24283
|
+
};
|
|
24284
|
+
this.warm.set(sn, hold);
|
|
24285
|
+
void this.warmUp(sn, route, identity, hold);
|
|
24286
|
+
return hold.dispose;
|
|
24287
|
+
}
|
|
24288
|
+
/**
|
|
24289
|
+
* Bring a station session up for a {@link keepWarm} hold and start its keepalive. A bring-up that fails,
|
|
24290
|
+
* or a hold disposed before it completes, clears the hold and arms no timer. Each tick first checks
|
|
24291
|
+
* that the hub echoed the previous keepalive, and drops the session when it did not. The keepalive
|
|
24292
|
+
* stops when the session closes, which also clears the hold so the next command or `keepWarm`
|
|
24293
|
+
* rebuilds cleanly.
|
|
24294
|
+
*/
|
|
24295
|
+
async warmUp(sn, route, identity, hold) {
|
|
24296
|
+
const adminUserId = route.adminUserId || identity.userId;
|
|
24297
|
+
let st;
|
|
24298
|
+
try {
|
|
24299
|
+
st = await this.stationSession(route, adminUserId, identity);
|
|
24300
|
+
} catch (e) {
|
|
24301
|
+
if (this.warm.get(sn) === hold)
|
|
24302
|
+
this.warm.delete(sn);
|
|
24303
|
+
this.deps.onError?.(e instanceof Error ? e : new Error(String(e)));
|
|
24304
|
+
return;
|
|
24305
|
+
}
|
|
24306
|
+
if (this.warm.get(sn) !== hold) {
|
|
24307
|
+
this.touch(sn, st);
|
|
24308
|
+
return;
|
|
24309
|
+
}
|
|
24310
|
+
st.leases++;
|
|
24311
|
+
this.touch(sn, st);
|
|
24312
|
+
const onClose = () => {
|
|
24313
|
+
this.unwatch(hold);
|
|
24314
|
+
if (this.warm.get(sn) === hold)
|
|
24315
|
+
this.warm.delete(sn);
|
|
24316
|
+
};
|
|
24317
|
+
const onEcho = () => {
|
|
24318
|
+
hold.awaitingEcho = false;
|
|
24319
|
+
};
|
|
24320
|
+
hold.watch = { st, onClose, onEcho };
|
|
24321
|
+
st.session.once("close", onClose);
|
|
24322
|
+
st.session.on("keepaliveEcho", onEcho);
|
|
24323
|
+
hold.keepalive = setInterval(() => {
|
|
24324
|
+
if (hold.awaitingEcho) {
|
|
24325
|
+
this.deps.logger?.warn?.(`[rtc] ${sn} warm keepalive went unechoed, dropping the session`);
|
|
24326
|
+
this.drop(sn, st);
|
|
24327
|
+
return;
|
|
24328
|
+
}
|
|
24329
|
+
hold.awaitingEcho = true;
|
|
24330
|
+
st.session.sendRaw(PORTAL_KEEPALIVE);
|
|
24331
|
+
}, PORTAL_KEEPALIVE_MS);
|
|
24332
|
+
hold.keepalive.unref?.();
|
|
24333
|
+
}
|
|
24334
|
+
/** Stop a hold's keepalive and remove its listeners from the session it watches. */
|
|
24335
|
+
unwatch(hold) {
|
|
24336
|
+
if (hold.keepalive)
|
|
24337
|
+
clearInterval(hold.keepalive);
|
|
24338
|
+
hold.keepalive = void 0;
|
|
24339
|
+
if (hold.watch) {
|
|
24340
|
+
hold.watch.st.session.off("close", hold.watch.onClose);
|
|
24341
|
+
hold.watch.st.session.off("keepaliveEcho", hold.watch.onEcho);
|
|
24342
|
+
}
|
|
24343
|
+
hold.watch = void 0;
|
|
24344
|
+
}
|
|
24345
|
+
touch(sn, st) {
|
|
24346
|
+
if (st.idle)
|
|
24347
|
+
clearTimeout(st.idle);
|
|
24348
|
+
if (st.leases > 0)
|
|
24349
|
+
return;
|
|
24350
|
+
st.idle = setTimeout(() => this.drop(sn, st), IDLE_CLOSE_MS);
|
|
24351
|
+
st.idle.unref?.();
|
|
24352
|
+
}
|
|
24353
|
+
drop(sn, st) {
|
|
24354
|
+
if (st.idle)
|
|
24355
|
+
clearTimeout(st.idle);
|
|
24356
|
+
if (this.sessions.get(sn) === st)
|
|
24357
|
+
this.sessions.delete(sn);
|
|
24358
|
+
try {
|
|
24359
|
+
st.session.close();
|
|
24360
|
+
} catch {
|
|
24361
|
+
}
|
|
24362
|
+
}
|
|
24363
|
+
};
|
|
24364
|
+
|
|
24365
|
+
// dist/transport/mqtt/command-router.js
|
|
24366
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
24367
|
+
|
|
24368
|
+
// dist/transport/mqtt/broker-discovery.js
|
|
24369
|
+
import { resolve4 } from "node:dns/promises";
|
|
24370
|
+
async function resolveBrokerIps(hostname) {
|
|
24371
|
+
try {
|
|
24372
|
+
return await resolve4(hostname);
|
|
24373
|
+
} catch {
|
|
24374
|
+
return [];
|
|
24375
|
+
}
|
|
24376
|
+
}
|
|
24377
|
+
function mergeCandidateIps(dnsIps, extraIps = []) {
|
|
24378
|
+
return Array.from(/* @__PURE__ */ new Set([...dnsIps, ...extraIps]));
|
|
24379
|
+
}
|
|
24380
|
+
function rankResults(results) {
|
|
24381
|
+
return [...results].sort((a, b) => Number(b.granted) - Number(a.granted));
|
|
24382
|
+
}
|
|
24383
|
+
async function probeBrokerInstance(ip, creds, opts) {
|
|
24384
|
+
const mqtt = await loadMqtt();
|
|
24385
|
+
const start = Date.now();
|
|
24386
|
+
const timeoutMs = opts.timeoutMs ?? 8e3;
|
|
24387
|
+
return await new Promise((resolvePromise) => {
|
|
24388
|
+
let settled = false;
|
|
24389
|
+
const finish = (r) => {
|
|
24390
|
+
if (settled)
|
|
24391
|
+
return;
|
|
24392
|
+
settled = true;
|
|
24393
|
+
clearTimeout(timer);
|
|
24394
|
+
try {
|
|
24395
|
+
client.end(true);
|
|
24396
|
+
} catch {
|
|
24397
|
+
}
|
|
24398
|
+
resolvePromise({ ip, ms: Date.now() - start, ...r });
|
|
24399
|
+
};
|
|
24400
|
+
const client = mqtt.connect({
|
|
24401
|
+
host: ip,
|
|
24402
|
+
port: creds.port ?? 8883,
|
|
24403
|
+
protocol: "mqtts",
|
|
24404
|
+
...bareIpTlsOptions({
|
|
24405
|
+
hostname: creds.hostname,
|
|
24406
|
+
cert: creds.certificate_pem,
|
|
24407
|
+
key: creds.private_key,
|
|
24408
|
+
ca: creds.aws_root_ca1_pem
|
|
24409
|
+
}),
|
|
24410
|
+
clientId: opts.clientId,
|
|
24411
|
+
protocolVersion: 4,
|
|
24412
|
+
keepalive: 60,
|
|
24413
|
+
clean: true,
|
|
24414
|
+
reconnectPeriod: 0,
|
|
24415
|
+
connectTimeout: timeoutMs
|
|
24416
|
+
});
|
|
24417
|
+
const timer = setTimeout(() => finish({ granted: false, error: "timeout" }), timeoutMs);
|
|
24418
|
+
client.on("connect", () => {
|
|
24419
|
+
client.subscribe(opts.topic, { qos: 1 }, (err, granted, packet) => {
|
|
24420
|
+
if (err) {
|
|
24421
|
+
const code = packet?.granted?.[0];
|
|
24422
|
+
return finish({
|
|
24423
|
+
granted: false,
|
|
24424
|
+
grantedQos: typeof code === "number" ? code : void 0,
|
|
24425
|
+
error: err.message
|
|
24426
|
+
});
|
|
24427
|
+
}
|
|
24428
|
+
const g = granted?.[0];
|
|
24429
|
+
finish({ granted: !!g, grantedQos: g?.qos });
|
|
24430
|
+
});
|
|
24431
|
+
});
|
|
24432
|
+
client.on("error", (err) => finish({ granted: false, error: err.message }));
|
|
24433
|
+
});
|
|
24434
|
+
}
|
|
24435
|
+
async function discoverReachableInstance(creds, opts) {
|
|
24436
|
+
const dnsIps = await resolveBrokerIps(creds.hostname);
|
|
24437
|
+
const all = mergeCandidateIps(dnsIps, opts.candidateIps);
|
|
24438
|
+
const results = [];
|
|
24439
|
+
for (const ip of all) {
|
|
24440
|
+
const r = await probeBrokerInstance(ip, creds, {
|
|
24441
|
+
clientId: opts.clientIdFor(ip),
|
|
24442
|
+
topic: opts.topic,
|
|
24443
|
+
timeoutMs: opts.perAttemptTimeoutMs
|
|
24444
|
+
});
|
|
24445
|
+
results.push(r);
|
|
24446
|
+
if (r.granted && opts.stopOnFirstGrant)
|
|
24447
|
+
break;
|
|
24448
|
+
}
|
|
24449
|
+
return rankResults(results);
|
|
22440
24450
|
}
|
|
22441
24451
|
|
|
22442
24452
|
// dist/transport/dp-preset.js
|
|
@@ -22660,10 +24670,10 @@ function dpColorFields(spec) {
|
|
|
22660
24670
|
}
|
|
22661
24671
|
|
|
22662
24672
|
// dist/transport/mqtt/clean-codec.js
|
|
22663
|
-
import { randomUUID as
|
|
24673
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
22664
24674
|
function buildCleanDpEnvelope(accountId, deviceSn, dp, value, opts) {
|
|
22665
24675
|
const payload = JSON.stringify({
|
|
22666
|
-
t: opts?.uuid ??
|
|
24676
|
+
t: opts?.uuid ?? randomUUID3(),
|
|
22667
24677
|
protocol: 2,
|
|
22668
24678
|
account_id: accountId,
|
|
22669
24679
|
device_sn: deviceSn,
|
|
@@ -22842,8 +24852,9 @@ var MqttCommandRouter = class _MqttCommandRouter {
|
|
|
22842
24852
|
* One exception is known: a single garage unit whose own-cert SUBSCRIBE was denied on every candidate
|
|
22843
24853
|
* while a phone-extracted cert granted on the same one, with a sibling of the same model on the same
|
|
22844
24854
|
* account granting fine. That is a per-device authorization gap on the vendor's backend, not a
|
|
22845
|
-
* broker-instance problem and not something this SDK works around —
|
|
22846
|
-
* (
|
|
24855
|
+
* broker-instance problem and not something this SDK works around — the throw below names each
|
|
24856
|
+
* candidate's outcome (its SUBACK deny code, a timeout or a connection error) until the backend grants
|
|
24857
|
+
* the account's own credentials.
|
|
22847
24858
|
*
|
|
22848
24859
|
* One fresh, explicitly non-reconnecting (`reconnectPeriod: 0`) connection per call — it repays the
|
|
22849
24860
|
* full mTLS handshake every command and tears the socket down right after (see
|
|
@@ -22872,7 +24883,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
|
|
|
22872
24883
|
});
|
|
22873
24884
|
const winner = results.find((res) => res.granted);
|
|
22874
24885
|
if (!winner) {
|
|
22875
|
-
throw new Error(`ensureSecurityMqttFor(${dev.sn}): no broker instance currently holds this device's session (probed ${results.length} candidate IPs
|
|
24886
|
+
throw new Error(`ensureSecurityMqttFor(${dev.sn}): no broker instance currently holds this device's session (probed ${results.length} candidate IPs: ` + results.map((r) => `${r.ip} ${r.grantedQos === void 0 ? "" : `SUBACK 0x${r.grantedQos.toString(16)} `}${r.error ?? "denied"}`).join("; ") + `).`);
|
|
22876
24887
|
}
|
|
22877
24888
|
const m = new SecureMqtt({
|
|
22878
24889
|
credentials: creds,
|
|
@@ -23199,7 +25210,7 @@ var MqttCommandRouter = class _MqttCommandRouter {
|
|
|
23199
25210
|
|
|
23200
25211
|
// dist/transport/tuya/account.js
|
|
23201
25212
|
import { createCipheriv as createCipheriv5 } from "node:crypto";
|
|
23202
|
-
var TUYA_USERNAME_PREFIX = "
|
|
25213
|
+
var TUYA_USERNAME_PREFIX = "eh-";
|
|
23203
25214
|
var TUYA_PASSWORD_KEY = Buffer.from([
|
|
23204
25215
|
36,
|
|
23205
25216
|
78,
|
|
@@ -23350,7 +25361,7 @@ function deriveTuyaAccount(eufyUserId, phoneCode) {
|
|
|
23350
25361
|
}
|
|
23351
25362
|
|
|
23352
25363
|
// dist/transport/tuya/sign.js
|
|
23353
|
-
import { createHash as createHash5, createHmac as
|
|
25364
|
+
import { createHash as createHash5, createHmac as createHmac4 } from "node:crypto";
|
|
23354
25365
|
var SIGN_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
23355
25366
|
"a",
|
|
23356
25367
|
"v",
|
|
@@ -23387,22 +25398,55 @@ var StubSigner = class {
|
|
|
23387
25398
|
}
|
|
23388
25399
|
};
|
|
23389
25400
|
var TUYA_SIGN_K = "com.oceanwing.battery.cam_16:6C:23:45:57:B7:76:CA:D8:AC:94:C9:79:37:9E:48:DF:38:7D:4D:8F:96:A3:43:DF:40:FC:D9:05:BF:F6:86_dn9erpyp7nmeuvah8ktghqsgpay87maa_pt585qhmt75hwcynchnps9dnxh9suhwd";
|
|
25401
|
+
function resolveSignKey(override) {
|
|
25402
|
+
return override ?? process.env.TUYA_SIGN_KEY ?? TUYA_SIGN_K;
|
|
25403
|
+
}
|
|
23390
25404
|
var HmacSigner = class {
|
|
23391
25405
|
key;
|
|
23392
25406
|
constructor(key) {
|
|
23393
|
-
this.key = key
|
|
25407
|
+
this.key = resolveSignKey(key);
|
|
23394
25408
|
}
|
|
23395
25409
|
sign(preimage) {
|
|
23396
|
-
return
|
|
25410
|
+
return createHmac4("sha256", this.key).update(preimage, "utf-8").digest("hex");
|
|
23397
25411
|
}
|
|
23398
25412
|
};
|
|
23399
25413
|
var TUYA_CHKEY = "7cbfe6d8";
|
|
23400
25414
|
|
|
23401
25415
|
// dist/transport/tuya/client.js
|
|
23402
|
-
import { randomBytes as
|
|
25416
|
+
import { randomBytes as randomBytes5, createHash as createHash6, createPublicKey, publicEncrypt, constants as constants2 } from "node:crypto";
|
|
25417
|
+
|
|
25418
|
+
// dist/transport/tuya/et3.js
|
|
25419
|
+
import { createCipheriv as createCipheriv6, createDecipheriv as createDecipheriv8, createHmac as createHmac5, randomBytes as randomBytes4 } from "node:crypto";
|
|
25420
|
+
import { gunzipSync } from "node:zlib";
|
|
25421
|
+
function deriveBodyKey(requestId, ecode) {
|
|
25422
|
+
const k = resolveSignKey();
|
|
25423
|
+
const msg = ecode === null ? k : `${k}_${ecode}`;
|
|
25424
|
+
const hex = createHmac5("sha256", requestId).update(msg, "utf-8").digest("hex");
|
|
25425
|
+
return Buffer.from(hex.slice(0, 16), "ascii");
|
|
25426
|
+
}
|
|
25427
|
+
function encryptBody2(key, json) {
|
|
25428
|
+
const nonce = randomBytes4(12);
|
|
25429
|
+
const cipher = createCipheriv6("aes-128-gcm", key, nonce);
|
|
25430
|
+
const ciphertext = Buffer.concat([cipher.update(Buffer.from(json, "utf-8")), cipher.final()]);
|
|
25431
|
+
return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64");
|
|
25432
|
+
}
|
|
25433
|
+
function decryptReply(key, resultB64) {
|
|
25434
|
+
const blob = Buffer.from(resultB64, "base64");
|
|
25435
|
+
if (blob.length < 12 + 16) {
|
|
25436
|
+
throw new Error("tuya et=3 reply too short to hold a 12-byte nonce + 16-byte tag");
|
|
25437
|
+
}
|
|
25438
|
+
const nonce = blob.subarray(0, 12);
|
|
25439
|
+
const tag2 = blob.subarray(blob.length - 16);
|
|
25440
|
+
const ciphertext = blob.subarray(12, blob.length - 16);
|
|
25441
|
+
const decipher = createDecipheriv8("aes-128-gcm", key, nonce);
|
|
25442
|
+
decipher.setAuthTag(tag2);
|
|
25443
|
+
const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
25444
|
+
const text2 = plain.length >= 2 && plain[0] === 31 && plain[1] === 139 ? gunzipSync(plain) : plain;
|
|
25445
|
+
return text2.toString("utf-8");
|
|
25446
|
+
}
|
|
23403
25447
|
|
|
23404
25448
|
// dist/transport/tuya/request.js
|
|
23405
|
-
import { randomUUID as
|
|
25449
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
23406
25450
|
var TUYA_API_ENDPOINT = "https://a1.tuyaeu.com/api.json";
|
|
23407
25451
|
var TUYA_APP_KEY = "w8x4ppqkdxvqnd73ahj9";
|
|
23408
25452
|
var TUYA_APP_SECRET = "pt585qhmt75hwcynchnps9dnxh9suhwd";
|
|
@@ -23434,7 +25478,7 @@ function buildApiParams(action, opts) {
|
|
|
23434
25478
|
a: action.a,
|
|
23435
25479
|
v: action.v,
|
|
23436
25480
|
time: String(opts.time ?? Math.floor(Date.now() / 1e3)),
|
|
23437
|
-
requestId: opts.requestId ??
|
|
25481
|
+
requestId: opts.requestId ?? randomUUID4(),
|
|
23438
25482
|
sid: opts.session.sid,
|
|
23439
25483
|
deviceId: opts.session.deviceId,
|
|
23440
25484
|
chKey: opts.session.chKey,
|
|
@@ -23454,8 +25498,9 @@ function buildApiParams(action, opts) {
|
|
|
23454
25498
|
nd: env.nd,
|
|
23455
25499
|
deviceCoreVersion: env.deviceCoreVersion
|
|
23456
25500
|
};
|
|
23457
|
-
if (action.postData !== void 0)
|
|
23458
|
-
params.postData = action.postData;
|
|
25501
|
+
if (action.postData !== void 0) {
|
|
25502
|
+
params.postData = env.et === "3" ? encryptBody2(deriveBodyKey(params.requestId, opts.session.ecode ?? null), action.postData) : action.postData;
|
|
25503
|
+
}
|
|
23459
25504
|
const sign = opts.signer.sign(buildSignPreimage(params));
|
|
23460
25505
|
return { ...params, sign };
|
|
23461
25506
|
}
|
|
@@ -23475,14 +25520,14 @@ async function sendApiRequest(params, opts = {}) {
|
|
|
23475
25520
|
}
|
|
23476
25521
|
function buildPublishDpsAction(devId, gwId, dps) {
|
|
23477
25522
|
return {
|
|
23478
|
-
a: "
|
|
25523
|
+
a: "smartlife.m.device.dp.publish",
|
|
23479
25524
|
v: "2.0",
|
|
23480
25525
|
postData: JSON.stringify({ gwId, devId, dps: JSON.stringify(dps) })
|
|
23481
25526
|
};
|
|
23482
25527
|
}
|
|
23483
25528
|
function buildGetDeviceDpsAction(devId, dpCacheType = 1) {
|
|
23484
25529
|
return {
|
|
23485
|
-
a: "
|
|
25530
|
+
a: "smartlife.m.device.cache.dp.get",
|
|
23486
25531
|
v: "2.0",
|
|
23487
25532
|
postData: JSON.stringify({ devId, dpCacheType })
|
|
23488
25533
|
};
|
|
@@ -23498,7 +25543,15 @@ function buildPasswordLoginRegAction(countryCode, uid, passwd, token) {
|
|
|
23498
25543
|
return {
|
|
23499
25544
|
a: "smartlife.m.user.uid.password.login.reg",
|
|
23500
25545
|
v: "1.0",
|
|
23501
|
-
postData: JSON.stringify({
|
|
25546
|
+
postData: JSON.stringify({
|
|
25547
|
+
countryCode,
|
|
25548
|
+
uid,
|
|
25549
|
+
passwd,
|
|
25550
|
+
token,
|
|
25551
|
+
ifencrypt: 1,
|
|
25552
|
+
createGroup: true,
|
|
25553
|
+
options: '{"group": 1}'
|
|
25554
|
+
})
|
|
23502
25555
|
};
|
|
23503
25556
|
}
|
|
23504
25557
|
function buildUidTokenCreateAction(countryCode, uid) {
|
|
@@ -23511,7 +25564,7 @@ function buildUidTokenCreateAction(countryCode, uid) {
|
|
|
23511
25564
|
|
|
23512
25565
|
// dist/transport/tuya/client.js
|
|
23513
25566
|
function genDeviceId() {
|
|
23514
|
-
return
|
|
25567
|
+
return randomBytes5(22).toString("hex");
|
|
23515
25568
|
}
|
|
23516
25569
|
function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
|
|
23517
25570
|
const bigintToBuffer = (n) => {
|
|
@@ -23561,9 +25614,29 @@ var TuyaClient = class {
|
|
|
23561
25614
|
buildRequest(action) {
|
|
23562
25615
|
return buildApiParams(action, { session: this.session, signer: this.signer, env: this.env });
|
|
23563
25616
|
}
|
|
23564
|
-
/**
|
|
25617
|
+
/**
|
|
25618
|
+
* Build + POST an action, returning the parsed envelope. Requires a working signer.
|
|
25619
|
+
*
|
|
25620
|
+
* On an `et=3` reply the envelope's `result` is a base64 AES-128-GCM blob, not an object; this
|
|
25621
|
+
* decrypts it (keyed off the request's `requestId` and the session's `ecode`) and returns the
|
|
25622
|
+
* decrypted inner envelope, so callers read `result` as an object regardless. A non-string `result`
|
|
25623
|
+
* (an error, or an already-plain reply) passes through untouched.
|
|
25624
|
+
*/
|
|
23565
25625
|
async call(action) {
|
|
23566
|
-
|
|
25626
|
+
const params = this.buildRequest(action);
|
|
25627
|
+
const env = await sendApiRequest(params, { endpoint: this.endpoint, http: this.http });
|
|
25628
|
+
return this.decodeReply(env, params.requestId);
|
|
25629
|
+
}
|
|
25630
|
+
/**
|
|
25631
|
+
* Decode an et=3 reply. When `result` is a non-empty string it is the encrypted blob: decrypt and
|
|
25632
|
+
* parse it (a wrong key or tampered blob fails the GCM tag in {@link decryptReply} and throws). The
|
|
25633
|
+
* plaintext is itself the standard `{success, t, result}` envelope, which becomes the returned one.
|
|
25634
|
+
*/
|
|
25635
|
+
decodeReply(env, requestId) {
|
|
25636
|
+
if (typeof env.result !== "string" || env.result === "")
|
|
25637
|
+
return env;
|
|
25638
|
+
const key = deriveBodyKey(requestId, this.session.ecode ?? null);
|
|
25639
|
+
return JSON.parse(decryptReply(key, env.result));
|
|
23567
25640
|
}
|
|
23568
25641
|
/**
|
|
23569
25642
|
* Log into the Tuya cloud from a eufy user id (wire-confirmed from the eufy Security app).
|
|
@@ -23596,7 +25669,7 @@ var TuyaClient = class {
|
|
|
23596
25669
|
const encPasswd = rsaEncryptPassword(password, publicKey, exponent);
|
|
23597
25670
|
const loginRes = await this.call(buildPasswordLoginRegAction(account.countryCode, account.username, encPasswd, token));
|
|
23598
25671
|
if (loginRes.success && loginRes.result?.sid && loginRes.result?.uid) {
|
|
23599
|
-
return { sid: loginRes.result.sid, uid: loginRes.result.uid };
|
|
25672
|
+
return { sid: loginRes.result.sid, uid: loginRes.result.uid, ecode: loginRes.result.ecode };
|
|
23600
25673
|
}
|
|
23601
25674
|
if ((loginRes.errorCode ?? loginRes.errorMsg) === "USER_PASSWD_WRONG")
|
|
23602
25675
|
return "PASSWD_WRONG";
|
|
@@ -23608,18 +25681,18 @@ var TuyaClient = class {
|
|
|
23608
25681
|
if (result === "PASSWD_WRONG") {
|
|
23609
25682
|
throw new Error("tuya login: USER_PASSWD_WRONG on both derived password and fallback '12345678'");
|
|
23610
25683
|
}
|
|
23611
|
-
this.session = { ...this.session, sid: result.sid };
|
|
23612
|
-
return result;
|
|
25684
|
+
this.session = { ...this.session, sid: result.sid, ecode: result.ecode };
|
|
25685
|
+
return { sid: result.sid, uid: result.uid };
|
|
23613
25686
|
}
|
|
23614
25687
|
/**
|
|
23615
|
-
* READ/dump a device's cached data-points (`
|
|
25688
|
+
* READ/dump a device's cached data-points (`smartlife.m.device.cache.dp.get`).
|
|
23616
25689
|
* Builds the request without needing a working signer; the signer is only exercised on send.
|
|
23617
25690
|
*/
|
|
23618
25691
|
async getDeviceDps(devId, dpCacheType) {
|
|
23619
25692
|
return this.call(buildGetDeviceDpsAction(devId, dpCacheType));
|
|
23620
25693
|
}
|
|
23621
25694
|
/**
|
|
23622
|
-
* CONTROL: publish data-points to a device (`
|
|
25695
|
+
* CONTROL: publish data-points to a device (`smartlife.m.device.dp.publish`). `dps` is
|
|
23623
25696
|
* `{ "<dpId>": <value> }`. `gwId` is the gateway/parent id (equals `devId` for a standalone gw).
|
|
23624
25697
|
*
|
|
23625
25698
|
* ⚠️ UNVERIFIED write — refuses to send by default. The `dp.publish` param shape
|
|
@@ -23758,7 +25831,7 @@ var TuyaCommandRouter = class {
|
|
|
23758
25831
|
return this.loginOnce;
|
|
23759
25832
|
}
|
|
23760
25833
|
/**
|
|
23761
|
-
* Fetch a device's cached DPs from the Tuya cloud (`
|
|
25834
|
+
* Fetch a device's cached DPs from the Tuya cloud (`smartlife.m.device.cache.dp.get`) and deliver
|
|
23762
25835
|
* the raw DP map to the caller. Used for initial state hydration after MQTT subscribe — gets the
|
|
23763
25836
|
* last-known state without waiting for the first realtime push.
|
|
23764
25837
|
*
|
|
@@ -23971,7 +26044,7 @@ async function resolveLightEffect(mega, lightId) {
|
|
|
23971
26044
|
}
|
|
23972
26045
|
|
|
23973
26046
|
// dist/transport/push/push-client.js
|
|
23974
|
-
import { EventEmitter as
|
|
26047
|
+
import { EventEmitter as EventEmitter12 } from "node:events";
|
|
23975
26048
|
import tls2 from "node:tls";
|
|
23976
26049
|
|
|
23977
26050
|
// dist/transport/push/proto.js
|
|
@@ -24226,7 +26299,7 @@ var MessageTag;
|
|
|
24226
26299
|
})(MessageTag || (MessageTag = {}));
|
|
24227
26300
|
|
|
24228
26301
|
// dist/transport/push/parser.js
|
|
24229
|
-
import { EventEmitter as
|
|
26302
|
+
import { EventEmitter as EventEmitter11 } from "node:events";
|
|
24230
26303
|
var State;
|
|
24231
26304
|
(function(State2) {
|
|
24232
26305
|
State2[State2["VERSION_TAG_AND_SIZE"] = 0] = "VERSION_TAG_AND_SIZE";
|
|
@@ -24244,7 +26317,7 @@ var TAG_TO_TYPE = {
|
|
|
24244
26317
|
[MessageTag.DataMessageStanza]: "mcs_proto.DataMessageStanza",
|
|
24245
26318
|
[MessageTag.StreamErrorStanza]: "mcs_proto.StreamErrorStanza"
|
|
24246
26319
|
};
|
|
24247
|
-
var McsParser = class extends
|
|
26320
|
+
var McsParser = class extends EventEmitter11 {
|
|
24248
26321
|
data = Buffer.alloc(0);
|
|
24249
26322
|
state = State.VERSION_TAG_AND_SIZE;
|
|
24250
26323
|
messageTag = 0;
|
|
@@ -24422,7 +26495,7 @@ function normalizePushEvent(raw) {
|
|
|
24422
26495
|
raw
|
|
24423
26496
|
};
|
|
24424
26497
|
}
|
|
24425
|
-
var PushClient = class _PushClient extends
|
|
26498
|
+
var PushClient = class _PushClient extends EventEmitter12 {
|
|
24426
26499
|
creds;
|
|
24427
26500
|
logger;
|
|
24428
26501
|
/** Consecutive MCS login rejections tolerated (self-healing propagation) before surfacing an error. */
|
|
@@ -24636,7 +26709,7 @@ var PushClient = class _PushClient extends EventEmitter8 {
|
|
|
24636
26709
|
};
|
|
24637
26710
|
|
|
24638
26711
|
// dist/transport/push/fcm.js
|
|
24639
|
-
import { randomBytes as
|
|
26712
|
+
import { randomBytes as randomBytes6 } from "node:crypto";
|
|
24640
26713
|
import { setTimeout as sleep3 } from "node:timers/promises";
|
|
24641
26714
|
var FCM = {
|
|
24642
26715
|
PROJECT_ID: "batterycam-3250a",
|
|
@@ -24649,7 +26722,7 @@ var FCM = {
|
|
|
24649
26722
|
SDK_VERSION: "a:16.3.1"
|
|
24650
26723
|
};
|
|
24651
26724
|
function generateFid() {
|
|
24652
|
-
const b =
|
|
26725
|
+
const b = randomBytes6(17);
|
|
24653
26726
|
b[0] = 112 + b[0] % 16;
|
|
24654
26727
|
return b.toString("base64url").slice(0, 22);
|
|
24655
26728
|
}
|
|
@@ -25012,7 +27085,14 @@ function deviceClassOf(codec, realtime) {
|
|
|
25012
27085
|
const cls = CLASS_BY_CODEC[codec];
|
|
25013
27086
|
return cls === "camera" && realtime !== "p2p" ? "other" : cls;
|
|
25014
27087
|
}
|
|
27088
|
+
function recordName(raw, deviceClass) {
|
|
27089
|
+
if (deviceClass === "vacuum") {
|
|
27090
|
+
return raw.alias_name || raw.device_alias_name || raw.device_name || void 0;
|
|
27091
|
+
}
|
|
27092
|
+
return raw.device_name ?? raw.device_alias_name ?? raw.alias_name;
|
|
27093
|
+
}
|
|
25015
27094
|
var LIST_REUSE_MS = 5e3;
|
|
27095
|
+
var CLOUD_DPS_MODELS = /* @__PURE__ */ new Set(["T2351"]);
|
|
25016
27096
|
var DeviceRegistry = class {
|
|
25017
27097
|
mega;
|
|
25018
27098
|
onError;
|
|
@@ -25037,6 +27117,8 @@ var DeviceRegistry = class {
|
|
|
25037
27117
|
overlayRefusalReported = false;
|
|
25038
27118
|
/** Per-serial capability cache for {@link capabilitiesForDevice}; `null` = negative hit. */
|
|
25039
27119
|
deviceCapsCache = /* @__PURE__ */ new Map();
|
|
27120
|
+
/** Per-serial cloud data points of an AIoT robot, joined beneath the params in {@link record} — see {@link loadCloudDps}. */
|
|
27121
|
+
cloudDps = /* @__PURE__ */ new Map();
|
|
25040
27122
|
/** Per-serial realtime state, keyed by param id in the device's own namespace (see {@link DeviceRecord.dpParams}). */
|
|
25041
27123
|
dpParams = /* @__PURE__ */ new Map();
|
|
25042
27124
|
/** Callers blocked in {@link awaitRealtimeState}, released by the device's first report. */
|
|
@@ -25131,21 +27213,24 @@ var DeviceRegistry = class {
|
|
|
25131
27213
|
category: raw.category,
|
|
25132
27214
|
params
|
|
25133
27215
|
}).codec;
|
|
27216
|
+
const deviceClass = deviceClassOf(codec, c.realtime);
|
|
25134
27217
|
seen.set(raw.device_sn, {
|
|
25135
27218
|
sn: raw.device_sn,
|
|
25136
|
-
name: raw
|
|
27219
|
+
name: recordName(raw, deviceClass),
|
|
25137
27220
|
model: raw.device_model,
|
|
25138
27221
|
stationSn: resolvedStationSn(raw, raw.device_sn),
|
|
27222
|
+
...typeof raw.device_channel === "number" ? { channel: raw.device_channel } : {},
|
|
25139
27223
|
p2pDid: raw.p2p_did,
|
|
25140
27224
|
params,
|
|
25141
27225
|
paramUpdatedAt,
|
|
25142
27226
|
lastSeenMs: lastSeenMsOf(paramUpdatedAt),
|
|
25143
27227
|
raw,
|
|
25144
|
-
deviceClass
|
|
27228
|
+
deviceClass,
|
|
25145
27229
|
...c
|
|
25146
27230
|
});
|
|
25147
27231
|
}
|
|
25148
27232
|
}
|
|
27233
|
+
await this.loadCloudDps(seen);
|
|
25149
27234
|
this.lastRefreshPartial = partial;
|
|
25150
27235
|
if (partial) {
|
|
25151
27236
|
for (const prev of this.devices)
|
|
@@ -25236,9 +27321,46 @@ var DeviceRegistry = class {
|
|
|
25236
27321
|
}
|
|
25237
27322
|
return dev;
|
|
25238
27323
|
}
|
|
27324
|
+
/**
|
|
27325
|
+
* Hold an AIoT robot's cloud data points: the cloud's last-known value of each one it has reported,
|
|
27326
|
+
* from `getDeviceRelationList`'s per-device `dps`.
|
|
27327
|
+
*
|
|
27328
|
+
* The device list carries only some of a robot's data points, so a point the robot publishes on change
|
|
27329
|
+
* alone (its scene list, its schedules, its consumables) has no value until it next reports. The
|
|
27330
|
+
* relation list holds every one, for an account the robot is shared with as well as its owner.
|
|
27331
|
+
*
|
|
27332
|
+
* Kept apart from the device list's params rather than merged into them: those are what a poll diffs,
|
|
27333
|
+
* and a diff retires the realtime report for that id, so a cloud value lagging the robot's last report
|
|
27334
|
+
* would replace it. {@link record} joins this beneath both halves. A failure keeps what was held.
|
|
27335
|
+
*
|
|
27336
|
+
* Limited to {@link CLOUD_DPS_MODELS}, the models whose relation-list `dps` are checked to carry the
|
|
27337
|
+
* DP numbers their realtime reports use.
|
|
27338
|
+
*/
|
|
27339
|
+
async loadCloudDps(seen) {
|
|
27340
|
+
const robots = [...seen.values()].filter((d) => CLOUD_DPS_MODELS.has(d.model ?? ""));
|
|
27341
|
+
if (!robots.length)
|
|
27342
|
+
return;
|
|
27343
|
+
let res;
|
|
27344
|
+
try {
|
|
27345
|
+
res = await this.mega.getDeviceRelationList();
|
|
27346
|
+
} catch (e) {
|
|
27347
|
+
if (e instanceof SessionExpiredError)
|
|
27348
|
+
throw e;
|
|
27349
|
+
this.logger.debug(`[registry] device relation list unavailable: ${e?.message ?? e}`);
|
|
27350
|
+
return;
|
|
27351
|
+
}
|
|
27352
|
+
const wanted = new Set(robots.map((d) => d.sn));
|
|
27353
|
+
for (const entry of res.devices ?? []) {
|
|
27354
|
+
const sn = entry?.device?.device_sn;
|
|
27355
|
+
const dps = parseAiotDpReport({ payload: entry?.device?.dps });
|
|
27356
|
+
if (sn && wanted.has(sn) && dps)
|
|
27357
|
+
this.cloudDps.set(sn, dps);
|
|
27358
|
+
}
|
|
27359
|
+
}
|
|
25239
27360
|
/**
|
|
25240
27361
|
* 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.
|
|
27362
|
+
* overlays a fresh `get_device_param_list` when that call is available to this account. An AIoT robot's
|
|
27363
|
+
* cloud data points ({@link loadCloudDps}) sit beneath both, filling only ids neither carries.
|
|
25242
27364
|
*
|
|
25243
27365
|
* The overlay is **owner-gated** — a shared or member account is refused it for every device, permanently,
|
|
25244
27366
|
* which {@link OWNER_ONLY_CODE} identifies — so when it is unavailable the device list is the source
|
|
@@ -25267,7 +27389,7 @@ var DeviceRegistry = class {
|
|
|
25267
27389
|
let dev = this.devices.find((d) => d.sn === sn);
|
|
25268
27390
|
if (!dev)
|
|
25269
27391
|
throw new Error(`device ${sn} not found (have: ${this.devices.map((d) => d.sn).join(", ")})`);
|
|
25270
|
-
const params = { ...dev.params ?? {} };
|
|
27392
|
+
const params = { ...this.cloudDps.get(sn), ...dev.params ?? {} };
|
|
25271
27393
|
const paramUpdatedAt = { ...dev.paramUpdatedAt ?? {} };
|
|
25272
27394
|
if (!this.overlayRefused.has(sn)) {
|
|
25273
27395
|
try {
|
|
@@ -25560,6 +27682,8 @@ function tuyaDevIdFrom(raw) {
|
|
|
25560
27682
|
var DEFAULT_POLL_MS = 6e5;
|
|
25561
27683
|
var DEFAULT_PREWARM_TIERS = ["wired", "battery"];
|
|
25562
27684
|
var SESSION_RECYCLE_WAIT_MS = 5e3;
|
|
27685
|
+
var PUSH_RETRY_MIN_MS = 3e4;
|
|
27686
|
+
var PUSH_RETRY_MAX_MS = 6e5;
|
|
25563
27687
|
var RealtimeStartupSupersededError = class extends Error {
|
|
25564
27688
|
};
|
|
25565
27689
|
var RefreshWindowClosedError = class extends Error {
|
|
@@ -25575,7 +27699,7 @@ function readinessSnapshot(readiness, state = readiness.state) {
|
|
|
25575
27699
|
wiredP2p: Object.freeze({ ...readiness.wiredP2p })
|
|
25576
27700
|
});
|
|
25577
27701
|
}
|
|
25578
|
-
var EufyMega = class extends
|
|
27702
|
+
var EufyMega = class extends EventEmitter13 {
|
|
25579
27703
|
opts;
|
|
25580
27704
|
mega;
|
|
25581
27705
|
/**
|
|
@@ -25635,6 +27759,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25635
27759
|
prewarmTiers;
|
|
25636
27760
|
/** Transport-side owner of the P2P sessions + all wire senders. */
|
|
25637
27761
|
p2p;
|
|
27762
|
+
/** Transport-side owner of the T9000 station sessions (sibling of {@link p2p}). */
|
|
27763
|
+
rtc;
|
|
25638
27764
|
/** Transport-side owner of the secure-MQTT ff09 lock/garage command path (sibling of {@link p2p}). */
|
|
25639
27765
|
mqtt;
|
|
25640
27766
|
/** Transport-side owner of the legacy Tuya REST command path for non-AIoT vacuums (G-series). */
|
|
@@ -25656,6 +27782,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25656
27782
|
mapStores = /* @__PURE__ */ new Map();
|
|
25657
27783
|
/** Re-armed after each cloud-param poll; cancelled by {@link disconnect}. */
|
|
25658
27784
|
pollTimer = new Timer();
|
|
27785
|
+
/** Armed while a failed push bring-up waits to be retried; cancelled by {@link disconnect}. */
|
|
27786
|
+
pushRetryTimer = new Timer();
|
|
25659
27787
|
constructor(opts) {
|
|
25660
27788
|
super();
|
|
25661
27789
|
this.opts = opts;
|
|
@@ -25695,6 +27823,14 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25695
27823
|
onLevel2Ready: (sn, cipherId) => this.emit("p2pLevel2Ready", { stationSn: sn, cipherId }),
|
|
25696
27824
|
onFrame: (stationSn, f) => this.onP2PFrame(stationSn, f)
|
|
25697
27825
|
});
|
|
27826
|
+
this.rtc = new RtcCommandRouter({
|
|
27827
|
+
identity: () => this.mega.rtcIdentity(),
|
|
27828
|
+
shard: () => this.mega.rtcShard,
|
|
27829
|
+
country: opts.countryCode,
|
|
27830
|
+
logger: opts.logger,
|
|
27831
|
+
ffmpegPath: opts.ffmpegPath,
|
|
27832
|
+
onError: (e) => this.reportError(e)
|
|
27833
|
+
});
|
|
25698
27834
|
this.mqtt = new MqttCommandRouter({
|
|
25699
27835
|
mega: this.mega,
|
|
25700
27836
|
logger: opts.logger,
|
|
@@ -25960,13 +28096,22 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
25960
28096
|
*
|
|
25961
28097
|
* The evidence set is widened, never replaced: the ids come back through the cloud record, and a
|
|
25962
28098
|
* record that omits a realtime-only id would otherwise un-know it and re-trigger on the next report.
|
|
28099
|
+
*
|
|
28100
|
+
* The device is re-resolved against the fresh record before binding. A property gated on a
|
|
28101
|
+
* realtime-only param enters the schema only once that param is evidence, and the value that made it
|
|
28102
|
+
* evidence was stored before the property existed, so the record's realtime params are applied again
|
|
28103
|
+
* to land it under the property's name.
|
|
25963
28104
|
*/
|
|
25964
28105
|
async rebindReads(sn) {
|
|
25965
28106
|
const dev = this.liveDevices.get(sn)?.deref();
|
|
25966
28107
|
if (!dev)
|
|
25967
28108
|
return;
|
|
25968
28109
|
try {
|
|
25969
|
-
const
|
|
28110
|
+
const rec = await this.registry.record(sn);
|
|
28111
|
+
dev.reresolve(rec);
|
|
28112
|
+
if (rec.dpParams)
|
|
28113
|
+
this.applyAndAnnounce(dev, rec.dpParams);
|
|
28114
|
+
const ctx = await this.commandContext(sn, rec);
|
|
25970
28115
|
dev.bindActions(ctx, this.commandSinkFor(sn), this.mediaProviderFor(sn), this.ff09SettingsReaderFor(sn, ctx), rawDpCodec);
|
|
25971
28116
|
this.boundParamIds.set(sn, /* @__PURE__ */ new Set([...this.boundParamIds.get(sn) ?? [], ...ctx.paramIds]));
|
|
25972
28117
|
this.emit("deviceState", this.deviceState(sn));
|
|
@@ -26026,6 +28171,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26026
28171
|
* only "differs from what was read before", which state already on hand can satisfy spuriously — and the
|
|
26027
28172
|
* caller that has no expectation is the push path, where the signal itself is the news that a re-read is owed.
|
|
26028
28173
|
*
|
|
28174
|
+
* Only a write's observation throws at the deadline; a push with nothing moved answers `false`.
|
|
28175
|
+
*
|
|
26029
28176
|
* The cloud half is asked for through {@link DeviceRegistry.refreshedList}, never by fetching the account
|
|
26030
28177
|
* list outright. The fetch is account-wide — one house list plus one device list per house — so a param that
|
|
26031
28178
|
* never converges would otherwise spend a whole burst of those every iteration of this loop, and concurrent
|
|
@@ -26076,6 +28223,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26076
28223
|
if (refresh.expected !== void 0 && settled())
|
|
26077
28224
|
return true;
|
|
26078
28225
|
}
|
|
28226
|
+
if (refresh.expected === void 0)
|
|
28227
|
+
return false;
|
|
26079
28228
|
throw new StateConvergenceError({
|
|
26080
28229
|
sn,
|
|
26081
28230
|
property: refresh.property,
|
|
@@ -26121,10 +28270,24 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26121
28270
|
recycleStandaloneSession(sn) {
|
|
26122
28271
|
const releaseWrite = new Promise((resolve) => void setTimeout(resolve, SESSION_RECYCLE_WAIT_MS).unref?.());
|
|
26123
28272
|
return Promise.race([
|
|
26124
|
-
this.p2p.resetStandaloneSession(sn),
|
|
28273
|
+
this.p2p.resetStandaloneSession(sn).finally(() => this.reopenWiredStation(sn)),
|
|
26125
28274
|
releaseWrite.then(() => this.opts.logger?.debug(`[session ${sn}] recycle still waiting on an attached viewer \u2014 releasing the write that asked for it`))
|
|
26126
28275
|
]);
|
|
26127
28276
|
}
|
|
28277
|
+
/** Reopen a wired station that {@link recycleStandaloneSession} closed, while the generation that warmed it is current. */
|
|
28278
|
+
reopenWiredStation(sn) {
|
|
28279
|
+
const generation = this.realtimeGeneration;
|
|
28280
|
+
if (generation?.epoch !== this.realtimeEpoch)
|
|
28281
|
+
return;
|
|
28282
|
+
if (this.p2p.stationKeyOf(sn) !== sn || !this.registry.p2pDevices().some((d) => d.sn === sn))
|
|
28283
|
+
return;
|
|
28284
|
+
if (this.stationPower(sn) !== "wired")
|
|
28285
|
+
return;
|
|
28286
|
+
void this.p2p.ensureStation(sn, generation.abort.signal).catch((error) => {
|
|
28287
|
+
if (!generation.abort.signal.aborted)
|
|
28288
|
+
this.reportError(error);
|
|
28289
|
+
});
|
|
28290
|
+
}
|
|
26128
28291
|
/**
|
|
26129
28292
|
* Whether the DECODED property now reads what the write asked for.
|
|
26130
28293
|
*
|
|
@@ -26381,6 +28544,28 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26381
28544
|
}
|
|
26382
28545
|
};
|
|
26383
28546
|
}
|
|
28547
|
+
/**
|
|
28548
|
+
* The station a device's commands go through: the device itself, or the HomeBase the registry links it
|
|
28549
|
+
* to. Answers the target record, the station record and serial, the station's `device_type`, and the
|
|
28550
|
+
* `t9000` / `t8n00` family flags.
|
|
28551
|
+
*/
|
|
28552
|
+
stationOf(sn) {
|
|
28553
|
+
const devices = this.registry.list();
|
|
28554
|
+
const target = devices.find((d) => d.sn === sn);
|
|
28555
|
+
const stationSn = target?.stationSn || sn;
|
|
28556
|
+
const station = devices.find((d) => d.sn === stationSn);
|
|
28557
|
+
const raw = station?.raw ?? {};
|
|
28558
|
+
const deviceType = typeof raw.device_type === "number" ? raw.device_type : void 0;
|
|
28559
|
+
const family = { deviceType, model: station?.model };
|
|
28560
|
+
return { target, station, stationSn, raw, deviceType, t9000: isStation9000(family), t8n00: isNvrT8N00(family) };
|
|
28561
|
+
}
|
|
28562
|
+
/** The vendor DeviceType of the station a device hangs off; `undefined` for a station itself. */
|
|
28563
|
+
stationDeviceTypeOf(sn) {
|
|
28564
|
+
const st = this.stationOf(sn);
|
|
28565
|
+
if (!st.station || st.stationSn === sn)
|
|
28566
|
+
return void 0;
|
|
28567
|
+
return st.deviceType;
|
|
28568
|
+
}
|
|
26384
28569
|
/**
|
|
26385
28570
|
* Combine explicit P2P media with the optional passive push-thumbnail provider.
|
|
26386
28571
|
*
|
|
@@ -26391,7 +28576,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26391
28576
|
* current. With nothing retained the refusal stands.
|
|
26392
28577
|
*/
|
|
26393
28578
|
mediaProviderFor(sn) {
|
|
26394
|
-
const media = this.p2p.mediaProviderFor(sn);
|
|
28579
|
+
const media = this.rtcMediaFor(sn) ?? this.p2p.mediaProviderFor(sn);
|
|
26395
28580
|
const cache = this.storedImages;
|
|
26396
28581
|
if (!cache)
|
|
26397
28582
|
return media;
|
|
@@ -26428,8 +28613,10 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26428
28613
|
* The `eufy_life` DP writes (smart lights) are secure-MQTT-only. `aiot-dp` routes to either the
|
|
26429
28614
|
* Anker AIoT MQTT stack or the legacy Tuya REST router depending on the device's category
|
|
26430
28615
|
* (`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
|
-
*
|
|
28616
|
+
* kind and stays transport-agnostic; only the facade sees both sides and decides here. A T9000 or T8N00
|
|
28617
|
+
* station and the devices attached to it go over RTC, with an attached device's command refused
|
|
28618
|
+
* unless its channel resolves back to it on the station. T8N00 uses full SDP and admits direct and
|
|
28619
|
+
* relay candidates; T9000 retains compact SDP and relay-only candidates. Everything else is P2P.
|
|
26433
28620
|
*/
|
|
26434
28621
|
routeCommand(sn, cmd) {
|
|
26435
28622
|
if (cmd.kind === "ff09-actuate" || cmd.kind === "ff09-autolock" || cmd.kind === "ff09-setting-toggle") {
|
|
@@ -26449,8 +28636,42 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26449
28636
|
return this.tuya.dispatchCommand(sn, cmd);
|
|
26450
28637
|
return this.mqtt.dispatchCommand(sn, cmd);
|
|
26451
28638
|
}
|
|
28639
|
+
const route = this.rtcRouteFor(sn);
|
|
28640
|
+
if (route) {
|
|
28641
|
+
if (route.attached && this.registry.serialForFrame(route.stationSn, cmd.channel) !== sn)
|
|
28642
|
+
return Promise.reject(new Error("RTC command requires an unambiguous attached-device channel"));
|
|
28643
|
+
return this.rtc.dispatchCommand(route, cmd);
|
|
28644
|
+
}
|
|
26452
28645
|
return this.p2p.dispatchCommand(sn, cmd);
|
|
26453
28646
|
}
|
|
28647
|
+
/**
|
|
28648
|
+
* The RTC route for a T9000 or T8N00 station or a device attached to one, decided by the station's model
|
|
28649
|
+
* classification; `undefined` for any other device. A T8N00 route carries its native `call` signalling. The admin id is the station's
|
|
28650
|
+
* `member.admin_user_id`, else the logged-in user.
|
|
28651
|
+
*/
|
|
28652
|
+
rtcRouteFor(sn) {
|
|
28653
|
+
const { target, station, stationSn, raw, t9000, t8n00: native } = this.stationOf(sn);
|
|
28654
|
+
if (!target || !station || !(t9000 || native))
|
|
28655
|
+
return void 0;
|
|
28656
|
+
const member = raw.member?.admin_user_id;
|
|
28657
|
+
const adminUserId = typeof member === "string" && member ? member : void 0;
|
|
28658
|
+
const base = { stationSn, adminUserId, attached: stationSn !== sn };
|
|
28659
|
+
return native ? { ...base, signalingMode: "call", iceTransportPolicy: "all" } : base;
|
|
28660
|
+
}
|
|
28661
|
+
/**
|
|
28662
|
+
* The RTC media provider for a camera attached to a T9000 whose `device_channel` resolves back to it on
|
|
28663
|
+
* the station; `undefined` otherwise, including a camera behind a T8N00, whose route carries commands only.
|
|
28664
|
+
*/
|
|
28665
|
+
rtcMediaFor(sn) {
|
|
28666
|
+
const route = this.rtcRouteFor(sn);
|
|
28667
|
+
if (!route?.attached || route.signalingMode === "call")
|
|
28668
|
+
return void 0;
|
|
28669
|
+
const raw = this.registry.list().find((d) => d.sn === sn)?.raw ?? {};
|
|
28670
|
+
const channel = typeof raw.device_channel === "number" ? raw.device_channel : void 0;
|
|
28671
|
+
if (channel === void 0 || this.registry.serialForFrame(route.stationSn, channel) !== sn)
|
|
28672
|
+
return void 0;
|
|
28673
|
+
return this.rtc.mediaProviderFor({ ...route, cameraSn: sn, channel });
|
|
28674
|
+
}
|
|
26454
28675
|
/**
|
|
26455
28676
|
* List + classify devices across all houses (mega API). Each device is tagged with its API backend
|
|
26456
28677
|
* + realtime transport. Camera/HomeBase records still appear here for inventory; driving them is
|
|
@@ -26683,13 +28904,38 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26683
28904
|
this.schedulePoll();
|
|
26684
28905
|
const failures = readiness.push.failed + readiness.mqtt.failed + readiness.wiredP2p.failed;
|
|
26685
28906
|
this.settleRealtimeGeneration(generation, failures || startupFailed ? "partial" : "ready");
|
|
28907
|
+
if (readiness.push.failed)
|
|
28908
|
+
this.retryPush(generation);
|
|
26686
28909
|
} catch (e) {
|
|
26687
28910
|
this.reportError(e);
|
|
26688
28911
|
readiness.push.failed += readiness.push.pending;
|
|
26689
28912
|
readiness.push.pending = 0;
|
|
26690
28913
|
this.settleRealtimeGeneration(generation, epoch === this.realtimeEpoch ? "partial" : "superseded");
|
|
28914
|
+
if (epoch === this.realtimeEpoch)
|
|
28915
|
+
this.retryPush(generation);
|
|
26691
28916
|
}
|
|
26692
28917
|
}
|
|
28918
|
+
/**
|
|
28919
|
+
* Bring push up again after the generation's startup attempt failed, backing off between attempts
|
|
28920
|
+
* until one connects, the generation is superseded, or it is aborted by {@link disconnect}.
|
|
28921
|
+
*
|
|
28922
|
+
* The MCS socket reconnects on its own only once it has connected. The settled
|
|
28923
|
+
* {@link RealtimeReadiness} is a startup snapshot and is not revised when a retry connects.
|
|
28924
|
+
*/
|
|
28925
|
+
retryPush(generation, delayMs = PUSH_RETRY_MIN_MS) {
|
|
28926
|
+
this.pushRetryTimer.arm(delayMs, () => {
|
|
28927
|
+
this.startPush(generation.abort.signal).then((client) => {
|
|
28928
|
+
if (generation.epoch !== this.realtimeEpoch)
|
|
28929
|
+
return client.close();
|
|
28930
|
+
this.pushClient = client;
|
|
28931
|
+
}, (error) => {
|
|
28932
|
+
if (generation.epoch !== this.realtimeEpoch)
|
|
28933
|
+
return;
|
|
28934
|
+
this.reportError(error);
|
|
28935
|
+
this.retryPush(generation, Math.min(delayMs * 2, PUSH_RETRY_MAX_MS));
|
|
28936
|
+
});
|
|
28937
|
+
});
|
|
28938
|
+
}
|
|
26693
28939
|
/** Resolve a generation with an immutable count snapshot; later transport completions are ignored. */
|
|
26694
28940
|
settleRealtimeGeneration(generation, state) {
|
|
26695
28941
|
if (generation.settled)
|
|
@@ -26918,6 +29164,17 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26918
29164
|
await this.getDevices();
|
|
26919
29165
|
await this.p2p.ensureStation(this.p2p.stationKeyOf(deviceSn), signal);
|
|
26920
29166
|
}
|
|
29167
|
+
/**
|
|
29168
|
+
* Keep a T9000 station's RTC command session open and warm, so a command fires without a cold reconnect.
|
|
29169
|
+
* Answers the hold's disposer; throws for any other station, and while logged out.
|
|
29170
|
+
*/
|
|
29171
|
+
keepStationWarm(stationSn) {
|
|
29172
|
+
const { t9000 } = this.stationOf(stationSn);
|
|
29173
|
+
const route = this.rtcRouteFor(stationSn);
|
|
29174
|
+
if (!t9000 || !route)
|
|
29175
|
+
throw new Error(`rtc: ${stationSn} has no warm command session`);
|
|
29176
|
+
return this.rtc.keepWarm(route);
|
|
29177
|
+
}
|
|
26921
29178
|
/** Eagerly open P2P sessions for WIRED stations only (persistent — they don't drain). Battery
|
|
26922
29179
|
* stations stay closed until an on-demand open. Best-effort per station. */
|
|
26923
29180
|
async warmWiredP2P(readiness = createPlaneReadiness(), signal) {
|
|
@@ -27287,16 +29544,20 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27287
29544
|
await this.commandSinkFor(sn).dispatch(cmd);
|
|
27288
29545
|
}
|
|
27289
29546
|
/**
|
|
27290
|
-
* Restart a HomeBase.
|
|
29547
|
+
* Restart a station: a HomeBase, or a camera-family device that is its own station.
|
|
27291
29548
|
*
|
|
27292
|
-
*
|
|
27293
|
-
*
|
|
27294
|
-
*
|
|
29549
|
+
* The restart is `RESTART_HUB` on the station's broadcast channel, so it reaches whatever owns the
|
|
29550
|
+
* P2P session. A standalone camera or doorbell owns its own session and is the station it restarts.
|
|
29551
|
+
* A camera attached to a HomeBase is not — the same frame would restart its HomeBase — so its serial
|
|
29552
|
+
* throws, as does any non-camera serial, rather than doing nothing. The device drops its connection
|
|
29553
|
+
* and returns after a minute or two; a HomeBase takes everything behind it offline meanwhile.
|
|
29554
|
+
* Verified on real hardware against a HomeBase and a standalone indoor camera.
|
|
27295
29555
|
*/
|
|
27296
29556
|
async reboot(sn) {
|
|
27297
29557
|
const ctx = await this.commandContext(sn);
|
|
27298
|
-
|
|
27299
|
-
|
|
29558
|
+
const standaloneCamera = ctx.codec === "camera" && this.p2p.stationKeyOf(sn) === sn;
|
|
29559
|
+
if (!isHomeBase({ deviceType: ctx.deviceType, model: ctx.model }) && !standaloneCamera) {
|
|
29560
|
+
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
29561
|
}
|
|
27301
29562
|
await this.p2p.rebootStation(sn);
|
|
27302
29563
|
}
|
|
@@ -27328,8 +29589,9 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27328
29589
|
return EMPTY_DP_CATALOG;
|
|
27329
29590
|
}
|
|
27330
29591
|
}
|
|
27331
|
-
|
|
27332
|
-
|
|
29592
|
+
/** The {@link CommandContext} for a device, built from `known` when that record is already in hand. */
|
|
29593
|
+
async commandContext(sn, known) {
|
|
29594
|
+
const rec = known ?? await this.registry.record(sn);
|
|
27333
29595
|
const dev = this.registry.require(sn);
|
|
27334
29596
|
const raw = dev.raw ?? {};
|
|
27335
29597
|
const rawChannel = raw.device_channel;
|
|
@@ -27366,6 +29628,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27366
29628
|
hasP2p: P2PCommandRouter.claimsDevice(dev),
|
|
27367
29629
|
// Topology as the record states it: a parent that isn't the device itself means HomeBase-attached.
|
|
27368
29630
|
homeBaseAttached: !!raw.parent_sn && raw.parent_sn !== dev.sn,
|
|
29631
|
+
stationDeviceType: this.stationDeviceTypeOf(sn),
|
|
27369
29632
|
dpCatalog
|
|
27370
29633
|
};
|
|
27371
29634
|
}
|
|
@@ -27540,8 +29803,10 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
27540
29803
|
*/
|
|
27541
29804
|
async teardownRealtime() {
|
|
27542
29805
|
this.pollTimer.cancel();
|
|
29806
|
+
this.pushRetryTimer.cancel();
|
|
27543
29807
|
this.lastStateAnnounced.clear();
|
|
27544
29808
|
await this.closeMqttTransports();
|
|
29809
|
+
this.rtc.close();
|
|
27545
29810
|
await this.p2p.closeAll();
|
|
27546
29811
|
this.pushClient?.close();
|
|
27547
29812
|
this.pushClient = void 0;
|
|
@@ -28046,7 +30311,7 @@ var SolixClient = class _SolixClient {
|
|
|
28046
30311
|
};
|
|
28047
30312
|
|
|
28048
30313
|
// dist/transport/mqtt/solix-mqtt.js
|
|
28049
|
-
import { EventEmitter as
|
|
30314
|
+
import { EventEmitter as EventEmitter14 } from "node:events";
|
|
28050
30315
|
var SOLIX_METER_FIELD_NAMES = {
|
|
28051
30316
|
168: "meterPowerL1",
|
|
28052
30317
|
// Anker Modbus: primary_phase_1_active_power
|
|
@@ -28224,7 +30489,7 @@ function addSolarbankScalars(frame, out) {
|
|
|
28224
30489
|
out.gridExportLimit = df.readUInt16LE(5);
|
|
28225
30490
|
}
|
|
28226
30491
|
}
|
|
28227
|
-
var SolixMqtt = class extends
|
|
30492
|
+
var SolixMqtt = class extends EventEmitter14 {
|
|
28228
30493
|
transport;
|
|
28229
30494
|
appName;
|
|
28230
30495
|
userId;
|
|
@@ -28584,6 +30849,7 @@ __export(tuya_exports, {
|
|
|
28584
30849
|
genDeviceId: () => genDeviceId,
|
|
28585
30850
|
isoToDialCode: () => isoToDialCode,
|
|
28586
30851
|
resolveCountryCode: () => resolveCountryCode,
|
|
30852
|
+
resolveSignKey: () => resolveSignKey,
|
|
28587
30853
|
sendApiRequest: () => sendApiRequest,
|
|
28588
30854
|
swapMd5: () => swapMd5,
|
|
28589
30855
|
tuyaUsername: () => tuyaUsername
|