@mega-yfue/eufy-sdk 0.2.0-beta.9 → 0.2.0

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 (53) hide show
  1. package/dist/client/device-registry.d.ts +14 -28
  2. package/dist/client/eufy-mega.d.ts +11 -6
  3. package/dist/client/types.d.ts +6 -2
  4. package/dist/core/contracts.d.ts +23 -27
  5. package/dist/core/crypto.d.ts +10 -0
  6. package/dist/core/index.d.ts +1 -0
  7. package/dist/core/solix-types.d.ts +121 -0
  8. package/dist/core/store.d.ts +38 -10
  9. package/dist/index.js +2690 -485
  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 +4 -0
  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/doorbell.d.ts +24 -14
  16. package/dist/model/capabilities/index.d.ts +14 -3
  17. package/dist/model/capabilities/lock.d.ts +15 -12
  18. package/dist/model/capabilities/ptz.d.ts +6 -2
  19. package/dist/model/capabilities/solix.d.ts +173 -0
  20. package/dist/model/capabilities/types.d.ts +60 -10
  21. package/dist/model/capabilities/vacuum-clean.d.ts +59 -0
  22. package/dist/model/classify.d.ts +3 -1
  23. package/dist/model/device-family.d.ts +2 -1
  24. package/dist/model/device-types.d.ts +1 -0
  25. package/dist/model/device.d.ts +15 -0
  26. package/dist/model/index.d.ts +5 -0
  27. package/dist/model/solix-catalog.d.ts +25 -0
  28. package/dist/model/solix-device.d.ts +137 -0
  29. package/dist/model/solix-family.d.ts +31 -0
  30. package/dist/model/solix-site.d.ts +70 -0
  31. package/dist/transport/ff09.d.ts +7 -0
  32. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  33. package/dist/transport/http/index.d.ts +1 -0
  34. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  35. package/dist/transport/http/media-download.d.ts +3 -0
  36. package/dist/transport/http/mega-client.d.ts +89 -9
  37. package/dist/transport/http/solix-client.d.ts +270 -0
  38. package/dist/transport/http/solix-constants.d.ts +56 -0
  39. package/dist/transport/media-failure.d.ts +48 -0
  40. package/dist/transport/mqtt/index.d.ts +3 -0
  41. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  42. package/dist/transport/mqtt/solix-mqtt.d.ts +360 -0
  43. package/dist/transport/mqtt/topics.d.ts +30 -0
  44. package/dist/transport/p2p/command-router.d.ts +131 -19
  45. package/dist/transport/p2p/live-stream.d.ts +5 -4
  46. package/dist/transport/p2p/live-trace.d.ts +32 -5
  47. package/dist/transport/p2p/media.d.ts +2 -2
  48. package/dist/transport/p2p/p2p-session.d.ts +9 -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/p2p/station-channels.d.ts +54 -0
  52. package/dist/transport/stored-image-cache.d.ts +7 -1
  53. package/package.json +5 -5
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
@@ -2296,6 +2612,7 @@ var DeviceType = {
2296
2612
  PROFESSIONAL_247: 24,
2297
2613
  MINIBASE_CHIME: 25,
2298
2614
  CAMERA3_PRO: 26,
2615
+ STATION_9000: 27,
2299
2616
  HOMEBASE_MINI: 28,
2300
2617
  INDOOR_CAMERA: 30,
2301
2618
  INDOOR_PT_CAMERA: 31,
@@ -2392,6 +2709,7 @@ function classifyDevice(raw) {
2392
2709
  var STATION_TYPES = /* @__PURE__ */ new Set([
2393
2710
  DeviceType.STATION,
2394
2711
  DeviceType.HB3,
2712
+ DeviceType.STATION_9000,
2395
2713
  DeviceType.MINIBASE_CHIME,
2396
2714
  DeviceType.HOMEBASE_MINI,
2397
2715
  DeviceType.NVR_S4_MAX,
@@ -2471,6 +2789,8 @@ function codecFromModel(model) {
2471
2789
  return "station";
2472
2790
  if (/^T8N0/.test(m) || /^T8E0/.test(m))
2473
2791
  return "station";
2792
+ if (/^T9000/.test(m))
2793
+ return "station";
2474
2794
  if (/^T74/.test(m))
2475
2795
  return "lock";
2476
2796
  if (/^T85/.test(m))
@@ -2578,14 +2898,14 @@ var StationUnreachableError = class extends Error {
2578
2898
  this.name = "StationUnreachableError";
2579
2899
  }
2580
2900
  };
2581
- var StationBusyError = class extends Error {
2582
- servingChannel;
2583
- /** Always true: the station is busy now, and stops being busy when the other stream is released. */
2584
- retryable = true;
2585
- constructor(servingChannel, options) {
2586
- 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);
2587
- this.servingChannel = servingChannel;
2588
- this.name = "StationBusyError";
2901
+ var DeviceChannelUnresolvedError = class extends Error {
2902
+ sn;
2903
+ stationSn;
2904
+ constructor(sn, stationSn, options) {
2905
+ super(`${sn} has no usable channel on station ${stationSn}, so nothing was sent to it`, options);
2906
+ this.sn = sn;
2907
+ this.stationSn = stationSn;
2908
+ this.name = "DeviceChannelUnresolvedError";
2589
2909
  }
2590
2910
  };
2591
2911
  var LiveStreamStartError = class extends Error {
@@ -2924,7 +3244,14 @@ function describeBound(modules, bound, ctx) {
2924
3244
  else
2925
3245
  undescribedActions.push(name);
2926
3246
  }
2927
- out.push({ capability: m.capability, accessor, reads: reads2, actions, undescribedActions, events: emitsOf(m) });
3247
+ out.push({
3248
+ capability: m.capability,
3249
+ accessor,
3250
+ reads: reads2,
3251
+ actions,
3252
+ undescribedActions,
3253
+ events: emitsOf(m, reads2, ctx)
3254
+ });
2928
3255
  }
2929
3256
  return out;
2930
3257
  }
@@ -2943,8 +3270,22 @@ function readDescriptor(name, m, descriptors, ctx) {
2943
3270
  description: m.description
2944
3271
  };
2945
3272
  }
2946
- function emitsOf(m) {
2947
- return [.../* @__PURE__ */ new Set([...(m.events ?? []).map((e) => e.emit), ...m.emits ?? []])];
3273
+ function emitsOf(m, reads2, ctx) {
3274
+ const installed = new Set(reads2.map((r) => r.accessor));
3275
+ const claimed = (m.events ?? []).filter((e) => holds(e.claim, installed, ctx)).map((e) => e.emit);
3276
+ return [.../* @__PURE__ */ new Set([...claimed, ...m.emits ?? []])];
3277
+ }
3278
+ function holds(claim, installed, ctx) {
3279
+ if (!claim)
3280
+ return true;
3281
+ if (claim.codecs && (ctx?.codec === void 0 || !claim.codecs.includes(ctx.codec)))
3282
+ return false;
3283
+ if (claim.reads?.some((name) => !installed.has(name)))
3284
+ return false;
3285
+ if (claim.homeBaseAttached !== void 0 && ctx?.homeBaseAttached !== void 0) {
3286
+ return ctx.homeBaseAttached === claim.homeBaseAttached;
3287
+ }
3288
+ return true;
2948
3289
  }
2949
3290
 
2950
3291
  // dist/model/capabilities/video.js
@@ -3449,6 +3790,9 @@ var AiDetectType = {
3449
3790
  vehicle: 4,
3450
3791
  pet: 8
3451
3792
  };
3793
+ var CAMERA_AI_CLAIM = { codecs: ["camera"] };
3794
+ var VEHICLE_CLAIM = { ...CAMERA_AI_CLAIM, reads: ["aiDetectType"] };
3795
+ var DOG_CLAIM = { ...CAMERA_AI_CLAIM, homeBaseAttached: true };
3452
3796
  var SENSITIVITY_SCALES = [
3453
3797
  /** Standalone PIR sensor: five steps counting DOWN. */
3454
3798
  {
@@ -3555,7 +3899,7 @@ function sensitivityCommand(step, scale, ctx) {
3555
3899
  return setScalar(scale.writeId, value, ctx, "direct-binary");
3556
3900
  if (scale.form === "control")
3557
3901
  return setJson(scale.writeId, { index: value }, ctx);
3558
- return setPayload(scale.writeId, { sensitivity: value, channel: ctx.channel }, ctx, 0);
3902
+ return setPayload(scale.writeId, { sensitivity: value, channel: ctx.channel }, ctx, 0, void 0, "auto");
3559
3903
  }
3560
3904
  var EXIT_TEST_MODE_VALUE = 808464384;
3561
3905
  function requireFamily(action, ctx, verifiedOn) {
@@ -3633,7 +3977,7 @@ var MOTION_MEMBERS = {
3633
3977
  const n = Number(v);
3634
3978
  if (!Number.isInteger(n) || n < 0)
3635
3979
  return void 0;
3636
- return setPayload(MOTION_CMD.AI_DETECT_TYPE, { ai_detect_type: n, channel: ctx.channel }, ctx, 0, 0);
3980
+ return setPayload(MOTION_CMD.AI_DETECT_TYPE, { ai_detect_type: n, channel: ctx.channel }, ctx, 0, 0, "auto");
3637
3981
  }
3638
3982
  },
3639
3983
  /**
@@ -3746,7 +4090,7 @@ var MOTION_MEMBERS = {
3746
4090
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
3747
4091
  write: (v, ctx) => {
3748
4092
  requireFamily("humanOnlyAtNight", ctx, "camera");
3749
- return setPayload(MOTION_CMD.HUMAN_ONLY_AT_NIGHT, { only_ai: asBool(v) ? 1 : 0 }, ctx, 0);
4093
+ return setPayload(MOTION_CMD.HUMAN_ONLY_AT_NIGHT, { only_ai: asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto");
3750
4094
  }
3751
4095
  },
3752
4096
  /**
@@ -3763,7 +4107,7 @@ var MOTION_MEMBERS = {
3763
4107
  requires: [MOTION_CMD.LOITERING_DETECTION],
3764
4108
  write: (v, ctx) => {
3765
4109
  requireFamily("loiteringDetection", ctx, "camera");
3766
- return setPayload(MOTION_CMD.LOITERING_DETECTION, { radar_wd_switch: asBool(v) ? 1 : 0 }, ctx, 0);
4110
+ return setPayload(MOTION_CMD.LOITERING_DETECTION, { radar_wd_switch: asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto");
3767
4111
  }
3768
4112
  }
3769
4113
  };
@@ -3840,21 +4184,24 @@ var MOTION = {
3840
4184
  events: [
3841
4185
  { source: "push", match: DoorbellPushEvent.MOTION_DETECTION, emit: "motion" },
3842
4186
  { source: "push", match: CusPushEvent.MOTION_SENSOR_PIR, emit: "motion" },
3843
- { source: "push", match: IndoorPushEvent.CRYING_DETECTION, emit: "cryingDetected" },
3844
- { source: "push", match: IndoorPushEvent.SOUND_DETECTION, emit: "soundDetected" },
3845
- { source: "push", match: DoorbellPushEvent.VEHICLE_DETECTION, emit: "vehicleDetected" },
3846
- { source: "push", match: HB3PairedDevicePushEvent.DOG_DETECTION, emit: "dogDetected" },
4187
+ { source: "push", match: IndoorPushEvent.CRYING_DETECTION, emit: "cryingDetected", claim: CAMERA_AI_CLAIM },
4188
+ { source: "push", match: IndoorPushEvent.SOUND_DETECTION, emit: "soundDetected", claim: CAMERA_AI_CLAIM },
4189
+ { source: "push", match: IndoorPushEvent.PET_DETECTION, emit: "petDetection", claim: CAMERA_AI_CLAIM },
4190
+ { source: "push", match: DoorbellPushEvent.VEHICLE_DETECTION, emit: "vehicleDetected", claim: VEHICLE_CLAIM },
4191
+ { source: "push", match: HB3PairedDevicePushEvent.DOG_DETECTION, emit: "dogDetected", claim: DOG_CLAIM },
3847
4192
  {
3848
4193
  source: "push",
3849
4194
  match: HB3PairedDevicePushEvent.DOG_LICK_DETECTION,
3850
4195
  emit: "dogDetected",
3851
- payload: { kind: "lick" }
4196
+ payload: { kind: "lick" },
4197
+ claim: DOG_CLAIM
3852
4198
  },
3853
4199
  {
3854
4200
  source: "push",
3855
4201
  match: HB3PairedDevicePushEvent.DOG_POOP_DETECTION,
3856
4202
  emit: "dogDetected",
3857
- payload: { kind: "poop" }
4203
+ payload: { kind: "poop" },
4204
+ claim: DOG_CLAIM
3858
4205
  }
3859
4206
  ]
3860
4207
  };
@@ -3945,12 +4292,32 @@ function decodePowerSource(raw) {
3945
4292
  function recordSetting(param, value, ctx) {
3946
4293
  return setStationScalar(param, value, ctx.channel);
3947
4294
  }
3948
- var MAINS_CAMERA_MODELS = ["T8425", "T8419"];
4295
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410"];
3949
4296
  var notMainsCamera = (ctx) => {
3950
4297
  const model = (ctx.model ?? "").toUpperCase();
3951
4298
  return !MAINS_CAMERA_MODELS.some((prefix) => model.startsWith(prefix));
3952
4299
  };
3953
- var BATTERY_MEMBERS = {
4300
+ var CELL_PARAMS = [
4301
+ BATTERY_PARAM.BATTERY,
4302
+ BATTERY_PARAM.BATTERY_STATUS,
4303
+ BATTERY_PARAM.BATTERY_TEMP,
4304
+ BATTERY_PARAM.BATTERY_HEALTH,
4305
+ BATTERY_PARAM.SOLAR_INTENSITY,
4306
+ BATTERY_PARAM.SOLAR_CONNECT_24H,
4307
+ BATTERY_PARAM.BATTERY_POWER_DATAS
4308
+ ];
4309
+ function cellGated(members) {
4310
+ const gated = Object.entries(members).map(([name, m]) => {
4311
+ const param = m.param;
4312
+ if (typeof param !== "number" || !CELL_PARAMS.includes(param))
4313
+ return [name, m];
4314
+ const own = m.available;
4315
+ const available = own ? (ctx) => notMainsCamera(ctx) && own(ctx) : notMainsCamera;
4316
+ return [name, { ...m, available }];
4317
+ });
4318
+ return Object.fromEntries(gated);
4319
+ }
4320
+ var BATTERY_MEMBERS = cellGated({
3954
4321
  /**
3955
4322
  * The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
3956
4323
  * device is battery-powered, which is also the fact `MediaProvider` reads to decide a stream needs a
@@ -3963,7 +4330,6 @@ var BATTERY_MEMBERS = {
3963
4330
  unit: "%",
3964
4331
  kind: "percent",
3965
4332
  provenance: "verified",
3966
- available: notMainsCamera,
3967
4333
  description: "Battery level 0-100 (verified: param 1101)."
3968
4334
  },
3969
4335
  /**
@@ -3976,7 +4342,6 @@ var BATTERY_MEMBERS = {
3976
4342
  type: "bool",
3977
4343
  kind: "boolean",
3978
4344
  provenance: "apk",
3979
- available: notMainsCamera,
3980
4345
  coerce: (v) => {
3981
4346
  const n = Number(v);
3982
4347
  return n !== 0 && n !== 2;
@@ -4002,7 +4367,7 @@ var BATTERY_MEMBERS = {
4002
4367
  args: [{ name: "source", kind: "enum", description: "The raw value, or the name `battery`/`external`." }],
4003
4368
  ...accepts(),
4004
4369
  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.",
4005
- write: (v, ctx) => setPayload(BATTERY_PARAM.POWER_CHARGE, { charge_mode: v === "external" ? 1 : v === "battery" ? 0 : asBool(v) ? 1 : 0 }, ctx, 0)
4370
+ 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")
4006
4371
  },
4007
4372
  /**
4008
4373
  * A DEVICE-SPECIFIC mode index: the app maps value→mode through a per-model config (Hermes
@@ -4147,6 +4512,11 @@ var BATTERY_MEMBERS = {
4147
4512
  * Reported, so it stays in the schema and answers through `getProperty` — but given no typed getter:
4148
4513
  * the payload's fields have never been decoded, and a getter would hand back an opaque blob typed as
4149
4514
  * though it meant something.
4515
+ *
4516
+ * `unexposed` is NOT a substitute for the cell gate. It suppresses the fluent GETTER; `propertiesOf`
4517
+ * filters `writeOnly` and `available` and deliberately not `unexposed`, because a schema entry
4518
+ * reachable through `getProperty` is the whole point of the mark. So a cell param needs
4519
+ * {@link CELL_PARAMS} either way, or a mains camera publishes "battery power history" and answers it.
4150
4520
  */
4151
4521
  batteryPowerStats: {
4152
4522
  param: BATTERY_PARAM.BATTERY_POWER_DATAS,
@@ -4172,7 +4542,7 @@ var BATTERY_MEMBERS = {
4172
4542
  unexposed: true,
4173
4543
  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."
4174
4544
  }
4175
- };
4545
+ });
4176
4546
  var BATTERY = {
4177
4547
  capability: "battery",
4178
4548
  description: "Battery level, charging, health, temperature, and solar input.",
@@ -4315,6 +4685,10 @@ var CONTACT_MEMBERS = {
4315
4685
  * `1350` SET_PAYLOAD, `mChannel` = the device channel, `mValue3` 0, payload carrying the channel and a
4316
4686
  * transaction stamp — the byte-shape of the app's own captured frame. Out of range is refused rather
4317
4687
  * than clamped: the app's slider has no values outside it, so one is a caller error, not a nudge.
4688
+ *
4689
+ * Level-2 only, though the `mValue3` 0 would allow `"auto"`: an entry sensor's session IS its
4690
+ * HomeBase's, which always holds a key — the second of `setPayload`'s two conditions, not an oversight
4691
+ * of the first.
4318
4692
  */
4319
4693
  alarmVolume: {
4320
4694
  param: CONTACT_CMD.ALARM_VOLUME,
@@ -4441,6 +4815,7 @@ var WALL_LIGHT_TYPES = /* @__PURE__ */ new Set([
4441
4815
  var HOMEBASE_TYPES = /* @__PURE__ */ new Set([
4442
4816
  DeviceType.STATION,
4443
4817
  DeviceType.HB3,
4818
+ DeviceType.STATION_9000,
4444
4819
  DeviceType.MINIBASE_CHIME,
4445
4820
  DeviceType.HOMEBASE_MINI
4446
4821
  ]);
@@ -4542,7 +4917,7 @@ var AUDIO_MEMBERS = {
4542
4917
  provenance: "verified",
4543
4918
  available: isCameraCodec,
4544
4919
  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.",
4545
- write: (v, ctx) => setPayload(AUDIO_CMD.AUDIO_RECORDING, { channel: ctx.channel, record_mute: asBool(v) ? 0 : 1 }, ctx, 0)
4920
+ write: (v, ctx) => setPayload(AUDIO_CMD.AUDIO_RECORDING, { channel: ctx.channel, record_mute: asBool(v) ? 0 : 1 }, ctx, 0, void 0, "auto")
4546
4921
  },
4547
4922
  /**
4548
4923
  * Doorbell ring/chime loudness — WRITE-ONLY here on purpose. The READ property `ringtoneVolume` (1708)
@@ -4990,7 +5365,7 @@ var CAMERA_MEMBERS = {
4990
5365
  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.",
4991
5366
  write: (v, ctx) => {
4992
5367
  const nv = coerceEnumValue(NightVision, v);
4993
- return nv == null ? void 0 : setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0);
5368
+ return nv == null ? void 0 : setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0, "auto");
4994
5369
  }
4995
5370
  },
4996
5371
  /**
@@ -5038,7 +5413,7 @@ var CAMERA_MEMBERS = {
5038
5413
  ...accepts(),
5039
5414
  write: (v, ctx) => {
5040
5415
  const q = resolveRecordingQualityTier(v);
5041
- return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0);
5416
+ 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");
5042
5417
  }
5043
5418
  },
5044
5419
  /**
@@ -5418,7 +5793,7 @@ function autoSpotlightCommand(on, ctx, opts = {}) {
5418
5793
  schedule: [],
5419
5794
  sunset2rise: 0,
5420
5795
  time: opts.time ?? 30
5421
- }, ctx, 0);
5796
+ }, ctx, 0, void 0, "auto");
5422
5797
  }
5423
5798
  function lightSwitchWire(ctx) {
5424
5799
  const t = ctx.deviceType;
@@ -5957,7 +6332,7 @@ function zoomCommand(dstZoom, ctx, region) {
5957
6332
  offset,
5958
6333
  orgZoom: r.orgZoom ?? 0,
5959
6334
  dstZoom
5960
- }, ctx, 0);
6335
+ }, ctx, 0, void 0, "auto");
5961
6336
  }
5962
6337
  function gotoPresetCommand(id, ctx) {
5963
6338
  return setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx);
@@ -6180,8 +6555,9 @@ var DOORBELL_CMD = {
6180
6555
  */
6181
6556
  QUICK_RESPONSE: 1706,
6182
6557
  /**
6183
- * Mechanical (wired) chime enable/disable — whether the doorbell drives an existing wired chime box
6184
- * (as opposed to / in addition to the wireless indoor chime, param 1702 `chimeSwitch` above). Same
6558
+ * Mechanical (wired) chime enable/disable — whether the doorbell drives an existing wired chime box,
6559
+ * the app's "existing doorbell chime" (as opposed to / in addition to the HomeBase acting as the chime,
6560
+ * param 1702 `chimeSwitch` above; toggling each switch alone wrote one parameter and named the pair). Same
6185
6561
  * 136-byte direct-binary shape as `QUICK_RESPONSE`/camera on-off: `[u32 channel][u32 value][account_id
6186
6562
  * ASCII, zero-padded to 128 bytes]`, outer P2P cmd = 1703 itself, signCode 8, on the device's own
6187
6563
  * channel. Reversed from a live capture (outer-cmd 1703, signCode 8, captured on the doorbell's
@@ -6190,6 +6566,19 @@ var DOORBELL_CMD = {
6190
6566
  * App `APP_CMD_BAT_DOORBELL_MECHANICAL_CHIME_SWITCH`.
6191
6567
  */
6192
6568
  MECHANICAL_CHIME_SWITCH: 1703,
6569
+ /**
6570
+ * Whether the HOMEBASE acts as the doorbell's chime — the app's "HomeBase as chime" switch, as opposed
6571
+ * to the wired chime box {@link DOORBELL_CMD.MECHANICAL_CHIME_SWITCH} drives. The app constant reads
6572
+ * `CHIME_SWITCH`, which is why this was long described as a separate plug-in chime; toggling that one
6573
+ * switch alone on a T8210 behind a HomeBase wrote 1702 and nothing else, and the account has no
6574
+ * plug-in chime at all. Same 136-byte direct-binary shape as
6575
+ * that sibling: `[u32 channel][u32 value][account_id ASCII, zero-padded to 128 bytes]`, outer P2P cmd
6576
+ * 1702 itself, signCode 8, on the device's own channel. Reversed from a live capture (outer-cmd 1702,
6577
+ * signCode 8, on the doorbell's `device_channel` — 2 for that unit, NOT a wire constant, always use
6578
+ * `ctx.channel`): turning OFF sent `value=0`, turning ON sent `value=1` — a plain boolean.
6579
+ * App `APP_CMD_BAT_DOORBELL_CHIME_SWITCH`.
6580
+ */
6581
+ CHIME_SWITCH: 1702,
6193
6582
  /**
6194
6583
  * Wide Dynamic Range (WDR) image switch — a video/image tone-mapping setting that widens the
6195
6584
  * exposure range in high-contrast scenes (bright sky behind a visitor, etc). SAME 136-byte
@@ -6286,19 +6675,18 @@ function parseQuickResponses(voiceList) {
6286
6675
  }
6287
6676
  var DOORBELL_MEMBERS = {
6288
6677
  /**
6289
- * The WIRELESS indoor chime — the separate plug-in unit, not the wired chime box
6290
- * `mechanicalChimeSwitch` drives. `unverified: true` with no `write` at all: the read is confirmed but
6291
- * the write frame has never been captured, so the setter is absent from the surface (a compile-time
6292
- * signal) and the intent path throws rather than reporting the doorbell as lacking the feature.
6293
- * Sharing the 1702-1719 range with its captured siblings is not evidence of a shared frame shape.
6678
+ * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
6679
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame,
6680
+ * not merely the param id observed live: direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape
6681
+ * as its 1703 sibling — captured, not inferred from the shared param range.
6294
6682
  */
6295
6683
  chimeSwitch: {
6296
- param: 1702,
6684
+ param: DOORBELL_CMD.CHIME_SWITCH,
6297
6685
  type: "bool",
6298
6686
  kind: "boolean",
6299
- provenance: "mega",
6300
- unverified: true,
6301
- description: "Wireless indoor chime enabled (1702 CMD_BAT_DOORBELL_CHIME_SWITCH; READ confirmed on T8214 \u2014 the WRITE wire is unconfirmed, so no setter is offered rather than guessing it)."
6687
+ provenance: "verified",
6688
+ description: `The HomeBase plays the doorbell's chime (1702; the app's "HomeBase as chime" switch \u2014 write wire-confirmed live on T8210).`,
6689
+ write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6302
6690
  },
6303
6691
  /**
6304
6692
  * `provenance` is "verified" not "mega": the actual write wire is confirmed (
@@ -6310,7 +6698,7 @@ var DOORBELL_MEMBERS = {
6310
6698
  type: "bool",
6311
6699
  kind: "boolean",
6312
6700
  provenance: "verified",
6313
- description: "Mechanical chime enabled (1703; confirmed on T8214).",
6701
+ description: `The existing wired chime box rings (1703; the app's "existing doorbell chime" switch \u2014 confirmed on T8214, and that pairing confirmed by toggling it alone on a T8210).`,
6314
6702
  write: (v, ctx) => setScalar(DOORBELL_CMD.MECHANICAL_CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6315
6703
  },
6316
6704
  /** Same reasoning and the same capture session as {@link DOORBELL_MEMBERS.mechanicalChimeSwitch}. */
@@ -6353,7 +6741,7 @@ var DOORBELL_MEMBERS = {
6353
6741
  min: 0,
6354
6742
  max: 100,
6355
6743
  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.",
6356
- write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0)
6744
+ write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0, void 0, "auto")
6357
6745
  },
6358
6746
  /**
6359
6747
  * The same `1350` SET_PAYLOAD shape and capture session as {@link DOORBELL_MEMBERS.dingdongVolume},
@@ -6371,7 +6759,7 @@ var DOORBELL_MEMBERS = {
6371
6759
  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.",
6372
6760
  write: (v, ctx) => {
6373
6761
  const tone = coerceEnumValue(DoorbellRingtone, v);
6374
- return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0);
6762
+ return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0, void 0, "auto");
6375
6763
  }
6376
6764
  },
6377
6765
  /**
@@ -6497,10 +6885,16 @@ var DOORBELL = {
6497
6885
  properties: propertiesOf(DOORBELL_MEMBERS),
6498
6886
  /** Doorbells self-report no single unambiguous param; the model name is the reliable signal. */
6499
6887
  detection: { modelHints: [/doorbell/i] },
6500
- /** Inbound FCM doorbell events (`DoorbellPushEvent`): ring press, pet, package delivered/taken. */
6888
+ /**
6889
+ * Inbound FCM doorbell events (`DoorbellPushEvent`): ring press and the package trio.
6890
+ *
6891
+ * Pet (3106) is NOT here. The id is declared identically in the doorbell, indoor and HB3-paired
6892
+ * vocabularies, so it belongs to the camera-wide `motion` module that every camera binds; claiming
6893
+ * it here as well would make it a contested id that a doorbell — which has both capabilities —
6894
+ * matches twice, emitting one push as two events.
6895
+ */
6501
6896
  events: [
6502
6897
  { source: "push", match: DoorbellPushEvent.PRESS_DOORBELL, emit: "doorbellPress" },
6503
- { source: "push", match: DoorbellPushEvent.PET_DETECTION, emit: "petDetection" },
6504
6898
  { source: "push", match: DoorbellPushEvent.PACKAGE_DELIVERED, emit: "packageDelivered" },
6505
6899
  { source: "push", match: DoorbellPushEvent.PACKAGE_TAKEN, emit: "packageTaken" },
6506
6900
  { source: "push", match: DoorbellPushEvent.PACKAGE_STRANDED, emit: "packageStranded" }
@@ -6663,32 +7057,32 @@ function settingToggle(settingId, name, enabled, ctx, sink) {
6663
7057
  var overP2p = (ctx) => ctx.hasP2p === true;
6664
7058
  var LOCK_MEMBERS = {
6665
7059
  /**
6666
- * The lock's own state, and the weakest thing in this table: 1200 is a `guessed` placeholder because
6667
- * the lock announces (un)locking as a pushed event rather than holding a param, so the evidence gate
6668
- * will normally leave this getter uninstalled and `lockState` is the real read. `writtenElsewhere`
6669
- * points at the `lock`/`unlock` methods — a single `write` cannot express two verbs that carry no
6670
- * value.
7060
+ * The lock's own state, verified on param 6000: "4" = locked, "3" = unlocked.
7061
+ * `writtenElsewhere` points at the `lock`/`unlock` methods — a single `write` cannot express two
7062
+ * verbs that carry no value.
6671
7063
  */
6672
7064
  locked: {
6673
- param: 1200,
7065
+ param: 6e3,
6674
7066
  type: "bool",
6675
7067
  kind: "boolean",
6676
- provenance: "guessed",
7068
+ provenance: "verified",
6677
7069
  writtenElsewhere: true,
6678
- description: "Lock state, true=locked. UNVERIFIED: no stable state param to key off; placeholder id pending verification."
7070
+ coerce: (raw) => String(raw).trim() === "4",
7071
+ description: "Lock state, true=locked (verified: param 6000, 4=locked, 3=unlocked)."
6679
7072
  },
6680
7073
  /**
6681
- * Cell charge as a percentage, on the same param 1101 every battery device reports. Named `battery`
7074
+ * Cell charge as a percentage, on param 1101 or smart-lock param 6001. Named `battery`
6682
7075
  * within this capability rather than deferring to the `battery` capability: a lock resolves as a lock,
6683
7076
  * so the accessor is `dev.lock().battery`.
6684
7077
  */
6685
7078
  battery: {
6686
7079
  param: 1101,
7080
+ readAliases: [{ paramType: 6001 }],
6687
7081
  type: "number",
6688
7082
  unit: "%",
6689
7083
  kind: "percent",
6690
7084
  provenance: "verified",
6691
- description: "Lock battery level 0-100 (verified: param 1101)."
7085
+ description: "Lock battery level 0-100 (verified: param 1101, alias 6001)."
6692
7086
  },
6693
7087
  /**
6694
7088
  * Link quality in dBm as the lock measures it, on the shared param 1141. Both actuation methods here
@@ -6846,16 +7240,26 @@ function settingToggleCommand(settingId, enabled, ctx) {
6846
7240
  value: asBool(enabled)
6847
7241
  };
6848
7242
  }
7243
+ function decodeLockTransition(signal) {
7244
+ if (signal.source !== "push" || signal.eventType === void 0)
7245
+ return {};
7246
+ const e = signal.eventType;
7247
+ if (e >= LockPushEvent.MANUAL_LOCK && e <= LockPushEvent.TEMPORARY_PW_LOCK)
7248
+ return { locked: true };
7249
+ const unlocked = e >= LockPushEvent.MANUAL_UNLOCK && e <= LockPushEvent.APP_UNLOCK || e === LockPushEvent.TEMPORARY_PW_UNLOCK;
7250
+ return unlocked ? { locked: false } : {};
7251
+ }
6849
7252
  var LOCK = {
6850
7253
  capability: "lock",
6851
7254
  description: "Smart-lock locked/unlocked state and battery.",
6852
7255
  members: LOCK_MEMBERS,
6853
7256
  properties: propertiesOf(LOCK_MEMBERS),
6854
- // Lock state is reported via CommandType (no stable param); the model name (lock/safe) is a
7257
+ // Lock state is reported via param 6000 (verified: 4=locked, 3=unlocked); the model name (lock/safe) is a
6855
7258
  // signal, and every lock-codec device has the lock capability as its baseline.
6856
- detection: { modelHints: [/lock/i, /safe/i], codecs: ["lock"] },
6857
- // Inbound FCM lock events (LockPushEvent 257..771: (un)lock actions + alarms) → one "lockState".
6858
- events: [{ source: "push", match: [257, 771], emit: "lockState" }]
7259
+ detection: { evidenceParams: [6e3], modelHints: [/lock/i, /safe/i], codecs: ["lock"] },
7260
+ // Inbound FCM lock events (LockPushEvent 257..771: (un)lock actions + alarms) → one "lockState", whose
7261
+ // payload carries a decoded `locked` boolean for the (un)lock actions (see decodeLockTransition).
7262
+ events: [{ source: "push", match: [257, 771], emit: "lockState", derive: decodeLockTransition }]
6859
7263
  };
6860
7264
 
6861
7265
  // dist/model/capabilities/keypad.js
@@ -6952,6 +7356,10 @@ var ARMING_CMD = {
6952
7356
  * first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
6953
7357
  * frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
6954
7358
  * the per-value evidence and the dates.
7359
+ *
7360
+ * The ENCRYPTION LEVEL is the session's to pick (`"auto"`), not this command's: the T8030 the envelope
7361
+ * was captured on holds a level-2 key and seals it level-2, while an own-session camera that never
7362
+ * negotiates one carries the same envelope level-1. See `armingCommand`.
6955
7363
  */
6956
7364
  SET_ARMING: 1224,
6957
7365
  /**
@@ -7004,7 +7412,7 @@ function armingCommand(mode, ctx) {
7004
7412
  if (!ctx.accountName) {
7005
7413
  throw new Error(`arming: missing account identity (user_name) [${describeDevice(ctx)}]`);
7006
7414
  }
7007
- return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0);
7415
+ return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0, void 0, "auto");
7008
7416
  }
7009
7417
  function alarmDelayCommand(mode, config, ctx) {
7010
7418
  const data = {
@@ -7989,6 +8397,25 @@ var CARPET_STRATEGIES = ["autoRaise", "avoid", "ignore"];
7989
8397
  var CARPET_STRATEGY = { 0: "autoRaise", 1: "avoid", 2: "ignore" };
7990
8398
  var CLEAN_EXTENTS = ["normal", "narrow", "quick"];
7991
8399
  var CLEAN_EXTENT = { 0: "normal", 1: "narrow", 2: "quick" };
8400
+ function cleanParamWireValue(names, name) {
8401
+ const found = Object.entries(names).find(([, candidate]) => candidate === name);
8402
+ if (found === void 0)
8403
+ throw new Error(`clean param: ${name} is not a value this setting takes`);
8404
+ return Number(found[0]);
8405
+ }
8406
+ function writeCleanSetting(p, wrapper, inner, value) {
8407
+ p.sub(wrapper, (w) => {
8408
+ if (value !== 0)
8409
+ w.int(inner, value);
8410
+ });
8411
+ }
8412
+ function encodeCleanParam(cleanType, cleanExtent, mopLevel) {
8413
+ return rawDp((w) => w.sub(CLEAN_PARAM_FIELD.CONFIGURED, (p) => {
8414
+ writeCleanSetting(p, CLEAN_PARAM_FIELD.CLEAN_TYPE, CLEAN_PARAM_FIELD.VALUE, cleanParamWireValue(CLEAN_TYPE, cleanType));
8415
+ writeCleanSetting(p, CLEAN_PARAM_FIELD.CLEAN_EXTENT, CLEAN_PARAM_FIELD.VALUE, cleanParamWireValue(CLEAN_EXTENT, cleanExtent));
8416
+ writeCleanSetting(p, CLEAN_PARAM_FIELD.MOP_MODE, CLEAN_PARAM_FIELD.MOP_LEVEL, cleanParamWireValue(MOP_LEVEL, mopLevel));
8417
+ }));
8418
+ }
7992
8419
  function decodeCleanParamValue(raw, codec, field, inner) {
7993
8420
  if (typeof raw !== "string" || !codec)
7994
8421
  return void 0;
@@ -9668,6 +10095,49 @@ var VACUUM_CLEAN_MEMBERS = {
9668
10095
  * WATCHED: run on a T2351, it started the named scene.
9669
10096
  */
9670
10097
  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),
10098
+ /**
10099
+ * State the cleaning settings a run uses — `CleanParamRequest.clean_param` over DP 154.
10100
+ *
10101
+ * The write counterpart of {@link VACUUM_CLEAN_MEMBERS.cleanType},
10102
+ * {@link VACUUM_CLEAN_MEMBERS.cleanExtent} and {@link VACUUM_CLEAN_MEMBERS.mopLevel}: one message
10103
+ * carries all three, so they are set together rather than through three setters that would each send
10104
+ * the same message with the other two silent.
10105
+ *
10106
+ * **The evidence, and its limit.** The frame is field 1 of `CleanParamRequest`, which carries the
10107
+ * very `CleanParam` this module decodes out of field 1 of the reports a live T2351 sends — the
10108
+ * field numbers, the single-field wrappers and the `mop_mode.level` scale are all read off that
10109
+ * capture, and `encodeCleanParam` writes what `decodeCleanParamValue` reads. What is NOT captured is
10110
+ * the write direction itself. It ships as a method rather than an unverified write because the
10111
+ * hazard that rule answers does not arise here: DP 154 is the robot's own settings report, so a frame
10112
+ * it does not accept leaves those three reads unchanged, where a wrong fire-and-forget command would
10113
+ * look exactly like success.
10114
+ *
10115
+ * Suction is not here. It has its own data point and its own capability — a `fan` field exists in
10116
+ * this message and is deliberately not written, for the same reason it is not read.
10117
+ */
10118
+ setCleanParam: {
10119
+ ...method(({ sink }) => (cleanType, cleanExtent, mopLevel) => sink.dispatch(aiotDp(VACUUM_DP.CLEAN_PARAM, encodeCleanParam(cleanType, cleanExtent, mopLevel))), "Set the cleaning type, extent and mop water level together (CleanParamRequest.clean_param over DP 154).", isAiotVacuum),
10120
+ args: [
10121
+ {
10122
+ name: "cleanType",
10123
+ kind: "enum",
10124
+ values: VACUUM_CLEAN_TYPES,
10125
+ description: "What the robot does with a surface."
10126
+ },
10127
+ {
10128
+ name: "cleanExtent",
10129
+ kind: "enum",
10130
+ values: CLEAN_EXTENTS,
10131
+ description: "How far past the mapped edge a job reaches. Wire order, not app order."
10132
+ },
10133
+ {
10134
+ name: "mopLevel",
10135
+ kind: "enum",
10136
+ values: MOP_LEVELS,
10137
+ description: "How much water the mop lays down. Only meaningful for a clean type that mops."
10138
+ }
10139
+ ]
10140
+ },
9671
10141
  /**
9672
10142
  * Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
9673
10143
  *
@@ -10066,6 +10536,16 @@ function getCapabilityModule(cap) {
10066
10536
  return BY_CAP.get(cap);
10067
10537
  }
10068
10538
  var CAPABILITY_MODULES = Object.fromEntries(MODULES.map((m) => [m.capability, m]));
10539
+ function claimedParams(caps) {
10540
+ const claimed = /* @__PURE__ */ new Set();
10541
+ for (const cap of caps)
10542
+ for (const spec of getCapabilityModule(cap)?.properties ?? []) {
10543
+ claimed.add(spec.paramType);
10544
+ for (const alias of spec.readAliases ?? [])
10545
+ claimed.add(alias.paramType);
10546
+ }
10547
+ return claimed;
10548
+ }
10069
10549
  function mergeProperties(caps, ctx) {
10070
10550
  const seen = /* @__PURE__ */ new Set();
10071
10551
  const merged = [];
@@ -10107,7 +10587,7 @@ var CODEC_LINE = {
10107
10587
  };
10108
10588
  function lineAllows(module, codec) {
10109
10589
  const line = module.line ?? "security";
10110
- return line === "any" || line === CODEC_LINE[codec];
10590
+ return line === "any" || codec !== void 0 && line === CODEC_LINE[codec];
10111
10591
  }
10112
10592
  function detectCapabilities(rec, codec) {
10113
10593
  const found = /* @__PURE__ */ new Set();
@@ -10137,10 +10617,10 @@ function detectCapabilities(rec, codec) {
10137
10617
  if (!matched && d.modelHints && haystack.length > 0) {
10138
10618
  matched = d.modelHints.some((re) => re.test(haystack));
10139
10619
  }
10140
- if (!matched && d.codecs) {
10620
+ if (!matched && d.codecs && codec !== void 0) {
10141
10621
  matched = d.codecs.includes(codec);
10142
10622
  }
10143
- if (!matched && d.detect) {
10623
+ if (!matched && d.detect && codec !== void 0) {
10144
10624
  try {
10145
10625
  matched = d.detect(rec, codec) === true;
10146
10626
  } catch {
@@ -10348,6 +10828,11 @@ var MODEL_REGISTRY = {
10348
10828
  T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
10349
10829
  // Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
10350
10830
  T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
10831
+ // Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
10832
+ // member on purpose — this model is wired, so the codec baseline plus inference is the whole
10833
+ // truth for power. Without this row it classified as a plain camera, so the doorbell capability
10834
+ // never appeared and consumers got no doorbell event entity or ring trigger.
10835
+ T8200: { codec: "camera", caps: ["doorbell"], name: "Wired Doorbell 2K" },
10351
10836
  // Cameras observed on real owned hardware (inspect-device sweep). Names from the app's own
10352
10837
  // model-family constants (scripts/data/app_model_registry.json); caps mirror what the device
10353
10838
  // reports. T8171 has no family entry in that dump, so it keeps the raw T-code.
@@ -11490,6 +11975,16 @@ var SECURITY_PARAMS = {
11490
11975
  type: "string",
11491
11976
  provenance: "guessed"
11492
11977
  },
11978
+ 6e3: {
11979
+ name: "lockStatus",
11980
+ type: "number",
11981
+ provenance: "verified"
11982
+ },
11983
+ 6001: {
11984
+ name: "lockBattery",
11985
+ type: "number",
11986
+ provenance: "verified"
11987
+ },
11493
11988
  6010: {
11494
11989
  name: "pwList",
11495
11990
  type: "bool",
@@ -12357,6 +12852,11 @@ var Device = class _Device {
12357
12852
  * different wire ids across device families still resolves to one named value.
12358
12853
  */
12359
12854
  specByParam;
12855
+ /**
12856
+ * Params a resolved capability declares that this device's schema does not carry — withheld by a
12857
+ * member's gate, so not named from the param dictionary either. See {@link Device.applyParams}.
12858
+ */
12859
+ withheld = /* @__PURE__ */ new Set();
12360
12860
  /** Which param namespace this device's ids live in (clean DPs vs security P2P). */
12361
12861
  namespace;
12362
12862
  /** The record's `device_name` as stated, before the {@link modelName} fallback is applied. */
@@ -12423,6 +12923,7 @@ var Device = class _Device {
12423
12923
  byParam.set(a.paramType, { spec: p, invert: a.invert ?? false });
12424
12924
  }
12425
12925
  this.specByParam = byParam;
12926
+ this.withheld = new Set([...claimedParams(resolved.capabilities)].filter((pt) => !byParam.has(pt)));
12426
12927
  this.namespace = namespaceForCodec(resolved.codec);
12427
12928
  this.installAccessors();
12428
12929
  }
@@ -12585,6 +13086,16 @@ var Device = class _Device {
12585
13086
  * Apply a raw param map (cloud record or P2P notification). Known params update their named
12586
13087
  * property; unrecognised params are retained as `unknown_<paramType>` so nothing is lost.
12587
13088
  *
13089
+ * Naming precedence: this device's own `PropertySpec` (curated), then the param dictionary for its
13090
+ * namespace, then the `unknown_<paramType>` passthrough. The dictionary def is consulted even where a
13091
+ * spec exists, because `encoding` lives there.
13092
+ *
13093
+ * A param a resolved capability's gate WITHHELD takes the passthrough instead of its dictionary name.
13094
+ * The gate decided the read does not describe this device, the dictionary names it what the member
13095
+ * would have, and republishing it there hands a caller a reading indistinguishable from one the device
13096
+ * really answered. A capability that never resolved withholds nothing: a param arriving before its
13097
+ * capability is still the device's own, and keeps its dictionary name.
13098
+ *
12588
13099
  * @param params param_type → raw value.
12589
13100
  * @param ts observation time (epoch ms); defaults to `Date.now()`.
12590
13101
  * @returns the list of property names whose value changed.
@@ -12597,7 +13108,7 @@ var Device = class _Device {
12597
13108
  continue;
12598
13109
  const hit = this.specByParam.get(pt);
12599
13110
  const spec = hit?.spec;
12600
- const def = paramDef(this.namespace, pt);
13111
+ const def = this.withheld.has(pt) ? void 0 : paramDef(this.namespace, pt);
12601
13112
  const name = spec ? spec.name : def ? def.name : `${UNKNOWN_PARAM_PREFIX}${pt}`;
12602
13113
  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);
12603
13114
  const prev = this.state.get(name);
@@ -12675,7 +13186,8 @@ var Device = class _Device {
12675
13186
  details: describeCapabilities(this.actionMap, {
12676
13187
  codec: this.codec,
12677
13188
  model: this.model,
12678
- capabilities: new Set(this.capabilities)
13189
+ capabilities: new Set(this.capabilities),
13190
+ homeBaseAttached: this.stationSn !== this.sn
12679
13191
  })
12680
13192
  };
12681
13193
  }
@@ -12799,7 +13311,7 @@ function lz4BlockDecompress(block, size) {
12799
13311
  const end = block.length;
12800
13312
  let pos = 0;
12801
13313
  let written = 0;
12802
- const extend = (base) => {
13314
+ const extend2 = (base) => {
12803
13315
  let len = base;
12804
13316
  if (base !== 15)
12805
13317
  return len;
@@ -12814,7 +13326,7 @@ function lz4BlockDecompress(block, size) {
12814
13326
  };
12815
13327
  while (pos < end) {
12816
13328
  const token = block[pos++];
12817
- const literals = extend(token >> 4);
13329
+ const literals = extend2(token >> 4);
12818
13330
  if (literals === void 0)
12819
13331
  return void 0;
12820
13332
  if (pos + literals > end || written + literals > size)
@@ -12829,7 +13341,7 @@ function lz4BlockDecompress(block, size) {
12829
13341
  pos += 2;
12830
13342
  if (offset === 0 || offset > written)
12831
13343
  return void 0;
12832
- const match = extend(token & 15);
13344
+ const match = extend2(token & 15);
12833
13345
  if (match === void 0)
12834
13346
  return void 0;
12835
13347
  const length = match + 4;
@@ -13333,24 +13845,397 @@ function inspectParams(rec, sn) {
13333
13845
  };
13334
13846
  }
13335
13847
 
13336
- // dist/client/map-channels.js
13337
- var reader = (kind, decode) => (raw, codec) => {
13338
- const value = decode(raw, codec);
13339
- return value === void 0 ? void 0 : { kind, value };
13848
+ // dist/model/capabilities/solix.js
13849
+ var SOLIX_ENERGY_METER_MEMBERS = {
13850
+ /** Line-1 voltage (V), ff09 tag `0xAC` — confirmed live (a nominal mains voltage). */
13851
+ meterVoltageL1: {
13852
+ param: 172,
13853
+ type: "number",
13854
+ kind: "scalar",
13855
+ unit: "V",
13856
+ provenance: "verified",
13857
+ description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
13858
+ },
13859
+ /** Line-2 voltage (V), ff09 tag `0xAD` — the app's field; reads 0 until a multi-phase frame carries it. */
13860
+ meterVoltageL2: {
13861
+ param: 173,
13862
+ type: "number",
13863
+ kind: "scalar",
13864
+ unit: "V",
13865
+ provenance: "guessed",
13866
+ 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."
13867
+ },
13868
+ /** Line-3 voltage (V), ff09 tag `0xAE` — the app's field; reads 0 until a multi-phase frame carries it. */
13869
+ meterVoltageL3: {
13870
+ param: 174,
13871
+ type: "number",
13872
+ kind: "scalar",
13873
+ unit: "V",
13874
+ provenance: "guessed",
13875
+ 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."
13876
+ },
13877
+ /** Line-1 current (A), ff09 tag `0xAF` — confirmed live (the line's CT current). */
13878
+ meterCurrentL1: {
13879
+ param: 175,
13880
+ type: "number",
13881
+ kind: "scalar",
13882
+ unit: "A",
13883
+ provenance: "verified",
13884
+ description: "Meter line-1 current (A) \u2014 ff09 tag 0xAF, confirmed against a live single-phase frame."
13885
+ },
13886
+ /** Line-2 current (A), ff09 tag `0xB0` — the app's field; reads 0 until a multi-phase frame carries it. */
13887
+ meterCurrentL2: {
13888
+ param: 176,
13889
+ type: "number",
13890
+ kind: "scalar",
13891
+ unit: "A",
13892
+ provenance: "guessed",
13893
+ 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."
13894
+ },
13895
+ /** Line-3 current (A), ff09 tag `0xB1` — the app's field; reads 0 until a multi-phase frame carries it. */
13896
+ meterCurrentL3: {
13897
+ param: 177,
13898
+ type: "number",
13899
+ kind: "scalar",
13900
+ unit: "A",
13901
+ provenance: "guessed",
13902
+ 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."
13903
+ },
13904
+ /** Line-1 active power (W), ff09 tag `0xA8` — confirmed live; negative on export. */
13905
+ meterPowerL1: {
13906
+ param: 168,
13907
+ type: "number",
13908
+ kind: "scalar",
13909
+ unit: "W",
13910
+ provenance: "verified",
13911
+ description: "Meter line-1 active power (W) \u2014 ff09 tag 0xA8, confirmed live; negative on export."
13912
+ },
13913
+ /** Line-2 active power (W), ff09 tag `0xA9` — the app's field; reads 0 until a multi-phase frame carries it. */
13914
+ meterPowerL2: {
13915
+ param: 169,
13916
+ type: "number",
13917
+ kind: "scalar",
13918
+ unit: "W",
13919
+ provenance: "guessed",
13920
+ 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."
13921
+ },
13922
+ /** Line-3 active power (W), ff09 tag `0xAA` — the app's field; reads 0 until a multi-phase frame carries it. */
13923
+ meterPowerL3: {
13924
+ param: 170,
13925
+ type: "number",
13926
+ kind: "scalar",
13927
+ unit: "W",
13928
+ provenance: "guessed",
13929
+ 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."
13930
+ },
13931
+ /** Aggregate active power (W), ff09 tag `0xAB` — confirmed live; equals line-1 on a single phase. */
13932
+ meterPowerTotal: {
13933
+ param: 171,
13934
+ type: "number",
13935
+ kind: "scalar",
13936
+ unit: "W",
13937
+ provenance: "verified",
13938
+ description: "Meter total active power (W) \u2014 ff09 tag 0xAB, confirmed live; equals L1 on one phase."
13939
+ }
13340
13940
  };
13341
- var READER_BY_CHANNEL = {
13342
- MAP_DATA: reader("plane", decodeVacuumMap),
13343
- ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
13344
- ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
13345
- RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
13346
- DYNAMIC_DATA: reader("pose", decodeVacuumPose)
13941
+ var CATEGORY_CAPABILITIES = {
13942
+ "Portable Power Station": ["battery", "acOutput", "solarInput"],
13943
+ /**
13944
+ * NOT `energyMeter`: a home battery measures its own grid/PV/output power, but that is a different
13945
+ * ff09 tag family than the AE1X0 Smart Meter's — the `energyMeter` members are AE1X0-specific, so a
13946
+ * Solarbank frame decoded through them mislabels (tag `0xac` is power on a Solarbank, voltage on the
13947
+ * meter — enforced by the meter-family gate in {@link SOLIX_METER_MODELS} / the transport decoder,
13948
+ * see PR #186). `energyMeter` is model-detected for the meter, not category-detected.
13949
+ */
13950
+ "Plug-in Home Battery": ["battery", "solarInput", "acOutput"],
13951
+ "Powered Cooler": ["battery", "cooler"],
13952
+ "Power Bank": ["battery"],
13953
+ "Smart EV Charger": ["evCharger"],
13954
+ Charger: ["charger"],
13955
+ Accessory: []
13347
13956
  };
13348
- var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
13349
- var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
13350
- function decodeMapFrame(frame, codec) {
13351
- if (frame.offset !== 0)
13352
- return void 0;
13353
- return READERS.get(frame.channelId)?.(frame.payload, codec);
13957
+ var SOLIX_METER_MODELS = ["AE1X0"];
13958
+ var SOLARBANK_MODELS = ["A1790", "A17C", "AE10"];
13959
+ function detectSolixCapabilities(rec, category) {
13960
+ const caps = /* @__PURE__ */ new Set(["identity"]);
13961
+ if (rec.device_sw_version)
13962
+ caps.add("firmware");
13963
+ if (rec.wifi_online !== void 0 || rec.rssi != null || rec.wifi_name)
13964
+ caps.add("connectivity");
13965
+ for (const c of category && CATEGORY_CAPABILITIES[category] || [])
13966
+ caps.add(c);
13967
+ if (SOLIX_METER_MODELS.some((m) => rec.product_code?.startsWith(m)))
13968
+ caps.add("energyMeter");
13969
+ if (SOLARBANK_MODELS.some((m) => rec.product_code?.startsWith(m))) {
13970
+ caps.add("battery");
13971
+ caps.add("solarInput");
13972
+ }
13973
+ return caps;
13974
+ }
13975
+
13976
+ // dist/model/solix-catalog.js
13977
+ function buildModelIndex(categories) {
13978
+ const index = /* @__PURE__ */ new Map();
13979
+ for (const category of categories) {
13980
+ for (const product of category.products ?? []) {
13981
+ const entry = { name: product.name, category: category.name.trim() };
13982
+ if (product.product_code)
13983
+ index.set(product.product_code, entry);
13984
+ for (const variant of product.p_codes ?? []) {
13985
+ const code = typeof variant === "string" ? variant : variant?.product_code;
13986
+ if (code)
13987
+ index.set(code, entry);
13988
+ }
13989
+ }
13990
+ }
13991
+ return index;
13992
+ }
13993
+
13994
+ // dist/model/solix-family.js
13995
+ var CATEGORY_FAMILY = {
13996
+ "Portable Power Station": "powerStation",
13997
+ "Power Bank": "powerBank",
13998
+ "Powered Cooler": "cooler",
13999
+ "Smart EV Charger": "evCharger",
14000
+ Charger: "charger"
14001
+ };
14002
+ var hasPrefix = (code, prefixes) => !!code && prefixes.some((p) => code.startsWith(p));
14003
+ var isSolixSolarbank = (input) => hasPrefix(input.product_code, SOLARBANK_MODELS) || input.category === "Plug-in Home Battery";
14004
+ var isSolixSmartMeter = (input) => hasPrefix(input.product_code, SOLIX_METER_MODELS);
14005
+ var isSolixPowerStation = (input) => input.category === "Portable Power Station";
14006
+ function solixProductFamily(input) {
14007
+ if (isSolixSmartMeter(input))
14008
+ return "smartMeter";
14009
+ if (isSolixSolarbank(input))
14010
+ return "solarbank";
14011
+ return (input.category ? CATEGORY_FAMILY[input.category] : void 0) ?? "unknown";
14012
+ }
14013
+
14014
+ // dist/model/solix-device.js
14015
+ var READ_ONLY_SINK = { dispatch: async () => {
14016
+ } };
14017
+ var SolixDevice = class {
14018
+ serial;
14019
+ productCode;
14020
+ record;
14021
+ caps;
14022
+ identity_;
14023
+ values = {};
14024
+ constructor(record, opts = {}) {
14025
+ this.record = record;
14026
+ this.serial = record.device_sn;
14027
+ this.productCode = record.product_code;
14028
+ const label = opts.catalog ? buildModelIndex(opts.catalog).get(record.product_code) : void 0;
14029
+ this.identity_ = {
14030
+ serial: record.device_sn,
14031
+ productCode: record.product_code,
14032
+ name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
14033
+ category: label?.category,
14034
+ family: solixProductFamily({ product_code: record.product_code, category: label?.category })
14035
+ };
14036
+ this.caps = detectSolixCapabilities(record, this.identity_.category);
14037
+ }
14038
+ /**
14039
+ * The device's {@link SolixProductFamily} — the classification a caller branches on to SORT devices
14040
+ * (a site's power stations vs its meters), the Solix analogue of eufy's `isHomeBase()`. Distinct from
14041
+ * {@link has}, which answers what the device can DO; family answers what KIND of device it is.
14042
+ */
14043
+ get family() {
14044
+ return this.identity_.family;
14045
+ }
14046
+ /** All capabilities this device carries. */
14047
+ get capabilities() {
14048
+ return [...this.caps];
14049
+ }
14050
+ /** Whether the device carries a capability — the only correct way to branch on behaviour. */
14051
+ has(capability) {
14052
+ return this.caps.has(capability);
14053
+ }
14054
+ /**
14055
+ * Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
14056
+ * WHOLE reading, not just its values, and drops one addressed to a different device: the documented
14057
+ * wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
14058
+ * watched meter on the account — so without this filter two meters would cross-feed each other's floats.
14059
+ * A reading with no `deviceSn` (a hand-built one) is accepted as-is.
14060
+ */
14061
+ applyReading(reading) {
14062
+ if (reading.deviceSn && reading.deviceSn !== this.serial)
14063
+ return;
14064
+ this.values = { ...this.values, ...reading.values };
14065
+ }
14066
+ /** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
14067
+ telemetry() {
14068
+ return { ...this.values };
14069
+ }
14070
+ identity() {
14071
+ return { ...this.identity_ };
14072
+ }
14073
+ firmware() {
14074
+ return this.record.device_sw_version ? { version: this.record.device_sw_version } : void 0;
14075
+ }
14076
+ connectivity() {
14077
+ if (!this.has("connectivity"))
14078
+ return void 0;
14079
+ const rssi = this.record.rssi != null ? Number(this.record.rssi) : void 0;
14080
+ return {
14081
+ online: !!this.record.wifi_online,
14082
+ rssi: Number.isFinite(rssi) ? rssi : void 0,
14083
+ ssid: this.record.wifi_name
14084
+ };
14085
+ }
14086
+ /**
14087
+ * The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
14088
+ * installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
14089
+ * held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
14090
+ * time of this call, so re-call it to pick up a tag first seen since.
14091
+ */
14092
+ energyMeter() {
14093
+ if (!this.has("energyMeter"))
14094
+ return void 0;
14095
+ return bindMembers(SOLIX_ENERGY_METER_MEMBERS, this.meterDeps());
14096
+ }
14097
+ /**
14098
+ * The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
14099
+ * evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
14100
+ * codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
14101
+ * Solix telemetry is read-only, no member here dispatches a command.
14102
+ */
14103
+ meterDeps() {
14104
+ const read = (name) => {
14105
+ const v = this.values[name];
14106
+ return v === void 0 ? void 0 : { name, paramType: 0, value: v, ts: Date.now() };
14107
+ };
14108
+ return { ctx: { channel: 0, paramIds: this.seenTags() }, sink: READ_ONLY_SINK, read };
14109
+ }
14110
+ /** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
14111
+ seenTags() {
14112
+ const tags = /* @__PURE__ */ new Set();
14113
+ for (const key of Object.keys(this.values)) {
14114
+ const m = /^channel_([0-9a-f]+)$/.exec(key);
14115
+ if (m)
14116
+ tags.add(parseInt(m[1], 16));
14117
+ }
14118
+ return tags;
14119
+ }
14120
+ };
14121
+ function resolveSolixCatalog(client, opts) {
14122
+ return opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => []);
14123
+ }
14124
+ async function discoverSolixDevices(client, opts = {}) {
14125
+ const [records, catalog] = await Promise.all([client.getDevices(), resolveSolixCatalog(client, opts)]);
14126
+ return records.map((r) => new SolixDevice(r, { catalog }));
14127
+ }
14128
+ function sceneNum(v) {
14129
+ if (typeof v === "number")
14130
+ return Number.isFinite(v) ? v : void 0;
14131
+ if (typeof v === "string" && v.trim() !== "") {
14132
+ const n = Number(v);
14133
+ return Number.isFinite(n) ? n : void 0;
14134
+ }
14135
+ return void 0;
14136
+ }
14137
+ function solarbankSceneReadings(scene) {
14138
+ const list = scene.solarbank_info?.solarbank_list ?? [];
14139
+ const out = [];
14140
+ for (const sb of list) {
14141
+ const deviceSn = typeof sb.device_sn === "string" ? sb.device_sn : void 0;
14142
+ if (!deviceSn)
14143
+ continue;
14144
+ const values = {};
14145
+ const temp = sceneNum(sb.bat_temperature);
14146
+ if (temp !== void 0)
14147
+ values.batteryTemperature = temp;
14148
+ const soc = sceneNum(sb.bat_soc);
14149
+ if (soc !== void 0)
14150
+ values.batterySoc = soc;
14151
+ const packs = sceneNum(sb.sub_package_num);
14152
+ if (packs !== void 0)
14153
+ values.expansionPacks = packs;
14154
+ if (Object.keys(values).length > 0)
14155
+ out.push({ deviceSn, values });
14156
+ }
14157
+ return out;
14158
+ }
14159
+
14160
+ // dist/model/solix-site.js
14161
+ var SolixSite = class {
14162
+ id;
14163
+ /** Friendly site name (e.g. "My Home"), or the site id when the record carries none. */
14164
+ name;
14165
+ /** Anker's site-type discriminator (e.g. 20 for a Solarbank-anchored home system), when present. */
14166
+ powerSiteType;
14167
+ record;
14168
+ devices;
14169
+ constructor(record, devices) {
14170
+ this.record = record;
14171
+ this.id = record.site_id;
14172
+ this.name = record.site_name || record.site_id;
14173
+ this.powerSiteType = record.power_site_type;
14174
+ this.devices = devices;
14175
+ }
14176
+ /** The member device with this serial, if it belongs to the site. */
14177
+ device(serial) {
14178
+ return this.devices.find((d) => d.serial === serial);
14179
+ }
14180
+ /** Member devices of a given product {@link SolixProductFamily} — the grouping accessor. */
14181
+ withFamily(family) {
14182
+ return this.devices.filter((d) => d.family === family);
14183
+ }
14184
+ /** Member devices that carry a given capability (e.g. every `battery` in the system). */
14185
+ withCapability(capability) {
14186
+ return this.devices.filter((d) => d.has(capability));
14187
+ }
14188
+ /** The Solarbank / plug-in home-battery members of the system. */
14189
+ solarbanks() {
14190
+ return this.withFamily("solarbank");
14191
+ }
14192
+ /** The smart-meter (grid-CT) members of the system. */
14193
+ smartMeters() {
14194
+ return this.withFamily("smartMeter");
14195
+ }
14196
+ /** The portable power-station members of the system. */
14197
+ powerStations() {
14198
+ return this.withFamily("powerStation");
14199
+ }
14200
+ };
14201
+ async function discoverSolixSites(client, opts = {}) {
14202
+ const [sites, records, catalog] = await Promise.all([
14203
+ client.getSites(),
14204
+ client.getDevices(),
14205
+ resolveSolixCatalog(client, opts)
14206
+ ]);
14207
+ const bySerial = new Map(records.map((r) => [r.device_sn, r]));
14208
+ return sites.map((site) => {
14209
+ const members = (site.site_device_list ?? []).map((entry) => {
14210
+ const record = bySerial.get(entry.device_sn) ?? {
14211
+ device_sn: entry.device_sn,
14212
+ product_code: entry.device_model,
14213
+ device_name: entry.device_name
14214
+ };
14215
+ return new SolixDevice(record, { catalog });
14216
+ });
14217
+ return new SolixSite(site, members);
14218
+ });
14219
+ }
14220
+
14221
+ // dist/client/map-channels.js
14222
+ var reader = (kind, decode) => (raw, codec) => {
14223
+ const value = decode(raw, codec);
14224
+ return value === void 0 ? void 0 : { kind, value };
14225
+ };
14226
+ var READER_BY_CHANNEL = {
14227
+ MAP_DATA: reader("plane", decodeVacuumMap),
14228
+ ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
14229
+ ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
14230
+ RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
14231
+ DYNAMIC_DATA: reader("pose", decodeVacuumPose)
14232
+ };
14233
+ var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
14234
+ var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
14235
+ function decodeMapFrame(frame, codec) {
14236
+ if (frame.offset !== 0)
14237
+ return void 0;
14238
+ return READERS.get(frame.channelId)?.(frame.payload, codec);
13354
14239
  }
13355
14240
 
13356
14241
  // dist/transport/p2p/command-router.js
@@ -14627,6 +15512,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14627
15512
  if (host && !this.closed)
14628
15513
  this.selfAddress = { host, port: boundPort };
14629
15514
  });
15515
+ this.trace({
15516
+ phase: "lookup-channels",
15517
+ local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15518
+ cloud: this.cfg.dskKey !== void 0 && (this.cfg.cloudAddresses?.length ?? 0) > 0
15519
+ });
14630
15520
  this.sendLookups();
14631
15521
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
14632
15522
  this.connectTimer = setTimeout(() => {
@@ -14709,8 +15599,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14709
15599
  for (const addr of this.cfg.cloudAddresses)
14710
15600
  this.send(addr, type, payload);
14711
15601
  this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: cloud -> ${this.cfg.cloudAddresses.map((a) => `${a.host}:${a.port}`).join(", ")} self=${this.selfAddress?.host}:${this.selfAddress?.port}`);
14712
- } else {
14713
- this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: NO cloud lookup sent (dskKey=${!!this.cfg.dskKey} cloudAddresses=${this.cfg.cloudAddresses?.length ?? 0})`);
14714
15602
  }
14715
15603
  }
14716
15604
  /**
@@ -15910,19 +16798,23 @@ function parseFf09SettingsResponse(plain) {
15910
16798
  if (plain.length < 1)
15911
16799
  throw new Error("ff09: settings response too short (missing status byte)");
15912
16800
  const status = plain[0];
16801
+ const fields = walkFf09Tlv(plain, 1, plain.length);
16802
+ return { status, fields };
16803
+ }
16804
+ function walkFf09Tlv(buf, start, end) {
15913
16805
  const fields = /* @__PURE__ */ new Map();
15914
- let i = 1;
15915
- while (i + 2 <= plain.length) {
15916
- const sep = plain[i];
15917
- if (sep === 0)
16806
+ let i = start;
16807
+ while (i + 2 <= end) {
16808
+ const tag2 = buf[i];
16809
+ if (tag2 === 0)
15918
16810
  break;
15919
- const len = plain[i + 1];
15920
- if (i + 2 + len > plain.length)
16811
+ const len = buf[i + 1];
16812
+ if (i + 2 + len > end)
15921
16813
  break;
15922
- fields.set(sep, plain.subarray(i + 2, i + 2 + len));
16814
+ fields.set(tag2, buf.subarray(i + 2, i + 2 + len));
15923
16815
  i += 2 + len;
15924
16816
  }
15925
- return { status, fields };
16817
+ return fields;
15926
16818
  }
15927
16819
  function readFf09U16LE(field, name) {
15928
16820
  if (!field || field.length < 2)
@@ -16576,8 +17468,9 @@ var LiveStream = class extends EventEmitter3 {
16576
17468
  * Stop re-issuing the media start once this camera's own media has arrived, on an attached camera.
16577
17469
  *
16578
17470
  * The nudge differs by topology and only one branch is a ping: an own-session camera sends a small
16579
- * keepalive, while an attached camera has no such state and re-sends the FULL media start. On a station that
16580
- * serves one camera at a time that restart re-asserts this channel against whatever else is warm, so two
17471
+ * keepalive, while an attached camera has no such state and re-sends the FULL media start. Over one session,
17472
+ * which serves one camera at a time, that restart re-asserts this channel against whatever else is warm on
17473
+ * it, so two
16581
17474
  * attached streams restart every interval and contend for the station continuously — measured on a real base
16582
17475
  * as a full start every 3 s from each.
16583
17476
  *
@@ -16744,7 +17637,7 @@ var LiveStream = class extends EventEmitter3 {
16744
17637
  *
16745
17638
  * The match is UNCONDITIONAL, however long a station serves another camera instead of this one.
16746
17639
  *
16747
- * A station serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
17640
+ * One session serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
16748
17641
  * it switches, so "no media of my own yet, plenty for someone else" is what an ordinary handover looks like
16749
17642
  * and does not distinguish a station that tags differently from one that is simply busy.
16750
17643
  *
@@ -17794,6 +18687,8 @@ var SharedLiveSource = class {
17794
18687
  this.configuredFrom = void 0;
17795
18688
  this.ring = [];
17796
18689
  this._state = state;
18690
+ if (state === "stopped")
18691
+ this.opts.onStopped?.();
17797
18692
  if (startFailed && report)
17798
18693
  this.opts.onStartFailed?.();
17799
18694
  }
@@ -17876,14 +18771,14 @@ var SessionManager = class {
17876
18771
  this.logger = opts.logger ?? noopLogger;
17877
18772
  }
17878
18773
  /** The live session for a station, or `undefined` if not open. */
17879
- get(parentSn) {
17880
- return this.entries.get(parentSn)?.session;
18774
+ get(key) {
18775
+ return this.entries.get(key)?.session;
17881
18776
  }
17882
- /** Serials of stations with a live session. */
18777
+ /** Keys of the open sessions. */
17883
18778
  keys() {
17884
18779
  return [...this.entries].filter(([, e]) => e.session).map(([sn]) => sn);
17885
18780
  }
17886
- /** A plain `Map<parentSn, P2PSession>` snapshot of the live sessions (for `getSessions()` / tests). */
18781
+ /** A plain `Map<key, P2PSession>` snapshot of the open sessions (for `getSessions()` / tests). */
17887
18782
  liveSessions() {
17888
18783
  const m = /* @__PURE__ */ new Map();
17889
18784
  for (const [sn, e] of this.entries)
@@ -17891,56 +18786,77 @@ var SessionManager = class {
17891
18786
  m.set(sn, e.session);
17892
18787
  return m;
17893
18788
  }
17894
- /** Get or create the lifecycle entry for a station. */
17895
- entry(parentSn) {
17896
- let e = this.entries.get(parentSn);
18789
+ /**
18790
+ * Get or create the lifecycle entry under `key`, recording which station it connects to. An existing
18791
+ * entry keeps the station it was opened with — the key owns one connection for its lifetime, and a
18792
+ * later caller passing a different station would otherwise re-point a live entry's power tier.
18793
+ */
18794
+ entry(key, station) {
18795
+ let e = this.entries.get(key);
17897
18796
  if (!e) {
17898
- e = { retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
17899
- this.entries.set(parentSn, e);
18797
+ e = { station, retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
18798
+ this.entries.set(key, e);
17900
18799
  }
17901
18800
  return e;
17902
18801
  }
17903
- /** Register an already-built session for test seeding or an externally assembled connection. */
17904
- register(parentSn, session) {
17905
- this.entry(parentSn).session = session;
18802
+ /**
18803
+ * Register an already-built session for test seeding or an externally assembled connection.
18804
+ *
18805
+ * `station` defaults to the key, which is safe HERE and nowhere else in this class: the paths that open a
18806
+ * session under a key that is not a station serial all go through {@link acquire}, which requires it. A
18807
+ * caller seeding one under such a key must pass it.
18808
+ */
18809
+ register(key, session, station = key) {
18810
+ this.entry(key, station).session = session;
17906
18811
  }
17907
18812
  /**
17908
- * Ensure a session to `parentSn` is open, building it via `factory` if cold. Concurrent calls for the
18813
+ * Ensure a session to `key` is open, building it via `factory` if cold. Concurrent calls for the
17909
18814
  * same cold station share ONE connect (the `connecting` promise); `factory` builds + wires + awaits
17910
18815
  * `connect()` and resolves the connected session.
17911
18816
  */
17912
- async acquire(parentSn, factory) {
17913
- const e = this.entry(parentSn);
18817
+ async acquire(key, factory, station) {
18818
+ const e = this.entry(key, station);
17914
18819
  if (e.connecting) {
17915
- this.logger.debug(`[session ${parentSn}] connecting \u2014 joining in-flight open`);
18820
+ this.logger.debug(`[session ${key}] connecting \u2014 joining in-flight open`);
17916
18821
  return e.connecting;
17917
18822
  }
17918
18823
  if (e.session)
17919
18824
  return e.session;
17920
- this.logger.debug(`[session ${parentSn}] connecting now (on demand)`);
18825
+ this.logger.debug(`[session ${key}] connecting now (on demand)`);
17921
18826
  const generation = this.generation;
17922
18827
  const p = factory((session) => e.session = session);
17923
18828
  e.connecting = p;
17924
18829
  try {
17925
18830
  const session = await p;
17926
- if (generation !== this.generation || this.entries.get(parentSn) !== e) {
18831
+ if (generation !== this.generation || this.entries.get(key) !== e) {
17927
18832
  await session.close();
17928
- throw new SessionSupersededError(`P2P session start superseded for station ${parentSn}`);
18833
+ throw new SessionSupersededError(`P2P session start superseded for ${key}`);
17929
18834
  }
17930
18835
  e.session ??= session;
17931
- this.logger.debug(`[session ${parentSn}] connected`);
18836
+ this.logger.debug(`[session ${key}] connected`);
17932
18837
  return e.session;
17933
18838
  } finally {
17934
18839
  if (e.connecting === p)
17935
18840
  e.connecting = void 0;
17936
18841
  }
17937
18842
  }
17938
- /** Add a reason to stay connected; cancels a pending idle-close. */
17939
- retain(parentSn) {
17940
- const e = this.entry(parentSn);
18843
+ /**
18844
+ * Add a reason to stay connected; cancels a pending idle-close.
18845
+ *
18846
+ * Refused when nothing is open under `key`, for the reason {@link release} gives in the other direction: a
18847
+ * retain names a session that was acquired, and one that names nothing would file an entry with no
18848
+ * connection behind it. {@link hold} is the path that legitimately creates one, and it opens the entry
18849
+ * itself before retaining it.
18850
+ */
18851
+ retain(key) {
18852
+ const e = this.entries.get(key);
18853
+ if (!e) {
18854
+ this.logger.warn(`[session ${key}] retain on a session that is not open \u2014 ignored`);
18855
+ return;
18856
+ }
17941
18857
  e.retained++;
17942
18858
  if (e.idle.pending)
17943
- this.logger.debug(`[session ${parentSn}] in use again \u2014 idle-detach cancelled`);
18859
+ this.logger.debug(`[session ${key}] in use again \u2014 idle-detach cancelled`);
17944
18860
  e.idle.cancel();
17945
18861
  }
17946
18862
  /**
@@ -17951,44 +18867,50 @@ var SessionManager = class {
17951
18867
  * from scratch or complete a deferred reset a real viewer has not yet earned. Clamping to zero did
17952
18868
  * both silently.
17953
18869
  */
17954
- release(parentSn) {
17955
- const e = this.entries.get(parentSn);
18870
+ release(key) {
18871
+ const e = this.entries.get(key);
17956
18872
  if (!e)
17957
18873
  return;
17958
18874
  if (e.retained === 0) {
17959
- this.logger.warn(`[session ${parentSn}] release with nothing retained \u2014 ignored`);
18875
+ this.logger.warn(`[session ${key}] release with nothing retained \u2014 ignored`);
17960
18876
  return;
17961
18877
  }
17962
18878
  e.retained -= 1;
17963
18879
  if (e.reset && e.retained <= e.holdTimers.size) {
17964
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] deferred reset failed`, error));
18880
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] deferred reset failed`, error));
17965
18881
  return;
17966
18882
  }
17967
18883
  if (e.retained === 0)
17968
- this.armIdle(parentSn, e);
18884
+ this.armIdle(key, e);
17969
18885
  }
17970
18886
  /**
17971
18887
  * Hold a session warm for `commandKeepAliveMs` after a control command, then release. A burst of
17972
18888
  * commands each re-holds before the previous release fires, so the session never idles mid-burst.
17973
18889
  */
17974
- bumpCommand(parentSn) {
17975
- this.hold(parentSn, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS);
18890
+ bumpCommand(key, station) {
18891
+ this.hold(key, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS, station);
17976
18892
  }
17977
18893
  /**
17978
- * Retain a station and release it again after `ms` — the primitive behind command-keepalive and event
18894
+ * Retain a session and release it again after `ms` — the primitive behind command-keepalive and event
17979
18895
  * pre-warm, and the only way to hold one open without an attachment to release it.
17980
18896
  *
18897
+ * `station` is required rather than defaulted from the key, because this is the one path that can
18898
+ * CREATE an entry: a pre-warm takes its hold before the open. An entry filed under a media key with
18899
+ * that key as its own station would be asked for the power tier of a serial that does not exist, be
18900
+ * answered `wired`, and never idle-detach — which on a battery station is the drain this class exists
18901
+ * to prevent, and is invisible until the battery is flat.
18902
+ *
17981
18903
  * The timer is owned by the entry, so {@link discard} cancels it. That ownership is the point: keyed
17982
18904
  * only by serial, an expiring hold would otherwise outlive the entry it was taken on and release a
17983
18905
  * retain counted by the SUCCESSOR entry — dropping a live viewer's count and arming an idle-detach
17984
18906
  * underneath it.
17985
18907
  */
17986
- hold(parentSn, ms) {
17987
- const entry = this.entry(parentSn);
17988
- this.retain(parentSn);
18908
+ hold(key, ms, station) {
18909
+ const entry = this.entry(key, station);
18910
+ this.retain(key);
17989
18911
  const timer = setTimeout(() => {
17990
18912
  entry.holdTimers.delete(timer);
17991
- this.release(parentSn);
18913
+ this.release(key);
17992
18914
  }, ms);
17993
18915
  timer.unref?.();
17994
18916
  entry.holdTimers.add(timer);
@@ -17997,29 +18919,29 @@ var SessionManager = class {
17997
18919
  * Arm the idle-close timer for a station whose retain count just reached zero. A wired station with
17998
18920
  * an infinite window is left persistent (no timer). Any subsequent {@link retain} cancels it.
17999
18921
  */
18000
- armIdle(parentSn, e) {
18922
+ armIdle(key, e) {
18001
18923
  e.idle.cancel();
18002
- if ((this.opts.poweredFor?.(parentSn) ?? "wired") !== "battery") {
18003
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 staying persistent (wired)`);
18924
+ if ((this.opts.poweredFor?.(e.station) ?? "wired") !== "battery") {
18925
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 staying persistent (wired)`);
18004
18926
  return;
18005
18927
  }
18006
18928
  const idleMs = this.opts.batteryIdleMs ?? BATTERY_IDLE_MS;
18007
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
18008
- e.idle.arm(idleMs, () => this.onIdle(parentSn));
18929
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
18930
+ e.idle.arm(idleMs, () => this.onIdle(key));
18009
18931
  }
18010
18932
  /**
18011
18933
  * Close a station's session once its idle window elapses with nothing retained, letting the device sleep.
18012
18934
  * Re-checks the count first (activity between the timer firing and now re-arms instead). Dropping the
18013
18935
  * entry here and the session's own `close` → {@link remove} are both idempotent.
18014
18936
  */
18015
- onIdle(parentSn) {
18016
- const e = this.entries.get(parentSn);
18937
+ onIdle(key) {
18938
+ const e = this.entries.get(key);
18017
18939
  if (!e)
18018
18940
  return;
18019
18941
  if (e.retained > 0)
18020
18942
  return;
18021
- this.logger.debug(`[session ${parentSn}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
18022
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] idle detach failed`, error));
18943
+ this.logger.debug(`[session ${key}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
18944
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] idle detach failed`, error));
18023
18945
  }
18024
18946
  /**
18025
18947
  * Close a station the manager itself decided to close, and announce it.
@@ -18037,23 +18959,23 @@ var SessionManager = class {
18037
18959
  * An entry that never carried a session is still torn down, but silently: a pre-warm whose open failed
18038
18960
  * leaves one behind, and announcing it would report a station closed that was never reported open.
18039
18961
  */
18040
- async autoClose(parentSn) {
18041
- const entry = this.discard(parentSn);
18962
+ async autoClose(key) {
18963
+ const entry = this.discard(key);
18042
18964
  if (!entry)
18043
18965
  return;
18044
18966
  if (entry.session)
18045
- this.opts.onAutoClose?.(parentSn);
18967
+ this.opts.onAutoClose?.(key);
18046
18968
  await this.closeEntry(entry);
18047
18969
  }
18048
18970
  /** Drop a station's entry + timer (called from the session's `close` handler). Idempotent. */
18049
- remove(parentSn) {
18050
- const entry = this.discard(parentSn);
18971
+ remove(key) {
18972
+ const entry = this.discard(key);
18051
18973
  if (entry)
18052
18974
  this.settleReset(entry);
18053
18975
  }
18054
18976
  /** Close one station now and discard its lifecycle entry. */
18055
- async close(parentSn) {
18056
- const entry = this.discard(parentSn);
18977
+ async close(key) {
18978
+ const entry = this.discard(key);
18057
18979
  if (entry)
18058
18980
  await this.closeEntry(entry);
18059
18981
  }
@@ -18067,12 +18989,12 @@ var SessionManager = class {
18067
18989
  * Both branches close through {@link autoClose}: the caller asked for a recycle, not for the station's
18068
18990
  * live sources to be dropped, so it does not clean up after one — exactly like the idle path.
18069
18991
  */
18070
- resetWhenUnused(parentSn) {
18071
- const entry = this.entries.get(parentSn);
18992
+ resetWhenUnused(key) {
18993
+ const entry = this.entries.get(key);
18072
18994
  if (!entry)
18073
18995
  return Promise.resolve();
18074
18996
  if (entry.retained <= entry.holdTimers.size)
18075
- return this.autoClose(parentSn);
18997
+ return this.autoClose(key);
18076
18998
  entry.reset ??= Promise.withResolvers();
18077
18999
  return entry.reset.promise;
18078
19000
  }
@@ -18104,22 +19026,22 @@ var SessionManager = class {
18104
19026
  * discarded entry owns no live timer, which is what stops a deferred release from landing on whatever
18105
19027
  * entry next occupies this serial.
18106
19028
  */
18107
- discard(parentSn) {
18108
- const entry = this.entries.get(parentSn);
19029
+ discard(key) {
19030
+ const entry = this.entries.get(key);
18109
19031
  if (!entry)
18110
19032
  return void 0;
18111
19033
  entry.idle.cancel();
18112
19034
  for (const timer of entry.holdTimers)
18113
19035
  clearTimeout(timer);
18114
19036
  entry.holdTimers.clear();
18115
- this.entries.delete(parentSn);
19037
+ this.entries.delete(key);
18116
19038
  return entry;
18117
19039
  }
18118
19040
  /** Close every session and clear all timers. */
18119
19041
  async closeAll() {
18120
19042
  this.generation++;
18121
- const entries = [...this.entries.keys()].flatMap((parentSn) => {
18122
- const entry = this.discard(parentSn);
19043
+ const entries = [...this.entries.keys()].flatMap((key) => {
19044
+ const entry = this.discard(key);
18123
19045
  return entry ? [entry] : [];
18124
19046
  });
18125
19047
  const results = await Promise.allSettled(entries.map((entry) => this.closeEntry(entry)));
@@ -19107,9 +20029,52 @@ var FragmentRecording = class extends EventEmitter6 {
19107
20029
  }
19108
20030
  };
19109
20031
 
20032
+ // dist/transport/p2p/station-channels.js
20033
+ function resolvedStationSn(raw, sn) {
20034
+ const parent = typeof raw.parent_sn === "string" && raw.parent_sn ? raw.parent_sn : void 0;
20035
+ if (parent && parent !== sn)
20036
+ return parent;
20037
+ const station = typeof raw.station_sn === "string" && raw.station_sn ? raw.station_sn : void 0;
20038
+ return station ?? sn;
20039
+ }
20040
+ function stationOf(dev) {
20041
+ const raw = dev.raw ?? {};
20042
+ const parent = typeof raw.parent_sn === "string" && raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : void 0;
20043
+ return parent ?? dev.stationSn ?? resolvedStationSn(raw, dev.sn);
20044
+ }
20045
+ function statedChannel(dev) {
20046
+ const v = dev.raw?.device_channel;
20047
+ return typeof v === "number" ? v : void 0;
20048
+ }
20049
+ function stationChannels(devices) {
20050
+ const out = /* @__PURE__ */ new Map();
20051
+ const claimants = /* @__PURE__ */ new Map();
20052
+ for (const d of devices) {
20053
+ const station = stationOf(d);
20054
+ const stated = statedChannel(d);
20055
+ if (d.sn === station || stated === void 0)
20056
+ continue;
20057
+ const counts = claimants.get(station) ?? /* @__PURE__ */ new Map();
20058
+ counts.set(stated, (counts.get(stated) ?? 0) + 1);
20059
+ claimants.set(station, counts);
20060
+ }
20061
+ for (const d of devices) {
20062
+ const station = stationOf(d);
20063
+ const stated = statedChannel(d);
20064
+ if (d.sn === station)
20065
+ out.set(d.sn, { channel: stated ?? 0 });
20066
+ else if (stated === void 0)
20067
+ out.set(d.sn, { issue: "missing" });
20068
+ else if ((claimants.get(station)?.get(stated) ?? 0) > 1)
20069
+ out.set(d.sn, { issue: "shared", claimed: stated });
20070
+ else
20071
+ out.set(d.sn, { channel: stated });
20072
+ }
20073
+ return out;
20074
+ }
20075
+
19110
20076
  // dist/transport/p2p/command-router.js
19111
20077
  var DIRECT_CMD_SENDS = 5;
19112
- var CONNECT_WAIT_MS = 2e4;
19113
20078
  function abortable(work, signal) {
19114
20079
  if (!signal)
19115
20080
  return work;
@@ -19124,7 +20089,7 @@ function abortable(work, signal) {
19124
20089
  var LEVEL2_GRACE_MS = 25e3;
19125
20090
  var LEVEL2_SETTLE_MS = 8e3;
19126
20091
  var P2P_STATION_WAITS = {
19127
- connect: CONNECT_WAIT_MS,
20092
+ connect: CONNECT_TIMEOUT_MS,
19128
20093
  level2Grace: LEVEL2_GRACE_MS,
19129
20094
  level2Settle: LEVEL2_SETTLE_MS
19130
20095
  };
@@ -19145,7 +20110,7 @@ function sameLiveOpt(a, b) {
19145
20110
  }
19146
20111
  var P2PCommandRouter = class _P2PCommandRouter {
19147
20112
  deps;
19148
- /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
20113
+ /** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
19149
20114
  manager;
19150
20115
  /** Error objects already forwarded while a station startup awaits the same session signal. */
19151
20116
  reportedErrors = /* @__PURE__ */ new WeakSet();
@@ -19153,6 +20118,21 @@ var P2PCommandRouter = class _P2PCommandRouter {
19153
20118
  liveSources = /* @__PURE__ */ new Map();
19154
20119
  /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
19155
20120
  liveSourceOpts = /* @__PURE__ */ new Map();
20121
+ /**
20122
+ * The session each live source pulls over — the station's own serial, or the source's own media
20123
+ * session key.
20124
+ *
20125
+ * Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
20126
+ * a reservation rather than a record. Two cameras started together (the four-tile case this feature
20127
+ * exists for) would otherwise both find the station's session free: a consumer attaches only after
20128
+ * `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
20129
+ * would take the shared connection and contend on it.
20130
+ *
20131
+ * A station entry is kept as well as a media one, because "is the station's own session already
20132
+ * claimed" is the question being asked, and an entry that is present but not yet in
20133
+ * {@link liveSources} is a claim in flight.
20134
+ */
20135
+ liveSessionKeys = /* @__PURE__ */ new Map();
19156
20136
  /**
19157
20137
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
19158
20138
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -19166,7 +20146,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19166
20146
  this.manager = new SessionManager({
19167
20147
  poweredFor: deps.poweredFor,
19168
20148
  logger: deps.logger,
19169
- onAutoClose: (parentSn) => this.tearDownStation(parentSn),
20149
+ onAutoClose: (key) => _P2PCommandRouter.isMediaSessionKey(key) ? this.tearDownMediaSession(key) : this.tearDownStation(key),
19170
20150
  ...deps.sessionIdle
19171
20151
  });
19172
20152
  }
@@ -19198,7 +20178,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
19198
20178
  static claimsDevice(dev) {
19199
20179
  return typeof dev.p2pDid === "string" && dev.p2pDid.length > 0;
19200
20180
  }
19201
- /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
20181
+ /**
20182
+ * The open P2P sessions by key — a station's own under its serial, a camera's media session under
20183
+ * `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
20184
+ */
19202
20185
  getSessions() {
19203
20186
  return this.manager.liveSessions();
19204
20187
  }
@@ -19210,16 +20193,16 @@ var P2PCommandRouter = class _P2PCommandRouter {
19210
20193
  *
19211
20194
  * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
19212
20195
  * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
19213
- * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
20196
+ * A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
19214
20197
  * and the lookups are away, not once the peer has answered, so both would expire together.
19215
20198
  *
19216
20199
  * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
19217
20200
  * session was deliberately closed underneath a speculative open, which is not a fault to report.
19218
20201
  */
19219
20202
  async prewarm(parentSn, ms = PREWARM_MS) {
19220
- this.manager.hold(parentSn, ms);
20203
+ this.manager.hold(parentSn, ms, parentSn);
19221
20204
  try {
19222
- await this.openStation(parentSn);
20205
+ await this.openSession(parentSn, parentSn);
19223
20206
  } catch (e) {
19224
20207
  if (e instanceof SessionSupersededError)
19225
20208
  return;
@@ -19236,17 +20219,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
19236
20219
  src.dispose();
19237
20220
  this.liveSources.clear();
19238
20221
  this.liveSourceOpts.clear();
20222
+ this.liveSessionKeys.clear();
19239
20223
  await this.manager.closeAll();
19240
20224
  }
19241
20225
  /** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
19242
20226
  recordFor(sn) {
19243
20227
  return this.deps.listDevices().find((d) => d.sn === sn);
19244
20228
  }
19245
- /** The parent-station key a device's session lives under (its HomeBase, or itself if standalone). */
19246
- stationKeyFor(dev) {
19247
- const raw = dev.raw ?? {};
19248
- return raw.parent_sn && raw.parent_sn !== dev.sn ? raw.parent_sn : dev.stationSn ?? dev.sn;
19249
- }
19250
20229
  /**
19251
20230
  * The parent-station serial a device serial's session lives under — the single source of truth for
19252
20231
  * session keying, used by the facade (e.g. to pre-warm the right station for an event). Returns the
@@ -19254,14 +20233,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19254
20233
  */
19255
20234
  stationKeyOf(sn) {
19256
20235
  const dev = this.recordFor(sn);
19257
- return dev ? this.stationKeyFor(dev) : sn;
20236
+ return dev ? stationOf(dev) : sn;
19258
20237
  }
19259
20238
  /** Reset only a standalone device's session; an attached device must not close its shared HomeBase. */
19260
20239
  async resetStandaloneSession(sn) {
19261
20240
  const device = this.recordFor(sn);
19262
20241
  if (!device)
19263
20242
  return;
19264
- const station = this.stationKeyFor(device);
20243
+ const station = stationOf(device);
19265
20244
  if (station === sn)
19266
20245
  await this.manager.resetWhenUnused(station);
19267
20246
  }
@@ -19277,8 +20256,55 @@ var P2PCommandRouter = class _P2PCommandRouter {
19277
20256
  * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
19278
20257
  * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
19279
20258
  */
19280
- async openStation(parentSn) {
19281
- return this.manager.acquire(parentSn, async (register) => {
20259
+ /**
20260
+ * The key a camera's own media session is filed under, distinct from every station serial because a
20261
+ * serial contains no `#`.
20262
+ */
20263
+ static mediaSessionKey(parentSn, channel) {
20264
+ return `${parentSn}#live:${channel}`;
20265
+ }
20266
+ /** Whether `key` names a media session rather than a station's own. */
20267
+ static isMediaSessionKey(key) {
20268
+ return key.includes("#live:");
20269
+ }
20270
+ /**
20271
+ * Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
20272
+ *
20273
+ * One session serves one camera: a station fans its cameras over a session and answers the most recent
20274
+ * start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
20275
+ * how a station serves another camera.
20276
+ *
20277
+ * The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
20278
+ * captured opening one PPCS session per camera, with three cameras' video arriving in the same second
20279
+ * at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
20280
+ * Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
20281
+ * full frame rate at once over a session each, where the same pair down one session could only take
20282
+ * turns.
20283
+ *
20284
+ * It carries media alone. The station announces its state to every client that connects, so a session
20285
+ * wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
20286
+ * unannounced, and the station's own session stays the single source of connection state, control
20287
+ * notifications and frames.
20288
+ *
20289
+ * The level-2 key is waited for here on the same terms the station's own session gets, because a
20290
+ * connection is not usable for this without one: an attached camera's media start has no level-1 form,
20291
+ * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
20292
+ * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
20293
+ * carry the start is not a connected session, so this refuses rather than returning one.
20294
+ */
20295
+ async openMediaSession(parentSn, channel, signal) {
20296
+ const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
20297
+ let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
20298
+ if (!ready && session.repromptLevel2Key()) {
20299
+ ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
20300
+ }
20301
+ if (!ready)
20302
+ throw new StationKeyUnavailableError(parentSn);
20303
+ return session;
20304
+ }
20305
+ /** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
20306
+ async openSession(key, parentSn) {
20307
+ return this.manager.acquire(key, async (register) => {
19282
20308
  const stationDev = this.recordFor(parentSn);
19283
20309
  if (!stationDev)
19284
20310
  throw new Error(`station ${parentSn} is not in the device list`);
@@ -19293,11 +20319,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
19293
20319
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19294
20320
  }
19295
20321
  const localAddress = this.deps.localAddresses?.[parentSn] ?? freshestLanIp(raw);
19296
- const session = this.makeSession(parentSn, did, raw, dskKey, localAddress);
20322
+ const session = this.makeSession(parentSn, did, raw, dskKey, localAddress, key, key === parentSn);
19297
20323
  register(session);
19298
20324
  await session.connect();
19299
20325
  return session;
19300
- });
20326
+ }, parentSn);
19301
20327
  }
19302
20328
  /**
19303
20329
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
@@ -19310,8 +20336,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19310
20336
  *
19311
20337
  * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
19312
20338
  * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
20339
+ *
20340
+ * A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
20341
+ * level-2 readiness and inbound frames all reach the owner through the station's own session, and a
20342
+ * station announces those to every client that connects — so fanning a second connection's copies out
20343
+ * under the same station serial would report each one twice, and a close would tear down the station
20344
+ * while its own session is still serving.
19313
20345
  */
19314
- makeSession(stationSn, did, raw, dskKey, localAddress) {
20346
+ makeSession(stationSn, did, raw, dskKey, localAddress, key, announces) {
19315
20347
  const conn = raw?.p2p_conn ?? raw?.app_conn;
19316
20348
  const adminUserId = raw?.member?.admin_user_id || this.deps.mega.auth?.userId || "";
19317
20349
  const session = new P2PSession({
@@ -19345,6 +20377,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19345
20377
  },
19346
20378
  logger: this.deps.logger ?? noopLogger
19347
20379
  });
20380
+ session.on("error", (e) => this.reportError(e));
20381
+ if (!announces) {
20382
+ session.on("close", () => {
20383
+ if (this.manager.get(key) === session)
20384
+ this.tearDownMediaSession(key);
20385
+ });
20386
+ return session;
20387
+ }
19348
20388
  session.on("connect", () => {
19349
20389
  if (this.manager.get(stationSn) === session)
19350
20390
  this.deps.onConnect(stationSn);
@@ -19354,7 +20394,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
19354
20394
  return;
19355
20395
  this.tearDownStation(stationSn);
19356
20396
  });
19357
- session.on("error", (e) => this.reportError(e));
19358
20397
  session.on("level2Ready", ({ cipherId }) => this.deps.onLevel2Ready(stationSn, cipherId));
19359
20398
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
19360
20399
  return session;
@@ -19365,7 +20404,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19365
20404
  */
19366
20405
  async ensureStation(parentSn, signal) {
19367
20406
  try {
19368
- const session = await this.openStation(parentSn);
20407
+ const session = await this.openSession(parentSn, parentSn);
19369
20408
  if (session.isConnected)
19370
20409
  return;
19371
20410
  await new Promise((resolve, reject) => {
@@ -19413,7 +20452,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19413
20452
  const dev = this.recordFor(sn);
19414
20453
  if (!dev)
19415
20454
  throw new Error(`device ${sn} not found`);
19416
- await this.openStation(this.stationKeyFor(dev));
20455
+ await this.openSession(stationOf(dev), stationOf(dev));
19417
20456
  return dev;
19418
20457
  }
19419
20458
  /**
@@ -19497,11 +20536,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
19497
20536
  });
19498
20537
  },
19499
20538
  live: async (opts) => {
19500
- const source = await this.sharedLiveSourceFor(sn, opts);
20539
+ const source = await this.sharedLiveSourceFor(sn, opts, true);
19501
20540
  return this.attachUnlessAborted(source, opts?.signal);
19502
20541
  },
19503
20542
  openReadable: async (opts) => {
19504
- const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20543
+ const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
19505
20544
  return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
19506
20545
  },
19507
20546
  recordFragments: (opts) => this.recordFragments(sn, opts),
@@ -19642,10 +20681,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
19642
20681
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
19643
20682
  * reuse branch, so a reuse frees the station as a cold start does.
19644
20683
  *
20684
+ * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
20685
+ * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
20686
+ * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
20687
+ * so a still asked for while a sibling is being watched contends as it always did, and the caller's
20688
+ * retained image answers it. A live view still outranks a tile; what changed is that two live views no
20689
+ * longer have to outrank each other.
20690
+ *
19645
20691
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
19646
20692
  * place.
19647
20693
  */
19648
- async sharedLiveSourceFor(sn, opts = {}) {
20694
+ async sharedLiveSourceFor(sn, opts = {}, mayOpenOwnSession = false) {
19649
20695
  const { session, parentSn, channel, accountId, homeBaseAttached } = await this.resolveSession(sn, {
19650
20696
  waitLevel2: "soft",
19651
20697
  requireLevel2ForAttached: true,
@@ -19658,14 +20704,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
19658
20704
  source = void 0;
19659
20705
  }
19660
20706
  this.releaseLingeringSiblings(parentSn, channel);
19661
- if (homeBaseAttached) {
19662
- const serving = this.occupiedSiblingChannel(parentSn, key);
19663
- if (serving !== void 0)
19664
- throw new StationBusyError(serving);
19665
- }
19666
20707
  if (!source) {
19667
20708
  const logger = this.deps.logger ?? noopLogger;
19668
- const held = { session };
20709
+ const sessionKey = mayOpenOwnSession && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
20710
+ this.liveSessionKeys.set(key, sessionKey);
20711
+ let held;
20712
+ try {
20713
+ held = {
20714
+ session: sessionKey === parentSn ? session : await this.openMediaSession(parentSn, channel, opts.signal)
20715
+ };
20716
+ } catch (error) {
20717
+ this.liveSessionKeys.delete(key);
20718
+ throw error;
20719
+ }
19669
20720
  source = new SharedLiveSource({
19670
20721
  makeStream: (ctx) => new LiveStream(held.session, {
19671
20722
  channel,
@@ -19683,8 +20734,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19683
20734
  budgetGraceMs: opts.budgetGraceMs,
19684
20735
  logger,
19685
20736
  label: key,
19686
- onActive: () => this.manager.retain(parentSn),
19687
- onIdle: () => this.manager.release(parentSn),
20737
+ onActive: () => this.manager.retain(sessionKey),
20738
+ onIdle: () => this.manager.release(sessionKey),
20739
+ onStopped: () => this.closeMediaSession(key),
19688
20740
  onStartFailed: () => this.onLiveStartFailed(sn, key),
19689
20741
  onSessionUnreachable: () => this.replaceUnreachableSession(sn, key, held)
19690
20742
  });
@@ -19699,14 +20751,25 @@ var P2PCommandRouter = class _P2PCommandRouter {
19699
20751
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
19700
20752
  *
19701
20753
  * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
19702
- * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
19703
- * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
19704
- * long as the linger lasts.
20754
+ * re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
20755
+ * which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
20756
+ * for as long as the linger lasts.
19705
20757
  *
19706
20758
  * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
19707
20759
  * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
19708
20760
  * looking at competing with one somebody just asked for.
19709
20761
  *
20762
+ * An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
20763
+ * has sent no media start and is competing for nothing. Without that, two cameras opened at the same
20764
+ * moment destroy each other — the second finds the first's source built but not yet attached to, reads
20765
+ * zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
20766
+ * this whole path exists for.
20767
+ *
20768
+ * A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
20769
+ * contention this releases is contention over one session, and that sibling is not on this one. Dropping
20770
+ * it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
20771
+ * competition that is not happening.
20772
+ *
19710
20773
  * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
19711
20774
  * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
19712
20775
  * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
@@ -19720,28 +20783,49 @@ var P2PCommandRouter = class _P2PCommandRouter {
19720
20783
  for (const [key, source] of [...this.liveSources]) {
19721
20784
  if (!key.startsWith(`${parentSn}:`) || key === own || source.consumerCount > 0)
19722
20785
  continue;
19723
- (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`);
20786
+ if (source.state === "idle")
20787
+ continue;
20788
+ if (this.liveSessionKeys.get(key) !== parentSn)
20789
+ continue;
20790
+ (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`);
19724
20791
  this.dropLiveSource(key);
19725
20792
  }
19726
20793
  }
19727
20794
  /**
19728
- * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
20795
+ * Whether another camera has already claimed this station's OWN session, ignoring `key`.
20796
+ *
20797
+ * The question a newcomer has to answer is not whether the station is busy — it can serve one camera
20798
+ * per connection — but whether the connection it would otherwise share is taken. A camera on a media
20799
+ * session of its own does not hold this one, so a station whose first camera has since stopped hands
20800
+ * its own session to the next arrival rather than opening a socket beside an idle one.
20801
+ *
20802
+ * A claim counts while its source is still `idle`, source or no source. That is a start that has been
20803
+ * handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
20804
+ * at the same moment can see each other: consumers attach after this method has already run for both.
20805
+ *
20806
+ * The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
20807
+ * session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
20808
+ * socket against destroying a start someone is waiting on, which is not a close trade.
19729
20809
  *
19730
20810
  * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
19731
20811
  * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
19732
- * and counting that as a viewer would refuse every later stream on the station until the client
19733
- * restarted. Only a source that can still deliver holds a place.
20812
+ * and counting that as a viewer would put every later camera on a connection of its own until the
20813
+ * client restarted. Only a source that can still deliver holds a place.
19734
20814
  */
19735
- occupiedSiblingChannel(parentSn, key) {
19736
- for (const [siblingKey, sibling] of this.liveSources) {
19737
- if (siblingKey === key || !siblingKey.startsWith(`${parentSn}:`))
20815
+ stationSessionInUse(parentSn, key) {
20816
+ for (const [siblingKey, sessionKey] of this.liveSessionKeys) {
20817
+ if (siblingKey === key || sessionKey !== parentSn)
19738
20818
  continue;
19739
- if (sibling.state === "stopped" || sibling.consumerCount === 0)
20819
+ const sibling = this.liveSources.get(siblingKey);
20820
+ if (!sibling)
20821
+ return true;
20822
+ if (sibling.state === "stopped")
19740
20823
  continue;
19741
- const channel = Number(siblingKey.slice(parentSn.length + 1));
19742
- return Number.isFinite(channel) ? channel : void 0;
20824
+ if (sibling.state !== "idle" && sibling.consumerCount === 0)
20825
+ continue;
20826
+ return true;
19743
20827
  }
19744
- return void 0;
20828
+ return false;
19745
20829
  }
19746
20830
  /**
19747
20831
  * Whether the stream on `key` should re-assert its channel to hold the station.
@@ -19752,8 +20836,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
19752
20836
  * - Nothing attached: no. There is nobody to take the station for.
19753
20837
  * - A live viewer attached: yes. That is the picture someone is looking at.
19754
20838
  * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
19755
- * tile that is off screen while the live view is on it, and a station serving one camera at a time
19756
- * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
20839
+ * tile that is off screen while the live view is on it, and one session serving one camera at a time
20840
+ * 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
19757
20841
  * it took, and its own capture then took fifteen seconds because it was contending.
19758
20842
  *
19759
20843
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
@@ -19775,7 +20859,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
19775
20859
  }
19776
20860
  return consumer;
19777
20861
  }
19778
- /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
20862
+ /**
20863
+ * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
20864
+ * on a session goes with it, and a media session opened for this source alone is closed: nothing else
20865
+ * can reach that connection, so leaving it open would hold a socket and a station keepalive for a
20866
+ * camera no longer being pulled. A claim on the STATION's own session is released without closing
20867
+ * anything — that connection carries the station's control traffic and outlives any one camera.
20868
+ */
19779
20869
  dropLiveSource(key) {
19780
20870
  const source = this.liveSources.get(key);
19781
20871
  if (!source)
@@ -19783,6 +20873,34 @@ var P2PCommandRouter = class _P2PCommandRouter {
19783
20873
  source.dispose();
19784
20874
  this.liveSources.delete(key);
19785
20875
  this.liveSourceOpts.delete(key);
20876
+ this.closeMediaSession(key);
20877
+ }
20878
+ /** Close and forget the media session `key`'s live source owned, if it owned one. */
20879
+ closeMediaSession(key) {
20880
+ const sessionKey = this.liveSessionKeys.get(key);
20881
+ if (sessionKey === void 0)
20882
+ return;
20883
+ this.liveSessionKeys.delete(key);
20884
+ if (!_P2PCommandRouter.isMediaSessionKey(sessionKey))
20885
+ return;
20886
+ void this.manager.close(sessionKey).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
20887
+ }
20888
+ /**
20889
+ * Drop the live source a media session was carrying, after that session closed on its own.
20890
+ *
20891
+ * The source holds the closed connection and never re-resolves it, so it can only answer its retained
20892
+ * keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
20893
+ * a fresh source — which, finding the station busy again, opens a fresh media session for it.
20894
+ */
20895
+ tearDownMediaSession(sessionKey) {
20896
+ this.manager.remove(sessionKey);
20897
+ for (const [key, owned] of this.liveSessionKeys) {
20898
+ if (owned === sessionKey) {
20899
+ this.liveSessionKeys.delete(key);
20900
+ this.dropLiveSource(key);
20901
+ return;
20902
+ }
20903
+ }
19786
20904
  }
19787
20905
  /**
19788
20906
  * Drop everything that was riding a station's session, and report the station closed.
@@ -20122,26 +21240,40 @@ var P2PCommandRouter = class _P2PCommandRouter {
20122
21240
  async resolveSession(sn, opts = {}, rebuilt = false) {
20123
21241
  const dev = await this.deviceFor(sn);
20124
21242
  const raw = dev.raw ?? {};
20125
- const homeBaseAttached = !!raw.parent_sn && raw.parent_sn !== sn;
20126
- const parentSn = homeBaseAttached ? raw.parent_sn : dev.stationSn ?? sn;
21243
+ const parentSn = stationOf(dev);
21244
+ const homeBaseAttached = parentSn !== sn;
20127
21245
  const session = this.manager.get(parentSn) ?? this.manager.get(sn) ?? (dev.stationSn ? this.manager.get(dev.stationSn) : void 0);
20128
21246
  if (!session) {
20129
- throw new Error(`no P2P session for ${sn} (known: ${this.manager.keys().join(", ") || "none"})`);
21247
+ const stations = this.manager.keys().filter((k) => !_P2PCommandRouter.isMediaSessionKey(k));
21248
+ throw new Error(`no P2P session for ${sn} (known: ${stations.join(", ") || "none"})`);
20130
21249
  }
20131
21250
  if (session.pathAnswering === false && !rebuilt) {
20132
21251
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
20133
21252
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
20134
21253
  return await this.resolveSession(sn, opts, true);
20135
21254
  }
20136
- this.manager.bumpCommand(parentSn);
20137
- const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
21255
+ const address = stationChannels(this.deps.listDevices()).get(sn);
21256
+ if (!("channel" in address)) {
21257
+ this.traceOnStation(session, { phase: "station-channel-unresolved", issue: address.issue });
21258
+ throw new DeviceChannelUnresolvedError(sn, parentSn);
21259
+ }
21260
+ this.manager.bumpCommand(parentSn, parentSn);
21261
+ const { channel } = address;
20138
21262
  const stationAdminId = raw.member?.admin_user_id;
21263
+ const stationModel = this.recordFor(parentSn)?.model;
20139
21264
  const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
21265
+ this.traceOnStation(session, {
21266
+ phase: "station-resolved",
21267
+ topology: homeBaseAttached ? "attached" : "own",
21268
+ channel,
21269
+ stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other",
21270
+ ...stationModel ? { stationModel } : {}
21271
+ });
20140
21272
  const t0 = Date.now();
20141
21273
  let waitedMs = 0;
20142
21274
  if (!session.isConnected) {
20143
- this.traceOnStation(session, { phase: "session-connect-wait", waitMs: CONNECT_WAIT_MS });
20144
- while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
21275
+ this.traceOnStation(session, { phase: "session-connect-wait", waitMs: P2P_STATION_WAITS.connect });
21276
+ while (!session.isConnected && Date.now() - t0 < P2P_STATION_WAITS.connect) {
20145
21277
  opts.signal?.throwIfAborted();
20146
21278
  await sleep2(200);
20147
21279
  }
@@ -20151,12 +21283,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
20151
21283
  opts.signal?.throwIfAborted();
20152
21284
  if (!session.isConnected)
20153
21285
  throw new StationUnreachableError(parentSn, waitedMs);
20154
- this.traceOnStation(session, {
20155
- phase: "station-resolved",
20156
- topology: homeBaseAttached ? "attached" : "own",
20157
- channel,
20158
- stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other"
20159
- });
20160
21286
  if (opts.waitLevel2) {
20161
21287
  if (opts.waitLevel2 === "settle") {
20162
21288
  await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
@@ -20243,13 +21369,22 @@ var P2PCommandRouter = class _P2PCommandRouter {
20243
21369
  * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
20244
21370
  * unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
20245
21371
  * camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
21372
+ *
21373
+ * Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
21374
+ * control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
21375
+ * least able to afford a single dropped one — it has no reply, no readback here, and nothing that
21376
+ * would tell a caller the write was lost rather than refused. The level-1 form reports delivery by
21377
+ * throwing (`sendSetPayload` throws when the session has no address) rather than by returning a
21378
+ * boolean, so the first pass carries the failure and the rest are repeats.
20246
21379
  */
20247
21380
  async sendSetPayloadEnvelope(sn, cmd, payload, channel, mValue3, resolved, form) {
20248
21381
  if (form === "auto") {
20249
21382
  await this.sendBySessionLevel(sn, {
20250
- l1: ({ session, accountId }) => {
20251
- session.sendSetPayload(cmd, payload, { accountId, channel });
20252
- return Promise.resolve();
21383
+ l1: async ({ session, accountId }) => {
21384
+ for (let i = 0; i < DIRECT_CMD_SENDS; i++) {
21385
+ session.sendSetPayload(cmd, payload, { accountId, channel });
21386
+ await sleep2(200);
21387
+ }
20253
21388
  },
20254
21389
  // NB: do NOT forward sendBySessionLevel's resolved session here — it was resolved with
20255
21390
  // waitLevel2:false (enough to read topology), so on a HomeBase-attached device the level-2
@@ -21657,8 +22792,8 @@ function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
21657
22792
  },
21658
22793
  format: "jwk"
21659
22794
  });
21660
- const md5Hex = createHash6("md5").update(password).digest("hex");
21661
- return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex)).toString("hex");
22795
+ const md5Hex2 = createHash6("md5").update(password).digest("hex");
22796
+ return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex2)).toString("hex");
21662
22797
  }
21663
22798
  var TuyaClient = class {
21664
22799
  signer;
@@ -22921,8 +24056,21 @@ var FileFcmStore = class {
22921
24056
  }
22922
24057
  };
22923
24058
 
24059
+ // dist/transport/media-failure.js
24060
+ var MEDIA_FAILURE_REASONS = [
24061
+ "url-not-allowed",
24062
+ "address-not-public",
24063
+ "redirect-not-allowed",
24064
+ "http-status",
24065
+ "too-large",
24066
+ "timeout",
24067
+ "network",
24068
+ "decode-failed"
24069
+ ];
24070
+
22924
24071
  // dist/transport/stored-image-cache.js
22925
24072
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
24073
+ var MAX_REMEMBERED_URLS = 64;
22926
24074
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
22927
24075
  var StoredImageCache = class {
22928
24076
  downloader;
@@ -22939,7 +24087,13 @@ var StoredImageCache = class {
22939
24087
  this.clock = clock;
22940
24088
  this.isLifecycleError = isLifecycleError;
22941
24089
  }
22942
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
24090
+ /**
24091
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
24092
+ *
24093
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
24094
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
24095
+ * so the entry evicted once it is full is the oldest attempt.
24096
+ */
22943
24097
  observe(deviceKey, url) {
22944
24098
  let state = this.devices.get(deviceKey);
22945
24099
  if (!state) {
@@ -22954,6 +24108,12 @@ var StoredImageCache = class {
22954
24108
  if (state.seenUrls.has(url))
22955
24109
  return;
22956
24110
  state.seenUrls.add(url);
24111
+ while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
24112
+ const oldest = state.seenUrls.values().next();
24113
+ if (oldest.done)
24114
+ break;
24115
+ state.seenUrls.delete(oldest.value);
24116
+ }
22957
24117
  state.queued = {
22958
24118
  deviceKey,
22959
24119
  url,
@@ -23003,7 +24163,7 @@ var StoredImageCache = class {
23003
24163
  } else if (image === void 0) {
23004
24164
  state.lifecycleError = void 0;
23005
24165
  state.reason = "download-failed";
23006
- this.diagnose(state, candidate, "download-failed");
24166
+ this.diagnose(state, candidate, "download-failed", error);
23007
24167
  } else if (!this.isValidJpeg(image)) {
23008
24168
  state.lifecycleError = void 0;
23009
24169
  state.reason = "invalid-image";
@@ -23019,7 +24179,7 @@ var StoredImageCache = class {
23019
24179
  isValidJpeg(image) {
23020
24180
  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;
23021
24181
  }
23022
- diagnose(state, candidate, failure) {
24182
+ diagnose(state, candidate, failure, error) {
23023
24183
  const now2 = this.clock();
23024
24184
  const last = state.loggedFailures.get(failure);
23025
24185
  if (last !== void 0 && now2 - last < DIAGNOSTIC_INTERVAL_MS)
@@ -23027,20 +24187,25 @@ var StoredImageCache = class {
23027
24187
  state.loggedFailures.set(failure, now2);
23028
24188
  this.logger.warn("[stored-snapshot-cache] candidate failed", {
23029
24189
  class: failure,
24190
+ ...mediaFailureTag(error),
23030
24191
  observedAt: candidate.observedAt,
23031
24192
  retained: state.retained !== void 0
23032
24193
  });
23033
24194
  }
23034
24195
  };
24196
+ function mediaFailureTag(error) {
24197
+ if (typeof error !== "object" || error === null)
24198
+ return {};
24199
+ const { mediaFailure, status } = error;
24200
+ if (typeof mediaFailure !== "string")
24201
+ return {};
24202
+ const cause = MEDIA_FAILURE_REASONS.find((reason) => reason === mediaFailure);
24203
+ if (!cause)
24204
+ return {};
24205
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? { cause, status } : { cause };
24206
+ }
23035
24207
 
23036
24208
  // dist/client/device-registry.js
23037
- function resolvedStationSn(raw, sn) {
23038
- const parent = typeof raw.parent_sn === "string" && raw.parent_sn ? raw.parent_sn : void 0;
23039
- if (parent && parent !== sn)
23040
- return parent;
23041
- const station = typeof raw.station_sn === "string" && raw.station_sn ? raw.station_sn : void 0;
23042
- return station ?? sn;
23043
- }
23044
24209
  function mergeParams(raw, params, paramUpdatedAt) {
23045
24210
  for (const p of raw ?? []) {
23046
24211
  if (!p || p.param_type == null || p.param_value == null)
@@ -23082,6 +24247,9 @@ var DeviceRegistry = class {
23082
24247
  devices = [];
23083
24248
  /** Per-(station, channel) capability cache for {@link capabilitiesForFrame}; `null` = negative hit. */
23084
24249
  frameCapsCache = /* @__PURE__ */ new Map();
24250
+ /** {@link stationChannels} of {@link channelMapFor}, the roster it was computed over. */
24251
+ channelMap = /* @__PURE__ */ new Map();
24252
+ channelMapFor;
23085
24253
  /**
23086
24254
  * Serials whose per-device param overlay has been refused. The call is owner-gated, so on a shared or
23087
24255
  * member account it fails for the whole life of the client — retrying it every refresh spends a request
@@ -23349,7 +24517,7 @@ var DeviceRegistry = class {
23349
24517
  }
23350
24518
  const raw = dev.raw;
23351
24519
  const deviceType = typeof raw?.["device_type"] === "number" ? raw["device_type"] : void 0;
23352
- const station = this.stationOf(dev);
24520
+ const station = stationOf(dev);
23353
24521
  return {
23354
24522
  deviceType,
23355
24523
  model: dev.model,
@@ -23497,43 +24665,47 @@ var DeviceRegistry = class {
23497
24665
  serialForFrame(stationSn, channel) {
23498
24666
  return this.deviceForFrame(stationSn, channel)?.sn;
23499
24667
  }
23500
- /**
23501
- * The parent station a device's frames arrive under.
23502
- *
23503
- * `parent_sn` on the cloud record is the field that is actually populated for a HomeBase-attached
23504
- * device — `stationSn` is frequently absent (observed empty on every attached sensor of a T8010),
23505
- * so keying on it alone silently resolves an attached device to ITSELF and no frame ever matches.
23506
- * Mirrors the router's own session-keying precedence, which is the source of truth for which
23507
- * station a device's traffic belongs to. Answering the device's OWN serial is what "stands alone"
23508
- * means, so this is also the topology signal `record()`/`capsOf` hand the resolver.
23509
- */
23510
- stationOf(dev) {
23511
- return dev.stationSn ?? resolvedStationSn(dev.raw ?? {}, dev.sn);
23512
- }
23513
24668
  /**
23514
24669
  * The device a `(station, channel)` pair refers to — a station fans out to attached devices by
23515
24670
  * `device_channel`, while a standalone device is its own station at channel 0.
23516
24671
  *
23517
- * A device claims a channel only when its record actually STATES one. Treating a missing
23518
- * `device_channel` as 0 turns every such device into a rival claimant for channel 0, where a
23519
- * station legitimately has an attached device already, and the winner is then decided by cloud list
23520
- * order — so the same frame resolves to different devices across refreshes. The resolved serial now
23521
- * decides where realtime state is written, not just which decoders may run, so an ambiguous answer
24672
+ * A device claims a channel only when its record actually STATES one that no other device attached to
24673
+ * the same station also states ({@link stationChannels}). Treating a missing `device_channel` as 0, or
24674
+ * letting two claimants of one channel both hold it, leaves the winner to cloud list order — which a
24675
+ * partial refresh reorders — so the same frame resolves to different devices across refreshes. The resolved
24676
+ * serial decides where realtime state is written, not just which decoders may run, so an ambiguous answer
23522
24677
  * writes one device's params onto another.
23523
24678
  *
23524
24679
  * An attached device that names the channel wins over the station itself, which is what a station
23525
- * fanning traffic out by channel means; the station answers for channel 0 only when nothing is
23526
- * attached there, which is also the standalone case (a device is its own station).
24680
+ * fanning traffic out by channel means. A channel two attached devices both state belongs to one of them,
24681
+ * which cannot be told apart, so it answers nothing rather than the station. The station answers for
24682
+ * channel 0 only when nothing attached states it, which is also the standalone case (a device is its own
24683
+ * station).
23527
24684
  */
23528
24685
  deviceForFrame(stationSn, channel) {
23529
- const attached = this.devices.find((d) => {
23530
- const stated = d.raw?.device_channel;
23531
- return typeof stated === "number" && stated === channel && d.sn !== stationSn && this.stationOf(d) === stationSn;
23532
- });
23533
- if (attached)
23534
- return attached;
24686
+ const channels = this.stationChannelMap();
24687
+ let claimed = false;
24688
+ for (const d of this.devices) {
24689
+ if (d.sn === stationSn || stationOf(d) !== stationSn)
24690
+ continue;
24691
+ const c = channels.get(d.sn);
24692
+ if (c && "channel" in c && c.channel === channel)
24693
+ return d;
24694
+ if (c && "claimed" in c && c.claimed === channel)
24695
+ claimed = true;
24696
+ }
24697
+ if (claimed)
24698
+ return void 0;
23535
24699
  return channel === 0 ? this.devices.find((d) => d.sn === stationSn) : void 0;
23536
24700
  }
24701
+ /** {@link stationChannels} over the current roster, recomputed only when the roster itself is replaced. */
24702
+ stationChannelMap() {
24703
+ if (this.channelMapFor !== this.devices) {
24704
+ this.channelMap = stationChannels(this.devices);
24705
+ this.channelMapFor = this.devices;
24706
+ }
24707
+ return this.channelMap;
24708
+ }
23537
24709
  /**
23538
24710
  * Resolve the capability set of the device a P2P frame belongs to — the `(station, channel)` pair
23539
24711
  * (a station fans out to attached devices by `device_channel`; a standalone device is its own
@@ -23582,7 +24754,7 @@ var DeviceRegistry = class {
23582
24754
  /** Resolve a record's capabilities the way `getDevice` does, so gating matches `device.has()`. */
23583
24755
  capsOf(dev) {
23584
24756
  const raw = dev.raw ?? {};
23585
- const station = this.stationOf(dev);
24757
+ const station = stationOf(dev);
23586
24758
  return new Set(resolveDevice({
23587
24759
  deviceType: raw.device_type,
23588
24760
  model: dev.model,
@@ -24440,9 +25612,9 @@ var EufyMega = class extends EventEmitter9 {
24440
25612
  /**
24441
25613
  * Combine explicit P2P media with the optional passive push-thumbnail provider.
24442
25614
  *
24443
- * The retained still also becomes the answer for a live still that could not be captured. A station
24444
- * serves one camera at a time and a live view outranks a tile, so a still asked for while a sibling is
24445
- * being watched is refused at the transport. Answering the retained bytes answers the read rather than
25615
+ * The retained still also becomes the answer for a live still that could not be captured. One session
25616
+ * serves one camera at a time and a live view outranks a tile — a still does not open a connection of its
25617
+ * 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
24446
25618
  * failing it, marked {@link MediaProvider.snapshotLive} `retained` so the caller knows they are not
24447
25619
  * current. With nothing retained the refusal stands.
24448
25620
  */
@@ -25195,10 +26367,15 @@ var EufyMega = class extends EventEmitter9 {
25195
26367
  await sink.dispatch(cmd);
25196
26368
  }
25197
26369
  /**
25198
- * Stations with a live P2P session. P2P is auto-managed: wired stations are warmed at login, battery
26370
+ * The open P2P sessions, by key. P2P is auto-managed: wired stations are warmed at login, battery
25199
26371
  * stations open on demand (command / stream, or an opted-in event pre-warm) and idle-detach — so this
25200
- * map grows and shrinks over time. `p2pConnect(stationSn)` / `p2pClose(stationSn)` events track the
25201
- * changes.
26372
+ * map grows and shrinks over time.
26373
+ *
26374
+ * A station's own session is keyed by its serial, and `p2pConnect(stationSn)` / `p2pClose(stationSn)`
26375
+ * track those. A station serving more than one camera at once also holds a session per extra camera,
26376
+ * keyed `<stationSn>#live:<channel>` — these carry media alone and raise no connection events, because
26377
+ * a station announces its state to every client that connects and reporting each copy would double
26378
+ * every event the station's own session already delivers.
25202
26379
  */
25203
26380
  getP2pSessions() {
25204
26381
  return this.p2p.getSessions();
@@ -25571,6 +26748,1014 @@ var EufyMega = class extends EventEmitter9 {
25571
26748
  }
25572
26749
  };
25573
26750
 
26751
+ // dist/transport/http/solix-constants.js
26752
+ var SOLIX_APP_NAME = "anker_power";
26753
+ var SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
26754
+ var SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
26755
+ var SOLIX_ENDPOINTS = {
26756
+ estimateDomain: "/passport/estimate_domain",
26757
+ keyExchange: "/openapi/oauth/key/exchange",
26758
+ login: "/passport/login",
26759
+ /** Bound devices for the account (flat list). */
26760
+ getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices",
26761
+ /** Sites (systems) the account owns; devices are grouped under a site. */
26762
+ getSiteList: "/power_service/v1/site/get_site_list",
26763
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26764
+ getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
26765
+ /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
26766
+ productCategories: "/power_service/v1/product_categories",
26767
+ /** POST (encrypted+signed): write device attributes, e.g. `{ambient_light_switch: 0|1}`. */
26768
+ setDeviceAttrs: "/power_service/v1/app/device/set_device_attrs",
26769
+ /** POST (plain authed): read device attributes, e.g. the display `screen_off_time` (seconds). */
26770
+ getDeviceAttrs: "/power_service/v1/app/device/get_device_attrs",
26771
+ /** POST (plain authed): the battery discharge-cutoff (minimum-SOC) preset options. */
26772
+ getPowerCutoff: "/power_service/v1/app/compatible/get_power_cutoff",
26773
+ /** POST (encrypted+signed): select the discharge-cutoff preset by `cutoff_data_id`. */
26774
+ setPowerCutoff: "/power_service/v1/app/compatible/set_power_cutoff",
26775
+ /**
26776
+ * POST (plain authed): the site "scene" snapshot — the same clean Solarbank/grid telemetry the app
26777
+ * reads on load/refresh. Used as a low-rate BACKSTOP for the fields the realtime `ff09` push doesn't
26778
+ * carry reliably (notably `bat_temperature`), NOT as the realtime source (that is the MQTT push).
26779
+ */
26780
+ getSiteScene: "/power_service/v2/site/platform_get_site_scene",
26781
+ /**
26782
+ * POST (plain authed): read a site "device param" block by `param_type`. Body is
26783
+ * `{ site_id, param_type, cmd: 246 }`; the response's `data.param_data` is a JSON STRING the caller
26784
+ * parses. The Solarbank's SOC-limit settings live under `param_type "27"` (charge/discharge limits,
26785
+ * backup reserve) — verified live on an AE103 (`"18"` returns empty for this device).
26786
+ */
26787
+ getSiteDeviceParam: "/power_service/v1/site/get_site_device_param",
26788
+ /**
26789
+ * POST (encrypted+signed): write a site "device param" block. Body is
26790
+ * `{ site_id, cmd: 246, param_type, param_data: <JSON string> }`. Used for the SOC-limit write
26791
+ * (`param_type "27"`, `param_data` = the SocSettingParam map) — see {@link SolixClient.setSafetySocParams}.
26792
+ */
26793
+ setSiteDeviceParam: "/power_service/v1/site/set_site_device_param"
26794
+ };
26795
+
26796
+ // dist/transport/http/solix-client.js
26797
+ function solixSessionFresh(s) {
26798
+ return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
26799
+ }
26800
+ var SOLIX_TOKEN_KICKED_CODE = 26084;
26801
+ var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
26802
+ var SolixClient = class _SolixClient {
26803
+ email;
26804
+ password;
26805
+ country;
26806
+ appVersion;
26807
+ doFetch;
26808
+ store;
26809
+ openudid;
26810
+ apiHost;
26811
+ session_;
26812
+ /** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
26813
+ pending2fa;
26814
+ /**
26815
+ * Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
26816
+ * not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
26817
+ * handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
26818
+ * override that also skips domain-estimate, and every read goes through `this.apiHost`.
26819
+ */
26820
+ constructor(opts) {
26821
+ this.email = opts.email;
26822
+ this.password = opts.password;
26823
+ this.country = (opts.countryCode ?? "US").toUpperCase();
26824
+ this.appVersion = opts.appVersion ?? "3.23.0";
26825
+ this.doFetch = opts.fetchImpl ?? fetch;
26826
+ this.apiHost = opts.apiHost ?? SOLIX_DEFAULT_API_HOST;
26827
+ this.store = opts.store;
26828
+ const saved = this.store?.load();
26829
+ this.openudid = opts.openudid ?? saved?.openudid ?? uuidFromHex(md5Hex(`anker-solix:${opts.email}`));
26830
+ if (saved?.session && solixSessionFresh(saved.session)) {
26831
+ this.session_ = saved.session;
26832
+ this.apiHost = opts.apiHost ?? saved.session.apiHost;
26833
+ }
26834
+ }
26835
+ /** Persist the current device id (+ session, if any) when a store is configured. */
26836
+ persist() {
26837
+ this.store?.save({ openudid: this.openudid, session: this.session_ });
26838
+ }
26839
+ /** The authenticated session, once {@link login} has resolved to `ok`. */
26840
+ get session() {
26841
+ return this.session_;
26842
+ }
26843
+ /**
26844
+ * Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
26845
+ * must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
26846
+ * (`401 token error`) — so those use {@link baseHeaders} directly.
26847
+ */
26848
+ authHeaders(extra = {}) {
26849
+ return this.baseHeaders({ openudid: this.openudid, "x-terminal-id": this.openudid, ...extra });
26850
+ }
26851
+ /** Base headers common to every Solix request. */
26852
+ baseHeaders(extra = {}) {
26853
+ return {
26854
+ "content-type": "application/json",
26855
+ "app-name": SOLIX_APP_NAME,
26856
+ "model-type": "PHONE",
26857
+ "os-type": "android",
26858
+ "os-version": "36",
26859
+ "app-version": this.appVersion,
26860
+ country: this.country,
26861
+ timezone: "GMT+00:00",
26862
+ language: "en",
26863
+ "user-agent": "ktor-client",
26864
+ accept: "application/json",
26865
+ ...extra
26866
+ };
26867
+ }
26868
+ /** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
26869
+ async send(method2, host, path, headers, body) {
26870
+ const res = await this.doFetch(`https://${host}${path}`, {
26871
+ method: method2,
26872
+ headers,
26873
+ body,
26874
+ signal: AbortSignal.timeout(2e4)
26875
+ });
26876
+ const text2 = await res.text();
26877
+ try {
26878
+ return JSON.parse(text2);
26879
+ } catch {
26880
+ throw new Error(`Solix ${path} \u2192 HTTP ${res.status}, non-JSON: ${text2.slice(0, 120)}`);
26881
+ }
26882
+ }
26883
+ /** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
26884
+ post(host, path, body, headers) {
26885
+ return this.send("POST", host, path, headers, body);
26886
+ }
26887
+ /** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
26888
+ async estimateHost() {
26889
+ try {
26890
+ const env = await this.post(SOLIX_ESTIMATE_HOST, SOLIX_ENDPOINTS.estimateDomain, JSON.stringify({ ab: this.country, mode: 1 }), this.baseHeaders());
26891
+ const domain = env.data?.domain;
26892
+ if (domain)
26893
+ this.apiHost = domain;
26894
+ } catch {
26895
+ }
26896
+ }
26897
+ /** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
26898
+ async keyExchange() {
26899
+ const prep = prepareKeyExchange(SOLIX_LOCAL_KEY_HEX);
26900
+ const env = await this.post(this.apiHost, SOLIX_ENDPOINTS.keyExchange, JSON.stringify({ client_public_key: prep.encryptedClientPublicKey }), this.authHeaders(prep.headers));
26901
+ const spk = env.data?.server_public_key;
26902
+ if (env.code !== 0 || !spk)
26903
+ throw new Error(`Solix key/exchange failed (${env.code}): ${env.msg}`);
26904
+ return finishKeyExchange(prep, spk);
26905
+ }
26906
+ /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
26907
+ async postLogin(kx, verifyCode, limitedToken) {
26908
+ const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
26909
+ const bodyObj = {
26910
+ email: this.email,
26911
+ password: encryptedPassword,
26912
+ ab: this.country,
26913
+ client_secret_info: { public_key: clientPublicKeyHex },
26914
+ answer: "",
26915
+ captcha_id: "",
26916
+ verify_code: verifyCode ?? "",
26917
+ login_id: ""
26918
+ };
26919
+ const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
26920
+ const ts = nowSec();
26921
+ const once = genId();
26922
+ return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
26923
+ "x-encryption-info": "algo_ecdh",
26924
+ "x-key-ident": kx.keyIdent,
26925
+ "x-request-ts": ts,
26926
+ "x-request-once": once,
26927
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26928
+ ...limitedToken ? { "x-auth-token": limitedToken } : {}
26929
+ }));
26930
+ }
26931
+ /**
26932
+ * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
26933
+ * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
26934
+ * has been satisfied.
26935
+ *
26936
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
26937
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
26938
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
26939
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
26940
+ */
26941
+ classifyLogin(data, isVerify) {
26942
+ const userId = data.ap_cloud_user_id ?? data.user_id;
26943
+ const authToken = data.auth_token;
26944
+ if (!userId || !authToken)
26945
+ throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
26946
+ const faInfo = data.fa_info ?? {};
26947
+ if (!isVerify && faInfo.info) {
26948
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
26949
+ return { status: "2fa", method: "code sent by the passport" };
26950
+ }
26951
+ this.pending2fa = void 0;
26952
+ this.session_ = {
26953
+ authToken,
26954
+ userId,
26955
+ gtoken: gtoken(userId),
26956
+ apiHost: this.apiHost,
26957
+ tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
26958
+ };
26959
+ this.persist();
26960
+ return { status: "ok", session: this.session_ };
26961
+ }
26962
+ /** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
26963
+ decryptLogin(env, kx) {
26964
+ if (typeof env.data !== "string")
26965
+ throw new Error(`Solix login (${env.code}): ${env.msg}`);
26966
+ return JSON.parse(decryptBody(env.data, kx.shareKey).toString("utf-8"));
26967
+ }
26968
+ /**
26969
+ * Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
26970
+ * when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
26971
+ * (adopted from a store) is answered without a handshake.
26972
+ */
26973
+ async login() {
26974
+ if (solixSessionFresh(this.session_)) {
26975
+ return { status: "ok", session: this.session_ };
26976
+ }
26977
+ await this.estimateHost();
26978
+ const kx = await this.keyExchange();
26979
+ const env = await this.postLogin(kx);
26980
+ this.assertLoginAccepted(env);
26981
+ return this.classifyLogin(this.decryptLogin(env, kx), false);
26982
+ }
26983
+ /**
26984
+ * On a rejected `/passport/login` (non-zero code, so `data` is an error envelope not the encrypted
26985
+ * payload), throw a diagnostic that names WHY the passport refused — the throttle (`26161`, "too
26986
+ * frequent") vs a challenge it wants the client to satisfy. The passport marks a required captcha with
26987
+ * a `captcha_id`/`item`; our headless client cannot answer one, so surfacing it distinguishes "wait
26988
+ * out the rate-limit" from "a captcha is required — clear it in the app". No secrets are logged, only
26989
+ * the code, message, and which challenge fields are present.
26990
+ */
26991
+ assertLoginAccepted(env) {
26992
+ if (env.code === 0)
26993
+ return;
26994
+ const d = env.data ?? {};
26995
+ const hints = [];
26996
+ if (typeof d === "object" && d) {
26997
+ if ("captcha_id" in d && d.captcha_id)
26998
+ hints.push("captcha_id present (captcha required)");
26999
+ if ("item" in d && d.item)
27000
+ hints.push(`item=${String(d.item).slice(0, 40)}`);
27001
+ const keys = Object.keys(d);
27002
+ if (keys.length && hints.length === 0)
27003
+ hints.push(`data keys: ${keys.join(",")}`);
27004
+ }
27005
+ const detail = hints.length ? ` [${hints.join("; ")}]` : "";
27006
+ throw new Error(`Solix login (${env.code}): ${env.msg}${detail}`);
27007
+ }
27008
+ /** Complete a `2fa` login with the code the passport sent. */
27009
+ async submitVerifyCode(code) {
27010
+ if (!this.pending2fa)
27011
+ throw new Error("no 2FA login is pending");
27012
+ const kx = await this.keyExchange();
27013
+ const env = await this.postLogin(kx, code, this.pending2fa.limitedToken);
27014
+ return this.classifyLogin(this.decryptLogin(env, kx), true);
27015
+ }
27016
+ /**
27017
+ * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
27018
+ * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
27019
+ *
27020
+ * Self-heals a **displaced session**: Anker allows ~one session per account, so another login (the app,
27021
+ * or a second client) invalidates this token and reads then fail with {@link SOLIX_TOKEN_KICKED_CODE}
27022
+ * ("token does not exist because it was kicked out"). On that code this re-logs in once and retries, so
27023
+ * a running client recovers on its own instead of failing every read until its session store is cleared.
27024
+ */
27025
+ async authed(method2, path, body, reauthed = false) {
27026
+ if (!this.session_)
27027
+ throw new Error("not authenticated \u2014 call login() first");
27028
+ 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);
27029
+ if (env.code === 0)
27030
+ return env.data ?? null;
27031
+ if (env.code === SOLIX_TOKEN_KICKED_CODE && !reauthed) {
27032
+ this.session_ = void 0;
27033
+ const r = await this.login();
27034
+ if (r.status !== "ok")
27035
+ throw new Error(`Solix ${path}: session was kicked and re-login did not complete (${r.status})`);
27036
+ return this.authed(method2, path, body, true);
27037
+ }
27038
+ throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
27039
+ }
27040
+ /**
27041
+ * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
27042
+ * gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
27043
+ * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
27044
+ */
27045
+ async getDevices() {
27046
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
27047
+ return Array.isArray(data) ? data : data?.data ?? [];
27048
+ }
27049
+ /**
27050
+ * The account's sites (systems); devices are grouped under a site. Each record carries its
27051
+ * `site_device_list` (the member devices), which {@link discoverSolixSites} resolves into a
27052
+ * capability-driven `SolixSite`. Asserted to {@link SolixSiteRecord} at this trust boundary — and
27053
+ * `site_id` (the one field the model layer keys a `SolixSite` on) is validated here, so a record the
27054
+ * cloud returns without a usable id is dropped rather than surfacing a `SolixSite` with `id ===
27055
+ * undefined`; every other field is optional and read defensively.
27056
+ */
27057
+ async getSites() {
27058
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
27059
+ const list = data?.site_list ?? [];
27060
+ return list.filter((s) => typeof s?.site_id === "string" && s.site_id.length > 0);
27061
+ }
27062
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
27063
+ async getUserMqttInfo() {
27064
+ return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
27065
+ }
27066
+ /**
27067
+ * Read a site's "scene" snapshot — the app's dashboard read for a system, a plain authed read. Its
27068
+ * battery detail (`solarbank_info.solarbank_list[]`) carries clean, correctly-named fields including
27069
+ * `bat_temperature`, which the realtime `ff09` MQTT push does NOT reliably carry (the fast frame's BMS
27070
+ * blob is empty, so the decoder withholds temperature). This is therefore a low-rate BACKSTOP for those
27071
+ * gap fields — NOT the realtime source: live power/SOC still come from the MQTT push (which is what the
27072
+ * app itself refreshes from every ~5 s; there is no clean-JSON scene PUSH). Verified live against the
27073
+ * `ff09` floats — the two agree to the watt at the same instant.
27074
+ */
27075
+ async getSiteScene(siteId) {
27076
+ return this.authed("POST", SOLIX_ENDPOINTS.getSiteScene, { site_id: siteId });
27077
+ }
27078
+ /**
27079
+ * The pairable-product catalog (categories → products). This is Anker's product registry, not the
27080
+ * account's devices — fetch it to label a discovered device's model code with a marketing name and
27081
+ * category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
27082
+ * baked-in table.
27083
+ */
27084
+ async getProductCatalog() {
27085
+ return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
27086
+ }
27087
+ /**
27088
+ * Write device attributes — a CONTROL write, e.g. the Solarbank ambient light
27089
+ * `{ ambient_light_switch: 0 | 1 }` (0 = on, 1 = off). Unlike the plain authenticated reads, a write
27090
+ * must be **encrypted + signed** with a freshly negotiated `algo_ecdh` key: the gateway accepts an
27091
+ * unsigned write with `code 0` but the device never applies it. The token-bearing request also must
27092
+ * NOT carry the device id (`openudid`), or the gateway answers `401 token error`. Both verified live
27093
+ * on an AE103 (the LED-enable bit in the `ba` telemetry flips exactly as commanded).
27094
+ */
27095
+ async setDeviceAttrs(deviceSn, attributes) {
27096
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setDeviceAttrs, "set_device_attrs", {
27097
+ device_sn: deviceSn,
27098
+ attributes
27099
+ });
27100
+ }
27101
+ /**
27102
+ * A CONTROL write: `algo_ecdh`-encrypted + signed, token-bearing but WITHOUT `openudid`. Every device
27103
+ * control the account performs (set_device_attrs, set_power_cutoff, …) goes through this — the gateway
27104
+ * accepts an unsigned/plain write with `code 0` but the device never applies it, and adding `openudid`
27105
+ * to the token-bearing request returns `401 token error`. Both verified live on an AE103.
27106
+ */
27107
+ async encryptedWrite(path, label, payload) {
27108
+ if (!this.session_)
27109
+ throw new Error("not authenticated \u2014 call login() first");
27110
+ const kx = await this.keyExchange();
27111
+ const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
27112
+ const ts = nowSec();
27113
+ const once = genId();
27114
+ const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
27115
+ "x-encryption-info": "algo_ecdh",
27116
+ "x-key-ident": kx.keyIdent,
27117
+ "x-request-ts": ts,
27118
+ "x-request-once": once,
27119
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27120
+ "x-auth-token": this.session_.authToken,
27121
+ gtoken: this.session_.gtoken
27122
+ }));
27123
+ if (env.code !== 0)
27124
+ throw new Error(`Solix ${label} failed (${env.code}): ${env.msg}`);
27125
+ }
27126
+ /** Turn the Solarbank's ambient LED on/off — a confirmed `set_device_attrs` write. */
27127
+ async setAmbientLight(deviceSn, on) {
27128
+ await this.setDeviceAttrs(deviceSn, { ambient_light_switch: on ? 0 : 1 });
27129
+ }
27130
+ /**
27131
+ * Read device attributes — a plain authenticated read (unlike the encrypted write). `attributes`
27132
+ * names the keys to fetch (e.g. `["screen_off_time"]`); an empty list asks for the device's default
27133
+ * set. Returns the gateway's attribute map as-is (values are device-typed — numbers, strings). Used
27134
+ * to reflect a control's live state, e.g. the display/light off-timeout.
27135
+ */
27136
+ async getDeviceAttrs(deviceSn, attributes = []) {
27137
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getDeviceAttrs, { device_sn: deviceSn, attributes });
27138
+ if (data && typeof data === "object" && "attributes" in data && data.attributes) {
27139
+ return data.attributes;
27140
+ }
27141
+ return data ?? {};
27142
+ }
27143
+ /**
27144
+ * Set the Solarbank display's screen-off timeout, in SECONDS (`screen_off_time`). The app's picker
27145
+ * offers 10/20/30 s and 1/5/30 min; the LCD backlight — and with it the ambient LED that the screen
27146
+ * gates — turns off after this idle period. This is the raw-seconds write; the caller maps its own UI
27147
+ * options to seconds. The "Never" (always-on) sentinel is device-defined and NOT assumed here — pass
27148
+ * the exact integer read back from {@link getDeviceAttrs} while the device is in that mode.
27149
+ */
27150
+ async setScreenOffTime(deviceSn, seconds) {
27151
+ await this.setDeviceAttrs(deviceSn, { screen_off_time: seconds });
27152
+ }
27153
+ /**
27154
+ * Read the Solarbank's battery discharge-cutoff (minimum-SOC) options — a plain authed read.
27155
+ * The gateway returns a preset list (`power_cutoff_data`): each entry is a selectable minimum
27156
+ * state-of-charge `output_cutoff_data` (percent) with its `id` and `is_selected` flag. The caller
27157
+ * presents these options and writes the chosen `id` back via {@link setPowerCutoff} — the values and
27158
+ * ids come from the device, never assumed. `siteId` is optional (the device knows its own cutoff).
27159
+ */
27160
+ async getPowerCutoff(deviceSn, siteId = "") {
27161
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getPowerCutoff, { site_id: siteId, device_sn: deviceSn });
27162
+ return data?.power_cutoff_data ?? [];
27163
+ }
27164
+ /**
27165
+ * Select the Solarbank's battery discharge-cutoff (minimum SOC) by option id — a control write.
27166
+ * `cutoffDataId` MUST be an `id` returned by {@link getPowerCutoff} for this device (the preset the
27167
+ * user picked), never a raw percentage; the gateway maps the id to its cutoff percent.
27168
+ */
27169
+ async setPowerCutoff(deviceSn, cutoffDataId) {
27170
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setPowerCutoff, "set_power_cutoff", {
27171
+ device_sn: deviceSn,
27172
+ cutoff_data_id: cutoffDataId
27173
+ });
27174
+ }
27175
+ /** The `param_type` under which the Solarbank's SOC-limit block lives (verified live on an AE103). */
27176
+ static SOC_PARAM_TYPE = "27";
27177
+ /** `cmd` value that scopes the `site/*_site_device_param` family (from the app's request builder). */
27178
+ static SITE_DEVICE_PARAM_CMD = 246;
27179
+ /**
27180
+ * Read one of a site's "device param" blocks by `param_type` — a plain authenticated read whose
27181
+ * `data.param_data` is itself a JSON STRING (the vendor double-encodes it). Returns the parsed inner
27182
+ * object, or `{}` when the block is empty (the gateway answers `code 0` with an empty `param_data`
27183
+ * for a `param_type` that does not apply to the site's hardware). The caller owns the inner shape.
27184
+ */
27185
+ async getSiteDeviceParam(siteId, paramType) {
27186
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteDeviceParam, {
27187
+ site_id: siteId,
27188
+ param_type: paramType,
27189
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD
27190
+ });
27191
+ const raw = data?.param_data;
27192
+ if (!raw)
27193
+ return {};
27194
+ try {
27195
+ return JSON.parse(raw);
27196
+ } catch {
27197
+ return {};
27198
+ }
27199
+ }
27200
+ /**
27201
+ * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
27202
+ * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
27203
+ * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
27204
+ * app-synced source for `chargeUpperLimit`. `backupReserve` (with its enable switch) also has a second
27205
+ * source under the same name, the realtime MQTT `b5` frame; whether the two agree while the switch is
27206
+ * OFF is not yet verified. Verified live against a known AE103 setting (discharge 20 / charge 80).
27207
+ */
27208
+ async getSafetySocParams(siteId) {
27209
+ const p = await this.getSiteDeviceParam(siteId, _SolixClient.SOC_PARAM_TYPE);
27210
+ if (typeof p.charge_upper_limit !== "number" || typeof p.discharge_lower_limit !== "number") {
27211
+ return void 0;
27212
+ }
27213
+ return {
27214
+ chargeUpperLimit: p.charge_upper_limit,
27215
+ dischargeLowerLimit: p.discharge_lower_limit,
27216
+ backupReserve: typeof p.backup_reserve === "number" ? p.backup_reserve : 0,
27217
+ backupReserveSwitch: typeof p.backup_reserve_switch === "number" ? p.backup_reserve_switch : 0,
27218
+ socCalibrationEnable: typeof p.soc_calibration_enable === "number" ? p.soc_calibration_enable : 0
27219
+ };
27220
+ }
27221
+ /**
27222
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
27223
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
27224
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
27225
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
27226
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
27227
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
27228
+ */
27229
+ async setSafetySocParams(siteId, changes) {
27230
+ const current = await this.getSafetySocParams(siteId);
27231
+ if (!current)
27232
+ throw new Error(`Solix set SOC params: site has no param_type 27 block`);
27233
+ const merged = { ...current, ...changes };
27234
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setSiteDeviceParam, "set_site_device_param", {
27235
+ site_id: siteId,
27236
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD,
27237
+ param_type: _SolixClient.SOC_PARAM_TYPE,
27238
+ param_data: JSON.stringify({
27239
+ charge_upper_limit: merged.chargeUpperLimit,
27240
+ discharge_lower_limit: merged.dischargeLowerLimit,
27241
+ backup_reserve_switch: merged.backupReserveSwitch,
27242
+ backup_reserve: merged.backupReserve,
27243
+ soc_calibration_enable: merged.socCalibrationEnable
27244
+ })
27245
+ });
27246
+ return merged;
27247
+ }
27248
+ };
27249
+
27250
+ // dist/transport/mqtt/solix-mqtt.js
27251
+ import { EventEmitter as EventEmitter10 } from "node:events";
27252
+ var SOLIX_METER_FIELD_NAMES = {
27253
+ 168: "meterPowerL1",
27254
+ // Anker Modbus: primary_phase_1_active_power
27255
+ 169: "meterPowerL2",
27256
+ // Anker Modbus: primary_phase_2_active_power
27257
+ 170: "meterPowerL3",
27258
+ // Anker Modbus: primary_phase_3_active_power
27259
+ 171: "meterPowerTotal",
27260
+ // Anker Modbus: primary_total_active_power
27261
+ 172: "meterVoltageL1",
27262
+ // Anker Modbus: primary_phase_1_voltage
27263
+ 173: "meterVoltageL2",
27264
+ // Anker Modbus: primary_phase_2_voltage
27265
+ 174: "meterVoltageL3",
27266
+ // Anker Modbus: primary_phase_3_voltage
27267
+ 175: "meterCurrentL1",
27268
+ // Anker Modbus: primary_phase_1_current
27269
+ 176: "meterCurrentL2",
27270
+ // Anker Modbus: primary_phase_2_current
27271
+ 177: "meterCurrentL3",
27272
+ // Anker Modbus: primary_phase_3_current
27273
+ 179: "meterImportEnergy",
27274
+ // Anker Modbus: primary_total_forward_active_energy
27275
+ 180: "meterExportEnergy"
27276
+ // Anker Modbus: primary_total_reverse_active_energy
27277
+ };
27278
+ var SOLIX_METER_PRODUCT_PREFIXES = ["AE1X0"];
27279
+ var SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
27280
+ var SOLIX_SOLARBANK_FIELD_NAMES = {
27281
+ 171: "photovoltaicPower",
27282
+ // total PV input across the strings — Anker Modbus: pv_power
27283
+ 172: "batteryPower",
27284
+ // signed net pack power — Anker Modbus: battery_charging_power − battery_discharging_power
27285
+ 188: "chargePower",
27286
+ // Anker Modbus: battery_charging_power
27287
+ 173: "dischargePower",
27288
+ // Anker Modbus: battery_discharging_power
27289
+ 174: "acPlugPower",
27290
+ // AC plug, signed — Anker Modbus: ac_grid_output_power
27291
+ 175: "socketPower",
27292
+ // the unit's own on-board AC socket (an appliance plugged into the Solarbank) — no Anker register
27293
+ 196: "gridInputPower",
27294
+ // Anker Modbus: grid_import_power
27295
+ 197: "homeLoadPower",
27296
+ // Anker Modbus: load_power
27297
+ 198: "pv1Power",
27298
+ // the four PV-string inputs (0 when a string is unused / dark); Anker exposes only a pv_power total
27299
+ 199: "pv2Power",
27300
+ 200: "pv3Power",
27301
+ 201: "pv4Power"
27302
+ };
27303
+ var SOLIX_STATE_FIELD_NAMES = {
27304
+ 169: "mode",
27305
+ // current operating (EMS) mode, AE103 numbering: 1 custom, 2 self-consumption, 4 rapid charge, 7 smart, 8 dynamic tariff (NOT the Modbus SOLIX_MODBUS_EMS_MODES numbering)
27306
+ 170: "maxLoad"
27307
+ // configured max home load (W) — matches get_site_device_param max_load
27308
+ // NOTE `0xab` is grid-in/out-related power but its exact meaning is not yet pinned, so it stays raw
27309
+ // `state_ab` (a diagnostic a consumer can watch) rather than being asserted under a guessed name.
27310
+ };
27311
+ var SOLIX_MODBUS_EMS_MODES = {
27312
+ 0: "selfConsumption",
27313
+ // Self-Consumption Mode (Anker: self_consumption, 0x8006 BIT0)
27314
+ 1: "timeOfUse",
27315
+ // Time Of Use Mode (Anker: tou_mode, BIT1)
27316
+ 3: "thirdPartyControl",
27317
+ // Third-Party Controlled (Anker: third_party_control, BIT5)
27318
+ 4: "custom",
27319
+ // Custom Mode (Anker: custom_mode, BIT2)
27320
+ 5: "socketOverlay",
27321
+ // Socket Overlay Mode (Anker: socket_overlay_mode, BIT4)
27322
+ 6: "smart",
27323
+ // Smart Mode (Anker: smart_mode, BIT3)
27324
+ 7: "dynamicTariff"
27325
+ // Dynamic Tariff Mode (Anker: dynamic_pricing, BIT6)
27326
+ };
27327
+ function solixStateReadings(frame) {
27328
+ const out = {};
27329
+ for (const [tag2, value] of frame.fields) {
27330
+ if (tag2 < 165 || !value || value.length < 2)
27331
+ continue;
27332
+ const type = value[0];
27333
+ const pl = value.subarray(1);
27334
+ let num2;
27335
+ if (type === 5 && pl.length >= 4)
27336
+ num2 = pl.readFloatLE(0);
27337
+ else if (type === 2 && pl.length >= 2)
27338
+ num2 = pl.readUInt16LE(0);
27339
+ else if (type === 1 && pl.length >= 1)
27340
+ num2 = pl[0];
27341
+ else if (type === 3 && pl.length >= 2)
27342
+ num2 = pl[1];
27343
+ if (num2 === void 0)
27344
+ continue;
27345
+ out[`state_${tag2.toString(16)}`] = num2;
27346
+ const name = SOLIX_STATE_FIELD_NAMES[tag2];
27347
+ if (name)
27348
+ out[name] = num2;
27349
+ }
27350
+ return out;
27351
+ }
27352
+ function readSolixChannel(value) {
27353
+ if (!value || value.length < 1)
27354
+ return void 0;
27355
+ const raw = value.subarray(1);
27356
+ const ch = { type: value[0], raw };
27357
+ if (raw.length === 4) {
27358
+ ch.float = raw.readFloatLE(0);
27359
+ ch.uint = raw.readUInt32LE(0);
27360
+ }
27361
+ return ch;
27362
+ }
27363
+ function decodeSolixParamFrame(buf) {
27364
+ if (buf.length < 10 || buf[0] !== 255 || buf[1] !== 9)
27365
+ return null;
27366
+ const declaredLen = buf.readUInt16LE(2);
27367
+ if (declaredLen < 5 || declaredLen > buf.length)
27368
+ return null;
27369
+ let xor = 0;
27370
+ for (let i = 0; i < declaredLen; i++)
27371
+ xor ^= buf[i];
27372
+ if (xor !== 0)
27373
+ return null;
27374
+ const end = declaredLen - 1;
27375
+ const start = buf.indexOf(161, 4);
27376
+ if (start < 0 || start >= end)
27377
+ return { fields: /* @__PURE__ */ new Map() };
27378
+ const fields = walkFf09Tlv(buf, start, end);
27379
+ let deviceSn;
27380
+ const a2 = fields.get(162);
27381
+ if (a2 && a2.length > 1)
27382
+ deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
27383
+ return { deviceSn, fields };
27384
+ }
27385
+ function solixReadings(frame, productCode) {
27386
+ const out = {};
27387
+ const isMeter = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
27388
+ const isSolarbank = productCode.startsWith(SOLIX_SOLARBANK_PRODUCT_PREFIX);
27389
+ const floatNames = isMeter ? SOLIX_METER_FIELD_NAMES : isSolarbank ? SOLIX_SOLARBANK_FIELD_NAMES : void 0;
27390
+ for (const [tag2, value] of frame.fields) {
27391
+ if (tag2 < 166)
27392
+ continue;
27393
+ const ch = readSolixChannel(value);
27394
+ if (ch?.type !== 5 || ch.float === void 0)
27395
+ continue;
27396
+ out[`channel_${tag2.toString(16)}`] = ch.float;
27397
+ const name = floatNames?.[tag2];
27398
+ if (name)
27399
+ out[name] = ch.float;
27400
+ }
27401
+ if (isSolarbank)
27402
+ addSolarbankScalars(frame, out);
27403
+ return out;
27404
+ }
27405
+ function addSolarbankScalars(frame, out) {
27406
+ const a3 = frame.fields.get(163);
27407
+ const soc = a3 && a3.length >= 2 ? a3[1] : void 0;
27408
+ if (soc !== void 0) {
27409
+ out.batterySoc = soc;
27410
+ const body = frame.fields.get(164)?.subarray(1);
27411
+ if (body && body.length >= 8 && body[body.length - 6] === soc) {
27412
+ out.batteryTemperature = body[body.length - 8];
27413
+ out.batteryHealth = body[body.length - 5];
27414
+ }
27415
+ }
27416
+ const b5 = frame.fields.get(181);
27417
+ if (b5 && b5[0] === 4 && b5.length === 4) {
27418
+ out.dischargeLimit = b5[1];
27419
+ out.chargeLimit = b5[3];
27420
+ } else if (b5 && b5[0] === 4 && b5.length === 25) {
27421
+ out.backupReserve = b5[1];
27422
+ }
27423
+ const df = frame.fields.get(223);
27424
+ if (df && df[0] === 4 && df.length >= 7) {
27425
+ out.gridImportLimit = df.readUInt16LE(3);
27426
+ out.gridExportLimit = df.readUInt16LE(5);
27427
+ }
27428
+ }
27429
+ var SolixMqtt = class extends EventEmitter10 {
27430
+ transport;
27431
+ appName;
27432
+ userId;
27433
+ appClientId;
27434
+ armIntervalMs;
27435
+ logger;
27436
+ siteId;
27437
+ watched = /* @__PURE__ */ new Map();
27438
+ seq = 0;
27439
+ armTimer;
27440
+ /**
27441
+ * Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
27442
+ * (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
27443
+ * restart presents itself to the broker as a new client, so it defaults deterministically from the user
27444
+ * id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
27445
+ */
27446
+ constructor(opts) {
27447
+ super();
27448
+ this.appName = opts.mqttInfo.app_name ?? "anker_power";
27449
+ this.userId = opts.userId ?? opts.mqttInfo.user_id;
27450
+ this.armIntervalMs = opts.armIntervalMs ?? 25e3;
27451
+ this.siteId = opts.siteId;
27452
+ this.logger = opts.logger;
27453
+ const uid = this.userId ?? "anonymous";
27454
+ this.appClientId = opts.appClientId ?? buildAppShapedClientId({
27455
+ appName: this.appName,
27456
+ uid,
27457
+ mqttUuid: opts.mqttUuid ?? mqttUuidFrom(`anker-solix-mqtt:${uid}`)
27458
+ });
27459
+ this.transport = new SecureMqtt({
27460
+ credentials: opts.mqttInfo,
27461
+ clientId: opts.clientId ?? opts.mqttInfo.thing_name,
27462
+ reconnectPeriod: 5e3,
27463
+ logger: opts.logger
27464
+ });
27465
+ this.transport.on("error", (e) => this.emit("error", e));
27466
+ this.transport.on("message", (msg) => this.onMessage(msg));
27467
+ }
27468
+ /**
27469
+ * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
27470
+ * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
27471
+ *
27472
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
27473
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
27474
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
27475
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
27476
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
27477
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
27478
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
27479
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
27480
+ *
27481
+ * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
27482
+ * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
27483
+ * success: the call would resolve and arm on every interval while no reading ever arrives.
27484
+ *
27485
+ * The re-arm timer is unreffed, so a caller that watches and returns can still exit.
27486
+ */
27487
+ async watch(device) {
27488
+ await this.transport.connect();
27489
+ const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
27490
+ const granted = await this.transport.subscribe([
27491
+ topics.paramInfo,
27492
+ topics.stateInfo,
27493
+ topics.cmdRes,
27494
+ topics.req,
27495
+ ...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
27496
+ ]);
27497
+ if (!granted.includes(topics.paramInfo)) {
27498
+ const scope = this.appName;
27499
+ 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`);
27500
+ }
27501
+ this.watched.set(device.device_sn, device);
27502
+ if (this.armIntervalMs > 0) {
27503
+ await this.armAll();
27504
+ if (!this.armTimer) {
27505
+ this.armTimer = setInterval(() => void this.armAll(), this.armIntervalMs);
27506
+ this.armTimer.unref?.();
27507
+ }
27508
+ }
27509
+ }
27510
+ /** Tear down the connection and stop the re-arm timer. */
27511
+ async close() {
27512
+ if (this.armTimer) {
27513
+ clearInterval(this.armTimer);
27514
+ this.armTimer = void 0;
27515
+ }
27516
+ this.watched.clear();
27517
+ await this.transport.disconnect();
27518
+ }
27519
+ /**
27520
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
27521
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
27522
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
27523
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
27524
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
27525
+ */
27526
+ async setDisplayTimeout(device, index) {
27527
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27528
+ const body = this.commandEnvelope(device, buildDisplayTimeoutFrame(index), {});
27529
+ await this.transport.publish(topic, body, { qos: 1 });
27530
+ this.logger?.debug?.(`[solix] display timeout set index=${index} on ${device.device_sn}`);
27531
+ }
27532
+ /**
27533
+ * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
27534
+ * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
27535
+ * heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
27536
+ * (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
27537
+ * not thrown, so one bad device doesn't stop the rest or kill the timer.
27538
+ */
27539
+ async armAll() {
27540
+ for (const device of this.watched.values()) {
27541
+ try {
27542
+ await this.arm(device);
27543
+ } catch (e) {
27544
+ this.emit("error", e);
27545
+ }
27546
+ }
27547
+ if (this.userId) {
27548
+ try {
27549
+ await this.transport.publish(solixUserTopics(this.appName, this.userId).powerSite, this.heartbeatEnvelope(), {
27550
+ qos: 1
27551
+ });
27552
+ } catch (e) {
27553
+ this.emit("error", e);
27554
+ }
27555
+ }
27556
+ }
27557
+ /** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
27558
+ async arm(device) {
27559
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27560
+ for (const variant of ["info", "realtime"]) {
27561
+ const body = this.commandEnvelope(device, buildFf09Request(variant), variant === "info" ? { encoding_type: 2 } : {});
27562
+ await this.transport.publish(topic, body, { qos: 1 });
27563
+ }
27564
+ this.logger?.debug?.(`[solix] armed ${device.device_sn} (param_info reporting requested)`);
27565
+ }
27566
+ /** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
27567
+ makeHead(cmd, extra) {
27568
+ return {
27569
+ version: "1.0.0.1",
27570
+ client_id: this.appClientId,
27571
+ timestamp: Math.floor(Date.now() / 1e3),
27572
+ cmd_status: 2,
27573
+ sign_code: 1,
27574
+ cmd,
27575
+ ...extra
27576
+ };
27577
+ }
27578
+ /**
27579
+ * Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
27580
+ * `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
27581
+ * from a real value, so sending `""` would claim an account this client does not have.
27582
+ */
27583
+ commandEnvelope(device, frame, extra) {
27584
+ this.seq += 1;
27585
+ return JSON.stringify({
27586
+ head: this.makeHead(17, {
27587
+ sess_id: genId(),
27588
+ msg_seq: this.seq,
27589
+ seed: genId(),
27590
+ device_pn: device.product_code,
27591
+ device_sn: device.device_sn
27592
+ }),
27593
+ payload: JSON.stringify({
27594
+ device_sn: device.device_sn,
27595
+ ...this.userId ? { account_id: this.userId } : {},
27596
+ data: frame.toString("base64"),
27597
+ ...extra
27598
+ })
27599
+ });
27600
+ }
27601
+ /**
27602
+ * The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
27603
+ * `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
27604
+ */
27605
+ heartbeatEnvelope() {
27606
+ return JSON.stringify({
27607
+ head: this.makeHead(10, { sess_id: "1", msg_seq: 1, seed: "1" }),
27608
+ payload: JSON.stringify({ user_id: this.userId ?? "", ...this.siteId ? { site_id: this.siteId } : {} })
27609
+ });
27610
+ }
27611
+ /**
27612
+ * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
27613
+ * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
27614
+ * own `a2` field wins for the serial when it carries one.
27615
+ *
27616
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
27617
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
27618
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
27619
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
27620
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
27621
+ */
27622
+ onMessage(msg) {
27623
+ const topic = msg.topic ?? "";
27624
+ const buf = extractFf09Payload(msg.raw);
27625
+ if (!buf)
27626
+ return;
27627
+ if (topic.endsWith("/req")) {
27628
+ this.handleCommand(topic, buf);
27629
+ return;
27630
+ }
27631
+ const frame = decodeSolixParamFrame(buf);
27632
+ if (!frame)
27633
+ return;
27634
+ const parts = topic.split("/");
27635
+ const productCode = parts[2] ?? "";
27636
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27637
+ if (!this.watched.has(deviceSn)) {
27638
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27639
+ if (ofProduct.length === 1)
27640
+ deviceSn = ofProduct[0].device_sn;
27641
+ }
27642
+ const values = topic.endsWith("/state_info") ? solixStateReadings(frame) : (
27643
+ // Pass the product code so meter tag→name binding is applied only to a meter frame; a Solarbank's
27644
+ // tags stay raw channel_<hex> (the model names them per capability) rather than being mislabelled.
27645
+ solixReadings(frame, productCode)
27646
+ );
27647
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27648
+ }
27649
+ /**
27650
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
27651
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
27652
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
27653
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
27654
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
27655
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
27656
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
27657
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
27658
+ */
27659
+ handleCommand(topic, buf) {
27660
+ if (buf.length < 10 || buf[8] !== 104)
27661
+ return;
27662
+ const frame = decodeSolixParamFrame(buf);
27663
+ if (!frame)
27664
+ return;
27665
+ const values = {};
27666
+ const a4 = frame.fields.get(164);
27667
+ if (a4 && a4.length >= 2)
27668
+ values.ambientLightOn = a4[1] === 0 ? 1 : 0;
27669
+ const a5 = frame.fields.get(165);
27670
+ if (a5 && a5.length >= 2)
27671
+ values.displayTimeoutIndex = a5[1];
27672
+ if (Object.keys(values).length === 0)
27673
+ return;
27674
+ const parts = topic.split("/");
27675
+ const productCode = parts[2] ?? "";
27676
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27677
+ if (!this.watched.has(deviceSn)) {
27678
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27679
+ if (ofProduct.length === 1)
27680
+ deviceSn = ofProduct[0].device_sn;
27681
+ }
27682
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27683
+ }
27684
+ };
27685
+ function extractFf09Payload(raw) {
27686
+ if (Buffer.isBuffer(raw))
27687
+ return raw;
27688
+ if (!raw || typeof raw !== "object")
27689
+ return null;
27690
+ const env = raw;
27691
+ let payload = env.payload;
27692
+ if (typeof payload === "string") {
27693
+ try {
27694
+ payload = JSON.parse(payload);
27695
+ } catch {
27696
+ return null;
27697
+ }
27698
+ }
27699
+ const p = payload;
27700
+ const data = p?.data ?? p?.trans ?? env.data;
27701
+ if (typeof data !== "string")
27702
+ return null;
27703
+ const buf = Buffer.from(data, "base64");
27704
+ return buf.length ? buf : null;
27705
+ }
27706
+ function buildDisplayTimeoutFrame(index) {
27707
+ const body = Buffer.from([3, 0, 15, 0, 104, 161, 1, 34, 165, 2, 1, index & 255]);
27708
+ const frame = Buffer.alloc(body.length + 5);
27709
+ frame[0] = 255;
27710
+ frame[1] = 9;
27711
+ frame.writeUInt16LE(frame.length, 2);
27712
+ body.copy(frame, 4);
27713
+ let xor = 0;
27714
+ for (let i = 0; i < frame.length - 1; i++)
27715
+ xor ^= frame[i];
27716
+ frame[frame.length - 1] = xor;
27717
+ return frame;
27718
+ }
27719
+ function buildFf09Request(variant, atUnixSec) {
27720
+ const ts = Buffer.alloc(4);
27721
+ ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
27722
+ const body = variant === "info" ? Buffer.concat([Buffer.from([3, 0, 15, 0, 64, 161, 1, 34, 254, 4]), ts]) : Buffer.concat([
27723
+ Buffer.from([
27724
+ 3,
27725
+ 0,
27726
+ 15,
27727
+ 0,
27728
+ 87,
27729
+ 161,
27730
+ 1,
27731
+ 34,
27732
+ 162,
27733
+ 2,
27734
+ 1,
27735
+ 1,
27736
+ 163,
27737
+ 3,
27738
+ 2,
27739
+ 44,
27740
+ 1,
27741
+ 254,
27742
+ 5,
27743
+ 3
27744
+ ]),
27745
+ ts
27746
+ ]);
27747
+ const frame = Buffer.alloc(body.length + 5);
27748
+ frame[0] = 255;
27749
+ frame[1] = 9;
27750
+ frame.writeUInt16LE(frame.length, 2);
27751
+ body.copy(frame, 4);
27752
+ let xor = 0;
27753
+ for (let i = 0; i < frame.length - 1; i++)
27754
+ xor ^= frame[i];
27755
+ frame[frame.length - 1] = xor;
27756
+ return frame;
27757
+ }
27758
+
25574
27759
  // dist/transport/tuya/index.js
25575
27760
  var tuya_exports = {};
25576
27761
  __export(tuya_exports, {
@@ -25622,6 +27807,7 @@ export {
25622
27807
  CLEAN_EXTENTS,
25623
27808
  CLEAN_FINISH_REASONS,
25624
27809
  CLEAN_PARAMS,
27810
+ CONNECT_TIMEOUT_MS,
25625
27811
  CONTACT_MEMBERS,
25626
27812
  CO_MEMBERS,
25627
27813
  CameraDisabledError,
@@ -25637,6 +27823,7 @@ export {
25637
27823
  DOCK_KINDS,
25638
27824
  DOORBELL_MEMBERS,
25639
27825
  Device,
27826
+ DeviceChannelUnresolvedError,
25640
27827
  DoorbellPushEvent,
25641
27828
  DoorbellRingtone,
25642
27829
  EMPTY_CLEAN_RECORD_PAGE,
@@ -25706,6 +27893,9 @@ export {
25706
27893
  SIREN_MEMBERS,
25707
27894
  SMART_LIGHT_MEMBERS,
25708
27895
  SMOKE_MEMBERS,
27896
+ SOLIX_ENERGY_METER_MEMBERS,
27897
+ SOLIX_LOCAL_KEY_HEX,
27898
+ SOLIX_MODBUS_EMS_MODES,
25709
27899
  STATE_EVENT_FIELDS,
25710
27900
  STATION_CHANNEL3 as STATION_CHANNEL,
25711
27901
  STATION_CHUNK_BYTES,
@@ -25718,8 +27908,11 @@ export {
25718
27908
  SirenAlarmDuration,
25719
27909
  SirenVolume,
25720
27910
  SmartDropPushEvent,
27911
+ SolixClient,
27912
+ SolixDevice,
27913
+ SolixMqtt,
27914
+ SolixSite,
25721
27915
  StateConvergenceError,
25722
- StationBusyError,
25723
27916
  StationKeyUnavailableError,
25724
27917
  StationUnreachableError,
25725
27918
  StoredSnapshotUnavailableError,
@@ -25744,7 +27937,6 @@ export {
25744
27937
  aesKey,
25745
27938
  asBool,
25746
27939
  assertNever,
25747
- autoContrast,
25748
27940
  bizChannelName,
25749
27941
  buildActions,
25750
27942
  buildAppShapedClientId,
@@ -25752,9 +27944,11 @@ export {
25752
27944
  buildDeviceNameBody,
25753
27945
  buildDirectBinaryBody,
25754
27946
  buildEventIndex,
27947
+ buildModelIndex,
25755
27948
  buildRealtimeInit,
25756
27949
  captureSnapshotFromShared,
25757
27950
  cellAtPoint,
27951
+ claimedParams,
25758
27952
  clamp,
25759
27953
  classify,
25760
27954
  classifyDevice,
@@ -25789,6 +27983,8 @@ export {
25789
27983
  detectCapabilities,
25790
27984
  detectionName,
25791
27985
  discoverReachableInstance,
27986
+ discoverSolixDevices,
27987
+ discoverSolixSites,
25792
27988
  encodeAiDetectType,
25793
27989
  encodeVarint,
25794
27990
  encryptBody,
@@ -25817,6 +28013,9 @@ export {
25817
28013
  isNotAuthorized,
25818
28014
  isPrivateIpv4,
25819
28015
  isSessionValid,
28016
+ isSolixPowerStation,
28017
+ isSolixSmartMeter,
28018
+ isSolixSolarbank,
25820
28019
  isV1Image,
25821
28020
  isV2Image,
25822
28021
  jpegGeometry,
@@ -25826,6 +28025,7 @@ export {
25826
28025
  lz4BlockDecompress,
25827
28026
  mapCellValue,
25828
28027
  mapCellValueAt,
28028
+ md5Hex,
25829
28029
  mergeCandidateIps,
25830
28030
  mergeProperties,
25831
28031
  mqttAppName,
@@ -25870,9 +28070,14 @@ export {
25870
28070
  secureTopic,
25871
28071
  signKey,
25872
28072
  signRequest,
28073
+ solarbankSceneReadings,
28074
+ solixDeviceTopics,
28075
+ solixProductFamily,
28076
+ solixUserTopics,
25873
28077
  structuralEqual,
25874
28078
  subscribeTopics,
25875
28079
  suctionLevelName,
28080
+ tokenNotExpired,
25876
28081
  tuya_exports as tuya,
25877
28082
  u16be,
25878
28083
  u16le,