@mega-yfue/eufy-sdk 0.2.0-beta.2 → 0.2.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/client/eufy-mega.d.ts +16 -11
  2. package/dist/client/types.d.ts +6 -2
  3. package/dist/core/contracts.d.ts +57 -27
  4. package/dist/core/crypto.d.ts +10 -0
  5. package/dist/core/index.d.ts +1 -0
  6. package/dist/core/logger.d.ts +5 -3
  7. package/dist/core/solix-types.d.ts +115 -0
  8. package/dist/core/store.d.ts +38 -10
  9. package/dist/index.js +2713 -452
  10. package/dist/index.js.map +4 -4
  11. package/dist/model/capabilities/access.d.ts +22 -3
  12. package/dist/model/capabilities/arming.d.ts +33 -27
  13. package/dist/model/capabilities/battery.d.ts +32 -4
  14. package/dist/model/capabilities/contact.d.ts +4 -0
  15. package/dist/model/capabilities/display.d.ts +85 -0
  16. package/dist/model/capabilities/index.d.ts +18 -3
  17. package/dist/model/capabilities/ptz.d.ts +6 -2
  18. package/dist/model/capabilities/solix.d.ts +173 -0
  19. package/dist/model/capabilities/types.d.ts +21 -5
  20. package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
  21. package/dist/model/device.d.ts +15 -0
  22. package/dist/model/index.d.ts +5 -0
  23. package/dist/model/param-dictionary.d.ts +24 -0
  24. package/dist/model/param-namespace.d.ts +1 -1
  25. package/dist/model/solix-catalog.d.ts +25 -0
  26. package/dist/model/solix-device.d.ts +136 -0
  27. package/dist/model/solix-family.d.ts +31 -0
  28. package/dist/model/solix-site.d.ts +70 -0
  29. package/dist/model/types.d.ts +5 -5
  30. package/dist/transport/ff09.d.ts +7 -0
  31. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  32. package/dist/transport/http/index.d.ts +1 -0
  33. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  34. package/dist/transport/http/media-download.d.ts +3 -0
  35. package/dist/transport/http/mega-client.d.ts +89 -9
  36. package/dist/transport/http/solix-client.d.ts +269 -0
  37. package/dist/transport/http/solix-constants.d.ts +56 -0
  38. package/dist/transport/media-failure.d.ts +48 -0
  39. package/dist/transport/mqtt/index.d.ts +2 -0
  40. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  41. package/dist/transport/mqtt/solix-mqtt.d.ts +321 -0
  42. package/dist/transport/mqtt/topics.d.ts +30 -0
  43. package/dist/transport/p2p/command-router.d.ts +152 -15
  44. package/dist/transport/p2p/index.d.ts +1 -0
  45. package/dist/transport/p2p/live-stream.d.ts +5 -4
  46. package/dist/transport/p2p/live-trace.d.ts +118 -6
  47. package/dist/transport/p2p/media.d.ts +11 -0
  48. package/dist/transport/p2p/p2p-session.d.ts +13 -0
  49. package/dist/transport/p2p/session-manager.d.ts +57 -23
  50. package/dist/transport/p2p/shared-live-source.d.ts +10 -1
  51. package/dist/transport/stored-image-cache.d.ts +7 -1
  52. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { createCipheriv, createDecipheriv, createECDH, createHash, createHmac, r
15
15
  var P256 = "prime256v1";
16
16
  var EUFY_MEGA_LOCAL_KEY_HEX = "2500a7d5617812f9d52515b2c8f20a3d";
17
17
  var EUFYLIFE_LOCAL_KEY_HEX = "118c12c81e211149304bd70a0c071d01";
18
+ var SOLIX_LOCAL_KEY_HEX = "e8ad18f61bbd3fbd52d5ed12d14d3b9c";
18
19
  var SERVER_STATIC_PUBLIC_KEY_HEX = "04c5c00c4f8d1197cc7c3167c52bf7acb054d722f0ef08dcd7e0883236e0d72a3868d9750cb47fa4619248f3d83f0f662671dadc6e2d31c2f41db0161651c7c076";
19
20
  function genId() {
20
21
  return randomBytes(16).toString("hex");
@@ -22,8 +23,11 @@ function genId() {
22
23
  function nowSec() {
23
24
  return Math.floor(Date.now() / 1e3).toString();
24
25
  }
26
+ function md5Hex(input) {
27
+ return createHash("md5").update(input, "utf-8").digest("hex");
28
+ }
25
29
  function gtoken(userId) {
26
- return createHash("md5").update(userId, "utf-8").digest("hex");
30
+ return md5Hex(userId);
27
31
  }
28
32
  function aesKey(shareKeyHex) {
29
33
  return Buffer.from(shareKeyHex, "hex").subarray(0, 16);
@@ -142,14 +146,17 @@ var FileSessionStore = class {
142
146
  }
143
147
  }
144
148
  };
145
- function isSessionValid(s, skewSec = 300) {
146
- if (!s?.authToken || !s.shareKey || !s.keyIdent)
147
- return false;
148
- if (s.tokenExpiresAt && s.tokenExpiresAt > 0) {
149
- return Math.floor(Date.now() / 1e3) < s.tokenExpiresAt - skewSec;
149
+ function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
150
+ if (tokenExpiresAt && tokenExpiresAt > 0) {
151
+ return Math.floor(Date.now() / 1e3) < tokenExpiresAt - skewSec;
150
152
  }
151
153
  return true;
152
154
  }
155
+ function isSessionValid(s, skewSec = 300) {
156
+ if (!s?.authToken || !s.shareKey || !s.keyIdent || !s.accountUserId)
157
+ return false;
158
+ return tokenNotExpired(s.tokenExpiresAt, skewSec);
159
+ }
153
160
 
154
161
  // dist/core/logger.js
155
162
  var noopLogger = {
@@ -210,18 +217,33 @@ BLOCKED_ADDRESSES.addAddress("::1", "ipv6");
210
217
  BLOCKED_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
211
218
  BLOCKED_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
212
219
  var MediaDownloadError = class extends Error {
220
+ mediaFailure;
221
+ status;
222
+ constructor(message, mediaFailure, status) {
223
+ super(message);
224
+ this.name = "MediaDownloadError";
225
+ this.mediaFailure = mediaFailure;
226
+ if (status !== void 0)
227
+ this.status = status;
228
+ }
213
229
  };
230
+ function mediaFailureError(message, reason, cause) {
231
+ const error = new MediaDownloadError(message, reason);
232
+ if (cause !== void 0)
233
+ error.cause = cause;
234
+ return error;
235
+ }
214
236
  var MediaDownloadAuthenticationError = class extends Error {
215
237
  };
216
- function allowedMediaUrl(value, hostPattern) {
238
+ function allowedMediaUrl(value, hostPattern, reason) {
217
239
  let url;
218
240
  try {
219
241
  url = new URL(value);
220
242
  } catch {
221
- throw new MediaDownloadError("Media download rejected");
243
+ throw new MediaDownloadError("Media download rejected", reason);
222
244
  }
223
245
  if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "" || isIP(url.hostname) !== 0 || !hostPattern.test(url.hostname)) {
224
- throw new MediaDownloadError("Media download rejected");
246
+ throw new MediaDownloadError("Media download rejected", reason);
225
247
  }
226
248
  return url;
227
249
  }
@@ -236,7 +258,7 @@ async function assertPublicResolution(url, resolveHost2, signal) {
236
258
  try {
237
259
  addresses = await Promise.race([resolveHost2(url.hostname), aborted]);
238
260
  } catch {
239
- throw new MediaDownloadError("Media download rejected");
261
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
240
262
  } finally {
241
263
  signal.removeEventListener("abort", onAbort);
242
264
  }
@@ -244,14 +266,14 @@ async function assertPublicResolution(url, resolveHost2, signal) {
244
266
  const type = family === 6 ? "ipv6" : family === 4 ? "ipv4" : void 0;
245
267
  return type === void 0 || BLOCKED_ADDRESSES.check(address, type);
246
268
  })) {
247
- throw new MediaDownloadError("Media download rejected");
269
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
248
270
  }
249
271
  }
250
272
  var resolveHost = (hostname) => lookup(hostname, { all: true, verbatim: true });
251
273
  async function readMediaBody(response) {
252
274
  const contentLength = response.headers.get("content-length");
253
275
  if (contentLength && /^\d+$/.test(contentLength) && BigInt(contentLength) > BigInt(MEDIA_DOWNLOAD_MAX_BYTES)) {
254
- throw new MediaDownloadError("Media download rejected");
276
+ throw new MediaDownloadError("Media download rejected", "too-large");
255
277
  }
256
278
  if (!response.body)
257
279
  return Buffer.alloc(0);
@@ -265,14 +287,14 @@ async function readMediaBody(response) {
265
287
  bytes2 += value.byteLength;
266
288
  if (bytes2 > MEDIA_DOWNLOAD_MAX_BYTES) {
267
289
  void reader2.cancel().catch(() => void 0);
268
- throw new MediaDownloadError("Media download rejected");
290
+ throw new MediaDownloadError("Media download rejected", "too-large");
269
291
  }
270
292
  chunks.push(value);
271
293
  }
272
294
  return Buffer.concat(chunks, bytes2);
273
295
  }
274
296
  async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetch, resolver = resolveHost) {
275
- const original = allowedMediaUrl(url, EUFY_MEDIA_HOST);
297
+ const original = allowedMediaUrl(url, EUFY_MEDIA_HOST, "url-not-allowed");
276
298
  const controller = new AbortController();
277
299
  const timeout = setTimeout(() => controller.abort(), MEDIA_DOWNLOAD_TIMEOUT_MS);
278
300
  try {
@@ -285,31 +307,33 @@ async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetc
285
307
  if (originalResponse.status >= 300 && originalResponse.status < 400) {
286
308
  const location = originalResponse.headers.get("location");
287
309
  if (!location)
288
- throw new MediaDownloadError("Media download rejected");
289
- const target = allowedMediaUrl(location, EUFY_OBJECT_HOST);
310
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
311
+ const target = allowedMediaUrl(location, EUFY_OBJECT_HOST, "redirect-not-allowed");
290
312
  await assertPublicResolution(target, resolver, controller.signal);
291
313
  const redirectedResponse = await fetchImpl(target, { redirect: "manual", signal: controller.signal });
292
314
  if (redirectedResponse.status >= 300 && redirectedResponse.status < 400) {
293
- throw new MediaDownloadError("Media download rejected");
315
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
316
+ }
317
+ if (redirectedResponse.status !== 200) {
318
+ throw new MediaDownloadError("Media download failed", "http-status", redirectedResponse.status);
294
319
  }
295
- if (redirectedResponse.status !== 200)
296
- throw new MediaDownloadError("Media download failed");
297
320
  return await readMediaBody(redirectedResponse);
298
321
  }
299
322
  if (originalResponse.status === 401 || originalResponse.status === 403) {
300
323
  throw new MediaDownloadAuthenticationError("Media authentication failed");
301
324
  }
302
- if (originalResponse.status !== 200)
303
- throw new MediaDownloadError("Media download failed");
325
+ if (originalResponse.status !== 200) {
326
+ throw new MediaDownloadError("Media download failed", "http-status", originalResponse.status);
327
+ }
304
328
  return await readMediaBody(originalResponse);
305
329
  } catch (error) {
306
330
  if (controller.signal.aborted)
307
- throw new MediaDownloadError("Media download timed out");
331
+ throw new MediaDownloadError("Media download timed out", "timeout");
308
332
  if (error instanceof MediaDownloadAuthenticationError)
309
333
  throw error;
310
334
  if (error instanceof MediaDownloadError)
311
335
  throw error;
312
- throw new MediaDownloadError("Media download failed");
336
+ throw new MediaDownloadError("Media download failed", "network");
313
337
  } finally {
314
338
  clearTimeout(timeout);
315
339
  }
@@ -368,8 +392,228 @@ function randomUserAgent(seed, model) {
368
392
  // dist/transport/http/decodeImageV1.js
369
393
  import { createDecipheriv as createDecipheriv2, createHash as createHash2 } from "node:crypto";
370
394
 
395
+ // dist/transport/http/jpeg-scan.js
396
+ var DHT = 196;
397
+ var SOS = 218;
398
+ var DRI = 221;
399
+ var RST_FIRST = 208;
400
+ var RST_LAST = 215;
401
+ var LUMA_SAMPLING = { 0: [1, 1], 1: [2, 1], 2: [2, 2] };
402
+ var ScanEnd = class extends Error {
403
+ };
404
+ function readHuffmanTables(segment, into) {
405
+ const end = 2 + (segment[2] << 8 | segment[3]);
406
+ let at = 4;
407
+ while (at < end) {
408
+ const id = segment[at];
409
+ const counts = segment.subarray(at + 1, at + 17);
410
+ let total = 0;
411
+ for (const count2 of counts)
412
+ total += count2;
413
+ const values = segment.slice(at + 17, at + 17 + total);
414
+ const minCode = new Int32Array(17);
415
+ const maxCode = new Int32Array(17).fill(-1);
416
+ const valPtr = new Int32Array(17);
417
+ let code = 0;
418
+ let index = 0;
419
+ for (let length = 1; length <= 16; length++) {
420
+ const count2 = counts[length - 1];
421
+ if (count2 > 0) {
422
+ valPtr[length] = index;
423
+ minCode[length] = code;
424
+ code += count2;
425
+ index += count2;
426
+ maxCode[length] = code - 1;
427
+ }
428
+ code <<= 1;
429
+ }
430
+ into.set(id, { minCode, maxCode, valPtr, values });
431
+ at += 17 + total;
432
+ }
433
+ }
434
+ var BitReader = class {
435
+ /** Byte offset of the next byte to read. */
436
+ position;
437
+ bits = 0;
438
+ count = 0;
439
+ /** Set when the reader stepped over a restart marker — the caller resets its DC predictors. */
440
+ restarted = false;
441
+ data;
442
+ constructor(data, start) {
443
+ this.data = data;
444
+ this.position = start;
445
+ }
446
+ /** The offset the last whole byte ended at — what "how much of the scan did this consume" reads. */
447
+ get consumed() {
448
+ return this.position;
449
+ }
450
+ readBit() {
451
+ if (this.count === 0) {
452
+ if (this.position >= this.data.length)
453
+ throw new ScanEnd("out of data");
454
+ let byte = this.data[this.position++];
455
+ if (byte === 255) {
456
+ const next = this.data[this.position];
457
+ if (next === 0) {
458
+ this.position++;
459
+ } else if (next !== void 0 && next >= RST_FIRST && next <= RST_LAST) {
460
+ this.position++;
461
+ this.restarted = true;
462
+ if (this.position >= this.data.length)
463
+ throw new ScanEnd("out of data");
464
+ byte = this.data[this.position++];
465
+ if (byte === 255)
466
+ throw new ScanEnd("marker after restart");
467
+ } else {
468
+ this.position--;
469
+ throw new ScanEnd("marker");
470
+ }
471
+ }
472
+ this.bits = byte;
473
+ this.count = 8;
474
+ }
475
+ this.count--;
476
+ return this.bits >> this.count & 1;
477
+ }
478
+ /** Read `length` bits, most significant first. */
479
+ readBits(length) {
480
+ let value = 0;
481
+ for (let i = 0; i < length; i++)
482
+ value = value << 1 | this.readBit();
483
+ return value;
484
+ }
485
+ /** Drop the current byte's remaining bits — what a restart interval boundary does. */
486
+ align() {
487
+ this.count = 0;
488
+ }
489
+ };
490
+ function decodeHuffman(reader2, table) {
491
+ let code = reader2.readBit();
492
+ for (let length = 1; length <= 16; length++) {
493
+ if (table.maxCode[length] >= 0 && code <= table.maxCode[length]) {
494
+ return table.values[table.valPtr[length] + code - table.minCode[length]];
495
+ }
496
+ code = code << 1 | reader2.readBit();
497
+ }
498
+ throw new ScanEnd("no huffman code of any length");
499
+ }
500
+ function extend(value, length) {
501
+ return value < 1 << length - 1 ? value - (1 << length) + 1 : value;
502
+ }
503
+ function decodeBlock(reader2, component) {
504
+ const dcLength = decodeHuffman(reader2, component.dc);
505
+ if (dcLength > 16)
506
+ throw new ScanEnd("dc magnitude out of range");
507
+ component.pred += dcLength === 0 ? 0 : extend(reader2.readBits(dcLength), dcLength);
508
+ let k = 1;
509
+ while (k < 64) {
510
+ const rs = decodeHuffman(reader2, component.ac);
511
+ const size = rs & 15;
512
+ const run = rs >> 4;
513
+ if (size === 0) {
514
+ if (run !== 15)
515
+ break;
516
+ k += 16;
517
+ } else {
518
+ k += run;
519
+ if (k > 63)
520
+ throw new ScanEnd("ac coefficient index out of range");
521
+ reader2.readBits(size);
522
+ k++;
523
+ }
524
+ }
525
+ return component.pred;
526
+ }
527
+ function scanEntropy(tail, subsampling, extraTables) {
528
+ const tables = /* @__PURE__ */ new Map();
529
+ for (const segment of extraTables)
530
+ readHuffmanTables(segment, tables);
531
+ let at = 0;
532
+ let restartInterval = 0;
533
+ let scanStart = -1;
534
+ let assignments = [];
535
+ while (at + 3 < tail.length) {
536
+ if (tail[at] !== 255)
537
+ return null;
538
+ const marker = tail[at + 1];
539
+ const length = tail[at + 2] << 8 | tail[at + 3];
540
+ if (marker === DHT) {
541
+ readHuffmanTables(tail.subarray(at, at + 2 + length), tables);
542
+ } else if (marker === DRI) {
543
+ restartInterval = tail[at + 4] << 8 | tail[at + 5];
544
+ } else if (marker === SOS) {
545
+ const count2 = tail[at + 4];
546
+ assignments = [];
547
+ for (let i = 0; i < count2; i++) {
548
+ const id = tail[at + 5 + i * 2];
549
+ const tableByte = tail[at + 6 + i * 2];
550
+ assignments.push({ id, dc: tableByte >> 4, ac: tableByte & 15 });
551
+ }
552
+ scanStart = at + 2 + length;
553
+ break;
554
+ }
555
+ at += 2 + length;
556
+ }
557
+ if (scanStart < 0 || assignments.length !== 3)
558
+ return null;
559
+ const [lumaH, lumaV] = LUMA_SAMPLING[subsampling] ?? LUMA_SAMPLING[0];
560
+ const components = [];
561
+ for (const [index, assignment] of assignments.entries()) {
562
+ const dc = tables.get(assignment.dc);
563
+ const ac = tables.get(16 | assignment.ac);
564
+ if (!dc || !ac)
565
+ return null;
566
+ components.push({ h: index === 0 ? lumaH : 1, v: index === 0 ? lumaV : 1, dc, ac, pred: 0 });
567
+ }
568
+ const reader2 = new BitReader(tail, scanStart);
569
+ let luma = new Int32Array(1024);
570
+ const lumaBlocks = lumaH * lumaV;
571
+ let mcus = 0;
572
+ let consumed = reader2.consumed;
573
+ let complete = false;
574
+ try {
575
+ for (; ; ) {
576
+ if (restartInterval > 0 && mcus > 0 && mcus % restartInterval === 0) {
577
+ reader2.align();
578
+ for (const component of components)
579
+ component.pred = 0;
580
+ }
581
+ if (reader2.restarted) {
582
+ reader2.restarted = false;
583
+ for (const component of components)
584
+ component.pred = 0;
585
+ }
586
+ let lumaSum = 0;
587
+ for (const component of components) {
588
+ for (let block = 0; block < component.h * component.v; block++) {
589
+ const dc = decodeBlock(reader2, component);
590
+ if (component === components[0])
591
+ lumaSum += dc;
592
+ }
593
+ }
594
+ if (mcus === luma.length) {
595
+ const grown = new Int32Array(luma.length * 2);
596
+ grown.set(luma);
597
+ luma = grown;
598
+ }
599
+ luma[mcus] = Math.round(lumaSum / lumaBlocks);
600
+ mcus++;
601
+ consumed = reader2.consumed;
602
+ if (consumed >= tail.length) {
603
+ complete = true;
604
+ break;
605
+ }
606
+ }
607
+ } catch (error) {
608
+ if (!(error instanceof ScanEnd))
609
+ throw error;
610
+ complete = tail.length - consumed <= 2;
611
+ }
612
+ return { mcus, luma: luma.subarray(0, mcus), complete };
613
+ }
614
+
371
615
  // dist/transport/http/decodeImageV2.js
372
- import { decode as jpegDecode, encode as jpegEncode } from "jpeg-js";
616
+ import { decode as jpegDecode } from "jpeg-js";
373
617
  var V2_PREFIX = "v2_eufysecurity:";
374
618
  var DC_CHROMA = Buffer.from([255, 196, 0, 31, 1]);
375
619
  var ZIGZAG = [
@@ -575,35 +819,6 @@ var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d0102030
575
819
  var SOI = Buffer.from([255, 216]);
576
820
  var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
577
821
  var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
578
- var LADDER = [
579
- [160, 90],
580
- [240, 135],
581
- [256, 144],
582
- [320, 180],
583
- [384, 216],
584
- [400, 225],
585
- [480, 270],
586
- [512, 288],
587
- [576, 324],
588
- [640, 360],
589
- [704, 396],
590
- [768, 432],
591
- [848, 480],
592
- [960, 540],
593
- [1024, 576],
594
- [1280, 720],
595
- [1600, 900],
596
- [1920, 1080],
597
- [176, 144],
598
- [320, 240],
599
- [352, 288],
600
- [480, 360],
601
- [640, 480],
602
- [800, 600],
603
- [1024, 768],
604
- [256, 480],
605
- [320, 384]
606
- ];
607
822
  function scaleQuant(base, quality) {
608
823
  const factor = quality < 50 ? Math.floor(5e3 / quality) : 200 - quality * 2;
609
824
  return base.map((v) => Math.min(255, Math.max(1, Math.floor((v * factor + 50) / 100))));
@@ -640,7 +855,7 @@ function sofSegment(width, height, subsampling) {
640
855
  1
641
856
  ]);
642
857
  }
643
- function buildHeader(width, height, subsampling, quality = 85) {
858
+ function buildHeader(width, height, subsampling, quality) {
644
859
  return Buffer.concat([
645
860
  SOI,
646
861
  APP0,
@@ -651,99 +866,107 @@ function buildHeader(width, height, subsampling, quality = 85) {
651
866
  DHT_AC_LUMA
652
867
  ]);
653
868
  }
654
- function decodeCandidate(tail, width, height, subsampling) {
869
+ var PROBE_MEMORY_MB = 24;
870
+ function decodeSpliced(header, tail) {
655
871
  try {
656
- const jpeg = Buffer.concat([buildHeader(width, height, subsampling), tail]);
657
- return jpegDecode(jpeg, { useTArray: true, maxMemoryUsageInMB: 128 });
872
+ return jpegDecode(Buffer.concat([header, tail]), { useTArray: true, maxMemoryUsageInMB: PROBE_MEMORY_MB });
658
873
  } catch {
659
874
  return null;
660
875
  }
661
876
  }
662
- function colorSpread(img) {
663
- const { width, height, data } = img;
664
- let sum = 0;
665
- let count2 = 0;
666
- for (let i = 0; i < width * height; i += 37) {
667
- const p = i * 4;
668
- const r = data[p];
669
- const g = data[p + 1];
670
- const b = data[p + 2];
671
- sum += Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
672
- count2++;
673
- }
674
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
675
- }
676
- function rowShear(img, rows) {
677
- const { width, data } = img;
678
- let sum = 0;
679
- let count2 = 0;
680
- for (let y = 1; y < rows; y++) {
681
- for (let x = 0; x < width; x += 4) {
682
- sum += Math.abs(data[(y * width + x) * 4] - data[((y - 1) * width + x) * 4]);
683
- count2++;
684
- }
877
+ var REFERENCE_QUALITY = 85;
878
+ var MAX_STRETCH = 8;
879
+ var CUTOFF_PERCENT = 0.5;
880
+ function trimmedRange(data, channel, total, cutoff) {
881
+ const hist = new Array(256).fill(0);
882
+ for (let i = 0; i < total; i++)
883
+ hist[data[i * 4 + channel]]++;
884
+ let remaining = Math.floor(total * cutoff / 100);
885
+ let lo = 0;
886
+ while (lo < 255 && remaining > 0) {
887
+ if (remaining < hist[lo])
888
+ break;
889
+ remaining -= hist[lo];
890
+ lo++;
891
+ }
892
+ remaining = Math.floor(total * cutoff / 100);
893
+ let hi = 255;
894
+ while (hi > 0 && remaining > 0) {
895
+ if (remaining < hist[hi])
896
+ break;
897
+ remaining -= hist[hi];
898
+ hi--;
685
899
  }
686
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
900
+ return { lo, hi };
687
901
  }
688
- function autoContrast(data, width, height, cutoff = 0.5) {
689
- const total = width * height;
902
+ function contrastScale(img, cutoff = CUTOFF_PERCENT) {
903
+ const total = img.width * img.height;
904
+ if (total === 0)
905
+ return 1;
906
+ let stretch = Number.POSITIVE_INFINITY;
690
907
  for (let channel = 0; channel < 3; channel++) {
691
- const hist = new Array(256).fill(0);
692
- for (let i = 0; i < total; i++)
693
- hist[data[i * 4 + channel]]++;
694
- let remaining = Math.floor(total * cutoff / 100);
695
- for (let bin = 0; bin < 256 && remaining > 0; bin++) {
696
- if (remaining > hist[bin]) {
697
- remaining -= hist[bin];
698
- hist[bin] = 0;
699
- } else {
700
- hist[bin] -= remaining;
701
- remaining = 0;
702
- }
703
- }
704
- remaining = Math.floor(total * cutoff / 100);
705
- for (let bin = 255; bin >= 0 && remaining > 0; bin--) {
706
- if (remaining > hist[bin]) {
707
- remaining -= hist[bin];
708
- hist[bin] = 0;
709
- } else {
710
- hist[bin] -= remaining;
711
- remaining = 0;
712
- }
713
- }
714
- let lo = 0;
715
- while (lo < 256 && hist[lo] === 0)
716
- lo++;
717
- let hi = 255;
718
- while (hi >= 0 && hist[hi] === 0)
719
- hi--;
908
+ const { lo, hi } = trimmedRange(img.data, channel, total, cutoff);
720
909
  if (hi <= lo)
721
910
  continue;
722
- const scale = 255 / (hi - lo);
723
- const offset = -lo * scale;
724
- const lut = new Uint8Array(256);
725
- for (let v = 0; v < 256; v++)
726
- lut[v] = Math.min(255, Math.max(0, Math.trunc(v * scale + offset)));
727
- for (let i = 0; i < total; i++)
728
- data[i * 4 + channel] = lut[data[i * 4 + channel]];
729
- }
730
- }
731
- function maxHeight(tail, width, subsampling, seed) {
732
- const mcuHeight = MCU[subsampling][1];
733
- let height = Math.max(mcuHeight, Math.round(seed / mcuHeight) * mcuHeight);
734
- if (!decodeCandidate(tail, width, height, subsampling)) {
735
- while (height > mcuHeight && !decodeCandidate(tail, width, height, subsampling))
736
- height -= mcuHeight;
737
- } else {
738
- while (height < 4096 && decodeCandidate(tail, width, height + mcuHeight, subsampling))
739
- height += mcuHeight;
740
- }
741
- return height;
742
- }
743
- function ladderByMcuCount(subsampling) {
744
- const [mcuWidth, mcuHeight] = MCU[subsampling];
745
- const mcus = ([width, height]) => width / mcuWidth * (height / mcuHeight);
746
- return [...LADDER].sort((a, b) => mcus(a) - mcus(b));
911
+ if (lo < 128)
912
+ stretch = Math.min(stretch, 128 / (128 - lo));
913
+ if (hi > 128)
914
+ stretch = Math.min(stretch, 127 / (hi - 128));
915
+ }
916
+ if (!Number.isFinite(stretch))
917
+ return 1;
918
+ return Math.min(MAX_STRETCH, Math.max(1, stretch));
919
+ }
920
+ function qualityForStretch(stretch) {
921
+ const referenceFactor = REFERENCE_QUALITY < 50 ? Math.floor(5e3 / REFERENCE_QUALITY) : 200 - REFERENCE_QUALITY * 2;
922
+ const factor = referenceFactor * stretch;
923
+ const quality = factor <= 100 ? (200 - factor) / 2 : 5e3 / factor;
924
+ return Math.min(99, Math.max(1, Math.round(quality)));
925
+ }
926
+ var MAX_EDGE = 4096;
927
+ var MIN_ASPECT = 0.4;
928
+ var MAX_ASPECT = 4;
929
+ function mcuRowShear(luma, mcuWidth, mcuHeight) {
930
+ if (mcuHeight < 2)
931
+ return Number.POSITIVE_INFINITY;
932
+ let sum = 0;
933
+ for (let y = 1; y < mcuHeight; y++) {
934
+ for (let x = 0; x < mcuWidth; x++)
935
+ sum += Math.abs(luma[y * mcuWidth + x] - luma[(y - 1) * mcuWidth + x]);
936
+ }
937
+ return sum / (mcuWidth * (mcuHeight - 1));
938
+ }
939
+ function bestGeometry(luma, mcus, subsampling) {
940
+ const [mcuPixelWidth, mcuPixelHeight] = MCU[subsampling];
941
+ let best = null;
942
+ for (let mcuWidth = 1; mcuWidth <= mcus; mcuWidth++) {
943
+ if (mcus % mcuWidth !== 0)
944
+ continue;
945
+ const mcuHeight = mcus / mcuWidth;
946
+ const width = mcuWidth * mcuPixelWidth;
947
+ const height = mcuHeight * mcuPixelHeight;
948
+ if (width > MAX_EDGE || height > MAX_EDGE)
949
+ continue;
950
+ const aspect = width / height;
951
+ if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
952
+ continue;
953
+ const shear = mcuRowShear(luma, mcuWidth, mcuHeight);
954
+ if (!best || shear < best.shear)
955
+ best = { subsampling, width, height, shear };
956
+ }
957
+ return best;
958
+ }
959
+ function findGeometry(tail) {
960
+ let best = null;
961
+ for (const subsampling of [0, 1, 2]) {
962
+ const scan = scanEntropy(tail, subsampling, [DHT_DC_LUMA, DHT_AC_LUMA]);
963
+ if (!scan?.complete || scan.mcus < 1)
964
+ continue;
965
+ const geometry = bestGeometry(scan.luma, scan.mcus, subsampling);
966
+ if (geometry && (!best || geometry.shear < best.shear))
967
+ best = geometry;
968
+ }
969
+ return best;
747
970
  }
748
971
  function isV2Image(data) {
749
972
  return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
@@ -755,53 +978,17 @@ function decodeImageV2(data) {
755
978
  if (cut < 0)
756
979
  return null;
757
980
  const tail = data.subarray(cut);
758
- let best = null;
759
- for (const subsampling2 of [2, 0, 1]) {
760
- let filled = null;
761
- for (const [width2, height2] of ladderByMcuCount(subsampling2)) {
762
- const img2 = decodeCandidate(tail, width2, height2, subsampling2);
763
- if (!img2)
764
- break;
765
- if (!filled || width2 * height2 > filled.width * filled.height)
766
- filled = { width: width2, height: height2, img: img2 };
767
- }
768
- if (!filled)
769
- continue;
770
- const spread = colorSpread(filled.img);
771
- if (!best || spread < best.spread)
772
- best = { spread, subsampling: subsampling2, width: filled.width, height: filled.height };
773
- }
774
- if (!best)
981
+ const geometry = findGeometry(tail);
982
+ if (!geometry)
775
983
  return null;
776
- const { subsampling, width: coarseWidth } = best;
777
- const [mcuWidth, mcuHeight] = MCU[subsampling];
778
- const coarseHeight = maxHeight(tail, coarseWidth, subsampling, best.height);
779
- const totalMcus = coarseWidth / mcuWidth * (coarseHeight / mcuHeight);
780
- let width = coarseWidth;
781
- let height = coarseHeight;
782
- let bestShear = Number.POSITIVE_INFINITY;
783
- const lowWidth = Math.max(mcuWidth * 4, Math.round(coarseWidth * 0.75 / mcuWidth) * mcuWidth);
784
- const highWidth = Math.round(coarseWidth * 1.25 / mcuWidth) * mcuWidth;
785
- for (let candidateWidth = lowWidth; candidateWidth <= highWidth; candidateWidth += mcuWidth) {
786
- const candidateHeight = Math.floor(totalMcus / (candidateWidth / mcuWidth)) * mcuHeight;
787
- if (candidateHeight < mcuHeight)
788
- continue;
789
- const img2 = decodeCandidate(tail, candidateWidth, candidateHeight, subsampling);
790
- if (!img2)
791
- continue;
792
- const shear = rowShear(img2, candidateHeight);
793
- if (shear < bestShear) {
794
- bestShear = shear;
795
- width = candidateWidth;
796
- height = candidateHeight;
797
- }
798
- }
799
- height = maxHeight(tail, width, subsampling, height);
800
- const img = decodeCandidate(tail, width, height, subsampling);
801
- if (!img)
984
+ const { width, height, subsampling } = geometry;
985
+ const reference = buildHeader(width, height, subsampling, REFERENCE_QUALITY);
986
+ const probe = decodeSpliced(reference, tail);
987
+ if (!probe)
802
988
  return null;
803
- autoContrast(img.data, img.width, img.height);
804
- return Buffer.from(jpegEncode({ data: img.data, width: img.width, height: img.height }, 90).data);
989
+ const stretch = contrastScale(probe);
990
+ const header = stretch > 1.01 ? buildHeader(width, height, subsampling, qualityForStretch(stretch)) : reference;
991
+ return Buffer.concat([header, tail]);
805
992
  }
806
993
 
807
994
  // dist/transport/http/decodeImageV1.js
@@ -905,9 +1092,25 @@ var MegaApiError = class extends Error {
905
1092
  };
906
1093
  var OWNER_ONLY_CODE = 20004;
907
1094
  var SessionExpiredError = class extends Error {
908
- constructor(message) {
1095
+ /**
1096
+ * How long the next session replacement is barred for, in milliseconds; `0` when nothing bars one now.
1097
+ *
1098
+ * The remainder of the client's own hold-off, which doubles per consecutive replacement and is capped —
1099
+ * and which every replacement extends, whether the client spent it or a login made on this error did.
1100
+ */
1101
+ retryAfterMs;
1102
+ /**
1103
+ * Whether this rejection landed inside that bar — a token replaced recently and rejected again since.
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.
1107
+ */
1108
+ contended;
1109
+ constructor(message, opts = {}) {
909
1110
  super(message);
910
1111
  this.name = "SessionExpiredError";
1112
+ this.retryAfterMs = opts.retryAfterMs ?? 0;
1113
+ this.contended = opts.contended ?? false;
911
1114
  }
912
1115
  };
913
1116
  var EufyCloudErrorCode = {
@@ -946,9 +1149,9 @@ var LoginStatus = {
946
1149
  var REAUTH_HOLD_OFF_MS = 6e4;
947
1150
  var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
948
1151
  var REAUTH_STABLE_MS = 10 * 6e4;
949
- var CONTENDED_SESSION_HINT = "another client may be signed in with the same account and device identity, and each login displaces the other's session; give each client its own openudid";
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";
950
1153
  function tokenRejected(code, msg) {
951
- return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid.*token|token.*(expired|error|not exist)|kicked|(?:token|session).*does not exist|unauthor/i.test(msg ?? "");
1154
+ 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 ?? "");
952
1155
  }
953
1156
  function withoutTokenEcho(text2) {
954
1157
  return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
@@ -960,6 +1163,13 @@ var MegaHttpClient = class {
960
1163
  sessionKey;
961
1164
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
962
1165
  sessionKeys = /* @__PURE__ */ new Map();
1166
+ /**
1167
+ * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
1168
+ * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
1169
+ *
1170
+ * The `gtoken` header is hashed from `accountUserId`: that is the id the gateway recomputes the header
1171
+ * from, rejecting a disagreement with `"gtoken not equal userid error"`.
1172
+ */
963
1173
  auth_;
964
1174
  /** captcha_id of an in-flight challenge, held between login() and solveCaptcha(). */
965
1175
  pendingCaptchaId;
@@ -989,9 +1199,11 @@ var MegaHttpClient = class {
989
1199
  loggingIn = false;
990
1200
  /** The one in-flight re-login every call rejected on the same dead token waits on. */
991
1201
  reauthAttempt;
992
- /** Replacements since the held session last proved stable, and when the last one ran — see {@link recoveryDue}. */
1202
+ /** Replacements since the held session last proved stable, and when the last one ran — see {@link holdOffRemainingMs}. */
993
1203
  recoveries = 0;
994
1204
  lastRecoveryAt = 0;
1205
+ /** A token of ours has been rejected and not yet replaced — see {@link noteTokenReplacement}. */
1206
+ rejectedTokenPending = false;
995
1207
  constructor(cfg) {
996
1208
  this.cfg = {
997
1209
  appName: "eufy_mega",
@@ -1025,7 +1237,12 @@ var MegaHttpClient = class {
1025
1237
  if (!isSessionValid(saved) || !saved)
1026
1238
  return void 0;
1027
1239
  this.region = saved.region;
1028
- this.auth_ = { userId: saved.userId, authToken: saved.authToken, geoKey: saved.geoKey };
1240
+ this.auth_ = {
1241
+ userId: saved.userId,
1242
+ accountUserId: saved.accountUserId,
1243
+ authToken: saved.authToken,
1244
+ geoKey: saved.geoKey
1245
+ };
1029
1246
  this.tokenExpiresAt = saved.tokenExpiresAt;
1030
1247
  this.sessionKey = {
1031
1248
  keyIdent: saved.keyIdent,
@@ -1045,12 +1262,20 @@ var MegaHttpClient = class {
1045
1262
  return this.region;
1046
1263
  }
1047
1264
  /**
1048
- * The logged-in account's display name — the login email's local-part (e.g. `someone+tag` for
1049
- * `someone+tag@example.com`). This is the string the app writes into the ff09 command's acting
1050
- * "username" field (verified against a captured T8531 unlock frame). Falls back to the whole email
1051
- * if it has no `@`.
1265
+ * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
1266
+ * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
1267
+ * is the login email's local-part (e.g. `someone+tag` for `someone+tag@example.com`) and falls back
1268
+ * to the whole email if it has no `@`.
1269
+ *
1270
+ * The local-part is the string the app writes into the ff09 command's acting "username" field
1271
+ * (verified against a captured T8531 unlock frame), so it is the faithful default. An override is a
1272
+ * different LABEL for the same account, not a different identity: the session authenticates on the
1273
+ * token and the device record's member ids, neither of which this touches.
1052
1274
  */
1053
1275
  get accountName() {
1276
+ const pinned = this.cfg.accountName?.trim();
1277
+ if (pinned)
1278
+ return pinned;
1054
1279
  const email = this.cfg.email ?? "";
1055
1280
  const at = email.indexOf("@");
1056
1281
  return at > 0 ? email.slice(0, at) : email;
@@ -1088,7 +1313,16 @@ var MegaHttpClient = class {
1088
1313
  };
1089
1314
  }
1090
1315
  /**
1091
- * The account-credential headers every authed call carries — `x-auth-token` + `gtoken` (`md5(userId)`).
1316
+ * The id `gtoken` is hashed from — the account's own `user_id`, which is what the gateway recomputes the
1317
+ * header from. One place so the two header paths cannot drift on which of the session's ids that is.
1318
+ *
1319
+ * Call only where `auth_` is already established; every header path guards it.
1320
+ */
1321
+ gtokenUserId() {
1322
+ return this.auth_.accountUserId ?? this.auth_.userId;
1323
+ }
1324
+ /**
1325
+ * The account-credential headers every authed call carries — `x-auth-token` + `gtoken`.
1092
1326
  * One place so the signed path, the key-exchange and the bearer path can't drift on what "authed" means.
1093
1327
  */
1094
1328
  authTokenHeaders() {
@@ -1097,7 +1331,7 @@ var MegaHttpClient = class {
1097
1331
  return {
1098
1332
  "x-auth-token": this.auth_.authToken,
1099
1333
  authorization: this.auth_.authToken,
1100
- gtoken: gtoken(this.auth_.userId)
1334
+ gtoken: gtoken(this.gtokenUserId())
1101
1335
  };
1102
1336
  }
1103
1337
  /**
@@ -1252,12 +1486,17 @@ var MegaHttpClient = class {
1252
1486
  const tokenFinished = identityError && retry.identity || authed && last?.status === 401 && tokenRejected(last.code, last.msg);
1253
1487
  if (tokenFinished) {
1254
1488
  const reason = `${path} failed (${last?.status}/${last?.code}): ${last?.msg}`;
1489
+ this.rejectedTokenPending = true;
1255
1490
  const recovery = retry.reauth ? "unrecoverable" : await this.recoverRejectedSession(sentToken);
1256
1491
  if (recovery === "retry")
1257
1492
  return this.postSigned(host, path, body, authed, { ...retry, reauth: true }, headerOverrides);
1258
1493
  if (!this.sessionReplacedSince(sentToken))
1259
1494
  this.clearSession();
1260
- throw new SessionExpiredError(recovery === "held-off" ? `${reason} \u2014 ${CONTENDED_SESSION_HINT}` : reason);
1495
+ const contended = recovery === "held-off";
1496
+ throw new SessionExpiredError(contended ? `${reason} \u2014 ${CONTENDED_SESSION_HINT}` : reason, {
1497
+ retryAfterMs: this.holdOffRemainingMs(),
1498
+ contended
1499
+ });
1261
1500
  }
1262
1501
  throw new MegaApiError(`${path} failed (${last?.status}/${last?.code}): ${last?.msg}`, last?.code, last?.status);
1263
1502
  }
@@ -1401,7 +1640,7 @@ var MegaHttpClient = class {
1401
1640
  try {
1402
1641
  return await downloadMediaResource(url, {
1403
1642
  "x-auth-token": this.auth_.authToken,
1404
- gtoken: gtoken(this.auth_.userId),
1643
+ gtoken: gtoken(this.gtokenUserId()),
1405
1644
  "app-name": "eufy_mega",
1406
1645
  "model-type": "PHONE",
1407
1646
  "user-agent": this.mediaUserAgent
@@ -1413,9 +1652,20 @@ var MegaHttpClient = class {
1413
1652
  throw error;
1414
1653
  }
1415
1654
  }
1416
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
1655
+ /**
1656
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
1657
+ *
1658
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
1659
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
1660
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
1661
+ */
1417
1662
  async downloadImage(url, p2pDid) {
1418
- return normalizePushImage(await this.downloadMedia(url), p2pDid);
1663
+ const data = await this.downloadMedia(url);
1664
+ try {
1665
+ return normalizePushImage(data, p2pDid);
1666
+ } catch (error) {
1667
+ throw mediaFailureError("Push image could not be decoded", "decode-failed", error);
1668
+ }
1419
1669
  }
1420
1670
  /** The security-app data host for this region (face recognition, media, etc.). */
1421
1671
  securityAppHost() {
@@ -1571,8 +1821,7 @@ var MegaHttpClient = class {
1571
1821
  return "unrecoverable";
1572
1822
  if (!this.recoveryDue())
1573
1823
  return "held-off";
1574
- this.recoveries++;
1575
- this.lastRecoveryAt = Date.now();
1824
+ this.noteTokenReplacement();
1576
1825
  this.clearSession();
1577
1826
  return await this.reauthenticate() ? "retry" : "unrecoverable";
1578
1827
  }
@@ -1587,20 +1836,47 @@ var MegaHttpClient = class {
1587
1836
  * and a caller is told the honest reason instead of being served a fight.
1588
1837
  */
1589
1838
  recoveryDue() {
1590
- if (this.recoveries === 0)
1591
- return true;
1592
- const wait = Math.min(REAUTH_HOLD_OFF_MS * 2 ** (this.recoveries - 1), REAUTH_HOLD_OFF_CAP_MS);
1593
- const waited = Date.now() - this.lastRecoveryAt;
1594
- if (waited >= wait)
1839
+ const remaining = this.holdOffRemainingMs();
1840
+ if (remaining === 0)
1595
1841
  return true;
1596
- this.logger.warn(`[mega] token rejected ${Math.round(waited / 1e3)}s after the last replacement \u2014 holding off ${Math.round(wait / 1e3)}s. ${CONTENDED_SESSION_HINT}`);
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}`);
1597
1843
  return false;
1598
1844
  }
1845
+ /**
1846
+ * How much longer a token replacement must wait, in milliseconds; `0` when one may run now.
1847
+ *
1848
+ * The wait doubles per consecutive replacement and is capped, and it is what {@link recoveryDue} gates
1849
+ * this client's own recovery on — and what {@link SessionExpiredError.retryAfterMs} hands a host that
1850
+ * drives its own. One function so the two cannot disagree about the rate, which they would have to for
1851
+ * a host to be told it may retry while this client is still holding off.
1852
+ */
1853
+ holdOffRemainingMs() {
1854
+ if (this.recoveries === 0)
1855
+ return 0;
1856
+ const wait = Math.min(REAUTH_HOLD_OFF_MS * 2 ** (this.recoveries - 1), REAUTH_HOLD_OFF_CAP_MS);
1857
+ return Math.max(0, wait - (Date.now() - this.lastRecoveryAt));
1858
+ }
1859
+ /**
1860
+ * Count one token replacement against the hold-off, and clear the rejection it answered.
1861
+ *
1862
+ * Every replacement passes through here, wherever it was spent from: {@link recoverRejectedSession}, and
1863
+ * a {@link login} that follows a rejection this client surfaced. A hold-off that counted only its own
1864
+ * would be no bound at all — the wait would sit at its first value however many sessions had been spent,
1865
+ * and {@link SessionExpiredError.retryAfterMs} would report a minute while logins ran every few seconds.
1866
+ * Which of the two counted a given replacement is the flag: the recovery path clears it before logging
1867
+ * in, so the login cannot count the same one again.
1868
+ */
1869
+ noteTokenReplacement() {
1870
+ this.recoveries++;
1871
+ this.lastRecoveryAt = Date.now();
1872
+ this.rejectedTokenPending = false;
1873
+ }
1599
1874
  /**
1600
1875
  * Note that the held session is working. A replacement that keeps serving calls for long enough is not
1601
1876
  * contention, so the hold-off is forgotten and the next genuine expiry recovers immediately.
1602
1877
  */
1603
1878
  noteSessionWorking() {
1879
+ this.rejectedTokenPending = false;
1604
1880
  if (this.recoveries > 0 && Date.now() - this.lastRecoveryAt > REAUTH_STABLE_MS)
1605
1881
  this.recoveries = 0;
1606
1882
  }
@@ -1692,17 +1968,19 @@ var MegaHttpClient = class {
1692
1968
  {
1693
1969
  const cap = Object.keys(res).filter((k) => /captcha|answer|picture|image|fa_/i.test(k));
1694
1970
  this.logger.debug("[mega] login resp keys:", Object.keys(res).join(","));
1971
+ this.logger.debug("[mega] ids agree:", res.ap_cloud_user_id === res.user_id);
1695
1972
  if (cap.length)
1696
1973
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1697
1974
  }
1698
1975
  const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1976
+ const accountUserId = res.user_id ?? res.userId;
1699
1977
  const authToken = res.auth_token ?? res.token;
1700
1978
  if (!userId || !authToken)
1701
1979
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1702
1980
  const faInfo = res.fa_info ?? {};
1703
1981
  const needs2fa = !isVerify && !!faInfo.info;
1704
1982
  if (needs2fa) {
1705
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1983
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1706
1984
  this.pendingCaptchaId = void 0;
1707
1985
  this.pending2fa = true;
1708
1986
  await this.sendVerifyCode(messageType);
@@ -1710,11 +1988,13 @@ var MegaHttpClient = class {
1710
1988
  }
1711
1989
  this.pendingCaptchaId = void 0;
1712
1990
  this.pending2fa = false;
1713
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1991
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1714
1992
  this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
1715
1993
  this.sessionKey = void 0;
1716
1994
  await this.ensureSessionKey();
1717
1995
  this.persist();
1996
+ if (this.rejectedTokenPending)
1997
+ this.noteTokenReplacement();
1718
1998
  return { status: LoginStatus.Ok, session: { userId, authToken, geoKey: this.auth_.geoKey, raw: res } };
1719
1999
  }
1720
2000
  /** Save the current token + session key for reuse across runs. */
@@ -1723,6 +2003,7 @@ var MegaHttpClient = class {
1723
2003
  return;
1724
2004
  this.store.save({
1725
2005
  userId: this.auth_.userId,
2006
+ accountUserId: this.auth_.accountUserId ?? this.auth_.userId,
1726
2007
  authToken: this.auth_.authToken,
1727
2008
  geoKey: this.auth_.geoKey,
1728
2009
  region: this.region,
@@ -1810,6 +2091,19 @@ function parseSecureTopic(topic) {
1810
2091
  return void 0;
1811
2092
  return { root, category, model, sn, tail: parts.slice(4).join("/") };
1812
2093
  }
2094
+ function solixDeviceTopics(appName, productCode, deviceSn) {
2095
+ const dt = `dt/${appName}/${productCode}/${deviceSn}`;
2096
+ const cmd = `cmd/${appName}/${productCode}/${deviceSn}`;
2097
+ return {
2098
+ paramInfo: `${dt}/param_info`,
2099
+ stateInfo: `${dt}/state_info`,
2100
+ cmdRes: `${cmd}/app/res`,
2101
+ req: `${cmd}/req`
2102
+ };
2103
+ }
2104
+ function solixUserTopics(appName, userId) {
2105
+ return { cmdRes: `cmd/${appName}/${userId}/res`, powerSite: `dt/${appName}/${userId}/power_site` };
2106
+ }
1813
2107
 
1814
2108
  // dist/transport/mqtt/secure-mqtt.js
1815
2109
  var SUBACK_FAILURE = 128;
@@ -1903,7 +2197,7 @@ var SecureMqtt = class extends EventEmitter {
1903
2197
  * four topics for `eufy_life`).
1904
2198
  *
1905
2199
  * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
1906
- * `SUBACK_FAILURE` (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
2200
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
1907
2201
  * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
1908
2202
  * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
1909
2203
  * line that grants its state channel but refuses (say) the OTA leg still works.
@@ -1912,9 +2206,8 @@ var SecureMqtt = class extends EventEmitter {
1912
2206
  if (!this.client)
1913
2207
  throw new Error("SecureMqtt not connected");
1914
2208
  const topics = [...subscribeTopics(device)];
1915
- const grants = await this.client.subscribeAsync(topics, { qos: 1 });
2209
+ const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
1916
2210
  const scope = this.o.credentials.app_name ?? "default";
1917
- const denied = grants.filter((g) => g.qos === SUBACK_FAILURE).map((g) => g.topic);
1918
2211
  if (denied.length === topics.length) {
1919
2212
  throw new Error(`subscribe ${device.sn}: every topic denied on credential scope "${scope}" \u2014 this line's topics are granted to a different scope (${denied.join(", ")})`);
1920
2213
  }
@@ -1922,6 +2215,29 @@ var SecureMqtt = class extends EventEmitter {
1922
2215
  this.emit("error", new Error(`subscribe ${device.sn}: "${topic}" denied on credential scope "${scope}"`));
1923
2216
  }
1924
2217
  }
2218
+ /**
2219
+ * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
2220
+ * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
2221
+ * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
2222
+ * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
2223
+ */
2224
+ async subscribe(topics) {
2225
+ if (!this.client)
2226
+ throw new Error("SecureMqtt not connected");
2227
+ return this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 })).granted;
2228
+ }
2229
+ /**
2230
+ * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
2231
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
2232
+ * this split and layer their own policy (drop vs report) on top.
2233
+ */
2234
+ partitionGrants(grants) {
2235
+ const granted = [];
2236
+ const denied = [];
2237
+ for (const g of grants)
2238
+ (g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
2239
+ return { granted, denied };
2240
+ }
1925
2241
  /**
1926
2242
  * Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
1927
2243
  * is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the
@@ -2556,14 +2872,26 @@ var CameraDisabledError = class extends Error {
2556
2872
  this.name = "CameraDisabledError";
2557
2873
  }
2558
2874
  };
2559
- var StationBusyError = class extends Error {
2560
- servingChannel;
2561
- /** Always true: the station is busy now, and stops being busy when the other stream is released. */
2875
+ var StationKeyUnavailableError = class extends Error {
2876
+ stationSn;
2877
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
2878
+ retryable = true;
2879
+ constructor(stationSn, options) {
2880
+ super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
2881
+ this.stationSn = stationSn;
2882
+ this.name = "StationKeyUnavailableError";
2883
+ }
2884
+ };
2885
+ var StationUnreachableError = class extends Error {
2886
+ stationSn;
2887
+ waitedMs;
2888
+ /** Always true: a station unreachable now may answer on a later attempt. */
2562
2889
  retryable = true;
2563
- constructor(servingChannel, options) {
2564
- super(`the station is already serving channel ${servingChannel} to a viewer, and serves one camera at a time \u2014 stop that stream before opening another`, options);
2565
- this.servingChannel = servingChannel;
2566
- this.name = "StationBusyError";
2890
+ constructor(stationSn, waitedMs, options) {
2891
+ super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
2892
+ this.stationSn = stationSn;
2893
+ this.waitedMs = waitedMs;
2894
+ this.name = "StationUnreachableError";
2567
2895
  }
2568
2896
  };
2569
2897
  var LiveStreamStartError = class extends Error {
@@ -3533,7 +3861,7 @@ function sensitivityCommand(step, scale, ctx) {
3533
3861
  return setScalar(scale.writeId, value, ctx, "direct-binary");
3534
3862
  if (scale.form === "control")
3535
3863
  return setJson(scale.writeId, { index: value }, ctx);
3536
- return setPayload(scale.writeId, { sensitivity: value, channel: ctx.channel }, ctx, 0);
3864
+ return setPayload(scale.writeId, { sensitivity: value, channel: ctx.channel }, ctx, 0, void 0, "auto");
3537
3865
  }
3538
3866
  var EXIT_TEST_MODE_VALUE = 808464384;
3539
3867
  function requireFamily(action, ctx, verifiedOn) {
@@ -3611,7 +3939,7 @@ var MOTION_MEMBERS = {
3611
3939
  const n = Number(v);
3612
3940
  if (!Number.isInteger(n) || n < 0)
3613
3941
  return void 0;
3614
- return setPayload(MOTION_CMD.AI_DETECT_TYPE, { ai_detect_type: n, channel: ctx.channel }, ctx, 0, 0);
3942
+ return setPayload(MOTION_CMD.AI_DETECT_TYPE, { ai_detect_type: n, channel: ctx.channel }, ctx, 0, 0, "auto");
3615
3943
  }
3616
3944
  },
3617
3945
  /**
@@ -3724,7 +4052,7 @@ var MOTION_MEMBERS = {
3724
4052
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
3725
4053
  write: (v, ctx) => {
3726
4054
  requireFamily("humanOnlyAtNight", ctx, "camera");
3727
- return setPayload(MOTION_CMD.HUMAN_ONLY_AT_NIGHT, { only_ai: asBool(v) ? 1 : 0 }, ctx, 0);
4055
+ return setPayload(MOTION_CMD.HUMAN_ONLY_AT_NIGHT, { only_ai: asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto");
3728
4056
  }
3729
4057
  },
3730
4058
  /**
@@ -3741,7 +4069,7 @@ var MOTION_MEMBERS = {
3741
4069
  requires: [MOTION_CMD.LOITERING_DETECTION],
3742
4070
  write: (v, ctx) => {
3743
4071
  requireFamily("loiteringDetection", ctx, "camera");
3744
- return setPayload(MOTION_CMD.LOITERING_DETECTION, { radar_wd_switch: asBool(v) ? 1 : 0 }, ctx, 0);
4072
+ return setPayload(MOTION_CMD.LOITERING_DETECTION, { radar_wd_switch: asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto");
3745
4073
  }
3746
4074
  }
3747
4075
  };
@@ -3923,12 +4251,32 @@ function decodePowerSource(raw) {
3923
4251
  function recordSetting(param, value, ctx) {
3924
4252
  return setStationScalar(param, value, ctx.channel);
3925
4253
  }
3926
- var MAINS_CAMERA_MODELS = ["T8425", "T8419"];
4254
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410"];
3927
4255
  var notMainsCamera = (ctx) => {
3928
4256
  const model = (ctx.model ?? "").toUpperCase();
3929
4257
  return !MAINS_CAMERA_MODELS.some((prefix) => model.startsWith(prefix));
3930
4258
  };
3931
- var BATTERY_MEMBERS = {
4259
+ var CELL_PARAMS = [
4260
+ BATTERY_PARAM.BATTERY,
4261
+ BATTERY_PARAM.BATTERY_STATUS,
4262
+ BATTERY_PARAM.BATTERY_TEMP,
4263
+ BATTERY_PARAM.BATTERY_HEALTH,
4264
+ BATTERY_PARAM.SOLAR_INTENSITY,
4265
+ BATTERY_PARAM.SOLAR_CONNECT_24H,
4266
+ BATTERY_PARAM.BATTERY_POWER_DATAS
4267
+ ];
4268
+ function cellGated(members) {
4269
+ const gated = Object.entries(members).map(([name, m]) => {
4270
+ const param = m.param;
4271
+ if (typeof param !== "number" || !CELL_PARAMS.includes(param))
4272
+ return [name, m];
4273
+ const own = m.available;
4274
+ const available = own ? (ctx) => notMainsCamera(ctx) && own(ctx) : notMainsCamera;
4275
+ return [name, { ...m, available }];
4276
+ });
4277
+ return Object.fromEntries(gated);
4278
+ }
4279
+ var BATTERY_MEMBERS = cellGated({
3932
4280
  /**
3933
4281
  * The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
3934
4282
  * device is battery-powered, which is also the fact `MediaProvider` reads to decide a stream needs a
@@ -3941,7 +4289,6 @@ var BATTERY_MEMBERS = {
3941
4289
  unit: "%",
3942
4290
  kind: "percent",
3943
4291
  provenance: "verified",
3944
- available: notMainsCamera,
3945
4292
  description: "Battery level 0-100 (verified: param 1101)."
3946
4293
  },
3947
4294
  /**
@@ -3954,7 +4301,6 @@ var BATTERY_MEMBERS = {
3954
4301
  type: "bool",
3955
4302
  kind: "boolean",
3956
4303
  provenance: "apk",
3957
- available: notMainsCamera,
3958
4304
  coerce: (v) => {
3959
4305
  const n = Number(v);
3960
4306
  return n !== 0 && n !== 2;
@@ -3980,7 +4326,7 @@ var BATTERY_MEMBERS = {
3980
4326
  args: [{ name: "source", kind: "enum", description: "The raw value, or the name `battery`/`external`." }],
3981
4327
  ...accepts(),
3982
4328
  description: "Configured power source (1293): 0 = Battery, 1 = External Solar Panel. Verified live on T8124R. The richer raw APP_CMD_SET_POWER_SOURCE blob is surfaced separately as `powerSourceInfo` (6445). Format varies by model (plain int or JSON {power_source:N}); values beyond 0/1 shown raw.",
3983
- write: (v, ctx) => setPayload(BATTERY_PARAM.POWER_CHARGE, { charge_mode: v === "external" ? 1 : v === "battery" ? 0 : asBool(v) ? 1 : 0 }, ctx, 0)
4329
+ write: (v, ctx) => setPayload(BATTERY_PARAM.POWER_CHARGE, { charge_mode: v === "external" ? 1 : v === "battery" ? 0 : asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto")
3984
4330
  },
3985
4331
  /**
3986
4332
  * A DEVICE-SPECIFIC mode index: the app maps value→mode through a per-model config (Hermes
@@ -4125,6 +4471,11 @@ var BATTERY_MEMBERS = {
4125
4471
  * Reported, so it stays in the schema and answers through `getProperty` — but given no typed getter:
4126
4472
  * the payload's fields have never been decoded, and a getter would hand back an opaque blob typed as
4127
4473
  * though it meant something.
4474
+ *
4475
+ * `unexposed` is NOT a substitute for the cell gate. It suppresses the fluent GETTER; `propertiesOf`
4476
+ * filters `writeOnly` and `available` and deliberately not `unexposed`, because a schema entry
4477
+ * reachable through `getProperty` is the whole point of the mark. So a cell param needs
4478
+ * {@link CELL_PARAMS} either way, or a mains camera publishes "battery power history" and answers it.
4128
4479
  */
4129
4480
  batteryPowerStats: {
4130
4481
  param: BATTERY_PARAM.BATTERY_POWER_DATAS,
@@ -4150,7 +4501,7 @@ var BATTERY_MEMBERS = {
4150
4501
  unexposed: true,
4151
4502
  description: "Raw GET_CAMERA_INFO value (1103), meaning unevidenced \u2014 read live as the constant 5 on a T8170 at 92% and a T8171 at 27%, so it is NOT a low-battery flag."
4152
4503
  }
4153
- };
4504
+ });
4154
4505
  var BATTERY = {
4155
4506
  capability: "battery",
4156
4507
  description: "Battery level, charging, health, temperature, and solar input.",
@@ -4293,6 +4644,10 @@ var CONTACT_MEMBERS = {
4293
4644
  * `1350` SET_PAYLOAD, `mChannel` = the device channel, `mValue3` 0, payload carrying the channel and a
4294
4645
  * transaction stamp — the byte-shape of the app's own captured frame. Out of range is refused rather
4295
4646
  * than clamped: the app's slider has no values outside it, so one is a caller error, not a nudge.
4647
+ *
4648
+ * Level-2 only, though the `mValue3` 0 would allow `"auto"`: an entry sensor's session IS its
4649
+ * HomeBase's, which always holds a key — the second of `setPayload`'s two conditions, not an oversight
4650
+ * of the first.
4296
4651
  */
4297
4652
  alarmVolume: {
4298
4653
  param: CONTACT_CMD.ALARM_VOLUME,
@@ -4520,7 +4875,7 @@ var AUDIO_MEMBERS = {
4520
4875
  provenance: "verified",
4521
4876
  available: isCameraCodec,
4522
4877
  description: "Record audio with video (1288 record_mute, inverted). \u2705 HW-verified on T8425: readback flips (on\u21921288=0, off\u21921288=1). The write is a 1350 SET_PAYLOAD on the device channel with `{channel, record_mute}` \u2014 the key is record_mute and it is INVERTED.",
4523
- write: (v, ctx) => setPayload(AUDIO_CMD.AUDIO_RECORDING, { channel: ctx.channel, record_mute: asBool(v) ? 0 : 1 }, ctx, 0)
4878
+ write: (v, ctx) => setPayload(AUDIO_CMD.AUDIO_RECORDING, { channel: ctx.channel, record_mute: asBool(v) ? 0 : 1 }, ctx, 0, void 0, "auto")
4524
4879
  },
4525
4880
  /**
4526
4881
  * Doorbell ring/chime loudness — WRITE-ONLY here on purpose. The READ property `ringtoneVolume` (1708)
@@ -4968,7 +5323,7 @@ var CAMERA_MEMBERS = {
4968
5323
  description: "Night-vision mode (NIGHT_VISION_TYPE 1277): 0 = off, 1 = infrared (B&W), 2 = full colour. \u2705 wire verified live (T8425 ch3): 1350 SET_PAYLOAD, mChannel 0, {channel:N, night_sion:mode}. Enum labels are best-guess. Some models omit full colour.",
4969
5324
  write: (v, ctx) => {
4970
5325
  const nv = coerceEnumValue(NightVision, v);
4971
- return nv == null ? void 0 : setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0);
5326
+ return nv == null ? void 0 : setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0, "auto");
4972
5327
  }
4973
5328
  },
4974
5329
  /**
@@ -5016,7 +5371,7 @@ var CAMERA_MEMBERS = {
5016
5371
  ...accepts(),
5017
5372
  write: (v, ctx) => {
5018
5373
  const q = resolveRecordingQualityTier(v);
5019
- return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0);
5374
+ return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0, void 0, "auto");
5020
5375
  }
5021
5376
  },
5022
5377
  /**
@@ -5396,7 +5751,7 @@ function autoSpotlightCommand(on, ctx, opts = {}) {
5396
5751
  schedule: [],
5397
5752
  sunset2rise: 0,
5398
5753
  time: opts.time ?? 30
5399
- }, ctx, 0);
5754
+ }, ctx, 0, void 0, "auto");
5400
5755
  }
5401
5756
  function lightSwitchWire(ctx) {
5402
5757
  const t = ctx.deviceType;
@@ -5935,7 +6290,7 @@ function zoomCommand(dstZoom, ctx, region) {
5935
6290
  offset,
5936
6291
  orgZoom: r.orgZoom ?? 0,
5937
6292
  dstZoom
5938
- }, ctx, 0);
6293
+ }, ctx, 0, void 0, "auto");
5939
6294
  }
5940
6295
  function gotoPresetCommand(id, ctx) {
5941
6296
  return setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx);
@@ -6331,7 +6686,7 @@ var DOORBELL_MEMBERS = {
6331
6686
  min: 0,
6332
6687
  max: 100,
6333
6688
  description: "Doorbell chime volume, 0-100 (1717 APP_CMD_BAT_DOORBELL_SET_DINGDONG_VOLUME; SET_PAYLOAD envelope, distinct from the direct-binary ringtoneVolume/1708 \u2014 see DOORBELL_CMD.DINGDONG_VOLUME). Write wire-confirmed live on T8214, observed values 3 and 25.",
6334
- write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0)
6689
+ write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0, void 0, "auto")
6335
6690
  },
6336
6691
  /**
6337
6692
  * The same `1350` SET_PAYLOAD shape and capture session as {@link DOORBELL_MEMBERS.dingdongVolume},
@@ -6349,7 +6704,7 @@ var DOORBELL_MEMBERS = {
6349
6704
  description: "Doorbell chime ringtone SELECTION INDEX, not a bool (1718 APP_CMD_BAT_DOORBELL_SET_DINGDONG_RINGTONE; SET_PAYLOAD envelope \u2014 see DOORBELL_CMD.DINGDONG_RINGTONE). Write wire-confirmed live on T8214, observed values 4 and 0. Named options: see the {@link DoorbellRingtone} enum. A write outside that enum is REJECTED, not clamped into range \u2014 this is a selection, not a volume, so a bad index would otherwise land on a real-but-wrong tone.",
6350
6705
  write: (v, ctx) => {
6351
6706
  const tone = coerceEnumValue(DoorbellRingtone, v);
6352
- return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0);
6707
+ return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0, void 0, "auto");
6353
6708
  }
6354
6709
  },
6355
6710
  /**
@@ -6894,8 +7249,18 @@ var ArmingMode = {
6894
7249
  away: "away",
6895
7250
  /** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
6896
7251
  home: "home",
7252
+ /** Scheduled — the station follows the timetable configured in the app (wire value 2). */
7253
+ schedule: "schedule",
6897
7254
  /** Custom 1 — a user-defined posture configured in the app (wire value 3). */
6898
7255
  custom1: "custom1",
7256
+ /** Custom 2 — a user-defined posture configured in the app (wire value 4). */
7257
+ custom2: "custom2",
7258
+ /** Custom 3 — a user-defined posture configured in the app (wire value 5). */
7259
+ custom3: "custom3",
7260
+ /** Off — the station's alarm system is switched off entirely (wire value 6). */
7261
+ off: "off",
7262
+ /** Geofenced — the station follows the app's location-based rules (wire value 47). */
7263
+ geo: "geo",
6899
7264
  /** Disarmed — no alarms; sensors still report state (wire value 63). */
6900
7265
  disarmed: "disarmed"
6901
7266
  };
@@ -6916,10 +7281,14 @@ var ARMING_CMD = {
6916
7281
  *
6917
7282
  * ⚠️ Only 3 of the 9 modes were exercised in that capture — `mode_type` 0 (away), 63 (disarmed), 1
6918
7283
  * (home), all confirmed byte-exact. Re-confirmed live 2026-08-05: each reported its own MODE_SWITCH push
6919
- * within ~5s of the write. `custom1` 3 joined {@link ArmingMode} on a live confirmation rather than a
6920
- * capture, making four settable in total. The remaining five are named by the app but never observed
6921
- * leaving it, so this capability reads them and refuses to send them. See `ARMING_MODE_WIRE` for the
6922
- * per-value breakdown.
7284
+ * within ~5s of the write. The remaining six are live confirmations rather than captures — `custom1` 3
7285
+ * first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
7286
+ * frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
7287
+ * the per-value evidence and the dates.
7288
+ *
7289
+ * The ENCRYPTION LEVEL is the session's to pick (`"auto"`), not this command's: the T8030 the envelope
7290
+ * was captured on holds a level-2 key and seals it level-2, while an own-session camera that never
7291
+ * negotiates one carries the same envelope level-1. See `armingCommand`.
6923
7292
  */
6924
7293
  SET_ARMING: 1224,
6925
7294
  /**
@@ -6953,20 +7322,14 @@ var ARMING_MODE_WIRE = {
6953
7322
  away: 0,
6954
7323
  home: 1,
6955
7324
  schedule: 2,
6956
- // ⚠️ reportable, NOT settable — see the doc comment above
6957
7325
  custom1: 3,
6958
7326
  custom2: 4,
6959
- // ⚠️ reportable, NOT settable — see the doc comment above
6960
7327
  custom3: 5,
6961
- // ⚠️ reportable, NOT settable — see the doc comment above
6962
7328
  off: 6,
6963
- // ⚠️ reportable, NOT settable — see the doc comment above
6964
7329
  geo: 47,
6965
- // ⚠️ reportable, NOT settable — see the doc comment above
6966
7330
  disarmed: 63
6967
7331
  };
6968
7332
  var ARMING_MODE_LABELS = enumLabels(ARMING_MODE_WIRE);
6969
- var SETTABLE_MODES = Object.values(ArmingMode).map((m) => ARMING_MODE_WIRE[m]);
6970
7333
  function armingModeOf(v) {
6971
7334
  const name = String(v);
6972
7335
  if (name in ArmingMode)
@@ -6975,11 +7338,10 @@ function armingModeOf(v) {
6975
7338
  return Object.values(ArmingMode).find((m) => ARMING_MODE_WIRE[m] === wire);
6976
7339
  }
6977
7340
  function armingCommand(mode, ctx) {
6978
- const modeType = ARMING_MODE_WIRE[mode];
6979
7341
  if (!ctx.accountName) {
6980
7342
  throw new Error(`arming: missing account identity (user_name) [${describeDevice(ctx)}]`);
6981
7343
  }
6982
- return setPayload(ARMING_CMD.SET_ARMING, { mode_type: modeType, user_name: ctx.accountName }, ctx, 0);
7344
+ return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0, void 0, "auto");
6983
7345
  }
6984
7346
  function alarmDelayCommand(mode, config, ctx) {
6985
7347
  const data = {
@@ -6996,11 +7358,10 @@ function alarmDelayCommand(mode, config, ctx) {
6996
7358
  }
6997
7359
  var ARMING_MEMBERS = {
6998
7360
  /**
6999
- * The one member whose write domain is NARROWER than its read: `enumValues` names all nine modes a
7000
- * station can report, and the argument's `values` publishes only the four whose write is confirmed. That
7001
- * argument IS the domain the derived setter enforces and the refusal names, so an unconfirmed mode is
7002
- * refused by naming the four that work — nine labels for the read and four for the write, off one
7003
- * declaration.
7361
+ * Read and write are the same nine modes, so `enumValues` is the whole domain: `writeDomain` falls back
7362
+ * to it, and the derived setter, the refusal message and the offered argument all read from that one
7363
+ * declaration. A member states an `args` entry only where the two sides DIFFER. See
7364
+ * {@link ARMING_MODE_WIRE} for the per-value evidence.
7004
7365
  *
7005
7366
  * `armingCommand` may also throw synchronously (missing account identity) and `bindMembers` turns that
7006
7367
  * into a rejection, so the builder stays plain.
@@ -7019,8 +7380,7 @@ var ARMING_MEMBERS = {
7019
7380
  kind: "enum",
7020
7381
  enumValues: ARMING_MODE_LABELS,
7021
7382
  provenance: "verified",
7022
- args: [{ name: "mode", kind: "enum", values: SETTABLE_MODES }],
7023
- description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads all 9 modes the app defines; SETS only the 4 whose write is confirmed (away/home/custom1/disarmed) \u2014 schedule/custom2/custom3/off/geo are named by the app but no capture shows one being sent, so they are refused rather than guessed; see ARMING_MODE_WIRE in arming.ts for the breakdown.",
7383
+ description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads and SETS all 9 modes the app defines \u2014 away/home/schedule/custom1/custom2/custom3/off/geo/disarmed. Three are byte-captured writes and six are live-confirmed (each sent and observed to report its own MODE_SWITCH); see ARMING_MODE_WIRE in arming.ts for the per-value evidence.",
7024
7384
  observation: {
7025
7385
  event: "armingModeChanged",
7026
7386
  reflects: (value) => ({ param: ARMING_CMD.SET_ARMING, expected: ARMING_MODE_WIRE[armingModeOf(value)] }),
@@ -7715,6 +8075,89 @@ var ModeCtrlMethod = {
7715
8075
  STOP_SMART_FOLLOW: 18,
7716
8076
  START_GLOBAL_CRUISE: 20
7717
8077
  };
8078
+ var ModeCtrlParamMethod = {
8079
+ /** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
8080
+ SELECT_ROOMS: { method: 1, param: 4 },
8081
+ /** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
8082
+ SELECT_ZONES: { method: 2, param: 5 },
8083
+ /** `START_GOTO_CLEAN` — drive to a point and clean around it. */
8084
+ GOTO: { method: 4, param: 7 },
8085
+ /** `START_SCENE_CLEAN` — run a saved scene by its id. */
8086
+ SCENE: { method: 24, param: 14 }
8087
+ };
8088
+ var SELECT_ROOMS_FIELD = {
8089
+ /** `rooms` — repeated, one entry per room. */
8090
+ ROOMS: 1,
8091
+ /** `clean_times` — how many passes to make. */
8092
+ CLEAN_TIMES: 2,
8093
+ /** `map_id` — WHICH saved map the room ids belong to. */
8094
+ MAP_ID: 3,
8095
+ /** `id` within a `Room`. */
8096
+ ROOM_ID: 1,
8097
+ /** `order` within a `Room` — the sequence to visit them in. */
8098
+ ROOM_ORDER: 2
8099
+ };
8100
+ var SELECT_ZONES_FIELD = {
8101
+ /** `zones` — repeated, one entry per rectangle. */
8102
+ ZONES: 1,
8103
+ /** `map_id` — which saved map the coordinates belong to. */
8104
+ MAP_ID: 2,
8105
+ /** `quadrangle` within a `Zone` — its four corners. */
8106
+ QUADRANGLE: 1,
8107
+ /** `clean_times` within a `Zone`. */
8108
+ ZONE_CLEAN_TIMES: 2,
8109
+ /** `x` within a `Point`, SIGNED centimetres. */
8110
+ POINT_X: 1,
8111
+ /** `y` within a `Point`, SIGNED centimetres. */
8112
+ POINT_Y: 2
8113
+ };
8114
+ var SCENE_CLEAN_ID = 1;
8115
+ function encodeModeCtrlParam(method2, paramField, build) {
8116
+ return rawDp((w) => {
8117
+ if (method2 !== 0)
8118
+ w.int(MODE_CTRL_FIELD.METHOD, method2);
8119
+ w.int(MODE_CTRL_FIELD.SEQ, nextModeCtrlSeq());
8120
+ w.sub(paramField, build);
8121
+ });
8122
+ }
8123
+ function encodeSelectRoomsClean(mapId, rooms, cleanTimes = 1) {
8124
+ const { method: method2, param } = ModeCtrlParamMethod.SELECT_ROOMS;
8125
+ return encodeModeCtrlParam(method2, param, (p) => {
8126
+ for (const room of rooms) {
8127
+ p.sub(SELECT_ROOMS_FIELD.ROOMS, (r) => {
8128
+ r.int(SELECT_ROOMS_FIELD.ROOM_ID, room.id);
8129
+ if (room.order !== void 0)
8130
+ r.int(SELECT_ROOMS_FIELD.ROOM_ORDER, room.order);
8131
+ });
8132
+ }
8133
+ p.int(SELECT_ROOMS_FIELD.CLEAN_TIMES, cleanTimes);
8134
+ p.int(SELECT_ROOMS_FIELD.MAP_ID, mapId);
8135
+ });
8136
+ }
8137
+ function encodeSelectZonesClean(mapId, zones) {
8138
+ const { method: method2, param } = ModeCtrlParamMethod.SELECT_ZONES;
8139
+ return encodeModeCtrlParam(method2, param, (p) => {
8140
+ for (const zone of zones) {
8141
+ p.sub(SELECT_ZONES_FIELD.ZONES, (z) => {
8142
+ z.sub(SELECT_ZONES_FIELD.QUADRANGLE, (q) => {
8143
+ for (const [i, corner] of zone.corners.entries()) {
8144
+ q.sub(i + 1, (pt) => {
8145
+ pt.sint(SELECT_ZONES_FIELD.POINT_X, corner.x);
8146
+ pt.sint(SELECT_ZONES_FIELD.POINT_Y, corner.y);
8147
+ });
8148
+ }
8149
+ });
8150
+ if (zone.cleanTimes !== void 0)
8151
+ z.int(SELECT_ZONES_FIELD.ZONE_CLEAN_TIMES, zone.cleanTimes);
8152
+ });
8153
+ }
8154
+ p.int(SELECT_ZONES_FIELD.MAP_ID, mapId);
8155
+ });
8156
+ }
8157
+ function encodeSceneClean(sceneId) {
8158
+ const { method: method2, param } = ModeCtrlParamMethod.SCENE;
8159
+ return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
8160
+ }
7718
8161
  var modeCtrlSeq = 111;
7719
8162
  function nextModeCtrlSeq() {
7720
8163
  return ++modeCtrlSeq;
@@ -9550,7 +9993,41 @@ var VACUUM_CLEAN_MEMBERS = {
9550
9993
  /** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
9551
9994
  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),
9552
9995
  /** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
9553
- pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum)
9996
+ pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum),
9997
+ /**
9998
+ * Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).
9999
+ *
10000
+ * The id is the device's own, as {@link VACUUM_CLEAN_MEMBERS.scenes} reports it — `VacuumScene.id`
10001
+ * off the `SceneResponse` on DP 180. A scene the device reports invalid stays reportable and running
10002
+ * it is still a well-formed request; `VacuumScene.invalidReason` says why the device will refuse.
10003
+ *
10004
+ * Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 24 has been
10005
+ * WATCHED: run on a T2351, it started the named scene.
10006
+ */
10007
+ startScene: method(({ sink }) => (sceneId) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSceneClean(sceneId))), "Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).", isAiotVacuum),
10008
+ /**
10009
+ * Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
10010
+ *
10011
+ * `mapId` has no default and that is deliberate: room ids are per map, so assuming the map a
10012
+ * single-floor home would have sends a two-floor home's ids against the wrong floor. `SceneInfo.mapid`
10013
+ * on DP 180 and a scheduled rooms-clean's `map_id` are the two real map ids the device reports.
10014
+ *
10015
+ * `cleanTimes` is how many passes to make over the set; rooms with no `order` are visited in the
10016
+ * order given.
10017
+ *
10018
+ * Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 1 has been
10019
+ * WATCHED: run on a T2351, it cleaned the rooms named.
10020
+ */
10021
+ cleanRooms: method(({ sink }) => (mapId, rooms, cleanTimes = 1) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectRoomsClean(mapId, rooms, cleanTimes))), "Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).", isAiotVacuum),
10022
+ /**
10023
+ * Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).
10024
+ *
10025
+ * Corners are SIGNED centimetres in the map's own frame, whose origin sits wherever the robot first
10026
+ * mapped from — negative coordinates are ordinary and are ZigZag-encoded, not written as plain
10027
+ * varints. Same `mapId` reasoning as {@link VACUUM_CLEAN_MEMBERS.cleanRooms}, and the same evidence:
10028
+ * method 2 was run on a T2351 and cleaned the rectangles given.
10029
+ */
10030
+ cleanZones: method(({ sink }) => (mapId, zones) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectZonesClean(mapId, zones))), "Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).", isAiotVacuum)
9554
10031
  };
9555
10032
  var VACUUM_CLEAN = {
9556
10033
  capability: "vacuum_clean",
@@ -9726,6 +10203,70 @@ function toNumberArray(arr) {
9726
10203
  return result;
9727
10204
  }
9728
10205
 
10206
+ // dist/model/capabilities/display.js
10207
+ var DISPLAY_PARAM = { BATTERY: 8001, SOFTWARE_VERSION: 8003 };
10208
+ var DISPLAY_MEMBERS = {
10209
+ /**
10210
+ * Battery level, 0-100.
10211
+ *
10212
+ * **On this capability rather than on `battery`, and that is the line partition doing its job.** The
10213
+ * security-line `battery` capability reads param 1101 and a Smart Display's charge is 8001 in its own
10214
+ * id space — two wires that happen to mean the same thing. One capability reading both would be a claim
10215
+ * that the two ecosystems share a param space, so a consumer reads a display's charge through
10216
+ * `dev.display()` and a camera's through `dev.battery()`.
10217
+ *
10218
+ * `verified` rather than `mega`: the id is in the device's cloud record, but the NAME came from the
10219
+ * maintainer's own knowledge of the hardware rather than from the cloud data-point list, and `"100"`
10220
+ * fits brightness, volume or charge equally.
10221
+ *
10222
+ * The scale is `percent` on the reading itself, not on convention alone: a full charge reads `255` on a
10223
+ * 0-255 scale and `1000` on a 0-1000 one, so `"100"` on a charged unit is positive evidence for 0-100
10224
+ * rather than merely consistent with it. What nobody has done is watch it MOVE, which is why a value
10225
+ * frozen at 100 would not yet be distinguishable from a healthy one.
10226
+ */
10227
+ battery: {
10228
+ param: DISPLAY_PARAM.BATTERY,
10229
+ type: "number",
10230
+ unit: "%",
10231
+ kind: "percent",
10232
+ provenance: "verified",
10233
+ description: "Battery level, 0-100 (param 8001)."
10234
+ },
10235
+ /**
10236
+ * Version-shaped, and that shape is the whole of the evidence — hence `guessed`, and hence no typed
10237
+ * getter: a caller reading this off a bound object cannot see the label.
10238
+ *
10239
+ * `unexposed` rather than absent, so the schema still carries its type and that label and
10240
+ * `getProperty("softwareVersion")` still answers. `dev.info()?.firmwareVersion` is the field to trust
10241
+ * where the cloud record carries one; on this display it does not, which is the only reason 8003 is
10242
+ * named at all.
10243
+ */
10244
+ softwareVersion: {
10245
+ param: DISPLAY_PARAM.SOFTWARE_VERSION,
10246
+ type: "string",
10247
+ kind: "text",
10248
+ provenance: "guessed",
10249
+ unexposed: true,
10250
+ description: "Version-shaped string (param 8003), meaning unconfirmed \u2014 prefer `info.firmwareVersion`."
10251
+ }
10252
+ };
10253
+ var DISPLAY = {
10254
+ capability: "display",
10255
+ line: "display",
10256
+ description: "Smart Display battery level (param 8001, display namespace).",
10257
+ members: DISPLAY_MEMBERS,
10258
+ properties: propertiesOf(DISPLAY_MEMBERS),
10259
+ /**
10260
+ * Claimed by CODEC, not by an evidence param.
10261
+ *
10262
+ * 8001 is in the device's cloud record, so the ordinary evidence gate would install the getter anyway
10263
+ * — but the capability should attach to a Smart Display that has reported nothing yet too, because a
10264
+ * device on this line has no other capability to carry it. The line partition keeps this off
10265
+ * everything else: `display` is the only codec in the `display` line.
10266
+ */
10267
+ detection: { codecs: ["display"] }
10268
+ };
10269
+
9729
10270
  // dist/model/capabilities/locate.js
9730
10271
  var LOCATE_DP = 160;
9731
10272
  var LEGACY_LOCATE_DP = TUYA_VACUUM_DP.LOOK_FOR_SWEEPER;
@@ -9854,6 +10395,7 @@ var MODULES = [
9854
10395
  VACUUM_DOCK,
9855
10396
  SUCTION,
9856
10397
  LOCATE,
10398
+ DISPLAY,
9857
10399
  INFO
9858
10400
  ];
9859
10401
  var BY_CAP = new Map(MODULES.map((m) => [m.capability, m]));
@@ -9861,6 +10403,16 @@ function getCapabilityModule(cap) {
9861
10403
  return BY_CAP.get(cap);
9862
10404
  }
9863
10405
  var CAPABILITY_MODULES = Object.fromEntries(MODULES.map((m) => [m.capability, m]));
10406
+ function claimedParams(caps) {
10407
+ const claimed = /* @__PURE__ */ new Set();
10408
+ for (const cap of caps)
10409
+ for (const spec of getCapabilityModule(cap)?.properties ?? []) {
10410
+ claimed.add(spec.paramType);
10411
+ for (const alias of spec.readAliases ?? [])
10412
+ claimed.add(alias.paramType);
10413
+ }
10414
+ return claimed;
10415
+ }
9864
10416
  function mergeProperties(caps, ctx) {
9865
10417
  const seen = /* @__PURE__ */ new Set();
9866
10418
  const merged = [];
@@ -9898,11 +10450,11 @@ var CODEC_LINE = {
9898
10450
  mower: "clean",
9899
10451
  light: "life",
9900
10452
  printer: "print",
9901
- display: "security"
10453
+ display: "display"
9902
10454
  };
9903
10455
  function lineAllows(module, codec) {
9904
10456
  const line = module.line ?? "security";
9905
- return line === "any" || line === CODEC_LINE[codec];
10457
+ return line === "any" || codec !== void 0 && line === CODEC_LINE[codec];
9906
10458
  }
9907
10459
  function detectCapabilities(rec, codec) {
9908
10460
  const found = /* @__PURE__ */ new Set();
@@ -9932,10 +10484,10 @@ function detectCapabilities(rec, codec) {
9932
10484
  if (!matched && d.modelHints && haystack.length > 0) {
9933
10485
  matched = d.modelHints.some((re) => re.test(haystack));
9934
10486
  }
9935
- if (!matched && d.codecs) {
10487
+ if (!matched && d.codecs && codec !== void 0) {
9936
10488
  matched = d.codecs.includes(codec);
9937
10489
  }
9938
- if (!matched && d.detect) {
10490
+ if (!matched && d.detect && codec !== void 0) {
9939
10491
  try {
9940
10492
  matched = d.detect(rec, codec) === true;
9941
10493
  } catch {
@@ -10143,6 +10695,11 @@ var MODEL_REGISTRY = {
10143
10695
  T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
10144
10696
  // Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
10145
10697
  T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
10698
+ // Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
10699
+ // member on purpose — this model is wired, so the codec baseline plus inference is the whole
10700
+ // truth for power. Without this row it classified as a plain camera, so the doorbell capability
10701
+ // never appeared and consumers got no doorbell event entity or ring trigger.
10702
+ T8200: { codec: "camera", caps: ["doorbell"], name: "Wired Doorbell 2K" },
10146
10703
  // Cameras observed on real owned hardware (inspect-device sweep). Names from the app's own
10147
10704
  // model-family constants (scripts/data/app_model_registry.json); caps mirror what the device
10148
10705
  // reports. T8171 has no family entry in that dump, so it keeps the raw T-code.
@@ -11998,6 +12555,28 @@ var CLEAN_PARAMS = {
11998
12555
  provenance: "mega"
11999
12556
  }
12000
12557
  };
12558
+ var DISPLAY_PARAMS = {
12559
+ 8001: {
12560
+ name: "battery",
12561
+ type: "number",
12562
+ provenance: "verified"
12563
+ },
12564
+ 8003: {
12565
+ name: "softwareVersion",
12566
+ type: "string",
12567
+ provenance: "guessed"
12568
+ },
12569
+ 8005: {
12570
+ name: "modelName",
12571
+ type: "string",
12572
+ provenance: "mega"
12573
+ },
12574
+ 8006: {
12575
+ name: "modelCode",
12576
+ type: "string",
12577
+ provenance: "mega"
12578
+ }
12579
+ };
12001
12580
 
12002
12581
  // dist/model/life-params.js
12003
12582
  var LIFE_PARAMS = {
@@ -12025,7 +12604,10 @@ var TABLES = {
12025
12604
  // 3D-printer (ankermake) id space — empty until a live capture confirms the param↔semantic map
12026
12605
  // (printer-support plan Stage 3). Present so the printer codec resolves to its OWN namespace rather
12027
12606
  // than falling through to `security` and decoding another line's dictionary.
12028
- print: {}
12607
+ print: {},
12608
+ // Smart Display (T87Ax) — ids 8001-8006, four of them named. Same reason as `print`: its own
12609
+ // dictionary rather than a corner of another line's.
12610
+ display: DISPLAY_PARAMS
12029
12611
  };
12030
12612
  function paramDef(ns, paramType) {
12031
12613
  return TABLES[ns][paramType];
@@ -12040,7 +12622,7 @@ var NAMESPACE_BY_CODEC = {
12040
12622
  mower: "clean",
12041
12623
  light: "life",
12042
12624
  printer: "print",
12043
- display: "security"
12625
+ display: "display"
12044
12626
  };
12045
12627
  function namespaceForCodec(codec) {
12046
12628
  return NAMESPACE_BY_CODEC[codec];
@@ -12127,6 +12709,11 @@ var Device = class _Device {
12127
12709
  * different wire ids across device families still resolves to one named value.
12128
12710
  */
12129
12711
  specByParam;
12712
+ /**
12713
+ * Params a resolved capability declares that this device's schema does not carry — withheld by a
12714
+ * member's gate, so not named from the param dictionary either. See {@link Device.applyParams}.
12715
+ */
12716
+ withheld = /* @__PURE__ */ new Set();
12130
12717
  /** Which param namespace this device's ids live in (clean DPs vs security P2P). */
12131
12718
  namespace;
12132
12719
  /** The record's `device_name` as stated, before the {@link modelName} fallback is applied. */
@@ -12193,6 +12780,7 @@ var Device = class _Device {
12193
12780
  byParam.set(a.paramType, { spec: p, invert: a.invert ?? false });
12194
12781
  }
12195
12782
  this.specByParam = byParam;
12783
+ this.withheld = new Set([...claimedParams(resolved.capabilities)].filter((pt) => !byParam.has(pt)));
12196
12784
  this.namespace = namespaceForCodec(resolved.codec);
12197
12785
  this.installAccessors();
12198
12786
  }
@@ -12355,6 +12943,16 @@ var Device = class _Device {
12355
12943
  * Apply a raw param map (cloud record or P2P notification). Known params update their named
12356
12944
  * property; unrecognised params are retained as `unknown_<paramType>` so nothing is lost.
12357
12945
  *
12946
+ * Naming precedence: this device's own `PropertySpec` (curated), then the param dictionary for its
12947
+ * namespace, then the `unknown_<paramType>` passthrough. The dictionary def is consulted even where a
12948
+ * spec exists, because `encoding` lives there.
12949
+ *
12950
+ * A param a resolved capability's gate WITHHELD takes the passthrough instead of its dictionary name.
12951
+ * The gate decided the read does not describe this device, the dictionary names it what the member
12952
+ * would have, and republishing it there hands a caller a reading indistinguishable from one the device
12953
+ * really answered. A capability that never resolved withholds nothing: a param arriving before its
12954
+ * capability is still the device's own, and keeps its dictionary name.
12955
+ *
12358
12956
  * @param params param_type → raw value.
12359
12957
  * @param ts observation time (epoch ms); defaults to `Date.now()`.
12360
12958
  * @returns the list of property names whose value changed.
@@ -12367,7 +12965,7 @@ var Device = class _Device {
12367
12965
  continue;
12368
12966
  const hit = this.specByParam.get(pt);
12369
12967
  const spec = hit?.spec;
12370
- const def = paramDef(this.namespace, pt);
12968
+ const def = this.withheld.has(pt) ? void 0 : paramDef(this.namespace, pt);
12371
12969
  const name = spec ? spec.name : def ? def.name : `${UNKNOWN_PARAM_PREFIX}${pt}`;
12372
12970
  const value = def?.encoding ? decodeEncoded(rawVal, def.encoding) : spec ? coerce(spec, rawVal, hit.invert, this.logger) : def ? coerceByType(def.type, rawVal, def.name, this.logger) : String(rawVal);
12373
12971
  const prev = this.state.get(name);
@@ -12569,7 +13167,7 @@ function lz4BlockDecompress(block, size) {
12569
13167
  const end = block.length;
12570
13168
  let pos = 0;
12571
13169
  let written = 0;
12572
- const extend = (base) => {
13170
+ const extend2 = (base) => {
12573
13171
  let len = base;
12574
13172
  if (base !== 15)
12575
13173
  return len;
@@ -12584,7 +13182,7 @@ function lz4BlockDecompress(block, size) {
12584
13182
  };
12585
13183
  while (pos < end) {
12586
13184
  const token = block[pos++];
12587
- const literals = extend(token >> 4);
13185
+ const literals = extend2(token >> 4);
12588
13186
  if (literals === void 0)
12589
13187
  return void 0;
12590
13188
  if (pos + literals > end || written + literals > size)
@@ -12599,7 +13197,7 @@ function lz4BlockDecompress(block, size) {
12599
13197
  pos += 2;
12600
13198
  if (offset === 0 || offset > written)
12601
13199
  return void 0;
12602
- const match = extend(token & 15);
13200
+ const match = extend2(token & 15);
12603
13201
  if (match === void 0)
12604
13202
  return void 0;
12605
13203
  const length = match + 4;
@@ -13103,37 +13701,407 @@ function inspectParams(rec, sn) {
13103
13701
  };
13104
13702
  }
13105
13703
 
13106
- // dist/client/map-channels.js
13107
- var reader = (kind, decode) => (raw, codec) => {
13108
- const value = decode(raw, codec);
13109
- return value === void 0 ? void 0 : { kind, value };
13110
- };
13111
- var READER_BY_CHANNEL = {
13112
- MAP_DATA: reader("plane", decodeVacuumMap),
13113
- ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
13114
- ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
13115
- RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
13116
- DYNAMIC_DATA: reader("pose", decodeVacuumPose)
13117
- };
13118
- var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
13119
- var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
13120
- function decodeMapFrame(frame, codec) {
13121
- if (frame.offset !== 0)
13122
- return void 0;
13123
- return READERS.get(frame.channelId)?.(frame.payload, codec);
13124
- }
13125
-
13126
- // dist/transport/p2p/command-router.js
13127
- import { setTimeout as sleep2 } from "node:timers/promises";
13128
-
13129
- // dist/transport/p2p/p2p-session.js
13130
- import { EventEmitter as EventEmitter2 } from "node:events";
13131
- import dgram from "node:dgram";
13132
- import { constants, createCipheriv as createCipheriv3, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt, randomBytes as randomBytes2 } from "node:crypto";
13133
-
13134
- // dist/transport/p2p/codec.js
13135
- var codec_exports = {};
13136
- __export(codec_exports, {
13704
+ // dist/model/capabilities/solix.js
13705
+ var SOLIX_ENERGY_METER_MEMBERS = {
13706
+ /** Line-1 voltage (V), ff09 tag `0xAC` — confirmed live (a nominal mains voltage). */
13707
+ meterVoltageL1: {
13708
+ param: 172,
13709
+ type: "number",
13710
+ kind: "scalar",
13711
+ unit: "V",
13712
+ provenance: "verified",
13713
+ description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
13714
+ },
13715
+ /** Line-2 voltage (V), ff09 tag `0xAD` — the app's field; reads 0 until a multi-phase frame carries it. */
13716
+ meterVoltageL2: {
13717
+ param: 173,
13718
+ type: "number",
13719
+ kind: "scalar",
13720
+ unit: "V",
13721
+ provenance: "guessed",
13722
+ description: "Meter line-2 voltage (V) \u2014 ff09 tag 0xAD; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13723
+ },
13724
+ /** Line-3 voltage (V), ff09 tag `0xAE` — the app's field; reads 0 until a multi-phase frame carries it. */
13725
+ meterVoltageL3: {
13726
+ param: 174,
13727
+ type: "number",
13728
+ kind: "scalar",
13729
+ unit: "V",
13730
+ provenance: "guessed",
13731
+ description: "Meter line-3 voltage (V) \u2014 ff09 tag 0xAE; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13732
+ },
13733
+ /** Line-1 current (A), ff09 tag `0xAF` — confirmed live (the line's CT current). */
13734
+ meterCurrentL1: {
13735
+ param: 175,
13736
+ type: "number",
13737
+ kind: "scalar",
13738
+ unit: "A",
13739
+ provenance: "verified",
13740
+ description: "Meter line-1 current (A) \u2014 ff09 tag 0xAF, confirmed against a live single-phase frame."
13741
+ },
13742
+ /** Line-2 current (A), ff09 tag `0xB0` — the app's field; reads 0 until a multi-phase frame carries it. */
13743
+ meterCurrentL2: {
13744
+ param: 176,
13745
+ type: "number",
13746
+ kind: "scalar",
13747
+ unit: "A",
13748
+ provenance: "guessed",
13749
+ description: "Meter line-2 current (A) \u2014 ff09 tag 0xB0; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13750
+ },
13751
+ /** Line-3 current (A), ff09 tag `0xB1` — the app's field; reads 0 until a multi-phase frame carries it. */
13752
+ meterCurrentL3: {
13753
+ param: 177,
13754
+ type: "number",
13755
+ kind: "scalar",
13756
+ unit: "A",
13757
+ provenance: "guessed",
13758
+ description: "Meter line-3 current (A) \u2014 ff09 tag 0xB1; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13759
+ },
13760
+ /** Line-1 active power (W), ff09 tag `0xA8` — confirmed live; negative on export. */
13761
+ meterPowerL1: {
13762
+ param: 168,
13763
+ type: "number",
13764
+ kind: "scalar",
13765
+ unit: "W",
13766
+ provenance: "verified",
13767
+ description: "Meter line-1 active power (W) \u2014 ff09 tag 0xA8, confirmed live; negative on export."
13768
+ },
13769
+ /** Line-2 active power (W), ff09 tag `0xA9` — the app's field; reads 0 until a multi-phase frame carries it. */
13770
+ meterPowerL2: {
13771
+ param: 169,
13772
+ type: "number",
13773
+ kind: "scalar",
13774
+ unit: "W",
13775
+ provenance: "guessed",
13776
+ description: "Meter line-2 active power (W) \u2014 ff09 tag 0xA9; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13777
+ },
13778
+ /** Line-3 active power (W), ff09 tag `0xAA` — the app's field; reads 0 until a multi-phase frame carries it. */
13779
+ meterPowerL3: {
13780
+ param: 170,
13781
+ type: "number",
13782
+ kind: "scalar",
13783
+ unit: "W",
13784
+ provenance: "guessed",
13785
+ description: "Meter line-3 active power (W) \u2014 ff09 tag 0xAA; phase assignment inferred from block ordering (never observed non-zero), 0 on a single-phase install."
13786
+ },
13787
+ /** Aggregate active power (W), ff09 tag `0xAB` — confirmed live; equals line-1 on a single phase. */
13788
+ meterPowerTotal: {
13789
+ param: 171,
13790
+ type: "number",
13791
+ kind: "scalar",
13792
+ unit: "W",
13793
+ provenance: "verified",
13794
+ description: "Meter total active power (W) \u2014 ff09 tag 0xAB, confirmed live; equals L1 on one phase."
13795
+ }
13796
+ };
13797
+ var CATEGORY_CAPABILITIES = {
13798
+ "Portable Power Station": ["battery", "acOutput", "solarInput"],
13799
+ /**
13800
+ * NOT `energyMeter`: a home battery measures its own grid/PV/output power, but that is a different
13801
+ * ff09 tag family than the AE1X0 Smart Meter's — the `energyMeter` members are AE1X0-specific, so a
13802
+ * Solarbank frame decoded through them mislabels (tag `0xac` is power on a Solarbank, voltage on the
13803
+ * meter — enforced by the meter-family gate in {@link SOLIX_METER_MODELS} / the transport decoder,
13804
+ * see PR #186). `energyMeter` is model-detected for the meter, not category-detected.
13805
+ */
13806
+ "Plug-in Home Battery": ["battery", "solarInput", "acOutput"],
13807
+ "Powered Cooler": ["battery", "cooler"],
13808
+ "Power Bank": ["battery"],
13809
+ "Smart EV Charger": ["evCharger"],
13810
+ Charger: ["charger"],
13811
+ Accessory: []
13812
+ };
13813
+ var SOLIX_METER_MODELS = ["AE1X0"];
13814
+ var SOLARBANK_MODELS = ["A1790", "A17C", "AE10"];
13815
+ function detectSolixCapabilities(rec, category) {
13816
+ const caps = /* @__PURE__ */ new Set(["identity"]);
13817
+ if (rec.device_sw_version)
13818
+ caps.add("firmware");
13819
+ if (rec.wifi_online !== void 0 || rec.rssi != null || rec.wifi_name)
13820
+ caps.add("connectivity");
13821
+ for (const c of category && CATEGORY_CAPABILITIES[category] || [])
13822
+ caps.add(c);
13823
+ if (SOLIX_METER_MODELS.some((m) => rec.product_code?.startsWith(m)))
13824
+ caps.add("energyMeter");
13825
+ if (SOLARBANK_MODELS.some((m) => rec.product_code?.startsWith(m))) {
13826
+ caps.add("battery");
13827
+ caps.add("solarInput");
13828
+ }
13829
+ return caps;
13830
+ }
13831
+
13832
+ // dist/model/solix-catalog.js
13833
+ function buildModelIndex(categories) {
13834
+ const index = /* @__PURE__ */ new Map();
13835
+ for (const category of categories) {
13836
+ for (const product of category.products ?? []) {
13837
+ const entry = { name: product.name, category: category.name.trim() };
13838
+ if (product.product_code)
13839
+ index.set(product.product_code, entry);
13840
+ for (const variant of product.p_codes ?? []) {
13841
+ const code = typeof variant === "string" ? variant : variant?.product_code;
13842
+ if (code)
13843
+ index.set(code, entry);
13844
+ }
13845
+ }
13846
+ }
13847
+ return index;
13848
+ }
13849
+
13850
+ // dist/model/solix-family.js
13851
+ var CATEGORY_FAMILY = {
13852
+ "Portable Power Station": "powerStation",
13853
+ "Power Bank": "powerBank",
13854
+ "Powered Cooler": "cooler",
13855
+ "Smart EV Charger": "evCharger",
13856
+ Charger: "charger"
13857
+ };
13858
+ var hasPrefix = (code, prefixes) => !!code && prefixes.some((p) => code.startsWith(p));
13859
+ var isSolixSolarbank = (input) => hasPrefix(input.product_code, SOLARBANK_MODELS) || input.category === "Plug-in Home Battery";
13860
+ var isSolixSmartMeter = (input) => hasPrefix(input.product_code, SOLIX_METER_MODELS);
13861
+ var isSolixPowerStation = (input) => input.category === "Portable Power Station";
13862
+ function solixProductFamily(input) {
13863
+ if (isSolixSmartMeter(input))
13864
+ return "smartMeter";
13865
+ if (isSolixSolarbank(input))
13866
+ return "solarbank";
13867
+ return (input.category ? CATEGORY_FAMILY[input.category] : void 0) ?? "unknown";
13868
+ }
13869
+
13870
+ // dist/model/solix-device.js
13871
+ var READ_ONLY_SINK = { dispatch: async () => {
13872
+ } };
13873
+ var SolixDevice = class {
13874
+ serial;
13875
+ productCode;
13876
+ record;
13877
+ caps;
13878
+ identity_;
13879
+ values = {};
13880
+ constructor(record, opts = {}) {
13881
+ this.record = record;
13882
+ this.serial = record.device_sn;
13883
+ this.productCode = record.product_code;
13884
+ const label = opts.catalog ? buildModelIndex(opts.catalog).get(record.product_code) : void 0;
13885
+ this.identity_ = {
13886
+ serial: record.device_sn,
13887
+ productCode: record.product_code,
13888
+ name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
13889
+ category: label?.category,
13890
+ family: solixProductFamily({ product_code: record.product_code, category: label?.category })
13891
+ };
13892
+ this.caps = detectSolixCapabilities(record, this.identity_.category);
13893
+ }
13894
+ /**
13895
+ * The device's {@link SolixProductFamily} — the classification a caller branches on to SORT devices
13896
+ * (a site's power stations vs its meters), the Solix analogue of eufy's `isHomeBase()`. Distinct from
13897
+ * {@link has}, which answers what the device can DO; family answers what KIND of device it is.
13898
+ */
13899
+ get family() {
13900
+ return this.identity_.family;
13901
+ }
13902
+ /** All capabilities this device carries. */
13903
+ get capabilities() {
13904
+ return [...this.caps];
13905
+ }
13906
+ /** Whether the device carries a capability — the only correct way to branch on behaviour. */
13907
+ has(capability) {
13908
+ return this.caps.has(capability);
13909
+ }
13910
+ /**
13911
+ * Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
13912
+ * WHOLE reading, not just its values, and drops one addressed to a different device: the documented
13913
+ * wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
13914
+ * watched meter on the account — so without this filter two meters would cross-feed each other's floats.
13915
+ * A reading with no `deviceSn` (a hand-built one) is accepted as-is.
13916
+ */
13917
+ applyReading(reading) {
13918
+ if (reading.deviceSn && reading.deviceSn !== this.serial)
13919
+ return;
13920
+ this.values = { ...this.values, ...reading.values };
13921
+ }
13922
+ /** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
13923
+ telemetry() {
13924
+ return { ...this.values };
13925
+ }
13926
+ identity() {
13927
+ return { ...this.identity_ };
13928
+ }
13929
+ firmware() {
13930
+ return this.record.device_sw_version ? { version: this.record.device_sw_version } : void 0;
13931
+ }
13932
+ connectivity() {
13933
+ if (!this.has("connectivity"))
13934
+ return void 0;
13935
+ const rssi = this.record.rssi != null ? Number(this.record.rssi) : void 0;
13936
+ return {
13937
+ online: !!this.record.wifi_online,
13938
+ rssi: Number.isFinite(rssi) ? rssi : void 0,
13939
+ ssid: this.record.wifi_name
13940
+ };
13941
+ }
13942
+ /**
13943
+ * The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
13944
+ * installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
13945
+ * held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
13946
+ * time of this call, so re-call it to pick up a tag first seen since.
13947
+ */
13948
+ energyMeter() {
13949
+ if (!this.has("energyMeter"))
13950
+ return void 0;
13951
+ return bindMembers(SOLIX_ENERGY_METER_MEMBERS, this.meterDeps());
13952
+ }
13953
+ /**
13954
+ * The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
13955
+ * evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
13956
+ * codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
13957
+ * Solix telemetry is read-only, no member here dispatches a command.
13958
+ */
13959
+ meterDeps() {
13960
+ const read = (name) => {
13961
+ const v = this.values[name];
13962
+ return v === void 0 ? void 0 : { name, paramType: 0, value: v, ts: Date.now() };
13963
+ };
13964
+ return { ctx: { channel: 0, paramIds: this.seenTags() }, sink: READ_ONLY_SINK, read };
13965
+ }
13966
+ /** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
13967
+ seenTags() {
13968
+ const tags = /* @__PURE__ */ new Set();
13969
+ for (const key of Object.keys(this.values)) {
13970
+ const m = /^channel_([0-9a-f]+)$/.exec(key);
13971
+ if (m)
13972
+ tags.add(parseInt(m[1], 16));
13973
+ }
13974
+ return tags;
13975
+ }
13976
+ };
13977
+ function resolveSolixCatalog(client, opts) {
13978
+ return opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => []);
13979
+ }
13980
+ async function discoverSolixDevices(client, opts = {}) {
13981
+ const [records, catalog] = await Promise.all([client.getDevices(), resolveSolixCatalog(client, opts)]);
13982
+ return records.map((r) => new SolixDevice(r, { catalog }));
13983
+ }
13984
+ function sceneNum(v) {
13985
+ if (typeof v === "number")
13986
+ return Number.isFinite(v) ? v : void 0;
13987
+ if (typeof v === "string" && v.trim() !== "") {
13988
+ const n = Number(v);
13989
+ return Number.isFinite(n) ? n : void 0;
13990
+ }
13991
+ return void 0;
13992
+ }
13993
+ function solarbankSceneReadings(scene) {
13994
+ const list = scene.solarbank_info?.solarbank_list ?? [];
13995
+ const out = [];
13996
+ for (const sb of list) {
13997
+ const deviceSn = typeof sb.device_sn === "string" ? sb.device_sn : void 0;
13998
+ if (!deviceSn)
13999
+ continue;
14000
+ const values = {};
14001
+ const temp = sceneNum(sb.bat_temperature);
14002
+ if (temp !== void 0)
14003
+ values.batteryTemperature = temp;
14004
+ const soc = sceneNum(sb.bat_soc);
14005
+ if (soc !== void 0)
14006
+ values.batterySoc = soc;
14007
+ if (Object.keys(values).length > 0)
14008
+ out.push({ deviceSn, values });
14009
+ }
14010
+ return out;
14011
+ }
14012
+
14013
+ // dist/model/solix-site.js
14014
+ var SolixSite = class {
14015
+ id;
14016
+ /** Friendly site name (e.g. "My Home"), or the site id when the record carries none. */
14017
+ name;
14018
+ /** Anker's site-type discriminator (e.g. 20 for a Solarbank-anchored home system), when present. */
14019
+ powerSiteType;
14020
+ record;
14021
+ devices;
14022
+ constructor(record, devices) {
14023
+ this.record = record;
14024
+ this.id = record.site_id;
14025
+ this.name = record.site_name || record.site_id;
14026
+ this.powerSiteType = record.power_site_type;
14027
+ this.devices = devices;
14028
+ }
14029
+ /** The member device with this serial, if it belongs to the site. */
14030
+ device(serial) {
14031
+ return this.devices.find((d) => d.serial === serial);
14032
+ }
14033
+ /** Member devices of a given product {@link SolixProductFamily} — the grouping accessor. */
14034
+ withFamily(family) {
14035
+ return this.devices.filter((d) => d.family === family);
14036
+ }
14037
+ /** Member devices that carry a given capability (e.g. every `battery` in the system). */
14038
+ withCapability(capability) {
14039
+ return this.devices.filter((d) => d.has(capability));
14040
+ }
14041
+ /** The Solarbank / plug-in home-battery members of the system. */
14042
+ solarbanks() {
14043
+ return this.withFamily("solarbank");
14044
+ }
14045
+ /** The smart-meter (grid-CT) members of the system. */
14046
+ smartMeters() {
14047
+ return this.withFamily("smartMeter");
14048
+ }
14049
+ /** The portable power-station members of the system. */
14050
+ powerStations() {
14051
+ return this.withFamily("powerStation");
14052
+ }
14053
+ };
14054
+ async function discoverSolixSites(client, opts = {}) {
14055
+ const [sites, records, catalog] = await Promise.all([
14056
+ client.getSites(),
14057
+ client.getDevices(),
14058
+ resolveSolixCatalog(client, opts)
14059
+ ]);
14060
+ const bySerial = new Map(records.map((r) => [r.device_sn, r]));
14061
+ return sites.map((site) => {
14062
+ const members = (site.site_device_list ?? []).map((entry) => {
14063
+ const record = bySerial.get(entry.device_sn) ?? {
14064
+ device_sn: entry.device_sn,
14065
+ product_code: entry.device_model,
14066
+ device_name: entry.device_name
14067
+ };
14068
+ return new SolixDevice(record, { catalog });
14069
+ });
14070
+ return new SolixSite(site, members);
14071
+ });
14072
+ }
14073
+
14074
+ // dist/client/map-channels.js
14075
+ var reader = (kind, decode) => (raw, codec) => {
14076
+ const value = decode(raw, codec);
14077
+ return value === void 0 ? void 0 : { kind, value };
14078
+ };
14079
+ var READER_BY_CHANNEL = {
14080
+ MAP_DATA: reader("plane", decodeVacuumMap),
14081
+ ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
14082
+ ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
14083
+ RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
14084
+ DYNAMIC_DATA: reader("pose", decodeVacuumPose)
14085
+ };
14086
+ var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
14087
+ var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
14088
+ function decodeMapFrame(frame, codec) {
14089
+ if (frame.offset !== 0)
14090
+ return void 0;
14091
+ return READERS.get(frame.channelId)?.(frame.payload, codec);
14092
+ }
14093
+
14094
+ // dist/transport/p2p/command-router.js
14095
+ import { setTimeout as sleep2 } from "node:timers/promises";
14096
+
14097
+ // dist/transport/p2p/p2p-session.js
14098
+ import { EventEmitter as EventEmitter2 } from "node:events";
14099
+ import dgram from "node:dgram";
14100
+ import { constants, createCipheriv as createCipheriv3, createDecipheriv as createDecipheriv4, generateKeyPairSync, privateDecrypt, randomBytes as randomBytes2 } from "node:crypto";
14101
+
14102
+ // dist/transport/p2p/codec.js
14103
+ var codec_exports = {};
14104
+ __export(codec_exports, {
13137
14105
  MAGIC_WORD: () => MAGIC_WORD,
13138
14106
  P2PDataType: () => P2PDataType,
13139
14107
  P2PDataTypeHeader: () => P2PDataTypeHeader,
@@ -14201,19 +15169,27 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14201
15169
  * cameras from that second group streamed normally at level-1 — including one of the same firmware as an
14202
15170
  * own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
14203
15171
  * separates nothing on this path, and a start failure on such a session is not evidence about it.
15172
+ *
15173
+ * Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
15174
+ * `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
15175
+ * cipher and the cause are known, and re-stating it here would double every settled negotiation.
14204
15176
  */
14205
15177
  async awaitLevel2Key(graceMs, graceFrom = "call") {
14206
- if (this.closed)
15178
+ if (this.closed) {
15179
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14207
15180
  return false;
15181
+ }
14208
15182
  if (this.level2Key)
14209
15183
  return true;
14210
- if (!this.level2Pending)
15184
+ if (!this.level2Pending) {
15185
+ this.trace({ phase: "level2-unavailable", reason: "not-negotiating" });
14211
15186
  return false;
15187
+ }
14212
15188
  const since = graceFrom === "call" ? Date.now() : this.connectedAtMs ?? Date.now();
14213
15189
  const remaining = graceMs - (Date.now() - since);
14214
15190
  if (remaining <= 0) {
14215
15191
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no level-2 key and its ${graceMs}ms grace has elapsed`);
14216
- this.trace({ phase: "level2-absent", waitedMs: graceMs });
15192
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: graceMs });
14217
15193
  return false;
14218
15194
  }
14219
15195
  this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
@@ -14239,10 +15215,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14239
15215
  });
14240
15216
  if (outcome === "timeout") {
14241
15217
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 key did not arrive within its grace`);
15218
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: remaining });
14242
15219
  } else if (outcome === "terminal") {
14243
15220
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 negotiation concluded without a key`);
14244
15221
  } else if (outcome === "closed") {
14245
15222
  this.logger.debug(`[p2p] ${this.cfg.stationSn} session closed before the level-2 key arrived`);
15223
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14246
15224
  }
14247
15225
  return outcome === "key";
14248
15226
  }
@@ -14293,6 +15271,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14293
15271
  this.level2Negotiating = true;
14294
15272
  const generation = this.connectionGeneration;
14295
15273
  const cipherId = gatewayInfoCipherId(gwPayload);
15274
+ this.trace({ phase: "level2-negotiating", cipherId });
14296
15275
  void (async () => {
14297
15276
  try {
14298
15277
  const eccPrivHex = await this.cfg.resolveCipherKey?.(cipherId);
@@ -14300,11 +15279,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14300
15279
  return;
14301
15280
  if (!eccPrivHex) {
14302
15281
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no ECC key for cipher_id ${cipherId}`);
15282
+ this.trace({ phase: "level2-unavailable", reason: "no-cipher-key", cipherId });
14303
15283
  this.settleLevel2();
14304
15284
  return;
14305
15285
  }
14306
15286
  const key = deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivHex);
14307
15287
  if (!key) {
15288
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14308
15289
  this.settleLevel2();
14309
15290
  this.emit("error", new Error(`level-2 key derivation failed (cipher_id ${cipherId})`));
14310
15291
  return;
@@ -14316,6 +15297,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14316
15297
  } catch (e) {
14317
15298
  if (this.closed || generation !== this.connectionGeneration)
14318
15299
  return;
15300
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14319
15301
  this.settleLevel2();
14320
15302
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
14321
15303
  }
@@ -14383,6 +15365,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14383
15365
  if (host && !this.closed)
14384
15366
  this.selfAddress = { host, port: boundPort };
14385
15367
  });
15368
+ this.trace({
15369
+ phase: "lookup-channels",
15370
+ local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15371
+ cloud: this.cfg.dskKey !== void 0 && (this.cfg.cloudAddresses?.length ?? 0) > 0
15372
+ });
14386
15373
  this.sendLookups();
14387
15374
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
14388
15375
  this.connectTimer = setTimeout(() => {
@@ -14465,8 +15452,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14465
15452
  for (const addr of this.cfg.cloudAddresses)
14466
15453
  this.send(addr, type, payload);
14467
15454
  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}`);
14468
- } else {
14469
- this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: NO cloud lookup sent (dskKey=${!!this.cfg.dskKey} cloudAddresses=${this.cfg.cloudAddresses?.length ?? 0})`);
14470
15455
  }
14471
15456
  }
14472
15457
  /**
@@ -15666,19 +16651,23 @@ function parseFf09SettingsResponse(plain) {
15666
16651
  if (plain.length < 1)
15667
16652
  throw new Error("ff09: settings response too short (missing status byte)");
15668
16653
  const status = plain[0];
16654
+ const fields = walkFf09Tlv(plain, 1, plain.length);
16655
+ return { status, fields };
16656
+ }
16657
+ function walkFf09Tlv(buf, start, end) {
15669
16658
  const fields = /* @__PURE__ */ new Map();
15670
- let i = 1;
15671
- while (i + 2 <= plain.length) {
15672
- const sep = plain[i];
15673
- if (sep === 0)
16659
+ let i = start;
16660
+ while (i + 2 <= end) {
16661
+ const tag2 = buf[i];
16662
+ if (tag2 === 0)
15674
16663
  break;
15675
- const len = plain[i + 1];
15676
- if (i + 2 + len > plain.length)
16664
+ const len = buf[i + 1];
16665
+ if (i + 2 + len > end)
15677
16666
  break;
15678
- fields.set(sep, plain.subarray(i + 2, i + 2 + len));
16667
+ fields.set(tag2, buf.subarray(i + 2, i + 2 + len));
15679
16668
  i += 2 + len;
15680
16669
  }
15681
- return { status, fields };
16670
+ return fields;
15682
16671
  }
15683
16672
  function readFf09U16LE(field, name) {
15684
16673
  if (!field || field.length < 2)
@@ -16332,8 +17321,9 @@ var LiveStream = class extends EventEmitter3 {
16332
17321
  * Stop re-issuing the media start once this camera's own media has arrived, on an attached camera.
16333
17322
  *
16334
17323
  * The nudge differs by topology and only one branch is a ping: an own-session camera sends a small
16335
- * keepalive, while an attached camera has no such state and re-sends the FULL media start. On a station that
16336
- * serves one camera at a time that restart re-asserts this channel against whatever else is warm, so two
17324
+ * keepalive, while an attached camera has no such state and re-sends the FULL media start. Over one session,
17325
+ * which serves one camera at a time, that restart re-asserts this channel against whatever else is warm on
17326
+ * it, so two
16337
17327
  * attached streams restart every interval and contend for the station continuously — measured on a real base
16338
17328
  * as a full start every 3 s from each.
16339
17329
  *
@@ -16381,10 +17371,12 @@ var LiveStream = class extends EventEmitter3 {
16381
17371
  if (!this.listening || this.kaTimer)
16382
17372
  return;
16383
17373
  if (this.opts.reassertWanted?.() === false) {
17374
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "declined" });
16384
17375
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms, and its owner does not want this channel re-asserted \u2014 staying quiet`);
16385
17376
  this.armStallWatch();
16386
17377
  return;
16387
17378
  }
17379
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "reasserted" });
16388
17380
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms \u2014 re-asserting this camera's channel`);
16389
17381
  this.sendStart();
16390
17382
  this.kaTimer = setInterval(() => this.sendStart(), keepAliveMs);
@@ -16498,7 +17490,7 @@ var LiveStream = class extends EventEmitter3 {
16498
17490
  *
16499
17491
  * The match is UNCONDITIONAL, however long a station serves another camera instead of this one.
16500
17492
  *
16501
- * A station serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
17493
+ * One session serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
16502
17494
  * it switches, so "no media of my own yet, plenty for someone else" is what an ordinary handover looks like
16503
17495
  * and does not distinguish a station that tags differently from one that is simply busy.
16504
17496
  *
@@ -16605,10 +17597,12 @@ async function captureSnapshotFromShared(source, opts = {}) {
16605
17597
  const timeoutMs = opts.timeoutMs ?? 2e4;
16606
17598
  const collectMs = opts.collectMs ?? 1500;
16607
17599
  const skip = opts.skipKeyframes ?? 1;
17600
+ opts.signal?.throwIfAborted();
16608
17601
  const consumer = source.attach();
16609
17602
  const primed = consumer.primed;
17603
+ let burst;
16610
17604
  try {
16611
- const burst = await new Promise((resolve, reject) => {
17605
+ burst = await new Promise((resolve, reject) => {
16612
17606
  const bufs = [];
16613
17607
  let sets = source.parameterSets;
16614
17608
  let codec = "h264";
@@ -16643,6 +17637,10 @@ async function captureSnapshotFromShared(source, opts = {}) {
16643
17637
  cleanup();
16644
17638
  reject(new LiveSnapshotUnavailableError("source-failed", `source ended before a keyframe (state: ${source.state})`));
16645
17639
  };
17640
+ const onAbandoned = () => {
17641
+ cleanup();
17642
+ reject(opts.signal?.reason);
17643
+ };
16646
17644
  const cleanup = () => {
16647
17645
  clearTimeout(timer);
16648
17646
  if (settle)
@@ -16650,19 +17648,21 @@ async function captureSnapshotFromShared(source, opts = {}) {
16650
17648
  consumer.off("video", onVideo);
16651
17649
  consumer.off("error", onError);
16652
17650
  consumer.off("stop", onStop);
17651
+ opts.signal?.removeEventListener("abort", onAbandoned);
16653
17652
  };
16654
17653
  consumer.on("video", onVideo);
16655
17654
  consumer.on("error", onError);
16656
17655
  consumer.on("stop", onStop);
16657
- });
16658
- return await annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
16659
- logger: opts.logger ?? noopLogger,
16660
- level: opts.ffmpegLevel,
16661
- executable: opts.ffmpegPath
17656
+ opts.signal?.addEventListener("abort", onAbandoned, { once: true });
16662
17657
  });
16663
17658
  } finally {
16664
17659
  consumer.detach();
16665
17660
  }
17661
+ return annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
17662
+ logger: opts.logger ?? noopLogger,
17663
+ level: opts.ffmpegLevel,
17664
+ executable: opts.ffmpegPath
17665
+ });
16666
17666
  }
16667
17667
  async function recordClip(session, seconds, opts = {}) {
16668
17668
  const timeoutMs = opts.timeoutMs ?? 2e4;
@@ -17540,6 +18540,8 @@ var SharedLiveSource = class {
17540
18540
  this.configuredFrom = void 0;
17541
18541
  this.ring = [];
17542
18542
  this._state = state;
18543
+ if (state === "stopped")
18544
+ this.opts.onStopped?.();
17543
18545
  if (startFailed && report)
17544
18546
  this.opts.onStartFailed?.();
17545
18547
  }
@@ -17622,14 +18624,14 @@ var SessionManager = class {
17622
18624
  this.logger = opts.logger ?? noopLogger;
17623
18625
  }
17624
18626
  /** The live session for a station, or `undefined` if not open. */
17625
- get(parentSn) {
17626
- return this.entries.get(parentSn)?.session;
18627
+ get(key) {
18628
+ return this.entries.get(key)?.session;
17627
18629
  }
17628
- /** Serials of stations with a live session. */
18630
+ /** Keys of the open sessions. */
17629
18631
  keys() {
17630
18632
  return [...this.entries].filter(([, e]) => e.session).map(([sn]) => sn);
17631
18633
  }
17632
- /** A plain `Map<parentSn, P2PSession>` snapshot of the live sessions (for `getSessions()` / tests). */
18634
+ /** A plain `Map<key, P2PSession>` snapshot of the open sessions (for `getSessions()` / tests). */
17633
18635
  liveSessions() {
17634
18636
  const m = /* @__PURE__ */ new Map();
17635
18637
  for (const [sn, e] of this.entries)
@@ -17637,56 +18639,77 @@ var SessionManager = class {
17637
18639
  m.set(sn, e.session);
17638
18640
  return m;
17639
18641
  }
17640
- /** Get or create the lifecycle entry for a station. */
17641
- entry(parentSn) {
17642
- let e = this.entries.get(parentSn);
18642
+ /**
18643
+ * Get or create the lifecycle entry under `key`, recording which station it connects to. An existing
18644
+ * entry keeps the station it was opened with — the key owns one connection for its lifetime, and a
18645
+ * later caller passing a different station would otherwise re-point a live entry's power tier.
18646
+ */
18647
+ entry(key, station) {
18648
+ let e = this.entries.get(key);
17643
18649
  if (!e) {
17644
- e = { retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
17645
- this.entries.set(parentSn, e);
18650
+ e = { station, retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
18651
+ this.entries.set(key, e);
17646
18652
  }
17647
18653
  return e;
17648
18654
  }
17649
- /** Register an already-built session for test seeding or an externally assembled connection. */
17650
- register(parentSn, session) {
17651
- this.entry(parentSn).session = session;
18655
+ /**
18656
+ * Register an already-built session for test seeding or an externally assembled connection.
18657
+ *
18658
+ * `station` defaults to the key, which is safe HERE and nowhere else in this class: the paths that open a
18659
+ * session under a key that is not a station serial all go through {@link acquire}, which requires it. A
18660
+ * caller seeding one under such a key must pass it.
18661
+ */
18662
+ register(key, session, station = key) {
18663
+ this.entry(key, station).session = session;
17652
18664
  }
17653
18665
  /**
17654
- * Ensure a session to `parentSn` is open, building it via `factory` if cold. Concurrent calls for the
18666
+ * Ensure a session to `key` is open, building it via `factory` if cold. Concurrent calls for the
17655
18667
  * same cold station share ONE connect (the `connecting` promise); `factory` builds + wires + awaits
17656
18668
  * `connect()` and resolves the connected session.
17657
18669
  */
17658
- async acquire(parentSn, factory) {
17659
- const e = this.entry(parentSn);
18670
+ async acquire(key, factory, station) {
18671
+ const e = this.entry(key, station);
17660
18672
  if (e.connecting) {
17661
- this.logger.debug(`[session ${parentSn}] connecting \u2014 joining in-flight open`);
18673
+ this.logger.debug(`[session ${key}] connecting \u2014 joining in-flight open`);
17662
18674
  return e.connecting;
17663
18675
  }
17664
18676
  if (e.session)
17665
18677
  return e.session;
17666
- this.logger.debug(`[session ${parentSn}] connecting now (on demand)`);
18678
+ this.logger.debug(`[session ${key}] connecting now (on demand)`);
17667
18679
  const generation = this.generation;
17668
18680
  const p = factory((session) => e.session = session);
17669
18681
  e.connecting = p;
17670
18682
  try {
17671
18683
  const session = await p;
17672
- if (generation !== this.generation || this.entries.get(parentSn) !== e) {
18684
+ if (generation !== this.generation || this.entries.get(key) !== e) {
17673
18685
  await session.close();
17674
- throw new SessionSupersededError(`P2P session start superseded for station ${parentSn}`);
18686
+ throw new SessionSupersededError(`P2P session start superseded for ${key}`);
17675
18687
  }
17676
18688
  e.session ??= session;
17677
- this.logger.debug(`[session ${parentSn}] connected`);
18689
+ this.logger.debug(`[session ${key}] connected`);
17678
18690
  return e.session;
17679
18691
  } finally {
17680
18692
  if (e.connecting === p)
17681
18693
  e.connecting = void 0;
17682
18694
  }
17683
18695
  }
17684
- /** Add a reason to stay connected; cancels a pending idle-close. */
17685
- retain(parentSn) {
17686
- const e = this.entry(parentSn);
18696
+ /**
18697
+ * Add a reason to stay connected; cancels a pending idle-close.
18698
+ *
18699
+ * Refused when nothing is open under `key`, for the reason {@link release} gives in the other direction: a
18700
+ * retain names a session that was acquired, and one that names nothing would file an entry with no
18701
+ * connection behind it. {@link hold} is the path that legitimately creates one, and it opens the entry
18702
+ * itself before retaining it.
18703
+ */
18704
+ retain(key) {
18705
+ const e = this.entries.get(key);
18706
+ if (!e) {
18707
+ this.logger.warn(`[session ${key}] retain on a session that is not open \u2014 ignored`);
18708
+ return;
18709
+ }
17687
18710
  e.retained++;
17688
18711
  if (e.idle.pending)
17689
- this.logger.debug(`[session ${parentSn}] in use again \u2014 idle-detach cancelled`);
18712
+ this.logger.debug(`[session ${key}] in use again \u2014 idle-detach cancelled`);
17690
18713
  e.idle.cancel();
17691
18714
  }
17692
18715
  /**
@@ -17697,44 +18720,50 @@ var SessionManager = class {
17697
18720
  * from scratch or complete a deferred reset a real viewer has not yet earned. Clamping to zero did
17698
18721
  * both silently.
17699
18722
  */
17700
- release(parentSn) {
17701
- const e = this.entries.get(parentSn);
18723
+ release(key) {
18724
+ const e = this.entries.get(key);
17702
18725
  if (!e)
17703
18726
  return;
17704
18727
  if (e.retained === 0) {
17705
- this.logger.warn(`[session ${parentSn}] release with nothing retained \u2014 ignored`);
18728
+ this.logger.warn(`[session ${key}] release with nothing retained \u2014 ignored`);
17706
18729
  return;
17707
18730
  }
17708
18731
  e.retained -= 1;
17709
18732
  if (e.reset && e.retained <= e.holdTimers.size) {
17710
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] deferred reset failed`, error));
18733
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] deferred reset failed`, error));
17711
18734
  return;
17712
18735
  }
17713
18736
  if (e.retained === 0)
17714
- this.armIdle(parentSn, e);
18737
+ this.armIdle(key, e);
17715
18738
  }
17716
18739
  /**
17717
18740
  * Hold a session warm for `commandKeepAliveMs` after a control command, then release. A burst of
17718
18741
  * commands each re-holds before the previous release fires, so the session never idles mid-burst.
17719
18742
  */
17720
- bumpCommand(parentSn) {
17721
- this.hold(parentSn, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS);
18743
+ bumpCommand(key, station) {
18744
+ this.hold(key, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS, station);
17722
18745
  }
17723
18746
  /**
17724
- * Retain a station and release it again after `ms` — the primitive behind command-keepalive and event
18747
+ * Retain a session and release it again after `ms` — the primitive behind command-keepalive and event
17725
18748
  * pre-warm, and the only way to hold one open without an attachment to release it.
17726
18749
  *
18750
+ * `station` is required rather than defaulted from the key, because this is the one path that can
18751
+ * CREATE an entry: a pre-warm takes its hold before the open. An entry filed under a media key with
18752
+ * that key as its own station would be asked for the power tier of a serial that does not exist, be
18753
+ * answered `wired`, and never idle-detach — which on a battery station is the drain this class exists
18754
+ * to prevent, and is invisible until the battery is flat.
18755
+ *
17727
18756
  * The timer is owned by the entry, so {@link discard} cancels it. That ownership is the point: keyed
17728
18757
  * only by serial, an expiring hold would otherwise outlive the entry it was taken on and release a
17729
18758
  * retain counted by the SUCCESSOR entry — dropping a live viewer's count and arming an idle-detach
17730
18759
  * underneath it.
17731
18760
  */
17732
- hold(parentSn, ms) {
17733
- const entry = this.entry(parentSn);
17734
- this.retain(parentSn);
18761
+ hold(key, ms, station) {
18762
+ const entry = this.entry(key, station);
18763
+ this.retain(key);
17735
18764
  const timer = setTimeout(() => {
17736
18765
  entry.holdTimers.delete(timer);
17737
- this.release(parentSn);
18766
+ this.release(key);
17738
18767
  }, ms);
17739
18768
  timer.unref?.();
17740
18769
  entry.holdTimers.add(timer);
@@ -17743,29 +18772,29 @@ var SessionManager = class {
17743
18772
  * Arm the idle-close timer for a station whose retain count just reached zero. A wired station with
17744
18773
  * an infinite window is left persistent (no timer). Any subsequent {@link retain} cancels it.
17745
18774
  */
17746
- armIdle(parentSn, e) {
18775
+ armIdle(key, e) {
17747
18776
  e.idle.cancel();
17748
- if ((this.opts.poweredFor?.(parentSn) ?? "wired") !== "battery") {
17749
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 staying persistent (wired)`);
18777
+ if ((this.opts.poweredFor?.(e.station) ?? "wired") !== "battery") {
18778
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 staying persistent (wired)`);
17750
18779
  return;
17751
18780
  }
17752
18781
  const idleMs = this.opts.batteryIdleMs ?? BATTERY_IDLE_MS;
17753
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
17754
- e.idle.arm(idleMs, () => this.onIdle(parentSn));
18782
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
18783
+ e.idle.arm(idleMs, () => this.onIdle(key));
17755
18784
  }
17756
18785
  /**
17757
18786
  * Close a station's session once its idle window elapses with nothing retained, letting the device sleep.
17758
18787
  * Re-checks the count first (activity between the timer firing and now re-arms instead). Dropping the
17759
18788
  * entry here and the session's own `close` → {@link remove} are both idempotent.
17760
18789
  */
17761
- onIdle(parentSn) {
17762
- const e = this.entries.get(parentSn);
18790
+ onIdle(key) {
18791
+ const e = this.entries.get(key);
17763
18792
  if (!e)
17764
18793
  return;
17765
18794
  if (e.retained > 0)
17766
18795
  return;
17767
- this.logger.debug(`[session ${parentSn}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
17768
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] idle detach failed`, error));
18796
+ this.logger.debug(`[session ${key}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
18797
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] idle detach failed`, error));
17769
18798
  }
17770
18799
  /**
17771
18800
  * Close a station the manager itself decided to close, and announce it.
@@ -17783,23 +18812,23 @@ var SessionManager = class {
17783
18812
  * An entry that never carried a session is still torn down, but silently: a pre-warm whose open failed
17784
18813
  * leaves one behind, and announcing it would report a station closed that was never reported open.
17785
18814
  */
17786
- async autoClose(parentSn) {
17787
- const entry = this.discard(parentSn);
18815
+ async autoClose(key) {
18816
+ const entry = this.discard(key);
17788
18817
  if (!entry)
17789
18818
  return;
17790
18819
  if (entry.session)
17791
- this.opts.onAutoClose?.(parentSn);
18820
+ this.opts.onAutoClose?.(key);
17792
18821
  await this.closeEntry(entry);
17793
18822
  }
17794
18823
  /** Drop a station's entry + timer (called from the session's `close` handler). Idempotent. */
17795
- remove(parentSn) {
17796
- const entry = this.discard(parentSn);
18824
+ remove(key) {
18825
+ const entry = this.discard(key);
17797
18826
  if (entry)
17798
18827
  this.settleReset(entry);
17799
18828
  }
17800
18829
  /** Close one station now and discard its lifecycle entry. */
17801
- async close(parentSn) {
17802
- const entry = this.discard(parentSn);
18830
+ async close(key) {
18831
+ const entry = this.discard(key);
17803
18832
  if (entry)
17804
18833
  await this.closeEntry(entry);
17805
18834
  }
@@ -17813,12 +18842,12 @@ var SessionManager = class {
17813
18842
  * Both branches close through {@link autoClose}: the caller asked for a recycle, not for the station's
17814
18843
  * live sources to be dropped, so it does not clean up after one — exactly like the idle path.
17815
18844
  */
17816
- resetWhenUnused(parentSn) {
17817
- const entry = this.entries.get(parentSn);
18845
+ resetWhenUnused(key) {
18846
+ const entry = this.entries.get(key);
17818
18847
  if (!entry)
17819
18848
  return Promise.resolve();
17820
18849
  if (entry.retained <= entry.holdTimers.size)
17821
- return this.autoClose(parentSn);
18850
+ return this.autoClose(key);
17822
18851
  entry.reset ??= Promise.withResolvers();
17823
18852
  return entry.reset.promise;
17824
18853
  }
@@ -17850,22 +18879,22 @@ var SessionManager = class {
17850
18879
  * discarded entry owns no live timer, which is what stops a deferred release from landing on whatever
17851
18880
  * entry next occupies this serial.
17852
18881
  */
17853
- discard(parentSn) {
17854
- const entry = this.entries.get(parentSn);
18882
+ discard(key) {
18883
+ const entry = this.entries.get(key);
17855
18884
  if (!entry)
17856
18885
  return void 0;
17857
18886
  entry.idle.cancel();
17858
18887
  for (const timer of entry.holdTimers)
17859
18888
  clearTimeout(timer);
17860
18889
  entry.holdTimers.clear();
17861
- this.entries.delete(parentSn);
18890
+ this.entries.delete(key);
17862
18891
  return entry;
17863
18892
  }
17864
18893
  /** Close every session and clear all timers. */
17865
18894
  async closeAll() {
17866
18895
  this.generation++;
17867
- const entries = [...this.entries.keys()].flatMap((parentSn) => {
17868
- const entry = this.discard(parentSn);
18896
+ const entries = [...this.entries.keys()].flatMap((key) => {
18897
+ const entry = this.discard(key);
17869
18898
  return entry ? [entry] : [];
17870
18899
  });
17871
18900
  const results = await Promise.allSettled(entries.map((entry) => this.closeEntry(entry)));
@@ -18855,7 +19884,6 @@ var FragmentRecording = class extends EventEmitter6 {
18855
19884
 
18856
19885
  // dist/transport/p2p/command-router.js
18857
19886
  var DIRECT_CMD_SENDS = 5;
18858
- var CONNECT_WAIT_MS = 2e4;
18859
19887
  function abortable(work, signal) {
18860
19888
  if (!signal)
18861
19889
  return work;
@@ -18869,6 +19897,11 @@ function abortable(work, signal) {
18869
19897
  }
18870
19898
  var LEVEL2_GRACE_MS = 25e3;
18871
19899
  var LEVEL2_SETTLE_MS = 8e3;
19900
+ var P2P_STATION_WAITS = {
19901
+ connect: CONNECT_TIMEOUT_MS,
19902
+ level2Grace: LEVEL2_GRACE_MS,
19903
+ level2Settle: LEVEL2_SETTLE_MS
19904
+ };
18872
19905
  var RTSP_URL_READ_TIMEOUT_MS = 12e3;
18873
19906
  var SHARED_LIVE_OPT_KEYS = [
18874
19907
  "eccPrivateKey",
@@ -18886,7 +19919,7 @@ function sameLiveOpt(a, b) {
18886
19919
  }
18887
19920
  var P2PCommandRouter = class _P2PCommandRouter {
18888
19921
  deps;
18889
- /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
19922
+ /** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
18890
19923
  manager;
18891
19924
  /** Error objects already forwarded while a station startup awaits the same session signal. */
18892
19925
  reportedErrors = /* @__PURE__ */ new WeakSet();
@@ -18894,6 +19927,21 @@ var P2PCommandRouter = class _P2PCommandRouter {
18894
19927
  liveSources = /* @__PURE__ */ new Map();
18895
19928
  /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
18896
19929
  liveSourceOpts = /* @__PURE__ */ new Map();
19930
+ /**
19931
+ * The session each live source pulls over — the station's own serial, or the source's own media
19932
+ * session key.
19933
+ *
19934
+ * Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
19935
+ * a reservation rather than a record. Two cameras started together (the four-tile case this feature
19936
+ * exists for) would otherwise both find the station's session free: a consumer attaches only after
19937
+ * `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
19938
+ * would take the shared connection and contend on it.
19939
+ *
19940
+ * A station entry is kept as well as a media one, because "is the station's own session already
19941
+ * claimed" is the question being asked, and an entry that is present but not yet in
19942
+ * {@link liveSources} is a claim in flight.
19943
+ */
19944
+ liveSessionKeys = /* @__PURE__ */ new Map();
18897
19945
  /**
18898
19946
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
18899
19947
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -18907,7 +19955,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
18907
19955
  this.manager = new SessionManager({
18908
19956
  poweredFor: deps.poweredFor,
18909
19957
  logger: deps.logger,
18910
- onAutoClose: (parentSn) => this.tearDownStation(parentSn),
19958
+ onAutoClose: (key) => _P2PCommandRouter.isMediaSessionKey(key) ? this.tearDownMediaSession(key) : this.tearDownStation(key),
18911
19959
  ...deps.sessionIdle
18912
19960
  });
18913
19961
  }
@@ -18920,6 +19968,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
18920
19968
  }
18921
19969
  return normalized;
18922
19970
  }
19971
+ /**
19972
+ * Emit a live trace under a station session's handle, for work this router does ON that session before
19973
+ * the session itself records anything — reaching the station, and resolving what a device is on it. Same
19974
+ * handle as everything the session goes on to trace, which is what groups one attempt.
19975
+ */
19976
+ traceOnStation(session, trace) {
19977
+ traceLiveStart(this.deps.logger ?? noopLogger, trace, session.traceId);
19978
+ }
18923
19979
  /**
18924
19980
  * Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
18925
19981
  * usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
@@ -18931,7 +19987,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
18931
19987
  static claimsDevice(dev) {
18932
19988
  return typeof dev.p2pDid === "string" && dev.p2pDid.length > 0;
18933
19989
  }
18934
- /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
19990
+ /**
19991
+ * The open P2P sessions by key — a station's own under its serial, a camera's media session under
19992
+ * `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
19993
+ */
18935
19994
  getSessions() {
18936
19995
  return this.manager.liveSessions();
18937
19996
  }
@@ -18943,16 +20002,16 @@ var P2PCommandRouter = class _P2PCommandRouter {
18943
20002
  *
18944
20003
  * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
18945
20004
  * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
18946
- * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
20005
+ * A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
18947
20006
  * and the lookups are away, not once the peer has answered, so both would expire together.
18948
20007
  *
18949
20008
  * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
18950
20009
  * session was deliberately closed underneath a speculative open, which is not a fault to report.
18951
20010
  */
18952
20011
  async prewarm(parentSn, ms = PREWARM_MS) {
18953
- this.manager.hold(parentSn, ms);
20012
+ this.manager.hold(parentSn, ms, parentSn);
18954
20013
  try {
18955
- await this.openStation(parentSn);
20014
+ await this.openSession(parentSn, parentSn);
18956
20015
  } catch (e) {
18957
20016
  if (e instanceof SessionSupersededError)
18958
20017
  return;
@@ -18969,6 +20028,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
18969
20028
  src.dispose();
18970
20029
  this.liveSources.clear();
18971
20030
  this.liveSourceOpts.clear();
20031
+ this.liveSessionKeys.clear();
18972
20032
  await this.manager.closeAll();
18973
20033
  }
18974
20034
  /** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
@@ -19010,8 +20070,55 @@ var P2PCommandRouter = class _P2PCommandRouter {
19010
20070
  * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
19011
20071
  * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
19012
20072
  */
19013
- async openStation(parentSn) {
19014
- return this.manager.acquire(parentSn, async (register) => {
20073
+ /**
20074
+ * The key a camera's own media session is filed under, distinct from every station serial because a
20075
+ * serial contains no `#`.
20076
+ */
20077
+ static mediaSessionKey(parentSn, channel) {
20078
+ return `${parentSn}#live:${channel}`;
20079
+ }
20080
+ /** Whether `key` names a media session rather than a station's own. */
20081
+ static isMediaSessionKey(key) {
20082
+ return key.includes("#live:");
20083
+ }
20084
+ /**
20085
+ * Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
20086
+ *
20087
+ * One session serves one camera: a station fans its cameras over a session and answers the most recent
20088
+ * start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
20089
+ * how a station serves another camera.
20090
+ *
20091
+ * The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
20092
+ * captured opening one PPCS session per camera, with three cameras' video arriving in the same second
20093
+ * at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
20094
+ * Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
20095
+ * full frame rate at once over a session each, where the same pair down one session could only take
20096
+ * turns.
20097
+ *
20098
+ * It carries media alone. The station announces its state to every client that connects, so a session
20099
+ * wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
20100
+ * unannounced, and the station's own session stays the single source of connection state, control
20101
+ * notifications and frames.
20102
+ *
20103
+ * The level-2 key is waited for here on the same terms the station's own session gets, because a
20104
+ * connection is not usable for this without one: an attached camera's media start has no level-1 form,
20105
+ * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
20106
+ * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
20107
+ * carry the start is not a connected session, so this refuses rather than returning one.
20108
+ */
20109
+ async openMediaSession(parentSn, channel, signal) {
20110
+ const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
20111
+ let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
20112
+ if (!ready && session.repromptLevel2Key()) {
20113
+ ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
20114
+ }
20115
+ if (!ready)
20116
+ throw new StationKeyUnavailableError(parentSn);
20117
+ return session;
20118
+ }
20119
+ /** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
20120
+ async openSession(key, parentSn) {
20121
+ return this.manager.acquire(key, async (register) => {
19015
20122
  const stationDev = this.recordFor(parentSn);
19016
20123
  if (!stationDev)
19017
20124
  throw new Error(`station ${parentSn} is not in the device list`);
@@ -19026,11 +20133,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
19026
20133
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19027
20134
  }
19028
20135
  const localAddress = this.deps.localAddresses?.[parentSn] ?? freshestLanIp(raw);
19029
- const session = this.makeSession(parentSn, did, raw, dskKey, localAddress);
20136
+ const session = this.makeSession(parentSn, did, raw, dskKey, localAddress, key, key === parentSn);
19030
20137
  register(session);
19031
20138
  await session.connect();
19032
20139
  return session;
19033
- });
20140
+ }, parentSn);
19034
20141
  }
19035
20142
  /**
19036
20143
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
@@ -19043,8 +20150,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19043
20150
  *
19044
20151
  * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
19045
20152
  * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
20153
+ *
20154
+ * A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
20155
+ * level-2 readiness and inbound frames all reach the owner through the station's own session, and a
20156
+ * station announces those to every client that connects — so fanning a second connection's copies out
20157
+ * under the same station serial would report each one twice, and a close would tear down the station
20158
+ * while its own session is still serving.
19046
20159
  */
19047
- makeSession(stationSn, did, raw, dskKey, localAddress) {
20160
+ makeSession(stationSn, did, raw, dskKey, localAddress, key, announces) {
19048
20161
  const conn = raw?.p2p_conn ?? raw?.app_conn;
19049
20162
  const adminUserId = raw?.member?.admin_user_id || this.deps.mega.auth?.userId || "";
19050
20163
  const session = new P2PSession({
@@ -19060,7 +20173,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
19060
20173
  let ecc;
19061
20174
  try {
19062
20175
  const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
19063
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key ?? ciphers[0]?.ecc_private_key;
20176
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
20177
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
20178
+ ecc = ciphers[0].ecc_private_key;
20179
+ this.traceOnStation(session, {
20180
+ phase: "cipher-fallback",
20181
+ cipherId,
20182
+ answeredCipherId: Number(ciphers[0].cipher_id)
20183
+ });
20184
+ }
19064
20185
  } catch (e) {
19065
20186
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19066
20187
  }
@@ -19070,6 +20191,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19070
20191
  },
19071
20192
  logger: this.deps.logger ?? noopLogger
19072
20193
  });
20194
+ session.on("error", (e) => this.reportError(e));
20195
+ if (!announces) {
20196
+ session.on("close", () => {
20197
+ if (this.manager.get(key) === session)
20198
+ this.tearDownMediaSession(key);
20199
+ });
20200
+ return session;
20201
+ }
19073
20202
  session.on("connect", () => {
19074
20203
  if (this.manager.get(stationSn) === session)
19075
20204
  this.deps.onConnect(stationSn);
@@ -19079,7 +20208,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
19079
20208
  return;
19080
20209
  this.tearDownStation(stationSn);
19081
20210
  });
19082
- session.on("error", (e) => this.reportError(e));
19083
20211
  session.on("level2Ready", ({ cipherId }) => this.deps.onLevel2Ready(stationSn, cipherId));
19084
20212
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
19085
20213
  return session;
@@ -19090,7 +20218,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19090
20218
  */
19091
20219
  async ensureStation(parentSn, signal) {
19092
20220
  try {
19093
- const session = await this.openStation(parentSn);
20221
+ const session = await this.openSession(parentSn, parentSn);
19094
20222
  if (session.isConnected)
19095
20223
  return;
19096
20224
  await new Promise((resolve, reject) => {
@@ -19138,7 +20266,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19138
20266
  const dev = this.recordFor(sn);
19139
20267
  if (!dev)
19140
20268
  throw new Error(`device ${sn} not found`);
19141
- await this.openStation(this.stationKeyFor(dev));
20269
+ await this.openSession(this.stationKeyFor(dev), this.stationKeyFor(dev));
19142
20270
  return dev;
19143
20271
  }
19144
20272
  /**
@@ -19214,19 +20342,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
19214
20342
  return {
19215
20343
  snapshotLive: async (opts) => {
19216
20344
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
19217
- return abortable(captureSnapshotFromShared(source, {
20345
+ return captureSnapshotFromShared(source, {
19218
20346
  ...opts,
19219
20347
  logger: this.deps.logger ?? noopLogger,
19220
20348
  ffmpegLevel: this.deps.ffmpegLogLevel,
19221
20349
  ffmpegPath: this.deps.ffmpegPath
19222
- }), opts?.signal);
20350
+ });
19223
20351
  },
19224
20352
  live: async (opts) => {
19225
- const source = await this.sharedLiveSourceFor(sn, opts);
20353
+ const source = await this.sharedLiveSourceFor(sn, opts, true);
19226
20354
  return this.attachUnlessAborted(source, opts?.signal);
19227
20355
  },
19228
20356
  openReadable: async (opts) => {
19229
- const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20357
+ const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
19230
20358
  return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
19231
20359
  },
19232
20360
  recordFragments: (opts) => this.recordFragments(sn, opts),
@@ -19367,10 +20495,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
19367
20495
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
19368
20496
  * reuse branch, so a reuse frees the station as a cold start does.
19369
20497
  *
20498
+ * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
20499
+ * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
20500
+ * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
20501
+ * so a still asked for while a sibling is being watched contends as it always did, and the caller's
20502
+ * retained image answers it. A live view still outranks a tile; what changed is that two live views no
20503
+ * longer have to outrank each other.
20504
+ *
19370
20505
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
19371
20506
  * place.
19372
20507
  */
19373
- async sharedLiveSourceFor(sn, opts = {}) {
20508
+ async sharedLiveSourceFor(sn, opts = {}, mayOpenOwnSession = false) {
19374
20509
  const { session, parentSn, channel, accountId, homeBaseAttached } = await this.resolveSession(sn, {
19375
20510
  waitLevel2: "soft",
19376
20511
  requireLevel2ForAttached: true,
@@ -19383,14 +20518,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
19383
20518
  source = void 0;
19384
20519
  }
19385
20520
  this.releaseLingeringSiblings(parentSn, channel);
19386
- if (homeBaseAttached) {
19387
- const serving = this.occupiedSiblingChannel(parentSn, key);
19388
- if (serving !== void 0)
19389
- throw new StationBusyError(serving);
19390
- }
19391
20521
  if (!source) {
19392
20522
  const logger = this.deps.logger ?? noopLogger;
19393
- const held = { session };
20523
+ const sessionKey = mayOpenOwnSession && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
20524
+ this.liveSessionKeys.set(key, sessionKey);
20525
+ let held;
20526
+ try {
20527
+ held = {
20528
+ session: sessionKey === parentSn ? session : await this.openMediaSession(parentSn, channel, opts.signal)
20529
+ };
20530
+ } catch (error) {
20531
+ this.liveSessionKeys.delete(key);
20532
+ throw error;
20533
+ }
19394
20534
  source = new SharedLiveSource({
19395
20535
  makeStream: (ctx) => new LiveStream(held.session, {
19396
20536
  channel,
@@ -19408,8 +20548,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19408
20548
  budgetGraceMs: opts.budgetGraceMs,
19409
20549
  logger,
19410
20550
  label: key,
19411
- onActive: () => this.manager.retain(parentSn),
19412
- onIdle: () => this.manager.release(parentSn),
20551
+ onActive: () => this.manager.retain(sessionKey),
20552
+ onIdle: () => this.manager.release(sessionKey),
20553
+ onStopped: () => this.closeMediaSession(key),
19413
20554
  onStartFailed: () => this.onLiveStartFailed(sn, key),
19414
20555
  onSessionUnreachable: () => this.replaceUnreachableSession(sn, key, held)
19415
20556
  });
@@ -19424,14 +20565,25 @@ var P2PCommandRouter = class _P2PCommandRouter {
19424
20565
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
19425
20566
  *
19426
20567
  * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
19427
- * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
19428
- * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
19429
- * long as the linger lasts.
20568
+ * re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
20569
+ * which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
20570
+ * for as long as the linger lasts.
19430
20571
  *
19431
20572
  * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
19432
20573
  * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
19433
20574
  * looking at competing with one somebody just asked for.
19434
20575
  *
20576
+ * An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
20577
+ * has sent no media start and is competing for nothing. Without that, two cameras opened at the same
20578
+ * moment destroy each other — the second finds the first's source built but not yet attached to, reads
20579
+ * zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
20580
+ * this whole path exists for.
20581
+ *
20582
+ * A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
20583
+ * contention this releases is contention over one session, and that sibling is not on this one. Dropping
20584
+ * it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
20585
+ * competition that is not happening.
20586
+ *
19435
20587
  * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
19436
20588
  * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
19437
20589
  * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
@@ -19445,28 +20597,49 @@ var P2PCommandRouter = class _P2PCommandRouter {
19445
20597
  for (const [key, source] of [...this.liveSources]) {
19446
20598
  if (!key.startsWith(`${parentSn}:`) || key === own || source.consumerCount > 0)
19447
20599
  continue;
19448
- (this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one station serves one camera at a time`);
20600
+ if (source.state === "idle")
20601
+ continue;
20602
+ if (this.liveSessionKeys.get(key) !== parentSn)
20603
+ continue;
20604
+ (this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one session serves one camera at a time`);
19449
20605
  this.dropLiveSource(key);
19450
20606
  }
19451
20607
  }
19452
20608
  /**
19453
- * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
20609
+ * Whether another camera has already claimed this station's OWN session, ignoring `key`.
20610
+ *
20611
+ * The question a newcomer has to answer is not whether the station is busy — it can serve one camera
20612
+ * per connection — but whether the connection it would otherwise share is taken. A camera on a media
20613
+ * session of its own does not hold this one, so a station whose first camera has since stopped hands
20614
+ * its own session to the next arrival rather than opening a socket beside an idle one.
20615
+ *
20616
+ * A claim counts while its source is still `idle`, source or no source. That is a start that has been
20617
+ * handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
20618
+ * at the same moment can see each other: consumers attach after this method has already run for both.
20619
+ *
20620
+ * The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
20621
+ * session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
20622
+ * socket against destroying a start someone is waiting on, which is not a close trade.
19454
20623
  *
19455
20624
  * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
19456
20625
  * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
19457
- * and counting that as a viewer would refuse every later stream on the station until the client
19458
- * restarted. Only a source that can still deliver holds a place.
20626
+ * and counting that as a viewer would put every later camera on a connection of its own until the
20627
+ * client restarted. Only a source that can still deliver holds a place.
19459
20628
  */
19460
- occupiedSiblingChannel(parentSn, key) {
19461
- for (const [siblingKey, sibling] of this.liveSources) {
19462
- if (siblingKey === key || !siblingKey.startsWith(`${parentSn}:`))
20629
+ stationSessionInUse(parentSn, key) {
20630
+ for (const [siblingKey, sessionKey] of this.liveSessionKeys) {
20631
+ if (siblingKey === key || sessionKey !== parentSn)
19463
20632
  continue;
19464
- if (sibling.state === "stopped" || sibling.consumerCount === 0)
20633
+ const sibling = this.liveSources.get(siblingKey);
20634
+ if (!sibling)
20635
+ return true;
20636
+ if (sibling.state === "stopped")
20637
+ continue;
20638
+ if (sibling.state !== "idle" && sibling.consumerCount === 0)
19465
20639
  continue;
19466
- const channel = Number(siblingKey.slice(parentSn.length + 1));
19467
- return Number.isFinite(channel) ? channel : void 0;
20640
+ return true;
19468
20641
  }
19469
- return void 0;
20642
+ return false;
19470
20643
  }
19471
20644
  /**
19472
20645
  * Whether the stream on `key` should re-assert its channel to hold the station.
@@ -19477,8 +20650,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
19477
20650
  * - Nothing attached: no. There is nobody to take the station for.
19478
20651
  * - A live viewer attached: yes. That is the picture someone is looking at.
19479
20652
  * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
19480
- * tile that is off screen while the live view is on it, and a station serving one camera at a time
19481
- * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
20653
+ * tile that is off screen while the live view is on it, and one session serving one camera at a time
20654
+ * cannot satisfy both — and a still does not open a connection of its own. Measured: a still on a sibling halved a live view's frame rate for as long as
19482
20655
  * it took, and its own capture then took fifteen seconds because it was contending.
19483
20656
  *
19484
20657
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
@@ -19500,7 +20673,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
19500
20673
  }
19501
20674
  return consumer;
19502
20675
  }
19503
- /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
20676
+ /**
20677
+ * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
20678
+ * on a session goes with it, and a media session opened for this source alone is closed: nothing else
20679
+ * can reach that connection, so leaving it open would hold a socket and a station keepalive for a
20680
+ * camera no longer being pulled. A claim on the STATION's own session is released without closing
20681
+ * anything — that connection carries the station's control traffic and outlives any one camera.
20682
+ */
19504
20683
  dropLiveSource(key) {
19505
20684
  const source = this.liveSources.get(key);
19506
20685
  if (!source)
@@ -19508,6 +20687,34 @@ var P2PCommandRouter = class _P2PCommandRouter {
19508
20687
  source.dispose();
19509
20688
  this.liveSources.delete(key);
19510
20689
  this.liveSourceOpts.delete(key);
20690
+ this.closeMediaSession(key);
20691
+ }
20692
+ /** Close and forget the media session `key`'s live source owned, if it owned one. */
20693
+ closeMediaSession(key) {
20694
+ const sessionKey = this.liveSessionKeys.get(key);
20695
+ if (sessionKey === void 0)
20696
+ return;
20697
+ this.liveSessionKeys.delete(key);
20698
+ if (!_P2PCommandRouter.isMediaSessionKey(sessionKey))
20699
+ return;
20700
+ void this.manager.close(sessionKey).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
20701
+ }
20702
+ /**
20703
+ * Drop the live source a media session was carrying, after that session closed on its own.
20704
+ *
20705
+ * The source holds the closed connection and never re-resolves it, so it can only answer its retained
20706
+ * keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
20707
+ * a fresh source — which, finding the station busy again, opens a fresh media session for it.
20708
+ */
20709
+ tearDownMediaSession(sessionKey) {
20710
+ this.manager.remove(sessionKey);
20711
+ for (const [key, owned] of this.liveSessionKeys) {
20712
+ if (owned === sessionKey) {
20713
+ this.liveSessionKeys.delete(key);
20714
+ this.dropLiveSource(key);
20715
+ return;
20716
+ }
20717
+ }
19511
20718
  }
19512
20719
  /**
19513
20720
  * Drop everything that was riding a station's session, and report the station closed.
@@ -19691,9 +20898,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19691
20898
  s.sendStringPayloadCommand(P2P_ENVELOPE.CONTROL_PAYLOAD, json, ch);
19692
20899
  return Promise.resolve();
19693
20900
  },
19694
- l2: async ({ session: s, channel: ch }) => {
20901
+ l2: async ({ session: s, channel: ch, parentSn }) => {
19695
20902
  if (!await s.awaitLevel2Key(LEVEL2_GRACE_MS, "call")) {
19696
- throw new Error(`level-2 key not ready for ${sn} \u2014 cannot query`);
20903
+ throw new StationKeyUnavailableError(parentSn);
19697
20904
  }
19698
20905
  s.sendRawLevel2(json, ch, P2P_ENVELOPE.CONTROL_PAYLOAD);
19699
20906
  }
@@ -19851,24 +21058,40 @@ var P2PCommandRouter = class _P2PCommandRouter {
19851
21058
  const parentSn = homeBaseAttached ? raw.parent_sn : dev.stationSn ?? sn;
19852
21059
  const session = this.manager.get(parentSn) ?? this.manager.get(sn) ?? (dev.stationSn ? this.manager.get(dev.stationSn) : void 0);
19853
21060
  if (!session) {
19854
- throw new Error(`no P2P session for ${sn} (known: ${this.manager.keys().join(", ") || "none"})`);
21061
+ const stations = this.manager.keys().filter((k) => !_P2PCommandRouter.isMediaSessionKey(k));
21062
+ throw new Error(`no P2P session for ${sn} (known: ${stations.join(", ") || "none"})`);
19855
21063
  }
19856
21064
  if (session.pathAnswering === false && !rebuilt) {
19857
21065
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
19858
21066
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
19859
21067
  return await this.resolveSession(sn, opts, true);
19860
21068
  }
19861
- this.manager.bumpCommand(parentSn);
21069
+ this.manager.bumpCommand(parentSn, parentSn);
19862
21070
  const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
19863
- const accountId = raw.member?.admin_user_id ?? this.deps.mega.auth?.userId ?? "";
21071
+ const stationAdminId = raw.member?.admin_user_id;
21072
+ const stationModel = this.recordFor(parentSn)?.model;
21073
+ const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
21074
+ this.traceOnStation(session, {
21075
+ phase: "station-resolved",
21076
+ topology: homeBaseAttached ? "attached" : "own",
21077
+ channel,
21078
+ stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other",
21079
+ ...stationModel ? { stationModel } : {}
21080
+ });
19864
21081
  const t0 = Date.now();
19865
- while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
19866
- opts.signal?.throwIfAborted();
19867
- await sleep2(200);
21082
+ let waitedMs = 0;
21083
+ if (!session.isConnected) {
21084
+ this.traceOnStation(session, { phase: "session-connect-wait", waitMs: P2P_STATION_WAITS.connect });
21085
+ while (!session.isConnected && Date.now() - t0 < P2P_STATION_WAITS.connect) {
21086
+ opts.signal?.throwIfAborted();
21087
+ await sleep2(200);
21088
+ }
21089
+ waitedMs = Date.now() - t0;
21090
+ this.traceOnStation(session, session.isConnected ? { phase: "session-connected", waitedMs } : { phase: "session-unreachable", waitedMs });
19868
21091
  }
19869
21092
  opts.signal?.throwIfAborted();
19870
21093
  if (!session.isConnected)
19871
- throw new Error(`P2P session for ${parentSn} did not connect`);
21094
+ throw new StationUnreachableError(parentSn, waitedMs);
19872
21095
  if (opts.waitLevel2) {
19873
21096
  if (opts.waitLevel2 === "settle") {
19874
21097
  await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
@@ -19882,7 +21105,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19882
21105
  ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), opts.signal);
19883
21106
  }
19884
21107
  if (!ready)
19885
- throw new Error(`level-2 key not ready for ${parentSn}`);
21108
+ throw new StationKeyUnavailableError(parentSn);
19886
21109
  }
19887
21110
  return { session, parentSn, channel, accountId, homeBaseAttached };
19888
21111
  }
@@ -19955,13 +21178,22 @@ var P2PCommandRouter = class _P2PCommandRouter {
19955
21178
  * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
19956
21179
  * unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
19957
21180
  * camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
21181
+ *
21182
+ * Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
21183
+ * control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
21184
+ * least able to afford a single dropped one — it has no reply, no readback here, and nothing that
21185
+ * would tell a caller the write was lost rather than refused. The level-1 form reports delivery by
21186
+ * throwing (`sendSetPayload` throws when the session has no address) rather than by returning a
21187
+ * boolean, so the first pass carries the failure and the rest are repeats.
19958
21188
  */
19959
21189
  async sendSetPayloadEnvelope(sn, cmd, payload, channel, mValue3, resolved, form) {
19960
21190
  if (form === "auto") {
19961
21191
  await this.sendBySessionLevel(sn, {
19962
- l1: ({ session, accountId }) => {
19963
- session.sendSetPayload(cmd, payload, { accountId, channel });
19964
- return Promise.resolve();
21192
+ l1: async ({ session, accountId }) => {
21193
+ for (let i = 0; i < DIRECT_CMD_SENDS; i++) {
21194
+ session.sendSetPayload(cmd, payload, { accountId, channel });
21195
+ await sleep2(200);
21196
+ }
19965
21197
  },
19966
21198
  // NB: do NOT forward sendBySessionLevel's resolved session here — it was resolved with
19967
21199
  // waitLevel2:false (enough to read topology), so on a HomeBase-attached device the level-2
@@ -21369,8 +22601,8 @@ function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
21369
22601
  },
21370
22602
  format: "jwk"
21371
22603
  });
21372
- const md5Hex = createHash6("md5").update(password).digest("hex");
21373
- return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex)).toString("hex");
22604
+ const md5Hex2 = createHash6("md5").update(password).digest("hex");
22605
+ return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex2)).toString("hex");
21374
22606
  }
21375
22607
  var TuyaClient = class {
21376
22608
  signer;
@@ -22633,8 +23865,21 @@ var FileFcmStore = class {
22633
23865
  }
22634
23866
  };
22635
23867
 
23868
+ // dist/transport/media-failure.js
23869
+ var MEDIA_FAILURE_REASONS = [
23870
+ "url-not-allowed",
23871
+ "address-not-public",
23872
+ "redirect-not-allowed",
23873
+ "http-status",
23874
+ "too-large",
23875
+ "timeout",
23876
+ "network",
23877
+ "decode-failed"
23878
+ ];
23879
+
22636
23880
  // dist/transport/stored-image-cache.js
22637
23881
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
23882
+ var MAX_REMEMBERED_URLS = 64;
22638
23883
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
22639
23884
  var StoredImageCache = class {
22640
23885
  downloader;
@@ -22651,7 +23896,13 @@ var StoredImageCache = class {
22651
23896
  this.clock = clock;
22652
23897
  this.isLifecycleError = isLifecycleError;
22653
23898
  }
22654
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
23899
+ /**
23900
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
23901
+ *
23902
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
23903
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
23904
+ * so the entry evicted once it is full is the oldest attempt.
23905
+ */
22655
23906
  observe(deviceKey, url) {
22656
23907
  let state = this.devices.get(deviceKey);
22657
23908
  if (!state) {
@@ -22666,6 +23917,12 @@ var StoredImageCache = class {
22666
23917
  if (state.seenUrls.has(url))
22667
23918
  return;
22668
23919
  state.seenUrls.add(url);
23920
+ while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
23921
+ const oldest = state.seenUrls.values().next();
23922
+ if (oldest.done)
23923
+ break;
23924
+ state.seenUrls.delete(oldest.value);
23925
+ }
22669
23926
  state.queued = {
22670
23927
  deviceKey,
22671
23928
  url,
@@ -22715,7 +23972,7 @@ var StoredImageCache = class {
22715
23972
  } else if (image === void 0) {
22716
23973
  state.lifecycleError = void 0;
22717
23974
  state.reason = "download-failed";
22718
- this.diagnose(state, candidate, "download-failed");
23975
+ this.diagnose(state, candidate, "download-failed", error);
22719
23976
  } else if (!this.isValidJpeg(image)) {
22720
23977
  state.lifecycleError = void 0;
22721
23978
  state.reason = "invalid-image";
@@ -22731,7 +23988,7 @@ var StoredImageCache = class {
22731
23988
  isValidJpeg(image) {
22732
23989
  return Buffer.isBuffer(image) && image.length > 0 && image.length <= MAX_JPEG_BYTES && image.length >= 5 && image[0] === 255 && image[1] === 216 && image[2] === 255 && image[image.length - 2] === 255 && image[image.length - 1] === 217;
22733
23990
  }
22734
- diagnose(state, candidate, failure) {
23991
+ diagnose(state, candidate, failure, error) {
22735
23992
  const now2 = this.clock();
22736
23993
  const last = state.loggedFailures.get(failure);
22737
23994
  if (last !== void 0 && now2 - last < DIAGNOSTIC_INTERVAL_MS)
@@ -22739,11 +23996,23 @@ var StoredImageCache = class {
22739
23996
  state.loggedFailures.set(failure, now2);
22740
23997
  this.logger.warn("[stored-snapshot-cache] candidate failed", {
22741
23998
  class: failure,
23999
+ ...mediaFailureTag(error),
22742
24000
  observedAt: candidate.observedAt,
22743
24001
  retained: state.retained !== void 0
22744
24002
  });
22745
24003
  }
22746
24004
  };
24005
+ function mediaFailureTag(error) {
24006
+ if (typeof error !== "object" || error === null)
24007
+ return {};
24008
+ const { mediaFailure, status } = error;
24009
+ if (typeof mediaFailure !== "string")
24010
+ return {};
24011
+ const cause = MEDIA_FAILURE_REASONS.find((reason) => reason === mediaFailure);
24012
+ if (!cause)
24013
+ return {};
24014
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? { cause, status } : { cause };
24015
+ }
22747
24016
 
22748
24017
  // dist/client/device-registry.js
22749
24018
  function resolvedStationSn(raw, sn) {
@@ -23997,8 +25266,8 @@ var EufyMega = class extends EventEmitter9 {
23997
25266
  * @example
23998
25267
  * ```ts
23999
25268
  * const res = await eufy.login();
24000
- * if (res.status === "captcha") await eufy.solveCaptcha(await ask(res.image));
24001
- * else if (res.status === "2fa") await eufy.submitVerifyCode(await ask());
25269
+ * if (res.status === "captcha") await eufy.solveCaptcha(await promptUser(res.image));
25270
+ * else if (res.status === "2fa") await eufy.submitVerifyCode(await promptUser());
24002
25271
  * ```
24003
25272
  */
24004
25273
  async login(opts = {}) {
@@ -24152,9 +25421,9 @@ var EufyMega = class extends EventEmitter9 {
24152
25421
  /**
24153
25422
  * Combine explicit P2P media with the optional passive push-thumbnail provider.
24154
25423
  *
24155
- * The retained still also becomes the answer for a live still that could not be captured. A station
24156
- * serves one camera at a time and a live view outranks a tile, so a still asked for while a sibling is
24157
- * being watched is refused at the transport. Answering the retained bytes answers the read rather than
25424
+ * The retained still also becomes the answer for a live still that could not be captured. One session
25425
+ * serves one camera at a time and a live view outranks a tile — a still does not open a connection of its
25426
+ * own — so a still asked for while a sibling is being watched is refused at the transport. Answering the retained bytes answers the read rather than
24158
25427
  * failing it, marked {@link MediaProvider.snapshotLive} `retained` so the caller knows they are not
24159
25428
  * current. With nothing retained the refusal stands.
24160
25429
  */
@@ -24323,7 +25592,7 @@ var EufyMega = class extends EventEmitter9 {
24323
25592
  * @example
24324
25593
  * ```ts
24325
25594
  * const dev = await eufy.getDevice(sn);
24326
- * if (dev.has("camera")) await dev.camera()?.snapshotStored();
25595
+ * if (dev.has("camera")) await dev.camera?.()?.snapshotStored?.();
24327
25596
  * console.log(dev.getProperty("battery"));
24328
25597
  * ```
24329
25598
  */
@@ -24907,10 +26176,15 @@ var EufyMega = class extends EventEmitter9 {
24907
26176
  await sink.dispatch(cmd);
24908
26177
  }
24909
26178
  /**
24910
- * Stations with a live P2P session. P2P is auto-managed: wired stations are warmed at login, battery
26179
+ * The open P2P sessions, by key. P2P is auto-managed: wired stations are warmed at login, battery
24911
26180
  * stations open on demand (command / stream, or an opted-in event pre-warm) and idle-detach — so this
24912
- * map grows and shrinks over time. `p2pConnect(stationSn)` / `p2pClose(stationSn)` events track the
24913
- * changes.
26181
+ * map grows and shrinks over time.
26182
+ *
26183
+ * A station's own session is keyed by its serial, and `p2pConnect(stationSn)` / `p2pClose(stationSn)`
26184
+ * track those. A station serving more than one camera at once also holds a session per extra camera,
26185
+ * keyed `<stationSn>#live:<channel>` — these carry media alone and raise no connection events, because
26186
+ * a station announces its state to every client that connects and reporting each copy would double
26187
+ * every event the station's own session already delivers.
24914
26188
  */
24915
26189
  getP2pSessions() {
24916
26190
  return this.p2p.getSessions();
@@ -25283,6 +26557,971 @@ var EufyMega = class extends EventEmitter9 {
25283
26557
  }
25284
26558
  };
25285
26559
 
26560
+ // dist/transport/http/solix-constants.js
26561
+ var SOLIX_APP_NAME = "anker_power";
26562
+ var SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
26563
+ var SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
26564
+ var SOLIX_ENDPOINTS = {
26565
+ estimateDomain: "/passport/estimate_domain",
26566
+ keyExchange: "/openapi/oauth/key/exchange",
26567
+ login: "/passport/login",
26568
+ /** Bound devices for the account (flat list). */
26569
+ getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices",
26570
+ /** Sites (systems) the account owns; devices are grouped under a site. */
26571
+ getSiteList: "/power_service/v1/site/get_site_list",
26572
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26573
+ getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
26574
+ /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
26575
+ productCategories: "/power_service/v1/product_categories",
26576
+ /** POST (encrypted+signed): write device attributes, e.g. `{ambient_light_switch: 0|1}`. */
26577
+ setDeviceAttrs: "/power_service/v1/app/device/set_device_attrs",
26578
+ /** POST (plain authed): read device attributes, e.g. the display `screen_off_time` (seconds). */
26579
+ getDeviceAttrs: "/power_service/v1/app/device/get_device_attrs",
26580
+ /** POST (plain authed): the battery discharge-cutoff (minimum-SOC) preset options. */
26581
+ getPowerCutoff: "/power_service/v1/app/compatible/get_power_cutoff",
26582
+ /** POST (encrypted+signed): select the discharge-cutoff preset by `cutoff_data_id`. */
26583
+ setPowerCutoff: "/power_service/v1/app/compatible/set_power_cutoff",
26584
+ /**
26585
+ * POST (plain authed): the site "scene" snapshot — the same clean Solarbank/grid telemetry the app
26586
+ * reads on load/refresh. Used as a low-rate BACKSTOP for the fields the realtime `ff09` push doesn't
26587
+ * carry reliably (notably `bat_temperature`), NOT as the realtime source (that is the MQTT push).
26588
+ */
26589
+ getSiteScene: "/power_service/v2/site/platform_get_site_scene",
26590
+ /**
26591
+ * POST (plain authed): read a site "device param" block by `param_type`. Body is
26592
+ * `{ site_id, param_type, cmd: 246 }`; the response's `data.param_data` is a JSON STRING the caller
26593
+ * parses. The Solarbank's SOC-limit settings live under `param_type "27"` (charge/discharge limits,
26594
+ * backup reserve) — verified live on an AE103 (`"18"` returns empty for this device).
26595
+ */
26596
+ getSiteDeviceParam: "/power_service/v1/site/get_site_device_param",
26597
+ /**
26598
+ * POST (encrypted+signed): write a site "device param" block. Body is
26599
+ * `{ site_id, cmd: 246, param_type, param_data: <JSON string> }`. Used for the SOC-limit write
26600
+ * (`param_type "27"`, `param_data` = the SocSettingParam map) — see {@link SolixClient.setSafetySocParams}.
26601
+ */
26602
+ setSiteDeviceParam: "/power_service/v1/site/set_site_device_param"
26603
+ };
26604
+
26605
+ // dist/transport/http/solix-client.js
26606
+ function solixSessionFresh(s) {
26607
+ return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
26608
+ }
26609
+ var SOLIX_TOKEN_KICKED_CODE = 26084;
26610
+ var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
26611
+ var SolixClient = class _SolixClient {
26612
+ email;
26613
+ password;
26614
+ country;
26615
+ appVersion;
26616
+ doFetch;
26617
+ store;
26618
+ openudid;
26619
+ apiHost;
26620
+ session_;
26621
+ /** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
26622
+ pending2fa;
26623
+ /**
26624
+ * Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
26625
+ * not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
26626
+ * handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
26627
+ * override that also skips domain-estimate, and every read goes through `this.apiHost`.
26628
+ */
26629
+ constructor(opts) {
26630
+ this.email = opts.email;
26631
+ this.password = opts.password;
26632
+ this.country = (opts.countryCode ?? "US").toUpperCase();
26633
+ this.appVersion = opts.appVersion ?? "3.23.0";
26634
+ this.doFetch = opts.fetchImpl ?? fetch;
26635
+ this.apiHost = opts.apiHost ?? SOLIX_DEFAULT_API_HOST;
26636
+ this.store = opts.store;
26637
+ const saved = this.store?.load();
26638
+ this.openudid = opts.openudid ?? saved?.openudid ?? uuidFromHex(md5Hex(`anker-solix:${opts.email}`));
26639
+ if (saved?.session && solixSessionFresh(saved.session)) {
26640
+ this.session_ = saved.session;
26641
+ this.apiHost = opts.apiHost ?? saved.session.apiHost;
26642
+ }
26643
+ }
26644
+ /** Persist the current device id (+ session, if any) when a store is configured. */
26645
+ persist() {
26646
+ this.store?.save({ openudid: this.openudid, session: this.session_ });
26647
+ }
26648
+ /** The authenticated session, once {@link login} has resolved to `ok`. */
26649
+ get session() {
26650
+ return this.session_;
26651
+ }
26652
+ /**
26653
+ * Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
26654
+ * must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
26655
+ * (`401 token error`) — so those use {@link baseHeaders} directly.
26656
+ */
26657
+ authHeaders(extra = {}) {
26658
+ return this.baseHeaders({ openudid: this.openudid, "x-terminal-id": this.openudid, ...extra });
26659
+ }
26660
+ /** Base headers common to every Solix request. */
26661
+ baseHeaders(extra = {}) {
26662
+ return {
26663
+ "content-type": "application/json",
26664
+ "app-name": SOLIX_APP_NAME,
26665
+ "model-type": "PHONE",
26666
+ "os-type": "android",
26667
+ "os-version": "36",
26668
+ "app-version": this.appVersion,
26669
+ country: this.country,
26670
+ timezone: "GMT+00:00",
26671
+ language: "en",
26672
+ "user-agent": "ktor-client",
26673
+ accept: "application/json",
26674
+ ...extra
26675
+ };
26676
+ }
26677
+ /** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
26678
+ async send(method2, host, path, headers, body) {
26679
+ const res = await this.doFetch(`https://${host}${path}`, {
26680
+ method: method2,
26681
+ headers,
26682
+ body,
26683
+ signal: AbortSignal.timeout(2e4)
26684
+ });
26685
+ const text2 = await res.text();
26686
+ try {
26687
+ return JSON.parse(text2);
26688
+ } catch {
26689
+ throw new Error(`Solix ${path} \u2192 HTTP ${res.status}, non-JSON: ${text2.slice(0, 120)}`);
26690
+ }
26691
+ }
26692
+ /** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
26693
+ post(host, path, body, headers) {
26694
+ return this.send("POST", host, path, headers, body);
26695
+ }
26696
+ /** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
26697
+ async estimateHost() {
26698
+ try {
26699
+ const env = await this.post(SOLIX_ESTIMATE_HOST, SOLIX_ENDPOINTS.estimateDomain, JSON.stringify({ ab: this.country, mode: 1 }), this.baseHeaders());
26700
+ const domain = env.data?.domain;
26701
+ if (domain)
26702
+ this.apiHost = domain;
26703
+ } catch {
26704
+ }
26705
+ }
26706
+ /** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
26707
+ async keyExchange() {
26708
+ const prep = prepareKeyExchange(SOLIX_LOCAL_KEY_HEX);
26709
+ const env = await this.post(this.apiHost, SOLIX_ENDPOINTS.keyExchange, JSON.stringify({ client_public_key: prep.encryptedClientPublicKey }), this.authHeaders(prep.headers));
26710
+ const spk = env.data?.server_public_key;
26711
+ if (env.code !== 0 || !spk)
26712
+ throw new Error(`Solix key/exchange failed (${env.code}): ${env.msg}`);
26713
+ return finishKeyExchange(prep, spk);
26714
+ }
26715
+ /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
26716
+ async postLogin(kx, verifyCode, limitedToken) {
26717
+ const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
26718
+ const bodyObj = {
26719
+ email: this.email,
26720
+ password: encryptedPassword,
26721
+ ab: this.country,
26722
+ client_secret_info: { public_key: clientPublicKeyHex },
26723
+ answer: "",
26724
+ captcha_id: "",
26725
+ verify_code: verifyCode ?? "",
26726
+ login_id: ""
26727
+ };
26728
+ const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
26729
+ const ts = nowSec();
26730
+ const once = genId();
26731
+ return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
26732
+ "x-encryption-info": "algo_ecdh",
26733
+ "x-key-ident": kx.keyIdent,
26734
+ "x-request-ts": ts,
26735
+ "x-request-once": once,
26736
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26737
+ ...limitedToken ? { "x-auth-token": limitedToken } : {}
26738
+ }));
26739
+ }
26740
+ /**
26741
+ * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
26742
+ * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
26743
+ * has been satisfied.
26744
+ *
26745
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
26746
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
26747
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
26748
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
26749
+ */
26750
+ classifyLogin(data, isVerify) {
26751
+ const userId = data.ap_cloud_user_id ?? data.user_id;
26752
+ const authToken = data.auth_token;
26753
+ if (!userId || !authToken)
26754
+ throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
26755
+ const faInfo = data.fa_info ?? {};
26756
+ if (!isVerify && faInfo.info) {
26757
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
26758
+ return { status: "2fa", method: "code sent by the passport" };
26759
+ }
26760
+ this.pending2fa = void 0;
26761
+ this.session_ = {
26762
+ authToken,
26763
+ userId,
26764
+ gtoken: gtoken(userId),
26765
+ apiHost: this.apiHost,
26766
+ tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
26767
+ };
26768
+ this.persist();
26769
+ return { status: "ok", session: this.session_ };
26770
+ }
26771
+ /** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
26772
+ decryptLogin(env, kx) {
26773
+ if (typeof env.data !== "string")
26774
+ throw new Error(`Solix login (${env.code}): ${env.msg}`);
26775
+ return JSON.parse(decryptBody(env.data, kx.shareKey).toString("utf-8"));
26776
+ }
26777
+ /**
26778
+ * Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
26779
+ * when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
26780
+ * (adopted from a store) is answered without a handshake.
26781
+ */
26782
+ async login() {
26783
+ if (solixSessionFresh(this.session_)) {
26784
+ return { status: "ok", session: this.session_ };
26785
+ }
26786
+ await this.estimateHost();
26787
+ const kx = await this.keyExchange();
26788
+ const env = await this.postLogin(kx);
26789
+ this.assertLoginAccepted(env);
26790
+ return this.classifyLogin(this.decryptLogin(env, kx), false);
26791
+ }
26792
+ /**
26793
+ * On a rejected `/passport/login` (non-zero code, so `data` is an error envelope not the encrypted
26794
+ * payload), throw a diagnostic that names WHY the passport refused — the throttle (`26161`, "too
26795
+ * frequent") vs a challenge it wants the client to satisfy. The passport marks a required captcha with
26796
+ * a `captcha_id`/`item`; our headless client cannot answer one, so surfacing it distinguishes "wait
26797
+ * out the rate-limit" from "a captcha is required — clear it in the app". No secrets are logged, only
26798
+ * the code, message, and which challenge fields are present.
26799
+ */
26800
+ assertLoginAccepted(env) {
26801
+ if (env.code === 0)
26802
+ return;
26803
+ const d = env.data ?? {};
26804
+ const hints = [];
26805
+ if (typeof d === "object" && d) {
26806
+ if ("captcha_id" in d && d.captcha_id)
26807
+ hints.push("captcha_id present (captcha required)");
26808
+ if ("item" in d && d.item)
26809
+ hints.push(`item=${String(d.item).slice(0, 40)}`);
26810
+ const keys = Object.keys(d);
26811
+ if (keys.length && hints.length === 0)
26812
+ hints.push(`data keys: ${keys.join(",")}`);
26813
+ }
26814
+ const detail = hints.length ? ` [${hints.join("; ")}]` : "";
26815
+ throw new Error(`Solix login (${env.code}): ${env.msg}${detail}`);
26816
+ }
26817
+ /** Complete a `2fa` login with the code the passport sent. */
26818
+ async submitVerifyCode(code) {
26819
+ if (!this.pending2fa)
26820
+ throw new Error("no 2FA login is pending");
26821
+ const kx = await this.keyExchange();
26822
+ const env = await this.postLogin(kx, code, this.pending2fa.limitedToken);
26823
+ return this.classifyLogin(this.decryptLogin(env, kx), true);
26824
+ }
26825
+ /**
26826
+ * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
26827
+ * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
26828
+ *
26829
+ * Self-heals a **displaced session**: Anker allows ~one session per account, so another login (the app,
26830
+ * or a second client) invalidates this token and reads then fail with {@link SOLIX_TOKEN_KICKED_CODE}
26831
+ * ("token does not exist because it was kicked out"). On that code this re-logs in once and retries, so
26832
+ * a running client recovers on its own instead of failing every read until its session store is cleared.
26833
+ */
26834
+ async authed(method2, path, body, reauthed = false) {
26835
+ if (!this.session_)
26836
+ throw new Error("not authenticated \u2014 call login() first");
26837
+ const env = await this.send(method2, this.apiHost, path, this.baseHeaders({ gtoken: this.session_.gtoken, "x-auth-token": this.session_.authToken }), body ? JSON.stringify(body) : void 0);
26838
+ if (env.code === 0)
26839
+ return env.data ?? null;
26840
+ if (env.code === SOLIX_TOKEN_KICKED_CODE && !reauthed) {
26841
+ this.session_ = void 0;
26842
+ const r = await this.login();
26843
+ if (r.status !== "ok")
26844
+ throw new Error(`Solix ${path}: session was kicked and re-login did not complete (${r.status})`);
26845
+ return this.authed(method2, path, body, true);
26846
+ }
26847
+ throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
26848
+ }
26849
+ /**
26850
+ * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
26851
+ * gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
26852
+ * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
26853
+ */
26854
+ async getDevices() {
26855
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
26856
+ return Array.isArray(data) ? data : data?.data ?? [];
26857
+ }
26858
+ /**
26859
+ * The account's sites (systems); devices are grouped under a site. Each record carries its
26860
+ * `site_device_list` (the member devices), which {@link discoverSolixSites} resolves into a
26861
+ * capability-driven `SolixSite`. Asserted to {@link SolixSiteRecord} at this trust boundary — and
26862
+ * `site_id` (the one field the model layer keys a `SolixSite` on) is validated here, so a record the
26863
+ * cloud returns without a usable id is dropped rather than surfacing a `SolixSite` with `id ===
26864
+ * undefined`; every other field is optional and read defensively.
26865
+ */
26866
+ async getSites() {
26867
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
26868
+ const list = data?.site_list ?? [];
26869
+ return list.filter((s) => typeof s?.site_id === "string" && s.site_id.length > 0);
26870
+ }
26871
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26872
+ async getUserMqttInfo() {
26873
+ return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
26874
+ }
26875
+ /**
26876
+ * Read a site's "scene" snapshot — the app's dashboard read for a system, a plain authed read. Its
26877
+ * battery detail (`solarbank_info.solarbank_list[]`) carries clean, correctly-named fields including
26878
+ * `bat_temperature`, which the realtime `ff09` MQTT push does NOT reliably carry (the fast frame's BMS
26879
+ * blob is empty, so the decoder withholds temperature). This is therefore a low-rate BACKSTOP for those
26880
+ * gap fields — NOT the realtime source: live power/SOC still come from the MQTT push (which is what the
26881
+ * app itself refreshes from every ~5 s; there is no clean-JSON scene PUSH). Verified live against the
26882
+ * `ff09` floats — the two agree to the watt at the same instant.
26883
+ */
26884
+ async getSiteScene(siteId) {
26885
+ return this.authed("POST", SOLIX_ENDPOINTS.getSiteScene, { site_id: siteId });
26886
+ }
26887
+ /**
26888
+ * The pairable-product catalog (categories → products). This is Anker's product registry, not the
26889
+ * account's devices — fetch it to label a discovered device's model code with a marketing name and
26890
+ * category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
26891
+ * baked-in table.
26892
+ */
26893
+ async getProductCatalog() {
26894
+ return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
26895
+ }
26896
+ /**
26897
+ * Write device attributes — a CONTROL write, e.g. the Solarbank ambient light
26898
+ * `{ ambient_light_switch: 0 | 1 }` (0 = on, 1 = off). Unlike the plain authenticated reads, a write
26899
+ * must be **encrypted + signed** with a freshly negotiated `algo_ecdh` key: the gateway accepts an
26900
+ * unsigned write with `code 0` but the device never applies it. The token-bearing request also must
26901
+ * NOT carry the device id (`openudid`), or the gateway answers `401 token error`. Both verified live
26902
+ * on an AE103 (the LED-enable bit in the `ba` telemetry flips exactly as commanded).
26903
+ */
26904
+ async setDeviceAttrs(deviceSn, attributes) {
26905
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setDeviceAttrs, "set_device_attrs", {
26906
+ device_sn: deviceSn,
26907
+ attributes
26908
+ });
26909
+ }
26910
+ /**
26911
+ * A CONTROL write: `algo_ecdh`-encrypted + signed, token-bearing but WITHOUT `openudid`. Every device
26912
+ * control the account performs (set_device_attrs, set_power_cutoff, …) goes through this — the gateway
26913
+ * accepts an unsigned/plain write with `code 0` but the device never applies it, and adding `openudid`
26914
+ * to the token-bearing request returns `401 token error`. Both verified live on an AE103.
26915
+ */
26916
+ async encryptedWrite(path, label, payload) {
26917
+ if (!this.session_)
26918
+ throw new Error("not authenticated \u2014 call login() first");
26919
+ const kx = await this.keyExchange();
26920
+ const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
26921
+ const ts = nowSec();
26922
+ const once = genId();
26923
+ const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
26924
+ "x-encryption-info": "algo_ecdh",
26925
+ "x-key-ident": kx.keyIdent,
26926
+ "x-request-ts": ts,
26927
+ "x-request-once": once,
26928
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26929
+ "x-auth-token": this.session_.authToken,
26930
+ gtoken: this.session_.gtoken
26931
+ }));
26932
+ if (env.code !== 0)
26933
+ throw new Error(`Solix ${label} failed (${env.code}): ${env.msg}`);
26934
+ }
26935
+ /** Turn the Solarbank's ambient LED on/off — a confirmed `set_device_attrs` write. */
26936
+ async setAmbientLight(deviceSn, on) {
26937
+ await this.setDeviceAttrs(deviceSn, { ambient_light_switch: on ? 0 : 1 });
26938
+ }
26939
+ /**
26940
+ * Read device attributes — a plain authenticated read (unlike the encrypted write). `attributes`
26941
+ * names the keys to fetch (e.g. `["screen_off_time"]`); an empty list asks for the device's default
26942
+ * set. Returns the gateway's attribute map as-is (values are device-typed — numbers, strings). Used
26943
+ * to reflect a control's live state, e.g. the display/light off-timeout.
26944
+ */
26945
+ async getDeviceAttrs(deviceSn, attributes = []) {
26946
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getDeviceAttrs, { device_sn: deviceSn, attributes });
26947
+ if (data && typeof data === "object" && "attributes" in data && data.attributes) {
26948
+ return data.attributes;
26949
+ }
26950
+ return data ?? {};
26951
+ }
26952
+ /**
26953
+ * Set the Solarbank display's screen-off timeout, in SECONDS (`screen_off_time`). The app's picker
26954
+ * offers 10/20/30 s and 1/5/30 min; the LCD backlight — and with it the ambient LED that the screen
26955
+ * gates — turns off after this idle period. This is the raw-seconds write; the caller maps its own UI
26956
+ * options to seconds. The "Never" (always-on) sentinel is device-defined and NOT assumed here — pass
26957
+ * the exact integer read back from {@link getDeviceAttrs} while the device is in that mode.
26958
+ */
26959
+ async setScreenOffTime(deviceSn, seconds) {
26960
+ await this.setDeviceAttrs(deviceSn, { screen_off_time: seconds });
26961
+ }
26962
+ /**
26963
+ * Read the Solarbank's battery discharge-cutoff (minimum-SOC) options — a plain authed read.
26964
+ * The gateway returns a preset list (`power_cutoff_data`): each entry is a selectable minimum
26965
+ * state-of-charge `output_cutoff_data` (percent) with its `id` and `is_selected` flag. The caller
26966
+ * presents these options and writes the chosen `id` back via {@link setPowerCutoff} — the values and
26967
+ * ids come from the device, never assumed. `siteId` is optional (the device knows its own cutoff).
26968
+ */
26969
+ async getPowerCutoff(deviceSn, siteId = "") {
26970
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getPowerCutoff, { site_id: siteId, device_sn: deviceSn });
26971
+ return data?.power_cutoff_data ?? [];
26972
+ }
26973
+ /**
26974
+ * Select the Solarbank's battery discharge-cutoff (minimum SOC) by option id — a control write.
26975
+ * `cutoffDataId` MUST be an `id` returned by {@link getPowerCutoff} for this device (the preset the
26976
+ * user picked), never a raw percentage; the gateway maps the id to its cutoff percent.
26977
+ */
26978
+ async setPowerCutoff(deviceSn, cutoffDataId) {
26979
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setPowerCutoff, "set_power_cutoff", {
26980
+ device_sn: deviceSn,
26981
+ cutoff_data_id: cutoffDataId
26982
+ });
26983
+ }
26984
+ /** The `param_type` under which the Solarbank's SOC-limit block lives (verified live on an AE103). */
26985
+ static SOC_PARAM_TYPE = "27";
26986
+ /** `cmd` value that scopes the `site/*_site_device_param` family (from the app's request builder). */
26987
+ static SITE_DEVICE_PARAM_CMD = 246;
26988
+ /**
26989
+ * Read one of a site's "device param" blocks by `param_type` — a plain authenticated read whose
26990
+ * `data.param_data` is itself a JSON STRING (the vendor double-encodes it). Returns the parsed inner
26991
+ * object, or `{}` when the block is empty (the gateway answers `code 0` with an empty `param_data`
26992
+ * for a `param_type` that does not apply to the site's hardware). The caller owns the inner shape.
26993
+ */
26994
+ async getSiteDeviceParam(siteId, paramType) {
26995
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteDeviceParam, {
26996
+ site_id: siteId,
26997
+ param_type: paramType,
26998
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD
26999
+ });
27000
+ const raw = data?.param_data;
27001
+ if (!raw)
27002
+ return {};
27003
+ try {
27004
+ return JSON.parse(raw);
27005
+ } catch {
27006
+ return {};
27007
+ }
27008
+ }
27009
+ /**
27010
+ * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
27011
+ * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
27012
+ * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
27013
+ * app-synced source (and the only source for `chargeUpperLimit` / `backupReserve`). Verified live
27014
+ * against a known AE103 setting (discharge 20 / charge 80).
27015
+ */
27016
+ async getSafetySocParams(siteId) {
27017
+ const p = await this.getSiteDeviceParam(siteId, _SolixClient.SOC_PARAM_TYPE);
27018
+ if (typeof p.charge_upper_limit !== "number" || typeof p.discharge_lower_limit !== "number") {
27019
+ return void 0;
27020
+ }
27021
+ return {
27022
+ chargeUpperLimit: p.charge_upper_limit,
27023
+ dischargeLowerLimit: p.discharge_lower_limit,
27024
+ backupReserve: typeof p.backup_reserve === "number" ? p.backup_reserve : 0,
27025
+ backupReserveSwitch: typeof p.backup_reserve_switch === "number" ? p.backup_reserve_switch : 0,
27026
+ socCalibrationEnable: typeof p.soc_calibration_enable === "number" ? p.soc_calibration_enable : 0
27027
+ };
27028
+ }
27029
+ /**
27030
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
27031
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
27032
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
27033
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
27034
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
27035
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
27036
+ */
27037
+ async setSafetySocParams(siteId, changes) {
27038
+ const current = await this.getSafetySocParams(siteId);
27039
+ if (!current)
27040
+ throw new Error(`Solix set SOC params: site has no param_type 27 block`);
27041
+ const merged = { ...current, ...changes };
27042
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setSiteDeviceParam, "set_site_device_param", {
27043
+ site_id: siteId,
27044
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD,
27045
+ param_type: _SolixClient.SOC_PARAM_TYPE,
27046
+ param_data: JSON.stringify({
27047
+ charge_upper_limit: merged.chargeUpperLimit,
27048
+ discharge_lower_limit: merged.dischargeLowerLimit,
27049
+ backup_reserve_switch: merged.backupReserveSwitch,
27050
+ backup_reserve: merged.backupReserve,
27051
+ soc_calibration_enable: merged.socCalibrationEnable
27052
+ })
27053
+ });
27054
+ return merged;
27055
+ }
27056
+ };
27057
+
27058
+ // dist/transport/mqtt/solix-mqtt.js
27059
+ import { EventEmitter as EventEmitter10 } from "node:events";
27060
+ var SOLIX_METER_FIELD_NAMES = {
27061
+ 168: "meterPowerL1",
27062
+ 169: "meterPowerL2",
27063
+ 170: "meterPowerL3",
27064
+ 171: "meterPowerTotal",
27065
+ 172: "meterVoltageL1",
27066
+ 173: "meterVoltageL2",
27067
+ 174: "meterVoltageL3",
27068
+ 175: "meterCurrentL1",
27069
+ 176: "meterCurrentL2",
27070
+ 177: "meterCurrentL3",
27071
+ 179: "meterImportEnergy",
27072
+ 180: "meterExportEnergy"
27073
+ };
27074
+ var SOLIX_METER_PRODUCT_PREFIXES = ["AE1X0"];
27075
+ var SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
27076
+ var SOLIX_SOLARBANK_FIELD_NAMES = {
27077
+ 171: "photovoltaicPower",
27078
+ // total PV input across the strings
27079
+ 172: "batteryPower",
27080
+ 188: "chargePower",
27081
+ 173: "dischargePower",
27082
+ 174: "acPlugPower",
27083
+ 175: "socketPower",
27084
+ // the unit's own on-board AC socket (an appliance plugged into the Solarbank)
27085
+ 196: "gridInputPower",
27086
+ 197: "homeLoadPower",
27087
+ 198: "pv1Power",
27088
+ // the four PV-string inputs (0 when a string is unused / dark)
27089
+ 199: "pv2Power",
27090
+ 200: "pv3Power",
27091
+ 201: "pv4Power"
27092
+ };
27093
+ var SOLIX_STATE_FIELD_NAMES = {
27094
+ 169: "mode",
27095
+ // current operating (EMS) mode (1 custom, 2 self-consumption, 4 rapid charge, 7 smart, 8 dynamic tariff)
27096
+ 170: "maxLoad"
27097
+ // configured max home load (W) — matches get_site_device_param max_load
27098
+ // NOTE `0xab` is grid-in/out-related power but its exact meaning is not yet pinned, so it stays raw
27099
+ // `state_ab` (a diagnostic a consumer can watch) rather than being asserted under a guessed name.
27100
+ };
27101
+ function solixStateReadings(frame) {
27102
+ const out = {};
27103
+ for (const [tag2, value] of frame.fields) {
27104
+ if (tag2 < 165 || !value || value.length < 2)
27105
+ continue;
27106
+ const type = value[0];
27107
+ const pl = value.subarray(1);
27108
+ let num2;
27109
+ if (type === 5 && pl.length >= 4)
27110
+ num2 = pl.readFloatLE(0);
27111
+ else if (type === 2 && pl.length >= 2)
27112
+ num2 = pl.readUInt16LE(0);
27113
+ else if (type === 1 && pl.length >= 1)
27114
+ num2 = pl[0];
27115
+ else if (type === 3 && pl.length >= 2)
27116
+ num2 = pl[1];
27117
+ if (num2 === void 0)
27118
+ continue;
27119
+ out[`state_${tag2.toString(16)}`] = num2;
27120
+ const name = SOLIX_STATE_FIELD_NAMES[tag2];
27121
+ if (name)
27122
+ out[name] = num2;
27123
+ }
27124
+ return out;
27125
+ }
27126
+ function readSolixChannel(value) {
27127
+ if (!value || value.length < 1)
27128
+ return void 0;
27129
+ const raw = value.subarray(1);
27130
+ const ch = { type: value[0], raw };
27131
+ if (raw.length === 4) {
27132
+ ch.float = raw.readFloatLE(0);
27133
+ ch.uint = raw.readUInt32LE(0);
27134
+ }
27135
+ return ch;
27136
+ }
27137
+ function decodeSolixParamFrame(buf) {
27138
+ if (buf.length < 10 || buf[0] !== 255 || buf[1] !== 9)
27139
+ return null;
27140
+ const declaredLen = buf.readUInt16LE(2);
27141
+ if (declaredLen < 5 || declaredLen > buf.length)
27142
+ return null;
27143
+ let xor = 0;
27144
+ for (let i = 0; i < declaredLen; i++)
27145
+ xor ^= buf[i];
27146
+ if (xor !== 0)
27147
+ return null;
27148
+ const end = declaredLen - 1;
27149
+ const start = buf.indexOf(161, 4);
27150
+ if (start < 0 || start >= end)
27151
+ return { fields: /* @__PURE__ */ new Map() };
27152
+ const fields = walkFf09Tlv(buf, start, end);
27153
+ let deviceSn;
27154
+ const a2 = fields.get(162);
27155
+ if (a2 && a2.length > 1)
27156
+ deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
27157
+ return { deviceSn, fields };
27158
+ }
27159
+ function solixReadings(frame, productCode) {
27160
+ const out = {};
27161
+ const isMeter = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
27162
+ const isSolarbank = productCode.startsWith(SOLIX_SOLARBANK_PRODUCT_PREFIX);
27163
+ const floatNames = isMeter ? SOLIX_METER_FIELD_NAMES : isSolarbank ? SOLIX_SOLARBANK_FIELD_NAMES : void 0;
27164
+ for (const [tag2, value] of frame.fields) {
27165
+ if (tag2 < 166)
27166
+ continue;
27167
+ const ch = readSolixChannel(value);
27168
+ if (ch?.type !== 5 || ch.float === void 0)
27169
+ continue;
27170
+ out[`channel_${tag2.toString(16)}`] = ch.float;
27171
+ const name = floatNames?.[tag2];
27172
+ if (name)
27173
+ out[name] = ch.float;
27174
+ }
27175
+ if (isSolarbank)
27176
+ addSolarbankScalars(frame, out);
27177
+ return out;
27178
+ }
27179
+ function addSolarbankScalars(frame, out) {
27180
+ const a3 = frame.fields.get(163);
27181
+ const soc = a3 && a3.length >= 2 ? a3[1] : void 0;
27182
+ if (soc !== void 0) {
27183
+ out.batterySoc = soc;
27184
+ const body = frame.fields.get(164)?.subarray(1);
27185
+ if (body && body.length >= 8 && body[body.length - 6] === soc) {
27186
+ out.batteryTemperature = body[body.length - 8];
27187
+ }
27188
+ }
27189
+ const b5 = frame.fields.get(181);
27190
+ if (b5 && b5[0] === 4 && b5.length === 4) {
27191
+ out.dischargeLimit = b5[1];
27192
+ out.chargeLimit = b5[3];
27193
+ }
27194
+ }
27195
+ var SolixMqtt = class extends EventEmitter10 {
27196
+ transport;
27197
+ appName;
27198
+ userId;
27199
+ appClientId;
27200
+ armIntervalMs;
27201
+ logger;
27202
+ siteId;
27203
+ watched = /* @__PURE__ */ new Map();
27204
+ seq = 0;
27205
+ armTimer;
27206
+ /**
27207
+ * Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
27208
+ * (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
27209
+ * restart presents itself to the broker as a new client, so it defaults deterministically from the user
27210
+ * id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
27211
+ */
27212
+ constructor(opts) {
27213
+ super();
27214
+ this.appName = opts.mqttInfo.app_name ?? "anker_power";
27215
+ this.userId = opts.userId ?? opts.mqttInfo.user_id;
27216
+ this.armIntervalMs = opts.armIntervalMs ?? 25e3;
27217
+ this.siteId = opts.siteId;
27218
+ this.logger = opts.logger;
27219
+ const uid = this.userId ?? "anonymous";
27220
+ this.appClientId = opts.appClientId ?? buildAppShapedClientId({
27221
+ appName: this.appName,
27222
+ uid,
27223
+ mqttUuid: opts.mqttUuid ?? mqttUuidFrom(`anker-solix-mqtt:${uid}`)
27224
+ });
27225
+ this.transport = new SecureMqtt({
27226
+ credentials: opts.mqttInfo,
27227
+ clientId: opts.clientId ?? opts.mqttInfo.thing_name,
27228
+ reconnectPeriod: 5e3,
27229
+ logger: opts.logger
27230
+ });
27231
+ this.transport.on("error", (e) => this.emit("error", e));
27232
+ this.transport.on("message", (msg) => this.onMessage(msg));
27233
+ }
27234
+ /**
27235
+ * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
27236
+ * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
27237
+ *
27238
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
27239
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
27240
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
27241
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
27242
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
27243
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
27244
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
27245
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
27246
+ *
27247
+ * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
27248
+ * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
27249
+ * success: the call would resolve and arm on every interval while no reading ever arrives.
27250
+ *
27251
+ * The re-arm timer is unreffed, so a caller that watches and returns can still exit.
27252
+ */
27253
+ async watch(device) {
27254
+ await this.transport.connect();
27255
+ const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
27256
+ const granted = await this.transport.subscribe([
27257
+ topics.paramInfo,
27258
+ topics.stateInfo,
27259
+ topics.cmdRes,
27260
+ topics.req,
27261
+ ...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
27262
+ ]);
27263
+ if (!granted.includes(topics.paramInfo)) {
27264
+ const scope = this.appName;
27265
+ throw new Error(`watch ${device.device_sn}: telemetry topic "${topics.paramInfo}" denied on credential scope "${scope}" \u2014 the subscription would arm but never deliver a reading`);
27266
+ }
27267
+ this.watched.set(device.device_sn, device);
27268
+ if (this.armIntervalMs > 0) {
27269
+ await this.armAll();
27270
+ if (!this.armTimer) {
27271
+ this.armTimer = setInterval(() => void this.armAll(), this.armIntervalMs);
27272
+ this.armTimer.unref?.();
27273
+ }
27274
+ }
27275
+ }
27276
+ /** Tear down the connection and stop the re-arm timer. */
27277
+ async close() {
27278
+ if (this.armTimer) {
27279
+ clearInterval(this.armTimer);
27280
+ this.armTimer = void 0;
27281
+ }
27282
+ this.watched.clear();
27283
+ await this.transport.disconnect();
27284
+ }
27285
+ /**
27286
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
27287
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
27288
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
27289
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
27290
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
27291
+ */
27292
+ async setDisplayTimeout(device, index) {
27293
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27294
+ const body = this.commandEnvelope(device, buildDisplayTimeoutFrame(index), {});
27295
+ await this.transport.publish(topic, body, { qos: 1 });
27296
+ this.logger?.debug?.(`[solix] display timeout set index=${index} on ${device.device_sn}`);
27297
+ }
27298
+ /**
27299
+ * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
27300
+ * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
27301
+ * heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
27302
+ * (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
27303
+ * not thrown, so one bad device doesn't stop the rest or kill the timer.
27304
+ */
27305
+ async armAll() {
27306
+ for (const device of this.watched.values()) {
27307
+ try {
27308
+ await this.arm(device);
27309
+ } catch (e) {
27310
+ this.emit("error", e);
27311
+ }
27312
+ }
27313
+ if (this.userId) {
27314
+ try {
27315
+ await this.transport.publish(solixUserTopics(this.appName, this.userId).powerSite, this.heartbeatEnvelope(), {
27316
+ qos: 1
27317
+ });
27318
+ } catch (e) {
27319
+ this.emit("error", e);
27320
+ }
27321
+ }
27322
+ }
27323
+ /** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
27324
+ async arm(device) {
27325
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27326
+ for (const variant of ["info", "realtime"]) {
27327
+ const body = this.commandEnvelope(device, buildFf09Request(variant), variant === "info" ? { encoding_type: 2 } : {});
27328
+ await this.transport.publish(topic, body, { qos: 1 });
27329
+ }
27330
+ this.logger?.debug?.(`[solix] armed ${device.device_sn} (param_info reporting requested)`);
27331
+ }
27332
+ /** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
27333
+ makeHead(cmd, extra) {
27334
+ return {
27335
+ version: "1.0.0.1",
27336
+ client_id: this.appClientId,
27337
+ timestamp: Math.floor(Date.now() / 1e3),
27338
+ cmd_status: 2,
27339
+ sign_code: 1,
27340
+ cmd,
27341
+ ...extra
27342
+ };
27343
+ }
27344
+ /**
27345
+ * Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
27346
+ * `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
27347
+ * from a real value, so sending `""` would claim an account this client does not have.
27348
+ */
27349
+ commandEnvelope(device, frame, extra) {
27350
+ this.seq += 1;
27351
+ return JSON.stringify({
27352
+ head: this.makeHead(17, {
27353
+ sess_id: genId(),
27354
+ msg_seq: this.seq,
27355
+ seed: genId(),
27356
+ device_pn: device.product_code,
27357
+ device_sn: device.device_sn
27358
+ }),
27359
+ payload: JSON.stringify({
27360
+ device_sn: device.device_sn,
27361
+ ...this.userId ? { account_id: this.userId } : {},
27362
+ data: frame.toString("base64"),
27363
+ ...extra
27364
+ })
27365
+ });
27366
+ }
27367
+ /**
27368
+ * The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
27369
+ * `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
27370
+ */
27371
+ heartbeatEnvelope() {
27372
+ return JSON.stringify({
27373
+ head: this.makeHead(10, { sess_id: "1", msg_seq: 1, seed: "1" }),
27374
+ payload: JSON.stringify({ user_id: this.userId ?? "", ...this.siteId ? { site_id: this.siteId } : {} })
27375
+ });
27376
+ }
27377
+ /**
27378
+ * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
27379
+ * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
27380
+ * own `a2` field wins for the serial when it carries one.
27381
+ *
27382
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
27383
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
27384
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
27385
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
27386
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
27387
+ */
27388
+ onMessage(msg) {
27389
+ const topic = msg.topic ?? "";
27390
+ const buf = extractFf09Payload(msg.raw);
27391
+ if (!buf)
27392
+ return;
27393
+ if (topic.endsWith("/req")) {
27394
+ this.handleCommand(topic, buf);
27395
+ return;
27396
+ }
27397
+ const frame = decodeSolixParamFrame(buf);
27398
+ if (!frame)
27399
+ return;
27400
+ const parts = topic.split("/");
27401
+ const productCode = parts[2] ?? "";
27402
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27403
+ if (!this.watched.has(deviceSn)) {
27404
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27405
+ if (ofProduct.length === 1)
27406
+ deviceSn = ofProduct[0].device_sn;
27407
+ }
27408
+ const values = topic.endsWith("/state_info") ? solixStateReadings(frame) : (
27409
+ // Pass the product code so meter tag→name binding is applied only to a meter frame; a Solarbank's
27410
+ // tags stay raw channel_<hex> (the model names them per capability) rather than being mislabelled.
27411
+ solixReadings(frame, productCode)
27412
+ );
27413
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27414
+ }
27415
+ /**
27416
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
27417
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
27418
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
27419
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
27420
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
27421
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
27422
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
27423
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
27424
+ */
27425
+ handleCommand(topic, buf) {
27426
+ if (buf.length < 10 || buf[8] !== 104)
27427
+ return;
27428
+ const frame = decodeSolixParamFrame(buf);
27429
+ if (!frame)
27430
+ return;
27431
+ const values = {};
27432
+ const a4 = frame.fields.get(164);
27433
+ if (a4 && a4.length >= 2)
27434
+ values.ambientLightOn = a4[1] === 0 ? 1 : 0;
27435
+ const a5 = frame.fields.get(165);
27436
+ if (a5 && a5.length >= 2)
27437
+ values.displayTimeoutIndex = a5[1];
27438
+ if (Object.keys(values).length === 0)
27439
+ return;
27440
+ const parts = topic.split("/");
27441
+ const productCode = parts[2] ?? "";
27442
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27443
+ if (!this.watched.has(deviceSn)) {
27444
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27445
+ if (ofProduct.length === 1)
27446
+ deviceSn = ofProduct[0].device_sn;
27447
+ }
27448
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27449
+ }
27450
+ };
27451
+ function extractFf09Payload(raw) {
27452
+ if (Buffer.isBuffer(raw))
27453
+ return raw;
27454
+ if (!raw || typeof raw !== "object")
27455
+ return null;
27456
+ const env = raw;
27457
+ let payload = env.payload;
27458
+ if (typeof payload === "string") {
27459
+ try {
27460
+ payload = JSON.parse(payload);
27461
+ } catch {
27462
+ return null;
27463
+ }
27464
+ }
27465
+ const p = payload;
27466
+ const data = p?.data ?? p?.trans ?? env.data;
27467
+ if (typeof data !== "string")
27468
+ return null;
27469
+ const buf = Buffer.from(data, "base64");
27470
+ return buf.length ? buf : null;
27471
+ }
27472
+ function buildDisplayTimeoutFrame(index) {
27473
+ const body = Buffer.from([3, 0, 15, 0, 104, 161, 1, 34, 165, 2, 1, index & 255]);
27474
+ const frame = Buffer.alloc(body.length + 5);
27475
+ frame[0] = 255;
27476
+ frame[1] = 9;
27477
+ frame.writeUInt16LE(frame.length, 2);
27478
+ body.copy(frame, 4);
27479
+ let xor = 0;
27480
+ for (let i = 0; i < frame.length - 1; i++)
27481
+ xor ^= frame[i];
27482
+ frame[frame.length - 1] = xor;
27483
+ return frame;
27484
+ }
27485
+ function buildFf09Request(variant, atUnixSec) {
27486
+ const ts = Buffer.alloc(4);
27487
+ ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
27488
+ const body = variant === "info" ? Buffer.concat([Buffer.from([3, 0, 15, 0, 64, 161, 1, 34, 254, 4]), ts]) : Buffer.concat([
27489
+ Buffer.from([
27490
+ 3,
27491
+ 0,
27492
+ 15,
27493
+ 0,
27494
+ 87,
27495
+ 161,
27496
+ 1,
27497
+ 34,
27498
+ 162,
27499
+ 2,
27500
+ 1,
27501
+ 1,
27502
+ 163,
27503
+ 3,
27504
+ 2,
27505
+ 44,
27506
+ 1,
27507
+ 254,
27508
+ 5,
27509
+ 3
27510
+ ]),
27511
+ ts
27512
+ ]);
27513
+ const frame = Buffer.alloc(body.length + 5);
27514
+ frame[0] = 255;
27515
+ frame[1] = 9;
27516
+ frame.writeUInt16LE(frame.length, 2);
27517
+ body.copy(frame, 4);
27518
+ let xor = 0;
27519
+ for (let i = 0; i < frame.length - 1; i++)
27520
+ xor ^= frame[i];
27521
+ frame[frame.length - 1] = xor;
27522
+ return frame;
27523
+ }
27524
+
25286
27525
  // dist/transport/tuya/index.js
25287
27526
  var tuya_exports = {};
25288
27527
  __export(tuya_exports, {
@@ -25334,6 +27573,7 @@ export {
25334
27573
  CLEAN_EXTENTS,
25335
27574
  CLEAN_FINISH_REASONS,
25336
27575
  CLEAN_PARAMS,
27576
+ CONNECT_TIMEOUT_MS,
25337
27577
  CONTACT_MEMBERS,
25338
27578
  CO_MEMBERS,
25339
27579
  CameraDisabledError,
@@ -25344,6 +27584,7 @@ export {
25344
27584
  CusPushEvent,
25345
27585
  CusPushMode,
25346
27586
  DEFAULT_KEEPALIVE_MS,
27587
+ DISPLAY_MEMBERS,
25347
27588
  DOCK_ACTIVITIES,
25348
27589
  DOCK_KINDS,
25349
27590
  DOORBELL_MEMBERS,
@@ -25397,6 +27638,7 @@ export {
25397
27638
  P256,
25398
27639
  P2PSession,
25399
27640
  P2P_ENVELOPE,
27641
+ P2P_STATION_WAITS,
25400
27642
  PRINTER_CATEGORY_RE,
25401
27643
  PTZ_MEMBERS,
25402
27644
  PowerSource,
@@ -25416,6 +27658,8 @@ export {
25416
27658
  SIREN_MEMBERS,
25417
27659
  SMART_LIGHT_MEMBERS,
25418
27660
  SMOKE_MEMBERS,
27661
+ SOLIX_ENERGY_METER_MEMBERS,
27662
+ SOLIX_LOCAL_KEY_HEX,
25419
27663
  STATE_EVENT_FIELDS,
25420
27664
  STATION_CHANNEL3 as STATION_CHANNEL,
25421
27665
  STATION_CHUNK_BYTES,
@@ -25428,8 +27672,13 @@ export {
25428
27672
  SirenAlarmDuration,
25429
27673
  SirenVolume,
25430
27674
  SmartDropPushEvent,
27675
+ SolixClient,
27676
+ SolixDevice,
27677
+ SolixMqtt,
27678
+ SolixSite,
25431
27679
  StateConvergenceError,
25432
- StationBusyError,
27680
+ StationKeyUnavailableError,
27681
+ StationUnreachableError,
25433
27682
  StoredSnapshotUnavailableError,
25434
27683
  StreamingQuality,
25435
27684
  SuctionLevel,
@@ -25452,7 +27701,6 @@ export {
25452
27701
  aesKey,
25453
27702
  asBool,
25454
27703
  assertNever,
25455
- autoContrast,
25456
27704
  bizChannelName,
25457
27705
  buildActions,
25458
27706
  buildAppShapedClientId,
@@ -25460,9 +27708,11 @@ export {
25460
27708
  buildDeviceNameBody,
25461
27709
  buildDirectBinaryBody,
25462
27710
  buildEventIndex,
27711
+ buildModelIndex,
25463
27712
  buildRealtimeInit,
25464
27713
  captureSnapshotFromShared,
25465
27714
  cellAtPoint,
27715
+ claimedParams,
25466
27716
  clamp,
25467
27717
  classify,
25468
27718
  classifyDevice,
@@ -25497,6 +27747,8 @@ export {
25497
27747
  detectCapabilities,
25498
27748
  detectionName,
25499
27749
  discoverReachableInstance,
27750
+ discoverSolixDevices,
27751
+ discoverSolixSites,
25500
27752
  encodeAiDetectType,
25501
27753
  encodeVarint,
25502
27754
  encryptBody,
@@ -25525,6 +27777,9 @@ export {
25525
27777
  isNotAuthorized,
25526
27778
  isPrivateIpv4,
25527
27779
  isSessionValid,
27780
+ isSolixPowerStation,
27781
+ isSolixSmartMeter,
27782
+ isSolixSolarbank,
25528
27783
  isV1Image,
25529
27784
  isV2Image,
25530
27785
  jpegGeometry,
@@ -25534,6 +27789,7 @@ export {
25534
27789
  lz4BlockDecompress,
25535
27790
  mapCellValue,
25536
27791
  mapCellValueAt,
27792
+ md5Hex,
25537
27793
  mergeCandidateIps,
25538
27794
  mergeProperties,
25539
27795
  mqttAppName,
@@ -25578,9 +27834,14 @@ export {
25578
27834
  secureTopic,
25579
27835
  signKey,
25580
27836
  signRequest,
27837
+ solarbankSceneReadings,
27838
+ solixDeviceTopics,
27839
+ solixProductFamily,
27840
+ solixUserTopics,
25581
27841
  structuralEqual,
25582
27842
  subscribeTopics,
25583
27843
  suctionLevelName,
27844
+ tokenNotExpired,
25584
27845
  tuya_exports as tuya,
25585
27846
  u16be,
25586
27847
  u16le,