@mega-yfue/eufy-sdk 0.3.0-beta.0 → 0.3.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/eufy-mega.d.ts +2 -1
- package/dist/client/types.d.ts +3 -2
- package/dist/index.js +291 -71
- package/dist/index.js.map +3 -3
- package/dist/model/capabilities/battery.d.ts +5 -0
- package/dist/transport/http/mega-client.d.ts +9 -4
- package/dist/transport/mqtt/secure-mqtt.d.ts +10 -1
- package/dist/transport/p2p/p2p-session.d.ts +37 -9
- package/dist/transport/push/push-client.d.ts +11 -0
- package/dist/transport/push/types.d.ts +8 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1102,8 +1102,9 @@ var SessionExpiredError = class extends Error {
|
|
|
1102
1102
|
/**
|
|
1103
1103
|
* Whether this rejection landed inside that bar — a token replaced recently and rejected again since.
|
|
1104
1104
|
*
|
|
1105
|
-
*
|
|
1106
|
-
*
|
|
1105
|
+
* That is the shape displacement by another client signed in on this account has, where replacing the
|
|
1106
|
+
* token again only trades one login for another. It is not proof of one, since a cloud refusing to
|
|
1107
|
+
* re-issue the session looks the same.
|
|
1107
1108
|
*/
|
|
1108
1109
|
contended;
|
|
1109
1110
|
constructor(message, opts = {}) {
|
|
@@ -1149,7 +1150,7 @@ var LoginStatus = {
|
|
|
1149
1150
|
var REAUTH_HOLD_OFF_MS = 6e4;
|
|
1150
1151
|
var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
|
|
1151
1152
|
var REAUTH_STABLE_MS = 10 * 6e4;
|
|
1152
|
-
var CONTENDED_SESSION_HINT = "another client signed in
|
|
1153
|
+
var CONTENDED_SESSION_HINT = "the token was rejected again soon after it was last replaced; one possible cause is another client signed in with the same account and device identity, since the cloud holds about one session per pair and each login ends the other's (where that is another SDK install, give each its own openudid)";
|
|
1153
1154
|
function tokenRejected(code, msg) {
|
|
1154
1155
|
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 ?? "");
|
|
1155
1156
|
}
|
|
@@ -1163,6 +1164,8 @@ var MegaHttpClient = class {
|
|
|
1163
1164
|
sessionKey;
|
|
1164
1165
|
/** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
|
|
1165
1166
|
sessionKeys = /* @__PURE__ */ new Map();
|
|
1167
|
+
/** The in-flight key exchanges, by host and the token they carry — see {@link ensureSessionKey}. */
|
|
1168
|
+
keyExchanges = /* @__PURE__ */ new Map();
|
|
1166
1169
|
/**
|
|
1167
1170
|
* The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
|
|
1168
1171
|
* Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
|
|
@@ -1384,6 +1387,20 @@ var MegaHttpClient = class {
|
|
|
1384
1387
|
return cached2;
|
|
1385
1388
|
}
|
|
1386
1389
|
const host = isEufylife ? targetHost : this.bootstrapDomain ?? `app-openapi-${this.region}.eufy.com`;
|
|
1390
|
+
const flight = `${host}
|
|
1391
|
+
${this.auth_?.authToken ?? ""}`;
|
|
1392
|
+
const joining = this.keyExchanges.get(flight);
|
|
1393
|
+
if (joining)
|
|
1394
|
+
return joining;
|
|
1395
|
+
const exchange = this.exchangeSessionKey(host, isEufylife).finally(() => {
|
|
1396
|
+
if (this.keyExchanges.get(flight) === exchange)
|
|
1397
|
+
this.keyExchanges.delete(flight);
|
|
1398
|
+
});
|
|
1399
|
+
this.keyExchanges.set(flight, exchange);
|
|
1400
|
+
return exchange;
|
|
1401
|
+
}
|
|
1402
|
+
/** One key exchange against `host`, installed as that host's key. */
|
|
1403
|
+
async exchangeSessionKey(host, isEufylife) {
|
|
1387
1404
|
const kxPath = isEufylife ? "/v3/openapi/oauth/key/exchange" : "/openapi/oauth/key/exchange";
|
|
1388
1405
|
const prep = prepareKeyExchange(isEufylife ? EUFYLIFE_LOCAL_KEY_HEX : void 0);
|
|
1389
1406
|
const res = await this.httpPost(`https://${host}${kxPath}`, { client_public_key: prep.encryptedClientPublicKey }, {
|
|
@@ -1399,7 +1416,7 @@ var MegaHttpClient = class {
|
|
|
1399
1416
|
}
|
|
1400
1417
|
const entry = finishKeyExchange(prep, env.data.server_public_key);
|
|
1401
1418
|
if (isEufylife)
|
|
1402
|
-
this.sessionKeys.set(
|
|
1419
|
+
this.sessionKeys.set(host, entry);
|
|
1403
1420
|
else
|
|
1404
1421
|
this.sessionKey = entry;
|
|
1405
1422
|
return entry;
|
|
@@ -1470,9 +1487,10 @@ var MegaHttpClient = class {
|
|
|
1470
1487
|
const identityError = authed && (last?.code === EufyCloudErrorCode.IDENTITY_KEY_STALE || /get identity error|identity error/i.test(last?.msg ?? ""));
|
|
1471
1488
|
if (identityError && !retry.identity && this.auth_) {
|
|
1472
1489
|
this.logger.debug("[mega] identity error \u2192 re-exchanging session key and retrying");
|
|
1473
|
-
if (host.includes(".eufylife.com"))
|
|
1474
|
-
this.sessionKeys.
|
|
1475
|
-
|
|
1490
|
+
if (host.includes(".eufylife.com")) {
|
|
1491
|
+
if (this.sessionKeys.get(host) === entry)
|
|
1492
|
+
this.sessionKeys.delete(host);
|
|
1493
|
+
} else if (this.sessionKey === entry)
|
|
1476
1494
|
this.sessionKey = void 0;
|
|
1477
1495
|
try {
|
|
1478
1496
|
await this.ensureSessionKey(host);
|
|
@@ -1832,14 +1850,14 @@ var MegaHttpClient = class {
|
|
|
1832
1850
|
* like the same device — and the cloud keeps one session per device. Each finds its token rejected, replaces
|
|
1833
1851
|
* it, and evicts the other: an unbounded login war, silent, and repeated logins are exactly what makes an
|
|
1834
1852
|
* account start demanding captchas. The first replacement is immediate, because a token displaced once is
|
|
1835
|
-
* the ordinary case; a second one soon after
|
|
1836
|
-
* and a caller is told
|
|
1853
|
+
* the ordinary case; a second one soon after has the shape of contention rather than expiry, so the wait grows
|
|
1854
|
+
* and a caller is told what was seen instead of being served a fight.
|
|
1837
1855
|
*/
|
|
1838
1856
|
recoveryDue() {
|
|
1839
1857
|
const remaining = this.holdOffRemainingMs();
|
|
1840
1858
|
if (remaining === 0)
|
|
1841
1859
|
return true;
|
|
1842
|
-
this.logger.warn(`[mega]
|
|
1860
|
+
this.logger.warn(`[mega] holding off ${Math.round(remaining / 1e3)}s more before replacing the token again (last replaced ${Math.round((Date.now() - this.lastRecoveryAt) / 1e3)}s ago): ${CONTENDED_SESSION_HINT}`);
|
|
1843
1861
|
return false;
|
|
1844
1862
|
}
|
|
1845
1863
|
/**
|
|
@@ -2113,6 +2131,13 @@ function isNotAuthorized(err) {
|
|
|
2113
2131
|
var SecureMqtt = class extends EventEmitter {
|
|
2114
2132
|
kind = "smqtt";
|
|
2115
2133
|
client;
|
|
2134
|
+
/**
|
|
2135
|
+
* The in-flight or established connect. A client ID is exclusive at the broker, so a second mqtt.js
|
|
2136
|
+
* client under the same ID evicts the first, which reconnects and evicts it back, forever; `connect()`
|
|
2137
|
+
* therefore hands a later caller this same attempt instead of opening a rival. Cleared when the
|
|
2138
|
+
* attempt fails and on `disconnect()`, so a caller can still retry or deliberately reconnect.
|
|
2139
|
+
*/
|
|
2140
|
+
connecting;
|
|
2116
2141
|
o;
|
|
2117
2142
|
logger;
|
|
2118
2143
|
constructor(opts) {
|
|
@@ -2123,11 +2148,19 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
2123
2148
|
get id() {
|
|
2124
2149
|
return this.o.clientId ?? this.o.credentials.thing_name ?? "";
|
|
2125
2150
|
}
|
|
2151
|
+
/** Connect, joining the attempt in `connecting` when one is already opening or open. */
|
|
2152
|
+
async connect() {
|
|
2153
|
+
this.connecting ??= this.open().catch((error) => {
|
|
2154
|
+
this.connecting = void 0;
|
|
2155
|
+
throw error;
|
|
2156
|
+
});
|
|
2157
|
+
return await this.connecting;
|
|
2158
|
+
}
|
|
2126
2159
|
/**
|
|
2127
2160
|
* Open the broker connection, resolving once it is established. Pinned to a broker instance's IP, or
|
|
2128
2161
|
* to the plain hostname; only the former needs its own TLS shape, see `./bare-ip-tls.ts`.
|
|
2129
2162
|
*/
|
|
2130
|
-
async
|
|
2163
|
+
async open() {
|
|
2131
2164
|
const mqtt = await loadMqtt();
|
|
2132
2165
|
const c = this.o.credentials;
|
|
2133
2166
|
const port = c.endpoint_port ?? 8883;
|
|
@@ -2255,6 +2288,7 @@ var SecureMqtt = class extends EventEmitter {
|
|
|
2255
2288
|
await this.client.publishAsync(topic, body, { qos: opts.qos ?? 1 });
|
|
2256
2289
|
}
|
|
2257
2290
|
async disconnect() {
|
|
2291
|
+
this.connecting = void 0;
|
|
2258
2292
|
await this.client?.endAsync(true);
|
|
2259
2293
|
this.client = void 0;
|
|
2260
2294
|
}
|
|
@@ -4292,7 +4326,10 @@ function decodePowerSource(raw) {
|
|
|
4292
4326
|
function recordSetting(param, value, ctx) {
|
|
4293
4327
|
return setStationScalar(param, value, ctx.channel);
|
|
4294
4328
|
}
|
|
4295
|
-
var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410"];
|
|
4329
|
+
var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1"];
|
|
4330
|
+
function cameraPowerTier(model, capabilities) {
|
|
4331
|
+
return capabilities.has("battery") && notMainsCamera({ model }) ? "battery" : "wired";
|
|
4332
|
+
}
|
|
4296
4333
|
var notMainsCamera = (ctx) => {
|
|
4297
4334
|
const model = (ctx.model ?? "").toUpperCase();
|
|
4298
4335
|
return !MAINS_CAMERA_MODELS.some((prefix) => model.startsWith(prefix));
|
|
@@ -5202,7 +5239,7 @@ function statusLedCommand(on, ctx) {
|
|
|
5202
5239
|
return setScalar(CAMERA_CMD.DEV_LED_SWITCH, on ? 1 : 0, ctx, "int-string");
|
|
5203
5240
|
}
|
|
5204
5241
|
function poweredOf(ctx) {
|
|
5205
|
-
return ctx.capabilities
|
|
5242
|
+
return cameraPowerTier(ctx.model, ctx.capabilities ?? /* @__PURE__ */ new Set());
|
|
5206
5243
|
}
|
|
5207
5244
|
function enablementReflection(on, ctx) {
|
|
5208
5245
|
const alias = CAMERA_MEMBERS.enabled.readAliases[0].paramType;
|
|
@@ -5813,6 +5850,7 @@ var LIGHT_SWITCH_JSON_TYPES = /* @__PURE__ */ new Set([
|
|
|
5813
5850
|
DeviceType.CAMERA_4G_S330
|
|
5814
5851
|
]);
|
|
5815
5852
|
var LIGHT_SWITCH_INT_STRING_TYPES = /* @__PURE__ */ new Set([
|
|
5853
|
+
DeviceType.FLOODLIGHT_CAMERA_8423,
|
|
5816
5854
|
DeviceType.INDOOR_OUTDOOR_CAMERA_1080P_NO_LIGHT,
|
|
5817
5855
|
DeviceType.INDOOR_OUTDOOR_CAMERA_2K,
|
|
5818
5856
|
DeviceType.INDOOR_OUTDOOR_CAMERA_1080P,
|
|
@@ -10851,6 +10889,8 @@ var MODEL_REGISTRY = {
|
|
|
10851
10889
|
T8400: { codec: "camera", caps: ["arming"], name: "Indoor Cam" },
|
|
10852
10890
|
T8410: { codec: "camera", caps: ["arming"], name: "Indoor Cam Pan & Tilt" },
|
|
10853
10891
|
T8442: { codec: "camera", caps: ["arming"], name: "Indoor/Outdoor Cam 1080p" },
|
|
10892
|
+
// Hardwired wall-light camera; mains power asserted in MAINS_CAMERA_MODELS.
|
|
10893
|
+
T84A1: { codec: "camera", name: "Wall Light Cam S100" },
|
|
10854
10894
|
// Sensors — the concrete sensor kind is curated (DeviceType 2 alone is ambiguous).
|
|
10855
10895
|
T8900: { codec: "sensor", caps: ["contact"], name: "Entry Sensor" },
|
|
10856
10896
|
T8910: { codec: "sensor", caps: ["battery"], name: "Motion Sensor" },
|
|
@@ -11587,10 +11627,13 @@ var SECURITY_PARAMS = {
|
|
|
11587
11627
|
type: "number",
|
|
11588
11628
|
provenance: "apk"
|
|
11589
11629
|
},
|
|
11630
|
+
// Cameras and a HomeBase 3 answer a bare number; a HomeBase S1 Pro (T9000) answers a JSON envelope
|
|
11631
|
+
// (`{"ai_switch":1,"smart_detection":…,"devlist":[…]}`).
|
|
11590
11632
|
1299: {
|
|
11591
11633
|
name: "hbAiDetectType",
|
|
11592
|
-
type: "
|
|
11593
|
-
provenance: "apk"
|
|
11634
|
+
type: "string",
|
|
11635
|
+
provenance: "apk",
|
|
11636
|
+
encoding: "json"
|
|
11594
11637
|
},
|
|
11595
11638
|
1309: {
|
|
11596
11639
|
name: "solarIntensity",
|
|
@@ -15139,6 +15182,9 @@ var LIVE_START_RETRANSMIT_MS = 150;
|
|
|
15139
15182
|
var LIVE_START_ACK_DEADLINE_MS = 3e3;
|
|
15140
15183
|
var SEQUENCE_LOOKBACK = 32768;
|
|
15141
15184
|
var STALE_RETRANSMIT_DEPTH = 1024;
|
|
15185
|
+
var PUNCH_PROBE_SOCKETS = 7;
|
|
15186
|
+
var REORDER_WAIT_MS = 250;
|
|
15187
|
+
var REORDER_MAX_DATAGRAMS = 128;
|
|
15142
15188
|
var MAX_TRACED_DATAGRAM_GAPS = 8;
|
|
15143
15189
|
var CMD_SET_PAYLOAD = 1350;
|
|
15144
15190
|
var CMD_NOTIFY_PAYLOAD = 1351;
|
|
@@ -15200,13 +15246,18 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15200
15246
|
lookupTimer;
|
|
15201
15247
|
heartbeatTimer;
|
|
15202
15248
|
connectTimer;
|
|
15203
|
-
/**
|
|
15204
|
-
|
|
15249
|
+
/** Extra sockets registered alongside {@link socket} while connecting; see {@link PUNCH_PROBE_SOCKETS}. */
|
|
15250
|
+
probeSockets = [];
|
|
15251
|
+
/** Cloud lookup inputs resolved once for a connection. */
|
|
15252
|
+
cloudLookup;
|
|
15253
|
+
/** Local outbound IPv4 reported inside LOOKUP_WITH_KEY requests. */
|
|
15254
|
+
selfHost;
|
|
15205
15255
|
/** In-flight multi-datagram frame per data channel (see onData). */
|
|
15206
15256
|
pendingByDataType = /* @__PURE__ */ new Map();
|
|
15207
|
-
/** Last datagram sequence number
|
|
15208
|
-
* mid-frame and drop the (now unrecoverable) partial frame instead of splicing wrong bytes. */
|
|
15257
|
+
/** Last delivered datagram sequence number per data type. */
|
|
15209
15258
|
lastSeqByType = /* @__PURE__ */ new Map();
|
|
15259
|
+
/** Held datagrams and their active gap timer, grouped by data type. */
|
|
15260
|
+
reorderByType = /* @__PURE__ */ new Map();
|
|
15210
15261
|
tracedDatagramGaps = 0;
|
|
15211
15262
|
level1Key;
|
|
15212
15263
|
/** Negotiated 32-byte level-2/gateway key (AES-256-GCM). Set via setLevel2Key once known. */
|
|
@@ -15496,7 +15547,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15496
15547
|
}
|
|
15497
15548
|
const socket = dgram.createSocket("udp4");
|
|
15498
15549
|
this.socket = socket;
|
|
15499
|
-
socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo));
|
|
15550
|
+
socket.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, socket));
|
|
15500
15551
|
socket.on("error", (e) => this.emit("error", e));
|
|
15501
15552
|
await new Promise((resolve) => {
|
|
15502
15553
|
socket.bind(() => {
|
|
@@ -15507,15 +15558,20 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15507
15558
|
resolve();
|
|
15508
15559
|
});
|
|
15509
15560
|
});
|
|
15510
|
-
const boundPort = socket.address().port;
|
|
15511
15561
|
void _P2PSession.detectLocalIp().then((host) => {
|
|
15512
15562
|
if (host && !this.closed)
|
|
15513
|
-
this.
|
|
15563
|
+
this.selfHost = host;
|
|
15514
15564
|
});
|
|
15565
|
+
const { dskKey, cloudAddresses } = this.cfg;
|
|
15566
|
+
this.cloudLookup = dskKey && cloudAddresses?.length ? { key: dskKey, addresses: cloudAddresses } : void 0;
|
|
15567
|
+
if (this.cloudLookup)
|
|
15568
|
+
await this.startPunchProbes();
|
|
15569
|
+
if (this.closed)
|
|
15570
|
+
return;
|
|
15515
15571
|
this.trace({
|
|
15516
15572
|
phase: "lookup-channels",
|
|
15517
15573
|
local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
|
|
15518
|
-
cloud: this.
|
|
15574
|
+
cloud: this.cloudLookup !== void 0
|
|
15519
15575
|
});
|
|
15520
15576
|
this.sendLookups();
|
|
15521
15577
|
this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
|
|
@@ -15593,14 +15649,20 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15593
15649
|
this.send({ host: "255.255.255.255", port: LOCAL_LOOKUP_PORT }, RequestMessageType.LOCAL_LOOKUP, localPayload);
|
|
15594
15650
|
if (this.cfg.localAddress)
|
|
15595
15651
|
this.send({ host: this.cfg.localAddress, port: LOCAL_LOOKUP_PORT }, RequestMessageType.LOCAL_LOOKUP, localPayload);
|
|
15596
|
-
|
|
15597
|
-
|
|
15598
|
-
const
|
|
15599
|
-
|
|
15600
|
-
|
|
15601
|
-
this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: cloud -> ${this.cfg.cloudAddresses.map((a) => `${a.host}:${a.port}`).join(", ")} self=${this.selfAddress?.host}:${this.selfAddress?.port}`);
|
|
15652
|
+
const lookup2 = this.cloudLookup;
|
|
15653
|
+
if (lookup2) {
|
|
15654
|
+
for (const socket of [this.socket, ...this.probeSockets])
|
|
15655
|
+
this.sendCloudLookup(socket, lookup2);
|
|
15656
|
+
this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: cloud -> ${lookup2.addresses.map((a) => `${a.host}:${a.port}`).join(", ")} self=${this.selfHost ?? "unknown"}`);
|
|
15602
15657
|
}
|
|
15603
15658
|
}
|
|
15659
|
+
/** Register a bound socket's own port with each configured cloud lookup address. */
|
|
15660
|
+
sendCloudLookup(socket, lookup2) {
|
|
15661
|
+
const payload = this.selfHost ? buildLookupWithKeyPayload(this.cfg.p2pDid, this.selfHost, socket.address().port, lookup2.key) : buildLookupWithKeyPayload2(this.cfg.p2pDid, lookup2.key);
|
|
15662
|
+
const type = this.selfHost ? RequestMessageType.LOOKUP_WITH_KEY : RequestMessageType.LOOKUP_WITH_KEY2;
|
|
15663
|
+
for (const addr of lookup2.addresses)
|
|
15664
|
+
this.send(addr, type, payload, socket);
|
|
15665
|
+
}
|
|
15604
15666
|
/**
|
|
15605
15667
|
* Route one inbound UDP datagram by its message type, tracing every non-DATA one and any type this session
|
|
15606
15668
|
* does not model.
|
|
@@ -15612,28 +15674,30 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15612
15674
|
* recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
|
|
15613
15675
|
* as a cause.
|
|
15614
15676
|
*/
|
|
15615
|
-
onMessage(msg, rinfo) {
|
|
15677
|
+
onMessage(msg, rinfo, socket = this.socket) {
|
|
15678
|
+
if (!socket)
|
|
15679
|
+
return;
|
|
15616
15680
|
if (!hasHeader(msg, ResponseMessageType.DATA)) {
|
|
15617
15681
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} <<< ${rinfo.address}:${rinfo.port} header=${msg.subarray(0, 2).toString("hex")} len=${msg.length}`);
|
|
15618
15682
|
}
|
|
15619
15683
|
if (hasHeader(msg, ResponseMessageType.LOCAL_LOOKUP_RESP)) {
|
|
15620
15684
|
if (!this.connected)
|
|
15621
|
-
this.beginCheckCam({ host: rinfo.address, port: rinfo.port });
|
|
15685
|
+
this.beginCheckCam({ host: rinfo.address, port: rinfo.port }, socket);
|
|
15622
15686
|
} else if (hasHeader(msg, ResponseMessageType.LOOKUP_ADDR) || hasHeader(msg, ResponseMessageType.LOOKUP_ADDR2)) {
|
|
15623
15687
|
if (!this.connected) {
|
|
15624
15688
|
const addr = parseLookupAddr(msg);
|
|
15625
15689
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} LOOKUP_ADDR -> ${addr.host}:${addr.port}`);
|
|
15626
15690
|
if (addr.host !== "0.0.0.0")
|
|
15627
|
-
this.beginCheckCam(addr);
|
|
15691
|
+
this.beginCheckCam(addr, socket);
|
|
15628
15692
|
}
|
|
15629
15693
|
} else if (hasHeader(msg, ResponseMessageType.CAM_ID) || hasHeader(msg, ResponseMessageType.TURN_SERVER_CAM_ID)) {
|
|
15630
|
-
this.onConnected({ host: rinfo.address, port: rinfo.port });
|
|
15694
|
+
this.onConnected({ host: rinfo.address, port: rinfo.port }, socket);
|
|
15631
15695
|
} else if (hasHeader(msg, ResponseMessageType.PONG)) {
|
|
15632
15696
|
this.lastPongData = msg.length > 4 ? msg.subarray(4) : void 0;
|
|
15633
15697
|
this.lastPongAt = Date.now();
|
|
15634
15698
|
this.pathStaleTraced = false;
|
|
15635
15699
|
} else if (hasHeader(msg, ResponseMessageType.PING)) {
|
|
15636
|
-
this.send({ host: rinfo.address, port: rinfo.port }, RequestMessageType.PONG);
|
|
15700
|
+
this.send({ host: rinfo.address, port: rinfo.port }, RequestMessageType.PONG, void 0, socket);
|
|
15637
15701
|
} else if (hasHeader(msg, ResponseMessageType.ACK)) {
|
|
15638
15702
|
this.onAck(msg);
|
|
15639
15703
|
} else if (hasHeader(msg, ResponseMessageType.DATA)) {
|
|
@@ -15643,17 +15707,67 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15643
15707
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} UNHANDLED payload hex: ${msg.toString("hex")}`);
|
|
15644
15708
|
}
|
|
15645
15709
|
}
|
|
15646
|
-
beginCheckCam(addr) {
|
|
15710
|
+
beginCheckCam(addr, socket) {
|
|
15647
15711
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} beginCheckCam -> ${addr.host}:${addr.port} (+/-3)`);
|
|
15648
15712
|
const payload = buildCheckCamPayload(this.cfg.p2pDid);
|
|
15649
|
-
this.send(addr, RequestMessageType.CHECK_CAM, payload);
|
|
15713
|
+
this.send(addr, RequestMessageType.CHECK_CAM, payload, socket);
|
|
15650
15714
|
for (let p = addr.port - 3; p <= addr.port + 3; p++)
|
|
15651
15715
|
if (p !== addr.port && p > 0)
|
|
15652
|
-
this.send({ host: addr.host, port: p }, RequestMessageType.CHECK_CAM, payload);
|
|
15716
|
+
this.send({ host: addr.host, port: p }, RequestMessageType.CHECK_CAM, payload, socket);
|
|
15717
|
+
}
|
|
15718
|
+
/** Bind additional UDP source ports before the first cloud lookup. */
|
|
15719
|
+
async startPunchProbes() {
|
|
15720
|
+
await Promise.all(Array.from({ length: PUNCH_PROBE_SOCKETS }, async () => {
|
|
15721
|
+
const probe = dgram.createSocket("udp4");
|
|
15722
|
+
probe.on("error", (error) => this.logger.warn("[p2p] lookup socket error", error.message));
|
|
15723
|
+
probe.on("message", (msg, rinfo) => this.onMessage(msg, rinfo, probe));
|
|
15724
|
+
this.probeSockets.push(probe);
|
|
15725
|
+
await new Promise((resolve) => {
|
|
15726
|
+
let settled = false;
|
|
15727
|
+
const finish = (bound) => {
|
|
15728
|
+
if (settled)
|
|
15729
|
+
return;
|
|
15730
|
+
settled = true;
|
|
15731
|
+
probe.off("error", onError);
|
|
15732
|
+
probe.off("close", onClose);
|
|
15733
|
+
if (!bound) {
|
|
15734
|
+
this.probeSockets = this.probeSockets.filter((socket) => socket !== probe);
|
|
15735
|
+
try {
|
|
15736
|
+
probe.close();
|
|
15737
|
+
} catch {
|
|
15738
|
+
}
|
|
15739
|
+
}
|
|
15740
|
+
resolve();
|
|
15741
|
+
};
|
|
15742
|
+
const onError = () => finish(false);
|
|
15743
|
+
const onClose = () => finish(false);
|
|
15744
|
+
probe.once("error", onError);
|
|
15745
|
+
probe.once("close", onClose);
|
|
15746
|
+
probe.bind(() => finish(true));
|
|
15747
|
+
});
|
|
15748
|
+
}));
|
|
15749
|
+
}
|
|
15750
|
+
/** Close all lookup sockets except the one that completed the handshake. */
|
|
15751
|
+
stopPunchProbes(winner) {
|
|
15752
|
+
for (const probe of this.probeSockets) {
|
|
15753
|
+
if (probe === winner)
|
|
15754
|
+
continue;
|
|
15755
|
+
probe.removeAllListeners("message");
|
|
15756
|
+
probe.close();
|
|
15757
|
+
}
|
|
15758
|
+
this.probeSockets = [];
|
|
15653
15759
|
}
|
|
15654
|
-
onConnected(addr) {
|
|
15760
|
+
onConnected(addr, socket) {
|
|
15655
15761
|
if (this.connected)
|
|
15656
15762
|
return;
|
|
15763
|
+
this.stopPunchProbes(socket);
|
|
15764
|
+
if (this.socket !== socket) {
|
|
15765
|
+
this.socket?.removeAllListeners();
|
|
15766
|
+
this.socket?.close();
|
|
15767
|
+
socket.removeAllListeners("error");
|
|
15768
|
+
socket.on("error", (error) => this.emit("error", error));
|
|
15769
|
+
this.socket = socket;
|
|
15770
|
+
}
|
|
15657
15771
|
this.connected = true;
|
|
15658
15772
|
this.connectedAtMs = Date.now();
|
|
15659
15773
|
this.connecting = false;
|
|
@@ -16398,8 +16512,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16398
16512
|
* Acknowledge and reassemble one DATA datagram, sequenced independently per data type.
|
|
16399
16513
|
*
|
|
16400
16514
|
* The device numbers each data type's datagrams in its own 16-bit space and repeats what it thinks was
|
|
16401
|
-
* lost
|
|
16402
|
-
* retransmission of something already reassembled: it is acknowledged, then ignored. Distance is measured
|
|
16515
|
+
* lost. A repeat of something already delivered is acknowledged, then ignored. Distance is measured
|
|
16403
16516
|
* modulo the sequence space and read as backwards beyond {@link SEQUENCE_LOOKBACK}, which is what lets the
|
|
16404
16517
|
* numbering wrap without the next datagram looking like a jump of nearly a full space.
|
|
16405
16518
|
*
|
|
@@ -16409,9 +16522,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16409
16522
|
* numbering and the half-assembled frame goes — because ignoring it would freeze the mark, and every
|
|
16410
16523
|
* datagram of the new numbering would then be read as behind it too, for as long as it took to climb back.
|
|
16411
16524
|
*
|
|
16412
|
-
*
|
|
16413
|
-
*
|
|
16414
|
-
*
|
|
16525
|
+
* A forward gap means a datagram has not arrived YET, which is not the same as lost. The device repeats
|
|
16526
|
+
* unacknowledged datagrams, so successors are held for a bounded wait and delivered strictly in sequence
|
|
16527
|
+
* if the missing one arrives. A logical frame's payload spans datagrams that carry no header of their
|
|
16528
|
+
* own, so reassembling around a hole is impossible; waiting for it is what keeps the frame whole.
|
|
16529
|
+
*
|
|
16530
|
+
* Only once {@link REORDER_WAIT_MS} passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
|
|
16531
|
+
* treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
|
|
16415
16532
|
*/
|
|
16416
16533
|
onData(msg, addr) {
|
|
16417
16534
|
const dataTypeBuffer = msg.subarray(4, 6);
|
|
@@ -16422,18 +16539,98 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16422
16539
|
const advance = prevSeq === void 0 ? 1 : seqNo - prevSeq & 65535;
|
|
16423
16540
|
if (advance === 0)
|
|
16424
16541
|
return;
|
|
16425
|
-
|
|
16426
|
-
|
|
16427
|
-
|
|
16428
|
-
|
|
16429
|
-
|
|
16430
|
-
|
|
16431
|
-
|
|
16432
|
-
}
|
|
16542
|
+
if (advance > SEQUENCE_LOOKBACK) {
|
|
16543
|
+
if (65536 - advance <= STALE_RETRANSMIT_DEPTH)
|
|
16544
|
+
return;
|
|
16545
|
+
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16546
|
+
this.trace({ phase: "sequence-restart", dataType });
|
|
16547
|
+
this.clearReorderTimer(dataType);
|
|
16548
|
+
this.reorderByType.delete(dataType);
|
|
16433
16549
|
this.pendingByDataType.delete(dataType);
|
|
16550
|
+
this.lastSeqByType.set(dataType, seqNo);
|
|
16551
|
+
this.reassemble(dataType, msg.subarray(8));
|
|
16552
|
+
return;
|
|
16553
|
+
}
|
|
16554
|
+
if (advance === 1) {
|
|
16555
|
+
this.lastSeqByType.set(dataType, seqNo);
|
|
16556
|
+
this.reassemble(dataType, msg.subarray(8));
|
|
16557
|
+
this.drainReorder(dataType);
|
|
16558
|
+
return;
|
|
16559
|
+
}
|
|
16560
|
+
this.holdForRetransmit(dataType, seqNo, msg.subarray(8));
|
|
16561
|
+
}
|
|
16562
|
+
/** Hold a datagram that arrived past a hole, and arm the wait for the missing one to be repeated. */
|
|
16563
|
+
holdForRetransmit(dataType, seqNo, body) {
|
|
16564
|
+
let reorder = this.reorderByType.get(dataType);
|
|
16565
|
+
if (!reorder) {
|
|
16566
|
+
reorder = { held: /* @__PURE__ */ new Map() };
|
|
16567
|
+
this.reorderByType.set(dataType, reorder);
|
|
16568
|
+
}
|
|
16569
|
+
if (!reorder.held.has(seqNo))
|
|
16570
|
+
reorder.held.set(seqNo, body);
|
|
16571
|
+
if (reorder.held.size > REORDER_MAX_DATAGRAMS) {
|
|
16572
|
+
this.abandonHole(dataType);
|
|
16573
|
+
return;
|
|
16434
16574
|
}
|
|
16575
|
+
this.armReorderTimer(dataType);
|
|
16576
|
+
}
|
|
16577
|
+
/** Start a full wait for the current hole, leaving an existing wait undisturbed. */
|
|
16578
|
+
armReorderTimer(dataType) {
|
|
16579
|
+
const reorder = this.reorderByType.get(dataType);
|
|
16580
|
+
if (!reorder?.held.size || reorder.timer)
|
|
16581
|
+
return;
|
|
16582
|
+
const timer = setTimeout(() => {
|
|
16583
|
+
reorder.timer = void 0;
|
|
16584
|
+
this.abandonHole(dataType);
|
|
16585
|
+
}, REORDER_WAIT_MS);
|
|
16586
|
+
timer.unref?.();
|
|
16587
|
+
reorder.timer = timer;
|
|
16588
|
+
}
|
|
16589
|
+
/** Deliver held datagrams that now follow directly on from the last one delivered. */
|
|
16590
|
+
drainReorder(dataType) {
|
|
16591
|
+
const reorder = this.reorderByType.get(dataType);
|
|
16592
|
+
if (!reorder?.held.size)
|
|
16593
|
+
return;
|
|
16594
|
+
this.clearReorderTimer(dataType);
|
|
16595
|
+
for (; ; ) {
|
|
16596
|
+
const next = this.lastSeqByType.get(dataType) + 1 & 65535;
|
|
16597
|
+
const body = reorder.held.get(next);
|
|
16598
|
+
if (body === void 0)
|
|
16599
|
+
break;
|
|
16600
|
+
reorder.held.delete(next);
|
|
16601
|
+
this.lastSeqByType.set(dataType, next);
|
|
16602
|
+
this.reassemble(dataType, body);
|
|
16603
|
+
}
|
|
16604
|
+
if (reorder.held.size)
|
|
16605
|
+
this.armReorderTimer(dataType);
|
|
16606
|
+
else
|
|
16607
|
+
this.reorderByType.delete(dataType);
|
|
16608
|
+
}
|
|
16609
|
+
/**
|
|
16610
|
+
* The missing datagram was not repeated in time: report the gap, drop the frame it belonged to, and
|
|
16611
|
+
* resume from the earliest datagram still held so the stream keeps moving.
|
|
16612
|
+
*/
|
|
16613
|
+
abandonHole(dataType) {
|
|
16614
|
+
const held = this.reorderByType.get(dataType).held;
|
|
16615
|
+
if (this.pendingByDataType.has(dataType) && this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS)
|
|
16616
|
+
this.trace({ phase: "datagram-gap", dataType });
|
|
16617
|
+
this.pendingByDataType.delete(dataType);
|
|
16618
|
+
const last = this.lastSeqByType.get(dataType);
|
|
16619
|
+
const earliest = [...held.keys()].sort((a, b) => (a - last & 65535) - (b - last & 65535))[0];
|
|
16620
|
+
this.lastSeqByType.set(dataType, earliest - 1 & 65535);
|
|
16621
|
+
this.drainReorder(dataType);
|
|
16622
|
+
}
|
|
16623
|
+
clearReorderTimer(dataType) {
|
|
16624
|
+
const reorder = this.reorderByType.get(dataType);
|
|
16625
|
+
if (!reorder?.timer)
|
|
16626
|
+
return;
|
|
16627
|
+
clearTimeout(reorder.timer);
|
|
16628
|
+
reorder.timer = void 0;
|
|
16629
|
+
}
|
|
16630
|
+
/** Reassemble one in-sequence datagram body into logical frames. */
|
|
16631
|
+
reassemble(dataType, datagramBody) {
|
|
16435
16632
|
const pending = this.pendingByDataType.get(dataType);
|
|
16436
|
-
let body = pending ? Buffer.concat([pending.buf,
|
|
16633
|
+
let body = pending ? Buffer.concat([pending.buf, datagramBody]) : datagramBody;
|
|
16437
16634
|
const carryHeader = pending?.header;
|
|
16438
16635
|
this.pendingByDataType.delete(dataType);
|
|
16439
16636
|
if (carryHeader) {
|
|
@@ -16464,6 +16661,9 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16464
16661
|
* completed either.
|
|
16465
16662
|
*/
|
|
16466
16663
|
resetInboundSequencing() {
|
|
16664
|
+
for (const dataType of this.reorderByType.keys())
|
|
16665
|
+
this.clearReorderTimer(dataType);
|
|
16666
|
+
this.reorderByType.clear();
|
|
16467
16667
|
this.lastSeqByType.clear();
|
|
16468
16668
|
this.pendingByDataType.clear();
|
|
16469
16669
|
this.tracedDatagramGaps = 0;
|
|
@@ -16540,11 +16740,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16540
16740
|
}
|
|
16541
16741
|
this.emit("data", frame);
|
|
16542
16742
|
}
|
|
16543
|
-
send(addr, type, payload) {
|
|
16544
|
-
if (!
|
|
16743
|
+
send(addr, type, payload, socket = this.socket) {
|
|
16744
|
+
if (!socket)
|
|
16545
16745
|
return;
|
|
16546
16746
|
const packet = frameMessage(type, payload);
|
|
16547
|
-
|
|
16747
|
+
socket.send(packet, addr.port, addr.host, (err) => {
|
|
16548
16748
|
if (err)
|
|
16549
16749
|
this.logger.warn(`[p2p] send err ${addr.host}:${addr.port}`, err.message);
|
|
16550
16750
|
});
|
|
@@ -16553,6 +16753,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16553
16753
|
if (this.closed)
|
|
16554
16754
|
return;
|
|
16555
16755
|
this.closed = true;
|
|
16756
|
+
this.stopPunchProbes();
|
|
16556
16757
|
this.connectionGeneration += 1;
|
|
16557
16758
|
this.level2Key = void 0;
|
|
16558
16759
|
this.level2Seq = 0;
|
|
@@ -23639,24 +23840,35 @@ function readNullTerminated(buf) {
|
|
|
23639
23840
|
function nonemptyString(value) {
|
|
23640
23841
|
return typeof value === "string" && value.trim().length > 0;
|
|
23641
23842
|
}
|
|
23642
|
-
function
|
|
23643
|
-
const
|
|
23644
|
-
let
|
|
23645
|
-
|
|
23646
|
-
|
|
23647
|
-
|
|
23648
|
-
|
|
23649
|
-
|
|
23843
|
+
function payloadLevels(env) {
|
|
23844
|
+
const levels = [env];
|
|
23845
|
+
for (let level = env; level.payload != null; ) {
|
|
23846
|
+
let next = level.payload;
|
|
23847
|
+
if (typeof next === "string") {
|
|
23848
|
+
try {
|
|
23849
|
+
next = JSON.parse(next);
|
|
23850
|
+
} catch {
|
|
23851
|
+
next = void 0;
|
|
23852
|
+
}
|
|
23650
23853
|
}
|
|
23854
|
+
level = typeof next === "object" && next ? next : {};
|
|
23855
|
+
levels.push(level);
|
|
23651
23856
|
}
|
|
23652
|
-
|
|
23857
|
+
return levels;
|
|
23858
|
+
}
|
|
23859
|
+
function normalizePushEvent(raw) {
|
|
23860
|
+
const env = raw.payload ?? {};
|
|
23861
|
+
const levels = payloadLevels(env);
|
|
23862
|
+
const p = levels[levels.length - 1];
|
|
23863
|
+
const deepestFirst = [...levels].reverse();
|
|
23864
|
+
const deviceClaims = deepestFirst.map((l) => l.device_sn).filter(nonemptyString);
|
|
23865
|
+
const levelStations = deepestFirst.map((l) => l.station_sn).filter(nonemptyString);
|
|
23866
|
+
const stationClaims = nonemptyString(p.s) ? [...levelStations, p.s] : levelStations;
|
|
23653
23867
|
const eventType = p.event_type ?? p.a;
|
|
23654
23868
|
const url = nonemptyString(p.pic_url) ? p.pic_url : nonemptyString(p.thumbnail) ? p.thumbnail : void 0;
|
|
23655
23869
|
let thumbnailCandidate;
|
|
23656
23870
|
if (url) {
|
|
23657
|
-
const deviceClaims = [p.device_sn, env.device_sn].filter(nonemptyString);
|
|
23658
23871
|
const deviceSn = deviceClaims[0];
|
|
23659
|
-
const stationClaims = [p.station_sn, env.station_sn, p.s].filter(nonemptyString);
|
|
23660
23872
|
const stationSn = stationClaims[0];
|
|
23661
23873
|
thumbnailCandidate = {
|
|
23662
23874
|
url,
|
|
@@ -23667,8 +23879,8 @@ function normalizePushEvent(raw) {
|
|
|
23667
23879
|
};
|
|
23668
23880
|
}
|
|
23669
23881
|
return {
|
|
23670
|
-
deviceSn:
|
|
23671
|
-
stationSn:
|
|
23882
|
+
deviceSn: deviceClaims[0] ?? (nonemptyString(p.s) ? p.s : void 0),
|
|
23883
|
+
stationSn: levelStations[0],
|
|
23672
23884
|
eventType,
|
|
23673
23885
|
thumbnailUrl: p.pic_url ?? p.thumbnail,
|
|
23674
23886
|
thumbnailCandidate,
|
|
@@ -23808,6 +24020,13 @@ var PushClient = class _PushClient extends EventEmitter8 {
|
|
|
23808
24020
|
}
|
|
23809
24021
|
this.socket?.destroy();
|
|
23810
24022
|
}
|
|
24023
|
+
/**
|
|
24024
|
+
* Decode one MCS `DataMessageStanza` into a {@link RawPushMessage} and the normalised event.
|
|
24025
|
+
*
|
|
24026
|
+
* `payload` carries the whole app_data envelope, with its `payload` entry (base64 of NUL-terminated
|
|
24027
|
+
* JSON) parsed in place: the envelope's own keys, `device_sn` and `station_sn` among them, sit beside
|
|
24028
|
+
* that entry.
|
|
24029
|
+
*/
|
|
23811
24030
|
handleDataMessage(object) {
|
|
23812
24031
|
if (object?.persistentId)
|
|
23813
24032
|
this.persistentIds.push(object.persistentId);
|
|
@@ -23832,7 +24051,7 @@ var PushClient = class _PushClient extends EventEmitter8 {
|
|
|
23832
24051
|
persistentId: object?.persistentId,
|
|
23833
24052
|
ttl: object?.ttl,
|
|
23834
24053
|
sent: object?.sent,
|
|
23835
|
-
payload: data
|
|
24054
|
+
payload: data
|
|
23836
24055
|
};
|
|
23837
24056
|
this.emit("message", raw);
|
|
23838
24057
|
const event = normalizePushEvent(raw);
|
|
@@ -26170,7 +26389,8 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26170
26389
|
}
|
|
26171
26390
|
/**
|
|
26172
26391
|
* A station's power tier for the P2P lifecycle: a HomeBase/station is `"wired"` (persistent); a
|
|
26173
|
-
* standalone device is `"battery"` iff its resolved capabilities include `battery
|
|
26392
|
+
* standalone device is `"battery"` iff its resolved capabilities include `battery` and it is not a
|
|
26393
|
+
* confirmed mains-only camera model, else `"wired"`.
|
|
26174
26394
|
* Keyed on the STATION's own power, never a child's (a battery cam attached to a wired HomeBase draws
|
|
26175
26395
|
* from the base's persistent session). Reads capabilities on the client side — no model type leaks to
|
|
26176
26396
|
* transport (the router only ever sees the `"wired"|"battery"` string).
|
|
@@ -26188,7 +26408,7 @@ var EufyMega = class extends EventEmitter9 {
|
|
|
26188
26408
|
category: d.category,
|
|
26189
26409
|
params: d.params ?? {}
|
|
26190
26410
|
}).capabilities;
|
|
26191
|
-
return
|
|
26411
|
+
return cameraPowerTier(d.model, new Set(caps));
|
|
26192
26412
|
}
|
|
26193
26413
|
/**
|
|
26194
26414
|
* Speculatively open the P2P session of the station behind `deviceSn`, if the caller opted this
|