@mega-yfue/eufy-sdk 0.4.0-beta.3 → 0.4.0-beta.31

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/index.js CHANGED
@@ -1070,6 +1070,409 @@ function normalizePushImage(data, p2pDid) {
1070
1070
  return decodeImageV1(data, p2pDid) ?? data;
1071
1071
  }
1072
1072
 
1073
+ // dist/core/types.js
1074
+ var PRINTER_CATEGORY_RE = /ankermake|eufymake|fdm|3d_?print/i;
1075
+ function classifyDevice(raw) {
1076
+ const category = raw.category ?? "";
1077
+ const hasP2p = typeof raw.p2p_did === "string" && raw.p2p_did.length > 0;
1078
+ if (category === "eufy_security" || hasP2p)
1079
+ return { category, api: "mega", realtime: "p2p" };
1080
+ if (PRINTER_CATEGORY_RE.test(category))
1081
+ return { category, api: "ankermake", realtime: "ankermake-mqtt" };
1082
+ return { category, api: "mega", realtime: "smqtt" };
1083
+ }
1084
+
1085
+ // dist/core/contracts.js
1086
+ var StoredSnapshotUnavailableError = class extends Error {
1087
+ reason;
1088
+ constructor(reason, message, options) {
1089
+ super(message, options);
1090
+ this.reason = reason;
1091
+ this.name = "StoredSnapshotUnavailableError";
1092
+ }
1093
+ };
1094
+ var RETRYABLE_LIVE_SNAPSHOT_REASONS = [
1095
+ "no-keyframe",
1096
+ "source-failed",
1097
+ "undecodable-burst"
1098
+ ];
1099
+ var LiveSnapshotUnavailableError = class extends Error {
1100
+ reason;
1101
+ /** Whether another attempt could plausibly succeed without the host changing anything. */
1102
+ retryable;
1103
+ constructor(reason, message, options) {
1104
+ super(message, options);
1105
+ this.reason = reason;
1106
+ this.name = "LiveSnapshotUnavailableError";
1107
+ this.retryable = RETRYABLE_LIVE_SNAPSHOT_REASONS.includes(reason);
1108
+ }
1109
+ };
1110
+ var CameraDisabledError = class extends Error {
1111
+ camera;
1112
+ constructor(camera, options) {
1113
+ super(`camera${camera ? ` ${camera}` : ""} is disabled \u2014 no live stream possible`, options);
1114
+ this.camera = camera;
1115
+ this.name = "CameraDisabledError";
1116
+ }
1117
+ };
1118
+ var StationKeyUnavailableError = class extends Error {
1119
+ stationSn;
1120
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
1121
+ retryable = true;
1122
+ constructor(stationSn, options) {
1123
+ super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
1124
+ this.stationSn = stationSn;
1125
+ this.name = "StationKeyUnavailableError";
1126
+ }
1127
+ };
1128
+ var StationUnreachableError = class extends Error {
1129
+ stationSn;
1130
+ waitedMs;
1131
+ /** Always true: a station unreachable now may answer on a later attempt. */
1132
+ retryable = true;
1133
+ constructor(stationSn, waitedMs, options) {
1134
+ super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
1135
+ this.stationSn = stationSn;
1136
+ this.waitedMs = waitedMs;
1137
+ this.name = "StationUnreachableError";
1138
+ }
1139
+ };
1140
+ var DeviceChannelUnresolvedError = class extends Error {
1141
+ sn;
1142
+ stationSn;
1143
+ constructor(sn, stationSn, options) {
1144
+ super(`${sn} has no usable channel on station ${stationSn}, so nothing was sent to it`, options);
1145
+ this.sn = sn;
1146
+ this.stationSn = stationSn;
1147
+ this.name = "DeviceChannelUnresolvedError";
1148
+ }
1149
+ };
1150
+ var RecordingDownloadError = class extends Error {
1151
+ reason;
1152
+ constructor(reason, message, options) {
1153
+ super(message, options);
1154
+ this.reason = reason;
1155
+ this.name = "RecordingDownloadError";
1156
+ }
1157
+ };
1158
+ var LiveStreamStartError = class extends Error {
1159
+ reason;
1160
+ stage;
1161
+ timeoutMs;
1162
+ attempts;
1163
+ constructor(failure) {
1164
+ super(`live stream failed to start (${failure.reason} at ${failure.stage} after ${failure.attempts} attempts; ${failure.timeoutMs}ms deadline)`, failure.cause === void 0 ? void 0 : { cause: failure.cause });
1165
+ this.name = "LiveStreamStartError";
1166
+ this.reason = failure.reason;
1167
+ this.stage = failure.stage;
1168
+ this.timeoutMs = failure.timeoutMs;
1169
+ this.attempts = failure.attempts;
1170
+ }
1171
+ };
1172
+ var StateConvergenceError = class extends Error {
1173
+ sn;
1174
+ property;
1175
+ param;
1176
+ expected;
1177
+ observed;
1178
+ timeoutMs;
1179
+ constructor(failure) {
1180
+ super(`device ${failure.sn} did not report ${failure.property} as ${String(failure.expected)} within ${failure.timeoutMs}ms (param ${failure.param} read ${String(failure.observed)})`);
1181
+ this.name = "StateConvergenceError";
1182
+ this.sn = failure.sn;
1183
+ this.property = failure.property;
1184
+ this.param = failure.param;
1185
+ this.expected = failure.expected;
1186
+ this.observed = failure.observed;
1187
+ this.timeoutMs = failure.timeoutMs;
1188
+ }
1189
+ };
1190
+ var COMMAND_OBSERVATION = /* @__PURE__ */ Symbol("command-observation");
1191
+ function observeCommand(command, observation) {
1192
+ return Object.defineProperty(command, COMMAND_OBSERVATION, { configurable: true, value: observation });
1193
+ }
1194
+ function commandObservation(command) {
1195
+ return command[COMMAND_OBSERVATION];
1196
+ }
1197
+
1198
+ // dist/core/raw-dp-writer.js
1199
+ var WIRE = { VARINT: 0, LENGTH_DELIMITED: 2 };
1200
+ function encodeVarint(value) {
1201
+ if (!Number.isSafeInteger(value) || value < 0) {
1202
+ throw new Error(`varint: ${value} is not a non-negative safe integer`);
1203
+ }
1204
+ const out = [];
1205
+ let v = value;
1206
+ while (v > 127) {
1207
+ out.push(v % 128 + 128);
1208
+ v = Math.floor(v / 128);
1209
+ }
1210
+ out.push(v);
1211
+ return out;
1212
+ }
1213
+ function zigzag(value) {
1214
+ if (!Number.isSafeInteger(value))
1215
+ throw new Error(`zigzag: ${value} is not a safe integer`);
1216
+ return value < 0 ? -2 * value - 1 : 2 * value;
1217
+ }
1218
+ var RawDpWriter = class _RawDpWriter {
1219
+ body = [];
1220
+ /** A varint-valued field (`int32`, `uint32`, `bool`, an enum member). */
1221
+ int(field, value) {
1222
+ this.body.push(...encodeVarint(tag(field, WIRE.VARINT)), ...encodeVarint(value));
1223
+ return this;
1224
+ }
1225
+ /** A signed varint field (`sint32`, `sint64`), ZigZag-encoded — see {@link zigzag}. */
1226
+ sint(field, value) {
1227
+ return this.int(field, zigzag(value));
1228
+ }
1229
+ /** A boolean field, on protobuf's own terms: `1` or `0` in a varint. */
1230
+ bool(field, value) {
1231
+ return this.int(field, value ? 1 : 0);
1232
+ }
1233
+ /** A length-delimited field carrying raw bytes — a string, a blob, or a message built elsewhere. */
1234
+ bytes(field, value) {
1235
+ const bytes2 = value instanceof Uint8Array ? Array.from(value) : [...value];
1236
+ this.body.push(...encodeVarint(tag(field, WIRE.LENGTH_DELIMITED)), ...encodeVarint(bytes2.length), ...bytes2);
1237
+ return this;
1238
+ }
1239
+ /**
1240
+ * A nested sub-message, built by `build` into a writer of its own.
1241
+ *
1242
+ * The sub-message is emitted even when `build` writes nothing into it. That is not an oversight: an
1243
+ * EMPTY sub-message is how these protocols say "this subsystem exists and is in its zero state", as
1244
+ * opposed to saying nothing about it, and the readers on the other side of this file depend on the
1245
+ * distinction. Omitting the call is what says nothing about it.
1246
+ */
1247
+ sub(field, build) {
1248
+ const inner = new _RawDpWriter();
1249
+ build(inner);
1250
+ return this.bytes(field, inner.toBytes());
1251
+ }
1252
+ /** The message body on its own, unframed — for nesting into another writer. */
1253
+ toBytes() {
1254
+ return [...this.body];
1255
+ }
1256
+ /**
1257
+ * The finished DP value: `varint(bodyLength) ++ body`, base64-encoded.
1258
+ *
1259
+ * This is the envelope `RawDpCodec.decode` unwraps, so a value produced here and read back through the
1260
+ * codec is a genuine round-trip rather than two implementations of the same guess.
1261
+ */
1262
+ finish() {
1263
+ return Buffer.from([...encodeVarint(this.body.length), ...this.body]).toString("base64");
1264
+ }
1265
+ };
1266
+ function tag(field, wire) {
1267
+ if (!Number.isSafeInteger(field) || field < 1)
1268
+ throw new Error(`field number: ${field} is not a positive integer`);
1269
+ return field * 8 + wire;
1270
+ }
1271
+ function rawDp(build) {
1272
+ const w = new RawDpWriter();
1273
+ build(w);
1274
+ return w.finish();
1275
+ }
1276
+
1277
+ // dist/core/raw-dp-hex.js
1278
+ function hexToRawDp(hex) {
1279
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
1280
+ return void 0;
1281
+ return Buffer.from(hex, "hex").toString("base64");
1282
+ }
1283
+
1284
+ // dist/core/util.js
1285
+ var Timer = class {
1286
+ handle;
1287
+ /** (Re)arm to run `fn` after `ms`, cancelling any already-pending fire. */
1288
+ arm(ms, fn) {
1289
+ this.cancel();
1290
+ this.handle = setTimeout(() => {
1291
+ this.handle = void 0;
1292
+ fn();
1293
+ }, ms);
1294
+ this.handle.unref?.();
1295
+ }
1296
+ /** Cancel a pending fire. No-op if not armed. */
1297
+ cancel() {
1298
+ if (this.handle) {
1299
+ clearTimeout(this.handle);
1300
+ this.handle = void 0;
1301
+ }
1302
+ }
1303
+ /** Whether a fire is currently scheduled. */
1304
+ get pending() {
1305
+ return this.handle !== void 0;
1306
+ }
1307
+ };
1308
+ function assertNever(value) {
1309
+ throw new Error(`unhandled variant: ${JSON.stringify(value)}`);
1310
+ }
1311
+ function asBool(value) {
1312
+ if (typeof value === "string")
1313
+ return value === "1" || value.toLowerCase() === "true";
1314
+ return value === true || value === 1;
1315
+ }
1316
+ function clamp(value, min, max) {
1317
+ if (Number.isNaN(value))
1318
+ return min;
1319
+ return Math.max(min, Math.min(max, Math.round(value)));
1320
+ }
1321
+ function structuralEqual(a, b) {
1322
+ if (Object.is(a, b))
1323
+ return true;
1324
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
1325
+ return false;
1326
+ const aArr = Array.isArray(a);
1327
+ const bArr = Array.isArray(b);
1328
+ if (aArr !== bArr)
1329
+ return false;
1330
+ if (aArr && bArr) {
1331
+ if (a.length !== b.length)
1332
+ return false;
1333
+ for (let i = 0; i < a.length; i++)
1334
+ if (!structuralEqual(a[i], b[i]))
1335
+ return false;
1336
+ return true;
1337
+ }
1338
+ const aObj = a;
1339
+ const bObj = b;
1340
+ const aKeys = Object.keys(aObj);
1341
+ const bKeys = Object.keys(bObj);
1342
+ if (aKeys.length !== bKeys.length)
1343
+ return false;
1344
+ for (const k of aKeys) {
1345
+ if (!Object.prototype.hasOwnProperty.call(bObj, k))
1346
+ return false;
1347
+ if (!structuralEqual(aObj[k], bObj[k]))
1348
+ return false;
1349
+ }
1350
+ return true;
1351
+ }
1352
+ function coerceEnumValue(enumObj, value) {
1353
+ const n = Number(value);
1354
+ return Number.isInteger(n) && Object.values(enumObj).includes(n) ? n : void 0;
1355
+ }
1356
+ function enumValues(enumObj) {
1357
+ return Object.values(enumObj).join("/");
1358
+ }
1359
+ function enumLabels(enumObj) {
1360
+ return Object.fromEntries(Object.entries(enumObj).map(([name, value]) => [value, name]));
1361
+ }
1362
+ function jsonObject(text2) {
1363
+ if (typeof text2 !== "string")
1364
+ return void 0;
1365
+ try {
1366
+ const v = JSON.parse(text2);
1367
+ return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
1368
+ } catch {
1369
+ return void 0;
1370
+ }
1371
+ }
1372
+ function u16le(n) {
1373
+ const b = Buffer.alloc(2);
1374
+ b.writeUInt16LE(n & 65535);
1375
+ return b;
1376
+ }
1377
+ function u16be(n) {
1378
+ const b = Buffer.alloc(2);
1379
+ b.writeUInt16BE(n & 65535);
1380
+ return b;
1381
+ }
1382
+ function u32le(n) {
1383
+ const b = Buffer.alloc(4);
1384
+ b.writeUInt32LE(n >>> 0);
1385
+ return b;
1386
+ }
1387
+ function u32be(n) {
1388
+ const b = Buffer.alloc(4);
1389
+ b.writeUInt32BE(n >>> 0);
1390
+ return b;
1391
+ }
1392
+
1393
+ // dist/core/lz4-block.js
1394
+ var MAX_OUTPUT = 64 * 1024 * 1024;
1395
+ function lz4BlockDecompress(block, size) {
1396
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_OUTPUT)
1397
+ return void 0;
1398
+ const out = Buffer.alloc(size);
1399
+ const end = block.length;
1400
+ let pos = 0;
1401
+ let written = 0;
1402
+ const extend2 = (base) => {
1403
+ let len = base;
1404
+ if (base !== 15)
1405
+ return len;
1406
+ for (; ; ) {
1407
+ if (pos >= end)
1408
+ return void 0;
1409
+ const extra = block[pos++];
1410
+ len += extra;
1411
+ if (extra !== 255)
1412
+ return len;
1413
+ }
1414
+ };
1415
+ while (pos < end) {
1416
+ const token = block[pos++];
1417
+ const literals = extend2(token >> 4);
1418
+ if (literals === void 0)
1419
+ return void 0;
1420
+ if (pos + literals > end || written + literals > size)
1421
+ return void 0;
1422
+ for (let i = 0; i < literals; i++)
1423
+ out[written++] = block[pos++];
1424
+ if (pos === end)
1425
+ break;
1426
+ if (pos + 2 > end)
1427
+ return void 0;
1428
+ const offset = block[pos] | block[pos + 1] << 8;
1429
+ pos += 2;
1430
+ if (offset === 0 || offset > written)
1431
+ return void 0;
1432
+ const match = extend2(token & 15);
1433
+ if (match === void 0)
1434
+ return void 0;
1435
+ const length = match + 4;
1436
+ if (written + length > size)
1437
+ return void 0;
1438
+ let from = written - offset;
1439
+ for (let i = 0; i < length; i++)
1440
+ out[written++] = out[from++];
1441
+ }
1442
+ return written === size ? out : void 0;
1443
+ }
1444
+
1445
+ // dist/transport/http/passport.js
1446
+ function loginCredentials(email, password, country) {
1447
+ const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(password);
1448
+ return { email, password: encryptedPassword, ab: country, client_secret_info: { public_key: clientPublicKeyHex } };
1449
+ }
1450
+ function readLoginReply(data) {
1451
+ const userId = data.ap_cloud_user_id ?? data.user_id ?? data.userId;
1452
+ const authToken = data.auth_token ?? data.token;
1453
+ if (!userId || !authToken)
1454
+ return void 0;
1455
+ return {
1456
+ userId,
1457
+ accountUserId: data.user_id ?? data.userId,
1458
+ authToken,
1459
+ geoKey: data.geo_key,
1460
+ tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0,
1461
+ twoFactorPending: !!(data.fa_info ?? {}).info
1462
+ };
1463
+ }
1464
+ function signedHeaders(entry, encBody) {
1465
+ const ts = nowSec();
1466
+ const once = genId();
1467
+ return {
1468
+ "x-encryption-info": "algo_ecdh",
1469
+ "x-key-ident": entry.keyIdent,
1470
+ "x-request-ts": ts,
1471
+ "x-request-once": once,
1472
+ "x-signature": signRequest(entry.shareKey, ts, once, encBody)
1473
+ };
1474
+ }
1475
+
1073
1476
  // dist/transport/http/mega-client.js
1074
1477
  function parseMaybeJson(text2) {
1075
1478
  if (text2.length === 0)
@@ -1437,17 +1840,11 @@ ${this.auth_?.authToken ?? ""}`;
1437
1840
  const order = preferred === "text/plain" ? ["text/plain", "application/json"] : ["application/json", "text/plain"];
1438
1841
  let last;
1439
1842
  for (const contentType of order) {
1440
- const ts = nowSec();
1441
- const once = genId();
1442
1843
  const headers = {
1443
1844
  ...this.baseHeaders(),
1444
1845
  "content-type": contentType,
1445
- "x-encryption-info": "algo_ecdh",
1846
+ ...signedHeaders(entry, encBody),
1446
1847
  "x-replay-info": "replay",
1447
- "x-key-ident": entry.keyIdent,
1448
- "x-request-ts": ts,
1449
- "x-request-once": once,
1450
- "x-signature": signRequest(entry.shareKey, ts, once, encBody),
1451
1848
  // Per-call header overrides (e.g. app-name=eufy_security to request a security-scoped
1452
1849
  // MQTT cert on an existing eufy_mega session — see EufyMega.getUserMqttInfo).
1453
1850
  ...headerOverrides
@@ -1650,6 +2047,15 @@ ${this.auth_?.authToken ?? ""}`;
1650
2047
  token,
1651
2048
  voip_token: ""
1652
2049
  });
2050
+ await this.securityAppPost("/v1/apppush/register_push_token", {
2051
+ is_notification_enable: true,
2052
+ token,
2053
+ transaction: String(Date.now())
2054
+ });
2055
+ await this.securityAppPost("/v1/app/review/app_push_check", {
2056
+ app_type: "eufySecurity",
2057
+ transaction: String(Date.now())
2058
+ });
1653
2059
  }
1654
2060
  /** Download raw bytes from a push-media URL using the active account session. */
1655
2061
  async downloadMedia(url) {
@@ -1746,13 +2152,7 @@ ${this.auth_?.authToken ?? ""}`;
1746
2152
  }
1747
2153
  /** Build the /passport/login body (verify_code empty unless 2FA). */
1748
2154
  loginBody(opts) {
1749
- const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.cfg.password);
1750
- const base = {
1751
- email: this.cfg.email,
1752
- password: encryptedPassword,
1753
- ab: this.cfg.countryCode,
1754
- client_secret_info: { public_key: clientPublicKeyHex }
1755
- };
2155
+ const base = loginCredentials(this.cfg.email, this.cfg.password, this.cfg.countryCode);
1756
2156
  if (opts.verifyCode)
1757
2157
  return { ...base, verify_code: opts.verifyCode, login_id: "" };
1758
2158
  if (opts.captchaId)
@@ -1990,15 +2390,12 @@ ${this.auth_?.authToken ?? ""}`;
1990
2390
  if (cap.length)
1991
2391
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1992
2392
  }
1993
- const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1994
- const accountUserId = res.user_id ?? res.userId;
1995
- const authToken = res.auth_token ?? res.token;
1996
- if (!userId || !authToken)
2393
+ const reply = readLoginReply(res);
2394
+ if (!reply)
1997
2395
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1998
- const faInfo = res.fa_info ?? {};
1999
- const needs2fa = !isVerify && !!faInfo.info;
2000
- if (needs2fa) {
2001
- this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
2396
+ const { userId, accountUserId, authToken, geoKey } = reply;
2397
+ if (!isVerify && reply.twoFactorPending) {
2398
+ this.auth_ = { userId, accountUserId, authToken, geoKey };
2002
2399
  this.pendingCaptchaId = void 0;
2003
2400
  this.pending2fa = true;
2004
2401
  await this.sendVerifyCode(messageType);
@@ -2006,8 +2403,8 @@ ${this.auth_?.authToken ?? ""}`;
2006
2403
  }
2007
2404
  this.pendingCaptchaId = void 0;
2008
2405
  this.pending2fa = false;
2009
- this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
2010
- this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
2406
+ this.auth_ = { userId, accountUserId, authToken, geoKey };
2407
+ this.tokenExpiresAt = reply.tokenExpiresAt;
2011
2408
  this.sessionKey = void 0;
2012
2409
  await this.ensureSessionKey();
2013
2410
  this.persist();
@@ -2125,6 +2522,10 @@ function solixUserTopics(appName, userId) {
2125
2522
 
2126
2523
  // dist/transport/mqtt/secure-mqtt.js
2127
2524
  var SUBACK_FAILURE = 128;
2525
+ function isRefusal(err) {
2526
+ const granted = err?.packet?.granted;
2527
+ return Array.isArray(granted) && granted.some((g) => typeof g === "number" && (g & SUBACK_FAILURE) !== 0);
2528
+ }
2128
2529
  function isNotAuthorized(err) {
2129
2530
  return err instanceof Error && /connection refused/i.test(err.message) && /not authori[sz]ed/i.test(err.message);
2130
2531
  }
@@ -2227,19 +2628,16 @@ var SecureMqtt = class extends EventEmitter {
2227
2628
  }
2228
2629
  /**
2229
2630
  * Subscribe every inbound leg this device's line uses (see `topics.ts` — one `/res` for most lines,
2230
- * four topics for `eufy_life`).
2631
+ * four topics for `eufy_life` and the clean line).
2231
2632
  *
2232
- * The grants are INSPECTED, not assumed: AWS IoT answers a policy-denied filter with a
2233
- * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so subscribing with a credential
2234
- * whose scope doesn't cover the topic looks identical to success and then delivers nothing. A denied
2235
- * topic is reported via `error` naming the credential scope; only an all-denied device throws, so a
2236
- * line that grants its state channel but refuses (say) the OTA leg still works.
2633
+ * The grants are INSPECTED, not assumed: AWS IoT refuses a policy-denied filter with a
2634
+ * {@link SUBACK_FAILURE} grant rather than failing the connection. A denied topic is reported via
2635
+ * `error` naming the credential scope; only an all-denied device throws, so a line that grants its
2636
+ * state channel but refuses (say) a business leg still works.
2237
2637
  */
2238
2638
  async subscribeDevice(device) {
2239
- if (!this.client)
2240
- throw new Error("SecureMqtt not connected");
2241
2639
  const topics = [...subscribeTopics(device)];
2242
- const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
2640
+ const { denied } = await this.subscribeEach(topics);
2243
2641
  const scope = this.o.credentials.app_name ?? "default";
2244
2642
  if (denied.length === topics.length) {
2245
2643
  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(", ")})`);
@@ -2250,25 +2648,43 @@ var SecureMqtt = class extends EventEmitter {
2250
2648
  }
2251
2649
  /**
2252
2650
  * Subscribe to explicit topic filters, returning the topics that were granted. A scope-denied filter
2253
- * comes back with SUBACK_FAILURE rather than an error (AWS IoT quirk), so it is dropped from the result
2254
- * instead of throwing — callers that need every leg check the returned list. Used by lines whose topic
2255
- * vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix `dt/{app}/{pn}/{sn}`).
2651
+ * is dropped from the result instead of throwing — callers that need every leg check the returned
2652
+ * list. Used by lines whose topic vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix
2653
+ * `dt/{app}/{pn}/{sn}`).
2256
2654
  */
2257
2655
  async subscribe(topics) {
2258
- if (!this.client)
2259
- throw new Error("SecureMqtt not connected");
2260
- return this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 })).granted;
2656
+ return (await this.subscribeEach(topics)).granted;
2261
2657
  }
2262
2658
  /**
2263
- * Split SUBACK grants into granted vs scope-denied topics. AWS IoT marks a policy-denied filter with a
2264
- * SUBACK_FAILURE (`0x80`) grant rather than failing the SUBSCRIBE, so the two subscribe paths share
2265
- * this split and layer their own policy (drop vs report) on top.
2659
+ * Subscribe each filter in a SUBSCRIBE of its own and split the outcome into granted vs refused.
2660
+ *
2661
+ * One request per filter because the MQTT engine treats a SUBACK as all-or-nothing: one refused grant
2662
+ * rejects the whole request, and it forgets EVERY filter of that request for resubscription after a
2663
+ * reconnect — so a batch holding one denied leg would lose the granted legs on the next drop. Alone,
2664
+ * a refused filter costs only itself. A rejection that carries no SUBACK is a transport failure and
2665
+ * is thrown.
2666
+ *
2667
+ * The cost is one SUBSCRIBE and one SUBACK per filter instead of one per device — four for a
2668
+ * four-leg line. They are sent concurrently, so the wall-clock cost is one round trip. Folding them
2669
+ * back into one request brings back the lost resubscription.
2266
2670
  */
2267
- partitionGrants(grants) {
2671
+ async subscribeEach(topics) {
2672
+ const client = this.client;
2673
+ if (!client)
2674
+ throw new Error("SecureMqtt not connected");
2675
+ const outcomes = await Promise.allSettled(topics.map((topic) => client.subscribeAsync(topic, { qos: 1 })));
2268
2676
  const granted = [];
2269
2677
  const denied = [];
2270
- for (const g of grants)
2271
- (g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
2678
+ outcomes.forEach((outcome, i) => {
2679
+ const topic = topics[i];
2680
+ if (outcome.status === "fulfilled") {
2681
+ granted.push(topic);
2682
+ return;
2683
+ }
2684
+ if (!isRefusal(outcome.reason))
2685
+ throw outcome.reason;
2686
+ denied.push(topic);
2687
+ });
2272
2688
  return { granted, denied };
2273
2689
  }
2274
2690
  /**
@@ -2283,134 +2699,25 @@ var SecureMqtt = class extends EventEmitter {
2283
2699
  * don't share subscribe/publish routing).
2284
2700
  */
2285
2701
  async publish(topic, body, opts = {}) {
2286
- if (!this.client)
2287
- throw new Error("SecureMqtt not connected");
2288
- await this.client.publishAsync(topic, body, { qos: opts.qos ?? 1 });
2289
- }
2290
- async disconnect() {
2291
- this.connecting = void 0;
2292
- await this.client?.endAsync(true);
2293
- this.client = void 0;
2294
- }
2295
- };
2296
-
2297
- // dist/transport/mqtt/app-client-id.js
2298
- import { createHash as createHash4 } from "node:crypto";
2299
- function buildAppShapedClientId(input) {
2300
- const ts = input.timestamp ?? Math.floor(Date.now() / 1e3);
2301
- return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
2302
- }
2303
- function mqttUuidFrom(installId) {
2304
- return createHash4("sha256").update(installId).digest("hex").slice(0, 16);
2305
- }
2306
-
2307
- // dist/core/util.js
2308
- var Timer = class {
2309
- handle;
2310
- /** (Re)arm to run `fn` after `ms`, cancelling any already-pending fire. */
2311
- arm(ms, fn) {
2312
- this.cancel();
2313
- this.handle = setTimeout(() => {
2314
- this.handle = void 0;
2315
- fn();
2316
- }, ms);
2317
- this.handle.unref?.();
2318
- }
2319
- /** Cancel a pending fire. No-op if not armed. */
2320
- cancel() {
2321
- if (this.handle) {
2322
- clearTimeout(this.handle);
2323
- this.handle = void 0;
2324
- }
2325
- }
2326
- /** Whether a fire is currently scheduled. */
2327
- get pending() {
2328
- return this.handle !== void 0;
2329
- }
2330
- };
2331
- function assertNever(value) {
2332
- throw new Error(`unhandled variant: ${JSON.stringify(value)}`);
2333
- }
2334
- function asBool(value) {
2335
- if (typeof value === "string")
2336
- return value === "1" || value.toLowerCase() === "true";
2337
- return value === true || value === 1;
2338
- }
2339
- function clamp(value, min, max) {
2340
- if (Number.isNaN(value))
2341
- return min;
2342
- return Math.max(min, Math.min(max, Math.round(value)));
2343
- }
2344
- function structuralEqual(a, b) {
2345
- if (Object.is(a, b))
2346
- return true;
2347
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
2348
- return false;
2349
- const aArr = Array.isArray(a);
2350
- const bArr = Array.isArray(b);
2351
- if (aArr !== bArr)
2352
- return false;
2353
- if (aArr && bArr) {
2354
- if (a.length !== b.length)
2355
- return false;
2356
- for (let i = 0; i < a.length; i++)
2357
- if (!structuralEqual(a[i], b[i]))
2358
- return false;
2359
- return true;
2360
- }
2361
- const aObj = a;
2362
- const bObj = b;
2363
- const aKeys = Object.keys(aObj);
2364
- const bKeys = Object.keys(bObj);
2365
- if (aKeys.length !== bKeys.length)
2366
- return false;
2367
- for (const k of aKeys) {
2368
- if (!Object.prototype.hasOwnProperty.call(bObj, k))
2369
- return false;
2370
- if (!structuralEqual(aObj[k], bObj[k]))
2371
- return false;
2372
- }
2373
- return true;
2374
- }
2375
- function coerceEnumValue(enumObj, value) {
2376
- const n = Number(value);
2377
- return Number.isInteger(n) && Object.values(enumObj).includes(n) ? n : void 0;
2378
- }
2379
- function enumValues(enumObj) {
2380
- return Object.values(enumObj).join("/");
2381
- }
2382
- function enumLabels(enumObj) {
2383
- return Object.fromEntries(Object.entries(enumObj).map(([name, value]) => [value, name]));
2384
- }
2385
- function jsonObject(text2) {
2386
- if (typeof text2 !== "string")
2387
- return void 0;
2388
- try {
2389
- const v = JSON.parse(text2);
2390
- return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
2391
- } catch {
2392
- return void 0;
2393
- }
2394
- }
2395
- function u16le(n) {
2396
- const b = Buffer.alloc(2);
2397
- b.writeUInt16LE(n & 65535);
2398
- return b;
2399
- }
2400
- function u16be(n) {
2401
- const b = Buffer.alloc(2);
2402
- b.writeUInt16BE(n & 65535);
2403
- return b;
2404
- }
2405
- function u32le(n) {
2406
- const b = Buffer.alloc(4);
2407
- b.writeUInt32LE(n >>> 0);
2408
- return b;
2702
+ if (!this.client)
2703
+ throw new Error("SecureMqtt not connected");
2704
+ await this.client.publishAsync(topic, body, { qos: opts.qos ?? 1 });
2705
+ }
2706
+ async disconnect() {
2707
+ this.connecting = void 0;
2708
+ await this.client?.endAsync(true);
2709
+ this.client = void 0;
2710
+ }
2711
+ };
2712
+
2713
+ // dist/transport/mqtt/app-client-id.js
2714
+ import { createHash as createHash4 } from "node:crypto";
2715
+ function buildAppShapedClientId(input) {
2716
+ const ts = input.timestamp ?? Math.floor(Date.now() / 1e3);
2717
+ return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
2409
2718
  }
2410
- function u32be(n) {
2411
- const b = Buffer.alloc(4);
2412
- b.writeUInt32BE(n >>> 0);
2413
- return b;
2719
+ function mqttUuidFrom(installId) {
2720
+ return createHash4("sha256").update(installId).digest("hex").slice(0, 16);
2414
2721
  }
2415
2722
 
2416
2723
  // dist/transport/mqtt/availability.js
@@ -2537,13 +2844,6 @@ function parseAiotDpReport(raw) {
2537
2844
  return Object.keys(out).length ? out : void 0;
2538
2845
  }
2539
2846
 
2540
- // dist/core/raw-dp-hex.js
2541
- function hexToRawDp(hex) {
2542
- if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
2543
- return void 0;
2544
- return Buffer.from(hex, "hex").toString("base64");
2545
- }
2546
-
2547
2847
  // dist/transport/mqtt/biz-stream.js
2548
2848
  var BIZ_PROTOCOL = {
2549
2849
  /** Map-stream data: the frames this module parses. */
@@ -2727,18 +3027,6 @@ var DeviceType = {
2727
3027
  CAMERA_C35: 10035
2728
3028
  };
2729
3029
 
2730
- // dist/core/types.js
2731
- var PRINTER_CATEGORY_RE = /ankermake|eufymake|fdm|3d_?print/i;
2732
- function classifyDevice(raw) {
2733
- const category = raw.category ?? "";
2734
- const hasP2p = typeof raw.p2p_did === "string" && raw.p2p_did.length > 0;
2735
- if (category === "eufy_security" || hasP2p)
2736
- return { category, api: "mega", realtime: "p2p" };
2737
- if (PRINTER_CATEGORY_RE.test(category))
2738
- return { category, api: "ankermake", realtime: "ankermake-mqtt" };
2739
- return { category, api: "mega", realtime: "smqtt" };
2740
- }
2741
-
2742
3030
  // dist/model/classify.js
2743
3031
  var STATION_TYPES = /* @__PURE__ */ new Set([
2744
3032
  DeviceType.STATION,
@@ -2877,111 +3165,6 @@ function inferName(rec) {
2877
3165
  return void 0;
2878
3166
  }
2879
3167
 
2880
- // dist/core/contracts.js
2881
- var StoredSnapshotUnavailableError = class extends Error {
2882
- reason;
2883
- constructor(reason, message, options) {
2884
- super(message, options);
2885
- this.reason = reason;
2886
- this.name = "StoredSnapshotUnavailableError";
2887
- }
2888
- };
2889
- var RETRYABLE_LIVE_SNAPSHOT_REASONS = [
2890
- "no-keyframe",
2891
- "source-failed",
2892
- "undecodable-burst"
2893
- ];
2894
- var LiveSnapshotUnavailableError = class extends Error {
2895
- reason;
2896
- /** Whether another attempt could plausibly succeed without the host changing anything. */
2897
- retryable;
2898
- constructor(reason, message, options) {
2899
- super(message, options);
2900
- this.reason = reason;
2901
- this.name = "LiveSnapshotUnavailableError";
2902
- this.retryable = RETRYABLE_LIVE_SNAPSHOT_REASONS.includes(reason);
2903
- }
2904
- };
2905
- var CameraDisabledError = class extends Error {
2906
- camera;
2907
- constructor(camera, options) {
2908
- super(`camera${camera ? ` ${camera}` : ""} is disabled \u2014 no live stream possible`, options);
2909
- this.camera = camera;
2910
- this.name = "CameraDisabledError";
2911
- }
2912
- };
2913
- var StationKeyUnavailableError = class extends Error {
2914
- stationSn;
2915
- /** Always true: the negotiation is per connection, so a later one may still produce a key. */
2916
- retryable = true;
2917
- constructor(stationSn, options) {
2918
- super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
2919
- this.stationSn = stationSn;
2920
- this.name = "StationKeyUnavailableError";
2921
- }
2922
- };
2923
- var StationUnreachableError = class extends Error {
2924
- stationSn;
2925
- waitedMs;
2926
- /** Always true: a station unreachable now may answer on a later attempt. */
2927
- retryable = true;
2928
- constructor(stationSn, waitedMs, options) {
2929
- super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
2930
- this.stationSn = stationSn;
2931
- this.waitedMs = waitedMs;
2932
- this.name = "StationUnreachableError";
2933
- }
2934
- };
2935
- var DeviceChannelUnresolvedError = class extends Error {
2936
- sn;
2937
- stationSn;
2938
- constructor(sn, stationSn, options) {
2939
- super(`${sn} has no usable channel on station ${stationSn}, so nothing was sent to it`, options);
2940
- this.sn = sn;
2941
- this.stationSn = stationSn;
2942
- this.name = "DeviceChannelUnresolvedError";
2943
- }
2944
- };
2945
- var LiveStreamStartError = class extends Error {
2946
- reason;
2947
- stage;
2948
- timeoutMs;
2949
- attempts;
2950
- constructor(failure) {
2951
- super(`live stream failed to start (${failure.reason} at ${failure.stage} after ${failure.attempts} attempts; ${failure.timeoutMs}ms deadline)`, failure.cause === void 0 ? void 0 : { cause: failure.cause });
2952
- this.name = "LiveStreamStartError";
2953
- this.reason = failure.reason;
2954
- this.stage = failure.stage;
2955
- this.timeoutMs = failure.timeoutMs;
2956
- this.attempts = failure.attempts;
2957
- }
2958
- };
2959
- var StateConvergenceError = class extends Error {
2960
- sn;
2961
- property;
2962
- param;
2963
- expected;
2964
- observed;
2965
- timeoutMs;
2966
- constructor(failure) {
2967
- super(`device ${failure.sn} did not report ${failure.property} as ${String(failure.expected)} within ${failure.timeoutMs}ms (param ${failure.param} read ${String(failure.observed)})`);
2968
- this.name = "StateConvergenceError";
2969
- this.sn = failure.sn;
2970
- this.property = failure.property;
2971
- this.param = failure.param;
2972
- this.expected = failure.expected;
2973
- this.observed = failure.observed;
2974
- this.timeoutMs = failure.timeoutMs;
2975
- }
2976
- };
2977
- var COMMAND_OBSERVATION = /* @__PURE__ */ Symbol("command-observation");
2978
- function observeCommand(command, observation) {
2979
- return Object.defineProperty(command, COMMAND_OBSERVATION, { configurable: true, value: observation });
2980
- }
2981
- function commandObservation(command) {
2982
- return command[COMMAND_OBSERVATION];
2983
- }
2984
-
2985
3168
  // dist/model/capabilities/access.js
2986
3169
  function setScalar(param, value, ctx, form = "auto") {
2987
3170
  return { kind: "set-param", param, value, form, channel: ctx.channel };
@@ -3254,11 +3437,17 @@ var unreflectedMembers = (surface) => statement(surface, UNREFLECTED);
3254
3437
  // dist/model/capabilities/manifest.js
3255
3438
  function describeBound(modules, bound, ctx) {
3256
3439
  const out = [];
3440
+ const anyBound = Object.values(bound).some((obj) => obj && typeof obj === "object");
3257
3441
  for (const m of modules) {
3258
3442
  const accessor = camelCase(m.capability);
3259
3443
  const obj = bound[accessor];
3260
- if (!obj || typeof obj !== "object")
3444
+ if (!obj || typeof obj !== "object") {
3445
+ const events = anyBound ? emitsOf(m, [], ctx) : [];
3446
+ if (events.length && ctx?.capabilities?.has(m.capability)) {
3447
+ out.push({ capability: m.capability, reads: [], actions: [], undescribedActions: [], events });
3448
+ }
3261
3449
  continue;
3450
+ }
3262
3451
  const descriptors = Object.getOwnPropertyDescriptors(obj);
3263
3452
  const reads2 = [];
3264
3453
  const actions = [];
@@ -3781,7 +3970,6 @@ var MOTION_CMD = {
3781
3970
  * payload key AND pass mChannel 0 explicitly; the two are separate choices, not linked.)
3782
3971
  *
3783
3972
  * ⚠️ Replay + readback confirmed on a HomeBase-attached T8425 (1719 `0`→`1`→`0`), NOT byte-captured.
3784
- * Provenance is `apk`, not `verified`: a divergent-but-also-accepted frame can't be ruled out.
3785
3973
  */
3786
3974
  HUMAN_ONLY_AT_NIGHT: 1719,
3787
3975
  /**
@@ -3795,7 +3983,7 @@ var MOTION_CMD = {
3795
3983
  * form, so a feature that is on reads as off.
3796
3984
  * Decoded by {@link decodeRadarWdSwitch} to match the app.
3797
3985
  *
3798
- * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured. Provenance `apk`.
3986
+ * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured.
3799
3987
  */
3800
3988
  LOITERING_DETECTION: 2706,
3801
3989
  /**
@@ -4119,7 +4307,7 @@ var MOTION_MEMBERS = {
4119
4307
  param: MOTION_CMD.HUMAN_ONLY_AT_NIGHT,
4120
4308
  type: "bool",
4121
4309
  kind: "boolean",
4122
- provenance: "apk",
4310
+ provenance: "verified",
4123
4311
  description: "Restrict AI classification to night-time only (1719). \u26A0\uFE0F Replay + readback confirmed on a HomeBase-attached T8425, not byte-captured.",
4124
4312
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
4125
4313
  write: (v, ctx) => {
@@ -4135,7 +4323,7 @@ var MOTION_MEMBERS = {
4135
4323
  param: MOTION_CMD.LOITERING_DETECTION,
4136
4324
  type: "bool",
4137
4325
  kind: "boolean",
4138
- provenance: "apk",
4326
+ provenance: "verified",
4139
4327
  coerce: (raw) => decodeRadarWdSwitch(raw) ?? false,
4140
4328
  description: "Loitering detection \u2014 alert on lingering rather than passing (2706). Observed on the T8214 doorbell only; the read is object-OR-scalar, matching the app's own decode.",
4141
4329
  requires: [MOTION_CMD.LOITERING_DETECTION],
@@ -4241,6 +4429,7 @@ var MOTION = {
4241
4429
  };
4242
4430
 
4243
4431
  // dist/model/capabilities/person-detection.js
4432
+ var IDENTIFIED_PERSON_CLAIM = { homeBaseAttached: true };
4244
4433
  var PERSON_DETECTION = {
4245
4434
  capability: "person_detection",
4246
4435
  description: "AI human detection enable switch and live detected state.",
@@ -4257,8 +4446,18 @@ var PERSON_DETECTION = {
4257
4446
  */
4258
4447
  events: [
4259
4448
  { source: "push", match: DoorbellPushEvent.FACE_DETECTION, emit: "personDetected" },
4260
- { source: "push", match: HB3PairedDevicePushEvent.IDENTITY_PERSON_DETECTION, emit: "personDetected" },
4261
- { source: "push", match: HB3PairedDevicePushEvent.STRANGER_PERSON_DETECTION, emit: "strangerDetected" }
4449
+ {
4450
+ source: "push",
4451
+ match: HB3PairedDevicePushEvent.IDENTITY_PERSON_DETECTION,
4452
+ emit: "personDetected",
4453
+ claim: IDENTIFIED_PERSON_CLAIM
4454
+ },
4455
+ {
4456
+ source: "push",
4457
+ match: HB3PairedDevicePushEvent.STRANGER_PERSON_DETECTION,
4458
+ emit: "strangerDetected",
4459
+ claim: IDENTIFIED_PERSON_CLAIM
4460
+ }
4262
4461
  ]
4263
4462
  };
4264
4463
 
@@ -4326,7 +4525,7 @@ function decodePowerSource(raw) {
4326
4525
  function recordSetting(param, value, ctx) {
4327
4526
  return setStationScalar(param, value, ctx.channel);
4328
4527
  }
4329
- var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1"];
4528
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1", "T8423"];
4330
4529
  function cameraPowerTier(model, capabilities) {
4331
4530
  return capabilities.has("battery") && notMainsCamera({ model }) ? "battery" : "wired";
4332
4531
  }
@@ -5536,6 +5735,7 @@ var CAMERA_MEMBERS = {
5536
5735
  refuseWhenDisabled(ctx, read);
5537
5736
  return m.recordFragments({ powered: poweredOf(ctx), ...opts });
5538
5737
  }), "Continuous fragmented-MP4 (CMAF) recording."),
5738
+ downloadRecording: provided("media", (m) => m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
5539
5739
  /**
5540
5740
  * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
5541
5741
  * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
@@ -5577,7 +5777,7 @@ var SIREN_CMD = {
5577
5777
  ALARM_TEST: 1826,
5578
5778
  /** Manually stop a sounding alarm (app `APP_CMD_SIREN_SENSOR_MANUAL_STOP_ALARM`). */
5579
5779
  MANUAL_STOP: 1871,
5580
- /** Station-family HomeBase duration alarm, verified live on T8010 (app `SET_TONE_FILE`). */
5780
+ /** Station-family HomeBase duration alarm, verified live on T8010 and T8030 (app `SET_TONE_FILE`). */
5581
5781
  HOMEBASE_TONE: 1201,
5582
5782
  /** HomeBase alarm volume percentage (app `CMD_SET_HUB_SPK_VOLUME`). */
5583
5783
  HUB_SPK_VOLUME: 1235,
@@ -5592,7 +5792,7 @@ var SirenAlarmDuration = { Min1: 60, Min5: 300, Min10: 600, Min15: 900 };
5592
5792
  var HubAlarmTone = { Tone1: 1, Tone2: 2 };
5593
5793
  var HUB_ALARM_EVIDENCE = [SIREN_CMD.HUB_ALARM_TONE, 1282];
5594
5794
  function isStationAlarmOutput(ctx) {
5595
- return ctx.deviceType === DeviceType.STATION;
5795
+ return ctx.deviceType === DeviceType.STATION || ctx.deviceType === DeviceType.HB3;
5596
5796
  }
5597
5797
  function isEvidencedStationAlarm(ctx) {
5598
5798
  return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
@@ -6714,9 +6914,9 @@ function parseQuickResponses(voiceList) {
6714
6914
  var DOORBELL_MEMBERS = {
6715
6915
  /**
6716
6916
  * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
6717
- * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame,
6718
- * not merely the param id observed live: direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape
6719
- * as its 1703 sibling — captured, not inferred from the shared param range.
6917
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
6918
+ * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
6919
+ * inferred from the shared param range.
6720
6920
  */
6721
6921
  chimeSwitch: {
6722
6922
  param: DOORBELL_CMD.CHIME_SWITCH,
@@ -6727,9 +6927,8 @@ var DOORBELL_MEMBERS = {
6727
6927
  write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6728
6928
  },
6729
6929
  /**
6730
- * `provenance` is "verified" not "mega": the actual write wire is confirmed (
6731
- * our own P2P decrypt), not merely the param id observed on a live device. Direct-binary
6732
- * `[ch][value][acct]`, 1=on/0=off — verified live on a T8214 (ON then OFF).
6930
+ * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
6931
+ * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
6733
6932
  */
6734
6933
  mechanicalChimeSwitch: {
6735
6934
  param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
@@ -6760,7 +6959,7 @@ var DOORBELL_MEMBERS = {
6760
6959
  type: "number",
6761
6960
  unit: "%",
6762
6961
  kind: "percent",
6763
- provenance: "mega",
6962
+ provenance: "verified",
6764
6963
  writtenElsewhere: true,
6765
6964
  description: "Ringtone volume (1708; confirmed on T8214, observed value 80). The WRITE is `audio`'s setRingtoneVolume \u2014 that capability owns every volume wire."
6766
6965
  },
@@ -6865,7 +7064,7 @@ var DOORBELL_MEMBERS = {
6865
7064
  param: 1710,
6866
7065
  type: "string",
6867
7066
  kind: "text",
6868
- provenance: "mega",
7067
+ provenance: "verified",
6869
7068
  description: "Notification config JSON {notification_motion_onoff,notification_ring_onoff,notification_style} (1710; confirmed on T8214)."
6870
7069
  },
6871
7070
  /**
@@ -7526,120 +7725,41 @@ var ARMING_MEMBERS = {
7526
7725
  var ARMING = {
7527
7726
  capability: "arming",
7528
7727
  description: "Station/device guard (arming) mode.",
7529
- members: ARMING_MEMBERS,
7530
- properties: propertiesOf(ARMING_MEMBERS),
7531
- // A reported guard-mode param is the verified proof; every station-codec device also owns an
7532
- // arming surface as part of its baseline.
7533
- detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7534
- ownedByStation: true,
7535
- /**
7536
- * Station-scoped push events. The hub reports a guard-mode switch and the alarm lifecycle on the
7537
- * generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
7538
- * capability (see the barrel's event index).
7539
- *
7540
- * The alarm ids carry a static `phase` so one event name covers the whole lifecycle, the same shape
7541
- * `battery` uses for its threshold pushes. The mode-switch push is known to identify WHICH mode and
7542
- * what triggered the change, but neither has been observed on the wire here, so this emits the bare
7543
- * transition and refreshes the `mode` read rather than trusting a decoded field.
7544
- */
7545
- events: [
7546
- {
7547
- source: "push",
7548
- match: CusPushEvent.MODE_SWITCH,
7549
- emit: "armingModeChanged",
7550
- refresh: { member: "mode" }
7551
- },
7552
- { source: "push", match: CusPushEvent.ALARM, emit: "alarm", payload: { phase: "triggered" } },
7553
- { source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
7554
- ]
7555
- };
7556
-
7557
- // dist/model/capabilities/storage.js
7558
- var STORAGE = {
7559
- capability: "storage",
7560
- description: "Local storage (SD card / eMMC / HDD). Behaviour-only until a capacity param is captured.",
7561
- properties: [],
7562
- detection: { codecs: ["station"] }
7563
- };
7564
-
7565
- // dist/core/raw-dp-writer.js
7566
- var WIRE = { VARINT: 0, LENGTH_DELIMITED: 2 };
7567
- function encodeVarint(value) {
7568
- if (!Number.isSafeInteger(value) || value < 0) {
7569
- throw new Error(`varint: ${value} is not a non-negative safe integer`);
7570
- }
7571
- const out = [];
7572
- let v = value;
7573
- while (v > 127) {
7574
- out.push(v % 128 + 128);
7575
- v = Math.floor(v / 128);
7576
- }
7577
- out.push(v);
7578
- return out;
7579
- }
7580
- function zigzag(value) {
7581
- if (!Number.isSafeInteger(value))
7582
- throw new Error(`zigzag: ${value} is not a safe integer`);
7583
- return value < 0 ? -2 * value - 1 : 2 * value;
7584
- }
7585
- var RawDpWriter = class _RawDpWriter {
7586
- body = [];
7587
- /** A varint-valued field (`int32`, `uint32`, `bool`, an enum member). */
7588
- int(field, value) {
7589
- this.body.push(...encodeVarint(tag(field, WIRE.VARINT)), ...encodeVarint(value));
7590
- return this;
7591
- }
7592
- /** A signed varint field (`sint32`, `sint64`), ZigZag-encoded — see {@link zigzag}. */
7593
- sint(field, value) {
7594
- return this.int(field, zigzag(value));
7595
- }
7596
- /** A boolean field, on protobuf's own terms: `1` or `0` in a varint. */
7597
- bool(field, value) {
7598
- return this.int(field, value ? 1 : 0);
7599
- }
7600
- /** A length-delimited field carrying raw bytes — a string, a blob, or a message built elsewhere. */
7601
- bytes(field, value) {
7602
- const bytes2 = value instanceof Uint8Array ? Array.from(value) : [...value];
7603
- this.body.push(...encodeVarint(tag(field, WIRE.LENGTH_DELIMITED)), ...encodeVarint(bytes2.length), ...bytes2);
7604
- return this;
7605
- }
7606
- /**
7607
- * A nested sub-message, built by `build` into a writer of its own.
7608
- *
7609
- * The sub-message is emitted even when `build` writes nothing into it. That is not an oversight: an
7610
- * EMPTY sub-message is how these protocols say "this subsystem exists and is in its zero state", as
7611
- * opposed to saying nothing about it, and the readers on the other side of this file depend on the
7612
- * distinction. Omitting the call is what says nothing about it.
7613
- */
7614
- sub(field, build) {
7615
- const inner = new _RawDpWriter();
7616
- build(inner);
7617
- return this.bytes(field, inner.toBytes());
7618
- }
7619
- /** The message body on its own, unframed — for nesting into another writer. */
7620
- toBytes() {
7621
- return [...this.body];
7622
- }
7728
+ members: ARMING_MEMBERS,
7729
+ properties: propertiesOf(ARMING_MEMBERS),
7730
+ // A reported guard-mode param is the verified proof; every station-codec device also owns an
7731
+ // arming surface as part of its baseline.
7732
+ detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7733
+ ownedByStation: true,
7623
7734
  /**
7624
- * The finished DP value: `varint(bodyLength) ++ body`, base64-encoded.
7735
+ * Station-scoped push events. The hub reports a guard-mode switch and the alarm lifecycle on the
7736
+ * generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
7737
+ * capability (see the barrel's event index).
7625
7738
  *
7626
- * This is the envelope `RawDpCodec.decode` unwraps, so a value produced here and read back through the
7627
- * codec is a genuine round-trip rather than two implementations of the same guess.
7739
+ * The alarm ids carry a static `phase` so one event name covers the whole lifecycle, the same shape
7740
+ * `battery` uses for its threshold pushes. The mode-switch push is known to identify WHICH mode and
7741
+ * what triggered the change, but neither has been observed on the wire here, so this emits the bare
7742
+ * transition and refreshes the `mode` read rather than trusting a decoded field.
7628
7743
  */
7629
- finish() {
7630
- return Buffer.from([...encodeVarint(this.body.length), ...this.body]).toString("base64");
7631
- }
7744
+ events: [
7745
+ {
7746
+ source: "push",
7747
+ match: CusPushEvent.MODE_SWITCH,
7748
+ emit: "armingModeChanged",
7749
+ refresh: { member: "mode" }
7750
+ },
7751
+ { source: "push", match: CusPushEvent.ALARM, emit: "alarm", payload: { phase: "triggered" } },
7752
+ { source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
7753
+ ]
7754
+ };
7755
+
7756
+ // dist/model/capabilities/storage.js
7757
+ var STORAGE = {
7758
+ capability: "storage",
7759
+ description: "Local storage (SD card / eMMC / HDD). Behaviour-only until a capacity param is captured.",
7760
+ properties: [],
7761
+ detection: { codecs: ["station"] }
7632
7762
  };
7633
- function tag(field, wire) {
7634
- if (!Number.isSafeInteger(field) || field < 1)
7635
- throw new Error(`field number: ${field} is not a positive integer`);
7636
- return field * 8 + wire;
7637
- }
7638
- function rawDp(build) {
7639
- const w = new RawDpWriter();
7640
- build(w);
7641
- return w.finish();
7642
- }
7643
7763
 
7644
7764
  // dist/model/capabilities/vacuum-dock.js
7645
7765
  var VACUUM_DOCK_DP = 173;
@@ -13345,58 +13465,6 @@ function parseCleanRecordDetail(blob, codec) {
13345
13465
  };
13346
13466
  }
13347
13467
 
13348
- // dist/core/lz4-block.js
13349
- var MAX_OUTPUT = 64 * 1024 * 1024;
13350
- function lz4BlockDecompress(block, size) {
13351
- if (!Number.isSafeInteger(size) || size < 0 || size > MAX_OUTPUT)
13352
- return void 0;
13353
- const out = Buffer.alloc(size);
13354
- const end = block.length;
13355
- let pos = 0;
13356
- let written = 0;
13357
- const extend2 = (base) => {
13358
- let len = base;
13359
- if (base !== 15)
13360
- return len;
13361
- for (; ; ) {
13362
- if (pos >= end)
13363
- return void 0;
13364
- const extra = block[pos++];
13365
- len += extra;
13366
- if (extra !== 255)
13367
- return len;
13368
- }
13369
- };
13370
- while (pos < end) {
13371
- const token = block[pos++];
13372
- const literals = extend2(token >> 4);
13373
- if (literals === void 0)
13374
- return void 0;
13375
- if (pos + literals > end || written + literals > size)
13376
- return void 0;
13377
- for (let i = 0; i < literals; i++)
13378
- out[written++] = block[pos++];
13379
- if (pos === end)
13380
- break;
13381
- if (pos + 2 > end)
13382
- return void 0;
13383
- const offset = block[pos] | block[pos + 1] << 8;
13384
- pos += 2;
13385
- if (offset === 0 || offset > written)
13386
- return void 0;
13387
- const match = extend2(token & 15);
13388
- if (match === void 0)
13389
- return void 0;
13390
- const length = match + 4;
13391
- if (written + length > size)
13392
- return void 0;
13393
- let from = written - offset;
13394
- for (let i = 0; i < length; i++)
13395
- out[written++] = out[from++];
13396
- }
13397
- return written === size ? out : void 0;
13398
- }
13399
-
13400
13468
  // dist/model/vacuum-map.js
13401
13469
  var DOCK_KINDS = ["charger", "station"];
13402
13470
  var MAP_QUALITIES = ["incomplete", "rough", "effective", "listFull"];
@@ -14307,6 +14375,7 @@ __export(codec_exports, {
14307
14375
  buildLookupWithKeyPayload2: () => buildLookupWithKeyPayload2,
14308
14376
  buildRawCommandPayload: () => buildRawCommandPayload,
14309
14377
  buildStringCommandPayload: () => buildStringCommandPayload,
14378
+ buildStringPairCommandPayload: () => buildStringPairCommandPayload,
14310
14379
  buildVoidCommandPayload: () => buildVoidCommandPayload,
14311
14380
  decodeP2PCloudIPs: () => decodeP2PCloudIPs,
14312
14381
  decryptP2PData: () => decryptP2PData,
@@ -14343,6 +14412,11 @@ var ResponseMessageType = {
14343
14412
  LOOKUP_ADDR: Buffer.from([241, 64]),
14344
14413
  LOOKUP_ADDR2: Buffer.from([241, 130]),
14345
14414
  CAM_ID: Buffer.from([241, 66]),
14415
+ /**
14416
+ * The device's own address record, sent in answer to CHECK_CAM ahead of CAM_ID: its 20-byte device id, one
14417
+ * record in `encodeSelfAddress`'s shape naming the address it answers from, then 8 zero bytes (44-byte payload).
14418
+ */
14419
+ CAM_ADDR: Buffer.from([241, 67]),
14346
14420
  TURN_SERVER_CAM_ID: Buffer.from([241, 132]),
14347
14421
  PING: Buffer.from([241, 224]),
14348
14422
  PONG: Buffer.from([241, 225]),
@@ -14554,6 +14628,12 @@ function buildIntStringCommandPayload(value, valueSub, strValue, channel = 0, ke
14554
14628
  data
14555
14629
  ]);
14556
14630
  }
14631
+ function buildStringPairCommandPayload(strValue, strValueSub, channel = 0, key, encType = 1) {
14632
+ const encrypted = !!key && key.length === 16;
14633
+ const body = Buffer.concat([Buffer.alloc(5), stringWithLength(strValue), stringWithLength(strValueSub)]);
14634
+ const data = encrypted ? encryptP2PData(paddingP2PData(body), key) : body;
14635
+ return buildRawCommandPayload(data, channel, encrypted ? encType : 0);
14636
+ }
14557
14637
  function buildRawCommandPayload(data, channel = 0, signCode = 0, magic = [1, 0], streamId = 0) {
14558
14638
  const header = Buffer.allocUnsafe(2);
14559
14639
  header.writeUInt16LE(data.length, 0);
@@ -15159,6 +15239,7 @@ var LOCAL_LOOKUP_PORT = 32108;
15159
15239
  var HEARTBEAT_MS = 5e3;
15160
15240
  var PATH_SILENCE_MS = HEARTBEAT_MS * 3;
15161
15241
  var LOOKUP_RETRY_MS = 1e3;
15242
+ var RECEIVE_BUFFER_BYTES = 4 * 1024 * 1024;
15162
15243
  var CONNECT_TIMEOUT_MS = 15e3;
15163
15244
  var STATION_CHANNEL3 = 255;
15164
15245
  var CMD_GATEWAYINFO = 1100;
@@ -15184,6 +15265,7 @@ var SEQUENCE_LOOKBACK = 32768;
15184
15265
  var STALE_RETRANSMIT_DEPTH = 1024;
15185
15266
  var PUNCH_PROBE_SOCKETS = 7;
15186
15267
  var REORDER_WAIT_MS = 250;
15268
+ var VIDEO_REORDER_WAIT_MS = 700;
15187
15269
  var REORDER_MAX_DATAGRAMS = 128;
15188
15270
  var MAX_TRACED_DATAGRAM_GAPS = 8;
15189
15271
  var CMD_SET_PAYLOAD = 1350;
@@ -15194,6 +15276,7 @@ var CMD_DATABASE_IMAGE = 1308;
15194
15276
  var CMD_DATABASE = 1306;
15195
15277
  var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
15196
15278
  var GCM_AAD = Buffer.from("eufy security");
15279
+ var LEVEL2_SEQ_BASE = 16909056;
15197
15280
  function paramReport(payload) {
15198
15281
  const list = payload?.params;
15199
15282
  if (!Array.isArray(list))
@@ -15208,12 +15291,57 @@ function paramReport(payload) {
15208
15291
  return Object.keys(out).length ? out : void 0;
15209
15292
  }
15210
15293
  var traceSequence = 0;
15294
+ var DB_TABLE_TIMEOUT_MS = 15e3;
15295
+ function firstTableRows(text2) {
15296
+ const decoded = Buffer.from(text2, "latin1").toString("utf8");
15297
+ for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
15298
+ const end = closingBrace(decoded, start);
15299
+ if (end < 0)
15300
+ return void 0;
15301
+ let parsed;
15302
+ try {
15303
+ parsed = JSON.parse(decoded.slice(start, end + 1));
15304
+ } catch {
15305
+ continue;
15306
+ }
15307
+ const data = parsed?.data;
15308
+ if (Array.isArray(data))
15309
+ return data;
15310
+ }
15311
+ return void 0;
15312
+ }
15313
+ function closingBrace(text2, start) {
15314
+ let depth = 0;
15315
+ let inString = false;
15316
+ let escaped = false;
15317
+ for (let i = start; i < text2.length; i++) {
15318
+ const c = text2[i];
15319
+ if (inString) {
15320
+ if (escaped)
15321
+ escaped = false;
15322
+ else if (c === "\\")
15323
+ escaped = true;
15324
+ else if (c === '"')
15325
+ inString = false;
15326
+ continue;
15327
+ }
15328
+ if (c === '"')
15329
+ inString = true;
15330
+ else if (c === "{")
15331
+ depth++;
15332
+ else if (c === "}" && --depth === 0)
15333
+ return i;
15334
+ }
15335
+ return -1;
15336
+ }
15211
15337
  var P2PSession = class _P2PSession extends EventEmitter2 {
15212
15338
  cfg;
15213
15339
  socket;
15214
15340
  connected = false;
15215
15341
  connecting = false;
15216
15342
  closed = false;
15343
+ /** Whether a short receive buffer has been reported, so a reconnect does not repeat the same warning. */
15344
+ receiveBufferReported = false;
15217
15345
  connectAddress;
15218
15346
  seqNumber = 0;
15219
15347
  /**
@@ -15240,8 +15368,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15240
15368
  audioStalled = false;
15241
15369
  audioRetransmitTimer;
15242
15370
  lastPongData;
15243
- /** When this connection last received a PONG — `undefined` until the first, see {@link pathSilentMs}. */
15244
- lastPongAt;
15371
+ /** When the selected peer last answered outbound P2P traffic on this connection. */
15372
+ lastPeerAt;
15245
15373
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
15246
15374
  pathStaleTraced = false;
15247
15375
  lookupTimer;
@@ -15310,17 +15438,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15310
15438
  /**
15311
15439
  * How long this connection's path has been silent, or nothing where it has never answered.
15312
15440
  *
15313
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
15314
- * that has said nothing either way.
15441
+ * PONG and ACK from the selected peer prove the path answers outbound traffic. `undefined` means no such reply
15442
+ * has arrived since connection, so silence alone does not establish a stale path.
15315
15443
  */
15316
15444
  get pathSilentMs() {
15317
- return this.lastPongAt === void 0 ? void 0 : Date.now() - this.lastPongAt;
15445
+ return this.lastPeerAt === void 0 ? void 0 : Date.now() - this.lastPeerAt;
15318
15446
  }
15319
15447
  /**
15320
15448
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
15321
15449
  *
15322
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
15323
- * not known to be dead, so it answers true.
15450
+ * False where selected-peer replies arrived and then stopped for {@link PATH_SILENCE_MS}. A connection with
15451
+ * no post-connect reply is not known to be dead, so it answers true.
15324
15452
  *
15325
15453
  * Traces the silence once per connection, on the read that first observes it.
15326
15454
  */
@@ -15536,6 +15664,26 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15536
15664
  get isConnected() {
15537
15665
  return this.connected;
15538
15666
  }
15667
+ /**
15668
+ * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and warn once per session when it grants
15669
+ * less or refuses.
15670
+ *
15671
+ * The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
15672
+ * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
15673
+ */
15674
+ requestReceiveBuffer(socket) {
15675
+ let granted;
15676
+ try {
15677
+ socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
15678
+ granted = socket.getRecvBufferSize();
15679
+ } catch {
15680
+ granted = 0;
15681
+ }
15682
+ if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
15683
+ return;
15684
+ this.receiveBufferReported = true;
15685
+ this.logger.warn(`[p2p] ${this.cfg.stationSn} UDP receive buffer below the ${RECEIVE_BUFFER_BYTES} bytes requested` + (granted > 0 ? ` (granted ${granted})` : " (request refused)") + `; live video can lose keyframes. Raise the OS limit (net.core.rmem_max on Linux, kern.ipc.maxsockbuf on BSD) to at least ${RECEIVE_BUFFER_BYTES}.`);
15686
+ }
15539
15687
  /** Open the socket and start the lookup → hole-punch handshake. */
15540
15688
  async connect() {
15541
15689
  if (this.connecting || this.connected)
@@ -15559,6 +15707,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15559
15707
  socket.setBroadcast(true);
15560
15708
  } catch {
15561
15709
  }
15710
+ this.requestReceiveBuffer(socket);
15562
15711
  resolve();
15563
15712
  });
15564
15713
  });
@@ -15575,7 +15724,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15575
15724
  this.trace({
15576
15725
  phase: "lookup-channels",
15577
15726
  local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15578
- cloud: this.cloudLookup !== void 0
15727
+ cloud: this.cloudLookup !== void 0,
15728
+ ...this.cloudLookup ? {} : { cloudMissing: dskKey ? "cloud-addresses" : "dsk-key" }
15579
15729
  });
15580
15730
  this.sendLookups();
15581
15731
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
@@ -15677,10 +15827,18 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15677
15827
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
15678
15828
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
15679
15829
  * as a cause.
15830
+ *
15831
+ * CAM_ADDR is recognised and dropped: it names the address the device is already answering from, and the
15832
+ * CAM_ID that follows it is what completes the connect.
15680
15833
  */
15681
15834
  onMessage(msg, rinfo, socket = this.socket) {
15682
15835
  if (!socket)
15683
15836
  return;
15837
+ const fromConnectedPeer = this.connected && this.connectAddress?.host === rinfo.address && this.connectAddress.port === rinfo.port;
15838
+ if (fromConnectedPeer && (hasHeader(msg, ResponseMessageType.PONG) || hasHeader(msg, ResponseMessageType.ACK))) {
15839
+ this.lastPeerAt = Date.now();
15840
+ this.pathStaleTraced = false;
15841
+ }
15684
15842
  if (!hasHeader(msg, ResponseMessageType.DATA)) {
15685
15843
  this.logger.debug(`[p2p] ${this.cfg.stationSn} <<< ${rinfo.address}:${rinfo.port} header=${msg.subarray(0, 2).toString("hex")} len=${msg.length}`);
15686
15844
  }
@@ -15698,8 +15856,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15698
15856
  this.onConnected({ host: rinfo.address, port: rinfo.port }, socket);
15699
15857
  } else if (hasHeader(msg, ResponseMessageType.PONG)) {
15700
15858
  this.lastPongData = msg.length > 4 ? msg.subarray(4) : void 0;
15701
- this.lastPongAt = Date.now();
15702
- this.pathStaleTraced = false;
15703
15859
  } else if (hasHeader(msg, ResponseMessageType.PING)) {
15704
15860
  this.send({ host: rinfo.address, port: rinfo.port }, RequestMessageType.PONG, void 0, socket);
15705
15861
  } else if (hasHeader(msg, ResponseMessageType.ACK)) {
@@ -15707,7 +15863,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15707
15863
  } else if (hasHeader(msg, ResponseMessageType.DATA)) {
15708
15864
  if (this.connected)
15709
15865
  this.onData(msg, { host: rinfo.address, port: rinfo.port });
15710
- } else {
15866
+ } else if (!hasHeader(msg, ResponseMessageType.CAM_ADDR)) {
15711
15867
  this.logger.debug(`[p2p] ${this.cfg.stationSn} UNHANDLED payload hex: ${msg.toString("hex")}`);
15712
15868
  }
15713
15869
  }
@@ -15998,6 +16154,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15998
16154
  this.seqNumber = this.seqNumber + 1 & 65535;
15999
16155
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16000
16156
  }
16157
+ /** Send a level-1 {@link buildStringPairCommandPayload} command on `channel`. */
16158
+ sendStringPairCommand(commandType, strValue, strValueSub, channel) {
16159
+ if (!this.connectAddress)
16160
+ throw new Error(`P2P session ${this.cfg.stationSn} is not connected`);
16161
+ const data = Buffer.concat([
16162
+ buildCommandHeader(this.seqNumber, commandType),
16163
+ buildStringPairCommandPayload(strValue, strValueSub, channel, this.level1Key, 1)
16164
+ ]);
16165
+ this.seqNumber = this.seqNumber + 1 & 65535;
16166
+ this.send(this.connectAddress, RequestMessageType.DATA, data);
16167
+ }
16001
16168
  /**
16002
16169
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
16003
16170
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -16275,11 +16442,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16275
16442
  this.seqNumber = this.seqNumber + 1 & 65535;
16276
16443
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16277
16444
  }
16278
- /**
16279
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
16280
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
16281
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
16282
- */
16283
16445
  /**
16284
16446
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
16285
16447
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -16346,6 +16508,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16346
16508
  }
16347
16509
  return this.rsaModulusHex;
16348
16510
  }
16511
+ /**
16512
+ * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ seq(4) ‖ ciphertext`,
16513
+ * AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of `decryptLevel2`.
16514
+ * The 4-byte sub-header is cleartext (skipped on decrypt); `seq` counts up from {@link LEVEL2_SEQ_BASE}.
16515
+ */
16349
16516
  encryptLevel2(plaintext) {
16350
16517
  if (!this.level2Key)
16351
16518
  return void 0;
@@ -16353,8 +16520,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16353
16520
  const c = createCipheriv3("aes-256-gcm", this.level2Key, nonce);
16354
16521
  c.setAAD(GCM_AAD);
16355
16522
  const ct = Buffer.concat([c.update(plaintext), c.final()]);
16356
- const sub2 = Buffer.from([this.level2Seq & 255, 3, 2, 1]);
16357
- this.level2Seq = this.level2Seq + 1 & 255;
16523
+ const sub2 = Buffer.alloc(4);
16524
+ sub2.writeUInt32LE(LEVEL2_SEQ_BASE + this.level2Seq++ >>> 0);
16358
16525
  return Buffer.concat([c.getAuthTag(), nonce, sub2, ct]);
16359
16526
  }
16360
16527
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
@@ -16400,8 +16567,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16400
16567
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
16401
16568
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
16402
16569
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
16570
+ *
16571
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
16572
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
16573
+ * one's buffer and answered as its rows.
16403
16574
  */
16404
16575
  queryDatabase(table, opts = {}) {
16576
+ if (this.dbReadInFlight)
16577
+ throw new Error(`queryDatabase: ${this.cfg.stationSn} is already reading a table`);
16405
16578
  if (!this.connectAddress)
16406
16579
  throw new Error("not connected");
16407
16580
  const channel = opts.channel ?? STATION_CHANNEL3;
@@ -16464,6 +16637,49 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16464
16637
  query: this.fullTableQuery()
16465
16638
  });
16466
16639
  }
16640
+ /**
16641
+ * Query one on-station table and answer its rows, once the reply is whole.
16642
+ *
16643
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
16644
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
16645
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16646
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16647
+ * complete reply.
16648
+ *
16649
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16650
+ * session throws, so a second reply cannot land in this buffer.
16651
+ */
16652
+ async readDatabase(table, opts = {}) {
16653
+ if (opts.signal?.aborted)
16654
+ throw new Error("readDatabase: aborted");
16655
+ this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16656
+ this.dbReadInFlight = true;
16657
+ try {
16658
+ return await new Promise((resolve, reject) => {
16659
+ let text2 = "";
16660
+ const onChunk = (chunk) => {
16661
+ text2 += chunk.text;
16662
+ const rows = firstTableRows(text2);
16663
+ if (rows)
16664
+ settle(() => resolve(rows));
16665
+ };
16666
+ const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16667
+ const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
16668
+ const settle = (finish) => {
16669
+ clearTimeout(timer);
16670
+ this.off("dbChunk", onChunk);
16671
+ opts.signal?.removeEventListener("abort", onAbort);
16672
+ finish();
16673
+ };
16674
+ this.on("dbChunk", onChunk);
16675
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
16676
+ });
16677
+ } finally {
16678
+ this.dbReadInFlight = false;
16679
+ }
16680
+ }
16681
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
16682
+ dbReadInFlight = false;
16467
16683
  /**
16468
16684
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
16469
16685
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -16531,7 +16747,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16531
16747
  * if the missing one arrives. A logical frame's payload spans datagrams that carry no header of their
16532
16748
  * own, so reassembling around a hole is impossible; waiting for it is what keeps the frame whole.
16533
16749
  *
16534
- * Only once {@link REORDER_WAIT_MS} passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
16750
+ * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
16535
16751
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
16536
16752
  */
16537
16753
  onData(msg, addr) {
@@ -16586,7 +16802,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16586
16802
  const timer = setTimeout(() => {
16587
16803
  reorder.timer = void 0;
16588
16804
  this.abandonHole(dataType);
16589
- }, REORDER_WAIT_MS);
16805
+ }, dataType === P2PDataType.VIDEO ? VIDEO_REORDER_WAIT_MS : REORDER_WAIT_MS);
16590
16806
  timer.unref?.();
16591
16807
  reorder.timer = timer;
16592
16808
  }
@@ -18176,11 +18392,195 @@ function annexbToJpeg(annexb, spawn2) {
18176
18392
  });
18177
18393
  }
18178
18394
 
18395
+ // dist/transport/p2p/recording-download.js
18396
+ import { createDecipheriv as createDecipheriv7 } from "node:crypto";
18397
+ var RECORDING_DATA_TYPE = 3;
18398
+ var VIDEO_HEADER_LEN2 = 22;
18399
+ var AUDIO_HEADER_LEN2 = 16;
18400
+ var AUDIO_NONCE_START = 32;
18401
+ var AUDIO_BODY_START = 44;
18402
+ var AUDIO_CODEC_AAC_LC = 0;
18403
+ var FINISH_CHANNEL = 0;
18404
+ var FIRST_FRAME_WAIT_MS = 2e4;
18405
+ var IDLE_END_MS = 15e3;
18406
+ var DEFAULT_TRANSFER_MS = 12e4;
18407
+ var MAX_TRANSFER_BYTES = 32 * 1024 * 1024;
18408
+ function homeBase2RecordingPath(channel, recording) {
18409
+ if (!/^\d{14}$/.test(recording) || !Number.isInteger(channel) || channel < 0 || channel > 99)
18410
+ return void 0;
18411
+ return `/media/mmcblk0p1/Camera${String(channel).padStart(2, "0")}/${recording}.dat`;
18412
+ }
18413
+ function receiveRecording(session, request) {
18414
+ let resolveFrames;
18415
+ let rejectFrames;
18416
+ const frames = new Promise((resolve, reject) => {
18417
+ resolveFrames = resolve;
18418
+ rejectFrames = reject;
18419
+ });
18420
+ let resolveDrained;
18421
+ const drained = new Promise((resolve) => resolveDrained = resolve);
18422
+ const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
18423
+ let collected = [];
18424
+ const started = Date.now();
18425
+ let lastFrameAt = started;
18426
+ let received = false;
18427
+ let bytes2 = 0;
18428
+ let draining;
18429
+ let delivered = false;
18430
+ let listening = true;
18431
+ const deliver = (error) => {
18432
+ if (delivered)
18433
+ return;
18434
+ delivered = true;
18435
+ request.signal?.removeEventListener("abort", onAbort);
18436
+ if (error)
18437
+ rejectFrames(error);
18438
+ else
18439
+ resolveFrames(collected);
18440
+ };
18441
+ const stop = () => {
18442
+ if (!listening)
18443
+ return;
18444
+ listening = false;
18445
+ clearInterval(tick);
18446
+ session.off("data", onData);
18447
+ resolveDrained();
18448
+ };
18449
+ const incomplete = (why) => deliver(new RecordingDownloadError("incomplete", `the recording transfer ${why} before its finish frame`));
18450
+ const drain = (why) => {
18451
+ draining ??= why;
18452
+ collected = [];
18453
+ };
18454
+ const onData = (frame) => {
18455
+ if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
18456
+ if (frame.channel !== request.channel && frame.channel !== FINISH_CHANNEL)
18457
+ return;
18458
+ if (draining)
18459
+ incomplete(draining);
18460
+ else
18461
+ deliver();
18462
+ return stop();
18463
+ }
18464
+ if (frame.channel !== request.channel)
18465
+ return;
18466
+ if (frame.commandId !== CommandType.CMD_VIDEO_FRAME && frame.commandId !== CommandType.CMD_AUDIO_FRAME)
18467
+ return;
18468
+ if (frame.dataType !== RECORDING_DATA_TYPE)
18469
+ return;
18470
+ received = true;
18471
+ lastFrameAt = Date.now();
18472
+ if (draining)
18473
+ return;
18474
+ collected.push({ commandId: frame.commandId, signCode: frame.signCode, raw: Buffer.from(frame.raw) });
18475
+ bytes2 += frame.raw.length;
18476
+ if (bytes2 >= MAX_TRANSFER_BYTES)
18477
+ drain(`reached ${MAX_TRANSFER_BYTES} bytes`);
18478
+ };
18479
+ const onAbort = () => {
18480
+ deliver(request.signal?.reason instanceof Error ? request.signal.reason : new Error("aborted"));
18481
+ drain("was aborted");
18482
+ };
18483
+ const tick = setInterval(() => {
18484
+ const now2 = Date.now();
18485
+ if (!received && now2 - started > FIRST_FRAME_WAIT_MS) {
18486
+ deliver(new RecordingDownloadError("no-data", `the station sent nothing within ${FIRST_FRAME_WAIT_MS} ms`));
18487
+ stop();
18488
+ } else if (received && now2 - lastFrameAt > IDLE_END_MS) {
18489
+ incomplete(draining ?? `went quiet for ${IDLE_END_MS} ms`);
18490
+ stop();
18491
+ } else if (now2 - started > 2 * limitMs && draining) {
18492
+ incomplete(draining);
18493
+ stop();
18494
+ } else if (now2 - started > limitMs) {
18495
+ drain(`ran past ${limitMs} ms`);
18496
+ }
18497
+ }, 250);
18498
+ if (request.signal?.aborted) {
18499
+ onAbort();
18500
+ stop();
18501
+ return { frames, drained };
18502
+ }
18503
+ request.signal?.addEventListener("abort", onAbort, { once: true });
18504
+ session.on("data", onData);
18505
+ try {
18506
+ session.sendStringPairCommand(CommandType.CMD_DOWNLOAD_VIDEO, request.path, request.accountId, request.channel);
18507
+ } catch (error) {
18508
+ deliver(error instanceof Error ? error : new Error(String(error)));
18509
+ stop();
18510
+ }
18511
+ return { frames, drained };
18512
+ }
18513
+ function openAudio(key, nonce, tag2, body) {
18514
+ try {
18515
+ const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
18516
+ decipher.setAAD(VIDEO_GCM_AAD);
18517
+ decipher.setAuthTag(tag2);
18518
+ return Buffer.concat([decipher.update(body), decipher.final()]);
18519
+ } catch {
18520
+ return void 0;
18521
+ }
18522
+ }
18523
+ function decodeRecording(frames, eccPrivateKeyHex) {
18524
+ const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
18525
+ if (eccPrivateKey.length !== 32)
18526
+ throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
18527
+ const decoder = new VideoFrameDecoder(eccPrivateKey);
18528
+ const video = [];
18529
+ const audio = [];
18530
+ const numbers = /* @__PURE__ */ new Set();
18531
+ let firstStamp;
18532
+ let lastStamp;
18533
+ for (const { commandId, raw } of frames) {
18534
+ if (commandId === CommandType.CMD_VIDEO_FRAME) {
18535
+ const header = parseVideoFrameHeader(raw);
18536
+ if (!header)
18537
+ continue;
18538
+ const h264 = header.keyframe ? decoder.decodeFrame(raw)?.h264 : raw.length >= VIDEO_HEADER_LEN2 + header.payloadLength ? raw.subarray(VIDEO_HEADER_LEN2, VIDEO_HEADER_LEN2 + header.payloadLength) : void 0;
18539
+ if (!h264?.length)
18540
+ continue;
18541
+ video.push(h264);
18542
+ numbers.add(header.sequence);
18543
+ const stamp = raw.readUIntLE(14, 6);
18544
+ firstStamp ??= stamp;
18545
+ lastStamp = stamp;
18546
+ } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
18547
+ const mediaKey = decoder.currentMediaKey;
18548
+ if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
18549
+ continue;
18550
+ const len = raw.readUInt32LE(0);
18551
+ if (raw.length < AUDIO_BODY_START + len)
18552
+ continue;
18553
+ const aac = openAudio(mediaKey, raw.subarray(AUDIO_NONCE_START, AUDIO_BODY_START), raw.subarray(AUDIO_HEADER_LEN2, AUDIO_NONCE_START), raw.subarray(AUDIO_BODY_START, AUDIO_BODY_START + len));
18554
+ if (aac?.length)
18555
+ audio.push(aac);
18556
+ }
18557
+ }
18558
+ if (!video.length)
18559
+ throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
18560
+ const ordered = [...numbers];
18561
+ let missingFrames = 0;
18562
+ for (let i = 1; i < ordered.length; i++) {
18563
+ const step = ordered[i] - ordered[i - 1] & 65535;
18564
+ if (step > 1 && step < 32768)
18565
+ missingFrames += step - 1;
18566
+ }
18567
+ const span = ordered.length > 1 ? ordered[ordered.length - 1] - ordered[0] & 65535 : 0;
18568
+ const durationMs = (lastStamp ?? 0) - (firstStamp ?? 0);
18569
+ return {
18570
+ video: Buffer.concat(video),
18571
+ ...audio.length ? { audio: Buffer.concat(audio) } : {},
18572
+ frames: ordered.length,
18573
+ missingFrames,
18574
+ durationMs,
18575
+ fps: durationMs > 0 && span > 0 ? Math.round(span * 1e5 / durationMs) / 100 : 0
18576
+ };
18577
+ }
18578
+
18179
18579
  // dist/transport/p2p/shared-live-source.js
18180
18580
  import { EventEmitter as EventEmitter4 } from "node:events";
18181
- function durationMs(seconds) {
18182
- const milliseconds = (seconds ?? 0) * 1e3;
18183
- return Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : 0;
18581
+ var MAX_TIMER_DELAY_MS = 2147483647;
18582
+ function timerMs(value, fallback) {
18583
+ return value !== void 0 && Number.isFinite(value) && value >= 1 && value <= MAX_TIMER_DELAY_MS ? value : fallback;
18184
18584
  }
18185
18585
  function sameConfig(a, b) {
18186
18586
  return b !== void 0 && a.codec === b.codec && a.width === b.width && a.height === b.height;
@@ -18433,14 +18833,14 @@ var SharedLiveSource = class {
18433
18833
  tag;
18434
18834
  constructor(opts) {
18435
18835
  this.opts = opts;
18436
- this.lingerMs = opts.lingerMs ?? 8e3;
18836
+ this.lingerMs = opts.lingerMs === 0 ? 1 : timerMs(opts.lingerMs, 8e3);
18437
18837
  this.maxQueue = opts.maxQueue ?? 900;
18438
- this.preBufferMs = durationMs(opts.preBufferSeconds);
18439
- this.warmRetryMs = opts.warmRetryMs ?? 2e3;
18440
- this.warmTimeoutMs = opts.warmTimeoutMs ?? 2e4;
18838
+ this.preBufferMs = timerMs((opts.preBufferSeconds ?? 0) * 1e3, 0);
18839
+ this.warmRetryMs = timerMs(opts.warmRetryMs, 2e3);
18840
+ this.warmTimeoutMs = timerMs(opts.warmTimeoutMs, 2e4);
18441
18841
  this.powered = opts.powered ?? "wired";
18442
- this.batteryBudgetMs = opts.batteryBudgetMs ?? 45e3;
18443
- this.budgetGraceMs = opts.budgetGraceMs ?? 1e4;
18842
+ this.batteryBudgetMs = timerMs(opts.batteryBudgetMs, 45e3);
18843
+ this.budgetGraceMs = timerMs(opts.budgetGraceMs, 1e4);
18444
18844
  this.logger = opts.logger ?? noopLogger;
18445
18845
  this.tag = opts.label ? `[live ${opts.label}]` : "[live]";
18446
18846
  this.traceId = `pull-${++pullSequence}`;
@@ -18688,7 +19088,7 @@ var SharedLiveSource = class {
18688
19088
  if (this.disposed || !this.stream)
18689
19089
  return;
18690
19090
  this.clearBudget();
18691
- this.budgetTimer.arm(ms ?? this.batteryBudgetMs, () => this.onBudgetExpire());
19091
+ this.budgetTimer.arm(timerMs(ms, this.batteryBudgetMs), () => this.onBudgetExpire());
18692
19092
  }
18693
19093
  /** Settle a reuse watch on any frame — the join already holds a decodable picture. */
18694
19094
  settleReuseWatch() {
@@ -18829,7 +19229,7 @@ var SharedLiveSource = class {
18829
19229
  return this.bufferedMedia(seconds).filter((item) => item.kind === "video").map((item) => item.frame);
18830
19230
  }
18831
19231
  bufferedMedia(seconds) {
18832
- const requested = Math.min(durationMs(seconds), this.preBufferMs);
19232
+ const requested = Number.isFinite(seconds) ? Math.min(seconds * 1e3, this.preBufferMs) : 0;
18833
19233
  if (requested <= 0 || !this.ring.length)
18834
19234
  return [];
18835
19235
  const start = this.windowStart(Date.now() - requested);
@@ -20356,6 +20756,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
20356
20756
  talkbacks = /* @__PURE__ */ new Map();
20357
20757
  /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
20358
20758
  cipherKeyCache = /* @__PURE__ */ new Map();
20759
+ /** The recording download in flight per station; a station serves one at a time. */
20760
+ recordingDownloads = /* @__PURE__ */ new Map();
20359
20761
  constructor(deps) {
20360
20762
  this.deps = deps;
20361
20763
  this.manager = new SessionManager({
@@ -20568,28 +20970,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
20568
20970
  localAddress,
20569
20971
  dskKey,
20570
20972
  noBroadcast: this.deps.noBroadcast,
20571
- resolveCipherKey: async (cipherId) => {
20572
- if (this.cipherKeyCache.has(cipherId))
20573
- return this.cipherKeyCache.get(cipherId);
20574
- let ecc;
20575
- try {
20576
- const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
20577
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
20578
- if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
20579
- ecc = ciphers[0].ecc_private_key;
20580
- this.traceOnStation(session, {
20581
- phase: "cipher-fallback",
20582
- cipherId,
20583
- answeredCipherId: Number(ciphers[0].cipher_id)
20584
- });
20585
- }
20586
- } catch (e) {
20587
- this.deps.onError(e instanceof Error ? e : new Error(String(e)));
20588
- }
20589
- if (ecc !== void 0)
20590
- this.cipherKeyCache.set(cipherId, ecc);
20591
- return ecc;
20592
- },
20973
+ resolveCipherKey: (cipherId) => this.cipherKeyFor(session, cipherId, adminUserId, stationSn),
20593
20974
  logger: this.deps.logger ?? noopLogger
20594
20975
  });
20595
20976
  session.on("error", (e) => this.reportError(e));
@@ -20613,6 +20994,33 @@ var P2PCommandRouter = class _P2PCommandRouter {
20613
20994
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
20614
20995
  return session;
20615
20996
  }
20997
+ /**
20998
+ * The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
20999
+ * answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
21000
+ * cached.
21001
+ */
21002
+ async cipherKeyFor(session, cipherId, userId, stationSn) {
21003
+ if (this.cipherKeyCache.has(cipherId))
21004
+ return this.cipherKeyCache.get(cipherId);
21005
+ let ecc;
21006
+ try {
21007
+ const ciphers = await this.deps.mega.getCiphers([cipherId], userId, stationSn);
21008
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
21009
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
21010
+ ecc = ciphers[0].ecc_private_key;
21011
+ this.traceOnStation(session, {
21012
+ phase: "cipher-fallback",
21013
+ cipherId,
21014
+ answeredCipherId: Number(ciphers[0].cipher_id)
21015
+ });
21016
+ }
21017
+ } catch (e) {
21018
+ this.deps.onError(e instanceof Error ? e : new Error(String(e)));
21019
+ }
21020
+ if (ecc !== void 0)
21021
+ this.cipherKeyCache.set(cipherId, ecc);
21022
+ return ecc;
21023
+ }
20616
21024
  /**
20617
21025
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
20618
21026
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -20740,7 +21148,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
20740
21148
  * start has no level-1 form for.
20741
21149
  */
20742
21150
  mediaProviderFor(sn) {
21151
+ const recordings = this.downloadsRecordings(sn) ? { downloadRecording: (opts) => this.downloadRecording(sn, opts) } : {};
20743
21152
  return {
21153
+ ...recordings,
20744
21154
  snapshotLive: async (opts) => {
20745
21155
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20746
21156
  return captureSnapshotFromShared(source, {
@@ -20779,6 +21189,63 @@ var P2PCommandRouter = class _P2PCommandRouter {
20779
21189
  }
20780
21190
  };
20781
21191
  }
21192
+ /**
21193
+ * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
21194
+ * and frame formats are confirmed.
21195
+ */
21196
+ downloadsRecordings(sn) {
21197
+ const dev = this.recordFor(sn);
21198
+ if (!dev)
21199
+ return false;
21200
+ const parentSn = stationOf(dev);
21201
+ return parentSn !== sn && this.recordFor(parentSn)?.model === "T8010";
21202
+ }
21203
+ /**
21204
+ * Download one recording a HomeBase 2 holds for an attached camera, then decode it
21205
+ * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
21206
+ * on the camera's channel, so a download starts only once the previous one has drained
21207
+ * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
21208
+ * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
21209
+ * turn is held until the transfer drains.
21210
+ */
21211
+ async downloadRecording(sn, opts) {
21212
+ opts.signal?.throwIfAborted();
21213
+ const parentSn = this.stationKeyOf(sn);
21214
+ const previous = this.recordingDownloads.get(parentSn) ?? Promise.resolve();
21215
+ const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, opts));
21216
+ const turn = Promise.allSettled([previous, started.then((t) => t.drained)]).then(() => {
21217
+ if (this.recordingDownloads.get(parentSn) === turn)
21218
+ this.recordingDownloads.delete(parentSn);
21219
+ });
21220
+ this.recordingDownloads.set(parentSn, turn);
21221
+ return started.then((t) => t.recording);
21222
+ }
21223
+ /** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
21224
+ waitTurn(previous, signal) {
21225
+ return abortable(previous.then(() => void 0, () => void 0), signal);
21226
+ }
21227
+ /**
21228
+ * Start one recording download, once the station's previous download has drained: the decoded recording,
21229
+ * and when the station is done sending it.
21230
+ */
21231
+ async startRecordingDownload(sn, opts) {
21232
+ const { session, parentSn, channel, accountId } = await this.resolveSession(sn, { signal: opts.signal });
21233
+ const path = homeBase2RecordingPath(channel, opts.recording);
21234
+ if (!path)
21235
+ throw new RecordingDownloadError("invalid-recording", `no recording named ${opts.recording}`);
21236
+ const eccKey = await this.cipherKeyFor(session, opts.cipherId, accountId, parentSn);
21237
+ if (!eccKey) {
21238
+ throw new RecordingDownloadError("key-unavailable", `cipher ${opts.cipherId} could not be obtained`);
21239
+ }
21240
+ const transfer = receiveRecording(session, {
21241
+ path,
21242
+ accountId,
21243
+ channel,
21244
+ timeoutMs: opts.timeoutMs,
21245
+ signal: opts.signal
21246
+ });
21247
+ return { recording: transfer.frames.then((frames) => decodeRecording(frames, eccKey)), drained: transfer.drained };
21248
+ }
20782
21249
  /**
20783
21250
  * Open a {@link Talkback} on a device's camera channel.
20784
21251
  *
@@ -21127,9 +21594,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
21127
21594
  *
21128
21595
  * Reached two ways, both idempotent: the session's own `close` event, when it died while still the
21129
21596
  * station's registered session, and {@link SessionManagerOpts.onAutoClose}, when the manager closed it
21130
- * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} disposes its own
21131
- * sources first, and {@link replaceUnreachableSession} keeps its source alive on purpose to rewarm it
21132
- * on the replacement session.
21597
+ * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} and
21598
+ * {@link resolveSession} dispose their own sources first, while {@link replaceUnreachableSession} keeps
21599
+ * its source alive on purpose to rewarm it on the replacement session.
21133
21600
  */
21134
21601
  tearDownStation(stationSn) {
21135
21602
  this.manager.remove(stationSn);
@@ -21448,9 +21915,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
21448
21915
  *
21449
21916
  * A session whose {@link P2PSession.pathAnswering} is false is closed and re-resolved before it is handed
21450
21917
  * over: the station answers every heartbeat, so a path silent past several of them is gone. A session
21451
- * reporting nothing about its path is not reporting that evidence and is handed over as it is. Replaced at
21452
- * most once per resolution, so a station whose replacement is silent too is returned rather than closed
21453
- * again.
21918
+ * reporting nothing about its path is not reporting that evidence and is handed over as it is. Cached live
21919
+ * sources on the replaced session are discarded before closing it (see {@link tearDownStation}).
21920
+ * Replaced at most once per resolution, so a station whose replacement is silent too is returned rather
21921
+ * than closed again.
21454
21922
  */
21455
21923
  async resolveSession(sn, opts = {}, rebuilt = false) {
21456
21924
  const dev = await this.deviceFor(sn);
@@ -21464,6 +21932,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
21464
21932
  }
21465
21933
  if (session.pathAnswering === false && !rebuilt) {
21466
21934
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
21935
+ for (const [key, sessionKey] of [...this.liveSessionKeys]) {
21936
+ if (sessionKey === parentSn)
21937
+ this.dropLiveSource(key);
21938
+ }
21467
21939
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
21468
21940
  return await this.resolveSession(sn, opts, true);
21469
21941
  }
@@ -24304,6 +24776,7 @@ var MEDIA_FAILURE_REASONS = [
24304
24776
  // dist/transport/stored-image-cache.js
24305
24777
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
24306
24778
  var MAX_REMEMBERED_URLS = 64;
24779
+ var NOT_YET_PUBLISHED_RETRY_MS = [2e3, 4e3];
24307
24780
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
24308
24781
  var StoredImageCache = class {
24309
24782
  downloader;
@@ -24341,6 +24814,8 @@ var StoredImageCache = class {
24341
24814
  if (state.seenUrls.has(url))
24342
24815
  return;
24343
24816
  state.seenUrls.add(url);
24817
+ clearTimeout(state.retrying);
24818
+ state.retrying = void 0;
24344
24819
  while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
24345
24820
  const oldest = state.seenUrls.values().next();
24346
24821
  if (oldest.done)
@@ -24352,14 +24827,15 @@ var StoredImageCache = class {
24352
24827
  url,
24353
24828
  sequence: ++state.nextSequence,
24354
24829
  observedAt: this.clock(),
24355
- generation: this.generation
24830
+ generation: this.generation,
24831
+ retries: 0
24356
24832
  };
24357
24833
  this.pump();
24358
24834
  }
24359
24835
  /** Return retained bytes without starting or awaiting network work. */
24360
24836
  snapshotStored(deviceKey) {
24361
24837
  const state = this.devices.get(deviceKey);
24362
- const reason = state?.queued || state?.inFlight ? "pending" : state?.reason ?? "not-observed";
24838
+ const reason = state?.queued || state?.inFlight || state?.retrying ? "pending" : state?.reason ?? "not-observed";
24363
24839
  if (reason !== "pending" && state?.lifecycleError)
24364
24840
  return Promise.reject(state.lifecycleError);
24365
24841
  if (state?.retained)
@@ -24393,6 +24869,8 @@ var StoredImageCache = class {
24393
24869
  if (error !== void 0 && this.isLifecycleError(error)) {
24394
24870
  state.lifecycleError = error;
24395
24871
  state.reason = void 0;
24872
+ } else if (image === void 0 && this.retryLater(state, candidate, error)) {
24873
+ state.lifecycleError = void 0;
24396
24874
  } else if (image === void 0) {
24397
24875
  state.lifecycleError = void 0;
24398
24876
  state.reason = "download-failed";
@@ -24409,6 +24887,21 @@ var StoredImageCache = class {
24409
24887
  }
24410
24888
  this.pump();
24411
24889
  }
24890
+ /** Schedule the next attempt after a 404; answers whether one was scheduled. */
24891
+ retryLater(state, candidate, error) {
24892
+ const delay = NOT_YET_PUBLISHED_RETRY_MS[candidate.retries];
24893
+ const tag2 = mediaFailureTag(error);
24894
+ if (delay === void 0 || tag2.cause !== "http-status" || tag2.status !== 404 || state.queued)
24895
+ return false;
24896
+ const timer = setTimeout(() => {
24897
+ state.retrying = void 0;
24898
+ state.queued = { ...candidate, retries: candidate.retries + 1 };
24899
+ this.pump();
24900
+ }, delay);
24901
+ timer.unref?.();
24902
+ state.retrying = timer;
24903
+ return true;
24904
+ }
24412
24905
  isValidJpeg(image) {
24413
24906
  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;
24414
24907
  }
@@ -25004,6 +25497,11 @@ function recordString(raw, key) {
25004
25497
  const v = typeof nested === "string" && nested ? nested : raw[key];
25005
25498
  return typeof v === "string" && v ? v : void 0;
25006
25499
  }
25500
+ function adminUserIdFrom(raw) {
25501
+ const member = (raw ?? {}).member;
25502
+ const id = member?.admin_user_id;
25503
+ return typeof id === "string" && id ? id : void 0;
25504
+ }
25007
25505
  function tuyaDevIdFrom(raw) {
25008
25506
  for (const field of ["tuya_uuid", "tuya_virtual_id", "tuya_device_id", "virtualId"]) {
25009
25507
  const v = recordString(raw, field);
@@ -25161,13 +25659,7 @@ var EufyMega = class extends EventEmitter9 {
25161
25659
  onError: (e) => this.reportError(e),
25162
25660
  // The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id`,
25163
25661
  // falling back to the logged-in account's user id (the confirmed script path does the same).
25164
- resolveAccountId: (dev) => {
25165
- const member = (dev.raw ?? {}).member;
25166
- const adminId = member?.admin_user_id;
25167
- if (typeof adminId === "string" && adminId)
25168
- return adminId;
25169
- return this.mega.auth?.userId ?? "";
25170
- },
25662
+ resolveAccountId: (dev) => adminUserIdFrom(dev.raw) ?? this.mega.auth?.userId ?? "",
25171
25663
  // Fetch + parse a gallery effect from the HTTP catalogue into the serializable spec. The catalogue
25172
25664
  // lives in the http layer (transport/http/light-catalog); the mqtt router owns the frame bytes.
25173
25665
  resolvePreset: (presetId) => resolveLightEffect(this.mega, presetId),
@@ -26614,6 +27106,37 @@ var EufyMega = class extends EventEmitter9 {
26614
27106
  getP2pSessions() {
26615
27107
  return this.p2p.getSessions();
26616
27108
  }
27109
+ /**
27110
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
27111
+ *
27112
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
27113
+ * station, because they were never uploaded — the station holds them.
27114
+ *
27115
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
27116
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
27117
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
27118
+ *
27119
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
27120
+ * returned as they arrive; a caller that wants only named people filters them.
27121
+ */
27122
+ async getStationFaces(stationSn, opts = {}) {
27123
+ if (!this.registry.list().length)
27124
+ await this.getDevices();
27125
+ const station = this.p2p.stationKeyOf(stationSn);
27126
+ const accountId = this.adminUserIdOf(station);
27127
+ if (!accountId)
27128
+ throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
27129
+ await this.p2p.ensureStation(station, opts.signal);
27130
+ const session = this.p2p.getSessions().get(station);
27131
+ if (!session)
27132
+ throw new Error(`getStationFaces: no P2P session for ${station}`);
27133
+ const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
27134
+ return rows.filter((row) => typeof row === "object" && row !== null);
27135
+ }
27136
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
27137
+ adminUserIdOf(stationSn) {
27138
+ return adminUserIdFrom(this.registry.list().find((d) => d.sn === stationSn)?.raw);
27139
+ }
26617
27140
  /**
26618
27141
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
26619
27142
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.
@@ -26851,7 +27374,7 @@ var EufyMega = class extends EventEmitter9 {
26851
27374
  if (ev.eventName === void 0 && ev.eventType != null)
26852
27375
  ev.eventName = detectionName(ev.eventType);
26853
27376
  if (ev.thumbnailCandidate)
26854
- void this.observeStoredImage(ev.thumbnailCandidate);
27377
+ void this.observeStoredImage(ev.thumbnailCandidate, ev.deviceSn);
26855
27378
  this.emit("push", ev);
26856
27379
  const signal2 = {
26857
27380
  source: "push",
@@ -26862,7 +27385,10 @@ var EufyMega = class extends EventEmitter9 {
26862
27385
  thumbnailUrl: ev.thumbnailUrl,
26863
27386
  payload: ev.payload
26864
27387
  };
26865
- for (const out of decodeEvent(signal2, this.capsForEvent(ev.deviceSn))) {
27388
+ const caps = this.capsForEvent(ev.deviceSn);
27389
+ const decoded = decodeEvent(signal2, caps);
27390
+ this.opts.logger?.debug(`[push] in: eventType=${ev.eventType} device=${caps ? "known" : ev.deviceSn ? "unknown" : "none"} events=${decoded.length}`);
27391
+ for (const out of decoded) {
26866
27392
  this.emitSemantic(out.event, out.payload, { edge: true, refresh: out.refresh });
26867
27393
  const dsn = out.payload.deviceSn ?? ev.deviceSn;
26868
27394
  if (dsn)
@@ -26900,9 +27426,16 @@ var EufyMega = class extends EventEmitter9 {
26900
27426
  client.on("error", (e) => this.reportError(e));
26901
27427
  return client;
26902
27428
  }
26903
- /** Admit only exact, account-known devices with resolved snapshot evidence into the passive store. */
26904
- async observeStoredImage(candidate) {
26905
- if (!this.storedImages || candidate.attribution.kind !== "device")
27429
+ /**
27430
+ * Admit only exact, account-known devices with resolved snapshot evidence into the passive store; a
27431
+ * station-only candidate is admitted under the event's device serial.
27432
+ */
27433
+ async observeStoredImage(candidate, eventDeviceSn) {
27434
+ if (!this.storedImages)
27435
+ return;
27436
+ const { attribution } = candidate;
27437
+ const deviceSn = attribution.kind === "device" ? attribution.deviceSn : attribution.kind === "station" ? eventDeviceSn : void 0;
27438
+ if (!deviceSn)
26906
27439
  return;
26907
27440
  const account = this.mega.auth?.userId;
26908
27441
  if (!account)
@@ -26912,9 +27445,9 @@ var EufyMega = class extends EventEmitter9 {
26912
27445
  await this.registry.getDevices();
26913
27446
  if (this.mega.auth?.userId !== account)
26914
27447
  return;
26915
- const caps = this.registry.capabilitiesForDevice(candidate.attribution.deviceSn);
27448
+ const caps = this.registry.capabilitiesForDevice(deviceSn);
26916
27449
  if (caps && hasProvidedAction(caps, "snapshotStored")) {
26917
- this.storedImages.observe(candidate.attribution.deviceSn, candidate.url);
27450
+ this.storedImages.observe(deviceSn, candidate.url);
26918
27451
  }
26919
27452
  } catch (e) {
26920
27453
  this.reportError(e);
@@ -27139,26 +27672,16 @@ var SolixClient = class _SolixClient {
27139
27672
  }
27140
27673
  /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
27141
27674
  async postLogin(kx, verifyCode, limitedToken) {
27142
- const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
27143
27675
  const bodyObj = {
27144
- email: this.email,
27145
- password: encryptedPassword,
27146
- ab: this.country,
27147
- client_secret_info: { public_key: clientPublicKeyHex },
27676
+ ...loginCredentials(this.email, this.password, this.country),
27148
27677
  answer: "",
27149
27678
  captcha_id: "",
27150
27679
  verify_code: verifyCode ?? "",
27151
27680
  login_id: ""
27152
27681
  };
27153
27682
  const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
27154
- const ts = nowSec();
27155
- const once = genId();
27156
27683
  return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
27157
- "x-encryption-info": "algo_ecdh",
27158
- "x-key-ident": kx.keyIdent,
27159
- "x-request-ts": ts,
27160
- "x-request-once": once,
27161
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27684
+ ...signedHeaders(kx, encBody),
27162
27685
  ...limitedToken ? { "x-auth-token": limitedToken } : {}
27163
27686
  }));
27164
27687
  }
@@ -27173,13 +27696,12 @@ var SolixClient = class _SolixClient {
27173
27696
  * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
27174
27697
  */
27175
27698
  classifyLogin(data, isVerify) {
27176
- const userId = data.ap_cloud_user_id ?? data.user_id;
27177
- const authToken = data.auth_token;
27178
- if (!userId || !authToken)
27699
+ const reply = readLoginReply(data);
27700
+ if (!reply)
27179
27701
  throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
27180
- const faInfo = data.fa_info ?? {};
27181
- if (!isVerify && faInfo.info) {
27182
- this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
27702
+ const { userId, authToken } = reply;
27703
+ if (!isVerify && reply.twoFactorPending) {
27704
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: reply.geoKey };
27183
27705
  return { status: "2fa", method: "code sent by the passport" };
27184
27706
  }
27185
27707
  this.pending2fa = void 0;
@@ -27188,7 +27710,7 @@ var SolixClient = class _SolixClient {
27188
27710
  userId,
27189
27711
  gtoken: gtoken(userId),
27190
27712
  apiHost: this.apiHost,
27191
- tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
27713
+ tokenExpiresAt: reply.tokenExpiresAt
27192
27714
  };
27193
27715
  this.persist();
27194
27716
  return { status: "ok", session: this.session_ };
@@ -27343,14 +27865,8 @@ var SolixClient = class _SolixClient {
27343
27865
  throw new Error("not authenticated \u2014 call login() first");
27344
27866
  const kx = await this.keyExchange();
27345
27867
  const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
27346
- const ts = nowSec();
27347
- const once = genId();
27348
27868
  const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
27349
- "x-encryption-info": "algo_ecdh",
27350
- "x-key-ident": kx.keyIdent,
27351
- "x-request-ts": ts,
27352
- "x-request-once": once,
27353
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27869
+ ...signedHeaders(kx, encBody),
27354
27870
  "x-auth-token": this.session_.authToken,
27355
27871
  gtoken: this.session_.gtoken
27356
27872
  }));
@@ -28118,6 +28634,7 @@ export {
28118
28634
  ROOM_SUCTIONS,
28119
28635
  RTSP_MEMBERS,
28120
28636
  RawDpWriter,
28637
+ RecordingDownloadError,
28121
28638
  RecordingQuality,
28122
28639
  RtspRecordingMode,
28123
28640
  SCENE_INVALID_REASONS,
@@ -28161,6 +28678,7 @@ export {
28161
28678
  VACUUM_DOCK_MEMBERS,
28162
28679
  VACUUM_SCHEDULE_ACTIONS,
28163
28680
  VACUUM_SCHEDULE_WEEKDAYS,
28681
+ VIDEO_GCM_AAD,
28164
28682
  VacuumMapStore,
28165
28683
  VideoFrameDecoder,
28166
28684
  WORKING_MODE_MAPS,
@@ -28180,6 +28698,7 @@ export {
28180
28698
  buildEventIndex,
28181
28699
  buildModelIndex,
28182
28700
  buildRealtimeInit,
28701
+ cameraPowerTier,
28183
28702
  captureSnapshotFromShared,
28184
28703
  cellAtPoint,
28185
28704
  claimedParams,