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