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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -1746,13 +2143,7 @@ ${this.auth_?.authToken ?? ""}`;
1746
2143
  }
1747
2144
  /** Build the /passport/login body (verify_code empty unless 2FA). */
1748
2145
  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
- };
2146
+ const base = loginCredentials(this.cfg.email, this.cfg.password, this.cfg.countryCode);
1756
2147
  if (opts.verifyCode)
1757
2148
  return { ...base, verify_code: opts.verifyCode, login_id: "" };
1758
2149
  if (opts.captchaId)
@@ -1990,15 +2381,12 @@ ${this.auth_?.authToken ?? ""}`;
1990
2381
  if (cap.length)
1991
2382
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1992
2383
  }
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)
2384
+ const reply = readLoginReply(res);
2385
+ if (!reply)
1997
2386
  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 };
2387
+ const { userId, accountUserId, authToken, geoKey } = reply;
2388
+ if (!isVerify && reply.twoFactorPending) {
2389
+ this.auth_ = { userId, accountUserId, authToken, geoKey };
2002
2390
  this.pendingCaptchaId = void 0;
2003
2391
  this.pending2fa = true;
2004
2392
  await this.sendVerifyCode(messageType);
@@ -2006,8 +2394,8 @@ ${this.auth_?.authToken ?? ""}`;
2006
2394
  }
2007
2395
  this.pendingCaptchaId = void 0;
2008
2396
  this.pending2fa = false;
2009
- this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
2010
- this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
2397
+ this.auth_ = { userId, accountUserId, authToken, geoKey };
2398
+ this.tokenExpiresAt = reply.tokenExpiresAt;
2011
2399
  this.sessionKey = void 0;
2012
2400
  await this.ensureSessionKey();
2013
2401
  this.persist();
@@ -2125,6 +2513,10 @@ function solixUserTopics(appName, userId) {
2125
2513
 
2126
2514
  // dist/transport/mqtt/secure-mqtt.js
2127
2515
  var SUBACK_FAILURE = 128;
2516
+ function isRefusal(err) {
2517
+ const granted = err?.packet?.granted;
2518
+ return Array.isArray(granted) && granted.some((g) => typeof g === "number" && (g & SUBACK_FAILURE) !== 0);
2519
+ }
2128
2520
  function isNotAuthorized(err) {
2129
2521
  return err instanceof Error && /connection refused/i.test(err.message) && /not authori[sz]ed/i.test(err.message);
2130
2522
  }
@@ -2227,19 +2619,16 @@ var SecureMqtt = class extends EventEmitter {
2227
2619
  }
2228
2620
  /**
2229
2621
  * Subscribe every inbound leg this device's line uses (see `topics.ts` — one `/res` for most lines,
2230
- * four topics for `eufy_life`).
2622
+ * four topics for `eufy_life` and the clean line).
2231
2623
  *
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.
2624
+ * The grants are INSPECTED, not assumed: AWS IoT refuses a policy-denied filter with a
2625
+ * {@link SUBACK_FAILURE} grant rather than failing the connection. A denied topic is reported via
2626
+ * `error` naming the credential scope; only an all-denied device throws, so a line that grants its
2627
+ * state channel but refuses (say) a business leg still works.
2237
2628
  */
2238
2629
  async subscribeDevice(device) {
2239
- if (!this.client)
2240
- throw new Error("SecureMqtt not connected");
2241
2630
  const topics = [...subscribeTopics(device)];
2242
- const { denied } = this.partitionGrants(await this.client.subscribeAsync(topics, { qos: 1 }));
2631
+ const { denied } = await this.subscribeEach(topics);
2243
2632
  const scope = this.o.credentials.app_name ?? "default";
2244
2633
  if (denied.length === topics.length) {
2245
2634
  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 +2639,43 @@ var SecureMqtt = class extends EventEmitter {
2250
2639
  }
2251
2640
  /**
2252
2641
  * 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}`).
2642
+ * is dropped from the result instead of throwing — callers that need every leg check the returned
2643
+ * list. Used by lines whose topic vocabulary isn't the eufy `subscribeTopics` shape (e.g. Anker Solix
2644
+ * `dt/{app}/{pn}/{sn}`).
2256
2645
  */
2257
2646
  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;
2647
+ return (await this.subscribeEach(topics)).granted;
2261
2648
  }
2262
2649
  /**
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.
2650
+ * Subscribe each filter in a SUBSCRIBE of its own and split the outcome into granted vs refused.
2651
+ *
2652
+ * One request per filter because the MQTT engine treats a SUBACK as all-or-nothing: one refused grant
2653
+ * rejects the whole request, and it forgets EVERY filter of that request for resubscription after a
2654
+ * reconnect — so a batch holding one denied leg would lose the granted legs on the next drop. Alone,
2655
+ * a refused filter costs only itself. A rejection that carries no SUBACK is a transport failure and
2656
+ * is thrown.
2657
+ *
2658
+ * The cost is one SUBSCRIBE and one SUBACK per filter instead of one per device — four for a
2659
+ * four-leg line. They are sent concurrently, so the wall-clock cost is one round trip. Folding them
2660
+ * back into one request brings back the lost resubscription.
2266
2661
  */
2267
- partitionGrants(grants) {
2662
+ async subscribeEach(topics) {
2663
+ const client = this.client;
2664
+ if (!client)
2665
+ throw new Error("SecureMqtt not connected");
2666
+ const outcomes = await Promise.allSettled(topics.map((topic) => client.subscribeAsync(topic, { qos: 1 })));
2268
2667
  const granted = [];
2269
2668
  const denied = [];
2270
- for (const g of grants)
2271
- (g.qos === SUBACK_FAILURE ? denied : granted).push(g.topic);
2669
+ outcomes.forEach((outcome, i) => {
2670
+ const topic = topics[i];
2671
+ if (outcome.status === "fulfilled") {
2672
+ granted.push(topic);
2673
+ return;
2674
+ }
2675
+ if (!isRefusal(outcome.reason))
2676
+ throw outcome.reason;
2677
+ denied.push(topic);
2678
+ });
2272
2679
  return { granted, denied };
2273
2680
  }
2274
2681
  /**
@@ -2278,139 +2685,30 @@ var SecureMqtt = class extends EventEmitter {
2278
2685
  *
2279
2686
  * NOTE: which cert scope is used does NOT gate publishing to a security device's topic — confirmed
2280
2687
  * live, an `eufy_mega`-scoped cert and an `eufy_security`-scoped cert get the identical grant/deny
2281
- * pattern. What actually matters is landing on the broker instance that currently holds the device's
2282
- * session (`ensureSecurityMqttFor`/`broker-discovery.ts` — the NLB fronts several instances that
2283
- * don't share subscribe/publish routing).
2284
- */
2285
- 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;
2688
+ * pattern. What actually matters is landing on the broker instance that currently holds the device's
2689
+ * session (`ensureSecurityMqttFor`/`broker-discovery.ts` — the NLB fronts several instances that
2690
+ * don't share subscribe/publish routing).
2691
+ */
2692
+ async publish(topic, body, opts = {}) {
2693
+ if (!this.client)
2694
+ throw new Error("SecureMqtt not connected");
2695
+ await this.client.publishAsync(topic, body, { qos: opts.qos ?? 1 });
2696
+ }
2697
+ async disconnect() {
2698
+ this.connecting = void 0;
2699
+ await this.client?.endAsync(true);
2700
+ this.client = void 0;
2701
+ }
2702
+ };
2703
+
2704
+ // dist/transport/mqtt/app-client-id.js
2705
+ import { createHash as createHash4 } from "node:crypto";
2706
+ function buildAppShapedClientId(input) {
2707
+ const ts = input.timestamp ?? Math.floor(Date.now() / 1e3);
2708
+ return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
2409
2709
  }
2410
- function u32be(n) {
2411
- const b = Buffer.alloc(4);
2412
- b.writeUInt32BE(n >>> 0);
2413
- return b;
2710
+ function mqttUuidFrom(installId) {
2711
+ return createHash4("sha256").update(installId).digest("hex").slice(0, 16);
2414
2712
  }
2415
2713
 
2416
2714
  // dist/transport/mqtt/availability.js
@@ -2537,13 +2835,6 @@ function parseAiotDpReport(raw) {
2537
2835
  return Object.keys(out).length ? out : void 0;
2538
2836
  }
2539
2837
 
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
2838
  // dist/transport/mqtt/biz-stream.js
2548
2839
  var BIZ_PROTOCOL = {
2549
2840
  /** Map-stream data: the frames this module parses. */
@@ -2727,18 +3018,6 @@ var DeviceType = {
2727
3018
  CAMERA_C35: 10035
2728
3019
  };
2729
3020
 
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
3021
  // dist/model/classify.js
2743
3022
  var STATION_TYPES = /* @__PURE__ */ new Set([
2744
3023
  DeviceType.STATION,
@@ -2877,111 +3156,6 @@ function inferName(rec) {
2877
3156
  return void 0;
2878
3157
  }
2879
3158
 
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
3159
  // dist/model/capabilities/access.js
2986
3160
  function setScalar(param, value, ctx, form = "auto") {
2987
3161
  return { kind: "set-param", param, value, form, channel: ctx.channel };
@@ -3254,11 +3428,17 @@ var unreflectedMembers = (surface) => statement(surface, UNREFLECTED);
3254
3428
  // dist/model/capabilities/manifest.js
3255
3429
  function describeBound(modules, bound, ctx) {
3256
3430
  const out = [];
3431
+ const anyBound = Object.values(bound).some((obj) => obj && typeof obj === "object");
3257
3432
  for (const m of modules) {
3258
3433
  const accessor = camelCase(m.capability);
3259
3434
  const obj = bound[accessor];
3260
- if (!obj || typeof obj !== "object")
3435
+ if (!obj || typeof obj !== "object") {
3436
+ const events = anyBound ? emitsOf(m, [], ctx) : [];
3437
+ if (events.length && ctx?.capabilities?.has(m.capability)) {
3438
+ out.push({ capability: m.capability, reads: [], actions: [], undescribedActions: [], events });
3439
+ }
3261
3440
  continue;
3441
+ }
3262
3442
  const descriptors = Object.getOwnPropertyDescriptors(obj);
3263
3443
  const reads2 = [];
3264
3444
  const actions = [];
@@ -3781,7 +3961,6 @@ var MOTION_CMD = {
3781
3961
  * payload key AND pass mChannel 0 explicitly; the two are separate choices, not linked.)
3782
3962
  *
3783
3963
  * ⚠️ 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
3964
  */
3786
3965
  HUMAN_ONLY_AT_NIGHT: 1719,
3787
3966
  /**
@@ -3795,7 +3974,7 @@ var MOTION_CMD = {
3795
3974
  * form, so a feature that is on reads as off.
3796
3975
  * Decoded by {@link decodeRadarWdSwitch} to match the app.
3797
3976
  *
3798
- * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured. Provenance `apk`.
3977
+ * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured.
3799
3978
  */
3800
3979
  LOITERING_DETECTION: 2706,
3801
3980
  /**
@@ -4119,7 +4298,7 @@ var MOTION_MEMBERS = {
4119
4298
  param: MOTION_CMD.HUMAN_ONLY_AT_NIGHT,
4120
4299
  type: "bool",
4121
4300
  kind: "boolean",
4122
- provenance: "apk",
4301
+ provenance: "verified",
4123
4302
  description: "Restrict AI classification to night-time only (1719). \u26A0\uFE0F Replay + readback confirmed on a HomeBase-attached T8425, not byte-captured.",
4124
4303
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
4125
4304
  write: (v, ctx) => {
@@ -4135,7 +4314,7 @@ var MOTION_MEMBERS = {
4135
4314
  param: MOTION_CMD.LOITERING_DETECTION,
4136
4315
  type: "bool",
4137
4316
  kind: "boolean",
4138
- provenance: "apk",
4317
+ provenance: "verified",
4139
4318
  coerce: (raw) => decodeRadarWdSwitch(raw) ?? false,
4140
4319
  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
4320
  requires: [MOTION_CMD.LOITERING_DETECTION],
@@ -4241,6 +4420,7 @@ var MOTION = {
4241
4420
  };
4242
4421
 
4243
4422
  // dist/model/capabilities/person-detection.js
4423
+ var IDENTIFIED_PERSON_CLAIM = { homeBaseAttached: true };
4244
4424
  var PERSON_DETECTION = {
4245
4425
  capability: "person_detection",
4246
4426
  description: "AI human detection enable switch and live detected state.",
@@ -4257,8 +4437,18 @@ var PERSON_DETECTION = {
4257
4437
  */
4258
4438
  events: [
4259
4439
  { 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" }
4440
+ {
4441
+ source: "push",
4442
+ match: HB3PairedDevicePushEvent.IDENTITY_PERSON_DETECTION,
4443
+ emit: "personDetected",
4444
+ claim: IDENTIFIED_PERSON_CLAIM
4445
+ },
4446
+ {
4447
+ source: "push",
4448
+ match: HB3PairedDevicePushEvent.STRANGER_PERSON_DETECTION,
4449
+ emit: "strangerDetected",
4450
+ claim: IDENTIFIED_PERSON_CLAIM
4451
+ }
4262
4452
  ]
4263
4453
  };
4264
4454
 
@@ -4326,7 +4516,7 @@ function decodePowerSource(raw) {
4326
4516
  function recordSetting(param, value, ctx) {
4327
4517
  return setStationScalar(param, value, ctx.channel);
4328
4518
  }
4329
- var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1"];
4519
+ var MAINS_CAMERA_MODELS = ["T8425", "T8419", "T8410", "T84A1", "T8423"];
4330
4520
  function cameraPowerTier(model, capabilities) {
4331
4521
  return capabilities.has("battery") && notMainsCamera({ model }) ? "battery" : "wired";
4332
4522
  }
@@ -5536,6 +5726,7 @@ var CAMERA_MEMBERS = {
5536
5726
  refuseWhenDisabled(ctx, read);
5537
5727
  return m.recordFragments({ powered: poweredOf(ctx), ...opts });
5538
5728
  }), "Continuous fragmented-MP4 (CMAF) recording."),
5729
+ downloadRecording: provided("media", (m) => m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
5539
5730
  /**
5540
5731
  * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
5541
5732
  * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
@@ -5577,7 +5768,7 @@ var SIREN_CMD = {
5577
5768
  ALARM_TEST: 1826,
5578
5769
  /** Manually stop a sounding alarm (app `APP_CMD_SIREN_SENSOR_MANUAL_STOP_ALARM`). */
5579
5770
  MANUAL_STOP: 1871,
5580
- /** Station-family HomeBase duration alarm, verified live on T8010 (app `SET_TONE_FILE`). */
5771
+ /** Station-family HomeBase duration alarm, verified live on T8010 and T8030 (app `SET_TONE_FILE`). */
5581
5772
  HOMEBASE_TONE: 1201,
5582
5773
  /** HomeBase alarm volume percentage (app `CMD_SET_HUB_SPK_VOLUME`). */
5583
5774
  HUB_SPK_VOLUME: 1235,
@@ -5592,7 +5783,7 @@ var SirenAlarmDuration = { Min1: 60, Min5: 300, Min10: 600, Min15: 900 };
5592
5783
  var HubAlarmTone = { Tone1: 1, Tone2: 2 };
5593
5784
  var HUB_ALARM_EVIDENCE = [SIREN_CMD.HUB_ALARM_TONE, 1282];
5594
5785
  function isStationAlarmOutput(ctx) {
5595
- return ctx.deviceType === DeviceType.STATION;
5786
+ return ctx.deviceType === DeviceType.STATION || ctx.deviceType === DeviceType.HB3;
5596
5787
  }
5597
5788
  function isEvidencedStationAlarm(ctx) {
5598
5789
  return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
@@ -6714,9 +6905,9 @@ function parseQuickResponses(voiceList) {
6714
6905
  var DOORBELL_MEMBERS = {
6715
6906
  /**
6716
6907
  * 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.
6908
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
6909
+ * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
6910
+ * inferred from the shared param range.
6720
6911
  */
6721
6912
  chimeSwitch: {
6722
6913
  param: DOORBELL_CMD.CHIME_SWITCH,
@@ -6727,9 +6918,8 @@ var DOORBELL_MEMBERS = {
6727
6918
  write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6728
6919
  },
6729
6920
  /**
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).
6921
+ * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
6922
+ * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
6733
6923
  */
6734
6924
  mechanicalChimeSwitch: {
6735
6925
  param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
@@ -6760,7 +6950,7 @@ var DOORBELL_MEMBERS = {
6760
6950
  type: "number",
6761
6951
  unit: "%",
6762
6952
  kind: "percent",
6763
- provenance: "mega",
6953
+ provenance: "verified",
6764
6954
  writtenElsewhere: true,
6765
6955
  description: "Ringtone volume (1708; confirmed on T8214, observed value 80). The WRITE is `audio`'s setRingtoneVolume \u2014 that capability owns every volume wire."
6766
6956
  },
@@ -6865,7 +7055,7 @@ var DOORBELL_MEMBERS = {
6865
7055
  param: 1710,
6866
7056
  type: "string",
6867
7057
  kind: "text",
6868
- provenance: "mega",
7058
+ provenance: "verified",
6869
7059
  description: "Notification config JSON {notification_motion_onoff,notification_ring_onoff,notification_style} (1710; confirmed on T8214)."
6870
7060
  },
6871
7061
  /**
@@ -7533,113 +7723,34 @@ var ARMING = {
7533
7723
  detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7534
7724
  ownedByStation: true,
7535
7725
  /**
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
- }
7623
- /**
7624
- * The finished DP value: `varint(bodyLength) ++ body`, base64-encoded.
7726
+ * Station-scoped push events. The hub reports a guard-mode switch and the alarm lifecycle on the
7727
+ * generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
7728
+ * capability (see the barrel's event index).
7625
7729
  *
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.
7730
+ * The alarm ids carry a static `phase` so one event name covers the whole lifecycle, the same shape
7731
+ * `battery` uses for its threshold pushes. The mode-switch push is known to identify WHICH mode and
7732
+ * what triggered the change, but neither has been observed on the wire here, so this emits the bare
7733
+ * transition and refreshes the `mode` read rather than trusting a decoded field.
7628
7734
  */
7629
- finish() {
7630
- return Buffer.from([...encodeVarint(this.body.length), ...this.body]).toString("base64");
7631
- }
7735
+ events: [
7736
+ {
7737
+ source: "push",
7738
+ match: CusPushEvent.MODE_SWITCH,
7739
+ emit: "armingModeChanged",
7740
+ refresh: { member: "mode" }
7741
+ },
7742
+ { source: "push", match: CusPushEvent.ALARM, emit: "alarm", payload: { phase: "triggered" } },
7743
+ { source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
7744
+ ]
7745
+ };
7746
+
7747
+ // dist/model/capabilities/storage.js
7748
+ var STORAGE = {
7749
+ capability: "storage",
7750
+ description: "Local storage (SD card / eMMC / HDD). Behaviour-only until a capacity param is captured.",
7751
+ properties: [],
7752
+ detection: { codecs: ["station"] }
7632
7753
  };
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
7754
 
7644
7755
  // dist/model/capabilities/vacuum-dock.js
7645
7756
  var VACUUM_DOCK_DP = 173;
@@ -13345,58 +13456,6 @@ function parseCleanRecordDetail(blob, codec) {
13345
13456
  };
13346
13457
  }
13347
13458
 
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
13459
  // dist/model/vacuum-map.js
13401
13460
  var DOCK_KINDS = ["charger", "station"];
13402
13461
  var MAP_QUALITIES = ["incomplete", "rough", "effective", "listFull"];
@@ -14307,6 +14366,7 @@ __export(codec_exports, {
14307
14366
  buildLookupWithKeyPayload2: () => buildLookupWithKeyPayload2,
14308
14367
  buildRawCommandPayload: () => buildRawCommandPayload,
14309
14368
  buildStringCommandPayload: () => buildStringCommandPayload,
14369
+ buildStringPairCommandPayload: () => buildStringPairCommandPayload,
14310
14370
  buildVoidCommandPayload: () => buildVoidCommandPayload,
14311
14371
  decodeP2PCloudIPs: () => decodeP2PCloudIPs,
14312
14372
  decryptP2PData: () => decryptP2PData,
@@ -14343,6 +14403,11 @@ var ResponseMessageType = {
14343
14403
  LOOKUP_ADDR: Buffer.from([241, 64]),
14344
14404
  LOOKUP_ADDR2: Buffer.from([241, 130]),
14345
14405
  CAM_ID: Buffer.from([241, 66]),
14406
+ /**
14407
+ * The device's own address record, sent in answer to CHECK_CAM ahead of CAM_ID: its 20-byte device id, one
14408
+ * record in `encodeSelfAddress`'s shape naming the address it answers from, then 8 zero bytes (44-byte payload).
14409
+ */
14410
+ CAM_ADDR: Buffer.from([241, 67]),
14346
14411
  TURN_SERVER_CAM_ID: Buffer.from([241, 132]),
14347
14412
  PING: Buffer.from([241, 224]),
14348
14413
  PONG: Buffer.from([241, 225]),
@@ -14554,6 +14619,12 @@ function buildIntStringCommandPayload(value, valueSub, strValue, channel = 0, ke
14554
14619
  data
14555
14620
  ]);
14556
14621
  }
14622
+ function buildStringPairCommandPayload(strValue, strValueSub, channel = 0, key, encType = 1) {
14623
+ const encrypted = !!key && key.length === 16;
14624
+ const body = Buffer.concat([Buffer.alloc(5), stringWithLength(strValue), stringWithLength(strValueSub)]);
14625
+ const data = encrypted ? encryptP2PData(paddingP2PData(body), key) : body;
14626
+ return buildRawCommandPayload(data, channel, encrypted ? encType : 0);
14627
+ }
14557
14628
  function buildRawCommandPayload(data, channel = 0, signCode = 0, magic = [1, 0], streamId = 0) {
14558
14629
  const header = Buffer.allocUnsafe(2);
14559
14630
  header.writeUInt16LE(data.length, 0);
@@ -15159,6 +15230,7 @@ var LOCAL_LOOKUP_PORT = 32108;
15159
15230
  var HEARTBEAT_MS = 5e3;
15160
15231
  var PATH_SILENCE_MS = HEARTBEAT_MS * 3;
15161
15232
  var LOOKUP_RETRY_MS = 1e3;
15233
+ var RECEIVE_BUFFER_BYTES = 4 * 1024 * 1024;
15162
15234
  var CONNECT_TIMEOUT_MS = 15e3;
15163
15235
  var STATION_CHANNEL3 = 255;
15164
15236
  var CMD_GATEWAYINFO = 1100;
@@ -15194,6 +15266,7 @@ var CMD_DATABASE_IMAGE = 1308;
15194
15266
  var CMD_DATABASE = 1306;
15195
15267
  var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
15196
15268
  var GCM_AAD = Buffer.from("eufy security");
15269
+ var LEVEL2_SEQ_BASE = 16909056;
15197
15270
  function paramReport(payload) {
15198
15271
  const list = payload?.params;
15199
15272
  if (!Array.isArray(list))
@@ -15208,12 +15281,57 @@ function paramReport(payload) {
15208
15281
  return Object.keys(out).length ? out : void 0;
15209
15282
  }
15210
15283
  var traceSequence = 0;
15284
+ var DB_TABLE_TIMEOUT_MS = 15e3;
15285
+ function firstTableRows(text2) {
15286
+ const decoded = Buffer.from(text2, "latin1").toString("utf8");
15287
+ for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
15288
+ const end = closingBrace(decoded, start);
15289
+ if (end < 0)
15290
+ return void 0;
15291
+ let parsed;
15292
+ try {
15293
+ parsed = JSON.parse(decoded.slice(start, end + 1));
15294
+ } catch {
15295
+ continue;
15296
+ }
15297
+ const data = parsed?.data;
15298
+ if (Array.isArray(data))
15299
+ return data;
15300
+ }
15301
+ return void 0;
15302
+ }
15303
+ function closingBrace(text2, start) {
15304
+ let depth = 0;
15305
+ let inString = false;
15306
+ let escaped = false;
15307
+ for (let i = start; i < text2.length; i++) {
15308
+ const c = text2[i];
15309
+ if (inString) {
15310
+ if (escaped)
15311
+ escaped = false;
15312
+ else if (c === "\\")
15313
+ escaped = true;
15314
+ else if (c === '"')
15315
+ inString = false;
15316
+ continue;
15317
+ }
15318
+ if (c === '"')
15319
+ inString = true;
15320
+ else if (c === "{")
15321
+ depth++;
15322
+ else if (c === "}" && --depth === 0)
15323
+ return i;
15324
+ }
15325
+ return -1;
15326
+ }
15211
15327
  var P2PSession = class _P2PSession extends EventEmitter2 {
15212
15328
  cfg;
15213
15329
  socket;
15214
15330
  connected = false;
15215
15331
  connecting = false;
15216
15332
  closed = false;
15333
+ /** Whether a short receive buffer has been reported, so a reconnect does not repeat the same warning. */
15334
+ receiveBufferReported = false;
15217
15335
  connectAddress;
15218
15336
  seqNumber = 0;
15219
15337
  /**
@@ -15240,8 +15358,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15240
15358
  audioStalled = false;
15241
15359
  audioRetransmitTimer;
15242
15360
  lastPongData;
15243
- /** When this connection last received a PONG — `undefined` until the first, see {@link pathSilentMs}. */
15244
- lastPongAt;
15361
+ /** When the selected peer last answered outbound P2P traffic on this connection. */
15362
+ lastPeerAt;
15245
15363
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
15246
15364
  pathStaleTraced = false;
15247
15365
  lookupTimer;
@@ -15253,7 +15371,10 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15253
15371
  cloudLookup;
15254
15372
  /** Local outbound IPv4 reported inside LOOKUP_WITH_KEY requests. */
15255
15373
  selfHost;
15256
- /** In-flight multi-datagram frame per data channel (see onData). */
15374
+ /**
15375
+ * In-flight multi-datagram frame per data channel (see reassemble): the payload gathered so far under its
15376
+ * parsed header, or, without a header, the start of a frame header cut by the datagram boundary.
15377
+ */
15257
15378
  pendingByDataType = /* @__PURE__ */ new Map();
15258
15379
  /** Last delivered datagram sequence number per data type. */
15259
15380
  lastSeqByType = /* @__PURE__ */ new Map();
@@ -15307,17 +15428,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15307
15428
  /**
15308
15429
  * How long this connection's path has been silent, or nothing where it has never answered.
15309
15430
  *
15310
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
15311
- * that has said nothing either way.
15431
+ * PONG and ACK from the selected peer prove the path answers outbound traffic. `undefined` means no such reply
15432
+ * has arrived since connection, so silence alone does not establish a stale path.
15312
15433
  */
15313
15434
  get pathSilentMs() {
15314
- return this.lastPongAt === void 0 ? void 0 : Date.now() - this.lastPongAt;
15435
+ return this.lastPeerAt === void 0 ? void 0 : Date.now() - this.lastPeerAt;
15315
15436
  }
15316
15437
  /**
15317
15438
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
15318
15439
  *
15319
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
15320
- * not known to be dead, so it answers true.
15440
+ * False where selected-peer replies arrived and then stopped for {@link PATH_SILENCE_MS}. A connection with
15441
+ * no post-connect reply is not known to be dead, so it answers true.
15321
15442
  *
15322
15443
  * Traces the silence once per connection, on the read that first observes it.
15323
15444
  */
@@ -15533,6 +15654,26 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15533
15654
  get isConnected() {
15534
15655
  return this.connected;
15535
15656
  }
15657
+ /**
15658
+ * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and warn once per session when it grants
15659
+ * less or refuses.
15660
+ *
15661
+ * The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
15662
+ * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
15663
+ */
15664
+ requestReceiveBuffer(socket) {
15665
+ let granted;
15666
+ try {
15667
+ socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
15668
+ granted = socket.getRecvBufferSize();
15669
+ } catch {
15670
+ granted = 0;
15671
+ }
15672
+ if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
15673
+ return;
15674
+ this.receiveBufferReported = true;
15675
+ 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}.`);
15676
+ }
15536
15677
  /** Open the socket and start the lookup → hole-punch handshake. */
15537
15678
  async connect() {
15538
15679
  if (this.connecting || this.connected)
@@ -15556,6 +15697,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15556
15697
  socket.setBroadcast(true);
15557
15698
  } catch {
15558
15699
  }
15700
+ this.requestReceiveBuffer(socket);
15559
15701
  resolve();
15560
15702
  });
15561
15703
  });
@@ -15674,10 +15816,18 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15674
15816
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
15675
15817
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
15676
15818
  * as a cause.
15819
+ *
15820
+ * CAM_ADDR is recognised and dropped: it names the address the device is already answering from, and the
15821
+ * CAM_ID that follows it is what completes the connect.
15677
15822
  */
15678
15823
  onMessage(msg, rinfo, socket = this.socket) {
15679
15824
  if (!socket)
15680
15825
  return;
15826
+ const fromConnectedPeer = this.connected && this.connectAddress?.host === rinfo.address && this.connectAddress.port === rinfo.port;
15827
+ if (fromConnectedPeer && (hasHeader(msg, ResponseMessageType.PONG) || hasHeader(msg, ResponseMessageType.ACK))) {
15828
+ this.lastPeerAt = Date.now();
15829
+ this.pathStaleTraced = false;
15830
+ }
15681
15831
  if (!hasHeader(msg, ResponseMessageType.DATA)) {
15682
15832
  this.logger.debug(`[p2p] ${this.cfg.stationSn} <<< ${rinfo.address}:${rinfo.port} header=${msg.subarray(0, 2).toString("hex")} len=${msg.length}`);
15683
15833
  }
@@ -15695,8 +15845,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15695
15845
  this.onConnected({ host: rinfo.address, port: rinfo.port }, socket);
15696
15846
  } else if (hasHeader(msg, ResponseMessageType.PONG)) {
15697
15847
  this.lastPongData = msg.length > 4 ? msg.subarray(4) : void 0;
15698
- this.lastPongAt = Date.now();
15699
- this.pathStaleTraced = false;
15700
15848
  } else if (hasHeader(msg, ResponseMessageType.PING)) {
15701
15849
  this.send({ host: rinfo.address, port: rinfo.port }, RequestMessageType.PONG, void 0, socket);
15702
15850
  } else if (hasHeader(msg, ResponseMessageType.ACK)) {
@@ -15704,7 +15852,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15704
15852
  } else if (hasHeader(msg, ResponseMessageType.DATA)) {
15705
15853
  if (this.connected)
15706
15854
  this.onData(msg, { host: rinfo.address, port: rinfo.port });
15707
- } else {
15855
+ } else if (!hasHeader(msg, ResponseMessageType.CAM_ADDR)) {
15708
15856
  this.logger.debug(`[p2p] ${this.cfg.stationSn} UNHANDLED payload hex: ${msg.toString("hex")}`);
15709
15857
  }
15710
15858
  }
@@ -15995,6 +16143,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15995
16143
  this.seqNumber = this.seqNumber + 1 & 65535;
15996
16144
  this.send(this.connectAddress, RequestMessageType.DATA, data);
15997
16145
  }
16146
+ /** Send a level-1 {@link buildStringPairCommandPayload} command on `channel`. */
16147
+ sendStringPairCommand(commandType, strValue, strValueSub, channel) {
16148
+ if (!this.connectAddress)
16149
+ throw new Error(`P2P session ${this.cfg.stationSn} is not connected`);
16150
+ const data = Buffer.concat([
16151
+ buildCommandHeader(this.seqNumber, commandType),
16152
+ buildStringPairCommandPayload(strValue, strValueSub, channel, this.level1Key, 1)
16153
+ ]);
16154
+ this.seqNumber = this.seqNumber + 1 & 65535;
16155
+ this.send(this.connectAddress, RequestMessageType.DATA, data);
16156
+ }
15998
16157
  /**
15999
16158
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
16000
16159
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -16272,11 +16431,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16272
16431
  this.seqNumber = this.seqNumber + 1 & 65535;
16273
16432
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16274
16433
  }
16275
- /**
16276
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
16277
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
16278
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
16279
- */
16280
16434
  /**
16281
16435
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
16282
16436
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -16343,6 +16497,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16343
16497
  }
16344
16498
  return this.rsaModulusHex;
16345
16499
  }
16500
+ /**
16501
+ * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ seq(4) ‖ ciphertext`,
16502
+ * AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of `decryptLevel2`.
16503
+ * The 4-byte sub-header is cleartext (skipped on decrypt); `seq` counts up from {@link LEVEL2_SEQ_BASE}.
16504
+ */
16346
16505
  encryptLevel2(plaintext) {
16347
16506
  if (!this.level2Key)
16348
16507
  return void 0;
@@ -16350,8 +16509,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16350
16509
  const c = createCipheriv3("aes-256-gcm", this.level2Key, nonce);
16351
16510
  c.setAAD(GCM_AAD);
16352
16511
  const ct = Buffer.concat([c.update(plaintext), c.final()]);
16353
- const sub2 = Buffer.from([this.level2Seq & 255, 3, 2, 1]);
16354
- this.level2Seq = this.level2Seq + 1 & 255;
16512
+ const sub2 = Buffer.alloc(4);
16513
+ sub2.writeUInt32LE(LEVEL2_SEQ_BASE + this.level2Seq++ >>> 0);
16355
16514
  return Buffer.concat([c.getAuthTag(), nonce, sub2, ct]);
16356
16515
  }
16357
16516
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
@@ -16397,8 +16556,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16397
16556
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
16398
16557
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
16399
16558
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
16559
+ *
16560
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
16561
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
16562
+ * one's buffer and answered as its rows.
16400
16563
  */
16401
16564
  queryDatabase(table, opts = {}) {
16565
+ if (this.dbReadInFlight)
16566
+ throw new Error(`queryDatabase: ${this.cfg.stationSn} is already reading a table`);
16402
16567
  if (!this.connectAddress)
16403
16568
  throw new Error("not connected");
16404
16569
  const channel = opts.channel ?? STATION_CHANNEL3;
@@ -16461,6 +16626,49 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16461
16626
  query: this.fullTableQuery()
16462
16627
  });
16463
16628
  }
16629
+ /**
16630
+ * Query one on-station table and answer its rows, once the reply is whole.
16631
+ *
16632
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
16633
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
16634
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16635
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16636
+ * complete reply.
16637
+ *
16638
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16639
+ * session throws, so a second reply cannot land in this buffer.
16640
+ */
16641
+ async readDatabase(table, opts = {}) {
16642
+ if (opts.signal?.aborted)
16643
+ throw new Error("readDatabase: aborted");
16644
+ this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16645
+ this.dbReadInFlight = true;
16646
+ try {
16647
+ return await new Promise((resolve, reject) => {
16648
+ let text2 = "";
16649
+ const onChunk = (chunk) => {
16650
+ text2 += chunk.text;
16651
+ const rows = firstTableRows(text2);
16652
+ if (rows)
16653
+ settle(() => resolve(rows));
16654
+ };
16655
+ const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16656
+ const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
16657
+ const settle = (finish) => {
16658
+ clearTimeout(timer);
16659
+ this.off("dbChunk", onChunk);
16660
+ opts.signal?.removeEventListener("abort", onAbort);
16661
+ finish();
16662
+ };
16663
+ this.on("dbChunk", onChunk);
16664
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
16665
+ });
16666
+ } finally {
16667
+ this.dbReadInFlight = false;
16668
+ }
16669
+ }
16670
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
16671
+ dbReadInFlight = false;
16464
16672
  /**
16465
16673
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
16466
16674
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -16628,7 +16836,13 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16628
16836
  clearTimeout(reorder.timer);
16629
16837
  reorder.timer = void 0;
16630
16838
  }
16631
- /** Reassemble one in-sequence datagram body into logical frames. */
16839
+ /**
16840
+ * Reassemble one in-sequence datagram body into logical frames.
16841
+ *
16842
+ * Frames are packed back to back, so a frame header can itself be cut by a datagram boundary. The start
16843
+ * of a header left at the end of a datagram is carried into the next one rather than discarded; dropping
16844
+ * it would lose that frame and every frame after it until a datagram happened to begin on a header.
16845
+ */
16632
16846
  reassemble(dataType, datagramBody) {
16633
16847
  const pending = this.pendingByDataType.get(dataType);
16634
16848
  let body = pending ? Buffer.concat([pending.buf, datagramBody]) : datagramBody;
@@ -16652,6 +16866,9 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16652
16866
  this.handleFrame(header, payload.subarray(0, header.bytesToRead), dataType);
16653
16867
  body = body.subarray(P2P_DATA_HEADER_BYTES + header.bytesToRead);
16654
16868
  }
16869
+ if (body.length > 0 && body.length < P2P_DATA_HEADER_BYTES && MAGIC_WORD.startsWith(body.subarray(0, 4).toString())) {
16870
+ this.pendingByDataType.set(dataType, { buf: body });
16871
+ }
16655
16872
  }
16656
16873
  /**
16657
16874
  * Forget where each data type's sequence numbering had reached, and drop any half-reassembled frame.
@@ -18114,61 +18331,337 @@ function jpegGeometry(jpeg) {
18114
18331
  return void 0;
18115
18332
  return { height: jpeg.readUInt16BE(at + 5), width: jpeg.readUInt16BE(at + 7) };
18116
18333
  }
18117
- if (JPEG_STANDALONE_MARKERS.has(marker))
18118
- at += 2;
18119
- else if (at + 4 <= jpeg.length)
18120
- at += 2 + jpeg.readUInt16BE(at + 2);
18334
+ if (JPEG_STANDALONE_MARKERS.has(marker))
18335
+ at += 2;
18336
+ else if (at + 4 <= jpeg.length)
18337
+ at += 2 + jpeg.readUInt16BE(at + 2);
18338
+ else
18339
+ return void 0;
18340
+ }
18341
+ return void 0;
18342
+ }
18343
+ function annexbToJpeg(annexb, spawn2) {
18344
+ const codec = annexbFfmpegFormat(annexb);
18345
+ return new Promise((resolve, reject) => {
18346
+ const ff = spawnFfmpeg([
18347
+ // prettier-ignore
18348
+ "-f",
18349
+ codec,
18350
+ "-i",
18351
+ "pipe:0",
18352
+ "-frames:v",
18353
+ "1",
18354
+ "-pix_fmt",
18355
+ "yuvj420p",
18356
+ "-f",
18357
+ "image2",
18358
+ "-vcodec",
18359
+ "mjpeg",
18360
+ "pipe:1"
18361
+ ], spawn2);
18362
+ const out = [];
18363
+ const err = [];
18364
+ ff.stdout.on("data", (d) => out.push(d));
18365
+ ff.stderr.on("data", (d) => err.push(d));
18366
+ ff.on("error", (e) => reject(new LiveSnapshotUnavailableError("decoder-unavailable", `ffmpeg not runnable: ${e instanceof Error ? e.message : e}`, { cause: e })));
18367
+ ff.on("close", (code) => {
18368
+ const jpeg = Buffer.concat(out);
18369
+ const encoded = jpeg.length >= 3 && jpeg.subarray(0, 3).toString("hex") === "ffd8ff";
18370
+ const geometry = encoded ? jpegGeometry(jpeg) : void 0;
18371
+ if (geometry)
18372
+ return resolve({ jpeg, ...geometry });
18373
+ const diagnostics = Buffer.concat(err).toString().slice(0, 200).trim();
18374
+ const what = code !== 0 ? `ffmpeg exited ${code}` : encoded ? "encoded image declares no frame header" : "no complete frame in the burst";
18375
+ reject(new LiveSnapshotUnavailableError("undecodable-burst", diagnostics ? `${what}: ${diagnostics}` : what));
18376
+ });
18377
+ ff.stdin.on("error", () => {
18378
+ });
18379
+ ff.stdin.write(annexb);
18380
+ ff.stdin.end();
18381
+ });
18382
+ }
18383
+
18384
+ // dist/transport/p2p/recording-download.js
18385
+ import { createDecipheriv as createDecipheriv7 } from "node:crypto";
18386
+
18387
+ // dist/transport/p2p/adts.js
18388
+ var PROFILE_AAC_LC = 1;
18389
+ var FREQ_INDEX_16K = 8;
18390
+ var CHANNELS_MONO = 1;
18391
+ var AAC_SAMPLES_PER_FRAME = 1024;
18392
+ var AAC_SAMPLE_RATE = 16e3;
18393
+ var AAC_FRAME_MS = AAC_SAMPLES_PER_FRAME * 1e3 / AAC_SAMPLE_RATE;
18394
+ var MAX_AUDIO_FRAME_BYTES = 640;
18395
+ var HEADER_LEN_NO_CRC = 7;
18396
+ function parseAdtsHeader(buf, offset = 0) {
18397
+ if (offset + HEADER_LEN_NO_CRC > buf.length)
18398
+ return void 0;
18399
+ if (buf[offset] !== 255 || (buf[offset + 1] & 240) !== 240)
18400
+ return void 0;
18401
+ const headerLength = buf[offset + 1] & 1 ? 7 : 9;
18402
+ const frameLength = (buf[offset + 3] & 3) << 11 | buf[offset + 4] << 3 | (buf[offset + 5] & 224) >> 5;
18403
+ if (frameLength < headerLength)
18404
+ return void 0;
18405
+ return {
18406
+ frameLength,
18407
+ headerLength,
18408
+ profile: (buf[offset + 2] & 192) >> 6,
18409
+ frequencyIndex: (buf[offset + 2] & 60) >> 2,
18410
+ channels: (buf[offset + 2] & 1) << 2 | (buf[offset + 3] & 192) >> 6
18411
+ };
18412
+ }
18413
+ function buildAdtsHeader(payloadLength) {
18414
+ const frameLength = payloadLength + HEADER_LEN_NO_CRC;
18415
+ return Buffer.from([
18416
+ 255,
18417
+ 241,
18418
+ PROFILE_AAC_LC << 6 | FREQ_INDEX_16K << 2 | CHANNELS_MONO >> 2,
18419
+ (CHANNELS_MONO & 3) << 6 | frameLength >> 11 & 3,
18420
+ frameLength >> 3 & 255,
18421
+ (frameLength & 7) << 5 | 31,
18422
+ 252
18423
+ ]);
18424
+ }
18425
+ function isSupportedAdts(h) {
18426
+ return h.profile === PROFILE_AAC_LC && h.frequencyIndex === FREQ_INDEX_16K && h.channels === CHANNELS_MONO;
18427
+ }
18428
+ function describeAdts(h) {
18429
+ const rate = ADTS_SAMPLE_RATES[h.frequencyIndex] ?? `index ${h.frequencyIndex}`;
18430
+ const profile = h.profile === PROFILE_AAC_LC ? "AAC-LC" : `profile field ${h.profile}`;
18431
+ return `${profile}, ${rate} Hz, ${h.channels} channel(s)`;
18432
+ }
18433
+ var ADTS_SAMPLE_RATES = [
18434
+ 96e3,
18435
+ 88200,
18436
+ 64e3,
18437
+ 48e3,
18438
+ 44100,
18439
+ 32e3,
18440
+ 24e3,
18441
+ 22050,
18442
+ 16e3,
18443
+ 12e3,
18444
+ 11025,
18445
+ 8e3,
18446
+ 7350
18447
+ ];
18448
+ var AdtsFrameReader = class {
18449
+ buffered = Buffer.alloc(0);
18450
+ /**
18451
+ * Append `chunk` and return every complete frame now available, each a standalone buffer that still
18452
+ * carries its own ADTS header (the device expects the header on the wire).
18453
+ */
18454
+ push(chunk) {
18455
+ this.buffered = this.buffered.length ? Buffer.concat([this.buffered, chunk]) : chunk;
18456
+ const frames = [];
18457
+ let at = 0;
18458
+ for (; ; ) {
18459
+ const h = parseAdtsHeader(this.buffered, at);
18460
+ if (!h) {
18461
+ if (at + HEADER_LEN_NO_CRC > this.buffered.length)
18462
+ break;
18463
+ at++;
18464
+ continue;
18465
+ }
18466
+ if (at + h.frameLength > this.buffered.length)
18467
+ break;
18468
+ frames.push(this.buffered.subarray(at, at + h.frameLength));
18469
+ at += h.frameLength;
18470
+ }
18471
+ this.buffered = at ? this.buffered.subarray(at) : this.buffered;
18472
+ return frames;
18473
+ }
18474
+ /** Bytes held back awaiting the rest of their frame — non-zero mid-stream, zero on a clean boundary. */
18475
+ get pending() {
18476
+ return this.buffered.length;
18477
+ }
18478
+ };
18479
+
18480
+ // dist/transport/p2p/recording-download.js
18481
+ var RECORDING_DATA_TYPE = 3;
18482
+ var VIDEO_HEADER_LEN2 = 22;
18483
+ var AUDIO_HEADER_LEN2 = 16;
18484
+ var AUDIO_NONCE_START = 32;
18485
+ var AUDIO_BODY_START = 44;
18486
+ var AUDIO_CODEC_AAC_LC = 0;
18487
+ var FIRST_FRAME_WAIT_MS = 2e4;
18488
+ var IDLE_END_MS = 15e3;
18489
+ var DEFAULT_TRANSFER_MS = 12e4;
18490
+ var MAX_TRANSFER_BYTES = 32 * 1024 * 1024;
18491
+ function homeBase2RecordingPath(channel, recording) {
18492
+ if (!/^\d{14}$/.test(recording) || !Number.isInteger(channel) || channel < 0 || channel > 99)
18493
+ return void 0;
18494
+ return `/media/mmcblk0p1/Camera${String(channel).padStart(2, "0")}/${recording}.dat`;
18495
+ }
18496
+ function receiveRecording(session, request) {
18497
+ let resolveFrames;
18498
+ let rejectFrames;
18499
+ const frames = new Promise((resolve, reject) => {
18500
+ resolveFrames = resolve;
18501
+ rejectFrames = reject;
18502
+ });
18503
+ let resolveDrained;
18504
+ const drained = new Promise((resolve) => resolveDrained = resolve);
18505
+ const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
18506
+ let collected = [];
18507
+ const started = Date.now();
18508
+ let lastFrameAt = started;
18509
+ let received = false;
18510
+ let bytes2 = 0;
18511
+ let draining;
18512
+ let delivered = false;
18513
+ let listening = true;
18514
+ const deliver = (error) => {
18515
+ if (delivered)
18516
+ return;
18517
+ delivered = true;
18518
+ request.signal?.removeEventListener("abort", onAbort);
18519
+ if (error)
18520
+ rejectFrames(error);
18121
18521
  else
18122
- return void 0;
18522
+ resolveFrames(collected);
18523
+ };
18524
+ const stop = () => {
18525
+ if (!listening)
18526
+ return;
18527
+ listening = false;
18528
+ clearInterval(tick);
18529
+ session.off("data", onData);
18530
+ resolveDrained();
18531
+ };
18532
+ const incomplete = (why) => deliver(new RecordingDownloadError("incomplete", `the recording transfer ${why} before its finish frame`));
18533
+ const drain = (why) => {
18534
+ draining ??= why;
18535
+ collected = [];
18536
+ };
18537
+ const onData = (frame) => {
18538
+ if (frame.channel !== request.channel)
18539
+ return;
18540
+ if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
18541
+ if (draining)
18542
+ incomplete(draining);
18543
+ else
18544
+ deliver();
18545
+ return stop();
18546
+ }
18547
+ if (frame.commandId !== CommandType.CMD_VIDEO_FRAME && frame.commandId !== CommandType.CMD_AUDIO_FRAME)
18548
+ return;
18549
+ if (frame.dataType !== RECORDING_DATA_TYPE)
18550
+ return;
18551
+ received = true;
18552
+ lastFrameAt = Date.now();
18553
+ if (draining)
18554
+ return;
18555
+ collected.push({ commandId: frame.commandId, signCode: frame.signCode, raw: Buffer.from(frame.raw) });
18556
+ bytes2 += frame.raw.length;
18557
+ if (bytes2 >= MAX_TRANSFER_BYTES)
18558
+ drain(`reached ${MAX_TRANSFER_BYTES} bytes`);
18559
+ };
18560
+ const onAbort = () => {
18561
+ deliver(request.signal?.reason instanceof Error ? request.signal.reason : new Error("aborted"));
18562
+ drain("was aborted");
18563
+ };
18564
+ const tick = setInterval(() => {
18565
+ const now2 = Date.now();
18566
+ if (!received && now2 - started > FIRST_FRAME_WAIT_MS) {
18567
+ deliver(new RecordingDownloadError("no-data", `the station sent nothing within ${FIRST_FRAME_WAIT_MS} ms`));
18568
+ stop();
18569
+ } else if (received && now2 - lastFrameAt > IDLE_END_MS) {
18570
+ incomplete(draining ?? `went quiet for ${IDLE_END_MS} ms`);
18571
+ stop();
18572
+ } else if (now2 - started > 2 * limitMs && draining) {
18573
+ incomplete(draining);
18574
+ stop();
18575
+ } else if (now2 - started > limitMs) {
18576
+ drain(`ran past ${limitMs} ms`);
18577
+ }
18578
+ }, 250);
18579
+ if (request.signal?.aborted) {
18580
+ onAbort();
18581
+ stop();
18582
+ return { frames, drained };
18583
+ }
18584
+ request.signal?.addEventListener("abort", onAbort, { once: true });
18585
+ session.on("data", onData);
18586
+ try {
18587
+ session.sendStringPairCommand(CommandType.CMD_DOWNLOAD_VIDEO, request.path, request.accountId, request.channel);
18588
+ } catch (error) {
18589
+ deliver(error instanceof Error ? error : new Error(String(error)));
18590
+ stop();
18123
18591
  }
18124
- return void 0;
18592
+ return { frames, drained };
18125
18593
  }
18126
- function annexbToJpeg(annexb, spawn2) {
18127
- const codec = annexbFfmpegFormat(annexb);
18128
- return new Promise((resolve, reject) => {
18129
- const ff = spawnFfmpeg([
18130
- // prettier-ignore
18131
- "-f",
18132
- codec,
18133
- "-i",
18134
- "pipe:0",
18135
- "-frames:v",
18136
- "1",
18137
- "-pix_fmt",
18138
- "yuvj420p",
18139
- "-f",
18140
- "image2",
18141
- "-vcodec",
18142
- "mjpeg",
18143
- "pipe:1"
18144
- ], spawn2);
18145
- const out = [];
18146
- const err = [];
18147
- ff.stdout.on("data", (d) => out.push(d));
18148
- ff.stderr.on("data", (d) => err.push(d));
18149
- ff.on("error", (e) => reject(new LiveSnapshotUnavailableError("decoder-unavailable", `ffmpeg not runnable: ${e instanceof Error ? e.message : e}`, { cause: e })));
18150
- ff.on("close", (code) => {
18151
- const jpeg = Buffer.concat(out);
18152
- const encoded = jpeg.length >= 3 && jpeg.subarray(0, 3).toString("hex") === "ffd8ff";
18153
- const geometry = encoded ? jpegGeometry(jpeg) : void 0;
18154
- if (geometry)
18155
- return resolve({ jpeg, ...geometry });
18156
- const diagnostics = Buffer.concat(err).toString().slice(0, 200).trim();
18157
- const what = code !== 0 ? `ffmpeg exited ${code}` : encoded ? "encoded image declares no frame header" : "no complete frame in the burst";
18158
- reject(new LiveSnapshotUnavailableError("undecodable-burst", diagnostics ? `${what}: ${diagnostics}` : what));
18159
- });
18160
- ff.stdin.on("error", () => {
18161
- });
18162
- ff.stdin.write(annexb);
18163
- ff.stdin.end();
18164
- });
18594
+ function openAudio(key, nonce, tag2, body) {
18595
+ try {
18596
+ const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
18597
+ decipher.setAAD(VIDEO_GCM_AAD);
18598
+ decipher.setAuthTag(tag2);
18599
+ return Buffer.concat([decipher.update(body), decipher.final()]);
18600
+ } catch {
18601
+ return void 0;
18602
+ }
18603
+ }
18604
+ function decodeRecording(frames, eccPrivateKeyHex) {
18605
+ const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
18606
+ if (eccPrivateKey.length !== 32)
18607
+ throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
18608
+ const decoder = new VideoFrameDecoder(eccPrivateKey);
18609
+ const video = [];
18610
+ const audio = [];
18611
+ const numbers = /* @__PURE__ */ new Set();
18612
+ let firstStamp;
18613
+ let lastStamp;
18614
+ for (const { commandId, raw } of frames) {
18615
+ if (commandId === CommandType.CMD_VIDEO_FRAME) {
18616
+ const header = parseVideoFrameHeader(raw);
18617
+ if (!header)
18618
+ continue;
18619
+ 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;
18620
+ if (!h264?.length)
18621
+ continue;
18622
+ video.push(h264);
18623
+ numbers.add(header.sequence);
18624
+ const stamp = raw.readUIntLE(14, 6);
18625
+ firstStamp ??= stamp;
18626
+ lastStamp = stamp;
18627
+ } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
18628
+ const mediaKey = decoder.currentMediaKey;
18629
+ if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
18630
+ continue;
18631
+ const len = raw.readUInt32LE(0);
18632
+ if (raw.length < AUDIO_BODY_START + len)
18633
+ continue;
18634
+ 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));
18635
+ if (aac?.length)
18636
+ audio.push(buildAdtsHeader(aac.length), aac);
18637
+ }
18638
+ }
18639
+ if (!video.length)
18640
+ throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
18641
+ const ordered = [...numbers];
18642
+ let missingFrames = 0;
18643
+ for (let i = 1; i < ordered.length; i++) {
18644
+ const step = ordered[i] - ordered[i - 1] & 65535;
18645
+ if (step > 1 && step < 32768)
18646
+ missingFrames += step - 1;
18647
+ }
18648
+ const span = ordered.length > 1 ? ordered[ordered.length - 1] - ordered[0] & 65535 : 0;
18649
+ const durationMs = (lastStamp ?? 0) - (firstStamp ?? 0);
18650
+ return {
18651
+ video: Buffer.concat(video),
18652
+ ...audio.length ? { audio: Buffer.concat(audio) } : {},
18653
+ frames: ordered.length,
18654
+ missingFrames,
18655
+ durationMs,
18656
+ fps: durationMs > 0 && span > 0 ? Math.round(span * 1e5 / durationMs) / 100 : 0
18657
+ };
18165
18658
  }
18166
18659
 
18167
18660
  // dist/transport/p2p/shared-live-source.js
18168
18661
  import { EventEmitter as EventEmitter4 } from "node:events";
18169
- function durationMs(seconds) {
18170
- const milliseconds = (seconds ?? 0) * 1e3;
18171
- return Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : 0;
18662
+ var MAX_TIMER_DELAY_MS = 2147483647;
18663
+ function timerMs(value, fallback) {
18664
+ return value !== void 0 && Number.isFinite(value) && value >= 1 && value <= MAX_TIMER_DELAY_MS ? value : fallback;
18172
18665
  }
18173
18666
  function sameConfig(a, b) {
18174
18667
  return b !== void 0 && a.codec === b.codec && a.width === b.width && a.height === b.height;
@@ -18421,14 +18914,14 @@ var SharedLiveSource = class {
18421
18914
  tag;
18422
18915
  constructor(opts) {
18423
18916
  this.opts = opts;
18424
- this.lingerMs = opts.lingerMs ?? 8e3;
18917
+ this.lingerMs = opts.lingerMs === 0 ? 1 : timerMs(opts.lingerMs, 8e3);
18425
18918
  this.maxQueue = opts.maxQueue ?? 900;
18426
- this.preBufferMs = durationMs(opts.preBufferSeconds);
18427
- this.warmRetryMs = opts.warmRetryMs ?? 2e3;
18428
- this.warmTimeoutMs = opts.warmTimeoutMs ?? 2e4;
18919
+ this.preBufferMs = timerMs((opts.preBufferSeconds ?? 0) * 1e3, 0);
18920
+ this.warmRetryMs = timerMs(opts.warmRetryMs, 2e3);
18921
+ this.warmTimeoutMs = timerMs(opts.warmTimeoutMs, 2e4);
18429
18922
  this.powered = opts.powered ?? "wired";
18430
- this.batteryBudgetMs = opts.batteryBudgetMs ?? 45e3;
18431
- this.budgetGraceMs = opts.budgetGraceMs ?? 1e4;
18923
+ this.batteryBudgetMs = timerMs(opts.batteryBudgetMs, 45e3);
18924
+ this.budgetGraceMs = timerMs(opts.budgetGraceMs, 1e4);
18432
18925
  this.logger = opts.logger ?? noopLogger;
18433
18926
  this.tag = opts.label ? `[live ${opts.label}]` : "[live]";
18434
18927
  this.traceId = `pull-${++pullSequence}`;
@@ -18676,7 +19169,7 @@ var SharedLiveSource = class {
18676
19169
  if (this.disposed || !this.stream)
18677
19170
  return;
18678
19171
  this.clearBudget();
18679
- this.budgetTimer.arm(ms ?? this.batteryBudgetMs, () => this.onBudgetExpire());
19172
+ this.budgetTimer.arm(timerMs(ms, this.batteryBudgetMs), () => this.onBudgetExpire());
18680
19173
  }
18681
19174
  /** Settle a reuse watch on any frame — the join already holds a decodable picture. */
18682
19175
  settleReuseWatch() {
@@ -18817,7 +19310,7 @@ var SharedLiveSource = class {
18817
19310
  return this.bufferedMedia(seconds).filter((item) => item.kind === "video").map((item) => item.frame);
18818
19311
  }
18819
19312
  bufferedMedia(seconds) {
18820
- const requested = Math.min(durationMs(seconds), this.preBufferMs);
19313
+ const requested = Number.isFinite(seconds) ? Math.min(seconds * 1e3, this.preBufferMs) : 0;
18821
19314
  if (requested <= 0 || !this.ring.length)
18822
19315
  return [];
18823
19316
  const start = this.windowStart(Date.now() - requested);
@@ -19286,89 +19779,6 @@ function openReadableFromConsumer(consumer, opts = {}) {
19286
19779
  // dist/transport/p2p/talkback.js
19287
19780
  import { EventEmitter as EventEmitter5 } from "node:events";
19288
19781
  import { Writable } from "node:stream";
19289
-
19290
- // dist/transport/p2p/adts.js
19291
- var PROFILE_AAC_LC = 1;
19292
- var FREQ_INDEX_16K = 8;
19293
- var CHANNELS_MONO = 1;
19294
- var AAC_SAMPLES_PER_FRAME = 1024;
19295
- var AAC_SAMPLE_RATE = 16e3;
19296
- var AAC_FRAME_MS = AAC_SAMPLES_PER_FRAME * 1e3 / AAC_SAMPLE_RATE;
19297
- var MAX_AUDIO_FRAME_BYTES = 640;
19298
- var HEADER_LEN_NO_CRC = 7;
19299
- function parseAdtsHeader(buf, offset = 0) {
19300
- if (offset + HEADER_LEN_NO_CRC > buf.length)
19301
- return void 0;
19302
- if (buf[offset] !== 255 || (buf[offset + 1] & 240) !== 240)
19303
- return void 0;
19304
- const headerLength = buf[offset + 1] & 1 ? 7 : 9;
19305
- const frameLength = (buf[offset + 3] & 3) << 11 | buf[offset + 4] << 3 | (buf[offset + 5] & 224) >> 5;
19306
- if (frameLength < headerLength)
19307
- return void 0;
19308
- return {
19309
- frameLength,
19310
- headerLength,
19311
- profile: (buf[offset + 2] & 192) >> 6,
19312
- frequencyIndex: (buf[offset + 2] & 60) >> 2,
19313
- channels: (buf[offset + 2] & 1) << 2 | (buf[offset + 3] & 192) >> 6
19314
- };
19315
- }
19316
- function isSupportedAdts(h) {
19317
- return h.profile === PROFILE_AAC_LC && h.frequencyIndex === FREQ_INDEX_16K && h.channels === CHANNELS_MONO;
19318
- }
19319
- function describeAdts(h) {
19320
- const rate = ADTS_SAMPLE_RATES[h.frequencyIndex] ?? `index ${h.frequencyIndex}`;
19321
- const profile = h.profile === PROFILE_AAC_LC ? "AAC-LC" : `profile field ${h.profile}`;
19322
- return `${profile}, ${rate} Hz, ${h.channels} channel(s)`;
19323
- }
19324
- var ADTS_SAMPLE_RATES = [
19325
- 96e3,
19326
- 88200,
19327
- 64e3,
19328
- 48e3,
19329
- 44100,
19330
- 32e3,
19331
- 24e3,
19332
- 22050,
19333
- 16e3,
19334
- 12e3,
19335
- 11025,
19336
- 8e3,
19337
- 7350
19338
- ];
19339
- var AdtsFrameReader = class {
19340
- buffered = Buffer.alloc(0);
19341
- /**
19342
- * Append `chunk` and return every complete frame now available, each a standalone buffer that still
19343
- * carries its own ADTS header (the device expects the header on the wire).
19344
- */
19345
- push(chunk) {
19346
- this.buffered = this.buffered.length ? Buffer.concat([this.buffered, chunk]) : chunk;
19347
- const frames = [];
19348
- let at = 0;
19349
- for (; ; ) {
19350
- const h = parseAdtsHeader(this.buffered, at);
19351
- if (!h) {
19352
- if (at + HEADER_LEN_NO_CRC > this.buffered.length)
19353
- break;
19354
- at++;
19355
- continue;
19356
- }
19357
- if (at + h.frameLength > this.buffered.length)
19358
- break;
19359
- frames.push(this.buffered.subarray(at, at + h.frameLength));
19360
- at += h.frameLength;
19361
- }
19362
- this.buffered = at ? this.buffered.subarray(at) : this.buffered;
19363
- return frames;
19364
- }
19365
- /** Bytes held back awaiting the rest of their frame — non-zero mid-stream, zero on a clean boundary. */
19366
- get pending() {
19367
- return this.buffered.length;
19368
- }
19369
- };
19370
-
19371
- // dist/transport/p2p/talkback.js
19372
19782
  var DEFAULT_HIGH_WATER_FRAMES = Math.ceil(2e3 / AAC_FRAME_MS);
19373
19783
  var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
19374
19784
  var Talkback = class extends EventEmitter5 {
@@ -20344,6 +20754,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
20344
20754
  talkbacks = /* @__PURE__ */ new Map();
20345
20755
  /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
20346
20756
  cipherKeyCache = /* @__PURE__ */ new Map();
20757
+ /** The recording download in flight per station; a station serves one at a time. */
20758
+ recordingDownloads = /* @__PURE__ */ new Map();
20347
20759
  constructor(deps) {
20348
20760
  this.deps = deps;
20349
20761
  this.manager = new SessionManager({
@@ -20556,28 +20968,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
20556
20968
  localAddress,
20557
20969
  dskKey,
20558
20970
  noBroadcast: this.deps.noBroadcast,
20559
- resolveCipherKey: async (cipherId) => {
20560
- if (this.cipherKeyCache.has(cipherId))
20561
- return this.cipherKeyCache.get(cipherId);
20562
- let ecc;
20563
- try {
20564
- const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
20565
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
20566
- if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
20567
- ecc = ciphers[0].ecc_private_key;
20568
- this.traceOnStation(session, {
20569
- phase: "cipher-fallback",
20570
- cipherId,
20571
- answeredCipherId: Number(ciphers[0].cipher_id)
20572
- });
20573
- }
20574
- } catch (e) {
20575
- this.deps.onError(e instanceof Error ? e : new Error(String(e)));
20576
- }
20577
- if (ecc !== void 0)
20578
- this.cipherKeyCache.set(cipherId, ecc);
20579
- return ecc;
20580
- },
20971
+ resolveCipherKey: (cipherId) => this.cipherKeyFor(session, cipherId, adminUserId, stationSn),
20581
20972
  logger: this.deps.logger ?? noopLogger
20582
20973
  });
20583
20974
  session.on("error", (e) => this.reportError(e));
@@ -20601,6 +20992,33 @@ var P2PCommandRouter = class _P2PCommandRouter {
20601
20992
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
20602
20993
  return session;
20603
20994
  }
20995
+ /**
20996
+ * The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
20997
+ * answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
20998
+ * cached.
20999
+ */
21000
+ async cipherKeyFor(session, cipherId, userId, stationSn) {
21001
+ if (this.cipherKeyCache.has(cipherId))
21002
+ return this.cipherKeyCache.get(cipherId);
21003
+ let ecc;
21004
+ try {
21005
+ const ciphers = await this.deps.mega.getCiphers([cipherId], userId, stationSn);
21006
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
21007
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
21008
+ ecc = ciphers[0].ecc_private_key;
21009
+ this.traceOnStation(session, {
21010
+ phase: "cipher-fallback",
21011
+ cipherId,
21012
+ answeredCipherId: Number(ciphers[0].cipher_id)
21013
+ });
21014
+ }
21015
+ } catch (e) {
21016
+ this.deps.onError(e instanceof Error ? e : new Error(String(e)));
21017
+ }
21018
+ if (ecc !== void 0)
21019
+ this.cipherKeyCache.set(cipherId, ecc);
21020
+ return ecc;
21021
+ }
20604
21022
  /**
20605
21023
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
20606
21024
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -20728,7 +21146,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
20728
21146
  * start has no level-1 form for.
20729
21147
  */
20730
21148
  mediaProviderFor(sn) {
21149
+ const recordings = this.downloadsRecordings(sn) ? { downloadRecording: (opts) => this.downloadRecording(sn, opts) } : {};
20731
21150
  return {
21151
+ ...recordings,
20732
21152
  snapshotLive: async (opts) => {
20733
21153
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20734
21154
  return captureSnapshotFromShared(source, {
@@ -20767,6 +21187,63 @@ var P2PCommandRouter = class _P2PCommandRouter {
20767
21187
  }
20768
21188
  };
20769
21189
  }
21190
+ /**
21191
+ * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
21192
+ * and frame formats are confirmed.
21193
+ */
21194
+ downloadsRecordings(sn) {
21195
+ const dev = this.recordFor(sn);
21196
+ if (!dev)
21197
+ return false;
21198
+ const parentSn = stationOf(dev);
21199
+ return parentSn !== sn && this.recordFor(parentSn)?.model === "T8010";
21200
+ }
21201
+ /**
21202
+ * Download one recording a HomeBase 2 holds for an attached camera, then decode it
21203
+ * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
21204
+ * on the camera's channel, so a download starts only once the previous one has drained
21205
+ * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
21206
+ * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
21207
+ * turn is held until the transfer drains.
21208
+ */
21209
+ async downloadRecording(sn, opts) {
21210
+ opts.signal?.throwIfAborted();
21211
+ const parentSn = this.stationKeyOf(sn);
21212
+ const previous = this.recordingDownloads.get(parentSn) ?? Promise.resolve();
21213
+ const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, opts));
21214
+ const turn = Promise.allSettled([previous, started.then((t) => t.drained)]).then(() => {
21215
+ if (this.recordingDownloads.get(parentSn) === turn)
21216
+ this.recordingDownloads.delete(parentSn);
21217
+ });
21218
+ this.recordingDownloads.set(parentSn, turn);
21219
+ return started.then((t) => t.recording);
21220
+ }
21221
+ /** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
21222
+ waitTurn(previous, signal) {
21223
+ return abortable(previous.then(() => void 0, () => void 0), signal);
21224
+ }
21225
+ /**
21226
+ * Start one recording download, once the station's previous download has drained: the decoded recording,
21227
+ * and when the station is done sending it.
21228
+ */
21229
+ async startRecordingDownload(sn, opts) {
21230
+ const { session, parentSn, channel, accountId } = await this.resolveSession(sn, { signal: opts.signal });
21231
+ const path = homeBase2RecordingPath(channel, opts.recording);
21232
+ if (!path)
21233
+ throw new RecordingDownloadError("invalid-recording", `no recording named ${opts.recording}`);
21234
+ const eccKey = await this.cipherKeyFor(session, opts.cipherId, accountId, parentSn);
21235
+ if (!eccKey) {
21236
+ throw new RecordingDownloadError("key-unavailable", `cipher ${opts.cipherId} could not be obtained`);
21237
+ }
21238
+ const transfer = receiveRecording(session, {
21239
+ path,
21240
+ accountId,
21241
+ channel,
21242
+ timeoutMs: opts.timeoutMs,
21243
+ signal: opts.signal
21244
+ });
21245
+ return { recording: transfer.frames.then((frames) => decodeRecording(frames, eccKey)), drained: transfer.drained };
21246
+ }
20770
21247
  /**
20771
21248
  * Open a {@link Talkback} on a device's camera channel.
20772
21249
  *
@@ -24292,6 +24769,7 @@ var MEDIA_FAILURE_REASONS = [
24292
24769
  // dist/transport/stored-image-cache.js
24293
24770
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
24294
24771
  var MAX_REMEMBERED_URLS = 64;
24772
+ var NOT_YET_PUBLISHED_RETRY_MS = [2e3, 4e3];
24295
24773
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
24296
24774
  var StoredImageCache = class {
24297
24775
  downloader;
@@ -24329,6 +24807,8 @@ var StoredImageCache = class {
24329
24807
  if (state.seenUrls.has(url))
24330
24808
  return;
24331
24809
  state.seenUrls.add(url);
24810
+ clearTimeout(state.retrying);
24811
+ state.retrying = void 0;
24332
24812
  while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
24333
24813
  const oldest = state.seenUrls.values().next();
24334
24814
  if (oldest.done)
@@ -24340,14 +24820,15 @@ var StoredImageCache = class {
24340
24820
  url,
24341
24821
  sequence: ++state.nextSequence,
24342
24822
  observedAt: this.clock(),
24343
- generation: this.generation
24823
+ generation: this.generation,
24824
+ retries: 0
24344
24825
  };
24345
24826
  this.pump();
24346
24827
  }
24347
24828
  /** Return retained bytes without starting or awaiting network work. */
24348
24829
  snapshotStored(deviceKey) {
24349
24830
  const state = this.devices.get(deviceKey);
24350
- const reason = state?.queued || state?.inFlight ? "pending" : state?.reason ?? "not-observed";
24831
+ const reason = state?.queued || state?.inFlight || state?.retrying ? "pending" : state?.reason ?? "not-observed";
24351
24832
  if (reason !== "pending" && state?.lifecycleError)
24352
24833
  return Promise.reject(state.lifecycleError);
24353
24834
  if (state?.retained)
@@ -24381,6 +24862,8 @@ var StoredImageCache = class {
24381
24862
  if (error !== void 0 && this.isLifecycleError(error)) {
24382
24863
  state.lifecycleError = error;
24383
24864
  state.reason = void 0;
24865
+ } else if (image === void 0 && this.retryLater(state, candidate, error)) {
24866
+ state.lifecycleError = void 0;
24384
24867
  } else if (image === void 0) {
24385
24868
  state.lifecycleError = void 0;
24386
24869
  state.reason = "download-failed";
@@ -24397,6 +24880,21 @@ var StoredImageCache = class {
24397
24880
  }
24398
24881
  this.pump();
24399
24882
  }
24883
+ /** Schedule the next attempt after a 404; answers whether one was scheduled. */
24884
+ retryLater(state, candidate, error) {
24885
+ const delay = NOT_YET_PUBLISHED_RETRY_MS[candidate.retries];
24886
+ const tag2 = mediaFailureTag(error);
24887
+ if (delay === void 0 || tag2.cause !== "http-status" || tag2.status !== 404 || state.queued)
24888
+ return false;
24889
+ const timer = setTimeout(() => {
24890
+ state.retrying = void 0;
24891
+ state.queued = { ...candidate, retries: candidate.retries + 1 };
24892
+ this.pump();
24893
+ }, delay);
24894
+ timer.unref?.();
24895
+ state.retrying = timer;
24896
+ return true;
24897
+ }
24400
24898
  isValidJpeg(image) {
24401
24899
  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;
24402
24900
  }
@@ -24992,6 +25490,11 @@ function recordString(raw, key) {
24992
25490
  const v = typeof nested === "string" && nested ? nested : raw[key];
24993
25491
  return typeof v === "string" && v ? v : void 0;
24994
25492
  }
25493
+ function adminUserIdFrom(raw) {
25494
+ const member = (raw ?? {}).member;
25495
+ const id = member?.admin_user_id;
25496
+ return typeof id === "string" && id ? id : void 0;
25497
+ }
24995
25498
  function tuyaDevIdFrom(raw) {
24996
25499
  for (const field of ["tuya_uuid", "tuya_virtual_id", "tuya_device_id", "virtualId"]) {
24997
25500
  const v = recordString(raw, field);
@@ -25149,13 +25652,7 @@ var EufyMega = class extends EventEmitter9 {
25149
25652
  onError: (e) => this.reportError(e),
25150
25653
  // The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id`,
25151
25654
  // falling back to the logged-in account's user id (the confirmed script path does the same).
25152
- resolveAccountId: (dev) => {
25153
- const member = (dev.raw ?? {}).member;
25154
- const adminId = member?.admin_user_id;
25155
- if (typeof adminId === "string" && adminId)
25156
- return adminId;
25157
- return this.mega.auth?.userId ?? "";
25158
- },
25655
+ resolveAccountId: (dev) => adminUserIdFrom(dev.raw) ?? this.mega.auth?.userId ?? "",
25159
25656
  // Fetch + parse a gallery effect from the HTTP catalogue into the serializable spec. The catalogue
25160
25657
  // lives in the http layer (transport/http/light-catalog); the mqtt router owns the frame bytes.
25161
25658
  resolvePreset: (presetId) => resolveLightEffect(this.mega, presetId),
@@ -26602,6 +27099,37 @@ var EufyMega = class extends EventEmitter9 {
26602
27099
  getP2pSessions() {
26603
27100
  return this.p2p.getSessions();
26604
27101
  }
27102
+ /**
27103
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
27104
+ *
27105
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
27106
+ * station, because they were never uploaded — the station holds them.
27107
+ *
27108
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
27109
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
27110
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
27111
+ *
27112
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
27113
+ * returned as they arrive; a caller that wants only named people filters them.
27114
+ */
27115
+ async getStationFaces(stationSn, opts = {}) {
27116
+ if (!this.registry.list().length)
27117
+ await this.getDevices();
27118
+ const station = this.p2p.stationKeyOf(stationSn);
27119
+ const accountId = this.adminUserIdOf(station);
27120
+ if (!accountId)
27121
+ throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
27122
+ await this.p2p.ensureStation(station, opts.signal);
27123
+ const session = this.p2p.getSessions().get(station);
27124
+ if (!session)
27125
+ throw new Error(`getStationFaces: no P2P session for ${station}`);
27126
+ const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
27127
+ return rows.filter((row) => typeof row === "object" && row !== null);
27128
+ }
27129
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
27130
+ adminUserIdOf(stationSn) {
27131
+ return adminUserIdFrom(this.registry.list().find((d) => d.sn === stationSn)?.raw);
27132
+ }
26605
27133
  /**
26606
27134
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
26607
27135
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.
@@ -26839,7 +27367,7 @@ var EufyMega = class extends EventEmitter9 {
26839
27367
  if (ev.eventName === void 0 && ev.eventType != null)
26840
27368
  ev.eventName = detectionName(ev.eventType);
26841
27369
  if (ev.thumbnailCandidate)
26842
- void this.observeStoredImage(ev.thumbnailCandidate);
27370
+ void this.observeStoredImage(ev.thumbnailCandidate, ev.deviceSn);
26843
27371
  this.emit("push", ev);
26844
27372
  const signal2 = {
26845
27373
  source: "push",
@@ -26888,9 +27416,16 @@ var EufyMega = class extends EventEmitter9 {
26888
27416
  client.on("error", (e) => this.reportError(e));
26889
27417
  return client;
26890
27418
  }
26891
- /** Admit only exact, account-known devices with resolved snapshot evidence into the passive store. */
26892
- async observeStoredImage(candidate) {
26893
- if (!this.storedImages || candidate.attribution.kind !== "device")
27419
+ /**
27420
+ * Admit only exact, account-known devices with resolved snapshot evidence into the passive store; a
27421
+ * station-only candidate is admitted under the event's device serial.
27422
+ */
27423
+ async observeStoredImage(candidate, eventDeviceSn) {
27424
+ if (!this.storedImages)
27425
+ return;
27426
+ const { attribution } = candidate;
27427
+ const deviceSn = attribution.kind === "device" ? attribution.deviceSn : attribution.kind === "station" ? eventDeviceSn : void 0;
27428
+ if (!deviceSn)
26894
27429
  return;
26895
27430
  const account = this.mega.auth?.userId;
26896
27431
  if (!account)
@@ -26900,9 +27435,9 @@ var EufyMega = class extends EventEmitter9 {
26900
27435
  await this.registry.getDevices();
26901
27436
  if (this.mega.auth?.userId !== account)
26902
27437
  return;
26903
- const caps = this.registry.capabilitiesForDevice(candidate.attribution.deviceSn);
27438
+ const caps = this.registry.capabilitiesForDevice(deviceSn);
26904
27439
  if (caps && hasProvidedAction(caps, "snapshotStored")) {
26905
- this.storedImages.observe(candidate.attribution.deviceSn, candidate.url);
27440
+ this.storedImages.observe(deviceSn, candidate.url);
26906
27441
  }
26907
27442
  } catch (e) {
26908
27443
  this.reportError(e);
@@ -27127,26 +27662,16 @@ var SolixClient = class _SolixClient {
27127
27662
  }
27128
27663
  /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
27129
27664
  async postLogin(kx, verifyCode, limitedToken) {
27130
- const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
27131
27665
  const bodyObj = {
27132
- email: this.email,
27133
- password: encryptedPassword,
27134
- ab: this.country,
27135
- client_secret_info: { public_key: clientPublicKeyHex },
27666
+ ...loginCredentials(this.email, this.password, this.country),
27136
27667
  answer: "",
27137
27668
  captcha_id: "",
27138
27669
  verify_code: verifyCode ?? "",
27139
27670
  login_id: ""
27140
27671
  };
27141
27672
  const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
27142
- const ts = nowSec();
27143
- const once = genId();
27144
27673
  return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
27145
- "x-encryption-info": "algo_ecdh",
27146
- "x-key-ident": kx.keyIdent,
27147
- "x-request-ts": ts,
27148
- "x-request-once": once,
27149
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27674
+ ...signedHeaders(kx, encBody),
27150
27675
  ...limitedToken ? { "x-auth-token": limitedToken } : {}
27151
27676
  }));
27152
27677
  }
@@ -27161,13 +27686,12 @@ var SolixClient = class _SolixClient {
27161
27686
  * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
27162
27687
  */
27163
27688
  classifyLogin(data, isVerify) {
27164
- const userId = data.ap_cloud_user_id ?? data.user_id;
27165
- const authToken = data.auth_token;
27166
- if (!userId || !authToken)
27689
+ const reply = readLoginReply(data);
27690
+ if (!reply)
27167
27691
  throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
27168
- const faInfo = data.fa_info ?? {};
27169
- if (!isVerify && faInfo.info) {
27170
- this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
27692
+ const { userId, authToken } = reply;
27693
+ if (!isVerify && reply.twoFactorPending) {
27694
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: reply.geoKey };
27171
27695
  return { status: "2fa", method: "code sent by the passport" };
27172
27696
  }
27173
27697
  this.pending2fa = void 0;
@@ -27176,7 +27700,7 @@ var SolixClient = class _SolixClient {
27176
27700
  userId,
27177
27701
  gtoken: gtoken(userId),
27178
27702
  apiHost: this.apiHost,
27179
- tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
27703
+ tokenExpiresAt: reply.tokenExpiresAt
27180
27704
  };
27181
27705
  this.persist();
27182
27706
  return { status: "ok", session: this.session_ };
@@ -27331,14 +27855,8 @@ var SolixClient = class _SolixClient {
27331
27855
  throw new Error("not authenticated \u2014 call login() first");
27332
27856
  const kx = await this.keyExchange();
27333
27857
  const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
27334
- const ts = nowSec();
27335
- const once = genId();
27336
27858
  const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
27337
- "x-encryption-info": "algo_ecdh",
27338
- "x-key-ident": kx.keyIdent,
27339
- "x-request-ts": ts,
27340
- "x-request-once": once,
27341
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27859
+ ...signedHeaders(kx, encBody),
27342
27860
  "x-auth-token": this.session_.authToken,
27343
27861
  gtoken: this.session_.gtoken
27344
27862
  }));
@@ -28106,6 +28624,7 @@ export {
28106
28624
  ROOM_SUCTIONS,
28107
28625
  RTSP_MEMBERS,
28108
28626
  RawDpWriter,
28627
+ RecordingDownloadError,
28109
28628
  RecordingQuality,
28110
28629
  RtspRecordingMode,
28111
28630
  SCENE_INVALID_REASONS,
@@ -28149,6 +28668,7 @@ export {
28149
28668
  VACUUM_DOCK_MEMBERS,
28150
28669
  VACUUM_SCHEDULE_ACTIONS,
28151
28670
  VACUUM_SCHEDULE_WEEKDAYS,
28671
+ VIDEO_GCM_AAD,
28152
28672
  VacuumMapStore,
28153
28673
  VideoFrameDecoder,
28154
28674
  WORKING_MODE_MAPS,