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