@mega-yfue/eufy-sdk 0.3.0-beta.1 → 0.3.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -674,7 +674,8 @@ export declare class EufyMega extends EventEmitter {
674
674
  private warmWiredP2P;
675
675
  /**
676
676
  * A station's power tier for the P2P lifecycle: a HomeBase/station is `"wired"` (persistent); a
677
- * standalone device is `"battery"` iff its resolved capabilities include `battery`, else `"wired"`.
677
+ * standalone device is `"battery"` iff its resolved capabilities include `battery` and it is not a
678
+ * confirmed mains-only camera model, else `"wired"`.
678
679
  * Keyed on the STATION's own power, never a child's (a battery cam attached to a wired HomeBase draws
679
680
  * from the base's persistent session). Reads capabilities on the client side — no model type leaks to
680
681
  * transport (the router only ever sees the `"wired"|"battery"` string).
@@ -377,8 +377,9 @@ export type EufyMegaEventMap = {
377
377
  * on `error`.
378
378
  *
379
379
  * The error carries the rate: `err.retryAfterMs` is how long the next session replacement is barred
380
- * for, and `err.contended` says this session is being displaced by another client rather than expiring
381
- * — which a re-login does not answer. A login made before that wait elapses extends it.
380
+ * for, and `err.contended` says the token was rejected again soon after being replaced, which is how
381
+ * displacement by another client looks and which a re-login does not answer. A login made before that
382
+ * wait elapses extends it.
382
383
  */
383
384
  sessionExpired: [err: SessionExpiredError];
384
385
  error: [err: Error];
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
- * It says the session is being DISPLACED rather than expiring: something else is signing in on this
1106
- * account, and replacing the token again only trades one login for another.
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 on this account keeps displacing this session \u2014 the cloud holds about one session per account and device identity, so each login ends the other's; where the other client is another SDK install, give each its own openudid";
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(targetHost, entry);
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.delete(host);
1475
- else
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 is evidence of contention rather than expiry, so the wait grows
1836
- * and a caller is told the honest reason instead of being served a fight.
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] token rejected ${Math.round((Date.now() - this.lastRecoveryAt) / 1e3)}s after the last replacement \u2014 holding off ${Math.round(remaining / 1e3)}s more. ${CONTENDED_SESSION_HINT}`);
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 connect() {
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?.has("battery") ? "battery" : "wired";
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: "number",
11593
- provenance: "apk"
11634
+ type: "string",
11635
+ provenance: "apk",
11636
+ encoding: "json"
11594
11637
  },
11595
11638
  1309: {
11596
11639
  name: "solarIntensity",
@@ -15140,6 +15183,8 @@ var LIVE_START_ACK_DEADLINE_MS = 3e3;
15140
15183
  var SEQUENCE_LOOKBACK = 32768;
15141
15184
  var STALE_RETRANSMIT_DEPTH = 1024;
15142
15185
  var PUNCH_PROBE_SOCKETS = 7;
15186
+ var REORDER_WAIT_MS = 250;
15187
+ var REORDER_MAX_DATAGRAMS = 128;
15143
15188
  var MAX_TRACED_DATAGRAM_GAPS = 8;
15144
15189
  var CMD_SET_PAYLOAD = 1350;
15145
15190
  var CMD_NOTIFY_PAYLOAD = 1351;
@@ -15209,9 +15254,10 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15209
15254
  selfHost;
15210
15255
  /** In-flight multi-datagram frame per data channel (see onData). */
15211
15256
  pendingByDataType = /* @__PURE__ */ new Map();
15212
- /** Last datagram sequence number seen per dataType — used to detect a lost/reordered datagram
15213
- * mid-frame and drop the (now unrecoverable) partial frame instead of splicing wrong bytes. */
15257
+ /** Last delivered datagram sequence number per data type. */
15214
15258
  lastSeqByType = /* @__PURE__ */ new Map();
15259
+ /** Held datagrams and their active gap timer, grouped by data type. */
15260
+ reorderByType = /* @__PURE__ */ new Map();
15215
15261
  tracedDatagramGaps = 0;
15216
15262
  level1Key;
15217
15263
  /** Negotiated 32-byte level-2/gateway key (AES-256-GCM). Set via setLevel2Key once known. */
@@ -16466,8 +16512,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16466
16512
  * Acknowledge and reassemble one DATA datagram, sequenced independently per data type.
16467
16513
  *
16468
16514
  * The device numbers each data type's datagrams in its own 16-bit space and repeats what it thinks was
16469
- * lost, so a datagram that does not advance the sequence — a duplicate, or one already superseded — is a
16470
- * 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
16471
16516
  * modulo the sequence space and read as backwards beyond {@link SEQUENCE_LOOKBACK}, which is what lets the
16472
16517
  * numbering wrap without the next datagram looking like a jump of nearly a full space.
16473
16518
  *
@@ -16477,9 +16522,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16477
16522
  * numbering and the half-assembled frame goes — because ignoring it would freeze the mark, and every
16478
16523
  * datagram of the new numbering would then be read as behind it too, for as long as it took to climb back.
16479
16524
  *
16480
- * Only a forward gap means a datagram is genuinely missing. A logical frame's payload spans datagrams that
16481
- * carry no header of their own, so the bytes cannot be reassembled around the hole: whatever was pending
16482
- * for that data type is discarded, and the frame is rebuilt from the next header.
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.
16483
16532
  */
16484
16533
  onData(msg, addr) {
16485
16534
  const dataTypeBuffer = msg.subarray(4, 6);
@@ -16490,18 +16539,98 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16490
16539
  const advance = prevSeq === void 0 ? 1 : seqNo - prevSeq & 65535;
16491
16540
  if (advance === 0)
16492
16541
  return;
16493
- const restarted = advance > SEQUENCE_LOOKBACK && 65536 - advance > STALE_RETRANSMIT_DEPTH;
16494
- if (advance > SEQUENCE_LOOKBACK && !restarted)
16495
- return;
16496
- this.lastSeqByType.set(dataType, seqNo);
16497
- if ((advance > 1 || restarted) && this.pendingByDataType.has(dataType)) {
16498
- if (this.tracedDatagramGaps++ < MAX_TRACED_DATAGRAM_GAPS) {
16499
- this.trace({ phase: restarted ? "sequence-restart" : "datagram-gap", dataType });
16500
- }
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);
16501
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;
16502
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) {
16503
16632
  const pending = this.pendingByDataType.get(dataType);
16504
- let body = pending ? Buffer.concat([pending.buf, msg.subarray(8)]) : msg.subarray(8);
16633
+ let body = pending ? Buffer.concat([pending.buf, datagramBody]) : datagramBody;
16505
16634
  const carryHeader = pending?.header;
16506
16635
  this.pendingByDataType.delete(dataType);
16507
16636
  if (carryHeader) {
@@ -16532,6 +16661,9 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16532
16661
  * completed either.
16533
16662
  */
16534
16663
  resetInboundSequencing() {
16664
+ for (const dataType of this.reorderByType.keys())
16665
+ this.clearReorderTimer(dataType);
16666
+ this.reorderByType.clear();
16535
16667
  this.lastSeqByType.clear();
16536
16668
  this.pendingByDataType.clear();
16537
16669
  this.tracedDatagramGaps = 0;
@@ -23708,24 +23840,35 @@ function readNullTerminated(buf) {
23708
23840
  function nonemptyString(value) {
23709
23841
  return typeof value === "string" && value.trim().length > 0;
23710
23842
  }
23711
- function normalizePushEvent(raw) {
23712
- const env = raw.payload ?? {};
23713
- let inner = env.payload ?? env;
23714
- if (typeof inner === "string") {
23715
- try {
23716
- inner = JSON.parse(inner);
23717
- } catch {
23718
- inner = void 0;
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
+ }
23719
23853
  }
23854
+ level = typeof next === "object" && next ? next : {};
23855
+ levels.push(level);
23720
23856
  }
23721
- const p = typeof inner === "object" && inner ? inner : {};
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;
23722
23867
  const eventType = p.event_type ?? p.a;
23723
23868
  const url = nonemptyString(p.pic_url) ? p.pic_url : nonemptyString(p.thumbnail) ? p.thumbnail : void 0;
23724
23869
  let thumbnailCandidate;
23725
23870
  if (url) {
23726
- const deviceClaims = [p.device_sn, env.device_sn].filter(nonemptyString);
23727
23871
  const deviceSn = deviceClaims[0];
23728
- const stationClaims = [p.station_sn, env.station_sn, p.s].filter(nonemptyString);
23729
23872
  const stationSn = stationClaims[0];
23730
23873
  thumbnailCandidate = {
23731
23874
  url,
@@ -23736,8 +23879,8 @@ function normalizePushEvent(raw) {
23736
23879
  };
23737
23880
  }
23738
23881
  return {
23739
- deviceSn: p.device_sn ?? env.device_sn ?? p.s,
23740
- stationSn: p.station_sn ?? env.station_sn,
23882
+ deviceSn: deviceClaims[0] ?? (nonemptyString(p.s) ? p.s : void 0),
23883
+ stationSn: levelStations[0],
23741
23884
  eventType,
23742
23885
  thumbnailUrl: p.pic_url ?? p.thumbnail,
23743
23886
  thumbnailCandidate,
@@ -23877,6 +24020,13 @@ var PushClient = class _PushClient extends EventEmitter8 {
23877
24020
  }
23878
24021
  this.socket?.destroy();
23879
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
+ */
23880
24030
  handleDataMessage(object) {
23881
24031
  if (object?.persistentId)
23882
24032
  this.persistentIds.push(object.persistentId);
@@ -23901,7 +24051,7 @@ var PushClient = class _PushClient extends EventEmitter8 {
23901
24051
  persistentId: object?.persistentId,
23902
24052
  ttl: object?.ttl,
23903
24053
  sent: object?.sent,
23904
- payload: data.payload ?? data
24054
+ payload: data
23905
24055
  };
23906
24056
  this.emit("message", raw);
23907
24057
  const event = normalizePushEvent(raw);
@@ -26239,7 +26389,8 @@ var EufyMega = class extends EventEmitter9 {
26239
26389
  }
26240
26390
  /**
26241
26391
  * A station's power tier for the P2P lifecycle: a HomeBase/station is `"wired"` (persistent); a
26242
- * standalone device is `"battery"` iff its resolved capabilities include `battery`, else `"wired"`.
26392
+ * standalone device is `"battery"` iff its resolved capabilities include `battery` and it is not a
26393
+ * confirmed mains-only camera model, else `"wired"`.
26243
26394
  * Keyed on the STATION's own power, never a child's (a battery cam attached to a wired HomeBase draws
26244
26395
  * from the base's persistent session). Reads capabilities on the client side — no model type leaks to
26245
26396
  * transport (the router only ever sees the `"wired"|"battery"` string).
@@ -26257,7 +26408,7 @@ var EufyMega = class extends EventEmitter9 {
26257
26408
  category: d.category,
26258
26409
  params: d.params ?? {}
26259
26410
  }).capabilities;
26260
- return caps.includes("battery") ? "battery" : "wired";
26411
+ return cameraPowerTier(d.model, new Set(caps));
26261
26412
  }
26262
26413
  /**
26263
26414
  * Speculatively open the P2P session of the station behind `deviceSn`, if the caller opted this