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

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
  /**
@@ -2283,134 +2690,25 @@ var SecureMqtt = class extends EventEmitter {
2283
2690
  * don't share subscribe/publish routing).
2284
2691
  */
2285
2692
  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;
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
  /**
@@ -7526,120 +7716,41 @@ var ARMING_MEMBERS = {
7526
7716
  var ARMING = {
7527
7717
  capability: "arming",
7528
7718
  description: "Station/device guard (arming) mode.",
7529
- members: ARMING_MEMBERS,
7530
- properties: propertiesOf(ARMING_MEMBERS),
7531
- // A reported guard-mode param is the verified proof; every station-codec device also owns an
7532
- // arming surface as part of its baseline.
7533
- detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7534
- ownedByStation: true,
7535
- /**
7536
- * Station-scoped push events. The hub reports a guard-mode switch and the alarm lifecycle on the
7537
- * generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
7538
- * capability (see the barrel's event index).
7539
- *
7540
- * The alarm ids carry a static `phase` so one event name covers the whole lifecycle, the same shape
7541
- * `battery` uses for its threshold pushes. The mode-switch push is known to identify WHICH mode and
7542
- * what triggered the change, but neither has been observed on the wire here, so this emits the bare
7543
- * transition and refreshes the `mode` read rather than trusting a decoded field.
7544
- */
7545
- events: [
7546
- {
7547
- source: "push",
7548
- match: CusPushEvent.MODE_SWITCH,
7549
- emit: "armingModeChanged",
7550
- refresh: { member: "mode" }
7551
- },
7552
- { source: "push", match: CusPushEvent.ALARM, emit: "alarm", payload: { phase: "triggered" } },
7553
- { source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
7554
- ]
7555
- };
7556
-
7557
- // dist/model/capabilities/storage.js
7558
- var STORAGE = {
7559
- capability: "storage",
7560
- description: "Local storage (SD card / eMMC / HDD). Behaviour-only until a capacity param is captured.",
7561
- properties: [],
7562
- detection: { codecs: ["station"] }
7563
- };
7564
-
7565
- // dist/core/raw-dp-writer.js
7566
- var WIRE = { VARINT: 0, LENGTH_DELIMITED: 2 };
7567
- function encodeVarint(value) {
7568
- if (!Number.isSafeInteger(value) || value < 0) {
7569
- throw new Error(`varint: ${value} is not a non-negative safe integer`);
7570
- }
7571
- const out = [];
7572
- let v = value;
7573
- while (v > 127) {
7574
- out.push(v % 128 + 128);
7575
- v = Math.floor(v / 128);
7576
- }
7577
- out.push(v);
7578
- return out;
7579
- }
7580
- function zigzag(value) {
7581
- if (!Number.isSafeInteger(value))
7582
- throw new Error(`zigzag: ${value} is not a safe integer`);
7583
- return value < 0 ? -2 * value - 1 : 2 * value;
7584
- }
7585
- var RawDpWriter = class _RawDpWriter {
7586
- body = [];
7587
- /** A varint-valued field (`int32`, `uint32`, `bool`, an enum member). */
7588
- int(field, value) {
7589
- this.body.push(...encodeVarint(tag(field, WIRE.VARINT)), ...encodeVarint(value));
7590
- return this;
7591
- }
7592
- /** A signed varint field (`sint32`, `sint64`), ZigZag-encoded — see {@link zigzag}. */
7593
- sint(field, value) {
7594
- return this.int(field, zigzag(value));
7595
- }
7596
- /** A boolean field, on protobuf's own terms: `1` or `0` in a varint. */
7597
- bool(field, value) {
7598
- return this.int(field, value ? 1 : 0);
7599
- }
7600
- /** A length-delimited field carrying raw bytes — a string, a blob, or a message built elsewhere. */
7601
- bytes(field, value) {
7602
- const bytes2 = value instanceof Uint8Array ? Array.from(value) : [...value];
7603
- this.body.push(...encodeVarint(tag(field, WIRE.LENGTH_DELIMITED)), ...encodeVarint(bytes2.length), ...bytes2);
7604
- return this;
7605
- }
7606
- /**
7607
- * A nested sub-message, built by `build` into a writer of its own.
7608
- *
7609
- * The sub-message is emitted even when `build` writes nothing into it. That is not an oversight: an
7610
- * EMPTY sub-message is how these protocols say "this subsystem exists and is in its zero state", as
7611
- * opposed to saying nothing about it, and the readers on the other side of this file depend on the
7612
- * distinction. Omitting the call is what says nothing about it.
7613
- */
7614
- sub(field, build) {
7615
- const inner = new _RawDpWriter();
7616
- build(inner);
7617
- return this.bytes(field, inner.toBytes());
7618
- }
7619
- /** The message body on its own, unframed — for nesting into another writer. */
7620
- toBytes() {
7621
- return [...this.body];
7622
- }
7719
+ members: ARMING_MEMBERS,
7720
+ properties: propertiesOf(ARMING_MEMBERS),
7721
+ // A reported guard-mode param is the verified proof; every station-codec device also owns an
7722
+ // arming surface as part of its baseline.
7723
+ detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7724
+ ownedByStation: true,
7623
7725
  /**
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;
@@ -15184,6 +15256,7 @@ var SEQUENCE_LOOKBACK = 32768;
15184
15256
  var STALE_RETRANSMIT_DEPTH = 1024;
15185
15257
  var PUNCH_PROBE_SOCKETS = 7;
15186
15258
  var REORDER_WAIT_MS = 250;
15259
+ var VIDEO_REORDER_WAIT_MS = 700;
15187
15260
  var REORDER_MAX_DATAGRAMS = 128;
15188
15261
  var MAX_TRACED_DATAGRAM_GAPS = 8;
15189
15262
  var CMD_SET_PAYLOAD = 1350;
@@ -15194,6 +15267,7 @@ var CMD_DATABASE_IMAGE = 1308;
15194
15267
  var CMD_DATABASE = 1306;
15195
15268
  var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
15196
15269
  var GCM_AAD = Buffer.from("eufy security");
15270
+ var LEVEL2_SEQ_BASE = 16909056;
15197
15271
  function paramReport(payload) {
15198
15272
  const list = payload?.params;
15199
15273
  if (!Array.isArray(list))
@@ -15208,12 +15282,57 @@ function paramReport(payload) {
15208
15282
  return Object.keys(out).length ? out : void 0;
15209
15283
  }
15210
15284
  var traceSequence = 0;
15285
+ var DB_TABLE_TIMEOUT_MS = 15e3;
15286
+ function firstTableRows(text2) {
15287
+ const decoded = Buffer.from(text2, "latin1").toString("utf8");
15288
+ for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
15289
+ const end = closingBrace(decoded, start);
15290
+ if (end < 0)
15291
+ return void 0;
15292
+ let parsed;
15293
+ try {
15294
+ parsed = JSON.parse(decoded.slice(start, end + 1));
15295
+ } catch {
15296
+ continue;
15297
+ }
15298
+ const data = parsed?.data;
15299
+ if (Array.isArray(data))
15300
+ return data;
15301
+ }
15302
+ return void 0;
15303
+ }
15304
+ function closingBrace(text2, start) {
15305
+ let depth = 0;
15306
+ let inString = false;
15307
+ let escaped = false;
15308
+ for (let i = start; i < text2.length; i++) {
15309
+ const c = text2[i];
15310
+ if (inString) {
15311
+ if (escaped)
15312
+ escaped = false;
15313
+ else if (c === "\\")
15314
+ escaped = true;
15315
+ else if (c === '"')
15316
+ inString = false;
15317
+ continue;
15318
+ }
15319
+ if (c === '"')
15320
+ inString = true;
15321
+ else if (c === "{")
15322
+ depth++;
15323
+ else if (c === "}" && --depth === 0)
15324
+ return i;
15325
+ }
15326
+ return -1;
15327
+ }
15211
15328
  var P2PSession = class _P2PSession extends EventEmitter2 {
15212
15329
  cfg;
15213
15330
  socket;
15214
15331
  connected = false;
15215
15332
  connecting = false;
15216
15333
  closed = false;
15334
+ /** Whether a short receive buffer has been reported, so a reconnect does not repeat the same warning. */
15335
+ receiveBufferReported = false;
15217
15336
  connectAddress;
15218
15337
  seqNumber = 0;
15219
15338
  /**
@@ -15240,8 +15359,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15240
15359
  audioStalled = false;
15241
15360
  audioRetransmitTimer;
15242
15361
  lastPongData;
15243
- /** When this connection last received a PONG — `undefined` until the first, see {@link pathSilentMs}. */
15244
- lastPongAt;
15362
+ /** When the selected peer last answered outbound P2P traffic on this connection. */
15363
+ lastPeerAt;
15245
15364
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
15246
15365
  pathStaleTraced = false;
15247
15366
  lookupTimer;
@@ -15310,17 +15429,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15310
15429
  /**
15311
15430
  * How long this connection's path has been silent, or nothing where it has never answered.
15312
15431
  *
15313
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
15314
- * that has said nothing either way.
15432
+ * PONG and ACK from the selected peer prove the path answers outbound traffic. `undefined` means no such reply
15433
+ * has arrived since connection, so silence alone does not establish a stale path.
15315
15434
  */
15316
15435
  get pathSilentMs() {
15317
- return this.lastPongAt === void 0 ? void 0 : Date.now() - this.lastPongAt;
15436
+ return this.lastPeerAt === void 0 ? void 0 : Date.now() - this.lastPeerAt;
15318
15437
  }
15319
15438
  /**
15320
15439
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
15321
15440
  *
15322
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
15323
- * not known to be dead, so it answers true.
15441
+ * False where selected-peer replies arrived and then stopped for {@link PATH_SILENCE_MS}. A connection with
15442
+ * no post-connect reply is not known to be dead, so it answers true.
15324
15443
  *
15325
15444
  * Traces the silence once per connection, on the read that first observes it.
15326
15445
  */
@@ -15536,6 +15655,26 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15536
15655
  get isConnected() {
15537
15656
  return this.connected;
15538
15657
  }
15658
+ /**
15659
+ * Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and warn once per session when it grants
15660
+ * less or refuses.
15661
+ *
15662
+ * The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
15663
+ * inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
15664
+ */
15665
+ requestReceiveBuffer(socket) {
15666
+ let granted;
15667
+ try {
15668
+ socket.setRecvBufferSize(RECEIVE_BUFFER_BYTES);
15669
+ granted = socket.getRecvBufferSize();
15670
+ } catch {
15671
+ granted = 0;
15672
+ }
15673
+ if (granted >= RECEIVE_BUFFER_BYTES || this.receiveBufferReported)
15674
+ return;
15675
+ this.receiveBufferReported = true;
15676
+ 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}.`);
15677
+ }
15539
15678
  /** Open the socket and start the lookup → hole-punch handshake. */
15540
15679
  async connect() {
15541
15680
  if (this.connecting || this.connected)
@@ -15559,6 +15698,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15559
15698
  socket.setBroadcast(true);
15560
15699
  } catch {
15561
15700
  }
15701
+ this.requestReceiveBuffer(socket);
15562
15702
  resolve();
15563
15703
  });
15564
15704
  });
@@ -15575,7 +15715,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15575
15715
  this.trace({
15576
15716
  phase: "lookup-channels",
15577
15717
  local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15578
- cloud: this.cloudLookup !== void 0
15718
+ cloud: this.cloudLookup !== void 0,
15719
+ ...this.cloudLookup ? {} : { cloudMissing: dskKey ? "cloud-addresses" : "dsk-key" }
15579
15720
  });
15580
15721
  this.sendLookups();
15581
15722
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
@@ -15677,10 +15818,18 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15677
15818
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
15678
15819
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
15679
15820
  * as a cause.
15821
+ *
15822
+ * CAM_ADDR is recognised and dropped: it names the address the device is already answering from, and the
15823
+ * CAM_ID that follows it is what completes the connect.
15680
15824
  */
15681
15825
  onMessage(msg, rinfo, socket = this.socket) {
15682
15826
  if (!socket)
15683
15827
  return;
15828
+ const fromConnectedPeer = this.connected && this.connectAddress?.host === rinfo.address && this.connectAddress.port === rinfo.port;
15829
+ if (fromConnectedPeer && (hasHeader(msg, ResponseMessageType.PONG) || hasHeader(msg, ResponseMessageType.ACK))) {
15830
+ this.lastPeerAt = Date.now();
15831
+ this.pathStaleTraced = false;
15832
+ }
15684
15833
  if (!hasHeader(msg, ResponseMessageType.DATA)) {
15685
15834
  this.logger.debug(`[p2p] ${this.cfg.stationSn} <<< ${rinfo.address}:${rinfo.port} header=${msg.subarray(0, 2).toString("hex")} len=${msg.length}`);
15686
15835
  }
@@ -15698,8 +15847,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15698
15847
  this.onConnected({ host: rinfo.address, port: rinfo.port }, socket);
15699
15848
  } else if (hasHeader(msg, ResponseMessageType.PONG)) {
15700
15849
  this.lastPongData = msg.length > 4 ? msg.subarray(4) : void 0;
15701
- this.lastPongAt = Date.now();
15702
- this.pathStaleTraced = false;
15703
15850
  } else if (hasHeader(msg, ResponseMessageType.PING)) {
15704
15851
  this.send({ host: rinfo.address, port: rinfo.port }, RequestMessageType.PONG, void 0, socket);
15705
15852
  } else if (hasHeader(msg, ResponseMessageType.ACK)) {
@@ -15707,7 +15854,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15707
15854
  } else if (hasHeader(msg, ResponseMessageType.DATA)) {
15708
15855
  if (this.connected)
15709
15856
  this.onData(msg, { host: rinfo.address, port: rinfo.port });
15710
- } else {
15857
+ } else if (!hasHeader(msg, ResponseMessageType.CAM_ADDR)) {
15711
15858
  this.logger.debug(`[p2p] ${this.cfg.stationSn} UNHANDLED payload hex: ${msg.toString("hex")}`);
15712
15859
  }
15713
15860
  }
@@ -15998,6 +16145,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15998
16145
  this.seqNumber = this.seqNumber + 1 & 65535;
15999
16146
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16000
16147
  }
16148
+ /** Send a level-1 {@link buildStringPairCommandPayload} command on `channel`. */
16149
+ sendStringPairCommand(commandType, strValue, strValueSub, channel) {
16150
+ if (!this.connectAddress)
16151
+ throw new Error(`P2P session ${this.cfg.stationSn} is not connected`);
16152
+ const data = Buffer.concat([
16153
+ buildCommandHeader(this.seqNumber, commandType),
16154
+ buildStringPairCommandPayload(strValue, strValueSub, channel, this.level1Key, 1)
16155
+ ]);
16156
+ this.seqNumber = this.seqNumber + 1 & 65535;
16157
+ this.send(this.connectAddress, RequestMessageType.DATA, data);
16158
+ }
16001
16159
  /**
16002
16160
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
16003
16161
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -16275,11 +16433,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16275
16433
  this.seqNumber = this.seqNumber + 1 & 65535;
16276
16434
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16277
16435
  }
16278
- /**
16279
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
16280
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
16281
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
16282
- */
16283
16436
  /**
16284
16437
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
16285
16438
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -16346,6 +16499,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16346
16499
  }
16347
16500
  return this.rsaModulusHex;
16348
16501
  }
16502
+ /**
16503
+ * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ seq(4) ‖ ciphertext`,
16504
+ * AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of `decryptLevel2`.
16505
+ * The 4-byte sub-header is cleartext (skipped on decrypt); `seq` counts up from {@link LEVEL2_SEQ_BASE}.
16506
+ */
16349
16507
  encryptLevel2(plaintext) {
16350
16508
  if (!this.level2Key)
16351
16509
  return void 0;
@@ -16353,8 +16511,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16353
16511
  const c = createCipheriv3("aes-256-gcm", this.level2Key, nonce);
16354
16512
  c.setAAD(GCM_AAD);
16355
16513
  const ct = Buffer.concat([c.update(plaintext), c.final()]);
16356
- const sub2 = Buffer.from([this.level2Seq & 255, 3, 2, 1]);
16357
- this.level2Seq = this.level2Seq + 1 & 255;
16514
+ const sub2 = Buffer.alloc(4);
16515
+ sub2.writeUInt32LE(LEVEL2_SEQ_BASE + this.level2Seq++ >>> 0);
16358
16516
  return Buffer.concat([c.getAuthTag(), nonce, sub2, ct]);
16359
16517
  }
16360
16518
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
@@ -16400,8 +16558,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16400
16558
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
16401
16559
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
16402
16560
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
16561
+ *
16562
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
16563
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
16564
+ * one's buffer and answered as its rows.
16403
16565
  */
16404
16566
  queryDatabase(table, opts = {}) {
16567
+ if (this.dbReadInFlight)
16568
+ throw new Error(`queryDatabase: ${this.cfg.stationSn} is already reading a table`);
16405
16569
  if (!this.connectAddress)
16406
16570
  throw new Error("not connected");
16407
16571
  const channel = opts.channel ?? STATION_CHANNEL3;
@@ -16464,6 +16628,49 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16464
16628
  query: this.fullTableQuery()
16465
16629
  });
16466
16630
  }
16631
+ /**
16632
+ * Query one on-station table and answer its rows, once the reply is whole.
16633
+ *
16634
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
16635
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
16636
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16637
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16638
+ * complete reply.
16639
+ *
16640
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16641
+ * session throws, so a second reply cannot land in this buffer.
16642
+ */
16643
+ async readDatabase(table, opts = {}) {
16644
+ if (opts.signal?.aborted)
16645
+ throw new Error("readDatabase: aborted");
16646
+ this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16647
+ this.dbReadInFlight = true;
16648
+ try {
16649
+ return await new Promise((resolve, reject) => {
16650
+ let text2 = "";
16651
+ const onChunk = (chunk) => {
16652
+ text2 += chunk.text;
16653
+ const rows = firstTableRows(text2);
16654
+ if (rows)
16655
+ settle(() => resolve(rows));
16656
+ };
16657
+ const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16658
+ const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
16659
+ const settle = (finish) => {
16660
+ clearTimeout(timer);
16661
+ this.off("dbChunk", onChunk);
16662
+ opts.signal?.removeEventListener("abort", onAbort);
16663
+ finish();
16664
+ };
16665
+ this.on("dbChunk", onChunk);
16666
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
16667
+ });
16668
+ } finally {
16669
+ this.dbReadInFlight = false;
16670
+ }
16671
+ }
16672
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
16673
+ dbReadInFlight = false;
16467
16674
  /**
16468
16675
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
16469
16676
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -16531,7 +16738,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16531
16738
  * if the missing one arrives. A logical frame's payload spans datagrams that carry no header of their
16532
16739
  * own, so reassembling around a hole is impossible; waiting for it is what keeps the frame whole.
16533
16740
  *
16534
- * Only once {@link REORDER_WAIT_MS} passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
16741
+ * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
16535
16742
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
16536
16743
  */
16537
16744
  onData(msg, addr) {
@@ -16586,7 +16793,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16586
16793
  const timer = setTimeout(() => {
16587
16794
  reorder.timer = void 0;
16588
16795
  this.abandonHole(dataType);
16589
- }, REORDER_WAIT_MS);
16796
+ }, dataType === P2PDataType.VIDEO ? VIDEO_REORDER_WAIT_MS : REORDER_WAIT_MS);
16590
16797
  timer.unref?.();
16591
16798
  reorder.timer = timer;
16592
16799
  }
@@ -18176,11 +18383,195 @@ function annexbToJpeg(annexb, spawn2) {
18176
18383
  });
18177
18384
  }
18178
18385
 
18386
+ // dist/transport/p2p/recording-download.js
18387
+ import { createDecipheriv as createDecipheriv7 } from "node:crypto";
18388
+ var RECORDING_DATA_TYPE = 3;
18389
+ var VIDEO_HEADER_LEN2 = 22;
18390
+ var AUDIO_HEADER_LEN2 = 16;
18391
+ var AUDIO_NONCE_START = 32;
18392
+ var AUDIO_BODY_START = 44;
18393
+ var AUDIO_CODEC_AAC_LC = 0;
18394
+ var FINISH_CHANNEL = 0;
18395
+ var FIRST_FRAME_WAIT_MS = 2e4;
18396
+ var IDLE_END_MS = 15e3;
18397
+ var DEFAULT_TRANSFER_MS = 12e4;
18398
+ var MAX_TRANSFER_BYTES = 32 * 1024 * 1024;
18399
+ function homeBase2RecordingPath(channel, recording) {
18400
+ if (!/^\d{14}$/.test(recording) || !Number.isInteger(channel) || channel < 0 || channel > 99)
18401
+ return void 0;
18402
+ return `/media/mmcblk0p1/Camera${String(channel).padStart(2, "0")}/${recording}.dat`;
18403
+ }
18404
+ function receiveRecording(session, request) {
18405
+ let resolveFrames;
18406
+ let rejectFrames;
18407
+ const frames = new Promise((resolve, reject) => {
18408
+ resolveFrames = resolve;
18409
+ rejectFrames = reject;
18410
+ });
18411
+ let resolveDrained;
18412
+ const drained = new Promise((resolve) => resolveDrained = resolve);
18413
+ const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
18414
+ let collected = [];
18415
+ const started = Date.now();
18416
+ let lastFrameAt = started;
18417
+ let received = false;
18418
+ let bytes2 = 0;
18419
+ let draining;
18420
+ let delivered = false;
18421
+ let listening = true;
18422
+ const deliver = (error) => {
18423
+ if (delivered)
18424
+ return;
18425
+ delivered = true;
18426
+ request.signal?.removeEventListener("abort", onAbort);
18427
+ if (error)
18428
+ rejectFrames(error);
18429
+ else
18430
+ resolveFrames(collected);
18431
+ };
18432
+ const stop = () => {
18433
+ if (!listening)
18434
+ return;
18435
+ listening = false;
18436
+ clearInterval(tick);
18437
+ session.off("data", onData);
18438
+ resolveDrained();
18439
+ };
18440
+ const incomplete = (why) => deliver(new RecordingDownloadError("incomplete", `the recording transfer ${why} before its finish frame`));
18441
+ const drain = (why) => {
18442
+ draining ??= why;
18443
+ collected = [];
18444
+ };
18445
+ const onData = (frame) => {
18446
+ if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
18447
+ if (frame.channel !== request.channel && frame.channel !== FINISH_CHANNEL)
18448
+ return;
18449
+ if (draining)
18450
+ incomplete(draining);
18451
+ else
18452
+ deliver();
18453
+ return stop();
18454
+ }
18455
+ if (frame.channel !== request.channel)
18456
+ return;
18457
+ if (frame.commandId !== CommandType.CMD_VIDEO_FRAME && frame.commandId !== CommandType.CMD_AUDIO_FRAME)
18458
+ return;
18459
+ if (frame.dataType !== RECORDING_DATA_TYPE)
18460
+ return;
18461
+ received = true;
18462
+ lastFrameAt = Date.now();
18463
+ if (draining)
18464
+ return;
18465
+ collected.push({ commandId: frame.commandId, signCode: frame.signCode, raw: Buffer.from(frame.raw) });
18466
+ bytes2 += frame.raw.length;
18467
+ if (bytes2 >= MAX_TRANSFER_BYTES)
18468
+ drain(`reached ${MAX_TRANSFER_BYTES} bytes`);
18469
+ };
18470
+ const onAbort = () => {
18471
+ deliver(request.signal?.reason instanceof Error ? request.signal.reason : new Error("aborted"));
18472
+ drain("was aborted");
18473
+ };
18474
+ const tick = setInterval(() => {
18475
+ const now2 = Date.now();
18476
+ if (!received && now2 - started > FIRST_FRAME_WAIT_MS) {
18477
+ deliver(new RecordingDownloadError("no-data", `the station sent nothing within ${FIRST_FRAME_WAIT_MS} ms`));
18478
+ stop();
18479
+ } else if (received && now2 - lastFrameAt > IDLE_END_MS) {
18480
+ incomplete(draining ?? `went quiet for ${IDLE_END_MS} ms`);
18481
+ stop();
18482
+ } else if (now2 - started > 2 * limitMs && draining) {
18483
+ incomplete(draining);
18484
+ stop();
18485
+ } else if (now2 - started > limitMs) {
18486
+ drain(`ran past ${limitMs} ms`);
18487
+ }
18488
+ }, 250);
18489
+ if (request.signal?.aborted) {
18490
+ onAbort();
18491
+ stop();
18492
+ return { frames, drained };
18493
+ }
18494
+ request.signal?.addEventListener("abort", onAbort, { once: true });
18495
+ session.on("data", onData);
18496
+ try {
18497
+ session.sendStringPairCommand(CommandType.CMD_DOWNLOAD_VIDEO, request.path, request.accountId, request.channel);
18498
+ } catch (error) {
18499
+ deliver(error instanceof Error ? error : new Error(String(error)));
18500
+ stop();
18501
+ }
18502
+ return { frames, drained };
18503
+ }
18504
+ function openAudio(key, nonce, tag2, body) {
18505
+ try {
18506
+ const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
18507
+ decipher.setAAD(VIDEO_GCM_AAD);
18508
+ decipher.setAuthTag(tag2);
18509
+ return Buffer.concat([decipher.update(body), decipher.final()]);
18510
+ } catch {
18511
+ return void 0;
18512
+ }
18513
+ }
18514
+ function decodeRecording(frames, eccPrivateKeyHex) {
18515
+ const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
18516
+ if (eccPrivateKey.length !== 32)
18517
+ throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
18518
+ const decoder = new VideoFrameDecoder(eccPrivateKey);
18519
+ const video = [];
18520
+ const audio = [];
18521
+ const numbers = /* @__PURE__ */ new Set();
18522
+ let firstStamp;
18523
+ let lastStamp;
18524
+ for (const { commandId, raw } of frames) {
18525
+ if (commandId === CommandType.CMD_VIDEO_FRAME) {
18526
+ const header = parseVideoFrameHeader(raw);
18527
+ if (!header)
18528
+ continue;
18529
+ 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;
18530
+ if (!h264?.length)
18531
+ continue;
18532
+ video.push(h264);
18533
+ numbers.add(header.sequence);
18534
+ const stamp = raw.readUIntLE(14, 6);
18535
+ firstStamp ??= stamp;
18536
+ lastStamp = stamp;
18537
+ } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
18538
+ const mediaKey = decoder.currentMediaKey;
18539
+ if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
18540
+ continue;
18541
+ const len = raw.readUInt32LE(0);
18542
+ if (raw.length < AUDIO_BODY_START + len)
18543
+ continue;
18544
+ 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));
18545
+ if (aac?.length)
18546
+ audio.push(aac);
18547
+ }
18548
+ }
18549
+ if (!video.length)
18550
+ throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
18551
+ const ordered = [...numbers];
18552
+ let missingFrames = 0;
18553
+ for (let i = 1; i < ordered.length; i++) {
18554
+ const step = ordered[i] - ordered[i - 1] & 65535;
18555
+ if (step > 1 && step < 32768)
18556
+ missingFrames += step - 1;
18557
+ }
18558
+ const span = ordered.length > 1 ? ordered[ordered.length - 1] - ordered[0] & 65535 : 0;
18559
+ const durationMs = (lastStamp ?? 0) - (firstStamp ?? 0);
18560
+ return {
18561
+ video: Buffer.concat(video),
18562
+ ...audio.length ? { audio: Buffer.concat(audio) } : {},
18563
+ frames: ordered.length,
18564
+ missingFrames,
18565
+ durationMs,
18566
+ fps: durationMs > 0 && span > 0 ? Math.round(span * 1e5 / durationMs) / 100 : 0
18567
+ };
18568
+ }
18569
+
18179
18570
  // dist/transport/p2p/shared-live-source.js
18180
18571
  import { EventEmitter as EventEmitter4 } from "node:events";
18181
- function durationMs(seconds) {
18182
- const milliseconds = (seconds ?? 0) * 1e3;
18183
- return Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : 0;
18572
+ var MAX_TIMER_DELAY_MS = 2147483647;
18573
+ function timerMs(value, fallback) {
18574
+ return value !== void 0 && Number.isFinite(value) && value >= 1 && value <= MAX_TIMER_DELAY_MS ? value : fallback;
18184
18575
  }
18185
18576
  function sameConfig(a, b) {
18186
18577
  return b !== void 0 && a.codec === b.codec && a.width === b.width && a.height === b.height;
@@ -18433,14 +18824,14 @@ var SharedLiveSource = class {
18433
18824
  tag;
18434
18825
  constructor(opts) {
18435
18826
  this.opts = opts;
18436
- this.lingerMs = opts.lingerMs ?? 8e3;
18827
+ this.lingerMs = opts.lingerMs === 0 ? 1 : timerMs(opts.lingerMs, 8e3);
18437
18828
  this.maxQueue = opts.maxQueue ?? 900;
18438
- this.preBufferMs = durationMs(opts.preBufferSeconds);
18439
- this.warmRetryMs = opts.warmRetryMs ?? 2e3;
18440
- this.warmTimeoutMs = opts.warmTimeoutMs ?? 2e4;
18829
+ this.preBufferMs = timerMs((opts.preBufferSeconds ?? 0) * 1e3, 0);
18830
+ this.warmRetryMs = timerMs(opts.warmRetryMs, 2e3);
18831
+ this.warmTimeoutMs = timerMs(opts.warmTimeoutMs, 2e4);
18441
18832
  this.powered = opts.powered ?? "wired";
18442
- this.batteryBudgetMs = opts.batteryBudgetMs ?? 45e3;
18443
- this.budgetGraceMs = opts.budgetGraceMs ?? 1e4;
18833
+ this.batteryBudgetMs = timerMs(opts.batteryBudgetMs, 45e3);
18834
+ this.budgetGraceMs = timerMs(opts.budgetGraceMs, 1e4);
18444
18835
  this.logger = opts.logger ?? noopLogger;
18445
18836
  this.tag = opts.label ? `[live ${opts.label}]` : "[live]";
18446
18837
  this.traceId = `pull-${++pullSequence}`;
@@ -18688,7 +19079,7 @@ var SharedLiveSource = class {
18688
19079
  if (this.disposed || !this.stream)
18689
19080
  return;
18690
19081
  this.clearBudget();
18691
- this.budgetTimer.arm(ms ?? this.batteryBudgetMs, () => this.onBudgetExpire());
19082
+ this.budgetTimer.arm(timerMs(ms, this.batteryBudgetMs), () => this.onBudgetExpire());
18692
19083
  }
18693
19084
  /** Settle a reuse watch on any frame — the join already holds a decodable picture. */
18694
19085
  settleReuseWatch() {
@@ -18829,7 +19220,7 @@ var SharedLiveSource = class {
18829
19220
  return this.bufferedMedia(seconds).filter((item) => item.kind === "video").map((item) => item.frame);
18830
19221
  }
18831
19222
  bufferedMedia(seconds) {
18832
- const requested = Math.min(durationMs(seconds), this.preBufferMs);
19223
+ const requested = Number.isFinite(seconds) ? Math.min(seconds * 1e3, this.preBufferMs) : 0;
18833
19224
  if (requested <= 0 || !this.ring.length)
18834
19225
  return [];
18835
19226
  const start = this.windowStart(Date.now() - requested);
@@ -20356,6 +20747,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
20356
20747
  talkbacks = /* @__PURE__ */ new Map();
20357
20748
  /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
20358
20749
  cipherKeyCache = /* @__PURE__ */ new Map();
20750
+ /** The recording download in flight per station; a station serves one at a time. */
20751
+ recordingDownloads = /* @__PURE__ */ new Map();
20359
20752
  constructor(deps) {
20360
20753
  this.deps = deps;
20361
20754
  this.manager = new SessionManager({
@@ -20568,28 +20961,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
20568
20961
  localAddress,
20569
20962
  dskKey,
20570
20963
  noBroadcast: this.deps.noBroadcast,
20571
- resolveCipherKey: async (cipherId) => {
20572
- if (this.cipherKeyCache.has(cipherId))
20573
- return this.cipherKeyCache.get(cipherId);
20574
- let ecc;
20575
- try {
20576
- const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
20577
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
20578
- if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
20579
- ecc = ciphers[0].ecc_private_key;
20580
- this.traceOnStation(session, {
20581
- phase: "cipher-fallback",
20582
- cipherId,
20583
- answeredCipherId: Number(ciphers[0].cipher_id)
20584
- });
20585
- }
20586
- } catch (e) {
20587
- this.deps.onError(e instanceof Error ? e : new Error(String(e)));
20588
- }
20589
- if (ecc !== void 0)
20590
- this.cipherKeyCache.set(cipherId, ecc);
20591
- return ecc;
20592
- },
20964
+ resolveCipherKey: (cipherId) => this.cipherKeyFor(session, cipherId, adminUserId, stationSn),
20593
20965
  logger: this.deps.logger ?? noopLogger
20594
20966
  });
20595
20967
  session.on("error", (e) => this.reportError(e));
@@ -20613,6 +20985,33 @@ var P2PCommandRouter = class _P2PCommandRouter {
20613
20985
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
20614
20986
  return session;
20615
20987
  }
20988
+ /**
20989
+ * The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
20990
+ * answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
20991
+ * cached.
20992
+ */
20993
+ async cipherKeyFor(session, cipherId, userId, stationSn) {
20994
+ if (this.cipherKeyCache.has(cipherId))
20995
+ return this.cipherKeyCache.get(cipherId);
20996
+ let ecc;
20997
+ try {
20998
+ const ciphers = await this.deps.mega.getCiphers([cipherId], userId, stationSn);
20999
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
21000
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
21001
+ ecc = ciphers[0].ecc_private_key;
21002
+ this.traceOnStation(session, {
21003
+ phase: "cipher-fallback",
21004
+ cipherId,
21005
+ answeredCipherId: Number(ciphers[0].cipher_id)
21006
+ });
21007
+ }
21008
+ } catch (e) {
21009
+ this.deps.onError(e instanceof Error ? e : new Error(String(e)));
21010
+ }
21011
+ if (ecc !== void 0)
21012
+ this.cipherKeyCache.set(cipherId, ecc);
21013
+ return ecc;
21014
+ }
20616
21015
  /**
20617
21016
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
20618
21017
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -20740,7 +21139,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
20740
21139
  * start has no level-1 form for.
20741
21140
  */
20742
21141
  mediaProviderFor(sn) {
21142
+ const recordings = this.downloadsRecordings(sn) ? { downloadRecording: (opts) => this.downloadRecording(sn, opts) } : {};
20743
21143
  return {
21144
+ ...recordings,
20744
21145
  snapshotLive: async (opts) => {
20745
21146
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20746
21147
  return captureSnapshotFromShared(source, {
@@ -20779,6 +21180,63 @@ var P2PCommandRouter = class _P2PCommandRouter {
20779
21180
  }
20780
21181
  };
20781
21182
  }
21183
+ /**
21184
+ * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
21185
+ * and frame formats are confirmed.
21186
+ */
21187
+ downloadsRecordings(sn) {
21188
+ const dev = this.recordFor(sn);
21189
+ if (!dev)
21190
+ return false;
21191
+ const parentSn = stationOf(dev);
21192
+ return parentSn !== sn && this.recordFor(parentSn)?.model === "T8010";
21193
+ }
21194
+ /**
21195
+ * Download one recording a HomeBase 2 holds for an attached camera, then decode it
21196
+ * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
21197
+ * on the camera's channel, so a download starts only once the previous one has drained
21198
+ * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
21199
+ * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
21200
+ * turn is held until the transfer drains.
21201
+ */
21202
+ async downloadRecording(sn, opts) {
21203
+ opts.signal?.throwIfAborted();
21204
+ const parentSn = this.stationKeyOf(sn);
21205
+ const previous = this.recordingDownloads.get(parentSn) ?? Promise.resolve();
21206
+ const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, opts));
21207
+ const turn = Promise.allSettled([previous, started.then((t) => t.drained)]).then(() => {
21208
+ if (this.recordingDownloads.get(parentSn) === turn)
21209
+ this.recordingDownloads.delete(parentSn);
21210
+ });
21211
+ this.recordingDownloads.set(parentSn, turn);
21212
+ return started.then((t) => t.recording);
21213
+ }
21214
+ /** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
21215
+ waitTurn(previous, signal) {
21216
+ return abortable(previous.then(() => void 0, () => void 0), signal);
21217
+ }
21218
+ /**
21219
+ * Start one recording download, once the station's previous download has drained: the decoded recording,
21220
+ * and when the station is done sending it.
21221
+ */
21222
+ async startRecordingDownload(sn, opts) {
21223
+ const { session, parentSn, channel, accountId } = await this.resolveSession(sn, { signal: opts.signal });
21224
+ const path = homeBase2RecordingPath(channel, opts.recording);
21225
+ if (!path)
21226
+ throw new RecordingDownloadError("invalid-recording", `no recording named ${opts.recording}`);
21227
+ const eccKey = await this.cipherKeyFor(session, opts.cipherId, accountId, parentSn);
21228
+ if (!eccKey) {
21229
+ throw new RecordingDownloadError("key-unavailable", `cipher ${opts.cipherId} could not be obtained`);
21230
+ }
21231
+ const transfer = receiveRecording(session, {
21232
+ path,
21233
+ accountId,
21234
+ channel,
21235
+ timeoutMs: opts.timeoutMs,
21236
+ signal: opts.signal
21237
+ });
21238
+ return { recording: transfer.frames.then((frames) => decodeRecording(frames, eccKey)), drained: transfer.drained };
21239
+ }
20782
21240
  /**
20783
21241
  * Open a {@link Talkback} on a device's camera channel.
20784
21242
  *
@@ -21127,9 +21585,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
21127
21585
  *
21128
21586
  * Reached two ways, both idempotent: the session's own `close` event, when it died while still the
21129
21587
  * station's registered session, and {@link SessionManagerOpts.onAutoClose}, when the manager closed it
21130
- * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} disposes its own
21131
- * sources first, and {@link replaceUnreachableSession} keeps its source alive on purpose to rewarm it
21132
- * on the replacement session.
21588
+ * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} and
21589
+ * {@link resolveSession} dispose their own sources first, while {@link replaceUnreachableSession} keeps
21590
+ * its source alive on purpose to rewarm it on the replacement session.
21133
21591
  */
21134
21592
  tearDownStation(stationSn) {
21135
21593
  this.manager.remove(stationSn);
@@ -21448,9 +21906,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
21448
21906
  *
21449
21907
  * A session whose {@link P2PSession.pathAnswering} is false is closed and re-resolved before it is handed
21450
21908
  * over: the station answers every heartbeat, so a path silent past several of them is gone. A session
21451
- * reporting nothing about its path is not reporting that evidence and is handed over as it is. Replaced at
21452
- * most once per resolution, so a station whose replacement is silent too is returned rather than closed
21453
- * again.
21909
+ * reporting nothing about its path is not reporting that evidence and is handed over as it is. Cached live
21910
+ * sources on the replaced session are discarded before closing it (see {@link tearDownStation}).
21911
+ * Replaced at most once per resolution, so a station whose replacement is silent too is returned rather
21912
+ * than closed again.
21454
21913
  */
21455
21914
  async resolveSession(sn, opts = {}, rebuilt = false) {
21456
21915
  const dev = await this.deviceFor(sn);
@@ -21464,6 +21923,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
21464
21923
  }
21465
21924
  if (session.pathAnswering === false && !rebuilt) {
21466
21925
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
21926
+ for (const [key, sessionKey] of [...this.liveSessionKeys]) {
21927
+ if (sessionKey === parentSn)
21928
+ this.dropLiveSource(key);
21929
+ }
21467
21930
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
21468
21931
  return await this.resolveSession(sn, opts, true);
21469
21932
  }
@@ -24304,6 +24767,7 @@ var MEDIA_FAILURE_REASONS = [
24304
24767
  // dist/transport/stored-image-cache.js
24305
24768
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
24306
24769
  var MAX_REMEMBERED_URLS = 64;
24770
+ var NOT_YET_PUBLISHED_RETRY_MS = [2e3, 4e3];
24307
24771
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
24308
24772
  var StoredImageCache = class {
24309
24773
  downloader;
@@ -24341,6 +24805,8 @@ var StoredImageCache = class {
24341
24805
  if (state.seenUrls.has(url))
24342
24806
  return;
24343
24807
  state.seenUrls.add(url);
24808
+ clearTimeout(state.retrying);
24809
+ state.retrying = void 0;
24344
24810
  while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
24345
24811
  const oldest = state.seenUrls.values().next();
24346
24812
  if (oldest.done)
@@ -24352,14 +24818,15 @@ var StoredImageCache = class {
24352
24818
  url,
24353
24819
  sequence: ++state.nextSequence,
24354
24820
  observedAt: this.clock(),
24355
- generation: this.generation
24821
+ generation: this.generation,
24822
+ retries: 0
24356
24823
  };
24357
24824
  this.pump();
24358
24825
  }
24359
24826
  /** Return retained bytes without starting or awaiting network work. */
24360
24827
  snapshotStored(deviceKey) {
24361
24828
  const state = this.devices.get(deviceKey);
24362
- const reason = state?.queued || state?.inFlight ? "pending" : state?.reason ?? "not-observed";
24829
+ const reason = state?.queued || state?.inFlight || state?.retrying ? "pending" : state?.reason ?? "not-observed";
24363
24830
  if (reason !== "pending" && state?.lifecycleError)
24364
24831
  return Promise.reject(state.lifecycleError);
24365
24832
  if (state?.retained)
@@ -24393,6 +24860,8 @@ var StoredImageCache = class {
24393
24860
  if (error !== void 0 && this.isLifecycleError(error)) {
24394
24861
  state.lifecycleError = error;
24395
24862
  state.reason = void 0;
24863
+ } else if (image === void 0 && this.retryLater(state, candidate, error)) {
24864
+ state.lifecycleError = void 0;
24396
24865
  } else if (image === void 0) {
24397
24866
  state.lifecycleError = void 0;
24398
24867
  state.reason = "download-failed";
@@ -24409,6 +24878,21 @@ var StoredImageCache = class {
24409
24878
  }
24410
24879
  this.pump();
24411
24880
  }
24881
+ /** Schedule the next attempt after a 404; answers whether one was scheduled. */
24882
+ retryLater(state, candidate, error) {
24883
+ const delay = NOT_YET_PUBLISHED_RETRY_MS[candidate.retries];
24884
+ const tag2 = mediaFailureTag(error);
24885
+ if (delay === void 0 || tag2.cause !== "http-status" || tag2.status !== 404 || state.queued)
24886
+ return false;
24887
+ const timer = setTimeout(() => {
24888
+ state.retrying = void 0;
24889
+ state.queued = { ...candidate, retries: candidate.retries + 1 };
24890
+ this.pump();
24891
+ }, delay);
24892
+ timer.unref?.();
24893
+ state.retrying = timer;
24894
+ return true;
24895
+ }
24412
24896
  isValidJpeg(image) {
24413
24897
  return Buffer.isBuffer(image) && image.length > 0 && image.length <= MAX_JPEG_BYTES && image.length >= 5 && image[0] === 255 && image[1] === 216 && image[2] === 255 && image[image.length - 2] === 255 && image[image.length - 1] === 217;
24414
24898
  }
@@ -25004,6 +25488,11 @@ function recordString(raw, key) {
25004
25488
  const v = typeof nested === "string" && nested ? nested : raw[key];
25005
25489
  return typeof v === "string" && v ? v : void 0;
25006
25490
  }
25491
+ function adminUserIdFrom(raw) {
25492
+ const member = (raw ?? {}).member;
25493
+ const id = member?.admin_user_id;
25494
+ return typeof id === "string" && id ? id : void 0;
25495
+ }
25007
25496
  function tuyaDevIdFrom(raw) {
25008
25497
  for (const field of ["tuya_uuid", "tuya_virtual_id", "tuya_device_id", "virtualId"]) {
25009
25498
  const v = recordString(raw, field);
@@ -25161,13 +25650,7 @@ var EufyMega = class extends EventEmitter9 {
25161
25650
  onError: (e) => this.reportError(e),
25162
25651
  // The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id`,
25163
25652
  // falling back to the logged-in account's user id (the confirmed script path does the same).
25164
- resolveAccountId: (dev) => {
25165
- const member = (dev.raw ?? {}).member;
25166
- const adminId = member?.admin_user_id;
25167
- if (typeof adminId === "string" && adminId)
25168
- return adminId;
25169
- return this.mega.auth?.userId ?? "";
25170
- },
25653
+ resolveAccountId: (dev) => adminUserIdFrom(dev.raw) ?? this.mega.auth?.userId ?? "",
25171
25654
  // Fetch + parse a gallery effect from the HTTP catalogue into the serializable spec. The catalogue
25172
25655
  // lives in the http layer (transport/http/light-catalog); the mqtt router owns the frame bytes.
25173
25656
  resolvePreset: (presetId) => resolveLightEffect(this.mega, presetId),
@@ -26614,6 +27097,37 @@ var EufyMega = class extends EventEmitter9 {
26614
27097
  getP2pSessions() {
26615
27098
  return this.p2p.getSessions();
26616
27099
  }
27100
+ /**
27101
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
27102
+ *
27103
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
27104
+ * station, because they were never uploaded — the station holds them.
27105
+ *
27106
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
27107
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
27108
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
27109
+ *
27110
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
27111
+ * returned as they arrive; a caller that wants only named people filters them.
27112
+ */
27113
+ async getStationFaces(stationSn, opts = {}) {
27114
+ if (!this.registry.list().length)
27115
+ await this.getDevices();
27116
+ const station = this.p2p.stationKeyOf(stationSn);
27117
+ const accountId = this.adminUserIdOf(station);
27118
+ if (!accountId)
27119
+ throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
27120
+ await this.p2p.ensureStation(station, opts.signal);
27121
+ const session = this.p2p.getSessions().get(station);
27122
+ if (!session)
27123
+ throw new Error(`getStationFaces: no P2P session for ${station}`);
27124
+ const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
27125
+ return rows.filter((row) => typeof row === "object" && row !== null);
27126
+ }
27127
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
27128
+ adminUserIdOf(stationSn) {
27129
+ return adminUserIdFrom(this.registry.list().find((d) => d.sn === stationSn)?.raw);
27130
+ }
26617
27131
  /**
26618
27132
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
26619
27133
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.
@@ -26851,7 +27365,7 @@ var EufyMega = class extends EventEmitter9 {
26851
27365
  if (ev.eventName === void 0 && ev.eventType != null)
26852
27366
  ev.eventName = detectionName(ev.eventType);
26853
27367
  if (ev.thumbnailCandidate)
26854
- void this.observeStoredImage(ev.thumbnailCandidate);
27368
+ void this.observeStoredImage(ev.thumbnailCandidate, ev.deviceSn);
26855
27369
  this.emit("push", ev);
26856
27370
  const signal2 = {
26857
27371
  source: "push",
@@ -26862,7 +27376,10 @@ var EufyMega = class extends EventEmitter9 {
26862
27376
  thumbnailUrl: ev.thumbnailUrl,
26863
27377
  payload: ev.payload
26864
27378
  };
26865
- for (const out of decodeEvent(signal2, this.capsForEvent(ev.deviceSn))) {
27379
+ const caps = this.capsForEvent(ev.deviceSn);
27380
+ const decoded = decodeEvent(signal2, caps);
27381
+ this.opts.logger?.debug(`[push] in: eventType=${ev.eventType} device=${caps ? "known" : ev.deviceSn ? "unknown" : "none"} events=${decoded.length}`);
27382
+ for (const out of decoded) {
26866
27383
  this.emitSemantic(out.event, out.payload, { edge: true, refresh: out.refresh });
26867
27384
  const dsn = out.payload.deviceSn ?? ev.deviceSn;
26868
27385
  if (dsn)
@@ -26900,9 +27417,16 @@ var EufyMega = class extends EventEmitter9 {
26900
27417
  client.on("error", (e) => this.reportError(e));
26901
27418
  return client;
26902
27419
  }
26903
- /** Admit only exact, account-known devices with resolved snapshot evidence into the passive store. */
26904
- async observeStoredImage(candidate) {
26905
- if (!this.storedImages || candidate.attribution.kind !== "device")
27420
+ /**
27421
+ * Admit only exact, account-known devices with resolved snapshot evidence into the passive store; a
27422
+ * station-only candidate is admitted under the event's device serial.
27423
+ */
27424
+ async observeStoredImage(candidate, eventDeviceSn) {
27425
+ if (!this.storedImages)
27426
+ return;
27427
+ const { attribution } = candidate;
27428
+ const deviceSn = attribution.kind === "device" ? attribution.deviceSn : attribution.kind === "station" ? eventDeviceSn : void 0;
27429
+ if (!deviceSn)
26906
27430
  return;
26907
27431
  const account = this.mega.auth?.userId;
26908
27432
  if (!account)
@@ -26912,9 +27436,9 @@ var EufyMega = class extends EventEmitter9 {
26912
27436
  await this.registry.getDevices();
26913
27437
  if (this.mega.auth?.userId !== account)
26914
27438
  return;
26915
- const caps = this.registry.capabilitiesForDevice(candidate.attribution.deviceSn);
27439
+ const caps = this.registry.capabilitiesForDevice(deviceSn);
26916
27440
  if (caps && hasProvidedAction(caps, "snapshotStored")) {
26917
- this.storedImages.observe(candidate.attribution.deviceSn, candidate.url);
27441
+ this.storedImages.observe(deviceSn, candidate.url);
26918
27442
  }
26919
27443
  } catch (e) {
26920
27444
  this.reportError(e);
@@ -27139,26 +27663,16 @@ var SolixClient = class _SolixClient {
27139
27663
  }
27140
27664
  /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
27141
27665
  async postLogin(kx, verifyCode, limitedToken) {
27142
- const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
27143
27666
  const bodyObj = {
27144
- email: this.email,
27145
- password: encryptedPassword,
27146
- ab: this.country,
27147
- client_secret_info: { public_key: clientPublicKeyHex },
27667
+ ...loginCredentials(this.email, this.password, this.country),
27148
27668
  answer: "",
27149
27669
  captcha_id: "",
27150
27670
  verify_code: verifyCode ?? "",
27151
27671
  login_id: ""
27152
27672
  };
27153
27673
  const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
27154
- const ts = nowSec();
27155
- const once = genId();
27156
27674
  return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
27157
- "x-encryption-info": "algo_ecdh",
27158
- "x-key-ident": kx.keyIdent,
27159
- "x-request-ts": ts,
27160
- "x-request-once": once,
27161
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27675
+ ...signedHeaders(kx, encBody),
27162
27676
  ...limitedToken ? { "x-auth-token": limitedToken } : {}
27163
27677
  }));
27164
27678
  }
@@ -27173,13 +27687,12 @@ var SolixClient = class _SolixClient {
27173
27687
  * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
27174
27688
  */
27175
27689
  classifyLogin(data, isVerify) {
27176
- const userId = data.ap_cloud_user_id ?? data.user_id;
27177
- const authToken = data.auth_token;
27178
- if (!userId || !authToken)
27690
+ const reply = readLoginReply(data);
27691
+ if (!reply)
27179
27692
  throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
27180
- const faInfo = data.fa_info ?? {};
27181
- if (!isVerify && faInfo.info) {
27182
- this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
27693
+ const { userId, authToken } = reply;
27694
+ if (!isVerify && reply.twoFactorPending) {
27695
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: reply.geoKey };
27183
27696
  return { status: "2fa", method: "code sent by the passport" };
27184
27697
  }
27185
27698
  this.pending2fa = void 0;
@@ -27188,7 +27701,7 @@ var SolixClient = class _SolixClient {
27188
27701
  userId,
27189
27702
  gtoken: gtoken(userId),
27190
27703
  apiHost: this.apiHost,
27191
- tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
27704
+ tokenExpiresAt: reply.tokenExpiresAt
27192
27705
  };
27193
27706
  this.persist();
27194
27707
  return { status: "ok", session: this.session_ };
@@ -27343,14 +27856,8 @@ var SolixClient = class _SolixClient {
27343
27856
  throw new Error("not authenticated \u2014 call login() first");
27344
27857
  const kx = await this.keyExchange();
27345
27858
  const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
27346
- const ts = nowSec();
27347
- const once = genId();
27348
27859
  const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
27349
- "x-encryption-info": "algo_ecdh",
27350
- "x-key-ident": kx.keyIdent,
27351
- "x-request-ts": ts,
27352
- "x-request-once": once,
27353
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27860
+ ...signedHeaders(kx, encBody),
27354
27861
  "x-auth-token": this.session_.authToken,
27355
27862
  gtoken: this.session_.gtoken
27356
27863
  }));
@@ -28118,6 +28625,7 @@ export {
28118
28625
  ROOM_SUCTIONS,
28119
28626
  RTSP_MEMBERS,
28120
28627
  RawDpWriter,
28628
+ RecordingDownloadError,
28121
28629
  RecordingQuality,
28122
28630
  RtspRecordingMode,
28123
28631
  SCENE_INVALID_REASONS,
@@ -28161,6 +28669,7 @@ export {
28161
28669
  VACUUM_DOCK_MEMBERS,
28162
28670
  VACUUM_SCHEDULE_ACTIONS,
28163
28671
  VACUUM_SCHEDULE_WEEKDAYS,
28672
+ VIDEO_GCM_AAD,
28164
28673
  VacuumMapStore,
28165
28674
  VideoFrameDecoder,
28166
28675
  WORKING_MODE_MAPS,
@@ -28180,6 +28689,7 @@ export {
28180
28689
  buildEventIndex,
28181
28690
  buildModelIndex,
28182
28691
  buildRealtimeInit,
28692
+ cameraPowerTier,
28183
28693
  captureSnapshotFromShared,
28184
28694
  cellAtPoint,
28185
28695
  claimedParams,