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

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 (51) hide show
  1. package/dist/client/eufy-mega.d.ts +16 -11
  2. package/dist/core/contracts.d.ts +57 -27
  3. package/dist/core/crypto.d.ts +10 -0
  4. package/dist/core/index.d.ts +1 -0
  5. package/dist/core/logger.d.ts +5 -3
  6. package/dist/core/solix-types.d.ts +115 -0
  7. package/dist/core/store.d.ts +38 -10
  8. package/dist/index.js +2635 -425
  9. package/dist/index.js.map +4 -4
  10. package/dist/model/capabilities/access.d.ts +22 -3
  11. package/dist/model/capabilities/arming.d.ts +33 -27
  12. package/dist/model/capabilities/battery.d.ts +32 -4
  13. package/dist/model/capabilities/contact.d.ts +4 -0
  14. package/dist/model/capabilities/display.d.ts +85 -0
  15. package/dist/model/capabilities/index.d.ts +18 -3
  16. package/dist/model/capabilities/ptz.d.ts +6 -2
  17. package/dist/model/capabilities/solix.d.ts +173 -0
  18. package/dist/model/capabilities/types.d.ts +21 -5
  19. package/dist/model/capabilities/vacuum-clean.d.ts +74 -25
  20. package/dist/model/device.d.ts +15 -0
  21. package/dist/model/index.d.ts +5 -0
  22. package/dist/model/param-dictionary.d.ts +24 -0
  23. package/dist/model/param-namespace.d.ts +1 -1
  24. package/dist/model/solix-catalog.d.ts +25 -0
  25. package/dist/model/solix-device.d.ts +136 -0
  26. package/dist/model/solix-family.d.ts +31 -0
  27. package/dist/model/solix-site.d.ts +70 -0
  28. package/dist/model/types.d.ts +5 -5
  29. package/dist/transport/ff09.d.ts +7 -0
  30. package/dist/transport/http/decodeImageV2.d.ts +8 -14
  31. package/dist/transport/http/index.d.ts +1 -0
  32. package/dist/transport/http/jpeg-scan.d.ts +59 -0
  33. package/dist/transport/http/media-download.d.ts +3 -0
  34. package/dist/transport/http/mega-client.d.ts +39 -6
  35. package/dist/transport/http/solix-client.d.ts +269 -0
  36. package/dist/transport/http/solix-constants.d.ts +56 -0
  37. package/dist/transport/media-failure.d.ts +48 -0
  38. package/dist/transport/mqtt/index.d.ts +2 -0
  39. package/dist/transport/mqtt/secure-mqtt.d.ts +14 -1
  40. package/dist/transport/mqtt/solix-mqtt.d.ts +321 -0
  41. package/dist/transport/mqtt/topics.d.ts +30 -0
  42. package/dist/transport/p2p/command-router.d.ts +152 -15
  43. package/dist/transport/p2p/index.d.ts +1 -0
  44. package/dist/transport/p2p/live-stream.d.ts +5 -4
  45. package/dist/transport/p2p/live-trace.d.ts +118 -6
  46. package/dist/transport/p2p/media.d.ts +11 -0
  47. package/dist/transport/p2p/p2p-session.d.ts +13 -0
  48. package/dist/transport/p2p/session-manager.d.ts +57 -23
  49. package/dist/transport/p2p/shared-live-source.d.ts +10 -1
  50. package/dist/transport/stored-image-cache.d.ts +7 -1
  51. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@ import { createCipheriv, createDecipheriv, createECDH, createHash, createHmac, r
15
15
  var P256 = "prime256v1";
16
16
  var EUFY_MEGA_LOCAL_KEY_HEX = "2500a7d5617812f9d52515b2c8f20a3d";
17
17
  var EUFYLIFE_LOCAL_KEY_HEX = "118c12c81e211149304bd70a0c071d01";
18
+ var SOLIX_LOCAL_KEY_HEX = "e8ad18f61bbd3fbd52d5ed12d14d3b9c";
18
19
  var SERVER_STATIC_PUBLIC_KEY_HEX = "04c5c00c4f8d1197cc7c3167c52bf7acb054d722f0ef08dcd7e0883236e0d72a3868d9750cb47fa4619248f3d83f0f662671dadc6e2d31c2f41db0161651c7c076";
19
20
  function genId() {
20
21
  return randomBytes(16).toString("hex");
@@ -22,8 +23,11 @@ function genId() {
22
23
  function nowSec() {
23
24
  return Math.floor(Date.now() / 1e3).toString();
24
25
  }
26
+ function md5Hex(input) {
27
+ return createHash("md5").update(input, "utf-8").digest("hex");
28
+ }
25
29
  function gtoken(userId) {
26
- return createHash("md5").update(userId, "utf-8").digest("hex");
30
+ return md5Hex(userId);
27
31
  }
28
32
  function aesKey(shareKeyHex) {
29
33
  return Buffer.from(shareKeyHex, "hex").subarray(0, 16);
@@ -142,14 +146,17 @@ var FileSessionStore = class {
142
146
  }
143
147
  }
144
148
  };
145
- function isSessionValid(s, skewSec = 300) {
146
- if (!s?.authToken || !s.shareKey || !s.keyIdent)
147
- return false;
148
- if (s.tokenExpiresAt && s.tokenExpiresAt > 0) {
149
- return Math.floor(Date.now() / 1e3) < s.tokenExpiresAt - skewSec;
149
+ function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
150
+ if (tokenExpiresAt && tokenExpiresAt > 0) {
151
+ return Math.floor(Date.now() / 1e3) < tokenExpiresAt - skewSec;
150
152
  }
151
153
  return true;
152
154
  }
155
+ function isSessionValid(s, skewSec = 300) {
156
+ if (!s?.authToken || !s.shareKey || !s.keyIdent || !s.accountUserId)
157
+ return false;
158
+ return tokenNotExpired(s.tokenExpiresAt, skewSec);
159
+ }
153
160
 
154
161
  // dist/core/logger.js
155
162
  var noopLogger = {
@@ -210,18 +217,33 @@ BLOCKED_ADDRESSES.addAddress("::1", "ipv6");
210
217
  BLOCKED_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
211
218
  BLOCKED_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
212
219
  var MediaDownloadError = class extends Error {
220
+ mediaFailure;
221
+ status;
222
+ constructor(message, mediaFailure, status) {
223
+ super(message);
224
+ this.name = "MediaDownloadError";
225
+ this.mediaFailure = mediaFailure;
226
+ if (status !== void 0)
227
+ this.status = status;
228
+ }
213
229
  };
230
+ function mediaFailureError(message, reason, cause) {
231
+ const error = new MediaDownloadError(message, reason);
232
+ if (cause !== void 0)
233
+ error.cause = cause;
234
+ return error;
235
+ }
214
236
  var MediaDownloadAuthenticationError = class extends Error {
215
237
  };
216
- function allowedMediaUrl(value, hostPattern) {
238
+ function allowedMediaUrl(value, hostPattern, reason) {
217
239
  let url;
218
240
  try {
219
241
  url = new URL(value);
220
242
  } catch {
221
- throw new MediaDownloadError("Media download rejected");
243
+ throw new MediaDownloadError("Media download rejected", reason);
222
244
  }
223
245
  if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "" || isIP(url.hostname) !== 0 || !hostPattern.test(url.hostname)) {
224
- throw new MediaDownloadError("Media download rejected");
246
+ throw new MediaDownloadError("Media download rejected", reason);
225
247
  }
226
248
  return url;
227
249
  }
@@ -236,7 +258,7 @@ async function assertPublicResolution(url, resolveHost2, signal) {
236
258
  try {
237
259
  addresses = await Promise.race([resolveHost2(url.hostname), aborted]);
238
260
  } catch {
239
- throw new MediaDownloadError("Media download rejected");
261
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
240
262
  } finally {
241
263
  signal.removeEventListener("abort", onAbort);
242
264
  }
@@ -244,14 +266,14 @@ async function assertPublicResolution(url, resolveHost2, signal) {
244
266
  const type = family === 6 ? "ipv6" : family === 4 ? "ipv4" : void 0;
245
267
  return type === void 0 || BLOCKED_ADDRESSES.check(address, type);
246
268
  })) {
247
- throw new MediaDownloadError("Media download rejected");
269
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
248
270
  }
249
271
  }
250
272
  var resolveHost = (hostname) => lookup(hostname, { all: true, verbatim: true });
251
273
  async function readMediaBody(response) {
252
274
  const contentLength = response.headers.get("content-length");
253
275
  if (contentLength && /^\d+$/.test(contentLength) && BigInt(contentLength) > BigInt(MEDIA_DOWNLOAD_MAX_BYTES)) {
254
- throw new MediaDownloadError("Media download rejected");
276
+ throw new MediaDownloadError("Media download rejected", "too-large");
255
277
  }
256
278
  if (!response.body)
257
279
  return Buffer.alloc(0);
@@ -265,14 +287,14 @@ async function readMediaBody(response) {
265
287
  bytes2 += value.byteLength;
266
288
  if (bytes2 > MEDIA_DOWNLOAD_MAX_BYTES) {
267
289
  void reader2.cancel().catch(() => void 0);
268
- throw new MediaDownloadError("Media download rejected");
290
+ throw new MediaDownloadError("Media download rejected", "too-large");
269
291
  }
270
292
  chunks.push(value);
271
293
  }
272
294
  return Buffer.concat(chunks, bytes2);
273
295
  }
274
296
  async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetch, resolver = resolveHost) {
275
- const original = allowedMediaUrl(url, EUFY_MEDIA_HOST);
297
+ const original = allowedMediaUrl(url, EUFY_MEDIA_HOST, "url-not-allowed");
276
298
  const controller = new AbortController();
277
299
  const timeout = setTimeout(() => controller.abort(), MEDIA_DOWNLOAD_TIMEOUT_MS);
278
300
  try {
@@ -285,31 +307,33 @@ async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetc
285
307
  if (originalResponse.status >= 300 && originalResponse.status < 400) {
286
308
  const location = originalResponse.headers.get("location");
287
309
  if (!location)
288
- throw new MediaDownloadError("Media download rejected");
289
- const target = allowedMediaUrl(location, EUFY_OBJECT_HOST);
310
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
311
+ const target = allowedMediaUrl(location, EUFY_OBJECT_HOST, "redirect-not-allowed");
290
312
  await assertPublicResolution(target, resolver, controller.signal);
291
313
  const redirectedResponse = await fetchImpl(target, { redirect: "manual", signal: controller.signal });
292
314
  if (redirectedResponse.status >= 300 && redirectedResponse.status < 400) {
293
- throw new MediaDownloadError("Media download rejected");
315
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
316
+ }
317
+ if (redirectedResponse.status !== 200) {
318
+ throw new MediaDownloadError("Media download failed", "http-status", redirectedResponse.status);
294
319
  }
295
- if (redirectedResponse.status !== 200)
296
- throw new MediaDownloadError("Media download failed");
297
320
  return await readMediaBody(redirectedResponse);
298
321
  }
299
322
  if (originalResponse.status === 401 || originalResponse.status === 403) {
300
323
  throw new MediaDownloadAuthenticationError("Media authentication failed");
301
324
  }
302
- if (originalResponse.status !== 200)
303
- throw new MediaDownloadError("Media download failed");
325
+ if (originalResponse.status !== 200) {
326
+ throw new MediaDownloadError("Media download failed", "http-status", originalResponse.status);
327
+ }
304
328
  return await readMediaBody(originalResponse);
305
329
  } catch (error) {
306
330
  if (controller.signal.aborted)
307
- throw new MediaDownloadError("Media download timed out");
331
+ throw new MediaDownloadError("Media download timed out", "timeout");
308
332
  if (error instanceof MediaDownloadAuthenticationError)
309
333
  throw error;
310
334
  if (error instanceof MediaDownloadError)
311
335
  throw error;
312
- throw new MediaDownloadError("Media download failed");
336
+ throw new MediaDownloadError("Media download failed", "network");
313
337
  } finally {
314
338
  clearTimeout(timeout);
315
339
  }
@@ -368,8 +392,228 @@ function randomUserAgent(seed, model) {
368
392
  // dist/transport/http/decodeImageV1.js
369
393
  import { createDecipheriv as createDecipheriv2, createHash as createHash2 } from "node:crypto";
370
394
 
395
+ // dist/transport/http/jpeg-scan.js
396
+ var DHT = 196;
397
+ var SOS = 218;
398
+ var DRI = 221;
399
+ var RST_FIRST = 208;
400
+ var RST_LAST = 215;
401
+ var LUMA_SAMPLING = { 0: [1, 1], 1: [2, 1], 2: [2, 2] };
402
+ var ScanEnd = class extends Error {
403
+ };
404
+ function readHuffmanTables(segment, into) {
405
+ const end = 2 + (segment[2] << 8 | segment[3]);
406
+ let at = 4;
407
+ while (at < end) {
408
+ const id = segment[at];
409
+ const counts = segment.subarray(at + 1, at + 17);
410
+ let total = 0;
411
+ for (const count2 of counts)
412
+ total += count2;
413
+ const values = segment.slice(at + 17, at + 17 + total);
414
+ const minCode = new Int32Array(17);
415
+ const maxCode = new Int32Array(17).fill(-1);
416
+ const valPtr = new Int32Array(17);
417
+ let code = 0;
418
+ let index = 0;
419
+ for (let length = 1; length <= 16; length++) {
420
+ const count2 = counts[length - 1];
421
+ if (count2 > 0) {
422
+ valPtr[length] = index;
423
+ minCode[length] = code;
424
+ code += count2;
425
+ index += count2;
426
+ maxCode[length] = code - 1;
427
+ }
428
+ code <<= 1;
429
+ }
430
+ into.set(id, { minCode, maxCode, valPtr, values });
431
+ at += 17 + total;
432
+ }
433
+ }
434
+ var BitReader = class {
435
+ /** Byte offset of the next byte to read. */
436
+ position;
437
+ bits = 0;
438
+ count = 0;
439
+ /** Set when the reader stepped over a restart marker — the caller resets its DC predictors. */
440
+ restarted = false;
441
+ data;
442
+ constructor(data, start) {
443
+ this.data = data;
444
+ this.position = start;
445
+ }
446
+ /** The offset the last whole byte ended at — what "how much of the scan did this consume" reads. */
447
+ get consumed() {
448
+ return this.position;
449
+ }
450
+ readBit() {
451
+ if (this.count === 0) {
452
+ if (this.position >= this.data.length)
453
+ throw new ScanEnd("out of data");
454
+ let byte = this.data[this.position++];
455
+ if (byte === 255) {
456
+ const next = this.data[this.position];
457
+ if (next === 0) {
458
+ this.position++;
459
+ } else if (next !== void 0 && next >= RST_FIRST && next <= RST_LAST) {
460
+ this.position++;
461
+ this.restarted = true;
462
+ if (this.position >= this.data.length)
463
+ throw new ScanEnd("out of data");
464
+ byte = this.data[this.position++];
465
+ if (byte === 255)
466
+ throw new ScanEnd("marker after restart");
467
+ } else {
468
+ this.position--;
469
+ throw new ScanEnd("marker");
470
+ }
471
+ }
472
+ this.bits = byte;
473
+ this.count = 8;
474
+ }
475
+ this.count--;
476
+ return this.bits >> this.count & 1;
477
+ }
478
+ /** Read `length` bits, most significant first. */
479
+ readBits(length) {
480
+ let value = 0;
481
+ for (let i = 0; i < length; i++)
482
+ value = value << 1 | this.readBit();
483
+ return value;
484
+ }
485
+ /** Drop the current byte's remaining bits — what a restart interval boundary does. */
486
+ align() {
487
+ this.count = 0;
488
+ }
489
+ };
490
+ function decodeHuffman(reader2, table) {
491
+ let code = reader2.readBit();
492
+ for (let length = 1; length <= 16; length++) {
493
+ if (table.maxCode[length] >= 0 && code <= table.maxCode[length]) {
494
+ return table.values[table.valPtr[length] + code - table.minCode[length]];
495
+ }
496
+ code = code << 1 | reader2.readBit();
497
+ }
498
+ throw new ScanEnd("no huffman code of any length");
499
+ }
500
+ function extend(value, length) {
501
+ return value < 1 << length - 1 ? value - (1 << length) + 1 : value;
502
+ }
503
+ function decodeBlock(reader2, component) {
504
+ const dcLength = decodeHuffman(reader2, component.dc);
505
+ if (dcLength > 16)
506
+ throw new ScanEnd("dc magnitude out of range");
507
+ component.pred += dcLength === 0 ? 0 : extend(reader2.readBits(dcLength), dcLength);
508
+ let k = 1;
509
+ while (k < 64) {
510
+ const rs = decodeHuffman(reader2, component.ac);
511
+ const size = rs & 15;
512
+ const run = rs >> 4;
513
+ if (size === 0) {
514
+ if (run !== 15)
515
+ break;
516
+ k += 16;
517
+ } else {
518
+ k += run;
519
+ if (k > 63)
520
+ throw new ScanEnd("ac coefficient index out of range");
521
+ reader2.readBits(size);
522
+ k++;
523
+ }
524
+ }
525
+ return component.pred;
526
+ }
527
+ function scanEntropy(tail, subsampling, extraTables) {
528
+ const tables = /* @__PURE__ */ new Map();
529
+ for (const segment of extraTables)
530
+ readHuffmanTables(segment, tables);
531
+ let at = 0;
532
+ let restartInterval = 0;
533
+ let scanStart = -1;
534
+ let assignments = [];
535
+ while (at + 3 < tail.length) {
536
+ if (tail[at] !== 255)
537
+ return null;
538
+ const marker = tail[at + 1];
539
+ const length = tail[at + 2] << 8 | tail[at + 3];
540
+ if (marker === DHT) {
541
+ readHuffmanTables(tail.subarray(at, at + 2 + length), tables);
542
+ } else if (marker === DRI) {
543
+ restartInterval = tail[at + 4] << 8 | tail[at + 5];
544
+ } else if (marker === SOS) {
545
+ const count2 = tail[at + 4];
546
+ assignments = [];
547
+ for (let i = 0; i < count2; i++) {
548
+ const id = tail[at + 5 + i * 2];
549
+ const tableByte = tail[at + 6 + i * 2];
550
+ assignments.push({ id, dc: tableByte >> 4, ac: tableByte & 15 });
551
+ }
552
+ scanStart = at + 2 + length;
553
+ break;
554
+ }
555
+ at += 2 + length;
556
+ }
557
+ if (scanStart < 0 || assignments.length !== 3)
558
+ return null;
559
+ const [lumaH, lumaV] = LUMA_SAMPLING[subsampling] ?? LUMA_SAMPLING[0];
560
+ const components = [];
561
+ for (const [index, assignment] of assignments.entries()) {
562
+ const dc = tables.get(assignment.dc);
563
+ const ac = tables.get(16 | assignment.ac);
564
+ if (!dc || !ac)
565
+ return null;
566
+ components.push({ h: index === 0 ? lumaH : 1, v: index === 0 ? lumaV : 1, dc, ac, pred: 0 });
567
+ }
568
+ const reader2 = new BitReader(tail, scanStart);
569
+ let luma = new Int32Array(1024);
570
+ const lumaBlocks = lumaH * lumaV;
571
+ let mcus = 0;
572
+ let consumed = reader2.consumed;
573
+ let complete = false;
574
+ try {
575
+ for (; ; ) {
576
+ if (restartInterval > 0 && mcus > 0 && mcus % restartInterval === 0) {
577
+ reader2.align();
578
+ for (const component of components)
579
+ component.pred = 0;
580
+ }
581
+ if (reader2.restarted) {
582
+ reader2.restarted = false;
583
+ for (const component of components)
584
+ component.pred = 0;
585
+ }
586
+ let lumaSum = 0;
587
+ for (const component of components) {
588
+ for (let block = 0; block < component.h * component.v; block++) {
589
+ const dc = decodeBlock(reader2, component);
590
+ if (component === components[0])
591
+ lumaSum += dc;
592
+ }
593
+ }
594
+ if (mcus === luma.length) {
595
+ const grown = new Int32Array(luma.length * 2);
596
+ grown.set(luma);
597
+ luma = grown;
598
+ }
599
+ luma[mcus] = Math.round(lumaSum / lumaBlocks);
600
+ mcus++;
601
+ consumed = reader2.consumed;
602
+ if (consumed >= tail.length) {
603
+ complete = true;
604
+ break;
605
+ }
606
+ }
607
+ } catch (error) {
608
+ if (!(error instanceof ScanEnd))
609
+ throw error;
610
+ complete = tail.length - consumed <= 2;
611
+ }
612
+ return { mcus, luma: luma.subarray(0, mcus), complete };
613
+ }
614
+
371
615
  // dist/transport/http/decodeImageV2.js
372
- import { decode as jpegDecode, encode as jpegEncode } from "jpeg-js";
616
+ import { decode as jpegDecode } from "jpeg-js";
373
617
  var V2_PREFIX = "v2_eufysecurity:";
374
618
  var DC_CHROMA = Buffer.from([255, 196, 0, 31, 1]);
375
619
  var ZIGZAG = [
@@ -575,35 +819,6 @@ var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d0102030
575
819
  var SOI = Buffer.from([255, 216]);
576
820
  var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
577
821
  var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
578
- var LADDER = [
579
- [160, 90],
580
- [240, 135],
581
- [256, 144],
582
- [320, 180],
583
- [384, 216],
584
- [400, 225],
585
- [480, 270],
586
- [512, 288],
587
- [576, 324],
588
- [640, 360],
589
- [704, 396],
590
- [768, 432],
591
- [848, 480],
592
- [960, 540],
593
- [1024, 576],
594
- [1280, 720],
595
- [1600, 900],
596
- [1920, 1080],
597
- [176, 144],
598
- [320, 240],
599
- [352, 288],
600
- [480, 360],
601
- [640, 480],
602
- [800, 600],
603
- [1024, 768],
604
- [256, 480],
605
- [320, 384]
606
- ];
607
822
  function scaleQuant(base, quality) {
608
823
  const factor = quality < 50 ? Math.floor(5e3 / quality) : 200 - quality * 2;
609
824
  return base.map((v) => Math.min(255, Math.max(1, Math.floor((v * factor + 50) / 100))));
@@ -640,7 +855,7 @@ function sofSegment(width, height, subsampling) {
640
855
  1
641
856
  ]);
642
857
  }
643
- function buildHeader(width, height, subsampling, quality = 85) {
858
+ function buildHeader(width, height, subsampling, quality) {
644
859
  return Buffer.concat([
645
860
  SOI,
646
861
  APP0,
@@ -651,99 +866,107 @@ function buildHeader(width, height, subsampling, quality = 85) {
651
866
  DHT_AC_LUMA
652
867
  ]);
653
868
  }
654
- function decodeCandidate(tail, width, height, subsampling) {
869
+ var PROBE_MEMORY_MB = 24;
870
+ function decodeSpliced(header, tail) {
655
871
  try {
656
- const jpeg = Buffer.concat([buildHeader(width, height, subsampling), tail]);
657
- return jpegDecode(jpeg, { useTArray: true, maxMemoryUsageInMB: 128 });
872
+ return jpegDecode(Buffer.concat([header, tail]), { useTArray: true, maxMemoryUsageInMB: PROBE_MEMORY_MB });
658
873
  } catch {
659
874
  return null;
660
875
  }
661
876
  }
662
- function colorSpread(img) {
663
- const { width, height, data } = img;
664
- let sum = 0;
665
- let count2 = 0;
666
- for (let i = 0; i < width * height; i += 37) {
667
- const p = i * 4;
668
- const r = data[p];
669
- const g = data[p + 1];
670
- const b = data[p + 2];
671
- sum += Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
672
- count2++;
673
- }
674
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
675
- }
676
- function rowShear(img, rows) {
677
- const { width, data } = img;
678
- let sum = 0;
679
- let count2 = 0;
680
- for (let y = 1; y < rows; y++) {
681
- for (let x = 0; x < width; x += 4) {
682
- sum += Math.abs(data[(y * width + x) * 4] - data[((y - 1) * width + x) * 4]);
683
- count2++;
684
- }
877
+ var REFERENCE_QUALITY = 85;
878
+ var MAX_STRETCH = 8;
879
+ var CUTOFF_PERCENT = 0.5;
880
+ function trimmedRange(data, channel, total, cutoff) {
881
+ const hist = new Array(256).fill(0);
882
+ for (let i = 0; i < total; i++)
883
+ hist[data[i * 4 + channel]]++;
884
+ let remaining = Math.floor(total * cutoff / 100);
885
+ let lo = 0;
886
+ while (lo < 255 && remaining > 0) {
887
+ if (remaining < hist[lo])
888
+ break;
889
+ remaining -= hist[lo];
890
+ lo++;
891
+ }
892
+ remaining = Math.floor(total * cutoff / 100);
893
+ let hi = 255;
894
+ while (hi > 0 && remaining > 0) {
895
+ if (remaining < hist[hi])
896
+ break;
897
+ remaining -= hist[hi];
898
+ hi--;
685
899
  }
686
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
900
+ return { lo, hi };
687
901
  }
688
- function autoContrast(data, width, height, cutoff = 0.5) {
689
- const total = width * height;
902
+ function contrastScale(img, cutoff = CUTOFF_PERCENT) {
903
+ const total = img.width * img.height;
904
+ if (total === 0)
905
+ return 1;
906
+ let stretch = Number.POSITIVE_INFINITY;
690
907
  for (let channel = 0; channel < 3; channel++) {
691
- const hist = new Array(256).fill(0);
692
- for (let i = 0; i < total; i++)
693
- hist[data[i * 4 + channel]]++;
694
- let remaining = Math.floor(total * cutoff / 100);
695
- for (let bin = 0; bin < 256 && remaining > 0; bin++) {
696
- if (remaining > hist[bin]) {
697
- remaining -= hist[bin];
698
- hist[bin] = 0;
699
- } else {
700
- hist[bin] -= remaining;
701
- remaining = 0;
702
- }
703
- }
704
- remaining = Math.floor(total * cutoff / 100);
705
- for (let bin = 255; bin >= 0 && remaining > 0; bin--) {
706
- if (remaining > hist[bin]) {
707
- remaining -= hist[bin];
708
- hist[bin] = 0;
709
- } else {
710
- hist[bin] -= remaining;
711
- remaining = 0;
712
- }
713
- }
714
- let lo = 0;
715
- while (lo < 256 && hist[lo] === 0)
716
- lo++;
717
- let hi = 255;
718
- while (hi >= 0 && hist[hi] === 0)
719
- hi--;
908
+ const { lo, hi } = trimmedRange(img.data, channel, total, cutoff);
720
909
  if (hi <= lo)
721
910
  continue;
722
- const scale = 255 / (hi - lo);
723
- const offset = -lo * scale;
724
- const lut = new Uint8Array(256);
725
- for (let v = 0; v < 256; v++)
726
- lut[v] = Math.min(255, Math.max(0, Math.trunc(v * scale + offset)));
727
- for (let i = 0; i < total; i++)
728
- data[i * 4 + channel] = lut[data[i * 4 + channel]];
729
- }
730
- }
731
- function maxHeight(tail, width, subsampling, seed) {
732
- const mcuHeight = MCU[subsampling][1];
733
- let height = Math.max(mcuHeight, Math.round(seed / mcuHeight) * mcuHeight);
734
- if (!decodeCandidate(tail, width, height, subsampling)) {
735
- while (height > mcuHeight && !decodeCandidate(tail, width, height, subsampling))
736
- height -= mcuHeight;
737
- } else {
738
- while (height < 4096 && decodeCandidate(tail, width, height + mcuHeight, subsampling))
739
- height += mcuHeight;
740
- }
741
- return height;
742
- }
743
- function ladderByMcuCount(subsampling) {
744
- const [mcuWidth, mcuHeight] = MCU[subsampling];
745
- const mcus = ([width, height]) => width / mcuWidth * (height / mcuHeight);
746
- return [...LADDER].sort((a, b) => mcus(a) - mcus(b));
911
+ if (lo < 128)
912
+ stretch = Math.min(stretch, 128 / (128 - lo));
913
+ if (hi > 128)
914
+ stretch = Math.min(stretch, 127 / (hi - 128));
915
+ }
916
+ if (!Number.isFinite(stretch))
917
+ return 1;
918
+ return Math.min(MAX_STRETCH, Math.max(1, stretch));
919
+ }
920
+ function qualityForStretch(stretch) {
921
+ const referenceFactor = REFERENCE_QUALITY < 50 ? Math.floor(5e3 / REFERENCE_QUALITY) : 200 - REFERENCE_QUALITY * 2;
922
+ const factor = referenceFactor * stretch;
923
+ const quality = factor <= 100 ? (200 - factor) / 2 : 5e3 / factor;
924
+ return Math.min(99, Math.max(1, Math.round(quality)));
925
+ }
926
+ var MAX_EDGE = 4096;
927
+ var MIN_ASPECT = 0.4;
928
+ var MAX_ASPECT = 4;
929
+ function mcuRowShear(luma, mcuWidth, mcuHeight) {
930
+ if (mcuHeight < 2)
931
+ return Number.POSITIVE_INFINITY;
932
+ let sum = 0;
933
+ for (let y = 1; y < mcuHeight; y++) {
934
+ for (let x = 0; x < mcuWidth; x++)
935
+ sum += Math.abs(luma[y * mcuWidth + x] - luma[(y - 1) * mcuWidth + x]);
936
+ }
937
+ return sum / (mcuWidth * (mcuHeight - 1));
938
+ }
939
+ function bestGeometry(luma, mcus, subsampling) {
940
+ const [mcuPixelWidth, mcuPixelHeight] = MCU[subsampling];
941
+ let best = null;
942
+ for (let mcuWidth = 1; mcuWidth <= mcus; mcuWidth++) {
943
+ if (mcus % mcuWidth !== 0)
944
+ continue;
945
+ const mcuHeight = mcus / mcuWidth;
946
+ const width = mcuWidth * mcuPixelWidth;
947
+ const height = mcuHeight * mcuPixelHeight;
948
+ if (width > MAX_EDGE || height > MAX_EDGE)
949
+ continue;
950
+ const aspect = width / height;
951
+ if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
952
+ continue;
953
+ const shear = mcuRowShear(luma, mcuWidth, mcuHeight);
954
+ if (!best || shear < best.shear)
955
+ best = { subsampling, width, height, shear };
956
+ }
957
+ return best;
958
+ }
959
+ function findGeometry(tail) {
960
+ let best = null;
961
+ for (const subsampling of [0, 1, 2]) {
962
+ const scan = scanEntropy(tail, subsampling, [DHT_DC_LUMA, DHT_AC_LUMA]);
963
+ if (!scan?.complete || scan.mcus < 1)
964
+ continue;
965
+ const geometry = bestGeometry(scan.luma, scan.mcus, subsampling);
966
+ if (geometry && (!best || geometry.shear < best.shear))
967
+ best = geometry;
968
+ }
969
+ return best;
747
970
  }
748
971
  function isV2Image(data) {
749
972
  return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
@@ -755,53 +978,17 @@ function decodeImageV2(data) {
755
978
  if (cut < 0)
756
979
  return null;
757
980
  const tail = data.subarray(cut);
758
- let best = null;
759
- for (const subsampling2 of [2, 0, 1]) {
760
- let filled = null;
761
- for (const [width2, height2] of ladderByMcuCount(subsampling2)) {
762
- const img2 = decodeCandidate(tail, width2, height2, subsampling2);
763
- if (!img2)
764
- break;
765
- if (!filled || width2 * height2 > filled.width * filled.height)
766
- filled = { width: width2, height: height2, img: img2 };
767
- }
768
- if (!filled)
769
- continue;
770
- const spread = colorSpread(filled.img);
771
- if (!best || spread < best.spread)
772
- best = { spread, subsampling: subsampling2, width: filled.width, height: filled.height };
773
- }
774
- if (!best)
981
+ const geometry = findGeometry(tail);
982
+ if (!geometry)
775
983
  return null;
776
- const { subsampling, width: coarseWidth } = best;
777
- const [mcuWidth, mcuHeight] = MCU[subsampling];
778
- const coarseHeight = maxHeight(tail, coarseWidth, subsampling, best.height);
779
- const totalMcus = coarseWidth / mcuWidth * (coarseHeight / mcuHeight);
780
- let width = coarseWidth;
781
- let height = coarseHeight;
782
- let bestShear = Number.POSITIVE_INFINITY;
783
- const lowWidth = Math.max(mcuWidth * 4, Math.round(coarseWidth * 0.75 / mcuWidth) * mcuWidth);
784
- const highWidth = Math.round(coarseWidth * 1.25 / mcuWidth) * mcuWidth;
785
- for (let candidateWidth = lowWidth; candidateWidth <= highWidth; candidateWidth += mcuWidth) {
786
- const candidateHeight = Math.floor(totalMcus / (candidateWidth / mcuWidth)) * mcuHeight;
787
- if (candidateHeight < mcuHeight)
788
- continue;
789
- const img2 = decodeCandidate(tail, candidateWidth, candidateHeight, subsampling);
790
- if (!img2)
791
- continue;
792
- const shear = rowShear(img2, candidateHeight);
793
- if (shear < bestShear) {
794
- bestShear = shear;
795
- width = candidateWidth;
796
- height = candidateHeight;
797
- }
798
- }
799
- height = maxHeight(tail, width, subsampling, height);
800
- const img = decodeCandidate(tail, width, height, subsampling);
801
- if (!img)
984
+ const { width, height, subsampling } = geometry;
985
+ const reference = buildHeader(width, height, subsampling, REFERENCE_QUALITY);
986
+ const probe = decodeSpliced(reference, tail);
987
+ if (!probe)
802
988
  return null;
803
- autoContrast(img.data, img.width, img.height);
804
- return Buffer.from(jpegEncode({ data: img.data, width: img.width, height: img.height }, 90).data);
989
+ const stretch = contrastScale(probe);
990
+ const header = stretch > 1.01 ? buildHeader(width, height, subsampling, qualityForStretch(stretch)) : reference;
991
+ return Buffer.concat([header, tail]);
805
992
  }
806
993
 
807
994
  // dist/transport/http/decodeImageV1.js
@@ -948,7 +1135,7 @@ var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
948
1135
  var REAUTH_STABLE_MS = 10 * 6e4;
949
1136
  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";
950
1137
  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 ?? "");
1138
+ 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
1139
  }
953
1140
  function withoutTokenEcho(text2) {
954
1141
  return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
@@ -960,6 +1147,13 @@ var MegaHttpClient = class {
960
1147
  sessionKey;
961
1148
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
962
1149
  sessionKeys = /* @__PURE__ */ new Map();
1150
+ /**
1151
+ * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
1152
+ * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
1153
+ *
1154
+ * The `gtoken` header is hashed from `accountUserId`: that is the id the gateway recomputes the header
1155
+ * from, rejecting a disagreement with `"gtoken not equal userid error"`.
1156
+ */
963
1157
  auth_;
964
1158
  /** captcha_id of an in-flight challenge, held between login() and solveCaptcha(). */
965
1159
  pendingCaptchaId;
@@ -1025,7 +1219,12 @@ var MegaHttpClient = class {
1025
1219
  if (!isSessionValid(saved) || !saved)
1026
1220
  return void 0;
1027
1221
  this.region = saved.region;
1028
- this.auth_ = { userId: saved.userId, authToken: saved.authToken, geoKey: saved.geoKey };
1222
+ this.auth_ = {
1223
+ userId: saved.userId,
1224
+ accountUserId: saved.accountUserId,
1225
+ authToken: saved.authToken,
1226
+ geoKey: saved.geoKey
1227
+ };
1029
1228
  this.tokenExpiresAt = saved.tokenExpiresAt;
1030
1229
  this.sessionKey = {
1031
1230
  keyIdent: saved.keyIdent,
@@ -1045,12 +1244,20 @@ var MegaHttpClient = class {
1045
1244
  return this.region;
1046
1245
  }
1047
1246
  /**
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 `@`.
1247
+ * The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
1248
+ * pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
1249
+ * is the login email's local-part (e.g. `someone+tag` for `someone+tag@example.com`) and falls back
1250
+ * to the whole email if it has no `@`.
1251
+ *
1252
+ * The local-part is the string the app writes into the ff09 command's acting "username" field
1253
+ * (verified against a captured T8531 unlock frame), so it is the faithful default. An override is a
1254
+ * different LABEL for the same account, not a different identity: the session authenticates on the
1255
+ * token and the device record's member ids, neither of which this touches.
1052
1256
  */
1053
1257
  get accountName() {
1258
+ const pinned = this.cfg.accountName?.trim();
1259
+ if (pinned)
1260
+ return pinned;
1054
1261
  const email = this.cfg.email ?? "";
1055
1262
  const at = email.indexOf("@");
1056
1263
  return at > 0 ? email.slice(0, at) : email;
@@ -1088,7 +1295,16 @@ var MegaHttpClient = class {
1088
1295
  };
1089
1296
  }
1090
1297
  /**
1091
- * The account-credential headers every authed call carries — `x-auth-token` + `gtoken` (`md5(userId)`).
1298
+ * The id `gtoken` is hashed from — the account's own `user_id`, which is what the gateway recomputes the
1299
+ * header from. One place so the two header paths cannot drift on which of the session's ids that is.
1300
+ *
1301
+ * Call only where `auth_` is already established; every header path guards it.
1302
+ */
1303
+ gtokenUserId() {
1304
+ return this.auth_.accountUserId ?? this.auth_.userId;
1305
+ }
1306
+ /**
1307
+ * The account-credential headers every authed call carries — `x-auth-token` + `gtoken`.
1092
1308
  * One place so the signed path, the key-exchange and the bearer path can't drift on what "authed" means.
1093
1309
  */
1094
1310
  authTokenHeaders() {
@@ -1097,7 +1313,7 @@ var MegaHttpClient = class {
1097
1313
  return {
1098
1314
  "x-auth-token": this.auth_.authToken,
1099
1315
  authorization: this.auth_.authToken,
1100
- gtoken: gtoken(this.auth_.userId)
1316
+ gtoken: gtoken(this.gtokenUserId())
1101
1317
  };
1102
1318
  }
1103
1319
  /**
@@ -1401,7 +1617,7 @@ var MegaHttpClient = class {
1401
1617
  try {
1402
1618
  return await downloadMediaResource(url, {
1403
1619
  "x-auth-token": this.auth_.authToken,
1404
- gtoken: gtoken(this.auth_.userId),
1620
+ gtoken: gtoken(this.gtokenUserId()),
1405
1621
  "app-name": "eufy_mega",
1406
1622
  "model-type": "PHONE",
1407
1623
  "user-agent": this.mediaUserAgent
@@ -1413,9 +1629,20 @@ var MegaHttpClient = class {
1413
1629
  throw error;
1414
1630
  }
1415
1631
  }
1416
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
1632
+ /**
1633
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
1634
+ *
1635
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
1636
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
1637
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
1638
+ */
1417
1639
  async downloadImage(url, p2pDid) {
1418
- return normalizePushImage(await this.downloadMedia(url), p2pDid);
1640
+ const data = await this.downloadMedia(url);
1641
+ try {
1642
+ return normalizePushImage(data, p2pDid);
1643
+ } catch (error) {
1644
+ throw mediaFailureError("Push image could not be decoded", "decode-failed", error);
1645
+ }
1419
1646
  }
1420
1647
  /** The security-app data host for this region (face recognition, media, etc.). */
1421
1648
  securityAppHost() {
@@ -1692,17 +1919,19 @@ var MegaHttpClient = class {
1692
1919
  {
1693
1920
  const cap = Object.keys(res).filter((k) => /captcha|answer|picture|image|fa_/i.test(k));
1694
1921
  this.logger.debug("[mega] login resp keys:", Object.keys(res).join(","));
1922
+ this.logger.debug("[mega] ids agree:", res.ap_cloud_user_id === res.user_id);
1695
1923
  if (cap.length)
1696
1924
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1697
1925
  }
1698
1926
  const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1927
+ const accountUserId = res.user_id ?? res.userId;
1699
1928
  const authToken = res.auth_token ?? res.token;
1700
1929
  if (!userId || !authToken)
1701
1930
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1702
1931
  const faInfo = res.fa_info ?? {};
1703
1932
  const needs2fa = !isVerify && !!faInfo.info;
1704
1933
  if (needs2fa) {
1705
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1934
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1706
1935
  this.pendingCaptchaId = void 0;
1707
1936
  this.pending2fa = true;
1708
1937
  await this.sendVerifyCode(messageType);
@@ -1710,7 +1939,7 @@ var MegaHttpClient = class {
1710
1939
  }
1711
1940
  this.pendingCaptchaId = void 0;
1712
1941
  this.pending2fa = false;
1713
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1942
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1714
1943
  this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
1715
1944
  this.sessionKey = void 0;
1716
1945
  await this.ensureSessionKey();
@@ -1723,6 +1952,7 @@ var MegaHttpClient = class {
1723
1952
  return;
1724
1953
  this.store.save({
1725
1954
  userId: this.auth_.userId,
1955
+ accountUserId: this.auth_.accountUserId ?? this.auth_.userId,
1726
1956
  authToken: this.auth_.authToken,
1727
1957
  geoKey: this.auth_.geoKey,
1728
1958
  region: this.region,
@@ -1810,6 +2040,19 @@ function parseSecureTopic(topic) {
1810
2040
  return void 0;
1811
2041
  return { root, category, model, sn, tail: parts.slice(4).join("/") };
1812
2042
  }
2043
+ function solixDeviceTopics(appName, productCode, deviceSn) {
2044
+ const dt = `dt/${appName}/${productCode}/${deviceSn}`;
2045
+ const cmd = `cmd/${appName}/${productCode}/${deviceSn}`;
2046
+ return {
2047
+ paramInfo: `${dt}/param_info`,
2048
+ stateInfo: `${dt}/state_info`,
2049
+ cmdRes: `${cmd}/app/res`,
2050
+ req: `${cmd}/req`
2051
+ };
2052
+ }
2053
+ function solixUserTopics(appName, userId) {
2054
+ return { cmdRes: `cmd/${appName}/${userId}/res`, powerSite: `dt/${appName}/${userId}/power_site` };
2055
+ }
1813
2056
 
1814
2057
  // dist/transport/mqtt/secure-mqtt.js
1815
2058
  var SUBACK_FAILURE = 128;
@@ -1903,7 +2146,7 @@ var SecureMqtt = class extends EventEmitter {
1903
2146
  * four topics for `eufy_life`).
1904
2147
  *
1905
2148
  * 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
2149
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
1907
2150
  * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
1908
2151
  * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
1909
2152
  * line that grants its state channel but refuses (say) the OTA leg still works.
@@ -1912,9 +2155,8 @@ var SecureMqtt = class extends EventEmitter {
1912
2155
  if (!this.client)
1913
2156
  throw new Error("SecureMqtt not connected");
1914
2157
  const topics = [...subscribeTopics(device)];
1915
- const grants = await this.client.subscribeAsync(topics, { qos: 1 });
2158
+ const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
1916
2159
  const scope = this.o.credentials.app_name ?? "default";
1917
- const denied = grants.filter((g) => g.qos === SUBACK_FAILURE).map((g) => g.topic);
1918
2160
  if (denied.length === topics.length) {
1919
2161
  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
2162
  }
@@ -1922,6 +2164,29 @@ var SecureMqtt = class extends EventEmitter {
1922
2164
  this.emit("error", new Error(`subscribe ${device.sn}: "${topic}" denied on credential scope "${scope}"`));
1923
2165
  }
1924
2166
  }
2167
+ /**
2168
+ * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
2169
+ * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
2170
+ * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
2171
+ * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
2172
+ */
2173
+ async subscribe(topics) {
2174
+ if (!this.client)
2175
+ throw new Error("SecureMqtt not connected");
2176
+ return this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 })).granted;
2177
+ }
2178
+ /**
2179
+ * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
2180
+ * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
2181
+ * this split and layer their own policy (drop vs report) on top.
2182
+ */
2183
+ partitionGrants(grants) {
2184
+ const granted = [];
2185
+ const denied = [];
2186
+ for (const g of grants)
2187
+ (g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
2188
+ return { granted, denied };
2189
+ }
1925
2190
  /**
1926
2191
  * Publish a raw payload to an MQTT topic (the command leg — `cmd/{app}/{pn}/{sn}/req`). The `body`
1927
2192
  * is a pre-built envelope the caller supplies (the command router builds it). QoS 1 by default (the
@@ -2556,14 +2821,26 @@ var CameraDisabledError = class extends Error {
2556
2821
  this.name = "CameraDisabledError";
2557
2822
  }
2558
2823
  };
2559
- var StationBusyError = class extends Error {
2560
- servingChannel;
2561
- /** Always true: the station is busy now, and stops being busy when the other stream is released. */
2824
+ var StationKeyUnavailableError = class extends Error {
2825
+ stationSn;
2826
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
2827
+ retryable = true;
2828
+ constructor(stationSn, options) {
2829
+ super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
2830
+ this.stationSn = stationSn;
2831
+ this.name = "StationKeyUnavailableError";
2832
+ }
2833
+ };
2834
+ var StationUnreachableError = class extends Error {
2835
+ stationSn;
2836
+ waitedMs;
2837
+ /** Always true: a station unreachable now may answer on a later attempt. */
2562
2838
  retryable = true;
2563
- constructor(servingChannel, options) {
2564
- super(`the station is already serving channel ${servingChannel} to a viewer, and serves one camera at a time \u2014 stop that stream before opening another`, options);
2565
- this.servingChannel = servingChannel;
2566
- this.name = "StationBusyError";
2839
+ constructor(stationSn, waitedMs, options) {
2840
+ super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
2841
+ this.stationSn = stationSn;
2842
+ this.waitedMs = waitedMs;
2843
+ this.name = "StationUnreachableError";
2567
2844
  }
2568
2845
  };
2569
2846
  var LiveStreamStartError = class extends Error {
@@ -3533,7 +3810,7 @@ function sensitivityCommand(step, scale, ctx) {
3533
3810
  return setScalar(scale.writeId, value, ctx, "direct-binary");
3534
3811
  if (scale.form === "control")
3535
3812
  return setJson(scale.writeId, { index: value }, ctx);
3536
- return setPayload(scale.writeId, { sensitivity: value, channel: ctx.channel }, ctx, 0);
3813
+ return setPayload(scale.writeId, { sensitivity: value, channel: ctx.channel }, ctx, 0, void 0, "auto");
3537
3814
  }
3538
3815
  var EXIT_TEST_MODE_VALUE = 808464384;
3539
3816
  function requireFamily(action, ctx, verifiedOn) {
@@ -3611,7 +3888,7 @@ var MOTION_MEMBERS = {
3611
3888
  const n = Number(v);
3612
3889
  if (!Number.isInteger(n) || n < 0)
3613
3890
  return void 0;
3614
- return setPayload(MOTION_CMD.AI_DETECT_TYPE, { ai_detect_type: n, channel: ctx.channel }, ctx, 0, 0);
3891
+ return setPayload(MOTION_CMD.AI_DETECT_TYPE, { ai_detect_type: n, channel: ctx.channel }, ctx, 0, 0, "auto");
3615
3892
  }
3616
3893
  },
3617
3894
  /**
@@ -3724,7 +4001,7 @@ var MOTION_MEMBERS = {
3724
4001
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
3725
4002
  write: (v, ctx) => {
3726
4003
  requireFamily("humanOnlyAtNight", ctx, "camera");
3727
- return setPayload(MOTION_CMD.HUMAN_ONLY_AT_NIGHT, { only_ai: asBool(v) ? 1 : 0 }, ctx, 0);
4004
+ return setPayload(MOTION_CMD.HUMAN_ONLY_AT_NIGHT, { only_ai: asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto");
3728
4005
  }
3729
4006
  },
3730
4007
  /**
@@ -3741,7 +4018,7 @@ var MOTION_MEMBERS = {
3741
4018
  requires: [MOTION_CMD.LOITERING_DETECTION],
3742
4019
  write: (v, ctx) => {
3743
4020
  requireFamily("loiteringDetection", ctx, "camera");
3744
- return setPayload(MOTION_CMD.LOITERING_DETECTION, { radar_wd_switch: asBool(v) ? 1 : 0 }, ctx, 0);
4021
+ return setPayload(MOTION_CMD.LOITERING_DETECTION, { radar_wd_switch: asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto");
3745
4022
  }
3746
4023
  }
3747
4024
  };
@@ -3923,12 +4200,32 @@ function decodePowerSource(raw) {
3923
4200
  function recordSetting(param, value, ctx) {
3924
4201
  return setStationScalar(param, value, ctx.channel);
3925
4202
  }
3926
- var MAINS_CAMERA_MODELS = ["T8425", "T8419"];
4203
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410"];
3927
4204
  var notMainsCamera = (ctx) => {
3928
4205
  const model = (ctx.model ?? "").toUpperCase();
3929
4206
  return !MAINS_CAMERA_MODELS.some((prefix) => model.startsWith(prefix));
3930
4207
  };
3931
- var BATTERY_MEMBERS = {
4208
+ var CELL_PARAMS = [
4209
+ BATTERY_PARAM.BATTERY,
4210
+ BATTERY_PARAM.BATTERY_STATUS,
4211
+ BATTERY_PARAM.BATTERY_TEMP,
4212
+ BATTERY_PARAM.BATTERY_HEALTH,
4213
+ BATTERY_PARAM.SOLAR_INTENSITY,
4214
+ BATTERY_PARAM.SOLAR_CONNECT_24H,
4215
+ BATTERY_PARAM.BATTERY_POWER_DATAS
4216
+ ];
4217
+ function cellGated(members) {
4218
+ const gated = Object.entries(members).map(([name, m]) => {
4219
+ const param = m.param;
4220
+ if (typeof param !== "number" || !CELL_PARAMS.includes(param))
4221
+ return [name, m];
4222
+ const own = m.available;
4223
+ const available = own ? (ctx) => notMainsCamera(ctx) && own(ctx) : notMainsCamera;
4224
+ return [name, { ...m, available }];
4225
+ });
4226
+ return Object.fromEntries(gated);
4227
+ }
4228
+ var BATTERY_MEMBERS = cellGated({
3932
4229
  /**
3933
4230
  * The headline percentage, and this capability's detection evidence: reporting 1101 is what proves a
3934
4231
  * device is battery-powered, which is also the fact `MediaProvider` reads to decide a stream needs a
@@ -3941,7 +4238,6 @@ var BATTERY_MEMBERS = {
3941
4238
  unit: "%",
3942
4239
  kind: "percent",
3943
4240
  provenance: "verified",
3944
- available: notMainsCamera,
3945
4241
  description: "Battery level 0-100 (verified: param 1101)."
3946
4242
  },
3947
4243
  /**
@@ -3954,7 +4250,6 @@ var BATTERY_MEMBERS = {
3954
4250
  type: "bool",
3955
4251
  kind: "boolean",
3956
4252
  provenance: "apk",
3957
- available: notMainsCamera,
3958
4253
  coerce: (v) => {
3959
4254
  const n = Number(v);
3960
4255
  return n !== 0 && n !== 2;
@@ -3980,7 +4275,7 @@ var BATTERY_MEMBERS = {
3980
4275
  args: [{ name: "source", kind: "enum", description: "The raw value, or the name `battery`/`external`." }],
3981
4276
  ...accepts(),
3982
4277
  description: "Configured power source (1293): 0 = Battery, 1 = External Solar Panel. Verified live on T8124R. The richer raw APP_CMD_SET_POWER_SOURCE blob is surfaced separately as `powerSourceInfo` (6445). Format varies by model (plain int or JSON {power_source:N}); values beyond 0/1 shown raw.",
3983
- write: (v, ctx) => setPayload(BATTERY_PARAM.POWER_CHARGE, { charge_mode: v === "external" ? 1 : v === "battery" ? 0 : asBool(v) ? 1 : 0 }, ctx, 0)
4278
+ write: (v, ctx) => setPayload(BATTERY_PARAM.POWER_CHARGE, { charge_mode: v === "external" ? 1 : v === "battery" ? 0 : asBool(v) ? 1 : 0 }, ctx, 0, void 0, "auto")
3984
4279
  },
3985
4280
  /**
3986
4281
  * A DEVICE-SPECIFIC mode index: the app maps value→mode through a per-model config (Hermes
@@ -4125,6 +4420,11 @@ var BATTERY_MEMBERS = {
4125
4420
  * Reported, so it stays in the schema and answers through `getProperty` — but given no typed getter:
4126
4421
  * the payload's fields have never been decoded, and a getter would hand back an opaque blob typed as
4127
4422
  * though it meant something.
4423
+ *
4424
+ * `unexposed` is NOT a substitute for the cell gate. It suppresses the fluent GETTER; `propertiesOf`
4425
+ * filters `writeOnly` and `available` and deliberately not `unexposed`, because a schema entry
4426
+ * reachable through `getProperty` is the whole point of the mark. So a cell param needs
4427
+ * {@link CELL_PARAMS} either way, or a mains camera publishes "battery power history" and answers it.
4128
4428
  */
4129
4429
  batteryPowerStats: {
4130
4430
  param: BATTERY_PARAM.BATTERY_POWER_DATAS,
@@ -4150,7 +4450,7 @@ var BATTERY_MEMBERS = {
4150
4450
  unexposed: true,
4151
4451
  description: "Raw GET_CAMERA_INFO value (1103), meaning unevidenced \u2014 read live as the constant 5 on a T8170 at 92% and a T8171 at 27%, so it is NOT a low-battery flag."
4152
4452
  }
4153
- };
4453
+ });
4154
4454
  var BATTERY = {
4155
4455
  capability: "battery",
4156
4456
  description: "Battery level, charging, health, temperature, and solar input.",
@@ -4293,6 +4593,10 @@ var CONTACT_MEMBERS = {
4293
4593
  * `1350` SET_PAYLOAD, `mChannel` = the device channel, `mValue3` 0, payload carrying the channel and a
4294
4594
  * transaction stamp — the byte-shape of the app's own captured frame. Out of range is refused rather
4295
4595
  * than clamped: the app's slider has no values outside it, so one is a caller error, not a nudge.
4596
+ *
4597
+ * Level-2 only, though the `mValue3` 0 would allow `"auto"`: an entry sensor's session IS its
4598
+ * HomeBase's, which always holds a key — the second of `setPayload`'s two conditions, not an oversight
4599
+ * of the first.
4296
4600
  */
4297
4601
  alarmVolume: {
4298
4602
  param: CONTACT_CMD.ALARM_VOLUME,
@@ -4520,7 +4824,7 @@ var AUDIO_MEMBERS = {
4520
4824
  provenance: "verified",
4521
4825
  available: isCameraCodec,
4522
4826
  description: "Record audio with video (1288 record_mute, inverted). \u2705 HW-verified on T8425: readback flips (on\u21921288=0, off\u21921288=1). The write is a 1350 SET_PAYLOAD on the device channel with `{channel, record_mute}` \u2014 the key is record_mute and it is INVERTED.",
4523
- write: (v, ctx) => setPayload(AUDIO_CMD.AUDIO_RECORDING, { channel: ctx.channel, record_mute: asBool(v) ? 0 : 1 }, ctx, 0)
4827
+ write: (v, ctx) => setPayload(AUDIO_CMD.AUDIO_RECORDING, { channel: ctx.channel, record_mute: asBool(v) ? 0 : 1 }, ctx, 0, void 0, "auto")
4524
4828
  },
4525
4829
  /**
4526
4830
  * Doorbell ring/chime loudness — WRITE-ONLY here on purpose. The READ property `ringtoneVolume` (1708)
@@ -4968,7 +5272,7 @@ var CAMERA_MEMBERS = {
4968
5272
  description: "Night-vision mode (NIGHT_VISION_TYPE 1277): 0 = off, 1 = infrared (B&W), 2 = full colour. \u2705 wire verified live (T8425 ch3): 1350 SET_PAYLOAD, mChannel 0, {channel:N, night_sion:mode}. Enum labels are best-guess. Some models omit full colour.",
4969
5273
  write: (v, ctx) => {
4970
5274
  const nv = coerceEnumValue(NightVision, v);
4971
- return nv == null ? void 0 : setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0);
5275
+ return nv == null ? void 0 : setPayload(CAMERA_CMD.NIGHT_VISION_TYPE, { channel: ctx.channel, night_sion: nv }, ctx, 0, 0, "auto");
4972
5276
  }
4973
5277
  },
4974
5278
  /**
@@ -5016,7 +5320,7 @@ var CAMERA_MEMBERS = {
5016
5320
  ...accepts(),
5017
5321
  write: (v, ctx) => {
5018
5322
  const q = resolveRecordingQualityTier(v);
5019
- return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0);
5323
+ return q == null ? void 0 : setPayload(CAMERA_CMD.RECORDING_QUALITY_SET, { channel: 0, mode: 0, primary_view: 0, quality: q }, ctx, 0, void 0, "auto");
5020
5324
  }
5021
5325
  },
5022
5326
  /**
@@ -5396,7 +5700,7 @@ function autoSpotlightCommand(on, ctx, opts = {}) {
5396
5700
  schedule: [],
5397
5701
  sunset2rise: 0,
5398
5702
  time: opts.time ?? 30
5399
- }, ctx, 0);
5703
+ }, ctx, 0, void 0, "auto");
5400
5704
  }
5401
5705
  function lightSwitchWire(ctx) {
5402
5706
  const t = ctx.deviceType;
@@ -5935,7 +6239,7 @@ function zoomCommand(dstZoom, ctx, region) {
5935
6239
  offset,
5936
6240
  orgZoom: r.orgZoom ?? 0,
5937
6241
  dstZoom
5938
- }, ctx, 0);
6242
+ }, ctx, 0, void 0, "auto");
5939
6243
  }
5940
6244
  function gotoPresetCommand(id, ctx) {
5941
6245
  return setJson(PTZ_CMD.PTZ_PRESET_GOTO, { settingstate: 0, value: id }, ctx);
@@ -6331,7 +6635,7 @@ var DOORBELL_MEMBERS = {
6331
6635
  min: 0,
6332
6636
  max: 100,
6333
6637
  description: "Doorbell chime volume, 0-100 (1717 APP_CMD_BAT_DOORBELL_SET_DINGDONG_VOLUME; SET_PAYLOAD envelope, distinct from the direct-binary ringtoneVolume/1708 \u2014 see DOORBELL_CMD.DINGDONG_VOLUME). Write wire-confirmed live on T8214, observed values 3 and 25.",
6334
- write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0)
6638
+ write: (v, ctx) => setPayload(DOORBELL_CMD.DINGDONG_VOLUME, { dingdong_volume: Number(v) }, ctx, 0, void 0, "auto")
6335
6639
  },
6336
6640
  /**
6337
6641
  * The same `1350` SET_PAYLOAD shape and capture session as {@link DOORBELL_MEMBERS.dingdongVolume},
@@ -6349,7 +6653,7 @@ var DOORBELL_MEMBERS = {
6349
6653
  description: "Doorbell chime ringtone SELECTION INDEX, not a bool (1718 APP_CMD_BAT_DOORBELL_SET_DINGDONG_RINGTONE; SET_PAYLOAD envelope \u2014 see DOORBELL_CMD.DINGDONG_RINGTONE). Write wire-confirmed live on T8214, observed values 4 and 0. Named options: see the {@link DoorbellRingtone} enum. A write outside that enum is REJECTED, not clamped into range \u2014 this is a selection, not a volume, so a bad index would otherwise land on a real-but-wrong tone.",
6350
6654
  write: (v, ctx) => {
6351
6655
  const tone = coerceEnumValue(DoorbellRingtone, v);
6352
- return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0);
6656
+ return tone === void 0 ? void 0 : setPayload(DOORBELL_CMD.DINGDONG_RINGTONE, { dingdong_ringtone: tone }, ctx, 0, void 0, "auto");
6353
6657
  }
6354
6658
  },
6355
6659
  /**
@@ -6894,8 +7198,18 @@ var ArmingMode = {
6894
7198
  away: "away",
6895
7199
  /** Armed for occupancy — reduced/perimeter protection while home (wire value 1). */
6896
7200
  home: "home",
7201
+ /** Scheduled — the station follows the timetable configured in the app (wire value 2). */
7202
+ schedule: "schedule",
6897
7203
  /** Custom 1 — a user-defined posture configured in the app (wire value 3). */
6898
7204
  custom1: "custom1",
7205
+ /** Custom 2 — a user-defined posture configured in the app (wire value 4). */
7206
+ custom2: "custom2",
7207
+ /** Custom 3 — a user-defined posture configured in the app (wire value 5). */
7208
+ custom3: "custom3",
7209
+ /** Off — the station's alarm system is switched off entirely (wire value 6). */
7210
+ off: "off",
7211
+ /** Geofenced — the station follows the app's location-based rules (wire value 47). */
7212
+ geo: "geo",
6899
7213
  /** Disarmed — no alarms; sensors still report state (wire value 63). */
6900
7214
  disarmed: "disarmed"
6901
7215
  };
@@ -6916,10 +7230,14 @@ var ARMING_CMD = {
6916
7230
  *
6917
7231
  * ⚠️ Only 3 of the 9 modes were exercised in that capture — `mode_type` 0 (away), 63 (disarmed), 1
6918
7232
  * (home), all confirmed byte-exact. Re-confirmed live 2026-08-05: each reported its own MODE_SWITCH push
6919
- * within ~5s of the write. `custom1` 3 joined {@link ArmingMode} on a live confirmation rather than a
6920
- * capture, making four settable in total. The remaining five are named by the app but never observed
6921
- * leaving it, so this capability reads them and refuses to send them. See `ARMING_MODE_WIRE` for the
6922
- * per-value breakdown.
7233
+ * within ~5s of the write. The remaining six are live confirmations rather than captures — `custom1` 3
7234
+ * first, then `schedule` 2, `custom2` 4, `custom3` 5, `off` 6 and `geo` 47 — each sent as this exact
7235
+ * frame and each observed to bring MODE_SWITCH back, so all nine are settable. `ARMING_MODE_WIRE` has
7236
+ * the per-value evidence and the dates.
7237
+ *
7238
+ * The ENCRYPTION LEVEL is the session's to pick (`"auto"`), not this command's: the T8030 the envelope
7239
+ * was captured on holds a level-2 key and seals it level-2, while an own-session camera that never
7240
+ * negotiates one carries the same envelope level-1. See `armingCommand`.
6923
7241
  */
6924
7242
  SET_ARMING: 1224,
6925
7243
  /**
@@ -6953,20 +7271,14 @@ var ARMING_MODE_WIRE = {
6953
7271
  away: 0,
6954
7272
  home: 1,
6955
7273
  schedule: 2,
6956
- // ⚠️ reportable, NOT settable — see the doc comment above
6957
7274
  custom1: 3,
6958
7275
  custom2: 4,
6959
- // ⚠️ reportable, NOT settable — see the doc comment above
6960
7276
  custom3: 5,
6961
- // ⚠️ reportable, NOT settable — see the doc comment above
6962
7277
  off: 6,
6963
- // ⚠️ reportable, NOT settable — see the doc comment above
6964
7278
  geo: 47,
6965
- // ⚠️ reportable, NOT settable — see the doc comment above
6966
7279
  disarmed: 63
6967
7280
  };
6968
7281
  var ARMING_MODE_LABELS = enumLabels(ARMING_MODE_WIRE);
6969
- var SETTABLE_MODES = Object.values(ArmingMode).map((m) => ARMING_MODE_WIRE[m]);
6970
7282
  function armingModeOf(v) {
6971
7283
  const name = String(v);
6972
7284
  if (name in ArmingMode)
@@ -6975,11 +7287,10 @@ function armingModeOf(v) {
6975
7287
  return Object.values(ArmingMode).find((m) => ARMING_MODE_WIRE[m] === wire);
6976
7288
  }
6977
7289
  function armingCommand(mode, ctx) {
6978
- const modeType = ARMING_MODE_WIRE[mode];
6979
7290
  if (!ctx.accountName) {
6980
7291
  throw new Error(`arming: missing account identity (user_name) [${describeDevice(ctx)}]`);
6981
7292
  }
6982
- return setPayload(ARMING_CMD.SET_ARMING, { mode_type: modeType, user_name: ctx.accountName }, ctx, 0);
7293
+ return setPayload(ARMING_CMD.SET_ARMING, { mode_type: ARMING_MODE_WIRE[mode], user_name: ctx.accountName }, ctx, 0, void 0, "auto");
6983
7294
  }
6984
7295
  function alarmDelayCommand(mode, config, ctx) {
6985
7296
  const data = {
@@ -6996,11 +7307,10 @@ function alarmDelayCommand(mode, config, ctx) {
6996
7307
  }
6997
7308
  var ARMING_MEMBERS = {
6998
7309
  /**
6999
- * The one member whose write domain is NARROWER than its read: `enumValues` names all nine modes a
7000
- * station can report, and the argument's `values` publishes only the four whose write is confirmed. That
7001
- * argument IS the domain the derived setter enforces and the refusal names, so an unconfirmed mode is
7002
- * refused by naming the four that work — nine labels for the read and four for the write, off one
7003
- * declaration.
7310
+ * Read and write are the same nine modes, so `enumValues` is the whole domain: `writeDomain` falls back
7311
+ * to it, and the derived setter, the refusal message and the offered argument all read from that one
7312
+ * declaration. A member states an `args` entry only where the two sides DIFFER. See
7313
+ * {@link ARMING_MODE_WIRE} for the per-value evidence.
7004
7314
  *
7005
7315
  * `armingCommand` may also throw synchronously (missing account identity) and `bindMembers` turns that
7006
7316
  * into a rejection, so the builder stays plain.
@@ -7019,8 +7329,7 @@ var ARMING_MEMBERS = {
7019
7329
  kind: "enum",
7020
7330
  enumValues: ARMING_MODE_LABELS,
7021
7331
  provenance: "verified",
7022
- args: [{ name: "mode", kind: "enum", values: SETTABLE_MODES }],
7023
- description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads all 9 modes the app defines; SETS only the 4 whose write is confirmed (away/home/custom1/disarmed) \u2014 schedule/custom2/custom3/off/geo are named by the app but no capture shows one being sent, so they are refused rather than guessed; see ARMING_MODE_WIRE in arming.ts for the breakdown.",
7332
+ description: "Guard mode (verified: param 1224 = GUARD_MODE, read/write mechanism confirmed). Reads and SETS all 9 modes the app defines \u2014 away/home/schedule/custom1/custom2/custom3/off/geo/disarmed. Three are byte-captured writes and six are live-confirmed (each sent and observed to report its own MODE_SWITCH); see ARMING_MODE_WIRE in arming.ts for the per-value evidence.",
7024
7333
  observation: {
7025
7334
  event: "armingModeChanged",
7026
7335
  reflects: (value) => ({ param: ARMING_CMD.SET_ARMING, expected: ARMING_MODE_WIRE[armingModeOf(value)] }),
@@ -7715,6 +8024,89 @@ var ModeCtrlMethod = {
7715
8024
  STOP_SMART_FOLLOW: 18,
7716
8025
  START_GLOBAL_CRUISE: 20
7717
8026
  };
8027
+ var ModeCtrlParamMethod = {
8028
+ /** `START_SELECT_ROOMS_CLEAN` — clean the named rooms of a named map. */
8029
+ SELECT_ROOMS: { method: 1, param: 4 },
8030
+ /** `START_SELECT_ZONES_CLEAN` — clean the given rectangles of a named map. */
8031
+ SELECT_ZONES: { method: 2, param: 5 },
8032
+ /** `START_GOTO_CLEAN` — drive to a point and clean around it. */
8033
+ GOTO: { method: 4, param: 7 },
8034
+ /** `START_SCENE_CLEAN` — run a saved scene by its id. */
8035
+ SCENE: { method: 24, param: 14 }
8036
+ };
8037
+ var SELECT_ROOMS_FIELD = {
8038
+ /** `rooms` — repeated, one entry per room. */
8039
+ ROOMS: 1,
8040
+ /** `clean_times` — how many passes to make. */
8041
+ CLEAN_TIMES: 2,
8042
+ /** `map_id` — WHICH saved map the room ids belong to. */
8043
+ MAP_ID: 3,
8044
+ /** `id` within a `Room`. */
8045
+ ROOM_ID: 1,
8046
+ /** `order` within a `Room` — the sequence to visit them in. */
8047
+ ROOM_ORDER: 2
8048
+ };
8049
+ var SELECT_ZONES_FIELD = {
8050
+ /** `zones` — repeated, one entry per rectangle. */
8051
+ ZONES: 1,
8052
+ /** `map_id` — which saved map the coordinates belong to. */
8053
+ MAP_ID: 2,
8054
+ /** `quadrangle` within a `Zone` — its four corners. */
8055
+ QUADRANGLE: 1,
8056
+ /** `clean_times` within a `Zone`. */
8057
+ ZONE_CLEAN_TIMES: 2,
8058
+ /** `x` within a `Point`, SIGNED centimetres. */
8059
+ POINT_X: 1,
8060
+ /** `y` within a `Point`, SIGNED centimetres. */
8061
+ POINT_Y: 2
8062
+ };
8063
+ var SCENE_CLEAN_ID = 1;
8064
+ function encodeModeCtrlParam(method2, paramField, build) {
8065
+ return rawDp((w) => {
8066
+ if (method2 !== 0)
8067
+ w.int(MODE_CTRL_FIELD.METHOD, method2);
8068
+ w.int(MODE_CTRL_FIELD.SEQ, nextModeCtrlSeq());
8069
+ w.sub(paramField, build);
8070
+ });
8071
+ }
8072
+ function encodeSelectRoomsClean(mapId, rooms, cleanTimes = 1) {
8073
+ const { method: method2, param } = ModeCtrlParamMethod.SELECT_ROOMS;
8074
+ return encodeModeCtrlParam(method2, param, (p) => {
8075
+ for (const room of rooms) {
8076
+ p.sub(SELECT_ROOMS_FIELD.ROOMS, (r) => {
8077
+ r.int(SELECT_ROOMS_FIELD.ROOM_ID, room.id);
8078
+ if (room.order !== void 0)
8079
+ r.int(SELECT_ROOMS_FIELD.ROOM_ORDER, room.order);
8080
+ });
8081
+ }
8082
+ p.int(SELECT_ROOMS_FIELD.CLEAN_TIMES, cleanTimes);
8083
+ p.int(SELECT_ROOMS_FIELD.MAP_ID, mapId);
8084
+ });
8085
+ }
8086
+ function encodeSelectZonesClean(mapId, zones) {
8087
+ const { method: method2, param } = ModeCtrlParamMethod.SELECT_ZONES;
8088
+ return encodeModeCtrlParam(method2, param, (p) => {
8089
+ for (const zone of zones) {
8090
+ p.sub(SELECT_ZONES_FIELD.ZONES, (z) => {
8091
+ z.sub(SELECT_ZONES_FIELD.QUADRANGLE, (q) => {
8092
+ for (const [i, corner] of zone.corners.entries()) {
8093
+ q.sub(i + 1, (pt) => {
8094
+ pt.sint(SELECT_ZONES_FIELD.POINT_X, corner.x);
8095
+ pt.sint(SELECT_ZONES_FIELD.POINT_Y, corner.y);
8096
+ });
8097
+ }
8098
+ });
8099
+ if (zone.cleanTimes !== void 0)
8100
+ z.int(SELECT_ZONES_FIELD.ZONE_CLEAN_TIMES, zone.cleanTimes);
8101
+ });
8102
+ }
8103
+ p.int(SELECT_ZONES_FIELD.MAP_ID, mapId);
8104
+ });
8105
+ }
8106
+ function encodeSceneClean(sceneId) {
8107
+ const { method: method2, param } = ModeCtrlParamMethod.SCENE;
8108
+ return encodeModeCtrlParam(method2, param, (p) => p.int(SCENE_CLEAN_ID, sceneId));
8109
+ }
7718
8110
  var modeCtrlSeq = 111;
7719
8111
  function nextModeCtrlSeq() {
7720
8112
  return ++modeCtrlSeq;
@@ -9550,7 +9942,41 @@ var VACUUM_CLEAN_MEMBERS = {
9550
9942
  /** Return to the dock via ModeCtrlRequest method 6 (DP 152). AIoT only — Tuya write unverified. */
9551
9943
  returnToDock: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.START_GOHOME, nextModeCtrlSeq()))), "Return to the dock (ModeCtrlRequest method 6 over DP 152).", isAiotVacuum),
9552
9944
  /** Pause the current cleaning task via ModeCtrlRequest method 13 (DP 152). AIoT only — Tuya write unverified. */
9553
- pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum)
9945
+ pauseCleaning: method(({ sink }) => () => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeModeCtrl(ModeCtrlMethod.PAUSE_TASK, nextModeCtrlSeq()))), "Pause the current cleaning task (ModeCtrlRequest method 13 over DP 152).", isAiotVacuum),
9946
+ /**
9947
+ * Run a saved cleaning scene by its id (ModeCtrlRequest method 24 over DP 152).
9948
+ *
9949
+ * The id is the device's own, as {@link VACUUM_CLEAN_MEMBERS.scenes} reports it — `VacuumScene.id`
9950
+ * off the `SceneResponse` on DP 180. A scene the device reports invalid stays reportable and running
9951
+ * it is still a well-formed request; `VacuumScene.invalidReason` says why the device will refuse.
9952
+ *
9953
+ * Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 24 has been
9954
+ * WATCHED: run on a T2351, it started the named scene.
9955
+ */
9956
+ 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),
9957
+ /**
9958
+ * Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).
9959
+ *
9960
+ * `mapId` has no default and that is deliberate: room ids are per map, so assuming the map a
9961
+ * single-floor home would have sends a two-floor home's ids against the wrong floor. `SceneInfo.mapid`
9962
+ * on DP 180 and a scheduled rooms-clean's `map_id` are the two real map ids the device reports.
9963
+ *
9964
+ * `cleanTimes` is how many passes to make over the set; rooms with no `order` are visited in the
9965
+ * order given.
9966
+ *
9967
+ * Frame shape is byte-proven against the shared outer `ModeCtrlRequest`, and method 1 has been
9968
+ * WATCHED: run on a T2351, it cleaned the rooms named.
9969
+ */
9970
+ cleanRooms: method(({ sink }) => (mapId, rooms, cleanTimes = 1) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectRoomsClean(mapId, rooms, cleanTimes))), "Clean the named rooms of a named map (ModeCtrlRequest method 1 over DP 152).", isAiotVacuum),
9971
+ /**
9972
+ * Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).
9973
+ *
9974
+ * Corners are SIGNED centimetres in the map's own frame, whose origin sits wherever the robot first
9975
+ * mapped from — negative coordinates are ordinary and are ZigZag-encoded, not written as plain
9976
+ * varints. Same `mapId` reasoning as {@link VACUUM_CLEAN_MEMBERS.cleanRooms}, and the same evidence:
9977
+ * method 2 was run on a T2351 and cleaned the rectangles given.
9978
+ */
9979
+ cleanZones: method(({ sink }) => (mapId, zones) => sink.dispatch(aiotDp(VACUUM_DP.MODE_CTRL, encodeSelectZonesClean(mapId, zones))), "Clean the given rectangles of a named map (ModeCtrlRequest method 2 over DP 152).", isAiotVacuum)
9554
9980
  };
9555
9981
  var VACUUM_CLEAN = {
9556
9982
  capability: "vacuum_clean",
@@ -9726,6 +10152,70 @@ function toNumberArray(arr) {
9726
10152
  return result;
9727
10153
  }
9728
10154
 
10155
+ // dist/model/capabilities/display.js
10156
+ var DISPLAY_PARAM = { BATTERY: 8001, SOFTWARE_VERSION: 8003 };
10157
+ var DISPLAY_MEMBERS = {
10158
+ /**
10159
+ * Battery level, 0-100.
10160
+ *
10161
+ * **On this capability rather than on `battery`, and that is the line partition doing its job.** The
10162
+ * security-line `battery` capability reads param 1101 and a Smart Display's charge is 8001 in its own
10163
+ * id space — two wires that happen to mean the same thing. One capability reading both would be a claim
10164
+ * that the two ecosystems share a param space, so a consumer reads a display's charge through
10165
+ * `dev.display()` and a camera's through `dev.battery()`.
10166
+ *
10167
+ * `verified` rather than `mega`: the id is in the device's cloud record, but the NAME came from the
10168
+ * maintainer's own knowledge of the hardware rather than from the cloud data-point list, and `"100"`
10169
+ * fits brightness, volume or charge equally.
10170
+ *
10171
+ * The scale is `percent` on the reading itself, not on convention alone: a full charge reads `255` on a
10172
+ * 0-255 scale and `1000` on a 0-1000 one, so `"100"` on a charged unit is positive evidence for 0-100
10173
+ * rather than merely consistent with it. What nobody has done is watch it MOVE, which is why a value
10174
+ * frozen at 100 would not yet be distinguishable from a healthy one.
10175
+ */
10176
+ battery: {
10177
+ param: DISPLAY_PARAM.BATTERY,
10178
+ type: "number",
10179
+ unit: "%",
10180
+ kind: "percent",
10181
+ provenance: "verified",
10182
+ description: "Battery level, 0-100 (param 8001)."
10183
+ },
10184
+ /**
10185
+ * Version-shaped, and that shape is the whole of the evidence — hence `guessed`, and hence no typed
10186
+ * getter: a caller reading this off a bound object cannot see the label.
10187
+ *
10188
+ * `unexposed` rather than absent, so the schema still carries its type and that label and
10189
+ * `getProperty("softwareVersion")` still answers. `dev.info()?.firmwareVersion` is the field to trust
10190
+ * where the cloud record carries one; on this display it does not, which is the only reason 8003 is
10191
+ * named at all.
10192
+ */
10193
+ softwareVersion: {
10194
+ param: DISPLAY_PARAM.SOFTWARE_VERSION,
10195
+ type: "string",
10196
+ kind: "text",
10197
+ provenance: "guessed",
10198
+ unexposed: true,
10199
+ description: "Version-shaped string (param 8003), meaning unconfirmed \u2014 prefer `info.firmwareVersion`."
10200
+ }
10201
+ };
10202
+ var DISPLAY = {
10203
+ capability: "display",
10204
+ line: "display",
10205
+ description: "Smart Display battery level (param 8001, display namespace).",
10206
+ members: DISPLAY_MEMBERS,
10207
+ properties: propertiesOf(DISPLAY_MEMBERS),
10208
+ /**
10209
+ * Claimed by CODEC, not by an evidence param.
10210
+ *
10211
+ * 8001 is in the device's cloud record, so the ordinary evidence gate would install the getter anyway
10212
+ * — but the capability should attach to a Smart Display that has reported nothing yet too, because a
10213
+ * device on this line has no other capability to carry it. The line partition keeps this off
10214
+ * everything else: `display` is the only codec in the `display` line.
10215
+ */
10216
+ detection: { codecs: ["display"] }
10217
+ };
10218
+
9729
10219
  // dist/model/capabilities/locate.js
9730
10220
  var LOCATE_DP = 160;
9731
10221
  var LEGACY_LOCATE_DP = TUYA_VACUUM_DP.LOOK_FOR_SWEEPER;
@@ -9854,6 +10344,7 @@ var MODULES = [
9854
10344
  VACUUM_DOCK,
9855
10345
  SUCTION,
9856
10346
  LOCATE,
10347
+ DISPLAY,
9857
10348
  INFO
9858
10349
  ];
9859
10350
  var BY_CAP = new Map(MODULES.map((m) => [m.capability, m]));
@@ -9861,6 +10352,16 @@ function getCapabilityModule(cap) {
9861
10352
  return BY_CAP.get(cap);
9862
10353
  }
9863
10354
  var CAPABILITY_MODULES = Object.fromEntries(MODULES.map((m) => [m.capability, m]));
10355
+ function claimedParams(caps) {
10356
+ const claimed = /* @__PURE__ */ new Set();
10357
+ for (const cap of caps)
10358
+ for (const spec of getCapabilityModule(cap)?.properties ?? []) {
10359
+ claimed.add(spec.paramType);
10360
+ for (const alias of spec.readAliases ?? [])
10361
+ claimed.add(alias.paramType);
10362
+ }
10363
+ return claimed;
10364
+ }
9864
10365
  function mergeProperties(caps, ctx) {
9865
10366
  const seen = /* @__PURE__ */ new Set();
9866
10367
  const merged = [];
@@ -9898,11 +10399,11 @@ var CODEC_LINE = {
9898
10399
  mower: "clean",
9899
10400
  light: "life",
9900
10401
  printer: "print",
9901
- display: "security"
10402
+ display: "display"
9902
10403
  };
9903
10404
  function lineAllows(module, codec) {
9904
10405
  const line = module.line ?? "security";
9905
- return line === "any" || line === CODEC_LINE[codec];
10406
+ return line === "any" || codec !== void 0 && line === CODEC_LINE[codec];
9906
10407
  }
9907
10408
  function detectCapabilities(rec, codec) {
9908
10409
  const found = /* @__PURE__ */ new Set();
@@ -9932,10 +10433,10 @@ function detectCapabilities(rec, codec) {
9932
10433
  if (!matched && d.modelHints && haystack.length > 0) {
9933
10434
  matched = d.modelHints.some((re) => re.test(haystack));
9934
10435
  }
9935
- if (!matched && d.codecs) {
10436
+ if (!matched && d.codecs && codec !== void 0) {
9936
10437
  matched = d.codecs.includes(codec);
9937
10438
  }
9938
- if (!matched && d.detect) {
10439
+ if (!matched && d.detect && codec !== void 0) {
9939
10440
  try {
9940
10441
  matched = d.detect(rec, codec) === true;
9941
10442
  } catch {
@@ -10143,6 +10644,11 @@ var MODEL_REGISTRY = {
10143
10644
  T8210: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell" },
10144
10645
  // Confirmed against a real owned unit (named "Doorbell"): Video Doorbell Dual.
10145
10646
  T8214: { codec: "camera", caps: ["doorbell", "battery"], name: "Video Doorbell Dual" },
10647
+ // Confirmed against a real owned unit: the mains-powered Wired Doorbell 2K. No `battery` row
10648
+ // member on purpose — this model is wired, so the codec baseline plus inference is the whole
10649
+ // truth for power. Without this row it classified as a plain camera, so the doorbell capability
10650
+ // never appeared and consumers got no doorbell event entity or ring trigger.
10651
+ T8200: { codec: "camera", caps: ["doorbell"], name: "Wired Doorbell 2K" },
10146
10652
  // Cameras observed on real owned hardware (inspect-device sweep). Names from the app's own
10147
10653
  // model-family constants (scripts/data/app_model_registry.json); caps mirror what the device
10148
10654
  // reports. T8171 has no family entry in that dump, so it keeps the raw T-code.
@@ -11998,6 +12504,28 @@ var CLEAN_PARAMS = {
11998
12504
  provenance: "mega"
11999
12505
  }
12000
12506
  };
12507
+ var DISPLAY_PARAMS = {
12508
+ 8001: {
12509
+ name: "battery",
12510
+ type: "number",
12511
+ provenance: "verified"
12512
+ },
12513
+ 8003: {
12514
+ name: "softwareVersion",
12515
+ type: "string",
12516
+ provenance: "guessed"
12517
+ },
12518
+ 8005: {
12519
+ name: "modelName",
12520
+ type: "string",
12521
+ provenance: "mega"
12522
+ },
12523
+ 8006: {
12524
+ name: "modelCode",
12525
+ type: "string",
12526
+ provenance: "mega"
12527
+ }
12528
+ };
12001
12529
 
12002
12530
  // dist/model/life-params.js
12003
12531
  var LIFE_PARAMS = {
@@ -12025,7 +12553,10 @@ var TABLES = {
12025
12553
  // 3D-printer (ankermake) id space — empty until a live capture confirms the param↔semantic map
12026
12554
  // (printer-support plan Stage 3). Present so the printer codec resolves to its OWN namespace rather
12027
12555
  // than falling through to `security` and decoding another line's dictionary.
12028
- print: {}
12556
+ print: {},
12557
+ // Smart Display (T87Ax) — ids 8001-8006, four of them named. Same reason as `print`: its own
12558
+ // dictionary rather than a corner of another line's.
12559
+ display: DISPLAY_PARAMS
12029
12560
  };
12030
12561
  function paramDef(ns, paramType) {
12031
12562
  return TABLES[ns][paramType];
@@ -12040,7 +12571,7 @@ var NAMESPACE_BY_CODEC = {
12040
12571
  mower: "clean",
12041
12572
  light: "life",
12042
12573
  printer: "print",
12043
- display: "security"
12574
+ display: "display"
12044
12575
  };
12045
12576
  function namespaceForCodec(codec) {
12046
12577
  return NAMESPACE_BY_CODEC[codec];
@@ -12127,6 +12658,11 @@ var Device = class _Device {
12127
12658
  * different wire ids across device families still resolves to one named value.
12128
12659
  */
12129
12660
  specByParam;
12661
+ /**
12662
+ * Params a resolved capability declares that this device's schema does not carry — withheld by a
12663
+ * member's gate, so not named from the param dictionary either. See {@link Device.applyParams}.
12664
+ */
12665
+ withheld = /* @__PURE__ */ new Set();
12130
12666
  /** Which param namespace this device's ids live in (clean DPs vs security P2P). */
12131
12667
  namespace;
12132
12668
  /** The record's `device_name` as stated, before the {@link modelName} fallback is applied. */
@@ -12193,6 +12729,7 @@ var Device = class _Device {
12193
12729
  byParam.set(a.paramType, { spec: p, invert: a.invert ?? false });
12194
12730
  }
12195
12731
  this.specByParam = byParam;
12732
+ this.withheld = new Set([...claimedParams(resolved.capabilities)].filter((pt) => !byParam.has(pt)));
12196
12733
  this.namespace = namespaceForCodec(resolved.codec);
12197
12734
  this.installAccessors();
12198
12735
  }
@@ -12355,6 +12892,16 @@ var Device = class _Device {
12355
12892
  * Apply a raw param map (cloud record or P2P notification). Known params update their named
12356
12893
  * property; unrecognised params are retained as `unknown_<paramType>` so nothing is lost.
12357
12894
  *
12895
+ * Naming precedence: this device's own `PropertySpec` (curated), then the param dictionary for its
12896
+ * namespace, then the `unknown_<paramType>` passthrough. The dictionary def is consulted even where a
12897
+ * spec exists, because `encoding` lives there.
12898
+ *
12899
+ * A param a resolved capability's gate WITHHELD takes the passthrough instead of its dictionary name.
12900
+ * The gate decided the read does not describe this device, the dictionary names it what the member
12901
+ * would have, and republishing it there hands a caller a reading indistinguishable from one the device
12902
+ * really answered. A capability that never resolved withholds nothing: a param arriving before its
12903
+ * capability is still the device's own, and keeps its dictionary name.
12904
+ *
12358
12905
  * @param params param_type → raw value.
12359
12906
  * @param ts observation time (epoch ms); defaults to `Date.now()`.
12360
12907
  * @returns the list of property names whose value changed.
@@ -12367,7 +12914,7 @@ var Device = class _Device {
12367
12914
  continue;
12368
12915
  const hit = this.specByParam.get(pt);
12369
12916
  const spec = hit?.spec;
12370
- const def = paramDef(this.namespace, pt);
12917
+ const def = this.withheld.has(pt) ? void 0 : paramDef(this.namespace, pt);
12371
12918
  const name = spec ? spec.name : def ? def.name : `${UNKNOWN_PARAM_PREFIX}${pt}`;
12372
12919
  const value = def?.encoding ? decodeEncoded(rawVal, def.encoding) : spec ? coerce(spec, rawVal, hit.invert, this.logger) : def ? coerceByType(def.type, rawVal, def.name, this.logger) : String(rawVal);
12373
12920
  const prev = this.state.get(name);
@@ -12569,7 +13116,7 @@ function lz4BlockDecompress(block, size) {
12569
13116
  const end = block.length;
12570
13117
  let pos = 0;
12571
13118
  let written = 0;
12572
- const extend = (base) => {
13119
+ const extend2 = (base) => {
12573
13120
  let len = base;
12574
13121
  if (base !== 15)
12575
13122
  return len;
@@ -12584,7 +13131,7 @@ function lz4BlockDecompress(block, size) {
12584
13131
  };
12585
13132
  while (pos < end) {
12586
13133
  const token = block[pos++];
12587
- const literals = extend(token >> 4);
13134
+ const literals = extend2(token >> 4);
12588
13135
  if (literals === void 0)
12589
13136
  return void 0;
12590
13137
  if (pos + literals > end || written + literals > size)
@@ -12599,7 +13146,7 @@ function lz4BlockDecompress(block, size) {
12599
13146
  pos += 2;
12600
13147
  if (offset === 0 || offset > written)
12601
13148
  return void 0;
12602
- const match = extend(token & 15);
13149
+ const match = extend2(token & 15);
12603
13150
  if (match === void 0)
12604
13151
  return void 0;
12605
13152
  const length = match + 4;
@@ -13103,24 +13650,394 @@ function inspectParams(rec, sn) {
13103
13650
  };
13104
13651
  }
13105
13652
 
13106
- // dist/client/map-channels.js
13107
- var reader = (kind, decode) => (raw, codec) => {
13108
- const value = decode(raw, codec);
13109
- return value === void 0 ? void 0 : { kind, value };
13653
+ // dist/model/capabilities/solix.js
13654
+ var SOLIX_ENERGY_METER_MEMBERS = {
13655
+ /** Line-1 voltage (V), ff09 tag `0xAC` — confirmed live (a nominal mains voltage). */
13656
+ meterVoltageL1: {
13657
+ param: 172,
13658
+ type: "number",
13659
+ kind: "scalar",
13660
+ unit: "V",
13661
+ provenance: "verified",
13662
+ description: "Meter line-1 voltage (V) \u2014 ff09 tag 0xAC, confirmed against a live single-phase frame."
13663
+ },
13664
+ /** Line-2 voltage (V), ff09 tag `0xAD` — the app's field; reads 0 until a multi-phase frame carries it. */
13665
+ meterVoltageL2: {
13666
+ param: 173,
13667
+ type: "number",
13668
+ kind: "scalar",
13669
+ unit: "V",
13670
+ provenance: "guessed",
13671
+ 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."
13672
+ },
13673
+ /** Line-3 voltage (V), ff09 tag `0xAE` — the app's field; reads 0 until a multi-phase frame carries it. */
13674
+ meterVoltageL3: {
13675
+ param: 174,
13676
+ type: "number",
13677
+ kind: "scalar",
13678
+ unit: "V",
13679
+ provenance: "guessed",
13680
+ 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."
13681
+ },
13682
+ /** Line-1 current (A), ff09 tag `0xAF` — confirmed live (the line's CT current). */
13683
+ meterCurrentL1: {
13684
+ param: 175,
13685
+ type: "number",
13686
+ kind: "scalar",
13687
+ unit: "A",
13688
+ provenance: "verified",
13689
+ description: "Meter line-1 current (A) \u2014 ff09 tag 0xAF, confirmed against a live single-phase frame."
13690
+ },
13691
+ /** Line-2 current (A), ff09 tag `0xB0` — the app's field; reads 0 until a multi-phase frame carries it. */
13692
+ meterCurrentL2: {
13693
+ param: 176,
13694
+ type: "number",
13695
+ kind: "scalar",
13696
+ unit: "A",
13697
+ provenance: "guessed",
13698
+ 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."
13699
+ },
13700
+ /** Line-3 current (A), ff09 tag `0xB1` — the app's field; reads 0 until a multi-phase frame carries it. */
13701
+ meterCurrentL3: {
13702
+ param: 177,
13703
+ type: "number",
13704
+ kind: "scalar",
13705
+ unit: "A",
13706
+ provenance: "guessed",
13707
+ 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."
13708
+ },
13709
+ /** Line-1 active power (W), ff09 tag `0xA8` — confirmed live; negative on export. */
13710
+ meterPowerL1: {
13711
+ param: 168,
13712
+ type: "number",
13713
+ kind: "scalar",
13714
+ unit: "W",
13715
+ provenance: "verified",
13716
+ description: "Meter line-1 active power (W) \u2014 ff09 tag 0xA8, confirmed live; negative on export."
13717
+ },
13718
+ /** Line-2 active power (W), ff09 tag `0xA9` — the app's field; reads 0 until a multi-phase frame carries it. */
13719
+ meterPowerL2: {
13720
+ param: 169,
13721
+ type: "number",
13722
+ kind: "scalar",
13723
+ unit: "W",
13724
+ provenance: "guessed",
13725
+ 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."
13726
+ },
13727
+ /** Line-3 active power (W), ff09 tag `0xAA` — the app's field; reads 0 until a multi-phase frame carries it. */
13728
+ meterPowerL3: {
13729
+ param: 170,
13730
+ type: "number",
13731
+ kind: "scalar",
13732
+ unit: "W",
13733
+ provenance: "guessed",
13734
+ 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."
13735
+ },
13736
+ /** Aggregate active power (W), ff09 tag `0xAB` — confirmed live; equals line-1 on a single phase. */
13737
+ meterPowerTotal: {
13738
+ param: 171,
13739
+ type: "number",
13740
+ kind: "scalar",
13741
+ unit: "W",
13742
+ provenance: "verified",
13743
+ description: "Meter total active power (W) \u2014 ff09 tag 0xAB, confirmed live; equals L1 on one phase."
13744
+ }
13110
13745
  };
13111
- var READER_BY_CHANNEL = {
13112
- MAP_DATA: reader("plane", decodeVacuumMap),
13113
- ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
13114
- ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
13115
- RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
13116
- DYNAMIC_DATA: reader("pose", decodeVacuumPose)
13746
+ var CATEGORY_CAPABILITIES = {
13747
+ "Portable Power Station": ["battery", "acOutput", "solarInput"],
13748
+ /**
13749
+ * NOT `energyMeter`: a home battery measures its own grid/PV/output power, but that is a different
13750
+ * ff09 tag family than the AE1X0 Smart Meter's — the `energyMeter` members are AE1X0-specific, so a
13751
+ * Solarbank frame decoded through them mislabels (tag `0xac` is power on a Solarbank, voltage on the
13752
+ * meter — enforced by the meter-family gate in {@link SOLIX_METER_MODELS} / the transport decoder,
13753
+ * see PR #186). `energyMeter` is model-detected for the meter, not category-detected.
13754
+ */
13755
+ "Plug-in Home Battery": ["battery", "solarInput", "acOutput"],
13756
+ "Powered Cooler": ["battery", "cooler"],
13757
+ "Power Bank": ["battery"],
13758
+ "Smart EV Charger": ["evCharger"],
13759
+ Charger: ["charger"],
13760
+ Accessory: []
13117
13761
  };
13118
- var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
13119
- var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
13120
- function decodeMapFrame(frame, codec) {
13121
- if (frame.offset !== 0)
13122
- return void 0;
13123
- return READERS.get(frame.channelId)?.(frame.payload, codec);
13762
+ var SOLIX_METER_MODELS = ["AE1X0"];
13763
+ var SOLARBANK_MODELS = ["A1790", "A17C", "AE10"];
13764
+ function detectSolixCapabilities(rec, category) {
13765
+ const caps = /* @__PURE__ */ new Set(["identity"]);
13766
+ if (rec.device_sw_version)
13767
+ caps.add("firmware");
13768
+ if (rec.wifi_online !== void 0 || rec.rssi != null || rec.wifi_name)
13769
+ caps.add("connectivity");
13770
+ for (const c of category && CATEGORY_CAPABILITIES[category] || [])
13771
+ caps.add(c);
13772
+ if (SOLIX_METER_MODELS.some((m) => rec.product_code?.startsWith(m)))
13773
+ caps.add("energyMeter");
13774
+ if (SOLARBANK_MODELS.some((m) => rec.product_code?.startsWith(m))) {
13775
+ caps.add("battery");
13776
+ caps.add("solarInput");
13777
+ }
13778
+ return caps;
13779
+ }
13780
+
13781
+ // dist/model/solix-catalog.js
13782
+ function buildModelIndex(categories) {
13783
+ const index = /* @__PURE__ */ new Map();
13784
+ for (const category of categories) {
13785
+ for (const product of category.products ?? []) {
13786
+ const entry = { name: product.name, category: category.name.trim() };
13787
+ if (product.product_code)
13788
+ index.set(product.product_code, entry);
13789
+ for (const variant of product.p_codes ?? []) {
13790
+ const code = typeof variant === "string" ? variant : variant?.product_code;
13791
+ if (code)
13792
+ index.set(code, entry);
13793
+ }
13794
+ }
13795
+ }
13796
+ return index;
13797
+ }
13798
+
13799
+ // dist/model/solix-family.js
13800
+ var CATEGORY_FAMILY = {
13801
+ "Portable Power Station": "powerStation",
13802
+ "Power Bank": "powerBank",
13803
+ "Powered Cooler": "cooler",
13804
+ "Smart EV Charger": "evCharger",
13805
+ Charger: "charger"
13806
+ };
13807
+ var hasPrefix = (code, prefixes) => !!code && prefixes.some((p) => code.startsWith(p));
13808
+ var isSolixSolarbank = (input) => hasPrefix(input.product_code, SOLARBANK_MODELS) || input.category === "Plug-in Home Battery";
13809
+ var isSolixSmartMeter = (input) => hasPrefix(input.product_code, SOLIX_METER_MODELS);
13810
+ var isSolixPowerStation = (input) => input.category === "Portable Power Station";
13811
+ function solixProductFamily(input) {
13812
+ if (isSolixSmartMeter(input))
13813
+ return "smartMeter";
13814
+ if (isSolixSolarbank(input))
13815
+ return "solarbank";
13816
+ return (input.category ? CATEGORY_FAMILY[input.category] : void 0) ?? "unknown";
13817
+ }
13818
+
13819
+ // dist/model/solix-device.js
13820
+ var READ_ONLY_SINK = { dispatch: async () => {
13821
+ } };
13822
+ var SolixDevice = class {
13823
+ serial;
13824
+ productCode;
13825
+ record;
13826
+ caps;
13827
+ identity_;
13828
+ values = {};
13829
+ constructor(record, opts = {}) {
13830
+ this.record = record;
13831
+ this.serial = record.device_sn;
13832
+ this.productCode = record.product_code;
13833
+ const label = opts.catalog ? buildModelIndex(opts.catalog).get(record.product_code) : void 0;
13834
+ this.identity_ = {
13835
+ serial: record.device_sn,
13836
+ productCode: record.product_code,
13837
+ name: label?.name ?? record.alias_name ?? record.device_name ?? record.product_code,
13838
+ category: label?.category,
13839
+ family: solixProductFamily({ product_code: record.product_code, category: label?.category })
13840
+ };
13841
+ this.caps = detectSolixCapabilities(record, this.identity_.category);
13842
+ }
13843
+ /**
13844
+ * The device's {@link SolixProductFamily} — the classification a caller branches on to SORT devices
13845
+ * (a site's power stations vs its meters), the Solix analogue of eufy's `isHomeBase()`. Distinct from
13846
+ * {@link has}, which answers what the device can DO; family answers what KIND of device it is.
13847
+ */
13848
+ get family() {
13849
+ return this.identity_.family;
13850
+ }
13851
+ /** All capabilities this device carries. */
13852
+ get capabilities() {
13853
+ return [...this.caps];
13854
+ }
13855
+ /** Whether the device carries a capability — the only correct way to branch on behaviour. */
13856
+ has(capability) {
13857
+ return this.caps.has(capability);
13858
+ }
13859
+ /**
13860
+ * Merge a live telemetry reading (a `SolixMqtt` `reading` event) so accessors reflect it. Takes the
13861
+ * WHOLE reading, not just its values, and drops one addressed to a different device: the documented
13862
+ * wiring is `mqtt.on("reading", r => device.applyReading(r))`, and one MQTT stream carries every
13863
+ * watched meter on the account — so without this filter two meters would cross-feed each other's floats.
13864
+ * A reading with no `deviceSn` (a hand-built one) is accepted as-is.
13865
+ */
13866
+ applyReading(reading) {
13867
+ if (reading.deviceSn && reading.deviceSn !== this.serial)
13868
+ return;
13869
+ this.values = { ...this.values, ...reading.values };
13870
+ }
13871
+ /** All decoded float telemetry channels from the latest applied reading (raw, `channel_<tag>` keys). */
13872
+ telemetry() {
13873
+ return { ...this.values };
13874
+ }
13875
+ identity() {
13876
+ return { ...this.identity_ };
13877
+ }
13878
+ firmware() {
13879
+ return this.record.device_sw_version ? { version: this.record.device_sw_version } : void 0;
13880
+ }
13881
+ connectivity() {
13882
+ if (!this.has("connectivity"))
13883
+ return void 0;
13884
+ const rssi = this.record.rssi != null ? Number(this.record.rssi) : void 0;
13885
+ return {
13886
+ online: !!this.record.wifi_online,
13887
+ rssi: Number.isFinite(rssi) ? rssi : void 0,
13888
+ ssid: this.record.wifi_name
13889
+ };
13890
+ }
13891
+ /**
13892
+ * The members-derived `energyMeter` reads, or `undefined` when the device has no meter. Each getter is
13893
+ * installed only for a tag this device has actually reported, and reads `this.values` LIVE — a handle
13894
+ * held across an {@link applyReading} reflects the newer values. Getter INSTALLATION is fixed at the
13895
+ * time of this call, so re-call it to pick up a tag first seen since.
13896
+ */
13897
+ energyMeter() {
13898
+ if (!this.has("energyMeter"))
13899
+ return void 0;
13900
+ return bindMembers(SOLIX_ENERGY_METER_MEMBERS, this.meterDeps());
13901
+ }
13902
+ /**
13903
+ * The {@link MemberDeps} the members engine needs: a read closure over the live values store, and the
13904
+ * evidence gate (`ctx.paramIds`) rebuilt from the ff09 tags this device has reported. No `codec` — the
13905
+ * codecs are eufy transport families and a Solix device belongs to none of them. The sink is a no-op:
13906
+ * Solix telemetry is read-only, no member here dispatches a command.
13907
+ */
13908
+ meterDeps() {
13909
+ const read = (name) => {
13910
+ const v = this.values[name];
13911
+ return v === void 0 ? void 0 : { name, paramType: 0, value: v, ts: Date.now() };
13912
+ };
13913
+ return { ctx: { channel: 0, paramIds: this.seenTags() }, sink: READ_ONLY_SINK, read };
13914
+ }
13915
+ /** The ff09 tags this device has reported, derived from the decoder's `channel_<hex>` keys. */
13916
+ seenTags() {
13917
+ const tags = /* @__PURE__ */ new Set();
13918
+ for (const key of Object.keys(this.values)) {
13919
+ const m = /^channel_([0-9a-f]+)$/.exec(key);
13920
+ if (m)
13921
+ tags.add(parseInt(m[1], 16));
13922
+ }
13923
+ return tags;
13924
+ }
13925
+ };
13926
+ function resolveSolixCatalog(client, opts) {
13927
+ return opts.catalog ? Promise.resolve(opts.catalog) : client.getProductCatalog().catch(() => []);
13928
+ }
13929
+ async function discoverSolixDevices(client, opts = {}) {
13930
+ const [records, catalog] = await Promise.all([client.getDevices(), resolveSolixCatalog(client, opts)]);
13931
+ return records.map((r) => new SolixDevice(r, { catalog }));
13932
+ }
13933
+ function sceneNum(v) {
13934
+ if (typeof v === "number")
13935
+ return Number.isFinite(v) ? v : void 0;
13936
+ if (typeof v === "string" && v.trim() !== "") {
13937
+ const n = Number(v);
13938
+ return Number.isFinite(n) ? n : void 0;
13939
+ }
13940
+ return void 0;
13941
+ }
13942
+ function solarbankSceneReadings(scene) {
13943
+ const list = scene.solarbank_info?.solarbank_list ?? [];
13944
+ const out = [];
13945
+ for (const sb of list) {
13946
+ const deviceSn = typeof sb.device_sn === "string" ? sb.device_sn : void 0;
13947
+ if (!deviceSn)
13948
+ continue;
13949
+ const values = {};
13950
+ const temp = sceneNum(sb.bat_temperature);
13951
+ if (temp !== void 0)
13952
+ values.batteryTemperature = temp;
13953
+ const soc = sceneNum(sb.bat_soc);
13954
+ if (soc !== void 0)
13955
+ values.batterySoc = soc;
13956
+ if (Object.keys(values).length > 0)
13957
+ out.push({ deviceSn, values });
13958
+ }
13959
+ return out;
13960
+ }
13961
+
13962
+ // dist/model/solix-site.js
13963
+ var SolixSite = class {
13964
+ id;
13965
+ /** Friendly site name (e.g. "My Home"), or the site id when the record carries none. */
13966
+ name;
13967
+ /** Anker's site-type discriminator (e.g. 20 for a Solarbank-anchored home system), when present. */
13968
+ powerSiteType;
13969
+ record;
13970
+ devices;
13971
+ constructor(record, devices) {
13972
+ this.record = record;
13973
+ this.id = record.site_id;
13974
+ this.name = record.site_name || record.site_id;
13975
+ this.powerSiteType = record.power_site_type;
13976
+ this.devices = devices;
13977
+ }
13978
+ /** The member device with this serial, if it belongs to the site. */
13979
+ device(serial) {
13980
+ return this.devices.find((d) => d.serial === serial);
13981
+ }
13982
+ /** Member devices of a given product {@link SolixProductFamily} — the grouping accessor. */
13983
+ withFamily(family) {
13984
+ return this.devices.filter((d) => d.family === family);
13985
+ }
13986
+ /** Member devices that carry a given capability (e.g. every `battery` in the system). */
13987
+ withCapability(capability) {
13988
+ return this.devices.filter((d) => d.has(capability));
13989
+ }
13990
+ /** The Solarbank / plug-in home-battery members of the system. */
13991
+ solarbanks() {
13992
+ return this.withFamily("solarbank");
13993
+ }
13994
+ /** The smart-meter (grid-CT) members of the system. */
13995
+ smartMeters() {
13996
+ return this.withFamily("smartMeter");
13997
+ }
13998
+ /** The portable power-station members of the system. */
13999
+ powerStations() {
14000
+ return this.withFamily("powerStation");
14001
+ }
14002
+ };
14003
+ async function discoverSolixSites(client, opts = {}) {
14004
+ const [sites, records, catalog] = await Promise.all([
14005
+ client.getSites(),
14006
+ client.getDevices(),
14007
+ resolveSolixCatalog(client, opts)
14008
+ ]);
14009
+ const bySerial = new Map(records.map((r) => [r.device_sn, r]));
14010
+ return sites.map((site) => {
14011
+ const members = (site.site_device_list ?? []).map((entry) => {
14012
+ const record = bySerial.get(entry.device_sn) ?? {
14013
+ device_sn: entry.device_sn,
14014
+ product_code: entry.device_model,
14015
+ device_name: entry.device_name
14016
+ };
14017
+ return new SolixDevice(record, { catalog });
14018
+ });
14019
+ return new SolixSite(site, members);
14020
+ });
14021
+ }
14022
+
14023
+ // dist/client/map-channels.js
14024
+ var reader = (kind, decode) => (raw, codec) => {
14025
+ const value = decode(raw, codec);
14026
+ return value === void 0 ? void 0 : { kind, value };
14027
+ };
14028
+ var READER_BY_CHANNEL = {
14029
+ MAP_DATA: reader("plane", decodeVacuumMap),
14030
+ ROOM_OUTLINE: reader("outline", decodeVacuumRoomOutline),
14031
+ ROOM_PARAMS: reader("rooms", decodeVacuumRoomParams),
14032
+ RESTRICTED_ZONE: reader("zones", decodeVacuumRestrictedZones),
14033
+ DYNAMIC_DATA: reader("pose", decodeVacuumPose)
14034
+ };
14035
+ var DECODED_MAP_CHANNELS = Object.keys(READER_BY_CHANNEL);
14036
+ var READERS = new Map(DECODED_MAP_CHANNELS.map((name) => [BIZ_CHANNEL[name], READER_BY_CHANNEL[name]]));
14037
+ function decodeMapFrame(frame, codec) {
14038
+ if (frame.offset !== 0)
14039
+ return void 0;
14040
+ return READERS.get(frame.channelId)?.(frame.payload, codec);
13124
14041
  }
13125
14042
 
13126
14043
  // dist/transport/p2p/command-router.js
@@ -14201,19 +15118,27 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14201
15118
  * cameras from that second group streamed normally at level-1 — including one of the same firmware as an
14202
15119
  * own-session camera that delivered no video at all for a reason of its own. An expired grace therefore
14203
15120
  * separates nothing on this path, and a start failure on such a session is not evidence about it.
15121
+ *
15122
+ * Every `false` answer carries a `level2-unavailable` trace naming its reason, wherever the wait ended: a
15123
+ * `terminal` outcome is the one already stated where the negotiation concluded, since that is where the
15124
+ * cipher and the cause are known, and re-stating it here would double every settled negotiation.
14204
15125
  */
14205
15126
  async awaitLevel2Key(graceMs, graceFrom = "call") {
14206
- if (this.closed)
15127
+ if (this.closed) {
15128
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14207
15129
  return false;
15130
+ }
14208
15131
  if (this.level2Key)
14209
15132
  return true;
14210
- if (!this.level2Pending)
15133
+ if (!this.level2Pending) {
15134
+ this.trace({ phase: "level2-unavailable", reason: "not-negotiating" });
14211
15135
  return false;
15136
+ }
14212
15137
  const since = graceFrom === "call" ? Date.now() : this.connectedAtMs ?? Date.now();
14213
15138
  const remaining = graceMs - (Date.now() - since);
14214
15139
  if (remaining <= 0) {
14215
15140
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no level-2 key and its ${graceMs}ms grace has elapsed`);
14216
- this.trace({ phase: "level2-absent", waitedMs: graceMs });
15141
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: graceMs });
14217
15142
  return false;
14218
15143
  }
14219
15144
  this.logger.debug(`[p2p] ${this.cfg.stationSn} waiting up to ${remaining}ms for the level-2 key`);
@@ -14239,10 +15164,12 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14239
15164
  });
14240
15165
  if (outcome === "timeout") {
14241
15166
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 key did not arrive within its grace`);
15167
+ this.trace({ phase: "level2-unavailable", reason: "grace-elapsed", waitedMs: remaining });
14242
15168
  } else if (outcome === "terminal") {
14243
15169
  this.logger.debug(`[p2p] ${this.cfg.stationSn} level-2 negotiation concluded without a key`);
14244
15170
  } else if (outcome === "closed") {
14245
15171
  this.logger.debug(`[p2p] ${this.cfg.stationSn} session closed before the level-2 key arrived`);
15172
+ this.trace({ phase: "level2-unavailable", reason: "session-closed" });
14246
15173
  }
14247
15174
  return outcome === "key";
14248
15175
  }
@@ -14293,6 +15220,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14293
15220
  this.level2Negotiating = true;
14294
15221
  const generation = this.connectionGeneration;
14295
15222
  const cipherId = gatewayInfoCipherId(gwPayload);
15223
+ this.trace({ phase: "level2-negotiating", cipherId });
14296
15224
  void (async () => {
14297
15225
  try {
14298
15226
  const eccPrivHex = await this.cfg.resolveCipherKey?.(cipherId);
@@ -14300,11 +15228,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14300
15228
  return;
14301
15229
  if (!eccPrivHex) {
14302
15230
  this.logger.debug(`[p2p] ${this.cfg.stationSn} no ECC key for cipher_id ${cipherId}`);
15231
+ this.trace({ phase: "level2-unavailable", reason: "no-cipher-key", cipherId });
14303
15232
  this.settleLevel2();
14304
15233
  return;
14305
15234
  }
14306
15235
  const key = deriveLevel2KeyFromGatewayInfo(gwPayload, eccPrivHex);
14307
15236
  if (!key) {
15237
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14308
15238
  this.settleLevel2();
14309
15239
  this.emit("error", new Error(`level-2 key derivation failed (cipher_id ${cipherId})`));
14310
15240
  return;
@@ -14316,6 +15246,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14316
15246
  } catch (e) {
14317
15247
  if (this.closed || generation !== this.connectionGeneration)
14318
15248
  return;
15249
+ this.trace({ phase: "level2-unavailable", reason: "derivation-failed", cipherId });
14319
15250
  this.settleLevel2();
14320
15251
  this.emit("error", e instanceof Error ? e : new Error(String(e)));
14321
15252
  }
@@ -14383,6 +15314,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14383
15314
  if (host && !this.closed)
14384
15315
  this.selfAddress = { host, port: boundPort };
14385
15316
  });
15317
+ this.trace({
15318
+ phase: "lookup-channels",
15319
+ local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15320
+ cloud: this.cfg.dskKey !== void 0 && (this.cfg.cloudAddresses?.length ?? 0) > 0
15321
+ });
14386
15322
  this.sendLookups();
14387
15323
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
14388
15324
  this.connectTimer = setTimeout(() => {
@@ -14465,8 +15401,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
14465
15401
  for (const addr of this.cfg.cloudAddresses)
14466
15402
  this.send(addr, type, payload);
14467
15403
  this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: cloud -> ${this.cfg.cloudAddresses.map((a) => `${a.host}:${a.port}`).join(", ")} self=${this.selfAddress?.host}:${this.selfAddress?.port}`);
14468
- } else {
14469
- this.logger.debug(`[p2p] ${this.cfg.stationSn} sendLookups: NO cloud lookup sent (dskKey=${!!this.cfg.dskKey} cloudAddresses=${this.cfg.cloudAddresses?.length ?? 0})`);
14470
15404
  }
14471
15405
  }
14472
15406
  /**
@@ -15666,19 +16600,23 @@ function parseFf09SettingsResponse(plain) {
15666
16600
  if (plain.length < 1)
15667
16601
  throw new Error("ff09: settings response too short (missing status byte)");
15668
16602
  const status = plain[0];
16603
+ const fields = walkFf09Tlv(plain, 1, plain.length);
16604
+ return { status, fields };
16605
+ }
16606
+ function walkFf09Tlv(buf, start, end) {
15669
16607
  const fields = /* @__PURE__ */ new Map();
15670
- let i = 1;
15671
- while (i + 2 <= plain.length) {
15672
- const sep = plain[i];
15673
- if (sep === 0)
16608
+ let i = start;
16609
+ while (i + 2 <= end) {
16610
+ const tag2 = buf[i];
16611
+ if (tag2 === 0)
15674
16612
  break;
15675
- const len = plain[i + 1];
15676
- if (i + 2 + len > plain.length)
16613
+ const len = buf[i + 1];
16614
+ if (i + 2 + len > end)
15677
16615
  break;
15678
- fields.set(sep, plain.subarray(i + 2, i + 2 + len));
16616
+ fields.set(tag2, buf.subarray(i + 2, i + 2 + len));
15679
16617
  i += 2 + len;
15680
16618
  }
15681
- return { status, fields };
16619
+ return fields;
15682
16620
  }
15683
16621
  function readFf09U16LE(field, name) {
15684
16622
  if (!field || field.length < 2)
@@ -16332,8 +17270,9 @@ var LiveStream = class extends EventEmitter3 {
16332
17270
  * Stop re-issuing the media start once this camera's own media has arrived, on an attached camera.
16333
17271
  *
16334
17272
  * The nudge differs by topology and only one branch is a ping: an own-session camera sends a small
16335
- * keepalive, while an attached camera has no such state and re-sends the FULL media start. On a station that
16336
- * serves one camera at a time that restart re-asserts this channel against whatever else is warm, so two
17273
+ * keepalive, while an attached camera has no such state and re-sends the FULL media start. Over one session,
17274
+ * which serves one camera at a time, that restart re-asserts this channel against whatever else is warm on
17275
+ * it, so two
16337
17276
  * attached streams restart every interval and contend for the station continuously — measured on a real base
16338
17277
  * as a full start every 3 s from each.
16339
17278
  *
@@ -16381,10 +17320,12 @@ var LiveStream = class extends EventEmitter3 {
16381
17320
  if (!this.listening || this.kaTimer)
16382
17321
  return;
16383
17322
  if (this.opts.reassertWanted?.() === false) {
17323
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "declined" });
16384
17324
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms, and its owner does not want this channel re-asserted \u2014 staying quiet`);
16385
17325
  this.armStallWatch();
16386
17326
  return;
16387
17327
  }
17328
+ this.trace({ phase: "channel-silent", silentMs: stallMs, outcome: "reasserted" });
16388
17329
  this.logger.debug(`[live ch${this.channel}] no own media for ${stallMs}ms \u2014 re-asserting this camera's channel`);
16389
17330
  this.sendStart();
16390
17331
  this.kaTimer = setInterval(() => this.sendStart(), keepAliveMs);
@@ -16498,7 +17439,7 @@ var LiveStream = class extends EventEmitter3 {
16498
17439
  *
16499
17440
  * The match is UNCONDITIONAL, however long a station serves another camera instead of this one.
16500
17441
  *
16501
- * A station serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
17442
+ * One session serving one camera at a time hands a newly opened camera nothing but its sibling's frames until
16502
17443
  * it switches, so "no media of my own yet, plenty for someone else" is what an ordinary handover looks like
16503
17444
  * and does not distinguish a station that tags differently from one that is simply busy.
16504
17445
  *
@@ -16605,10 +17546,12 @@ async function captureSnapshotFromShared(source, opts = {}) {
16605
17546
  const timeoutMs = opts.timeoutMs ?? 2e4;
16606
17547
  const collectMs = opts.collectMs ?? 1500;
16607
17548
  const skip = opts.skipKeyframes ?? 1;
17549
+ opts.signal?.throwIfAborted();
16608
17550
  const consumer = source.attach();
16609
17551
  const primed = consumer.primed;
17552
+ let burst;
16610
17553
  try {
16611
- const burst = await new Promise((resolve, reject) => {
17554
+ burst = await new Promise((resolve, reject) => {
16612
17555
  const bufs = [];
16613
17556
  let sets = source.parameterSets;
16614
17557
  let codec = "h264";
@@ -16643,6 +17586,10 @@ async function captureSnapshotFromShared(source, opts = {}) {
16643
17586
  cleanup();
16644
17587
  reject(new LiveSnapshotUnavailableError("source-failed", `source ended before a keyframe (state: ${source.state})`));
16645
17588
  };
17589
+ const onAbandoned = () => {
17590
+ cleanup();
17591
+ reject(opts.signal?.reason);
17592
+ };
16646
17593
  const cleanup = () => {
16647
17594
  clearTimeout(timer);
16648
17595
  if (settle)
@@ -16650,19 +17597,21 @@ async function captureSnapshotFromShared(source, opts = {}) {
16650
17597
  consumer.off("video", onVideo);
16651
17598
  consumer.off("error", onError);
16652
17599
  consumer.off("stop", onStop);
17600
+ opts.signal?.removeEventListener("abort", onAbandoned);
16653
17601
  };
16654
17602
  consumer.on("video", onVideo);
16655
17603
  consumer.on("error", onError);
16656
17604
  consumer.on("stop", onStop);
16657
- });
16658
- return await annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
16659
- logger: opts.logger ?? noopLogger,
16660
- level: opts.ffmpegLevel,
16661
- executable: opts.ffmpegPath
17605
+ opts.signal?.addEventListener("abort", onAbandoned, { once: true });
16662
17606
  });
16663
17607
  } finally {
16664
17608
  consumer.detach();
16665
17609
  }
17610
+ return annexbToJpeg(primeForDecode(burst.h264, burst.sets, burst.codec), {
17611
+ logger: opts.logger ?? noopLogger,
17612
+ level: opts.ffmpegLevel,
17613
+ executable: opts.ffmpegPath
17614
+ });
16666
17615
  }
16667
17616
  async function recordClip(session, seconds, opts = {}) {
16668
17617
  const timeoutMs = opts.timeoutMs ?? 2e4;
@@ -17540,6 +18489,8 @@ var SharedLiveSource = class {
17540
18489
  this.configuredFrom = void 0;
17541
18490
  this.ring = [];
17542
18491
  this._state = state;
18492
+ if (state === "stopped")
18493
+ this.opts.onStopped?.();
17543
18494
  if (startFailed && report)
17544
18495
  this.opts.onStartFailed?.();
17545
18496
  }
@@ -17622,14 +18573,14 @@ var SessionManager = class {
17622
18573
  this.logger = opts.logger ?? noopLogger;
17623
18574
  }
17624
18575
  /** The live session for a station, or `undefined` if not open. */
17625
- get(parentSn) {
17626
- return this.entries.get(parentSn)?.session;
18576
+ get(key) {
18577
+ return this.entries.get(key)?.session;
17627
18578
  }
17628
- /** Serials of stations with a live session. */
18579
+ /** Keys of the open sessions. */
17629
18580
  keys() {
17630
18581
  return [...this.entries].filter(([, e]) => e.session).map(([sn]) => sn);
17631
18582
  }
17632
- /** A plain `Map<parentSn, P2PSession>` snapshot of the live sessions (for `getSessions()` / tests). */
18583
+ /** A plain `Map<key, P2PSession>` snapshot of the open sessions (for `getSessions()` / tests). */
17633
18584
  liveSessions() {
17634
18585
  const m = /* @__PURE__ */ new Map();
17635
18586
  for (const [sn, e] of this.entries)
@@ -17637,56 +18588,77 @@ var SessionManager = class {
17637
18588
  m.set(sn, e.session);
17638
18589
  return m;
17639
18590
  }
17640
- /** Get or create the lifecycle entry for a station. */
17641
- entry(parentSn) {
17642
- let e = this.entries.get(parentSn);
18591
+ /**
18592
+ * Get or create the lifecycle entry under `key`, recording which station it connects to. An existing
18593
+ * entry keeps the station it was opened with — the key owns one connection for its lifetime, and a
18594
+ * later caller passing a different station would otherwise re-point a live entry's power tier.
18595
+ */
18596
+ entry(key, station) {
18597
+ let e = this.entries.get(key);
17643
18598
  if (!e) {
17644
- e = { retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
17645
- this.entries.set(parentSn, e);
18599
+ e = { station, retained: 0, holdTimers: /* @__PURE__ */ new Set(), idle: new Timer() };
18600
+ this.entries.set(key, e);
17646
18601
  }
17647
18602
  return e;
17648
18603
  }
17649
- /** Register an already-built session for test seeding or an externally assembled connection. */
17650
- register(parentSn, session) {
17651
- this.entry(parentSn).session = session;
18604
+ /**
18605
+ * Register an already-built session for test seeding or an externally assembled connection.
18606
+ *
18607
+ * `station` defaults to the key, which is safe HERE and nowhere else in this class: the paths that open a
18608
+ * session under a key that is not a station serial all go through {@link acquire}, which requires it. A
18609
+ * caller seeding one under such a key must pass it.
18610
+ */
18611
+ register(key, session, station = key) {
18612
+ this.entry(key, station).session = session;
17652
18613
  }
17653
18614
  /**
17654
- * Ensure a session to `parentSn` is open, building it via `factory` if cold. Concurrent calls for the
18615
+ * Ensure a session to `key` is open, building it via `factory` if cold. Concurrent calls for the
17655
18616
  * same cold station share ONE connect (the `connecting` promise); `factory` builds + wires + awaits
17656
18617
  * `connect()` and resolves the connected session.
17657
18618
  */
17658
- async acquire(parentSn, factory) {
17659
- const e = this.entry(parentSn);
18619
+ async acquire(key, factory, station) {
18620
+ const e = this.entry(key, station);
17660
18621
  if (e.connecting) {
17661
- this.logger.debug(`[session ${parentSn}] connecting \u2014 joining in-flight open`);
18622
+ this.logger.debug(`[session ${key}] connecting \u2014 joining in-flight open`);
17662
18623
  return e.connecting;
17663
18624
  }
17664
18625
  if (e.session)
17665
18626
  return e.session;
17666
- this.logger.debug(`[session ${parentSn}] connecting now (on demand)`);
18627
+ this.logger.debug(`[session ${key}] connecting now (on demand)`);
17667
18628
  const generation = this.generation;
17668
18629
  const p = factory((session) => e.session = session);
17669
18630
  e.connecting = p;
17670
18631
  try {
17671
18632
  const session = await p;
17672
- if (generation !== this.generation || this.entries.get(parentSn) !== e) {
18633
+ if (generation !== this.generation || this.entries.get(key) !== e) {
17673
18634
  await session.close();
17674
- throw new SessionSupersededError(`P2P session start superseded for station ${parentSn}`);
18635
+ throw new SessionSupersededError(`P2P session start superseded for ${key}`);
17675
18636
  }
17676
18637
  e.session ??= session;
17677
- this.logger.debug(`[session ${parentSn}] connected`);
18638
+ this.logger.debug(`[session ${key}] connected`);
17678
18639
  return e.session;
17679
18640
  } finally {
17680
18641
  if (e.connecting === p)
17681
18642
  e.connecting = void 0;
17682
18643
  }
17683
18644
  }
17684
- /** Add a reason to stay connected; cancels a pending idle-close. */
17685
- retain(parentSn) {
17686
- const e = this.entry(parentSn);
18645
+ /**
18646
+ * Add a reason to stay connected; cancels a pending idle-close.
18647
+ *
18648
+ * Refused when nothing is open under `key`, for the reason {@link release} gives in the other direction: a
18649
+ * retain names a session that was acquired, and one that names nothing would file an entry with no
18650
+ * connection behind it. {@link hold} is the path that legitimately creates one, and it opens the entry
18651
+ * itself before retaining it.
18652
+ */
18653
+ retain(key) {
18654
+ const e = this.entries.get(key);
18655
+ if (!e) {
18656
+ this.logger.warn(`[session ${key}] retain on a session that is not open \u2014 ignored`);
18657
+ return;
18658
+ }
17687
18659
  e.retained++;
17688
18660
  if (e.idle.pending)
17689
- this.logger.debug(`[session ${parentSn}] in use again \u2014 idle-detach cancelled`);
18661
+ this.logger.debug(`[session ${key}] in use again \u2014 idle-detach cancelled`);
17690
18662
  e.idle.cancel();
17691
18663
  }
17692
18664
  /**
@@ -17697,44 +18669,50 @@ var SessionManager = class {
17697
18669
  * from scratch or complete a deferred reset a real viewer has not yet earned. Clamping to zero did
17698
18670
  * both silently.
17699
18671
  */
17700
- release(parentSn) {
17701
- const e = this.entries.get(parentSn);
18672
+ release(key) {
18673
+ const e = this.entries.get(key);
17702
18674
  if (!e)
17703
18675
  return;
17704
18676
  if (e.retained === 0) {
17705
- this.logger.warn(`[session ${parentSn}] release with nothing retained \u2014 ignored`);
18677
+ this.logger.warn(`[session ${key}] release with nothing retained \u2014 ignored`);
17706
18678
  return;
17707
18679
  }
17708
18680
  e.retained -= 1;
17709
18681
  if (e.reset && e.retained <= e.holdTimers.size) {
17710
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] deferred reset failed`, error));
18682
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] deferred reset failed`, error));
17711
18683
  return;
17712
18684
  }
17713
18685
  if (e.retained === 0)
17714
- this.armIdle(parentSn, e);
18686
+ this.armIdle(key, e);
17715
18687
  }
17716
18688
  /**
17717
18689
  * Hold a session warm for `commandKeepAliveMs` after a control command, then release. A burst of
17718
18690
  * commands each re-holds before the previous release fires, so the session never idles mid-burst.
17719
18691
  */
17720
- bumpCommand(parentSn) {
17721
- this.hold(parentSn, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS);
18692
+ bumpCommand(key, station) {
18693
+ this.hold(key, this.opts.commandKeepAliveMs ?? COMMAND_KEEPALIVE_MS, station);
17722
18694
  }
17723
18695
  /**
17724
- * Retain a station and release it again after `ms` — the primitive behind command-keepalive and event
18696
+ * Retain a session and release it again after `ms` — the primitive behind command-keepalive and event
17725
18697
  * pre-warm, and the only way to hold one open without an attachment to release it.
17726
18698
  *
18699
+ * `station` is required rather than defaulted from the key, because this is the one path that can
18700
+ * CREATE an entry: a pre-warm takes its hold before the open. An entry filed under a media key with
18701
+ * that key as its own station would be asked for the power tier of a serial that does not exist, be
18702
+ * answered `wired`, and never idle-detach — which on a battery station is the drain this class exists
18703
+ * to prevent, and is invisible until the battery is flat.
18704
+ *
17727
18705
  * The timer is owned by the entry, so {@link discard} cancels it. That ownership is the point: keyed
17728
18706
  * only by serial, an expiring hold would otherwise outlive the entry it was taken on and release a
17729
18707
  * retain counted by the SUCCESSOR entry — dropping a live viewer's count and arming an idle-detach
17730
18708
  * underneath it.
17731
18709
  */
17732
- hold(parentSn, ms) {
17733
- const entry = this.entry(parentSn);
17734
- this.retain(parentSn);
18710
+ hold(key, ms, station) {
18711
+ const entry = this.entry(key, station);
18712
+ this.retain(key);
17735
18713
  const timer = setTimeout(() => {
17736
18714
  entry.holdTimers.delete(timer);
17737
- this.release(parentSn);
18715
+ this.release(key);
17738
18716
  }, ms);
17739
18717
  timer.unref?.();
17740
18718
  entry.holdTimers.add(timer);
@@ -17743,29 +18721,29 @@ var SessionManager = class {
17743
18721
  * Arm the idle-close timer for a station whose retain count just reached zero. A wired station with
17744
18722
  * an infinite window is left persistent (no timer). Any subsequent {@link retain} cancels it.
17745
18723
  */
17746
- armIdle(parentSn, e) {
18724
+ armIdle(key, e) {
17747
18725
  e.idle.cancel();
17748
- if ((this.opts.poweredFor?.(parentSn) ?? "wired") !== "battery") {
17749
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 staying persistent (wired)`);
18726
+ if ((this.opts.poweredFor?.(e.station) ?? "wired") !== "battery") {
18727
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 staying persistent (wired)`);
17750
18728
  return;
17751
18729
  }
17752
18730
  const idleMs = this.opts.batteryIdleMs ?? BATTERY_IDLE_MS;
17753
- this.logger.debug(`[session ${parentSn}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
17754
- e.idle.arm(idleMs, () => this.onIdle(parentSn));
18731
+ this.logger.debug(`[session ${key}] idle (nothing retained) \u2014 detaching in ${idleMs}ms unless reused`);
18732
+ e.idle.arm(idleMs, () => this.onIdle(key));
17755
18733
  }
17756
18734
  /**
17757
18735
  * Close a station's session once its idle window elapses with nothing retained, letting the device sleep.
17758
18736
  * Re-checks the count first (activity between the timer firing and now re-arms instead). Dropping the
17759
18737
  * entry here and the session's own `close` → {@link remove} are both idempotent.
17760
18738
  */
17761
- onIdle(parentSn) {
17762
- const e = this.entries.get(parentSn);
18739
+ onIdle(key) {
18740
+ const e = this.entries.get(key);
17763
18741
  if (!e)
17764
18742
  return;
17765
18743
  if (e.retained > 0)
17766
18744
  return;
17767
- this.logger.debug(`[session ${parentSn}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
17768
- void this.autoClose(parentSn).catch((error) => this.logger.error(`[session ${parentSn}] idle detach failed`, error));
18745
+ this.logger.debug(`[session ${key}] idle window elapsed \u2014 disconnecting now (device can sleep)`);
18746
+ void this.autoClose(key).catch((error) => this.logger.error(`[session ${key}] idle detach failed`, error));
17769
18747
  }
17770
18748
  /**
17771
18749
  * Close a station the manager itself decided to close, and announce it.
@@ -17783,23 +18761,23 @@ var SessionManager = class {
17783
18761
  * An entry that never carried a session is still torn down, but silently: a pre-warm whose open failed
17784
18762
  * leaves one behind, and announcing it would report a station closed that was never reported open.
17785
18763
  */
17786
- async autoClose(parentSn) {
17787
- const entry = this.discard(parentSn);
18764
+ async autoClose(key) {
18765
+ const entry = this.discard(key);
17788
18766
  if (!entry)
17789
18767
  return;
17790
18768
  if (entry.session)
17791
- this.opts.onAutoClose?.(parentSn);
18769
+ this.opts.onAutoClose?.(key);
17792
18770
  await this.closeEntry(entry);
17793
18771
  }
17794
18772
  /** Drop a station's entry + timer (called from the session's `close` handler). Idempotent. */
17795
- remove(parentSn) {
17796
- const entry = this.discard(parentSn);
18773
+ remove(key) {
18774
+ const entry = this.discard(key);
17797
18775
  if (entry)
17798
18776
  this.settleReset(entry);
17799
18777
  }
17800
18778
  /** Close one station now and discard its lifecycle entry. */
17801
- async close(parentSn) {
17802
- const entry = this.discard(parentSn);
18779
+ async close(key) {
18780
+ const entry = this.discard(key);
17803
18781
  if (entry)
17804
18782
  await this.closeEntry(entry);
17805
18783
  }
@@ -17813,12 +18791,12 @@ var SessionManager = class {
17813
18791
  * Both branches close through {@link autoClose}: the caller asked for a recycle, not for the station's
17814
18792
  * live sources to be dropped, so it does not clean up after one — exactly like the idle path.
17815
18793
  */
17816
- resetWhenUnused(parentSn) {
17817
- const entry = this.entries.get(parentSn);
18794
+ resetWhenUnused(key) {
18795
+ const entry = this.entries.get(key);
17818
18796
  if (!entry)
17819
18797
  return Promise.resolve();
17820
18798
  if (entry.retained <= entry.holdTimers.size)
17821
- return this.autoClose(parentSn);
18799
+ return this.autoClose(key);
17822
18800
  entry.reset ??= Promise.withResolvers();
17823
18801
  return entry.reset.promise;
17824
18802
  }
@@ -17850,22 +18828,22 @@ var SessionManager = class {
17850
18828
  * discarded entry owns no live timer, which is what stops a deferred release from landing on whatever
17851
18829
  * entry next occupies this serial.
17852
18830
  */
17853
- discard(parentSn) {
17854
- const entry = this.entries.get(parentSn);
18831
+ discard(key) {
18832
+ const entry = this.entries.get(key);
17855
18833
  if (!entry)
17856
18834
  return void 0;
17857
18835
  entry.idle.cancel();
17858
18836
  for (const timer of entry.holdTimers)
17859
18837
  clearTimeout(timer);
17860
18838
  entry.holdTimers.clear();
17861
- this.entries.delete(parentSn);
18839
+ this.entries.delete(key);
17862
18840
  return entry;
17863
18841
  }
17864
18842
  /** Close every session and clear all timers. */
17865
18843
  async closeAll() {
17866
18844
  this.generation++;
17867
- const entries = [...this.entries.keys()].flatMap((parentSn) => {
17868
- const entry = this.discard(parentSn);
18845
+ const entries = [...this.entries.keys()].flatMap((key) => {
18846
+ const entry = this.discard(key);
17869
18847
  return entry ? [entry] : [];
17870
18848
  });
17871
18849
  const results = await Promise.allSettled(entries.map((entry) => this.closeEntry(entry)));
@@ -18855,7 +19833,6 @@ var FragmentRecording = class extends EventEmitter6 {
18855
19833
 
18856
19834
  // dist/transport/p2p/command-router.js
18857
19835
  var DIRECT_CMD_SENDS = 5;
18858
- var CONNECT_WAIT_MS = 2e4;
18859
19836
  function abortable(work, signal) {
18860
19837
  if (!signal)
18861
19838
  return work;
@@ -18869,6 +19846,11 @@ function abortable(work, signal) {
18869
19846
  }
18870
19847
  var LEVEL2_GRACE_MS = 25e3;
18871
19848
  var LEVEL2_SETTLE_MS = 8e3;
19849
+ var P2P_STATION_WAITS = {
19850
+ connect: CONNECT_TIMEOUT_MS,
19851
+ level2Grace: LEVEL2_GRACE_MS,
19852
+ level2Settle: LEVEL2_SETTLE_MS
19853
+ };
18872
19854
  var RTSP_URL_READ_TIMEOUT_MS = 12e3;
18873
19855
  var SHARED_LIVE_OPT_KEYS = [
18874
19856
  "eccPrivateKey",
@@ -18886,7 +19868,7 @@ function sameLiveOpt(a, b) {
18886
19868
  }
18887
19869
  var P2PCommandRouter = class _P2PCommandRouter {
18888
19870
  deps;
18889
- /** Per-station P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount. */
19871
+ /** P2P session lifecycle: on-demand open + battery-aware idle-detach + refcount, per session key. */
18890
19872
  manager;
18891
19873
  /** Error objects already forwarded while a station startup awaits the same session signal. */
18892
19874
  reportedErrors = /* @__PURE__ */ new WeakSet();
@@ -18894,6 +19876,21 @@ var P2PCommandRouter = class _P2PCommandRouter {
18894
19876
  liveSources = /* @__PURE__ */ new Map();
18895
19877
  /** The options each live source was built from, so a later caller's conflicting ones can be reported. */
18896
19878
  liveSourceOpts = /* @__PURE__ */ new Map();
19879
+ /**
19880
+ * The session each live source pulls over — the station's own serial, or the source's own media
19881
+ * session key.
19882
+ *
19883
+ * Written the instant the choice is made and BEFORE the connection is opened, which is what makes it
19884
+ * a reservation rather than a record. Two cameras started together (the four-tile case this feature
19885
+ * exists for) would otherwise both find the station's session free: a consumer attaches only after
19886
+ * `sharedLiveSourceFor` returns, so neither is visible to the other through consumer counts, and both
19887
+ * would take the shared connection and contend on it.
19888
+ *
19889
+ * A station entry is kept as well as a media one, because "is the station's own session already
19890
+ * claimed" is the question being asked, and an entry that is present but not yet in
19891
+ * {@link liveSources} is a claim in flight.
19892
+ */
19893
+ liveSessionKeys = /* @__PURE__ */ new Map();
18897
19894
  /**
18898
19895
  * The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
18899
19896
  * time and the session carries one audio sequence, so this path is exclusive where a live pull is
@@ -18907,7 +19904,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
18907
19904
  this.manager = new SessionManager({
18908
19905
  poweredFor: deps.poweredFor,
18909
19906
  logger: deps.logger,
18910
- onAutoClose: (parentSn) => this.tearDownStation(parentSn),
19907
+ onAutoClose: (key) => _P2PCommandRouter.isMediaSessionKey(key) ? this.tearDownMediaSession(key) : this.tearDownStation(key),
18911
19908
  ...deps.sessionIdle
18912
19909
  });
18913
19910
  }
@@ -18920,6 +19917,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
18920
19917
  }
18921
19918
  return normalized;
18922
19919
  }
19920
+ /**
19921
+ * Emit a live trace under a station session's handle, for work this router does ON that session before
19922
+ * the session itself records anything — reaching the station, and resolving what a device is on it. Same
19923
+ * handle as everything the session goes on to trace, which is what groups one attempt.
19924
+ */
19925
+ traceOnStation(session, trace) {
19926
+ traceLiveStart(this.deps.logger ?? noopLogger, trace, session.traceId);
19927
+ }
18923
19928
  /**
18924
19929
  * Whether this transport stack drives `dev`'s `ff09-*` commands — true when the device has its own
18925
19930
  * usable P2P endpoint (a non-empty `p2p_did`). The command sink asks each stack this to route a
@@ -18931,7 +19936,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
18931
19936
  static claimsDevice(dev) {
18932
19937
  return typeof dev.p2pDid === "string" && dev.p2pDid.length > 0;
18933
19938
  }
18934
- /** Stations with a live P2P session (a snapshot; mutate via the lifecycle methods, not this map). */
19939
+ /**
19940
+ * The open P2P sessions by key — a station's own under its serial, a camera's media session under
19941
+ * `<stationSn>#live:<channel>` (a snapshot; mutate via the lifecycle methods, not this map).
19942
+ */
18935
19943
  getSessions() {
18936
19944
  return this.manager.liveSessions();
18937
19945
  }
@@ -18943,16 +19951,16 @@ var P2PCommandRouter = class _P2PCommandRouter {
18943
19951
  *
18944
19952
  * One hold, taken before the open so a slow connect can't idle-close mid-flight. It expires on its
18945
19953
  * own, which arms the station's idle window rather than closing the session, per {@link PREWARM_MS}.
18946
- * A second hold after the open would buy nothing: {@link openStation} returns once the socket is bound
19954
+ * A second hold after the open would buy nothing: {@link openSession} returns once the socket is bound
18947
19955
  * and the lookups are away, not once the peer has answered, so both would expire together.
18948
19956
  *
18949
19957
  * Best-effort — a failed open surfaces via `onError`. A {@link SessionSupersededError} does not: the
18950
19958
  * session was deliberately closed underneath a speculative open, which is not a fault to report.
18951
19959
  */
18952
19960
  async prewarm(parentSn, ms = PREWARM_MS) {
18953
- this.manager.hold(parentSn, ms);
19961
+ this.manager.hold(parentSn, ms, parentSn);
18954
19962
  try {
18955
- await this.openStation(parentSn);
19963
+ await this.openSession(parentSn, parentSn);
18956
19964
  } catch (e) {
18957
19965
  if (e instanceof SessionSupersededError)
18958
19966
  return;
@@ -18969,6 +19977,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
18969
19977
  src.dispose();
18970
19978
  this.liveSources.clear();
18971
19979
  this.liveSourceOpts.clear();
19980
+ this.liveSessionKeys.clear();
18972
19981
  await this.manager.closeAll();
18973
19982
  }
18974
19983
  /** This serial's loaded record, or `undefined` — the one place the cached list is searched by serial. */
@@ -19010,8 +20019,55 @@ var P2PCommandRouter = class _P2PCommandRouter {
19010
20019
  * when present, else the freshest private IP in the record ({@link freshestLanIp}) — so P2P works
19011
20020
  * on-LAN even when broadcast is blocked (AP isolation) or the record's `ip_addr` went stale.
19012
20021
  */
19013
- async openStation(parentSn) {
19014
- return this.manager.acquire(parentSn, async (register) => {
20022
+ /**
20023
+ * The key a camera's own media session is filed under, distinct from every station serial because a
20024
+ * serial contains no `#`.
20025
+ */
20026
+ static mediaSessionKey(parentSn, channel) {
20027
+ return `${parentSn}#live:${channel}`;
20028
+ }
20029
+ /** Whether `key` names a media session rather than a station's own. */
20030
+ static isMediaSessionKey(key) {
20031
+ return key.includes("#live:");
20032
+ }
20033
+ /**
20034
+ * Open (or reuse) a SECOND connection to a station, carrying one camera's media and nothing else.
20035
+ *
20036
+ * One session serves one camera: a station fans its cameras over a session and answers the most recent
20037
+ * start on it, so two cameras down one tunnel take it from each other in turn. Another connection is
20038
+ * how a station serves another camera.
20039
+ *
20040
+ * The hardware was shown to do this before the SDK did. A first-party display showing four tiles was
20041
+ * captured opening one PPCS session per camera, with three cameras' video arriving in the same second
20042
+ * at 2304x1296, 1600x1200 and 3840x2160 — three geometries at once, which no composed stream can be.
20043
+ * Reproduced here afterwards on a base carrying two attached cameras, one at 3840x2160, both holding
20044
+ * full frame rate at once over a session each, where the same pair down one session could only take
20045
+ * turns.
20046
+ *
20047
+ * It carries media alone. The station announces its state to every client that connects, so a session
20048
+ * wired to the same fan-out would report every event a second time; {@link makeSession} leaves this one
20049
+ * unannounced, and the station's own session stays the single source of connection state, control
20050
+ * notifications and frames.
20051
+ *
20052
+ * The level-2 key is waited for here on the same terms the station's own session gets, because a
20053
+ * connection is not usable for this without one: an attached camera's media start has no level-1 form,
20054
+ * so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
20055
+ * shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
20056
+ * carry the start is not a connected session, so this refuses rather than returning one.
20057
+ */
20058
+ async openMediaSession(parentSn, channel, signal) {
20059
+ const session = await this.openSession(_P2PCommandRouter.mediaSessionKey(parentSn, channel), parentSn);
20060
+ let ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
20061
+ if (!ready && session.repromptLevel2Key()) {
20062
+ ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), signal);
20063
+ }
20064
+ if (!ready)
20065
+ throw new StationKeyUnavailableError(parentSn);
20066
+ return session;
20067
+ }
20068
+ /** Open (or reuse) the session filed under `key`, dialling `parentSn`'s endpoint. */
20069
+ async openSession(key, parentSn) {
20070
+ return this.manager.acquire(key, async (register) => {
19015
20071
  const stationDev = this.recordFor(parentSn);
19016
20072
  if (!stationDev)
19017
20073
  throw new Error(`station ${parentSn} is not in the device list`);
@@ -19026,11 +20082,11 @@ var P2PCommandRouter = class _P2PCommandRouter {
19026
20082
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19027
20083
  }
19028
20084
  const localAddress = this.deps.localAddresses?.[parentSn] ?? freshestLanIp(raw);
19029
- const session = this.makeSession(parentSn, did, raw, dskKey, localAddress);
20085
+ const session = this.makeSession(parentSn, did, raw, dskKey, localAddress, key, key === parentSn);
19030
20086
  register(session);
19031
20087
  await session.connect();
19032
20088
  return session;
19033
- });
20089
+ }, parentSn);
19034
20090
  }
19035
20091
  /**
19036
20092
  * Build + wire a {@link P2PSession} for a station (NOT yet connected — the caller awaits `connect()`).
@@ -19043,8 +20099,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19043
20099
  *
19044
20100
  * The `close` handler drops the session from the {@link SessionManager} and disposes any shared live
19045
20101
  * source riding this station (consumers get `stop`; a later attach rebuilds via the factory).
20102
+ *
20103
+ * A session filed under a media key is wired for errors and its own teardown ONLY. Connection state,
20104
+ * level-2 readiness and inbound frames all reach the owner through the station's own session, and a
20105
+ * station announces those to every client that connects — so fanning a second connection's copies out
20106
+ * under the same station serial would report each one twice, and a close would tear down the station
20107
+ * while its own session is still serving.
19046
20108
  */
19047
- makeSession(stationSn, did, raw, dskKey, localAddress) {
20109
+ makeSession(stationSn, did, raw, dskKey, localAddress, key, announces) {
19048
20110
  const conn = raw?.p2p_conn ?? raw?.app_conn;
19049
20111
  const adminUserId = raw?.member?.admin_user_id || this.deps.mega.auth?.userId || "";
19050
20112
  const session = new P2PSession({
@@ -19060,7 +20122,15 @@ var P2PCommandRouter = class _P2PCommandRouter {
19060
20122
  let ecc;
19061
20123
  try {
19062
20124
  const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
19063
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key ?? ciphers[0]?.ecc_private_key;
20125
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
20126
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
20127
+ ecc = ciphers[0].ecc_private_key;
20128
+ this.traceOnStation(session, {
20129
+ phase: "cipher-fallback",
20130
+ cipherId,
20131
+ answeredCipherId: Number(ciphers[0].cipher_id)
20132
+ });
20133
+ }
19064
20134
  } catch (e) {
19065
20135
  this.deps.onError(e instanceof Error ? e : new Error(String(e)));
19066
20136
  }
@@ -19070,6 +20140,14 @@ var P2PCommandRouter = class _P2PCommandRouter {
19070
20140
  },
19071
20141
  logger: this.deps.logger ?? noopLogger
19072
20142
  });
20143
+ session.on("error", (e) => this.reportError(e));
20144
+ if (!announces) {
20145
+ session.on("close", () => {
20146
+ if (this.manager.get(key) === session)
20147
+ this.tearDownMediaSession(key);
20148
+ });
20149
+ return session;
20150
+ }
19073
20151
  session.on("connect", () => {
19074
20152
  if (this.manager.get(stationSn) === session)
19075
20153
  this.deps.onConnect(stationSn);
@@ -19079,7 +20157,6 @@ var P2PCommandRouter = class _P2PCommandRouter {
19079
20157
  return;
19080
20158
  this.tearDownStation(stationSn);
19081
20159
  });
19082
- session.on("error", (e) => this.reportError(e));
19083
20160
  session.on("level2Ready", ({ cipherId }) => this.deps.onLevel2Ready(stationSn, cipherId));
19084
20161
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
19085
20162
  return session;
@@ -19090,7 +20167,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19090
20167
  */
19091
20168
  async ensureStation(parentSn, signal) {
19092
20169
  try {
19093
- const session = await this.openStation(parentSn);
20170
+ const session = await this.openSession(parentSn, parentSn);
19094
20171
  if (session.isConnected)
19095
20172
  return;
19096
20173
  await new Promise((resolve, reject) => {
@@ -19138,7 +20215,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19138
20215
  const dev = this.recordFor(sn);
19139
20216
  if (!dev)
19140
20217
  throw new Error(`device ${sn} not found`);
19141
- await this.openStation(this.stationKeyFor(dev));
20218
+ await this.openSession(this.stationKeyFor(dev), this.stationKeyFor(dev));
19142
20219
  return dev;
19143
20220
  }
19144
20221
  /**
@@ -19214,19 +20291,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
19214
20291
  return {
19215
20292
  snapshotLive: async (opts) => {
19216
20293
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
19217
- return abortable(captureSnapshotFromShared(source, {
20294
+ return captureSnapshotFromShared(source, {
19218
20295
  ...opts,
19219
20296
  logger: this.deps.logger ?? noopLogger,
19220
20297
  ffmpegLevel: this.deps.ffmpegLogLevel,
19221
20298
  ffmpegPath: this.deps.ffmpegPath
19222
- }), opts?.signal);
20299
+ });
19223
20300
  },
19224
20301
  live: async (opts) => {
19225
- const source = await this.sharedLiveSourceFor(sn, opts);
20302
+ const source = await this.sharedLiveSourceFor(sn, opts, true);
19226
20303
  return this.attachUnlessAborted(source, opts?.signal);
19227
20304
  },
19228
20305
  openReadable: async (opts) => {
19229
- const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20306
+ const source = await this.sharedLiveSourceFor(sn, opts ?? {}, true);
19230
20307
  return openReadableFromConsumer(this.attachUnlessAborted(source, opts?.signal), opts);
19231
20308
  },
19232
20309
  recordFragments: (opts) => this.recordFragments(sn, opts),
@@ -19367,10 +20444,17 @@ var P2PCommandRouter = class _P2PCommandRouter {
19367
20444
  * {@link releaseLingeringSiblings}. A pull with consumers is never touched. The release runs before the
19368
20445
  * reuse branch, so a reuse frees the station as a cold start does.
19369
20446
  *
20447
+ * `mayOpenOwnSession` decides whether a camera that finds the station's own session claimed may open a
20448
+ * connection of its own for it. Only the continuous-pull egresses pass it. A still must not: it wants one
20449
+ * frame, and a socket plus a level-2 negotiation per thumbnail is a cost a tile refresh cannot justify —
20450
+ * so a still asked for while a sibling is being watched contends as it always did, and the caller's
20451
+ * retained image answers it. A live view still outranks a tile; what changed is that two live views no
20452
+ * longer have to outrank each other.
20453
+ *
19370
20454
  * The session goes into a {@link HeldSession} cell, so it can be replaced under a source that stays in
19371
20455
  * place.
19372
20456
  */
19373
- async sharedLiveSourceFor(sn, opts = {}) {
20457
+ async sharedLiveSourceFor(sn, opts = {}, mayOpenOwnSession = false) {
19374
20458
  const { session, parentSn, channel, accountId, homeBaseAttached } = await this.resolveSession(sn, {
19375
20459
  waitLevel2: "soft",
19376
20460
  requireLevel2ForAttached: true,
@@ -19383,14 +20467,19 @@ var P2PCommandRouter = class _P2PCommandRouter {
19383
20467
  source = void 0;
19384
20468
  }
19385
20469
  this.releaseLingeringSiblings(parentSn, channel);
19386
- if (homeBaseAttached) {
19387
- const serving = this.occupiedSiblingChannel(parentSn, key);
19388
- if (serving !== void 0)
19389
- throw new StationBusyError(serving);
19390
- }
19391
20470
  if (!source) {
19392
20471
  const logger = this.deps.logger ?? noopLogger;
19393
- const held = { session };
20472
+ const sessionKey = mayOpenOwnSession && homeBaseAttached && this.stationSessionInUse(parentSn, key) ? _P2PCommandRouter.mediaSessionKey(parentSn, channel) : parentSn;
20473
+ this.liveSessionKeys.set(key, sessionKey);
20474
+ let held;
20475
+ try {
20476
+ held = {
20477
+ session: sessionKey === parentSn ? session : await this.openMediaSession(parentSn, channel, opts.signal)
20478
+ };
20479
+ } catch (error) {
20480
+ this.liveSessionKeys.delete(key);
20481
+ throw error;
20482
+ }
19394
20483
  source = new SharedLiveSource({
19395
20484
  makeStream: (ctx) => new LiveStream(held.session, {
19396
20485
  channel,
@@ -19408,8 +20497,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19408
20497
  budgetGraceMs: opts.budgetGraceMs,
19409
20498
  logger,
19410
20499
  label: key,
19411
- onActive: () => this.manager.retain(parentSn),
19412
- onIdle: () => this.manager.release(parentSn),
20500
+ onActive: () => this.manager.retain(sessionKey),
20501
+ onIdle: () => this.manager.release(sessionKey),
20502
+ onStopped: () => this.closeMediaSession(key),
19413
20503
  onStartFailed: () => this.onLiveStartFailed(sn, key),
19414
20504
  onSessionUnreachable: () => this.replaceUnreachableSession(sn, key, held)
19415
20505
  });
@@ -19424,14 +20514,25 @@ var P2PCommandRouter = class _P2PCommandRouter {
19424
20514
  * Tear down any pull on this station that is lingering for ANOTHER camera, before starting this one.
19425
20515
  *
19426
20516
  * A lingering pull has no consumers but is still held open, and on an attached camera holding it open means
19427
- * re-sending the full media start every keepalive tick. Two channels doing that at once on a station that
19428
- * serves one camera at a time leaves the new stream receiving nothing but the old camera's frames for as
19429
- * long as the linger lasts.
20517
+ * re-sending the full media start every keepalive tick. Two channels doing that at once over ONE session,
20518
+ * which serves one camera at a time, leaves the new stream receiving nothing but the old camera's frames
20519
+ * for as long as the linger lasts.
19430
20520
  *
19431
20521
  * Several cameras genuinely being WATCHED together are never disturbed — the linger exists to make
19432
20522
  * re-opening the SAME camera cheap, and it keeps doing that. What it may not do is keep a camera nobody is
19433
20523
  * looking at competing with one somebody just asked for.
19434
20524
  *
20525
+ * An `idle` source is skipped, because it is not a linger and holds nothing: it has never warmed, so it
20526
+ * has sent no media start and is competing for nothing. Without that, two cameras opened at the same
20527
+ * moment destroy each other — the second finds the first's source built but not yet attached to, reads
20528
+ * zero consumers as a linger, and disposes the source its caller is holding. Which is the four-tile case
20529
+ * this whole path exists for.
20530
+ *
20531
+ * A sibling lingering on a connection of its OWN is skipped for the same reason stated the other way: the
20532
+ * contention this releases is contention over one session, and that sibling is not on this one. Dropping
20533
+ * it would close a socket and throw away the cheap re-attach the linger exists to provide, to relieve a
20534
+ * competition that is not happening.
20535
+ *
19435
20536
  * A snapshot tile is nobody looking. Opening a live view in the Home app takes that cell fullscreen, so the
19436
20537
  * pulls refreshing the other cells are off screen, yet each goes on re-issuing its own media start every
19437
20538
  * retry tick — measured as four pulls warming together off one HomeBase, a live request landing 1.4 s later,
@@ -19445,28 +20546,49 @@ var P2PCommandRouter = class _P2PCommandRouter {
19445
20546
  for (const [key, source] of [...this.liveSources]) {
19446
20547
  if (!key.startsWith(`${parentSn}:`) || key === own || source.consumerCount > 0)
19447
20548
  continue;
19448
- (this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one station serves one camera at a time`);
20549
+ if (source.state === "idle")
20550
+ continue;
20551
+ if (this.liveSessionKeys.get(key) !== parentSn)
20552
+ continue;
20553
+ (this.deps.logger ?? noopLogger).debug(`[live ${key}] releasing a pull nothing is attached to so ${own} can start \u2014 one session serves one camera at a time`);
19449
20554
  this.dropLiveSource(key);
19450
20555
  }
19451
20556
  }
19452
20557
  /**
19453
- * The channel a live viewer already holds on this station, if any, ignoring `key` itself.
20558
+ * Whether another camera has already claimed this station's OWN session, ignoring `key`.
20559
+ *
20560
+ * The question a newcomer has to answer is not whether the station is busy — it can serve one camera
20561
+ * per connection — but whether the connection it would otherwise share is taken. A camera on a media
20562
+ * session of its own does not hold this one, so a station whose first camera has since stopped hands
20563
+ * its own session to the next arrival rather than opening a socket beside an idle one.
20564
+ *
20565
+ * A claim counts while its source is still `idle`, source or no source. That is a start that has been
20566
+ * handed to its caller but not yet attached to, and it is the only state in which two cameras asked for
20567
+ * at the same moment can see each other: consumers attach after this method has already run for both.
20568
+ *
20569
+ * The cost is that a pull genuinely abandoned before its first attach goes on holding the station's own
20570
+ * session, and the next camera pays for a connection of its own rather than reclaiming it. That is one
20571
+ * socket against destroying a start someone is waiting on, which is not a close trade.
19454
20572
  *
19455
20573
  * A stopped source is skipped even when consumers are still attached to it. A failed start fails its
19456
20574
  * consumers without detaching them, so a caller still holding a dead handle leaves the count non-zero,
19457
- * and counting that as a viewer would refuse every later stream on the station until the client
19458
- * restarted. Only a source that can still deliver holds a place.
20575
+ * and counting that as a viewer would put every later camera on a connection of its own until the
20576
+ * client restarted. Only a source that can still deliver holds a place.
19459
20577
  */
19460
- occupiedSiblingChannel(parentSn, key) {
19461
- for (const [siblingKey, sibling] of this.liveSources) {
19462
- if (siblingKey === key || !siblingKey.startsWith(`${parentSn}:`))
20578
+ stationSessionInUse(parentSn, key) {
20579
+ for (const [siblingKey, sessionKey] of this.liveSessionKeys) {
20580
+ if (siblingKey === key || sessionKey !== parentSn)
20581
+ continue;
20582
+ const sibling = this.liveSources.get(siblingKey);
20583
+ if (!sibling)
20584
+ return true;
20585
+ if (sibling.state === "stopped")
19463
20586
  continue;
19464
- if (sibling.state === "stopped" || sibling.consumerCount === 0)
20587
+ if (sibling.state !== "idle" && sibling.consumerCount === 0)
19465
20588
  continue;
19466
- const channel = Number(siblingKey.slice(parentSn.length + 1));
19467
- return Number.isFinite(channel) ? channel : void 0;
20589
+ return true;
19468
20590
  }
19469
- return void 0;
20591
+ return false;
19470
20592
  }
19471
20593
  /**
19472
20594
  * Whether the stream on `key` should re-assert its channel to hold the station.
@@ -19477,8 +20599,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
19477
20599
  * - Nothing attached: no. There is nobody to take the station for.
19478
20600
  * - A live viewer attached: yes. That is the picture someone is looking at.
19479
20601
  * - Held only for stills, while a sibling on this station has a live viewer: no. A still refreshes a
19480
- * tile that is off screen while the live view is on it, and a station serving one camera at a time
19481
- * cannot satisfy both. Measured: a still on a sibling halved a live view's frame rate for as long as
20602
+ * tile that is off screen while the live view is on it, and one session serving one camera at a time
20603
+ * cannot satisfy both — and a still does not open a connection of its own. Measured: a still on a sibling halved a live view's frame rate for as long as
19482
20604
  * it took, and its own capture then took fifteen seconds because it was contending.
19483
20605
  *
19484
20606
  * A still with no live sibling re-asserts, so a tile refreshing on a quiet station is
@@ -19500,7 +20622,13 @@ var P2PCommandRouter = class _P2PCommandRouter {
19500
20622
  }
19501
20623
  return consumer;
19502
20624
  }
19503
- /** Dispose one cached live source and forget it, so the next acquisition builds a fresh one. */
20625
+ /**
20626
+ * Dispose one cached live source and forget it, so the next acquisition builds a fresh one. Its claim
20627
+ * on a session goes with it, and a media session opened for this source alone is closed: nothing else
20628
+ * can reach that connection, so leaving it open would hold a socket and a station keepalive for a
20629
+ * camera no longer being pulled. A claim on the STATION's own session is released without closing
20630
+ * anything — that connection carries the station's control traffic and outlives any one camera.
20631
+ */
19504
20632
  dropLiveSource(key) {
19505
20633
  const source = this.liveSources.get(key);
19506
20634
  if (!source)
@@ -19508,6 +20636,34 @@ var P2PCommandRouter = class _P2PCommandRouter {
19508
20636
  source.dispose();
19509
20637
  this.liveSources.delete(key);
19510
20638
  this.liveSourceOpts.delete(key);
20639
+ this.closeMediaSession(key);
20640
+ }
20641
+ /** Close and forget the media session `key`'s live source owned, if it owned one. */
20642
+ closeMediaSession(key) {
20643
+ const sessionKey = this.liveSessionKeys.get(key);
20644
+ if (sessionKey === void 0)
20645
+ return;
20646
+ this.liveSessionKeys.delete(key);
20647
+ if (!_P2PCommandRouter.isMediaSessionKey(sessionKey))
20648
+ return;
20649
+ void this.manager.close(sessionKey).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
20650
+ }
20651
+ /**
20652
+ * Drop the live source a media session was carrying, after that session closed on its own.
20653
+ *
20654
+ * The source holds the closed connection and never re-resolves it, so it can only answer its retained
20655
+ * keyframe and then fail on its warm-up deadline. Its consumers get `stop`, and the next attach builds
20656
+ * a fresh source — which, finding the station busy again, opens a fresh media session for it.
20657
+ */
20658
+ tearDownMediaSession(sessionKey) {
20659
+ this.manager.remove(sessionKey);
20660
+ for (const [key, owned] of this.liveSessionKeys) {
20661
+ if (owned === sessionKey) {
20662
+ this.liveSessionKeys.delete(key);
20663
+ this.dropLiveSource(key);
20664
+ return;
20665
+ }
20666
+ }
19511
20667
  }
19512
20668
  /**
19513
20669
  * Drop everything that was riding a station's session, and report the station closed.
@@ -19691,9 +20847,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
19691
20847
  s.sendStringPayloadCommand(P2P_ENVELOPE.CONTROL_PAYLOAD, json, ch);
19692
20848
  return Promise.resolve();
19693
20849
  },
19694
- l2: async ({ session: s, channel: ch }) => {
20850
+ l2: async ({ session: s, channel: ch, parentSn }) => {
19695
20851
  if (!await s.awaitLevel2Key(LEVEL2_GRACE_MS, "call")) {
19696
- throw new Error(`level-2 key not ready for ${sn} \u2014 cannot query`);
20852
+ throw new StationKeyUnavailableError(parentSn);
19697
20853
  }
19698
20854
  s.sendRawLevel2(json, ch, P2P_ENVELOPE.CONTROL_PAYLOAD);
19699
20855
  }
@@ -19851,24 +21007,40 @@ var P2PCommandRouter = class _P2PCommandRouter {
19851
21007
  const parentSn = homeBaseAttached ? raw.parent_sn : dev.stationSn ?? sn;
19852
21008
  const session = this.manager.get(parentSn) ?? this.manager.get(sn) ?? (dev.stationSn ? this.manager.get(dev.stationSn) : void 0);
19853
21009
  if (!session) {
19854
- throw new Error(`no P2P session for ${sn} (known: ${this.manager.keys().join(", ") || "none"})`);
21010
+ const stations = this.manager.keys().filter((k) => !_P2PCommandRouter.isMediaSessionKey(k));
21011
+ throw new Error(`no P2P session for ${sn} (known: ${stations.join(", ") || "none"})`);
19855
21012
  }
19856
21013
  if (session.pathAnswering === false && !rebuilt) {
19857
21014
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
19858
21015
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
19859
21016
  return await this.resolveSession(sn, opts, true);
19860
21017
  }
19861
- this.manager.bumpCommand(parentSn);
21018
+ this.manager.bumpCommand(parentSn, parentSn);
19862
21019
  const channel = typeof raw.device_channel === "number" ? raw.device_channel : 0;
19863
- const accountId = raw.member?.admin_user_id ?? this.deps.mega.auth?.userId ?? "";
21020
+ const stationAdminId = raw.member?.admin_user_id;
21021
+ const stationModel = this.recordFor(parentSn)?.model;
21022
+ const accountId = stationAdminId ?? this.deps.mega.auth?.userId ?? "";
21023
+ this.traceOnStation(session, {
21024
+ phase: "station-resolved",
21025
+ topology: homeBaseAttached ? "attached" : "own",
21026
+ channel,
21027
+ stationAdmin: typeof stationAdminId !== "string" ? "unstated" : stationAdminId === this.deps.mega.auth?.userId ? "self" : "other",
21028
+ ...stationModel ? { stationModel } : {}
21029
+ });
19864
21030
  const t0 = Date.now();
19865
- while (!session.isConnected && Date.now() - t0 < CONNECT_WAIT_MS) {
19866
- opts.signal?.throwIfAborted();
19867
- await sleep2(200);
21031
+ let waitedMs = 0;
21032
+ if (!session.isConnected) {
21033
+ this.traceOnStation(session, { phase: "session-connect-wait", waitMs: P2P_STATION_WAITS.connect });
21034
+ while (!session.isConnected && Date.now() - t0 < P2P_STATION_WAITS.connect) {
21035
+ opts.signal?.throwIfAborted();
21036
+ await sleep2(200);
21037
+ }
21038
+ waitedMs = Date.now() - t0;
21039
+ this.traceOnStation(session, session.isConnected ? { phase: "session-connected", waitedMs } : { phase: "session-unreachable", waitedMs });
19868
21040
  }
19869
21041
  opts.signal?.throwIfAborted();
19870
21042
  if (!session.isConnected)
19871
- throw new Error(`P2P session for ${parentSn} did not connect`);
21043
+ throw new StationUnreachableError(parentSn, waitedMs);
19872
21044
  if (opts.waitLevel2) {
19873
21045
  if (opts.waitLevel2 === "settle") {
19874
21046
  await abortable(session.awaitLevel2Key(LEVEL2_SETTLE_MS, "session"), opts.signal);
@@ -19882,7 +21054,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
19882
21054
  ready = await abortable(session.awaitLevel2Key(LEVEL2_GRACE_MS, "call"), opts.signal);
19883
21055
  }
19884
21056
  if (!ready)
19885
- throw new Error(`level-2 key not ready for ${parentSn}`);
21057
+ throw new StationKeyUnavailableError(parentSn);
19886
21058
  }
19887
21059
  return { session, parentSn, channel, accountId, homeBaseAttached };
19888
21060
  }
@@ -19955,13 +21127,22 @@ var P2PCommandRouter = class _P2PCommandRouter {
19955
21127
  * standalone camera never negotiates a level-2 key, so pinning this to level 2 makes the envelope
19956
21128
  * unreachable on exactly the devices that serve their own RTSP stream. Verified live: a standalone
19957
21129
  * camera accepts the level-1 form. With no `form` (default) it stays level-2 only.
21130
+ *
21131
+ * Both seals REPLAY the frame {@link DIRECT_CMD_SENDS}× at 200ms, as every other fire-and-forget
21132
+ * control on this router does: these are unacknowledged datagrams, and a level-1 device is the one
21133
+ * least able to afford a single dropped one — it has no reply, no readback here, and nothing that
21134
+ * would tell a caller the write was lost rather than refused. The level-1 form reports delivery by
21135
+ * throwing (`sendSetPayload` throws when the session has no address) rather than by returning a
21136
+ * boolean, so the first pass carries the failure and the rest are repeats.
19958
21137
  */
19959
21138
  async sendSetPayloadEnvelope(sn, cmd, payload, channel, mValue3, resolved, form) {
19960
21139
  if (form === "auto") {
19961
21140
  await this.sendBySessionLevel(sn, {
19962
- l1: ({ session, accountId }) => {
19963
- session.sendSetPayload(cmd, payload, { accountId, channel });
19964
- return Promise.resolve();
21141
+ l1: async ({ session, accountId }) => {
21142
+ for (let i = 0; i < DIRECT_CMD_SENDS; i++) {
21143
+ session.sendSetPayload(cmd, payload, { accountId, channel });
21144
+ await sleep2(200);
21145
+ }
19965
21146
  },
19966
21147
  // NB: do NOT forward sendBySessionLevel's resolved session here — it was resolved with
19967
21148
  // waitLevel2:false (enough to read topology), so on a HomeBase-attached device the level-2
@@ -21369,8 +22550,8 @@ function rsaEncryptPassword(password, publicKeyDecimal, exponentDecimal) {
21369
22550
  },
21370
22551
  format: "jwk"
21371
22552
  });
21372
- const md5Hex = createHash6("md5").update(password).digest("hex");
21373
- return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex)).toString("hex");
22553
+ const md5Hex2 = createHash6("md5").update(password).digest("hex");
22554
+ return publicEncrypt({ key: rsaKey, padding: constants2.RSA_PKCS1_PADDING }, Buffer.from(md5Hex2)).toString("hex");
21374
22555
  }
21375
22556
  var TuyaClient = class {
21376
22557
  signer;
@@ -22633,8 +23814,21 @@ var FileFcmStore = class {
22633
23814
  }
22634
23815
  };
22635
23816
 
23817
+ // dist/transport/media-failure.js
23818
+ var MEDIA_FAILURE_REASONS = [
23819
+ "url-not-allowed",
23820
+ "address-not-public",
23821
+ "redirect-not-allowed",
23822
+ "http-status",
23823
+ "too-large",
23824
+ "timeout",
23825
+ "network",
23826
+ "decode-failed"
23827
+ ];
23828
+
22636
23829
  // dist/transport/stored-image-cache.js
22637
23830
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
23831
+ var MAX_REMEMBERED_URLS = 64;
22638
23832
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
22639
23833
  var StoredImageCache = class {
22640
23834
  downloader;
@@ -22651,7 +23845,13 @@ var StoredImageCache = class {
22651
23845
  this.clock = clock;
22652
23846
  this.isLifecycleError = isLifecycleError;
22653
23847
  }
22654
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
23848
+ /**
23849
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
23850
+ *
23851
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
23852
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
23853
+ * so the entry evicted once it is full is the oldest attempt.
23854
+ */
22655
23855
  observe(deviceKey, url) {
22656
23856
  let state = this.devices.get(deviceKey);
22657
23857
  if (!state) {
@@ -22666,6 +23866,12 @@ var StoredImageCache = class {
22666
23866
  if (state.seenUrls.has(url))
22667
23867
  return;
22668
23868
  state.seenUrls.add(url);
23869
+ while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
23870
+ const oldest = state.seenUrls.values().next();
23871
+ if (oldest.done)
23872
+ break;
23873
+ state.seenUrls.delete(oldest.value);
23874
+ }
22669
23875
  state.queued = {
22670
23876
  deviceKey,
22671
23877
  url,
@@ -22715,7 +23921,7 @@ var StoredImageCache = class {
22715
23921
  } else if (image === void 0) {
22716
23922
  state.lifecycleError = void 0;
22717
23923
  state.reason = "download-failed";
22718
- this.diagnose(state, candidate, "download-failed");
23924
+ this.diagnose(state, candidate, "download-failed", error);
22719
23925
  } else if (!this.isValidJpeg(image)) {
22720
23926
  state.lifecycleError = void 0;
22721
23927
  state.reason = "invalid-image";
@@ -22731,7 +23937,7 @@ var StoredImageCache = class {
22731
23937
  isValidJpeg(image) {
22732
23938
  return Buffer.isBuffer(image) && image.length > 0 && image.length <= MAX_JPEG_BYTES && image.length >= 5 && image[0] === 255 && image[1] === 216 && image[2] === 255 && image[image.length - 2] === 255 && image[image.length - 1] === 217;
22733
23939
  }
22734
- diagnose(state, candidate, failure) {
23940
+ diagnose(state, candidate, failure, error) {
22735
23941
  const now2 = this.clock();
22736
23942
  const last = state.loggedFailures.get(failure);
22737
23943
  if (last !== void 0 && now2 - last < DIAGNOSTIC_INTERVAL_MS)
@@ -22739,11 +23945,23 @@ var StoredImageCache = class {
22739
23945
  state.loggedFailures.set(failure, now2);
22740
23946
  this.logger.warn("[stored-snapshot-cache] candidate failed", {
22741
23947
  class: failure,
23948
+ ...mediaFailureTag(error),
22742
23949
  observedAt: candidate.observedAt,
22743
23950
  retained: state.retained !== void 0
22744
23951
  });
22745
23952
  }
22746
23953
  };
23954
+ function mediaFailureTag(error) {
23955
+ if (typeof error !== "object" || error === null)
23956
+ return {};
23957
+ const { mediaFailure, status } = error;
23958
+ if (typeof mediaFailure !== "string")
23959
+ return {};
23960
+ const cause = MEDIA_FAILURE_REASONS.find((reason) => reason === mediaFailure);
23961
+ if (!cause)
23962
+ return {};
23963
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? { cause, status } : { cause };
23964
+ }
22747
23965
 
22748
23966
  // dist/client/device-registry.js
22749
23967
  function resolvedStationSn(raw, sn) {
@@ -23997,8 +25215,8 @@ var EufyMega = class extends EventEmitter9 {
23997
25215
  * @example
23998
25216
  * ```ts
23999
25217
  * const res = await eufy.login();
24000
- * if (res.status === "captcha") await eufy.solveCaptcha(await ask(res.image));
24001
- * else if (res.status === "2fa") await eufy.submitVerifyCode(await ask());
25218
+ * if (res.status === "captcha") await eufy.solveCaptcha(await promptUser(res.image));
25219
+ * else if (res.status === "2fa") await eufy.submitVerifyCode(await promptUser());
24002
25220
  * ```
24003
25221
  */
24004
25222
  async login(opts = {}) {
@@ -24152,9 +25370,9 @@ var EufyMega = class extends EventEmitter9 {
24152
25370
  /**
24153
25371
  * Combine explicit P2P media with the optional passive push-thumbnail provider.
24154
25372
  *
24155
- * The retained still also becomes the answer for a live still that could not be captured. A station
24156
- * serves one camera at a time and a live view outranks a tile, so a still asked for while a sibling is
24157
- * being watched is refused at the transport. Answering the retained bytes answers the read rather than
25373
+ * The retained still also becomes the answer for a live still that could not be captured. One session
25374
+ * serves one camera at a time and a live view outranks a tile — a still does not open a connection of its
25375
+ * own — so a still asked for while a sibling is being watched is refused at the transport. Answering the retained bytes answers the read rather than
24158
25376
  * failing it, marked {@link MediaProvider.snapshotLive} `retained` so the caller knows they are not
24159
25377
  * current. With nothing retained the refusal stands.
24160
25378
  */
@@ -24323,7 +25541,7 @@ var EufyMega = class extends EventEmitter9 {
24323
25541
  * @example
24324
25542
  * ```ts
24325
25543
  * const dev = await eufy.getDevice(sn);
24326
- * if (dev.has("camera")) await dev.camera()?.snapshotStored();
25544
+ * if (dev.has("camera")) await dev.camera?.()?.snapshotStored?.();
24327
25545
  * console.log(dev.getProperty("battery"));
24328
25546
  * ```
24329
25547
  */
@@ -24907,10 +26125,15 @@ var EufyMega = class extends EventEmitter9 {
24907
26125
  await sink.dispatch(cmd);
24908
26126
  }
24909
26127
  /**
24910
- * Stations with a live P2P session. P2P is auto-managed: wired stations are warmed at login, battery
26128
+ * The open P2P sessions, by key. P2P is auto-managed: wired stations are warmed at login, battery
24911
26129
  * stations open on demand (command / stream, or an opted-in event pre-warm) and idle-detach — so this
24912
- * map grows and shrinks over time. `p2pConnect(stationSn)` / `p2pClose(stationSn)` events track the
24913
- * changes.
26130
+ * map grows and shrinks over time.
26131
+ *
26132
+ * A station's own session is keyed by its serial, and `p2pConnect(stationSn)` / `p2pClose(stationSn)`
26133
+ * track those. A station serving more than one camera at once also holds a session per extra camera,
26134
+ * keyed `<stationSn>#live:<channel>` — these carry media alone and raise no connection events, because
26135
+ * a station announces its state to every client that connects and reporting each copy would double
26136
+ * every event the station's own session already delivers.
24914
26137
  */
24915
26138
  getP2pSessions() {
24916
26139
  return this.p2p.getSessions();
@@ -25283,6 +26506,971 @@ var EufyMega = class extends EventEmitter9 {
25283
26506
  }
25284
26507
  };
25285
26508
 
26509
+ // dist/transport/http/solix-constants.js
26510
+ var SOLIX_APP_NAME = "anker_power";
26511
+ var SOLIX_ESTIMATE_HOST = "uniapp-api-pr.anker.com";
26512
+ var SOLIX_DEFAULT_API_HOST = "ankerpower-api-eu.anker.com";
26513
+ var SOLIX_ENDPOINTS = {
26514
+ estimateDomain: "/passport/estimate_domain",
26515
+ keyExchange: "/openapi/oauth/key/exchange",
26516
+ login: "/passport/login",
26517
+ /** Bound devices for the account (flat list). */
26518
+ getRelateAndBindDevices: "/power_service/v1/app/get_relate_and_bind_devices",
26519
+ /** Sites (systems) the account owns; devices are grouped under a site. */
26520
+ getSiteList: "/power_service/v1/site/get_site_list",
26521
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26522
+ getUserMqttInfo: "/v1/openapi/devicemanage/get_user_mqtt_info",
26523
+ /** GET: the pairable-product catalog (categories → products), for labelling model codes. */
26524
+ productCategories: "/power_service/v1/product_categories",
26525
+ /** POST (encrypted+signed): write device attributes, e.g. `{ambient_light_switch: 0|1}`. */
26526
+ setDeviceAttrs: "/power_service/v1/app/device/set_device_attrs",
26527
+ /** POST (plain authed): read device attributes, e.g. the display `screen_off_time` (seconds). */
26528
+ getDeviceAttrs: "/power_service/v1/app/device/get_device_attrs",
26529
+ /** POST (plain authed): the battery discharge-cutoff (minimum-SOC) preset options. */
26530
+ getPowerCutoff: "/power_service/v1/app/compatible/get_power_cutoff",
26531
+ /** POST (encrypted+signed): select the discharge-cutoff preset by `cutoff_data_id`. */
26532
+ setPowerCutoff: "/power_service/v1/app/compatible/set_power_cutoff",
26533
+ /**
26534
+ * POST (plain authed): the site "scene" snapshot — the same clean Solarbank/grid telemetry the app
26535
+ * reads on load/refresh. Used as a low-rate BACKSTOP for the fields the realtime `ff09` push doesn't
26536
+ * carry reliably (notably `bat_temperature`), NOT as the realtime source (that is the MQTT push).
26537
+ */
26538
+ getSiteScene: "/power_service/v2/site/platform_get_site_scene",
26539
+ /**
26540
+ * POST (plain authed): read a site "device param" block by `param_type`. Body is
26541
+ * `{ site_id, param_type, cmd: 246 }`; the response's `data.param_data` is a JSON STRING the caller
26542
+ * parses. The Solarbank's SOC-limit settings live under `param_type "27"` (charge/discharge limits,
26543
+ * backup reserve) — verified live on an AE103 (`"18"` returns empty for this device).
26544
+ */
26545
+ getSiteDeviceParam: "/power_service/v1/site/get_site_device_param",
26546
+ /**
26547
+ * POST (encrypted+signed): write a site "device param" block. Body is
26548
+ * `{ site_id, cmd: 246, param_type, param_data: <JSON string> }`. Used for the SOC-limit write
26549
+ * (`param_type "27"`, `param_data` = the SocSettingParam map) — see {@link SolixClient.setSafetySocParams}.
26550
+ */
26551
+ setSiteDeviceParam: "/power_service/v1/site/set_site_device_param"
26552
+ };
26553
+
26554
+ // dist/transport/http/solix-client.js
26555
+ function solixSessionFresh(s) {
26556
+ return !!s?.authToken && tokenNotExpired(s.tokenExpiresAt);
26557
+ }
26558
+ var SOLIX_TOKEN_KICKED_CODE = 26084;
26559
+ var uuidFromHex = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
26560
+ var SolixClient = class _SolixClient {
26561
+ email;
26562
+ password;
26563
+ country;
26564
+ appVersion;
26565
+ doFetch;
26566
+ store;
26567
+ openudid;
26568
+ apiHost;
26569
+ session_;
26570
+ /** Carried between {@link login} and {@link submitVerifyCode} while a 2FA code is outstanding. */
26571
+ pending2fa;
26572
+ /**
26573
+ * Resolve the device id (explicit → stored → deterministic from the email, so it is stable and does
26574
+ * not re-trigger 2FA) and adopt a stored session that has not expired, so a warm start skips the
26575
+ * handshake. An explicit `opts.apiHost` outranks a stored session's host in both cases: it is an
26576
+ * override that also skips domain-estimate, and every read goes through `this.apiHost`.
26577
+ */
26578
+ constructor(opts) {
26579
+ this.email = opts.email;
26580
+ this.password = opts.password;
26581
+ this.country = (opts.countryCode ?? "US").toUpperCase();
26582
+ this.appVersion = opts.appVersion ?? "3.23.0";
26583
+ this.doFetch = opts.fetchImpl ?? fetch;
26584
+ this.apiHost = opts.apiHost ?? SOLIX_DEFAULT_API_HOST;
26585
+ this.store = opts.store;
26586
+ const saved = this.store?.load();
26587
+ this.openudid = opts.openudid ?? saved?.openudid ?? uuidFromHex(md5Hex(`anker-solix:${opts.email}`));
26588
+ if (saved?.session && solixSessionFresh(saved.session)) {
26589
+ this.session_ = saved.session;
26590
+ this.apiHost = opts.apiHost ?? saved.session.apiHost;
26591
+ }
26592
+ }
26593
+ /** Persist the current device id (+ session, if any) when a store is configured. */
26594
+ persist() {
26595
+ this.store?.save({ openudid: this.openudid, session: this.session_ });
26596
+ }
26597
+ /** The authenticated session, once {@link login} has resolved to `ok`. */
26598
+ get session() {
26599
+ return this.session_;
26600
+ }
26601
+ /**
26602
+ * Headers for the login/key-exchange path, which carry the device id. Authenticated resource reads
26603
+ * must NOT send `openudid` — the gateway rejects a token-bearing read that also carries a device id
26604
+ * (`401 token error`) — so those use {@link baseHeaders} directly.
26605
+ */
26606
+ authHeaders(extra = {}) {
26607
+ return this.baseHeaders({ openudid: this.openudid, "x-terminal-id": this.openudid, ...extra });
26608
+ }
26609
+ /** Base headers common to every Solix request. */
26610
+ baseHeaders(extra = {}) {
26611
+ return {
26612
+ "content-type": "application/json",
26613
+ "app-name": SOLIX_APP_NAME,
26614
+ "model-type": "PHONE",
26615
+ "os-type": "android",
26616
+ "os-version": "36",
26617
+ "app-version": this.appVersion,
26618
+ country: this.country,
26619
+ timezone: "GMT+00:00",
26620
+ language: "en",
26621
+ "user-agent": "ktor-client",
26622
+ accept: "application/json",
26623
+ ...extra
26624
+ };
26625
+ }
26626
+ /** One request path for every Solix call (GET or POST) — always parses through the non-JSON guard. */
26627
+ async send(method2, host, path, headers, body) {
26628
+ const res = await this.doFetch(`https://${host}${path}`, {
26629
+ method: method2,
26630
+ headers,
26631
+ body,
26632
+ signal: AbortSignal.timeout(2e4)
26633
+ });
26634
+ const text2 = await res.text();
26635
+ try {
26636
+ return JSON.parse(text2);
26637
+ } catch {
26638
+ throw new Error(`Solix ${path} \u2192 HTTP ${res.status}, non-JSON: ${text2.slice(0, 120)}`);
26639
+ }
26640
+ }
26641
+ /** POST helper for the login/key-exchange path (which builds its own bespoke headers per request). */
26642
+ post(host, path, body, headers) {
26643
+ return this.send("POST", host, path, headers, body);
26644
+ }
26645
+ /** Resolve the regional API host via domain-estimate (best-effort; keeps the default on failure). */
26646
+ async estimateHost() {
26647
+ try {
26648
+ const env = await this.post(SOLIX_ESTIMATE_HOST, SOLIX_ENDPOINTS.estimateDomain, JSON.stringify({ ab: this.country, mode: 1 }), this.baseHeaders());
26649
+ const domain = env.data?.domain;
26650
+ if (domain)
26651
+ this.apiHost = domain;
26652
+ } catch {
26653
+ }
26654
+ }
26655
+ /** Do the localKey-bootstrapped ECDH key exchange and return the negotiated session key. */
26656
+ async keyExchange() {
26657
+ const prep = prepareKeyExchange(SOLIX_LOCAL_KEY_HEX);
26658
+ const env = await this.post(this.apiHost, SOLIX_ENDPOINTS.keyExchange, JSON.stringify({ client_public_key: prep.encryptedClientPublicKey }), this.authHeaders(prep.headers));
26659
+ const spk = env.data?.server_public_key;
26660
+ if (env.code !== 0 || !spk)
26661
+ throw new Error(`Solix key/exchange failed (${env.code}): ${env.msg}`);
26662
+ return finishKeyExchange(prep, spk);
26663
+ }
26664
+ /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
26665
+ async postLogin(kx, verifyCode, limitedToken) {
26666
+ const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
26667
+ const bodyObj = {
26668
+ email: this.email,
26669
+ password: encryptedPassword,
26670
+ ab: this.country,
26671
+ client_secret_info: { public_key: clientPublicKeyHex },
26672
+ answer: "",
26673
+ captcha_id: "",
26674
+ verify_code: verifyCode ?? "",
26675
+ login_id: ""
26676
+ };
26677
+ const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
26678
+ const ts = nowSec();
26679
+ const once = genId();
26680
+ return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
26681
+ "x-encryption-info": "algo_ecdh",
26682
+ "x-key-ident": kx.keyIdent,
26683
+ "x-request-ts": ts,
26684
+ "x-request-once": once,
26685
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26686
+ ...limitedToken ? { "x-auth-token": limitedToken } : {}
26687
+ }));
26688
+ }
26689
+ /**
26690
+ * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
26691
+ * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
26692
+ * has been satisfied.
26693
+ *
26694
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
26695
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
26696
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
26697
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
26698
+ */
26699
+ classifyLogin(data, isVerify) {
26700
+ const userId = data.ap_cloud_user_id ?? data.user_id;
26701
+ const authToken = data.auth_token;
26702
+ if (!userId || !authToken)
26703
+ throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
26704
+ const faInfo = data.fa_info ?? {};
26705
+ if (!isVerify && faInfo.info) {
26706
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
26707
+ return { status: "2fa", method: "code sent by the passport" };
26708
+ }
26709
+ this.pending2fa = void 0;
26710
+ this.session_ = {
26711
+ authToken,
26712
+ userId,
26713
+ gtoken: gtoken(userId),
26714
+ apiHost: this.apiHost,
26715
+ tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
26716
+ };
26717
+ this.persist();
26718
+ return { status: "ok", session: this.session_ };
26719
+ }
26720
+ /** Decrypt a login envelope's `data` (base64 `IV(16)||AES-128-CBC`, keyed by the share key). */
26721
+ decryptLogin(env, kx) {
26722
+ if (typeof env.data !== "string")
26723
+ throw new Error(`Solix login (${env.code}): ${env.msg}`);
26724
+ return JSON.parse(decryptBody(env.data, kx.shareKey).toString("utf-8"));
26725
+ }
26726
+ /**
26727
+ * Authenticate with the account credentials. Resolves to `ok` with a {@link SolixSession}, or `2fa`
26728
+ * when the passport sent a code — then call {@link submitVerifyCode}. A session that is already fresh
26729
+ * (adopted from a store) is answered without a handshake.
26730
+ */
26731
+ async login() {
26732
+ if (solixSessionFresh(this.session_)) {
26733
+ return { status: "ok", session: this.session_ };
26734
+ }
26735
+ await this.estimateHost();
26736
+ const kx = await this.keyExchange();
26737
+ const env = await this.postLogin(kx);
26738
+ this.assertLoginAccepted(env);
26739
+ return this.classifyLogin(this.decryptLogin(env, kx), false);
26740
+ }
26741
+ /**
26742
+ * On a rejected `/passport/login` (non-zero code, so `data` is an error envelope not the encrypted
26743
+ * payload), throw a diagnostic that names WHY the passport refused — the throttle (`26161`, "too
26744
+ * frequent") vs a challenge it wants the client to satisfy. The passport marks a required captcha with
26745
+ * a `captcha_id`/`item`; our headless client cannot answer one, so surfacing it distinguishes "wait
26746
+ * out the rate-limit" from "a captcha is required — clear it in the app". No secrets are logged, only
26747
+ * the code, message, and which challenge fields are present.
26748
+ */
26749
+ assertLoginAccepted(env) {
26750
+ if (env.code === 0)
26751
+ return;
26752
+ const d = env.data ?? {};
26753
+ const hints = [];
26754
+ if (typeof d === "object" && d) {
26755
+ if ("captcha_id" in d && d.captcha_id)
26756
+ hints.push("captcha_id present (captcha required)");
26757
+ if ("item" in d && d.item)
26758
+ hints.push(`item=${String(d.item).slice(0, 40)}`);
26759
+ const keys = Object.keys(d);
26760
+ if (keys.length && hints.length === 0)
26761
+ hints.push(`data keys: ${keys.join(",")}`);
26762
+ }
26763
+ const detail = hints.length ? ` [${hints.join("; ")}]` : "";
26764
+ throw new Error(`Solix login (${env.code}): ${env.msg}${detail}`);
26765
+ }
26766
+ /** Complete a `2fa` login with the code the passport sent. */
26767
+ async submitVerifyCode(code) {
26768
+ if (!this.pending2fa)
26769
+ throw new Error("no 2FA login is pending");
26770
+ const kx = await this.keyExchange();
26771
+ const env = await this.postLogin(kx, code, this.pending2fa.limitedToken);
26772
+ return this.classifyLogin(this.decryptLogin(env, kx), true);
26773
+ }
26774
+ /**
26775
+ * One authenticated PLAIN read for both GET and POST endpoints (no per-request encryption; carries
26776
+ * the auth token + `gtoken` only). Routes through {@link send} so every read keeps the non-JSON guard.
26777
+ *
26778
+ * Self-heals a **displaced session**: Anker allows ~one session per account, so another login (the app,
26779
+ * or a second client) invalidates this token and reads then fail with {@link SOLIX_TOKEN_KICKED_CODE}
26780
+ * ("token does not exist because it was kicked out"). On that code this re-logs in once and retries, so
26781
+ * a running client recovers on its own instead of failing every read until its session store is cleared.
26782
+ */
26783
+ async authed(method2, path, body, reauthed = false) {
26784
+ if (!this.session_)
26785
+ throw new Error("not authenticated \u2014 call login() first");
26786
+ 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);
26787
+ if (env.code === 0)
26788
+ return env.data ?? null;
26789
+ if (env.code === SOLIX_TOKEN_KICKED_CODE && !reauthed) {
26790
+ this.session_ = void 0;
26791
+ const r = await this.login();
26792
+ if (r.status !== "ok")
26793
+ throw new Error(`Solix ${path}: session was kicked and re-login did not complete (${r.status})`);
26794
+ return this.authed(method2, path, body, true);
26795
+ }
26796
+ throw new Error(`Solix ${path} failed (${env.code}): ${env.msg}`);
26797
+ }
26798
+ /**
26799
+ * The account's bound Solix devices (flat list; may be empty when devices live under sites). The
26800
+ * gateway's JSON is asserted to {@link SolixDeviceRecord} here, at the one trust boundary — every field
26801
+ * beyond `device_sn`/`product_code` is optional on the record, so a caller reads them defensively.
26802
+ */
26803
+ async getDevices() {
26804
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getRelateAndBindDevices, {});
26805
+ return Array.isArray(data) ? data : data?.data ?? [];
26806
+ }
26807
+ /**
26808
+ * The account's sites (systems); devices are grouped under a site. Each record carries its
26809
+ * `site_device_list` (the member devices), which {@link discoverSolixSites} resolves into a
26810
+ * capability-driven `SolixSite`. Asserted to {@link SolixSiteRecord} at this trust boundary — and
26811
+ * `site_id` (the one field the model layer keys a `SolixSite` on) is validated here, so a record the
26812
+ * cloud returns without a usable id is dropped rather than surfacing a `SolixSite` with `id ===
26813
+ * undefined`; every other field is optional and read defensively.
26814
+ */
26815
+ async getSites() {
26816
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteList, {});
26817
+ const list = data?.site_list ?? [];
26818
+ return list.filter((s) => typeof s?.site_id === "string" && s.site_id.length > 0);
26819
+ }
26820
+ /** Per-user AWS-IoT MQTT credentials (cert/key/endpoint/thing) for the real-time device plane. */
26821
+ async getUserMqttInfo() {
26822
+ return this.authed("POST", SOLIX_ENDPOINTS.getUserMqttInfo, {});
26823
+ }
26824
+ /**
26825
+ * Read a site's "scene" snapshot — the app's dashboard read for a system, a plain authed read. Its
26826
+ * battery detail (`solarbank_info.solarbank_list[]`) carries clean, correctly-named fields including
26827
+ * `bat_temperature`, which the realtime `ff09` MQTT push does NOT reliably carry (the fast frame's BMS
26828
+ * blob is empty, so the decoder withholds temperature). This is therefore a low-rate BACKSTOP for those
26829
+ * gap fields — NOT the realtime source: live power/SOC still come from the MQTT push (which is what the
26830
+ * app itself refreshes from every ~5 s; there is no clean-JSON scene PUSH). Verified live against the
26831
+ * `ff09` floats — the two agree to the watt at the same instant.
26832
+ */
26833
+ async getSiteScene(siteId) {
26834
+ return this.authed("POST", SOLIX_ENDPOINTS.getSiteScene, { site_id: siteId });
26835
+ }
26836
+ /**
26837
+ * The pairable-product catalog (categories → products). This is Anker's product registry, not the
26838
+ * account's devices — fetch it to label a discovered device's model code with a marketing name and
26839
+ * category. Pair with {@link buildModelIndex}. It is a live endpoint, so it stays current without a
26840
+ * baked-in table.
26841
+ */
26842
+ async getProductCatalog() {
26843
+ return await this.authed("GET", SOLIX_ENDPOINTS.productCategories) ?? [];
26844
+ }
26845
+ /**
26846
+ * Write device attributes — a CONTROL write, e.g. the Solarbank ambient light
26847
+ * `{ ambient_light_switch: 0 | 1 }` (0 = on, 1 = off). Unlike the plain authenticated reads, a write
26848
+ * must be **encrypted + signed** with a freshly negotiated `algo_ecdh` key: the gateway accepts an
26849
+ * unsigned write with `code 0` but the device never applies it. The token-bearing request also must
26850
+ * NOT carry the device id (`openudid`), or the gateway answers `401 token error`. Both verified live
26851
+ * on an AE103 (the LED-enable bit in the `ba` telemetry flips exactly as commanded).
26852
+ */
26853
+ async setDeviceAttrs(deviceSn, attributes) {
26854
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setDeviceAttrs, "set_device_attrs", {
26855
+ device_sn: deviceSn,
26856
+ attributes
26857
+ });
26858
+ }
26859
+ /**
26860
+ * A CONTROL write: `algo_ecdh`-encrypted + signed, token-bearing but WITHOUT `openudid`. Every device
26861
+ * control the account performs (set_device_attrs, set_power_cutoff, …) goes through this — the gateway
26862
+ * accepts an unsigned/plain write with `code 0` but the device never applies it, and adding `openudid`
26863
+ * to the token-bearing request returns `401 token error`. Both verified live on an AE103.
26864
+ */
26865
+ async encryptedWrite(path, label, payload) {
26866
+ if (!this.session_)
26867
+ throw new Error("not authenticated \u2014 call login() first");
26868
+ const kx = await this.keyExchange();
26869
+ const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
26870
+ const ts = nowSec();
26871
+ const once = genId();
26872
+ const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
26873
+ "x-encryption-info": "algo_ecdh",
26874
+ "x-key-ident": kx.keyIdent,
26875
+ "x-request-ts": ts,
26876
+ "x-request-once": once,
26877
+ "x-signature": signRequest(kx.shareKey, ts, once, encBody),
26878
+ "x-auth-token": this.session_.authToken,
26879
+ gtoken: this.session_.gtoken
26880
+ }));
26881
+ if (env.code !== 0)
26882
+ throw new Error(`Solix ${label} failed (${env.code}): ${env.msg}`);
26883
+ }
26884
+ /** Turn the Solarbank's ambient LED on/off — a confirmed `set_device_attrs` write. */
26885
+ async setAmbientLight(deviceSn, on) {
26886
+ await this.setDeviceAttrs(deviceSn, { ambient_light_switch: on ? 0 : 1 });
26887
+ }
26888
+ /**
26889
+ * Read device attributes — a plain authenticated read (unlike the encrypted write). `attributes`
26890
+ * names the keys to fetch (e.g. `["screen_off_time"]`); an empty list asks for the device's default
26891
+ * set. Returns the gateway's attribute map as-is (values are device-typed — numbers, strings). Used
26892
+ * to reflect a control's live state, e.g. the display/light off-timeout.
26893
+ */
26894
+ async getDeviceAttrs(deviceSn, attributes = []) {
26895
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getDeviceAttrs, { device_sn: deviceSn, attributes });
26896
+ if (data && typeof data === "object" && "attributes" in data && data.attributes) {
26897
+ return data.attributes;
26898
+ }
26899
+ return data ?? {};
26900
+ }
26901
+ /**
26902
+ * Set the Solarbank display's screen-off timeout, in SECONDS (`screen_off_time`). The app's picker
26903
+ * offers 10/20/30 s and 1/5/30 min; the LCD backlight — and with it the ambient LED that the screen
26904
+ * gates — turns off after this idle period. This is the raw-seconds write; the caller maps its own UI
26905
+ * options to seconds. The "Never" (always-on) sentinel is device-defined and NOT assumed here — pass
26906
+ * the exact integer read back from {@link getDeviceAttrs} while the device is in that mode.
26907
+ */
26908
+ async setScreenOffTime(deviceSn, seconds) {
26909
+ await this.setDeviceAttrs(deviceSn, { screen_off_time: seconds });
26910
+ }
26911
+ /**
26912
+ * Read the Solarbank's battery discharge-cutoff (minimum-SOC) options — a plain authed read.
26913
+ * The gateway returns a preset list (`power_cutoff_data`): each entry is a selectable minimum
26914
+ * state-of-charge `output_cutoff_data` (percent) with its `id` and `is_selected` flag. The caller
26915
+ * presents these options and writes the chosen `id` back via {@link setPowerCutoff} — the values and
26916
+ * ids come from the device, never assumed. `siteId` is optional (the device knows its own cutoff).
26917
+ */
26918
+ async getPowerCutoff(deviceSn, siteId = "") {
26919
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getPowerCutoff, { site_id: siteId, device_sn: deviceSn });
26920
+ return data?.power_cutoff_data ?? [];
26921
+ }
26922
+ /**
26923
+ * Select the Solarbank's battery discharge-cutoff (minimum SOC) by option id — a control write.
26924
+ * `cutoffDataId` MUST be an `id` returned by {@link getPowerCutoff} for this device (the preset the
26925
+ * user picked), never a raw percentage; the gateway maps the id to its cutoff percent.
26926
+ */
26927
+ async setPowerCutoff(deviceSn, cutoffDataId) {
26928
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setPowerCutoff, "set_power_cutoff", {
26929
+ device_sn: deviceSn,
26930
+ cutoff_data_id: cutoffDataId
26931
+ });
26932
+ }
26933
+ /** The `param_type` under which the Solarbank's SOC-limit block lives (verified live on an AE103). */
26934
+ static SOC_PARAM_TYPE = "27";
26935
+ /** `cmd` value that scopes the `site/*_site_device_param` family (from the app's request builder). */
26936
+ static SITE_DEVICE_PARAM_CMD = 246;
26937
+ /**
26938
+ * Read one of a site's "device param" blocks by `param_type` — a plain authenticated read whose
26939
+ * `data.param_data` is itself a JSON STRING (the vendor double-encodes it). Returns the parsed inner
26940
+ * object, or `{}` when the block is empty (the gateway answers `code 0` with an empty `param_data`
26941
+ * for a `param_type` that does not apply to the site's hardware). The caller owns the inner shape.
26942
+ */
26943
+ async getSiteDeviceParam(siteId, paramType) {
26944
+ const data = await this.authed("POST", SOLIX_ENDPOINTS.getSiteDeviceParam, {
26945
+ site_id: siteId,
26946
+ param_type: paramType,
26947
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD
26948
+ });
26949
+ const raw = data?.param_data;
26950
+ if (!raw)
26951
+ return {};
26952
+ try {
26953
+ return JSON.parse(raw);
26954
+ } catch {
26955
+ return {};
26956
+ }
26957
+ }
26958
+ /**
26959
+ * Read the Solarbank's battery SOC-limit settings (`param_type "27"`) — a plain authenticated read.
26960
+ * Returns `undefined` when the site carries no SOC block (e.g. non-Solarbank hardware). The realtime
26961
+ * `dischargeLowerLimit` also arrives on the MQTT `b5` telemetry blob; this is the authoritative,
26962
+ * app-synced source (and the only source for `chargeUpperLimit` / `backupReserve`). Verified live
26963
+ * against a known AE103 setting (discharge 20 / charge 80).
26964
+ */
26965
+ async getSafetySocParams(siteId) {
26966
+ const p = await this.getSiteDeviceParam(siteId, _SolixClient.SOC_PARAM_TYPE);
26967
+ if (typeof p.charge_upper_limit !== "number" || typeof p.discharge_lower_limit !== "number") {
26968
+ return void 0;
26969
+ }
26970
+ return {
26971
+ chargeUpperLimit: p.charge_upper_limit,
26972
+ dischargeLowerLimit: p.discharge_lower_limit,
26973
+ backupReserve: typeof p.backup_reserve === "number" ? p.backup_reserve : 0,
26974
+ backupReserveSwitch: typeof p.backup_reserve_switch === "number" ? p.backup_reserve_switch : 0,
26975
+ socCalibrationEnable: typeof p.soc_calibration_enable === "number" ? p.soc_calibration_enable : 0
26976
+ };
26977
+ }
26978
+ /**
26979
+ * Write the Solarbank's battery SOC limits — an `algo_ecdh`-encrypted + signed control write. This is
26980
+ * **read-modify-write**: it first reads the current `param_type "27"` block and overlays only the
26981
+ * fields the caller supplies, so changing the discharge limit alone never clobbers the charge limit,
26982
+ * backup reserve, or calibration toggle. `changes` values are whole-percent integers. The full block
26983
+ * (all five keys) is sent, matching the app's `SocSettingParam.toJson`. Throws if the site has no SOC
26984
+ * block to modify. Returns the merged parameters that were written (for an immediate optimistic echo).
26985
+ */
26986
+ async setSafetySocParams(siteId, changes) {
26987
+ const current = await this.getSafetySocParams(siteId);
26988
+ if (!current)
26989
+ throw new Error(`Solix set SOC params: site has no param_type 27 block`);
26990
+ const merged = { ...current, ...changes };
26991
+ await this.encryptedWrite(SOLIX_ENDPOINTS.setSiteDeviceParam, "set_site_device_param", {
26992
+ site_id: siteId,
26993
+ cmd: _SolixClient.SITE_DEVICE_PARAM_CMD,
26994
+ param_type: _SolixClient.SOC_PARAM_TYPE,
26995
+ param_data: JSON.stringify({
26996
+ charge_upper_limit: merged.chargeUpperLimit,
26997
+ discharge_lower_limit: merged.dischargeLowerLimit,
26998
+ backup_reserve_switch: merged.backupReserveSwitch,
26999
+ backup_reserve: merged.backupReserve,
27000
+ soc_calibration_enable: merged.socCalibrationEnable
27001
+ })
27002
+ });
27003
+ return merged;
27004
+ }
27005
+ };
27006
+
27007
+ // dist/transport/mqtt/solix-mqtt.js
27008
+ import { EventEmitter as EventEmitter10 } from "node:events";
27009
+ var SOLIX_METER_FIELD_NAMES = {
27010
+ 168: "meterPowerL1",
27011
+ 169: "meterPowerL2",
27012
+ 170: "meterPowerL3",
27013
+ 171: "meterPowerTotal",
27014
+ 172: "meterVoltageL1",
27015
+ 173: "meterVoltageL2",
27016
+ 174: "meterVoltageL3",
27017
+ 175: "meterCurrentL1",
27018
+ 176: "meterCurrentL2",
27019
+ 177: "meterCurrentL3",
27020
+ 179: "meterImportEnergy",
27021
+ 180: "meterExportEnergy"
27022
+ };
27023
+ var SOLIX_METER_PRODUCT_PREFIXES = ["AE1X0"];
27024
+ var SOLIX_SOLARBANK_PRODUCT_PREFIX = "AE10";
27025
+ var SOLIX_SOLARBANK_FIELD_NAMES = {
27026
+ 171: "photovoltaicPower",
27027
+ // total PV input across the strings
27028
+ 172: "batteryPower",
27029
+ 188: "chargePower",
27030
+ 173: "dischargePower",
27031
+ 174: "acPlugPower",
27032
+ 175: "socketPower",
27033
+ // the unit's own on-board AC socket (an appliance plugged into the Solarbank)
27034
+ 196: "gridInputPower",
27035
+ 197: "homeLoadPower",
27036
+ 198: "pv1Power",
27037
+ // the four PV-string inputs (0 when a string is unused / dark)
27038
+ 199: "pv2Power",
27039
+ 200: "pv3Power",
27040
+ 201: "pv4Power"
27041
+ };
27042
+ var SOLIX_STATE_FIELD_NAMES = {
27043
+ 169: "mode",
27044
+ // current operating (EMS) mode (1 custom, 2 self-consumption, 4 rapid charge, 7 smart, 8 dynamic tariff)
27045
+ 170: "maxLoad"
27046
+ // configured max home load (W) — matches get_site_device_param max_load
27047
+ // NOTE `0xab` is grid-in/out-related power but its exact meaning is not yet pinned, so it stays raw
27048
+ // `state_ab` (a diagnostic a consumer can watch) rather than being asserted under a guessed name.
27049
+ };
27050
+ function solixStateReadings(frame) {
27051
+ const out = {};
27052
+ for (const [tag2, value] of frame.fields) {
27053
+ if (tag2 < 165 || !value || value.length < 2)
27054
+ continue;
27055
+ const type = value[0];
27056
+ const pl = value.subarray(1);
27057
+ let num2;
27058
+ if (type === 5 && pl.length >= 4)
27059
+ num2 = pl.readFloatLE(0);
27060
+ else if (type === 2 && pl.length >= 2)
27061
+ num2 = pl.readUInt16LE(0);
27062
+ else if (type === 1 && pl.length >= 1)
27063
+ num2 = pl[0];
27064
+ else if (type === 3 && pl.length >= 2)
27065
+ num2 = pl[1];
27066
+ if (num2 === void 0)
27067
+ continue;
27068
+ out[`state_${tag2.toString(16)}`] = num2;
27069
+ const name = SOLIX_STATE_FIELD_NAMES[tag2];
27070
+ if (name)
27071
+ out[name] = num2;
27072
+ }
27073
+ return out;
27074
+ }
27075
+ function readSolixChannel(value) {
27076
+ if (!value || value.length < 1)
27077
+ return void 0;
27078
+ const raw = value.subarray(1);
27079
+ const ch = { type: value[0], raw };
27080
+ if (raw.length === 4) {
27081
+ ch.float = raw.readFloatLE(0);
27082
+ ch.uint = raw.readUInt32LE(0);
27083
+ }
27084
+ return ch;
27085
+ }
27086
+ function decodeSolixParamFrame(buf) {
27087
+ if (buf.length < 10 || buf[0] !== 255 || buf[1] !== 9)
27088
+ return null;
27089
+ const declaredLen = buf.readUInt16LE(2);
27090
+ if (declaredLen < 5 || declaredLen > buf.length)
27091
+ return null;
27092
+ let xor = 0;
27093
+ for (let i = 0; i < declaredLen; i++)
27094
+ xor ^= buf[i];
27095
+ if (xor !== 0)
27096
+ return null;
27097
+ const end = declaredLen - 1;
27098
+ const start = buf.indexOf(161, 4);
27099
+ if (start < 0 || start >= end)
27100
+ return { fields: /* @__PURE__ */ new Map() };
27101
+ const fields = walkFf09Tlv(buf, start, end);
27102
+ let deviceSn;
27103
+ const a2 = fields.get(162);
27104
+ if (a2 && a2.length > 1)
27105
+ deviceSn = a2.subarray(1).toString("latin1").replace(/\0+$/, "") || void 0;
27106
+ return { deviceSn, fields };
27107
+ }
27108
+ function solixReadings(frame, productCode) {
27109
+ const out = {};
27110
+ const isMeter = SOLIX_METER_PRODUCT_PREFIXES.some((p) => productCode.startsWith(p));
27111
+ const isSolarbank = productCode.startsWith(SOLIX_SOLARBANK_PRODUCT_PREFIX);
27112
+ const floatNames = isMeter ? SOLIX_METER_FIELD_NAMES : isSolarbank ? SOLIX_SOLARBANK_FIELD_NAMES : void 0;
27113
+ for (const [tag2, value] of frame.fields) {
27114
+ if (tag2 < 166)
27115
+ continue;
27116
+ const ch = readSolixChannel(value);
27117
+ if (ch?.type !== 5 || ch.float === void 0)
27118
+ continue;
27119
+ out[`channel_${tag2.toString(16)}`] = ch.float;
27120
+ const name = floatNames?.[tag2];
27121
+ if (name)
27122
+ out[name] = ch.float;
27123
+ }
27124
+ if (isSolarbank)
27125
+ addSolarbankScalars(frame, out);
27126
+ return out;
27127
+ }
27128
+ function addSolarbankScalars(frame, out) {
27129
+ const a3 = frame.fields.get(163);
27130
+ const soc = a3 && a3.length >= 2 ? a3[1] : void 0;
27131
+ if (soc !== void 0) {
27132
+ out.batterySoc = soc;
27133
+ const body = frame.fields.get(164)?.subarray(1);
27134
+ if (body && body.length >= 8 && body[body.length - 6] === soc) {
27135
+ out.batteryTemperature = body[body.length - 8];
27136
+ }
27137
+ }
27138
+ const b5 = frame.fields.get(181);
27139
+ if (b5 && b5[0] === 4 && b5.length === 4) {
27140
+ out.dischargeLimit = b5[1];
27141
+ out.chargeLimit = b5[3];
27142
+ }
27143
+ }
27144
+ var SolixMqtt = class extends EventEmitter10 {
27145
+ transport;
27146
+ appName;
27147
+ userId;
27148
+ appClientId;
27149
+ armIntervalMs;
27150
+ logger;
27151
+ siteId;
27152
+ watched = /* @__PURE__ */ new Map();
27153
+ seq = 0;
27154
+ armTimer;
27155
+ /**
27156
+ * Bind to one account's MQTT plane. The envelope `client_id` takes the app's shape
27157
+ * (`android-{app}-{uid}-{mqttUuid}-{ts}`); its `mqttUuid` half must be stable across restarts, or every
27158
+ * restart presents itself to the broker as a new client, so it defaults deterministically from the user
27159
+ * id (see {@link SolixMqttOptions.mqttUuid}) rather than a fresh random per instance.
27160
+ */
27161
+ constructor(opts) {
27162
+ super();
27163
+ this.appName = opts.mqttInfo.app_name ?? "anker_power";
27164
+ this.userId = opts.userId ?? opts.mqttInfo.user_id;
27165
+ this.armIntervalMs = opts.armIntervalMs ?? 25e3;
27166
+ this.siteId = opts.siteId;
27167
+ this.logger = opts.logger;
27168
+ const uid = this.userId ?? "anonymous";
27169
+ this.appClientId = opts.appClientId ?? buildAppShapedClientId({
27170
+ appName: this.appName,
27171
+ uid,
27172
+ mqttUuid: opts.mqttUuid ?? mqttUuidFrom(`anker-solix-mqtt:${uid}`)
27173
+ });
27174
+ this.transport = new SecureMqtt({
27175
+ credentials: opts.mqttInfo,
27176
+ clientId: opts.clientId ?? opts.mqttInfo.thing_name,
27177
+ reconnectPeriod: 5e3,
27178
+ logger: opts.logger
27179
+ });
27180
+ this.transport.on("error", (e) => this.emit("error", e));
27181
+ this.transport.on("message", (msg) => this.onMessage(msg));
27182
+ }
27183
+ /**
27184
+ * Connect, subscribe to the device's telemetry (+ command-reply) topics, ARM realtime reporting, and
27185
+ * start the re-arm/heartbeat timer so telemetry keeps flowing without the app. Idempotent per device.
27186
+ *
27187
+ * Subscribes to `param_info` (+ the device/account command-reply channels) AND the device's `…/req`
27188
+ * channel. `…/req` is the app→device request side — the broker copies the APP's own publishes there to
27189
+ * any co-subscriber, so watching it lets us read a control the app changed that the telemetry does NOT
27190
+ * reflect: the Solarbank's ambient light and display timeout ride an `…/req` cmd-17 (`0x68`) command
27191
+ * (tags `a4`/`a5`), and the `param_info` `ba` bit only tracks OUR `set_device_attrs` write, never the
27192
+ * app's separate command path. `onMessage` filters these — our own arming/echoes carry no
27193
+ * `a4`/`a5` — and turns an app command into a `reading` with the app-set state. A `…/req` grant denial
27194
+ * is non-fatal (only `param_info` is required); we just won't see app-side changes.
27195
+ *
27196
+ * Throws when `param_info` was not granted. A scope-denied filter comes back as SUBACK_FAILURE rather
27197
+ * than an error (see `SecureMqtt.subscribe`), so an unusable subscription otherwise looks like
27198
+ * success: the call would resolve and arm on every interval while no reading ever arrives.
27199
+ *
27200
+ * The re-arm timer is unreffed, so a caller that watches and returns can still exit.
27201
+ */
27202
+ async watch(device) {
27203
+ await this.transport.connect();
27204
+ const topics = solixDeviceTopics(this.appName, device.product_code, device.device_sn);
27205
+ const granted = await this.transport.subscribe([
27206
+ topics.paramInfo,
27207
+ topics.stateInfo,
27208
+ topics.cmdRes,
27209
+ topics.req,
27210
+ ...this.userId ? [solixUserTopics(this.appName, this.userId).cmdRes] : []
27211
+ ]);
27212
+ if (!granted.includes(topics.paramInfo)) {
27213
+ const scope = this.appName;
27214
+ 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`);
27215
+ }
27216
+ this.watched.set(device.device_sn, device);
27217
+ if (this.armIntervalMs > 0) {
27218
+ await this.armAll();
27219
+ if (!this.armTimer) {
27220
+ this.armTimer = setInterval(() => void this.armAll(), this.armIntervalMs);
27221
+ this.armTimer.unref?.();
27222
+ }
27223
+ }
27224
+ }
27225
+ /** Tear down the connection and stop the re-arm timer. */
27226
+ async close() {
27227
+ if (this.armTimer) {
27228
+ clearInterval(this.armTimer);
27229
+ this.armTimer = void 0;
27230
+ }
27231
+ this.watched.clear();
27232
+ await this.transport.disconnect();
27233
+ }
27234
+ /**
27235
+ * Set a Solarbank's display screen-off timeout — publishes the captured cmd-17 command (ff09 msgtype
27236
+ * `0x68`, tag `a5 = [01, index]`) on the device's `…/req` channel via the same envelope the arming
27237
+ * poll uses (`sign_code:1`, no per-message signature — which the device accepts for cmd 17). `index`
27238
+ * is the 1-based dropdown position (10s=1, 20s=2, 30s=3, 1m=4, 5m=5, 30m=6); "Never" is a separate
27239
+ * command not handled here. Fire-and-forget: the device does not ack on a subscribed channel.
27240
+ */
27241
+ async setDisplayTimeout(device, index) {
27242
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27243
+ const body = this.commandEnvelope(device, buildDisplayTimeoutFrame(index), {});
27244
+ await this.transport.publish(topic, body, { qos: 1 });
27245
+ this.logger?.debug?.(`[solix] display timeout set index=${index} on ${device.device_sn}`);
27246
+ }
27247
+ /**
27248
+ * Re-arm every watched device and send the site heartbeat. The device only pushes `param_info` while
27249
+ * a client keeps requesting it — this replays the app's `requestDeviceInfo` (cmd 17) + `power_site`
27250
+ * heartbeat (cmd 10); the request frames are reproduced byte-for-byte by {@link buildFf09Request}
27251
+ * (checksum-verified against captured frames in its spec). Best-effort: a publish failure is emitted,
27252
+ * not thrown, so one bad device doesn't stop the rest or kill the timer.
27253
+ */
27254
+ async armAll() {
27255
+ for (const device of this.watched.values()) {
27256
+ try {
27257
+ await this.arm(device);
27258
+ } catch (e) {
27259
+ this.emit("error", e);
27260
+ }
27261
+ }
27262
+ if (this.userId) {
27263
+ try {
27264
+ await this.transport.publish(solixUserTopics(this.appName, this.userId).powerSite, this.heartbeatEnvelope(), {
27265
+ qos: 1
27266
+ });
27267
+ } catch (e) {
27268
+ this.emit("error", e);
27269
+ }
27270
+ }
27271
+ }
27272
+ /** Publish the device-info arming request (both the "info" and "realtime" ff09 variants the app sends). */
27273
+ async arm(device) {
27274
+ const topic = solixDeviceTopics(this.appName, device.product_code, device.device_sn).req;
27275
+ for (const variant of ["info", "realtime"]) {
27276
+ const body = this.commandEnvelope(device, buildFf09Request(variant), variant === "info" ? { encoding_type: 2 } : {});
27277
+ await this.transport.publish(topic, body, { qos: 1 });
27278
+ }
27279
+ this.logger?.debug?.(`[solix] armed ${device.device_sn} (param_info reporting requested)`);
27280
+ }
27281
+ /** The common `head` fields for every cmd envelope; callers add `cmd` + the per-message variable bits. */
27282
+ makeHead(cmd, extra) {
27283
+ return {
27284
+ version: "1.0.0.1",
27285
+ client_id: this.appClientId,
27286
+ timestamp: Math.floor(Date.now() / 1e3),
27287
+ cmd_status: 2,
27288
+ sign_code: 1,
27289
+ cmd,
27290
+ ...extra
27291
+ };
27292
+ }
27293
+ /**
27294
+ * Build the `{head, payload}` cmd-17 (requestDeviceInfo) envelope carrying a base64 ff09 request.
27295
+ * `account_id` is omitted when the user id is unknown: a live broker cannot tell an empty placeholder
27296
+ * from a real value, so sending `""` would claim an account this client does not have.
27297
+ */
27298
+ commandEnvelope(device, frame, extra) {
27299
+ this.seq += 1;
27300
+ return JSON.stringify({
27301
+ head: this.makeHead(17, {
27302
+ sess_id: genId(),
27303
+ msg_seq: this.seq,
27304
+ seed: genId(),
27305
+ device_pn: device.product_code,
27306
+ device_sn: device.device_sn
27307
+ }),
27308
+ payload: JSON.stringify({
27309
+ device_sn: device.device_sn,
27310
+ ...this.userId ? { account_id: this.userId } : {},
27311
+ data: frame.toString("base64"),
27312
+ ...extra
27313
+ })
27314
+ });
27315
+ }
27316
+ /**
27317
+ * The `power_site` heartbeat (cmd 10) envelope the app sends on a timer to keep the session alive.
27318
+ * `site_id` is omitted when unknown, for the same reason `account_id` is in {@link commandEnvelope}.
27319
+ */
27320
+ heartbeatEnvelope() {
27321
+ return JSON.stringify({
27322
+ head: this.makeHead(10, { sess_id: "1", msg_seq: 1, seed: "1" }),
27323
+ payload: JSON.stringify({ user_id: this.userId ?? "", ...this.siteId ? { site_id: this.siteId } : {} })
27324
+ });
27325
+ }
27326
+ /**
27327
+ * Decode one inbound MQTT message envelope and emit a `reading` if it carries an ff09 param frame. The
27328
+ * product code and the fallback serial come from the topic (`dt/{app}/{pn}/{sn}/param_info`); the frame's
27329
+ * own `a2` field wins for the serial when it carries one.
27330
+ *
27331
+ * Serial resolution matters because NOT every frame carries it: the device-info frame (which alone
27332
+ * carries SOC/temperature via tags a3/a4) has a 1-byte `a2` (a status, not a serial) and can arrive on
27333
+ * a topic whose serial segment isn't the device serial either — leaving a `deviceSn` that matches no
27334
+ * watched device, so a consumer keying on it would drop the reading (and its temperature). So when the
27335
+ * resolved serial isn't a watched device, fall back to the single watched device of this product code.
27336
+ */
27337
+ onMessage(msg) {
27338
+ const topic = msg.topic ?? "";
27339
+ const buf = extractFf09Payload(msg.raw);
27340
+ if (!buf)
27341
+ return;
27342
+ if (topic.endsWith("/req")) {
27343
+ this.handleCommand(topic, buf);
27344
+ return;
27345
+ }
27346
+ const frame = decodeSolixParamFrame(buf);
27347
+ if (!frame)
27348
+ return;
27349
+ const parts = topic.split("/");
27350
+ const productCode = parts[2] ?? "";
27351
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27352
+ if (!this.watched.has(deviceSn)) {
27353
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27354
+ if (ofProduct.length === 1)
27355
+ deviceSn = ofProduct[0].device_sn;
27356
+ }
27357
+ const values = topic.endsWith("/state_info") ? solixStateReadings(frame) : (
27358
+ // Pass the product code so meter tag→name binding is applied only to a meter frame; a Solarbank's
27359
+ // tags stay raw channel_<hex> (the model names them per capability) rather than being mislabelled.
27360
+ solixReadings(frame, productCode)
27361
+ );
27362
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27363
+ }
27364
+ /**
27365
+ * Turn an app→device cmd-17 (`0x68`) command seen on the `…/req` channel into a `reading` carrying the
27366
+ * app-set control state, so a change made in the app reflects back. The Solarbank's ambient light and
27367
+ * display timeout are set this way (byte-identical to what {@link setDisplayTimeout} publishes), and the
27368
+ * broker copies the app's publish to us as a co-subscriber. Only `0x68` frames carrying `a4`/`a5` are
27369
+ * emitted, so the arming polls (`0x40`/`0x57`) and our own echoes contribute nothing:
27370
+ * - `a4 = [01, s]` → ambient light, INVERTED (`s` 0 = on) → `ambientLightOn` 1/0. The `ba` telemetry
27371
+ * bit only tracks our `set_device_attrs` write, so this is the ONLY read-back of an app light toggle.
27372
+ * - `a5 = [01, i]` → display timeout, `i` = 1-based dropdown index → `displayTimeoutIndex`.
27373
+ */
27374
+ handleCommand(topic, buf) {
27375
+ if (buf.length < 10 || buf[8] !== 104)
27376
+ return;
27377
+ const frame = decodeSolixParamFrame(buf);
27378
+ if (!frame)
27379
+ return;
27380
+ const values = {};
27381
+ const a4 = frame.fields.get(164);
27382
+ if (a4 && a4.length >= 2)
27383
+ values.ambientLightOn = a4[1] === 0 ? 1 : 0;
27384
+ const a5 = frame.fields.get(165);
27385
+ if (a5 && a5.length >= 2)
27386
+ values.displayTimeoutIndex = a5[1];
27387
+ if (Object.keys(values).length === 0)
27388
+ return;
27389
+ const parts = topic.split("/");
27390
+ const productCode = parts[2] ?? "";
27391
+ let deviceSn = frame.deviceSn ?? parts[3] ?? "";
27392
+ if (!this.watched.has(deviceSn)) {
27393
+ const ofProduct = [...this.watched.values()].filter((d) => d.product_code === productCode);
27394
+ if (ofProduct.length === 1)
27395
+ deviceSn = ofProduct[0].device_sn;
27396
+ }
27397
+ this.emit("reading", { deviceSn, productCode, topic, frame, values });
27398
+ }
27399
+ };
27400
+ function extractFf09Payload(raw) {
27401
+ if (Buffer.isBuffer(raw))
27402
+ return raw;
27403
+ if (!raw || typeof raw !== "object")
27404
+ return null;
27405
+ const env = raw;
27406
+ let payload = env.payload;
27407
+ if (typeof payload === "string") {
27408
+ try {
27409
+ payload = JSON.parse(payload);
27410
+ } catch {
27411
+ return null;
27412
+ }
27413
+ }
27414
+ const p = payload;
27415
+ const data = p?.data ?? p?.trans ?? env.data;
27416
+ if (typeof data !== "string")
27417
+ return null;
27418
+ const buf = Buffer.from(data, "base64");
27419
+ return buf.length ? buf : null;
27420
+ }
27421
+ function buildDisplayTimeoutFrame(index) {
27422
+ const body = Buffer.from([3, 0, 15, 0, 104, 161, 1, 34, 165, 2, 1, index & 255]);
27423
+ const frame = Buffer.alloc(body.length + 5);
27424
+ frame[0] = 255;
27425
+ frame[1] = 9;
27426
+ frame.writeUInt16LE(frame.length, 2);
27427
+ body.copy(frame, 4);
27428
+ let xor = 0;
27429
+ for (let i = 0; i < frame.length - 1; i++)
27430
+ xor ^= frame[i];
27431
+ frame[frame.length - 1] = xor;
27432
+ return frame;
27433
+ }
27434
+ function buildFf09Request(variant, atUnixSec) {
27435
+ const ts = Buffer.alloc(4);
27436
+ ts.writeUInt32LE((atUnixSec ?? Math.floor(Date.now() / 1e3)) >>> 0);
27437
+ const body = variant === "info" ? Buffer.concat([Buffer.from([3, 0, 15, 0, 64, 161, 1, 34, 254, 4]), ts]) : Buffer.concat([
27438
+ Buffer.from([
27439
+ 3,
27440
+ 0,
27441
+ 15,
27442
+ 0,
27443
+ 87,
27444
+ 161,
27445
+ 1,
27446
+ 34,
27447
+ 162,
27448
+ 2,
27449
+ 1,
27450
+ 1,
27451
+ 163,
27452
+ 3,
27453
+ 2,
27454
+ 44,
27455
+ 1,
27456
+ 254,
27457
+ 5,
27458
+ 3
27459
+ ]),
27460
+ ts
27461
+ ]);
27462
+ const frame = Buffer.alloc(body.length + 5);
27463
+ frame[0] = 255;
27464
+ frame[1] = 9;
27465
+ frame.writeUInt16LE(frame.length, 2);
27466
+ body.copy(frame, 4);
27467
+ let xor = 0;
27468
+ for (let i = 0; i < frame.length - 1; i++)
27469
+ xor ^= frame[i];
27470
+ frame[frame.length - 1] = xor;
27471
+ return frame;
27472
+ }
27473
+
25286
27474
  // dist/transport/tuya/index.js
25287
27475
  var tuya_exports = {};
25288
27476
  __export(tuya_exports, {
@@ -25334,6 +27522,7 @@ export {
25334
27522
  CLEAN_EXTENTS,
25335
27523
  CLEAN_FINISH_REASONS,
25336
27524
  CLEAN_PARAMS,
27525
+ CONNECT_TIMEOUT_MS,
25337
27526
  CONTACT_MEMBERS,
25338
27527
  CO_MEMBERS,
25339
27528
  CameraDisabledError,
@@ -25344,6 +27533,7 @@ export {
25344
27533
  CusPushEvent,
25345
27534
  CusPushMode,
25346
27535
  DEFAULT_KEEPALIVE_MS,
27536
+ DISPLAY_MEMBERS,
25347
27537
  DOCK_ACTIVITIES,
25348
27538
  DOCK_KINDS,
25349
27539
  DOORBELL_MEMBERS,
@@ -25397,6 +27587,7 @@ export {
25397
27587
  P256,
25398
27588
  P2PSession,
25399
27589
  P2P_ENVELOPE,
27590
+ P2P_STATION_WAITS,
25400
27591
  PRINTER_CATEGORY_RE,
25401
27592
  PTZ_MEMBERS,
25402
27593
  PowerSource,
@@ -25416,6 +27607,8 @@ export {
25416
27607
  SIREN_MEMBERS,
25417
27608
  SMART_LIGHT_MEMBERS,
25418
27609
  SMOKE_MEMBERS,
27610
+ SOLIX_ENERGY_METER_MEMBERS,
27611
+ SOLIX_LOCAL_KEY_HEX,
25419
27612
  STATE_EVENT_FIELDS,
25420
27613
  STATION_CHANNEL3 as STATION_CHANNEL,
25421
27614
  STATION_CHUNK_BYTES,
@@ -25428,8 +27621,13 @@ export {
25428
27621
  SirenAlarmDuration,
25429
27622
  SirenVolume,
25430
27623
  SmartDropPushEvent,
27624
+ SolixClient,
27625
+ SolixDevice,
27626
+ SolixMqtt,
27627
+ SolixSite,
25431
27628
  StateConvergenceError,
25432
- StationBusyError,
27629
+ StationKeyUnavailableError,
27630
+ StationUnreachableError,
25433
27631
  StoredSnapshotUnavailableError,
25434
27632
  StreamingQuality,
25435
27633
  SuctionLevel,
@@ -25452,7 +27650,6 @@ export {
25452
27650
  aesKey,
25453
27651
  asBool,
25454
27652
  assertNever,
25455
- autoContrast,
25456
27653
  bizChannelName,
25457
27654
  buildActions,
25458
27655
  buildAppShapedClientId,
@@ -25460,9 +27657,11 @@ export {
25460
27657
  buildDeviceNameBody,
25461
27658
  buildDirectBinaryBody,
25462
27659
  buildEventIndex,
27660
+ buildModelIndex,
25463
27661
  buildRealtimeInit,
25464
27662
  captureSnapshotFromShared,
25465
27663
  cellAtPoint,
27664
+ claimedParams,
25466
27665
  clamp,
25467
27666
  classify,
25468
27667
  classifyDevice,
@@ -25497,6 +27696,8 @@ export {
25497
27696
  detectCapabilities,
25498
27697
  detectionName,
25499
27698
  discoverReachableInstance,
27699
+ discoverSolixDevices,
27700
+ discoverSolixSites,
25500
27701
  encodeAiDetectType,
25501
27702
  encodeVarint,
25502
27703
  encryptBody,
@@ -25525,6 +27726,9 @@ export {
25525
27726
  isNotAuthorized,
25526
27727
  isPrivateIpv4,
25527
27728
  isSessionValid,
27729
+ isSolixPowerStation,
27730
+ isSolixSmartMeter,
27731
+ isSolixSolarbank,
25528
27732
  isV1Image,
25529
27733
  isV2Image,
25530
27734
  jpegGeometry,
@@ -25534,6 +27738,7 @@ export {
25534
27738
  lz4BlockDecompress,
25535
27739
  mapCellValue,
25536
27740
  mapCellValueAt,
27741
+ md5Hex,
25537
27742
  mergeCandidateIps,
25538
27743
  mergeProperties,
25539
27744
  mqttAppName,
@@ -25578,9 +27783,14 @@ export {
25578
27783
  secureTopic,
25579
27784
  signKey,
25580
27785
  signRequest,
27786
+ solarbankSceneReadings,
27787
+ solixDeviceTopics,
27788
+ solixProductFamily,
27789
+ solixUserTopics,
25581
27790
  structuralEqual,
25582
27791
  subscribeTopics,
25583
27792
  suctionLevelName,
27793
+ tokenNotExpired,
25584
27794
  tuya_exports as tuya,
25585
27795
  u16be,
25586
27796
  u16le,