@mega-yfue/eufy-sdk 0.4.0-beta.9 → 0.5.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1070,6 +1070,409 @@ function normalizePushImage(data, p2pDid) {
1070
1070
  return decodeImageV1(data, p2pDid) ?? data;
1071
1071
  }
1072
1072
 
1073
+ // dist/core/types.js
1074
+ var PRINTER_CATEGORY_RE = /ankermake|eufymake|fdm|3d_?print/i;
1075
+ function classifyDevice(raw) {
1076
+ const category = raw.category ?? "";
1077
+ const hasP2p = typeof raw.p2p_did === "string" && raw.p2p_did.length > 0;
1078
+ if (category === "eufy_security" || hasP2p)
1079
+ return { category, api: "mega", realtime: "p2p" };
1080
+ if (PRINTER_CATEGORY_RE.test(category))
1081
+ return { category, api: "ankermake", realtime: "ankermake-mqtt" };
1082
+ return { category, api: "mega", realtime: "smqtt" };
1083
+ }
1084
+
1085
+ // dist/core/contracts.js
1086
+ var StoredSnapshotUnavailableError = class extends Error {
1087
+ reason;
1088
+ constructor(reason, message, options) {
1089
+ super(message, options);
1090
+ this.reason = reason;
1091
+ this.name = "StoredSnapshotUnavailableError";
1092
+ }
1093
+ };
1094
+ var RETRYABLE_LIVE_SNAPSHOT_REASONS = [
1095
+ "no-keyframe",
1096
+ "source-failed",
1097
+ "undecodable-burst"
1098
+ ];
1099
+ var LiveSnapshotUnavailableError = class extends Error {
1100
+ reason;
1101
+ /** Whether another attempt could plausibly succeed without the host changing anything. */
1102
+ retryable;
1103
+ constructor(reason, message, options) {
1104
+ super(message, options);
1105
+ this.reason = reason;
1106
+ this.name = "LiveSnapshotUnavailableError";
1107
+ this.retryable = RETRYABLE_LIVE_SNAPSHOT_REASONS.includes(reason);
1108
+ }
1109
+ };
1110
+ var CameraDisabledError = class extends Error {
1111
+ camera;
1112
+ constructor(camera, options) {
1113
+ super(`camera${camera ? ` ${camera}` : ""} is disabled \u2014 no live stream possible`, options);
1114
+ this.camera = camera;
1115
+ this.name = "CameraDisabledError";
1116
+ }
1117
+ };
1118
+ var StationKeyUnavailableError = class extends Error {
1119
+ stationSn;
1120
+ /** Always true: the negotiation is per connection, so a later one may still produce a key. */
1121
+ retryable = true;
1122
+ constructor(stationSn, options) {
1123
+ super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
1124
+ this.stationSn = stationSn;
1125
+ this.name = "StationKeyUnavailableError";
1126
+ }
1127
+ };
1128
+ var StationUnreachableError = class extends Error {
1129
+ stationSn;
1130
+ waitedMs;
1131
+ /** Always true: a station unreachable now may answer on a later attempt. */
1132
+ retryable = true;
1133
+ constructor(stationSn, waitedMs, options) {
1134
+ super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
1135
+ this.stationSn = stationSn;
1136
+ this.waitedMs = waitedMs;
1137
+ this.name = "StationUnreachableError";
1138
+ }
1139
+ };
1140
+ var DeviceChannelUnresolvedError = class extends Error {
1141
+ sn;
1142
+ stationSn;
1143
+ constructor(sn, stationSn, options) {
1144
+ super(`${sn} has no usable channel on station ${stationSn}, so nothing was sent to it`, options);
1145
+ this.sn = sn;
1146
+ this.stationSn = stationSn;
1147
+ this.name = "DeviceChannelUnresolvedError";
1148
+ }
1149
+ };
1150
+ var RecordingDownloadError = class extends Error {
1151
+ reason;
1152
+ constructor(reason, message, options) {
1153
+ super(message, options);
1154
+ this.reason = reason;
1155
+ this.name = "RecordingDownloadError";
1156
+ }
1157
+ };
1158
+ var LiveStreamStartError = class extends Error {
1159
+ reason;
1160
+ stage;
1161
+ timeoutMs;
1162
+ attempts;
1163
+ constructor(failure) {
1164
+ super(`live stream failed to start (${failure.reason} at ${failure.stage} after ${failure.attempts} attempts; ${failure.timeoutMs}ms deadline)`, failure.cause === void 0 ? void 0 : { cause: failure.cause });
1165
+ this.name = "LiveStreamStartError";
1166
+ this.reason = failure.reason;
1167
+ this.stage = failure.stage;
1168
+ this.timeoutMs = failure.timeoutMs;
1169
+ this.attempts = failure.attempts;
1170
+ }
1171
+ };
1172
+ var StateConvergenceError = class extends Error {
1173
+ sn;
1174
+ property;
1175
+ param;
1176
+ expected;
1177
+ observed;
1178
+ timeoutMs;
1179
+ constructor(failure) {
1180
+ super(`device ${failure.sn} did not report ${failure.property} as ${String(failure.expected)} within ${failure.timeoutMs}ms (param ${failure.param} read ${String(failure.observed)})`);
1181
+ this.name = "StateConvergenceError";
1182
+ this.sn = failure.sn;
1183
+ this.property = failure.property;
1184
+ this.param = failure.param;
1185
+ this.expected = failure.expected;
1186
+ this.observed = failure.observed;
1187
+ this.timeoutMs = failure.timeoutMs;
1188
+ }
1189
+ };
1190
+ var COMMAND_OBSERVATION = /* @__PURE__ */ Symbol("command-observation");
1191
+ function observeCommand(command, observation) {
1192
+ return Object.defineProperty(command, COMMAND_OBSERVATION, { configurable: true, value: observation });
1193
+ }
1194
+ function commandObservation(command) {
1195
+ return command[COMMAND_OBSERVATION];
1196
+ }
1197
+
1198
+ // dist/core/raw-dp-writer.js
1199
+ var WIRE = { VARINT: 0, LENGTH_DELIMITED: 2 };
1200
+ function encodeVarint(value) {
1201
+ if (!Number.isSafeInteger(value) || value < 0) {
1202
+ throw new Error(`varint: ${value} is not a non-negative safe integer`);
1203
+ }
1204
+ const out = [];
1205
+ let v = value;
1206
+ while (v > 127) {
1207
+ out.push(v % 128 + 128);
1208
+ v = Math.floor(v / 128);
1209
+ }
1210
+ out.push(v);
1211
+ return out;
1212
+ }
1213
+ function zigzag(value) {
1214
+ if (!Number.isSafeInteger(value))
1215
+ throw new Error(`zigzag: ${value} is not a safe integer`);
1216
+ return value < 0 ? -2 * value - 1 : 2 * value;
1217
+ }
1218
+ var RawDpWriter = class _RawDpWriter {
1219
+ body = [];
1220
+ /** A varint-valued field (`int32`, `uint32`, `bool`, an enum member). */
1221
+ int(field, value) {
1222
+ this.body.push(...encodeVarint(tag(field, WIRE.VARINT)), ...encodeVarint(value));
1223
+ return this;
1224
+ }
1225
+ /** A signed varint field (`sint32`, `sint64`), ZigZag-encoded — see {@link zigzag}. */
1226
+ sint(field, value) {
1227
+ return this.int(field, zigzag(value));
1228
+ }
1229
+ /** A boolean field, on protobuf's own terms: `1` or `0` in a varint. */
1230
+ bool(field, value) {
1231
+ return this.int(field, value ? 1 : 0);
1232
+ }
1233
+ /** A length-delimited field carrying raw bytes — a string, a blob, or a message built elsewhere. */
1234
+ bytes(field, value) {
1235
+ const bytes2 = value instanceof Uint8Array ? Array.from(value) : [...value];
1236
+ this.body.push(...encodeVarint(tag(field, WIRE.LENGTH_DELIMITED)), ...encodeVarint(bytes2.length), ...bytes2);
1237
+ return this;
1238
+ }
1239
+ /**
1240
+ * A nested sub-message, built by `build` into a writer of its own.
1241
+ *
1242
+ * The sub-message is emitted even when `build` writes nothing into it. That is not an oversight: an
1243
+ * EMPTY sub-message is how these protocols say "this subsystem exists and is in its zero state", as
1244
+ * opposed to saying nothing about it, and the readers on the other side of this file depend on the
1245
+ * distinction. Omitting the call is what says nothing about it.
1246
+ */
1247
+ sub(field, build) {
1248
+ const inner = new _RawDpWriter();
1249
+ build(inner);
1250
+ return this.bytes(field, inner.toBytes());
1251
+ }
1252
+ /** The message body on its own, unframed — for nesting into another writer. */
1253
+ toBytes() {
1254
+ return [...this.body];
1255
+ }
1256
+ /**
1257
+ * The finished DP value: `varint(bodyLength) ++ body`, base64-encoded.
1258
+ *
1259
+ * This is the envelope `RawDpCodec.decode` unwraps, so a value produced here and read back through the
1260
+ * codec is a genuine round-trip rather than two implementations of the same guess.
1261
+ */
1262
+ finish() {
1263
+ return Buffer.from([...encodeVarint(this.body.length), ...this.body]).toString("base64");
1264
+ }
1265
+ };
1266
+ function tag(field, wire) {
1267
+ if (!Number.isSafeInteger(field) || field < 1)
1268
+ throw new Error(`field number: ${field} is not a positive integer`);
1269
+ return field * 8 + wire;
1270
+ }
1271
+ function rawDp(build) {
1272
+ const w = new RawDpWriter();
1273
+ build(w);
1274
+ return w.finish();
1275
+ }
1276
+
1277
+ // dist/core/raw-dp-hex.js
1278
+ function hexToRawDp(hex) {
1279
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
1280
+ return void 0;
1281
+ return Buffer.from(hex, "hex").toString("base64");
1282
+ }
1283
+
1284
+ // dist/core/util.js
1285
+ var Timer = class {
1286
+ handle;
1287
+ /** (Re)arm to run `fn` after `ms`, cancelling any already-pending fire. */
1288
+ arm(ms, fn) {
1289
+ this.cancel();
1290
+ this.handle = setTimeout(() => {
1291
+ this.handle = void 0;
1292
+ fn();
1293
+ }, ms);
1294
+ this.handle.unref?.();
1295
+ }
1296
+ /** Cancel a pending fire. No-op if not armed. */
1297
+ cancel() {
1298
+ if (this.handle) {
1299
+ clearTimeout(this.handle);
1300
+ this.handle = void 0;
1301
+ }
1302
+ }
1303
+ /** Whether a fire is currently scheduled. */
1304
+ get pending() {
1305
+ return this.handle !== void 0;
1306
+ }
1307
+ };
1308
+ function assertNever(value) {
1309
+ throw new Error(`unhandled variant: ${JSON.stringify(value)}`);
1310
+ }
1311
+ function asBool(value) {
1312
+ if (typeof value === "string")
1313
+ return value === "1" || value.toLowerCase() === "true";
1314
+ return value === true || value === 1;
1315
+ }
1316
+ function clamp(value, min, max) {
1317
+ if (Number.isNaN(value))
1318
+ return min;
1319
+ return Math.max(min, Math.min(max, Math.round(value)));
1320
+ }
1321
+ function structuralEqual(a, b) {
1322
+ if (Object.is(a, b))
1323
+ return true;
1324
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
1325
+ return false;
1326
+ const aArr = Array.isArray(a);
1327
+ const bArr = Array.isArray(b);
1328
+ if (aArr !== bArr)
1329
+ return false;
1330
+ if (aArr && bArr) {
1331
+ if (a.length !== b.length)
1332
+ return false;
1333
+ for (let i = 0; i < a.length; i++)
1334
+ if (!structuralEqual(a[i], b[i]))
1335
+ return false;
1336
+ return true;
1337
+ }
1338
+ const aObj = a;
1339
+ const bObj = b;
1340
+ const aKeys = Object.keys(aObj);
1341
+ const bKeys = Object.keys(bObj);
1342
+ if (aKeys.length !== bKeys.length)
1343
+ return false;
1344
+ for (const k of aKeys) {
1345
+ if (!Object.prototype.hasOwnProperty.call(bObj, k))
1346
+ return false;
1347
+ if (!structuralEqual(aObj[k], bObj[k]))
1348
+ return false;
1349
+ }
1350
+ return true;
1351
+ }
1352
+ function coerceEnumValue(enumObj, value) {
1353
+ const n = Number(value);
1354
+ return Number.isInteger(n) && Object.values(enumObj).includes(n) ? n : void 0;
1355
+ }
1356
+ function enumValues(enumObj) {
1357
+ return Object.values(enumObj).join("/");
1358
+ }
1359
+ function enumLabels(enumObj) {
1360
+ return Object.fromEntries(Object.entries(enumObj).map(([name, value]) => [value, name]));
1361
+ }
1362
+ function jsonObject(text2) {
1363
+ if (typeof text2 !== "string")
1364
+ return void 0;
1365
+ try {
1366
+ const v = JSON.parse(text2);
1367
+ return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
1368
+ } catch {
1369
+ return void 0;
1370
+ }
1371
+ }
1372
+ function u16le(n) {
1373
+ const b = Buffer.alloc(2);
1374
+ b.writeUInt16LE(n & 65535);
1375
+ return b;
1376
+ }
1377
+ function u16be(n) {
1378
+ const b = Buffer.alloc(2);
1379
+ b.writeUInt16BE(n & 65535);
1380
+ return b;
1381
+ }
1382
+ function u32le(n) {
1383
+ const b = Buffer.alloc(4);
1384
+ b.writeUInt32LE(n >>> 0);
1385
+ return b;
1386
+ }
1387
+ function u32be(n) {
1388
+ const b = Buffer.alloc(4);
1389
+ b.writeUInt32BE(n >>> 0);
1390
+ return b;
1391
+ }
1392
+
1393
+ // dist/core/lz4-block.js
1394
+ var MAX_OUTPUT = 64 * 1024 * 1024;
1395
+ function lz4BlockDecompress(block, size) {
1396
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_OUTPUT)
1397
+ return void 0;
1398
+ const out = Buffer.alloc(size);
1399
+ const end = block.length;
1400
+ let pos = 0;
1401
+ let written = 0;
1402
+ const extend2 = (base) => {
1403
+ let len = base;
1404
+ if (base !== 15)
1405
+ return len;
1406
+ for (; ; ) {
1407
+ if (pos >= end)
1408
+ return void 0;
1409
+ const extra = block[pos++];
1410
+ len += extra;
1411
+ if (extra !== 255)
1412
+ return len;
1413
+ }
1414
+ };
1415
+ while (pos < end) {
1416
+ const token = block[pos++];
1417
+ const literals = extend2(token >> 4);
1418
+ if (literals === void 0)
1419
+ return void 0;
1420
+ if (pos + literals > end || written + literals > size)
1421
+ return void 0;
1422
+ for (let i = 0; i < literals; i++)
1423
+ out[written++] = block[pos++];
1424
+ if (pos === end)
1425
+ break;
1426
+ if (pos + 2 > end)
1427
+ return void 0;
1428
+ const offset = block[pos] | block[pos + 1] << 8;
1429
+ pos += 2;
1430
+ if (offset === 0 || offset > written)
1431
+ return void 0;
1432
+ const match = extend2(token & 15);
1433
+ if (match === void 0)
1434
+ return void 0;
1435
+ const length = match + 4;
1436
+ if (written + length > size)
1437
+ return void 0;
1438
+ let from = written - offset;
1439
+ for (let i = 0; i < length; i++)
1440
+ out[written++] = out[from++];
1441
+ }
1442
+ return written === size ? out : void 0;
1443
+ }
1444
+
1445
+ // dist/transport/http/passport.js
1446
+ function loginCredentials(email, password, country) {
1447
+ const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(password);
1448
+ return { email, password: encryptedPassword, ab: country, client_secret_info: { public_key: clientPublicKeyHex } };
1449
+ }
1450
+ function readLoginReply(data) {
1451
+ const userId = data.ap_cloud_user_id ?? data.user_id ?? data.userId;
1452
+ const authToken = data.auth_token ?? data.token;
1453
+ if (!userId || !authToken)
1454
+ return void 0;
1455
+ return {
1456
+ userId,
1457
+ accountUserId: data.user_id ?? data.userId,
1458
+ authToken,
1459
+ geoKey: data.geo_key,
1460
+ tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0,
1461
+ twoFactorPending: !!(data.fa_info ?? {}).info
1462
+ };
1463
+ }
1464
+ function signedHeaders(entry, encBody) {
1465
+ const ts = nowSec();
1466
+ const once = genId();
1467
+ return {
1468
+ "x-encryption-info": "algo_ecdh",
1469
+ "x-key-ident": entry.keyIdent,
1470
+ "x-request-ts": ts,
1471
+ "x-request-once": once,
1472
+ "x-signature": signRequest(entry.shareKey, ts, once, encBody)
1473
+ };
1474
+ }
1475
+
1073
1476
  // dist/transport/http/mega-client.js
1074
1477
  function parseMaybeJson(text2) {
1075
1478
  if (text2.length === 0)
@@ -1437,17 +1840,11 @@ ${this.auth_?.authToken ?? ""}`;
1437
1840
  const order = preferred === "text/plain" ? ["text/plain", "application/json"] : ["application/json", "text/plain"];
1438
1841
  let last;
1439
1842
  for (const contentType of order) {
1440
- const ts = nowSec();
1441
- const once = genId();
1442
1843
  const headers = {
1443
1844
  ...this.baseHeaders(),
1444
1845
  "content-type": contentType,
1445
- "x-encryption-info": "algo_ecdh",
1846
+ ...signedHeaders(entry, encBody),
1446
1847
  "x-replay-info": "replay",
1447
- "x-key-ident": entry.keyIdent,
1448
- "x-request-ts": ts,
1449
- "x-request-once": once,
1450
- "x-signature": signRequest(entry.shareKey, ts, once, encBody),
1451
1848
  // Per-call header overrides (e.g. app-name=eufy_security to request a security-scoped
1452
1849
  // MQTT cert on an existing eufy_mega session — see EufyMega.getUserMqttInfo).
1453
1850
  ...headerOverrides
@@ -1650,6 +2047,15 @@ ${this.auth_?.authToken ?? ""}`;
1650
2047
  token,
1651
2048
  voip_token: ""
1652
2049
  });
2050
+ await this.securityAppPost("/v1/apppush/register_push_token", {
2051
+ is_notification_enable: true,
2052
+ token,
2053
+ transaction: String(Date.now())
2054
+ });
2055
+ await this.securityAppPost("/v1/app/review/app_push_check", {
2056
+ app_type: "eufySecurity",
2057
+ transaction: String(Date.now())
2058
+ });
1653
2059
  }
1654
2060
  /** Download raw bytes from a push-media URL using the active account session. */
1655
2061
  async downloadMedia(url) {
@@ -1746,13 +2152,7 @@ ${this.auth_?.authToken ?? ""}`;
1746
2152
  }
1747
2153
  /** Build the /passport/login body (verify_code empty unless 2FA). */
1748
2154
  loginBody(opts) {
1749
- const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.cfg.password);
1750
- const base = {
1751
- email: this.cfg.email,
1752
- password: encryptedPassword,
1753
- ab: this.cfg.countryCode,
1754
- client_secret_info: { public_key: clientPublicKeyHex }
1755
- };
2155
+ const base = loginCredentials(this.cfg.email, this.cfg.password, this.cfg.countryCode);
1756
2156
  if (opts.verifyCode)
1757
2157
  return { ...base, verify_code: opts.verifyCode, login_id: "" };
1758
2158
  if (opts.captchaId)
@@ -1990,15 +2390,12 @@ ${this.auth_?.authToken ?? ""}`;
1990
2390
  if (cap.length)
1991
2391
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1992
2392
  }
1993
- const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1994
- const accountUserId = res.user_id ?? res.userId;
1995
- const authToken = res.auth_token ?? res.token;
1996
- if (!userId || !authToken)
2393
+ const reply = readLoginReply(res);
2394
+ if (!reply)
1997
2395
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1998
- const faInfo = res.fa_info ?? {};
1999
- const needs2fa = !isVerify && !!faInfo.info;
2000
- if (needs2fa) {
2001
- this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
2396
+ const { userId, accountUserId, authToken, geoKey } = reply;
2397
+ if (!isVerify && reply.twoFactorPending) {
2398
+ this.auth_ = { userId, accountUserId, authToken, geoKey };
2002
2399
  this.pendingCaptchaId = void 0;
2003
2400
  this.pending2fa = true;
2004
2401
  await this.sendVerifyCode(messageType);
@@ -2006,8 +2403,8 @@ ${this.auth_?.authToken ?? ""}`;
2006
2403
  }
2007
2404
  this.pendingCaptchaId = void 0;
2008
2405
  this.pending2fa = false;
2009
- this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
2010
- this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
2406
+ this.auth_ = { userId, accountUserId, authToken, geoKey };
2407
+ this.tokenExpiresAt = reply.tokenExpiresAt;
2011
2408
  this.sessionKey = void 0;
2012
2409
  await this.ensureSessionKey();
2013
2410
  this.persist();
@@ -2317,119 +2714,10 @@ var SecureMqtt = class extends EventEmitter {
2317
2714
  import { createHash as createHash4 } from "node:crypto";
2318
2715
  function buildAppShapedClientId(input) {
2319
2716
  const ts = input.timestamp ?? Math.floor(Date.now() / 1e3);
2320
- return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
2321
- }
2322
- function mqttUuidFrom(installId) {
2323
- return createHash4("sha256").update(installId).digest("hex").slice(0, 16);
2324
- }
2325
-
2326
- // dist/core/util.js
2327
- var Timer = class {
2328
- handle;
2329
- /** (Re)arm to run `fn` after `ms`, cancelling any already-pending fire. */
2330
- arm(ms, fn) {
2331
- this.cancel();
2332
- this.handle = setTimeout(() => {
2333
- this.handle = void 0;
2334
- fn();
2335
- }, ms);
2336
- this.handle.unref?.();
2337
- }
2338
- /** Cancel a pending fire. No-op if not armed. */
2339
- cancel() {
2340
- if (this.handle) {
2341
- clearTimeout(this.handle);
2342
- this.handle = void 0;
2343
- }
2344
- }
2345
- /** Whether a fire is currently scheduled. */
2346
- get pending() {
2347
- return this.handle !== void 0;
2348
- }
2349
- };
2350
- function assertNever(value) {
2351
- throw new Error(`unhandled variant: ${JSON.stringify(value)}`);
2352
- }
2353
- function asBool(value) {
2354
- if (typeof value === "string")
2355
- return value === "1" || value.toLowerCase() === "true";
2356
- return value === true || value === 1;
2357
- }
2358
- function clamp(value, min, max) {
2359
- if (Number.isNaN(value))
2360
- return min;
2361
- return Math.max(min, Math.min(max, Math.round(value)));
2362
- }
2363
- function structuralEqual(a, b) {
2364
- if (Object.is(a, b))
2365
- return true;
2366
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
2367
- return false;
2368
- const aArr = Array.isArray(a);
2369
- const bArr = Array.isArray(b);
2370
- if (aArr !== bArr)
2371
- return false;
2372
- if (aArr && bArr) {
2373
- if (a.length !== b.length)
2374
- return false;
2375
- for (let i = 0; i < a.length; i++)
2376
- if (!structuralEqual(a[i], b[i]))
2377
- return false;
2378
- return true;
2379
- }
2380
- const aObj = a;
2381
- const bObj = b;
2382
- const aKeys = Object.keys(aObj);
2383
- const bKeys = Object.keys(bObj);
2384
- if (aKeys.length !== bKeys.length)
2385
- return false;
2386
- for (const k of aKeys) {
2387
- if (!Object.prototype.hasOwnProperty.call(bObj, k))
2388
- return false;
2389
- if (!structuralEqual(aObj[k], bObj[k]))
2390
- return false;
2391
- }
2392
- return true;
2393
- }
2394
- function coerceEnumValue(enumObj, value) {
2395
- const n = Number(value);
2396
- return Number.isInteger(n) && Object.values(enumObj).includes(n) ? n : void 0;
2397
- }
2398
- function enumValues(enumObj) {
2399
- return Object.values(enumObj).join("/");
2400
- }
2401
- function enumLabels(enumObj) {
2402
- return Object.fromEntries(Object.entries(enumObj).map(([name, value]) => [value, name]));
2403
- }
2404
- function jsonObject(text2) {
2405
- if (typeof text2 !== "string")
2406
- return void 0;
2407
- try {
2408
- const v = JSON.parse(text2);
2409
- return v && typeof v === "object" && !Array.isArray(v) ? v : void 0;
2410
- } catch {
2411
- return void 0;
2412
- }
2413
- }
2414
- function u16le(n) {
2415
- const b = Buffer.alloc(2);
2416
- b.writeUInt16LE(n & 65535);
2417
- return b;
2418
- }
2419
- function u16be(n) {
2420
- const b = Buffer.alloc(2);
2421
- b.writeUInt16BE(n & 65535);
2422
- return b;
2423
- }
2424
- function u32le(n) {
2425
- const b = Buffer.alloc(4);
2426
- b.writeUInt32LE(n >>> 0);
2427
- return b;
2428
- }
2429
- function u32be(n) {
2430
- const b = Buffer.alloc(4);
2431
- b.writeUInt32BE(n >>> 0);
2432
- return b;
2717
+ return `android-${input.appName}-${input.uid}-${input.mqttUuid}-${ts}`;
2718
+ }
2719
+ function mqttUuidFrom(installId) {
2720
+ return createHash4("sha256").update(installId).digest("hex").slice(0, 16);
2433
2721
  }
2434
2722
 
2435
2723
  // dist/transport/mqtt/availability.js
@@ -2556,13 +2844,6 @@ function parseAiotDpReport(raw) {
2556
2844
  return Object.keys(out).length ? out : void 0;
2557
2845
  }
2558
2846
 
2559
- // dist/core/raw-dp-hex.js
2560
- function hexToRawDp(hex) {
2561
- if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex))
2562
- return void 0;
2563
- return Buffer.from(hex, "hex").toString("base64");
2564
- }
2565
-
2566
2847
  // dist/transport/mqtt/biz-stream.js
2567
2848
  var BIZ_PROTOCOL = {
2568
2849
  /** Map-stream data: the frames this module parses. */
@@ -2746,18 +3027,6 @@ var DeviceType = {
2746
3027
  CAMERA_C35: 10035
2747
3028
  };
2748
3029
 
2749
- // dist/core/types.js
2750
- var PRINTER_CATEGORY_RE = /ankermake|eufymake|fdm|3d_?print/i;
2751
- function classifyDevice(raw) {
2752
- const category = raw.category ?? "";
2753
- const hasP2p = typeof raw.p2p_did === "string" && raw.p2p_did.length > 0;
2754
- if (category === "eufy_security" || hasP2p)
2755
- return { category, api: "mega", realtime: "p2p" };
2756
- if (PRINTER_CATEGORY_RE.test(category))
2757
- return { category, api: "ankermake", realtime: "ankermake-mqtt" };
2758
- return { category, api: "mega", realtime: "smqtt" };
2759
- }
2760
-
2761
3030
  // dist/model/classify.js
2762
3031
  var STATION_TYPES = /* @__PURE__ */ new Set([
2763
3032
  DeviceType.STATION,
@@ -2896,111 +3165,6 @@ function inferName(rec) {
2896
3165
  return void 0;
2897
3166
  }
2898
3167
 
2899
- // dist/core/contracts.js
2900
- var StoredSnapshotUnavailableError = class extends Error {
2901
- reason;
2902
- constructor(reason, message, options) {
2903
- super(message, options);
2904
- this.reason = reason;
2905
- this.name = "StoredSnapshotUnavailableError";
2906
- }
2907
- };
2908
- var RETRYABLE_LIVE_SNAPSHOT_REASONS = [
2909
- "no-keyframe",
2910
- "source-failed",
2911
- "undecodable-burst"
2912
- ];
2913
- var LiveSnapshotUnavailableError = class extends Error {
2914
- reason;
2915
- /** Whether another attempt could plausibly succeed without the host changing anything. */
2916
- retryable;
2917
- constructor(reason, message, options) {
2918
- super(message, options);
2919
- this.reason = reason;
2920
- this.name = "LiveSnapshotUnavailableError";
2921
- this.retryable = RETRYABLE_LIVE_SNAPSHOT_REASONS.includes(reason);
2922
- }
2923
- };
2924
- var CameraDisabledError = class extends Error {
2925
- camera;
2926
- constructor(camera, options) {
2927
- super(`camera${camera ? ` ${camera}` : ""} is disabled \u2014 no live stream possible`, options);
2928
- this.camera = camera;
2929
- this.name = "CameraDisabledError";
2930
- }
2931
- };
2932
- var StationKeyUnavailableError = class extends Error {
2933
- stationSn;
2934
- /** Always true: the negotiation is per connection, so a later one may still produce a key. */
2935
- retryable = true;
2936
- constructor(stationSn, options) {
2937
- super(`station ${stationSn} did not provide its session key, so nothing that requires one could be sent`, options);
2938
- this.stationSn = stationSn;
2939
- this.name = "StationKeyUnavailableError";
2940
- }
2941
- };
2942
- var StationUnreachableError = class extends Error {
2943
- stationSn;
2944
- waitedMs;
2945
- /** Always true: a station unreachable now may answer on a later attempt. */
2946
- retryable = true;
2947
- constructor(stationSn, waitedMs, options) {
2948
- super(`station ${stationSn}'s P2P session did not connect within ${waitedMs}ms, so nothing could be sent to it`, options);
2949
- this.stationSn = stationSn;
2950
- this.waitedMs = waitedMs;
2951
- this.name = "StationUnreachableError";
2952
- }
2953
- };
2954
- var DeviceChannelUnresolvedError = class extends Error {
2955
- sn;
2956
- stationSn;
2957
- constructor(sn, stationSn, options) {
2958
- super(`${sn} has no usable channel on station ${stationSn}, so nothing was sent to it`, options);
2959
- this.sn = sn;
2960
- this.stationSn = stationSn;
2961
- this.name = "DeviceChannelUnresolvedError";
2962
- }
2963
- };
2964
- var LiveStreamStartError = class extends Error {
2965
- reason;
2966
- stage;
2967
- timeoutMs;
2968
- attempts;
2969
- constructor(failure) {
2970
- 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 });
2971
- this.name = "LiveStreamStartError";
2972
- this.reason = failure.reason;
2973
- this.stage = failure.stage;
2974
- this.timeoutMs = failure.timeoutMs;
2975
- this.attempts = failure.attempts;
2976
- }
2977
- };
2978
- var StateConvergenceError = class extends Error {
2979
- sn;
2980
- property;
2981
- param;
2982
- expected;
2983
- observed;
2984
- timeoutMs;
2985
- constructor(failure) {
2986
- super(`device ${failure.sn} did not report ${failure.property} as ${String(failure.expected)} within ${failure.timeoutMs}ms (param ${failure.param} read ${String(failure.observed)})`);
2987
- this.name = "StateConvergenceError";
2988
- this.sn = failure.sn;
2989
- this.property = failure.property;
2990
- this.param = failure.param;
2991
- this.expected = failure.expected;
2992
- this.observed = failure.observed;
2993
- this.timeoutMs = failure.timeoutMs;
2994
- }
2995
- };
2996
- var COMMAND_OBSERVATION = /* @__PURE__ */ Symbol("command-observation");
2997
- function observeCommand(command, observation) {
2998
- return Object.defineProperty(command, COMMAND_OBSERVATION, { configurable: true, value: observation });
2999
- }
3000
- function commandObservation(command) {
3001
- return command[COMMAND_OBSERVATION];
3002
- }
3003
-
3004
3168
  // dist/model/capabilities/access.js
3005
3169
  function setScalar(param, value, ctx, form = "auto") {
3006
3170
  return { kind: "set-param", param, value, form, channel: ctx.channel };
@@ -3109,7 +3273,7 @@ function propertiesOf(members, ctx) {
3109
3273
  unit: m.unit,
3110
3274
  enumValues: enumValues2,
3111
3275
  provenance: m.provenance,
3112
- invert: promoted?.invert ?? m.invert,
3276
+ invert: promoted?.invert ?? (ctx ? m.invertFor?.(ctx) : void 0) ?? m.invert,
3113
3277
  decode: m.coerce,
3114
3278
  // A member with a getter-side `decode` reads a field out of a structured payload, so the
3115
3279
  // stored value is that payload — see `PropertySpec.raw`.
@@ -3273,11 +3437,17 @@ var unreflectedMembers = (surface) => statement(surface, UNREFLECTED);
3273
3437
  // dist/model/capabilities/manifest.js
3274
3438
  function describeBound(modules, bound, ctx) {
3275
3439
  const out = [];
3440
+ const anyBound = Object.values(bound).some((obj) => obj && typeof obj === "object");
3276
3441
  for (const m of modules) {
3277
3442
  const accessor = camelCase(m.capability);
3278
3443
  const obj = bound[accessor];
3279
- if (!obj || typeof obj !== "object")
3444
+ if (!obj || typeof obj !== "object") {
3445
+ const events = anyBound ? emitsOf(m, [], ctx) : [];
3446
+ if (events.length && ctx?.capabilities?.has(m.capability)) {
3447
+ out.push({ capability: m.capability, reads: [], actions: [], undescribedActions: [], events });
3448
+ }
3280
3449
  continue;
3450
+ }
3281
3451
  const descriptors = Object.getOwnPropertyDescriptors(obj);
3282
3452
  const reads2 = [];
3283
3453
  const actions = [];
@@ -3800,7 +3970,6 @@ var MOTION_CMD = {
3800
3970
  * payload key AND pass mChannel 0 explicitly; the two are separate choices, not linked.)
3801
3971
  *
3802
3972
  * ⚠️ Replay + readback confirmed on a HomeBase-attached T8425 (1719 `0`→`1`→`0`), NOT byte-captured.
3803
- * Provenance is `apk`, not `verified`: a divergent-but-also-accepted frame can't be ruled out.
3804
3973
  */
3805
3974
  HUMAN_ONLY_AT_NIGHT: 1719,
3806
3975
  /**
@@ -3814,7 +3983,7 @@ var MOTION_CMD = {
3814
3983
  * form, so a feature that is on reads as off.
3815
3984
  * Decoded by {@link decodeRadarWdSwitch} to match the app.
3816
3985
  *
3817
- * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured. Provenance `apk`.
3986
+ * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured.
3818
3987
  */
3819
3988
  LOITERING_DETECTION: 2706,
3820
3989
  /**
@@ -4138,7 +4307,7 @@ var MOTION_MEMBERS = {
4138
4307
  param: MOTION_CMD.HUMAN_ONLY_AT_NIGHT,
4139
4308
  type: "bool",
4140
4309
  kind: "boolean",
4141
- provenance: "apk",
4310
+ provenance: "verified",
4142
4311
  description: "Restrict AI classification to night-time only (1719). \u26A0\uFE0F Replay + readback confirmed on a HomeBase-attached T8425, not byte-captured.",
4143
4312
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
4144
4313
  write: (v, ctx) => {
@@ -4154,7 +4323,7 @@ var MOTION_MEMBERS = {
4154
4323
  param: MOTION_CMD.LOITERING_DETECTION,
4155
4324
  type: "bool",
4156
4325
  kind: "boolean",
4157
- provenance: "apk",
4326
+ provenance: "verified",
4158
4327
  coerce: (raw) => decodeRadarWdSwitch(raw) ?? false,
4159
4328
  description: "Loitering detection \u2014 alert on lingering rather than passing (2706). Observed on the T8214 doorbell only; the read is object-OR-scalar, matching the app's own decode.",
4160
4329
  requires: [MOTION_CMD.LOITERING_DETECTION],
@@ -4260,6 +4429,7 @@ var MOTION = {
4260
4429
  };
4261
4430
 
4262
4431
  // dist/model/capabilities/person-detection.js
4432
+ var IDENTIFIED_PERSON_CLAIM = { homeBaseAttached: true };
4263
4433
  var PERSON_DETECTION = {
4264
4434
  capability: "person_detection",
4265
4435
  description: "AI human detection enable switch and live detected state.",
@@ -4276,8 +4446,18 @@ var PERSON_DETECTION = {
4276
4446
  */
4277
4447
  events: [
4278
4448
  { source: "push", match: DoorbellPushEvent.FACE_DETECTION, emit: "personDetected" },
4279
- { source: "push", match: HB3PairedDevicePushEvent.IDENTITY_PERSON_DETECTION, emit: "personDetected" },
4280
- { source: "push", match: HB3PairedDevicePushEvent.STRANGER_PERSON_DETECTION, emit: "strangerDetected" }
4449
+ {
4450
+ source: "push",
4451
+ match: HB3PairedDevicePushEvent.IDENTITY_PERSON_DETECTION,
4452
+ emit: "personDetected",
4453
+ claim: IDENTIFIED_PERSON_CLAIM
4454
+ },
4455
+ {
4456
+ source: "push",
4457
+ match: HB3PairedDevicePushEvent.STRANGER_PERSON_DETECTION,
4458
+ emit: "strangerDetected",
4459
+ claim: IDENTIFIED_PERSON_CLAIM
4460
+ }
4281
4461
  ]
4282
4462
  };
4283
4463
 
@@ -5025,8 +5205,10 @@ var AUDIO = {
5025
5205
 
5026
5206
  // dist/model/capabilities/camera.js
5027
5207
  var CAMERA_CMD = {
5028
- /** Camera on/off. switch is inverted: camera ON ⇒ 0, OFF ⇒ 1. */
5208
+ /** Camera on/off. Read and write polarity depend on the family and topology. */
5029
5209
  CAMERA_ENABLE: 1035,
5210
+ /** Wrapped T8410 power switch on HomeBase 3. */
5211
+ PRIVACY_MODE: 6250,
5030
5212
  /**
5031
5213
  * Camera status LED on/off — the "power/recording" indicator. The
5032
5214
  * app JS has a sibling param `1056` (`APP_CMD_LIVEVIEW_LED_SWITCH`) for the SAME UI "Status Light"
@@ -5235,6 +5417,8 @@ function isEnableBitPolarity(ctx) {
5235
5417
  const t = ctx.deviceType;
5236
5418
  if (t === void 0)
5237
5419
  return false;
5420
+ if (t === DeviceType.INDOOR_CAMERA && ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE))
5421
+ return false;
5238
5422
  if (isIndoorCamera(ctx) && !isIndoorCamMini(ctx) && !isIndoorPanTiltS350(ctx))
5239
5423
  return true;
5240
5424
  return ENABLE_BIT_FLOODLIGHT_TYPES.has(t);
@@ -5246,7 +5430,27 @@ var ENABLE_BIT_FLOODLIGHT_TYPES = /* @__PURE__ */ new Set([
5246
5430
  function powerValue(on, ctx) {
5247
5431
  return isEnableBitPolarity(ctx) ? on ? 1 : 0 : on ? 0 : 1;
5248
5432
  }
5433
+ function isT8410HomeBase3(ctx) {
5434
+ return ctx.model === "T8410" && ctx.stationSerial?.startsWith("T8030") === true;
5435
+ }
5436
+ function usesHomeBasePowerPayload(ctx) {
5437
+ if (!isT8410HomeBase3(ctx) || !ctx.firmwareVersion || !/^\d+(?:\.\d+){2,3}$/.test(ctx.firmwareVersion))
5438
+ return false;
5439
+ const version = ctx.firmwareVersion.split(".").map(Number);
5440
+ const minimum = [2, 3, 1, 0];
5441
+ for (let i = 0; i < minimum.length; i++) {
5442
+ const difference = (version[i] ?? 0) - minimum[i];
5443
+ if (difference !== 0)
5444
+ return difference > 0;
5445
+ }
5446
+ return true;
5447
+ }
5448
+ function hasUnreflectedHomeBasePower(ctx) {
5449
+ return usesHomeBasePowerPayload(ctx) && !ctx.paramIds.has(2001);
5450
+ }
5249
5451
  function powerCommand(on, ctx) {
5452
+ if (usesHomeBasePowerPayload(ctx))
5453
+ return setPayload(CAMERA_CMD.PRIVACY_MODE, { switch: on ? 0 : 1 }, ctx, 0);
5250
5454
  return setScalar(CAMERA_CMD.CAMERA_ENABLE, powerValue(on, ctx), ctx, "auto");
5251
5455
  }
5252
5456
  function privacyCommand(enabled, channel) {
@@ -5264,28 +5468,34 @@ function enablementReflection(on, ctx) {
5264
5468
  const alias = CAMERA_MEMBERS.enabled.readAliases[0].paramType;
5265
5469
  if (ctx.paramIds.has(alias))
5266
5470
  return { param: alias, expected: on, observed: on };
5471
+ if (hasUnreflectedHomeBasePower(ctx))
5472
+ return void 0;
5267
5473
  if (ctx.paramIds.has(CAMERA_CMD.CAMERA_ENABLE)) {
5268
- return { param: CAMERA_CMD.CAMERA_ENABLE, expected: powerValue(on, ctx), observed: on };
5474
+ return {
5475
+ param: CAMERA_CMD.CAMERA_ENABLE,
5476
+ expected: powerValue(on, ctx),
5477
+ observed: on
5478
+ };
5269
5479
  }
5270
5480
  return void 0;
5271
5481
  }
5272
5482
  function refuseWhenDisabled(ctx, read) {
5273
- if (read("enabled")?.value === false)
5483
+ if (!hasUnreflectedHomeBasePower(ctx) && read("enabled")?.value === false)
5274
5484
  throw new CameraDisabledError(ctx.name ?? ctx.serial);
5275
5485
  }
5276
5486
  var CAMERA_MEMBERS = {
5277
5487
  /**
5278
- * The READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
5488
+ * The default READ is the *disable*-bit convention (1035 "0" ⇒ ON, 2001 direct); the WRITE polarity is
5279
5489
  * family-dependent — see `powerValue` / `isEnableBitPolarity`. Battery/solo cams report the state under
5280
5490
  * 1035, standalone indoor/outdoor cams under 2001 OPEN_DEVICE with direct polarity, so 2001 is a
5281
5491
  * read-alias. Both verified live, and the write polarity is confirmed against the app's own frames.
5282
5492
  *
5283
- * The read and the setter observe the SAME wire on every family — see `powerCommand` — which is what
5284
- * makes this value track what it is told, and what lets `enablementReflection` confirm a write.
5493
+ * T8410 on HomeBase 3 reports 1035 as an enable bit ("1" ⇒ ON). That reported state does not reliably
5494
+ * reflect the privacy command. `readReflectsWrite` states this distinction.
5285
5495
  *
5286
5496
  * The privacy param (6250) is reported by the outdoor-PT family and by no other camera measured, and both
5287
5497
  * of its polarities are observed. It is deliberately NOT aliased here: it moved in the same step as 1035, so
5288
- * the reading cannot say whether power and privacy are one state or two, and the app drives 1035 — so
5498
+ * the reading cannot say whether power and privacy are one state or two — so
5289
5499
  * aliasing a second param could only fold two possible states into one getter for no gain.
5290
5500
  */
5291
5501
  enabled: {
@@ -5294,8 +5504,10 @@ var CAMERA_MEMBERS = {
5294
5504
  kind: "boolean",
5295
5505
  provenance: "verified",
5296
5506
  invert: true,
5507
+ invertFor: (ctx) => isT8410HomeBase3(ctx) ? false : void 0,
5297
5508
  readAliases: [{ paramType: 2001, invert: false }],
5298
- description: "Camera enabled. Family-dependent wire param: 1035 CMD_DEVS_SWITCH (disable bit, battery/solo cams) or 2001 OPEN_DEVICE (standalone indoor/outdoor). Reliable on/off status source (a live-stream probe is not).",
5509
+ readReflectsWrite: (ctx) => !hasUnreflectedHomeBasePower(ctx),
5510
+ description: "Camera enabled. Family-dependent wire param: 1035 CMD_DEVS_SWITCH (enable bit on T8410/HomeBase 3, disable bit on battery/solo cams) or 2001 OPEN_DEVICE (standalone indoor/outdoor). Reported on/off state; unreflectedMembers identifies commands whose effect this report does not reliably reflect.",
5299
5511
  observation: {
5300
5512
  event: "cameraEnabledChanged",
5301
5513
  reflects: (value, ctx) => enablementReflection(asBool(value), ctx),
@@ -5522,7 +5734,7 @@ var CAMERA_MEMBERS = {
5522
5734
  * value, and it exists only on a device bound to a provider. Each is declared once, with its
5523
5735
  * signature taken FROM {@link MediaProvider} — so a change there is a compile error here, not a drift.
5524
5736
  *
5525
- * Every pull is refused where {@link CAMERA_MEMBERS.enabled} reads false, with {@link CameraDisabledError} —
5737
+ * Every pull is refused where a reliable {@link CAMERA_MEMBERS.enabled} reads false, with {@link CameraDisabledError} —
5526
5738
  * `live`, `snapshotLive`, `record`, `openReadable` and `recordFragments` alike, since each opens media on a
5527
5739
  * camera that serves none. That reading is the on/off source; a live probe is not one, since a disabled
5528
5740
  * camera answers a start with audio and never a video frame.
@@ -5555,6 +5767,7 @@ var CAMERA_MEMBERS = {
5555
5767
  refuseWhenDisabled(ctx, read);
5556
5768
  return m.recordFragments({ powered: poweredOf(ctx), ...opts });
5557
5769
  }), "Continuous fragmented-MP4 (CMAF) recording."),
5770
+ downloadRecording: provided("media", (m) => m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
5558
5771
  /**
5559
5772
  * Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
5560
5773
  * not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
@@ -5596,7 +5809,7 @@ var SIREN_CMD = {
5596
5809
  ALARM_TEST: 1826,
5597
5810
  /** Manually stop a sounding alarm (app `APP_CMD_SIREN_SENSOR_MANUAL_STOP_ALARM`). */
5598
5811
  MANUAL_STOP: 1871,
5599
- /** Station-family HomeBase duration alarm, verified live on T8010 (app `SET_TONE_FILE`). */
5812
+ /** Station-family HomeBase duration alarm, verified live on T8010 and T8030 (app `SET_TONE_FILE`). */
5600
5813
  HOMEBASE_TONE: 1201,
5601
5814
  /** HomeBase alarm volume percentage (app `CMD_SET_HUB_SPK_VOLUME`). */
5602
5815
  HUB_SPK_VOLUME: 1235,
@@ -5611,7 +5824,7 @@ var SirenAlarmDuration = { Min1: 60, Min5: 300, Min10: 600, Min15: 900 };
5611
5824
  var HubAlarmTone = { Tone1: 1, Tone2: 2 };
5612
5825
  var HUB_ALARM_EVIDENCE = [SIREN_CMD.HUB_ALARM_TONE, 1282];
5613
5826
  function isStationAlarmOutput(ctx) {
5614
- return ctx.deviceType === DeviceType.STATION;
5827
+ return ctx.deviceType === DeviceType.STATION || ctx.deviceType === DeviceType.HB3;
5615
5828
  }
5616
5829
  function isEvidencedStationAlarm(ctx) {
5617
5830
  return isStationAlarmOutput(ctx) && HUB_ALARM_EVIDENCE.some((param) => ctx.paramIds.has(param));
@@ -6733,9 +6946,9 @@ function parseQuickResponses(voiceList) {
6733
6946
  var DOORBELL_MEMBERS = {
6734
6947
  /**
6735
6948
  * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
6736
- * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame,
6737
- * not merely the param id observed live: direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape
6738
- * as its 1703 sibling — captured, not inferred from the shared param range.
6949
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
6950
+ * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
6951
+ * inferred from the shared param range.
6739
6952
  */
6740
6953
  chimeSwitch: {
6741
6954
  param: DOORBELL_CMD.CHIME_SWITCH,
@@ -6746,9 +6959,8 @@ var DOORBELL_MEMBERS = {
6746
6959
  write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6747
6960
  },
6748
6961
  /**
6749
- * `provenance` is "verified" not "mega": the actual write wire is confirmed (
6750
- * our own P2P decrypt), not merely the param id observed on a live device. Direct-binary
6751
- * `[ch][value][acct]`, 1=on/0=off — verified live on a T8214 (ON then OFF).
6962
+ * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
6963
+ * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
6752
6964
  */
6753
6965
  mechanicalChimeSwitch: {
6754
6966
  param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
@@ -6779,7 +6991,7 @@ var DOORBELL_MEMBERS = {
6779
6991
  type: "number",
6780
6992
  unit: "%",
6781
6993
  kind: "percent",
6782
- provenance: "mega",
6994
+ provenance: "verified",
6783
6995
  writtenElsewhere: true,
6784
6996
  description: "Ringtone volume (1708; confirmed on T8214, observed value 80). The WRITE is `audio`'s setRingtoneVolume \u2014 that capability owns every volume wire."
6785
6997
  },
@@ -6884,7 +7096,7 @@ var DOORBELL_MEMBERS = {
6884
7096
  param: 1710,
6885
7097
  type: "string",
6886
7098
  kind: "text",
6887
- provenance: "mega",
7099
+ provenance: "verified",
6888
7100
  description: "Notification config JSON {notification_motion_onoff,notification_ring_onoff,notification_style} (1710; confirmed on T8214)."
6889
7101
  },
6890
7102
  /**
@@ -7538,127 +7750,48 @@ var ARMING_MEMBERS = {
7538
7750
  try {
7539
7751
  return sink.dispatch(alarmDelayCommand(mode, config, ctx));
7540
7752
  } catch (e) {
7541
- return Promise.reject(e);
7542
- }
7543
- }, "Write the full per-mode alarm/arm-delay configuration.")
7544
- };
7545
- var ARMING = {
7546
- capability: "arming",
7547
- description: "Station/device guard (arming) mode.",
7548
- members: ARMING_MEMBERS,
7549
- properties: propertiesOf(ARMING_MEMBERS),
7550
- // A reported guard-mode param is the verified proof; every station-codec device also owns an
7551
- // arming surface as part of its baseline.
7552
- detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7553
- ownedByStation: true,
7554
- /**
7555
- * Station-scoped push events. The hub reports a guard-mode switch and the alarm lifecycle on the
7556
- * generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
7557
- * capability (see the barrel's event index).
7558
- *
7559
- * The alarm ids carry a static `phase` so one event name covers the whole lifecycle, the same shape
7560
- * `battery` uses for its threshold pushes. The mode-switch push is known to identify WHICH mode and
7561
- * what triggered the change, but neither has been observed on the wire here, so this emits the bare
7562
- * transition and refreshes the `mode` read rather than trusting a decoded field.
7563
- */
7564
- events: [
7565
- {
7566
- source: "push",
7567
- match: CusPushEvent.MODE_SWITCH,
7568
- emit: "armingModeChanged",
7569
- refresh: { member: "mode" }
7570
- },
7571
- { source: "push", match: CusPushEvent.ALARM, emit: "alarm", payload: { phase: "triggered" } },
7572
- { source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
7573
- ]
7574
- };
7575
-
7576
- // dist/model/capabilities/storage.js
7577
- var STORAGE = {
7578
- capability: "storage",
7579
- description: "Local storage (SD card / eMMC / HDD). Behaviour-only until a capacity param is captured.",
7580
- properties: [],
7581
- detection: { codecs: ["station"] }
7582
- };
7583
-
7584
- // dist/core/raw-dp-writer.js
7585
- var WIRE = { VARINT: 0, LENGTH_DELIMITED: 2 };
7586
- function encodeVarint(value) {
7587
- if (!Number.isSafeInteger(value) || value < 0) {
7588
- throw new Error(`varint: ${value} is not a non-negative safe integer`);
7589
- }
7590
- const out = [];
7591
- let v = value;
7592
- while (v > 127) {
7593
- out.push(v % 128 + 128);
7594
- v = Math.floor(v / 128);
7595
- }
7596
- out.push(v);
7597
- return out;
7598
- }
7599
- function zigzag(value) {
7600
- if (!Number.isSafeInteger(value))
7601
- throw new Error(`zigzag: ${value} is not a safe integer`);
7602
- return value < 0 ? -2 * value - 1 : 2 * value;
7603
- }
7604
- var RawDpWriter = class _RawDpWriter {
7605
- body = [];
7606
- /** A varint-valued field (`int32`, `uint32`, `bool`, an enum member). */
7607
- int(field, value) {
7608
- this.body.push(...encodeVarint(tag(field, WIRE.VARINT)), ...encodeVarint(value));
7609
- return this;
7610
- }
7611
- /** A signed varint field (`sint32`, `sint64`), ZigZag-encoded — see {@link zigzag}. */
7612
- sint(field, value) {
7613
- return this.int(field, zigzag(value));
7614
- }
7615
- /** A boolean field, on protobuf's own terms: `1` or `0` in a varint. */
7616
- bool(field, value) {
7617
- return this.int(field, value ? 1 : 0);
7618
- }
7619
- /** A length-delimited field carrying raw bytes — a string, a blob, or a message built elsewhere. */
7620
- bytes(field, value) {
7621
- const bytes2 = value instanceof Uint8Array ? Array.from(value) : [...value];
7622
- this.body.push(...encodeVarint(tag(field, WIRE.LENGTH_DELIMITED)), ...encodeVarint(bytes2.length), ...bytes2);
7623
- return this;
7624
- }
7625
- /**
7626
- * A nested sub-message, built by `build` into a writer of its own.
7627
- *
7628
- * The sub-message is emitted even when `build` writes nothing into it. That is not an oversight: an
7629
- * EMPTY sub-message is how these protocols say "this subsystem exists and is in its zero state", as
7630
- * opposed to saying nothing about it, and the readers on the other side of this file depend on the
7631
- * distinction. Omitting the call is what says nothing about it.
7632
- */
7633
- sub(field, build) {
7634
- const inner = new _RawDpWriter();
7635
- build(inner);
7636
- return this.bytes(field, inner.toBytes());
7637
- }
7638
- /** The message body on its own, unframed — for nesting into another writer. */
7639
- toBytes() {
7640
- return [...this.body];
7641
- }
7753
+ return Promise.reject(e);
7754
+ }
7755
+ }, "Write the full per-mode alarm/arm-delay configuration.")
7756
+ };
7757
+ var ARMING = {
7758
+ capability: "arming",
7759
+ description: "Station/device guard (arming) mode.",
7760
+ members: ARMING_MEMBERS,
7761
+ properties: propertiesOf(ARMING_MEMBERS),
7762
+ // A reported guard-mode param is the verified proof; every station-codec device also owns an
7763
+ // arming surface as part of its baseline.
7764
+ detection: { evidenceParams: [ARMING_CMD.SET_ARMING], codecs: ["station"] },
7765
+ ownedByStation: true,
7642
7766
  /**
7643
- * The finished DP value: `varint(bodyLength) ++ body`, base64-encoded.
7767
+ * Station-scoped push events. The hub reports a guard-mode switch and the alarm lifecycle on the
7768
+ * generic `CusPushEvent` channel, so these ids are shared with other families and resolve by
7769
+ * capability (see the barrel's event index).
7644
7770
  *
7645
- * This is the envelope `RawDpCodec.decode` unwraps, so a value produced here and read back through the
7646
- * codec is a genuine round-trip rather than two implementations of the same guess.
7771
+ * The alarm ids carry a static `phase` so one event name covers the whole lifecycle, the same shape
7772
+ * `battery` uses for its threshold pushes. The mode-switch push is known to identify WHICH mode and
7773
+ * what triggered the change, but neither has been observed on the wire here, so this emits the bare
7774
+ * transition and refreshes the `mode` read rather than trusting a decoded field.
7647
7775
  */
7648
- finish() {
7649
- return Buffer.from([...encodeVarint(this.body.length), ...this.body]).toString("base64");
7650
- }
7776
+ events: [
7777
+ {
7778
+ source: "push",
7779
+ match: CusPushEvent.MODE_SWITCH,
7780
+ emit: "armingModeChanged",
7781
+ refresh: { member: "mode" }
7782
+ },
7783
+ { source: "push", match: CusPushEvent.ALARM, emit: "alarm", payload: { phase: "triggered" } },
7784
+ { source: "push", match: CusPushEvent.ALARM_DELAY, emit: "alarm", payload: { phase: "delayed" } }
7785
+ ]
7786
+ };
7787
+
7788
+ // dist/model/capabilities/storage.js
7789
+ var STORAGE = {
7790
+ capability: "storage",
7791
+ description: "Local storage (SD card / eMMC / HDD). Behaviour-only until a capacity param is captured.",
7792
+ properties: [],
7793
+ detection: { codecs: ["station"] }
7651
7794
  };
7652
- function tag(field, wire) {
7653
- if (!Number.isSafeInteger(field) || field < 1)
7654
- throw new Error(`field number: ${field} is not a positive integer`);
7655
- return field * 8 + wire;
7656
- }
7657
- function rawDp(build) {
7658
- const w = new RawDpWriter();
7659
- build(w);
7660
- return w.finish();
7661
- }
7662
7795
 
7663
7796
  // dist/model/capabilities/vacuum-dock.js
7664
7797
  var VACUUM_DOCK_DP = 173;
@@ -11059,7 +11192,8 @@ function resolveProperties(rec, codec, capabilities) {
11059
11192
  model: rec.model,
11060
11193
  category: rec.category,
11061
11194
  capabilities: new Set(capabilities),
11062
- paramIds
11195
+ paramIds,
11196
+ stationSerial: rec.parentSn
11063
11197
  });
11064
11198
  }
11065
11199
 
@@ -13249,7 +13383,8 @@ var Device = class _Device {
13249
13383
  codec: this.codec,
13250
13384
  model: this.model,
13251
13385
  capabilities: new Set(this.capabilities),
13252
- homeBaseAttached: this.stationSn !== this.sn
13386
+ homeBaseAttached: this.stationSn !== this.sn,
13387
+ stationSerial: this.stationSn
13253
13388
  })
13254
13389
  };
13255
13390
  }
@@ -13364,58 +13499,6 @@ function parseCleanRecordDetail(blob, codec) {
13364
13499
  };
13365
13500
  }
13366
13501
 
13367
- // dist/core/lz4-block.js
13368
- var MAX_OUTPUT = 64 * 1024 * 1024;
13369
- function lz4BlockDecompress(block, size) {
13370
- if (!Number.isSafeInteger(size) || size < 0 || size > MAX_OUTPUT)
13371
- return void 0;
13372
- const out = Buffer.alloc(size);
13373
- const end = block.length;
13374
- let pos = 0;
13375
- let written = 0;
13376
- const extend2 = (base) => {
13377
- let len = base;
13378
- if (base !== 15)
13379
- return len;
13380
- for (; ; ) {
13381
- if (pos >= end)
13382
- return void 0;
13383
- const extra = block[pos++];
13384
- len += extra;
13385
- if (extra !== 255)
13386
- return len;
13387
- }
13388
- };
13389
- while (pos < end) {
13390
- const token = block[pos++];
13391
- const literals = extend2(token >> 4);
13392
- if (literals === void 0)
13393
- return void 0;
13394
- if (pos + literals > end || written + literals > size)
13395
- return void 0;
13396
- for (let i = 0; i < literals; i++)
13397
- out[written++] = block[pos++];
13398
- if (pos === end)
13399
- break;
13400
- if (pos + 2 > end)
13401
- return void 0;
13402
- const offset = block[pos] | block[pos + 1] << 8;
13403
- pos += 2;
13404
- if (offset === 0 || offset > written)
13405
- return void 0;
13406
- const match = extend2(token & 15);
13407
- if (match === void 0)
13408
- return void 0;
13409
- const length = match + 4;
13410
- if (written + length > size)
13411
- return void 0;
13412
- let from = written - offset;
13413
- for (let i = 0; i < length; i++)
13414
- out[written++] = out[from++];
13415
- }
13416
- return written === size ? out : void 0;
13417
- }
13418
-
13419
13502
  // dist/model/vacuum-map.js
13420
13503
  var DOCK_KINDS = ["charger", "station"];
13421
13504
  var MAP_QUALITIES = ["incomplete", "rough", "effective", "listFull"];
@@ -14326,6 +14409,7 @@ __export(codec_exports, {
14326
14409
  buildLookupWithKeyPayload2: () => buildLookupWithKeyPayload2,
14327
14410
  buildRawCommandPayload: () => buildRawCommandPayload,
14328
14411
  buildStringCommandPayload: () => buildStringCommandPayload,
14412
+ buildStringPairCommandPayload: () => buildStringPairCommandPayload,
14329
14413
  buildVoidCommandPayload: () => buildVoidCommandPayload,
14330
14414
  decodeP2PCloudIPs: () => decodeP2PCloudIPs,
14331
14415
  decryptP2PData: () => decryptP2PData,
@@ -14362,6 +14446,11 @@ var ResponseMessageType = {
14362
14446
  LOOKUP_ADDR: Buffer.from([241, 64]),
14363
14447
  LOOKUP_ADDR2: Buffer.from([241, 130]),
14364
14448
  CAM_ID: Buffer.from([241, 66]),
14449
+ /**
14450
+ * The device's own address record, sent in answer to CHECK_CAM ahead of CAM_ID: its 20-byte device id, one
14451
+ * record in `encodeSelfAddress`'s shape naming the address it answers from, then 8 zero bytes (44-byte payload).
14452
+ */
14453
+ CAM_ADDR: Buffer.from([241, 67]),
14365
14454
  TURN_SERVER_CAM_ID: Buffer.from([241, 132]),
14366
14455
  PING: Buffer.from([241, 224]),
14367
14456
  PONG: Buffer.from([241, 225]),
@@ -14573,6 +14662,12 @@ function buildIntStringCommandPayload(value, valueSub, strValue, channel = 0, ke
14573
14662
  data
14574
14663
  ]);
14575
14664
  }
14665
+ function buildStringPairCommandPayload(strValue, strValueSub, channel = 0, key, encType = 1) {
14666
+ const encrypted = !!key && key.length === 16;
14667
+ const body = Buffer.concat([Buffer.alloc(5), stringWithLength(strValue), stringWithLength(strValueSub)]);
14668
+ const data = encrypted ? encryptP2PData(paddingP2PData(body), key) : body;
14669
+ return buildRawCommandPayload(data, channel, encrypted ? encType : 0);
14670
+ }
14576
14671
  function buildRawCommandPayload(data, channel = 0, signCode = 0, magic = [1, 0], streamId = 0) {
14577
14672
  const header = Buffer.allocUnsafe(2);
14578
14673
  header.writeUInt16LE(data.length, 0);
@@ -15204,6 +15299,7 @@ var SEQUENCE_LOOKBACK = 32768;
15204
15299
  var STALE_RETRANSMIT_DEPTH = 1024;
15205
15300
  var PUNCH_PROBE_SOCKETS = 7;
15206
15301
  var REORDER_WAIT_MS = 250;
15302
+ var VIDEO_REORDER_WAIT_MS = 700;
15207
15303
  var REORDER_MAX_DATAGRAMS = 128;
15208
15304
  var MAX_TRACED_DATAGRAM_GAPS = 8;
15209
15305
  var CMD_SET_PAYLOAD = 1350;
@@ -15214,6 +15310,7 @@ var CMD_DATABASE_IMAGE = 1308;
15214
15310
  var CMD_DATABASE = 1306;
15215
15311
  var DB_QUERY = { FULL_TABLE: 1e4, COMBINATION_WITH_AI: 10011 };
15216
15312
  var GCM_AAD = Buffer.from("eufy security");
15313
+ var LEVEL2_SEQ_BASE = 16909056;
15217
15314
  function paramReport(payload) {
15218
15315
  const list = payload?.params;
15219
15316
  if (!Array.isArray(list))
@@ -15228,6 +15325,49 @@ function paramReport(payload) {
15228
15325
  return Object.keys(out).length ? out : void 0;
15229
15326
  }
15230
15327
  var traceSequence = 0;
15328
+ var DB_TABLE_TIMEOUT_MS = 15e3;
15329
+ function firstTableRows(text2) {
15330
+ const decoded = Buffer.from(text2, "latin1").toString("utf8");
15331
+ for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
15332
+ const end = closingBrace(decoded, start);
15333
+ if (end < 0)
15334
+ return void 0;
15335
+ let parsed;
15336
+ try {
15337
+ parsed = JSON.parse(decoded.slice(start, end + 1));
15338
+ } catch {
15339
+ continue;
15340
+ }
15341
+ const data = parsed?.data;
15342
+ if (Array.isArray(data))
15343
+ return data;
15344
+ }
15345
+ return void 0;
15346
+ }
15347
+ function closingBrace(text2, start) {
15348
+ let depth = 0;
15349
+ let inString = false;
15350
+ let escaped = false;
15351
+ for (let i = start; i < text2.length; i++) {
15352
+ const c = text2[i];
15353
+ if (inString) {
15354
+ if (escaped)
15355
+ escaped = false;
15356
+ else if (c === "\\")
15357
+ escaped = true;
15358
+ else if (c === '"')
15359
+ inString = false;
15360
+ continue;
15361
+ }
15362
+ if (c === '"')
15363
+ inString = true;
15364
+ else if (c === "{")
15365
+ depth++;
15366
+ else if (c === "}" && --depth === 0)
15367
+ return i;
15368
+ }
15369
+ return -1;
15370
+ }
15231
15371
  var P2PSession = class _P2PSession extends EventEmitter2 {
15232
15372
  cfg;
15233
15373
  socket;
@@ -15262,8 +15402,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15262
15402
  audioStalled = false;
15263
15403
  audioRetransmitTimer;
15264
15404
  lastPongData;
15265
- /** When this connection last received a PONG — `undefined` until the first, see {@link pathSilentMs}. */
15266
- lastPongAt;
15405
+ /** When the selected peer last answered outbound P2P traffic on this connection. */
15406
+ lastPeerAt;
15267
15407
  /** Whether the silence has already been stated, so it is traced once per connection rather than per read. */
15268
15408
  pathStaleTraced = false;
15269
15409
  lookupTimer;
@@ -15332,17 +15472,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15332
15472
  /**
15333
15473
  * How long this connection's path has been silent, or nothing where it has never answered.
15334
15474
  *
15335
- * A PONG is the station stating that the path is alive. `undefined` is neither alive nor dead: it is a station
15336
- * that has said nothing either way.
15475
+ * PONG and ACK from the selected peer prove the path answers outbound traffic. `undefined` means no such reply
15476
+ * has arrived since connection, so silence alone does not establish a stale path.
15337
15477
  */
15338
15478
  get pathSilentMs() {
15339
- return this.lastPongAt === void 0 ? void 0 : Date.now() - this.lastPongAt;
15479
+ return this.lastPeerAt === void 0 ? void 0 : Date.now() - this.lastPeerAt;
15340
15480
  }
15341
15481
  /**
15342
15482
  * Whether this path can still be committed to, on the evidence the heartbeat gives.
15343
15483
  *
15344
- * False where a pong arrived and then stopped for {@link PATH_SILENCE_MS}. A station that has never ponged is
15345
- * not known to be dead, so it answers true.
15484
+ * False where selected-peer replies arrived and then stopped for {@link PATH_SILENCE_MS}. A connection with
15485
+ * no post-connect reply is not known to be dead, so it answers true.
15346
15486
  *
15347
15487
  * Traces the silence once per connection, on the read that first observes it.
15348
15488
  */
@@ -15618,7 +15758,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15618
15758
  this.trace({
15619
15759
  phase: "lookup-channels",
15620
15760
  local: !this.cfg.noBroadcast || this.cfg.localAddress !== void 0,
15621
- cloud: this.cloudLookup !== void 0
15761
+ cloud: this.cloudLookup !== void 0,
15762
+ ...this.cloudLookup ? {} : { cloudMissing: dskKey ? "cloud-addresses" : "dsk-key" }
15622
15763
  });
15623
15764
  this.sendLookups();
15624
15765
  this.lookupTimer = setInterval(() => this.sendLookups(), LOOKUP_RETRY_MS);
@@ -15720,10 +15861,18 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15720
15861
  * straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
15721
15862
  * recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
15722
15863
  * as a cause.
15864
+ *
15865
+ * CAM_ADDR is recognised and dropped: it names the address the device is already answering from, and the
15866
+ * CAM_ID that follows it is what completes the connect.
15723
15867
  */
15724
15868
  onMessage(msg, rinfo, socket = this.socket) {
15725
15869
  if (!socket)
15726
15870
  return;
15871
+ const fromConnectedPeer = this.connected && this.connectAddress?.host === rinfo.address && this.connectAddress.port === rinfo.port;
15872
+ if (fromConnectedPeer && (hasHeader(msg, ResponseMessageType.PONG) || hasHeader(msg, ResponseMessageType.ACK))) {
15873
+ this.lastPeerAt = Date.now();
15874
+ this.pathStaleTraced = false;
15875
+ }
15727
15876
  if (!hasHeader(msg, ResponseMessageType.DATA)) {
15728
15877
  this.logger.debug(`[p2p] ${this.cfg.stationSn} <<< ${rinfo.address}:${rinfo.port} header=${msg.subarray(0, 2).toString("hex")} len=${msg.length}`);
15729
15878
  }
@@ -15741,8 +15890,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15741
15890
  this.onConnected({ host: rinfo.address, port: rinfo.port }, socket);
15742
15891
  } else if (hasHeader(msg, ResponseMessageType.PONG)) {
15743
15892
  this.lastPongData = msg.length > 4 ? msg.subarray(4) : void 0;
15744
- this.lastPongAt = Date.now();
15745
- this.pathStaleTraced = false;
15746
15893
  } else if (hasHeader(msg, ResponseMessageType.PING)) {
15747
15894
  this.send({ host: rinfo.address, port: rinfo.port }, RequestMessageType.PONG, void 0, socket);
15748
15895
  } else if (hasHeader(msg, ResponseMessageType.ACK)) {
@@ -15750,7 +15897,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
15750
15897
  } else if (hasHeader(msg, ResponseMessageType.DATA)) {
15751
15898
  if (this.connected)
15752
15899
  this.onData(msg, { host: rinfo.address, port: rinfo.port });
15753
- } else {
15900
+ } else if (!hasHeader(msg, ResponseMessageType.CAM_ADDR)) {
15754
15901
  this.logger.debug(`[p2p] ${this.cfg.stationSn} UNHANDLED payload hex: ${msg.toString("hex")}`);
15755
15902
  }
15756
15903
  }
@@ -16041,6 +16188,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16041
16188
  this.seqNumber = this.seqNumber + 1 & 65535;
16042
16189
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16043
16190
  }
16191
+ /** Send a level-1 {@link buildStringPairCommandPayload} command on `channel`. */
16192
+ sendStringPairCommand(commandType, strValue, strValueSub, channel) {
16193
+ if (!this.connectAddress)
16194
+ throw new Error(`P2P session ${this.cfg.stationSn} is not connected`);
16195
+ const data = Buffer.concat([
16196
+ buildCommandHeader(this.seqNumber, commandType),
16197
+ buildStringPairCommandPayload(strValue, strValueSub, channel, this.level1Key, 1)
16198
+ ]);
16199
+ this.seqNumber = this.seqNumber + 1 & 65535;
16200
+ this.send(this.connectAddress, RequestMessageType.DATA, data);
16201
+ }
16044
16202
  /**
16045
16203
  * Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
16046
16204
  * target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
@@ -16090,8 +16248,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16090
16248
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16091
16249
  return true;
16092
16250
  }
16093
- /** Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`. */
16094
- stopLiveMedia(channel = STATION_CHANNEL3, accountId = "") {
16251
+ /**
16252
+ * Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
16253
+ * its runtime topology takes, as {@link startLiveMedia} selects its start:
16254
+ * - `homeBaseAttached`: `CMD_SET_PAYLOAD` (1350) wrapping `{cmd:1004, mChannel:channel}` at level-2.
16255
+ * - own-session at level-2: the direct 1004 frame whose entire plaintext is the camera channel as a
16256
+ * `uint32`, the same 4-byte body as the attached talkback frames. The app sends this frame on closing
16257
+ * an own-session camera's live view; the camera stops streaming on it within a second, and keeps
16258
+ * streaming through the 1350-wrapped form.
16259
+ * - without a level-2 key: the bare 1004 command.
16260
+ */
16261
+ stopLiveMedia(channel = STATION_CHANNEL3, accountId = "", homeBaseAttached = false) {
16095
16262
  this.liveStartedChannels.delete(channel);
16096
16263
  for (const [sequence, pending] of this.unackedLiveStarts) {
16097
16264
  if (pending.channel === channel)
@@ -16101,7 +16268,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16101
16268
  clearInterval(this.liveStartRetransmitTimer);
16102
16269
  this.liveStartRetransmitTimer = void 0;
16103
16270
  }
16104
- if (this.level2Key) {
16271
+ if (this.level2Key && !homeBaseAttached) {
16272
+ const body = Buffer.allocUnsafe(4);
16273
+ body.writeUInt32LE(channel >>> 0, 0);
16274
+ this.sendRawLevel2Bytes(body, channel, CMD_STOP_REALTIME_MEDIA, 8);
16275
+ } else if (this.level2Key) {
16105
16276
  this.sendMediaPayloadLevel2(CMD_STOP_REALTIME_MEDIA, channel, accountId, {});
16106
16277
  } else {
16107
16278
  this.sendCommand(CMD_STOP_REALTIME_MEDIA, channel);
@@ -16318,11 +16489,6 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16318
16489
  this.seqNumber = this.seqNumber + 1 & 65535;
16319
16490
  this.send(this.connectAddress, RequestMessageType.DATA, data);
16320
16491
  }
16321
- /**
16322
- * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ [seq,03,02,01](4) ‖
16323
- * ciphertext`, AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of
16324
- * `decryptLevel2`. The 4-byte sub-header is cleartext (skipped on decrypt); `seq` is a counter.
16325
- */
16326
16492
  /**
16327
16493
  * Decode a `CMD_VIDEO_FRAME` (1300) payload into clean Annex-B H.264 (the 22-byte frame header
16328
16494
  * stripped). Reversed from the V6 app + live H.264 captures: the 22-byte header is
@@ -16389,6 +16555,11 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16389
16555
  }
16390
16556
  return this.rsaModulusHex;
16391
16557
  }
16558
+ /**
16559
+ * Encrypt a level-2 command body (signCode 8): `tag(16) ‖ nonce(12) ‖ seq(4) ‖ ciphertext`,
16560
+ * AES-256-GCM under the negotiated session key, AAD "eufy security". Inverse of `decryptLevel2`.
16561
+ * The 4-byte sub-header is cleartext (skipped on decrypt); `seq` counts up from {@link LEVEL2_SEQ_BASE}.
16562
+ */
16392
16563
  encryptLevel2(plaintext) {
16393
16564
  if (!this.level2Key)
16394
16565
  return void 0;
@@ -16396,8 +16567,8 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16396
16567
  const c = createCipheriv3("aes-256-gcm", this.level2Key, nonce);
16397
16568
  c.setAAD(GCM_AAD);
16398
16569
  const ct = Buffer.concat([c.update(plaintext), c.final()]);
16399
- const sub2 = Buffer.from([this.level2Seq & 255, 3, 2, 1]);
16400
- this.level2Seq = this.level2Seq + 1 & 255;
16570
+ const sub2 = Buffer.alloc(4);
16571
+ sub2.writeUInt32LE(LEVEL2_SEQ_BASE + this.level2Seq++ >>> 0);
16401
16572
  return Buffer.concat([c.getAuthTag(), nonce, sub2, ct]);
16402
16573
  }
16403
16574
  /** Send a no-arg command frame (e.g. CMD_GATEWAYINFO) on a channel (default: the station channel). */
@@ -16443,8 +16614,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16443
16614
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
16444
16615
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
16445
16616
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
16617
+ *
16618
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
16619
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
16620
+ * one's buffer and answered as its rows.
16446
16621
  */
16447
16622
  queryDatabase(table, opts = {}) {
16623
+ if (this.dbReadInFlight)
16624
+ throw new Error(`queryDatabase: ${this.cfg.stationSn} is already reading a table`);
16448
16625
  if (!this.connectAddress)
16449
16626
  throw new Error("not connected");
16450
16627
  const channel = opts.channel ?? STATION_CHANNEL3;
@@ -16507,6 +16684,49 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16507
16684
  query: this.fullTableQuery()
16508
16685
  });
16509
16686
  }
16687
+ /**
16688
+ * Query one on-station table and answer its rows, once the reply is whole.
16689
+ *
16690
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
16691
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
16692
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16693
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16694
+ * complete reply.
16695
+ *
16696
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16697
+ * session throws, so a second reply cannot land in this buffer.
16698
+ */
16699
+ async readDatabase(table, opts = {}) {
16700
+ if (opts.signal?.aborted)
16701
+ throw new Error("readDatabase: aborted");
16702
+ this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16703
+ this.dbReadInFlight = true;
16704
+ try {
16705
+ return await new Promise((resolve, reject) => {
16706
+ let text2 = "";
16707
+ const onChunk = (chunk) => {
16708
+ text2 += chunk.text;
16709
+ const rows = firstTableRows(text2);
16710
+ if (rows)
16711
+ settle(() => resolve(rows));
16712
+ };
16713
+ const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16714
+ const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
16715
+ const settle = (finish) => {
16716
+ clearTimeout(timer);
16717
+ this.off("dbChunk", onChunk);
16718
+ opts.signal?.removeEventListener("abort", onAbort);
16719
+ finish();
16720
+ };
16721
+ this.on("dbChunk", onChunk);
16722
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
16723
+ });
16724
+ } finally {
16725
+ this.dbReadInFlight = false;
16726
+ }
16727
+ }
16728
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
16729
+ dbReadInFlight = false;
16510
16730
  /**
16511
16731
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
16512
16732
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -16574,7 +16794,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16574
16794
  * if the missing one arrives. A logical frame's payload spans datagrams that carry no header of their
16575
16795
  * own, so reassembling around a hole is impossible; waiting for it is what keeps the frame whole.
16576
16796
  *
16577
- * Only once {@link REORDER_WAIT_MS} passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
16797
+ * Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
16578
16798
  * treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
16579
16799
  */
16580
16800
  onData(msg, addr) {
@@ -16629,7 +16849,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16629
16849
  const timer = setTimeout(() => {
16630
16850
  reorder.timer = void 0;
16631
16851
  this.abandonHole(dataType);
16632
- }, REORDER_WAIT_MS);
16852
+ }, dataType === P2PDataType.VIDEO ? VIDEO_REORDER_WAIT_MS : REORDER_WAIT_MS);
16633
16853
  timer.unref?.();
16634
16854
  reorder.timer = timer;
16635
16855
  }
@@ -17818,7 +18038,7 @@ var LiveStream = class extends EventEmitter3 {
17818
18038
  clearTimeout(this.stallTimer);
17819
18039
  this.stallTimer = void 0;
17820
18040
  try {
17821
- this.session.stopLiveMedia(this.opts.channel, this.opts.accountId);
18041
+ this.session.stopLiveMedia(this.opts.channel, this.opts.accountId, this.opts.homeBaseAttached);
17822
18042
  } catch (e) {
17823
18043
  this.logger.debug(`[live] stopLiveMedia ignored: ${e instanceof Error ? e.message : e}`);
17824
18044
  }
@@ -18219,6 +18439,190 @@ function annexbToJpeg(annexb, spawn2) {
18219
18439
  });
18220
18440
  }
18221
18441
 
18442
+ // dist/transport/p2p/recording-download.js
18443
+ import { createDecipheriv as createDecipheriv7 } from "node:crypto";
18444
+ var RECORDING_DATA_TYPE = 3;
18445
+ var VIDEO_HEADER_LEN2 = 22;
18446
+ var AUDIO_HEADER_LEN2 = 16;
18447
+ var AUDIO_NONCE_START = 32;
18448
+ var AUDIO_BODY_START = 44;
18449
+ var AUDIO_CODEC_AAC_LC = 0;
18450
+ var FINISH_CHANNEL = 0;
18451
+ var FIRST_FRAME_WAIT_MS = 2e4;
18452
+ var IDLE_END_MS = 15e3;
18453
+ var DEFAULT_TRANSFER_MS = 12e4;
18454
+ var MAX_TRANSFER_BYTES = 32 * 1024 * 1024;
18455
+ function homeBase2RecordingPath(channel, recording) {
18456
+ if (!/^\d{14}$/.test(recording) || !Number.isInteger(channel) || channel < 0 || channel > 99)
18457
+ return void 0;
18458
+ return `/media/mmcblk0p1/Camera${String(channel).padStart(2, "0")}/${recording}.dat`;
18459
+ }
18460
+ function receiveRecording(session, request) {
18461
+ let resolveFrames;
18462
+ let rejectFrames;
18463
+ const frames = new Promise((resolve, reject) => {
18464
+ resolveFrames = resolve;
18465
+ rejectFrames = reject;
18466
+ });
18467
+ let resolveDrained;
18468
+ const drained = new Promise((resolve) => resolveDrained = resolve);
18469
+ const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
18470
+ let collected = [];
18471
+ const started = Date.now();
18472
+ let lastFrameAt = started;
18473
+ let received = false;
18474
+ let bytes2 = 0;
18475
+ let draining;
18476
+ let delivered = false;
18477
+ let listening = true;
18478
+ const deliver = (error) => {
18479
+ if (delivered)
18480
+ return;
18481
+ delivered = true;
18482
+ request.signal?.removeEventListener("abort", onAbort);
18483
+ if (error)
18484
+ rejectFrames(error);
18485
+ else
18486
+ resolveFrames(collected);
18487
+ };
18488
+ const stop = () => {
18489
+ if (!listening)
18490
+ return;
18491
+ listening = false;
18492
+ clearInterval(tick);
18493
+ session.off("data", onData);
18494
+ resolveDrained();
18495
+ };
18496
+ const incomplete = (why) => deliver(new RecordingDownloadError("incomplete", `the recording transfer ${why} before its finish frame`));
18497
+ const drain = (why) => {
18498
+ draining ??= why;
18499
+ collected = [];
18500
+ };
18501
+ const onData = (frame) => {
18502
+ if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
18503
+ if (frame.channel !== request.channel && frame.channel !== FINISH_CHANNEL)
18504
+ return;
18505
+ if (draining)
18506
+ incomplete(draining);
18507
+ else
18508
+ deliver();
18509
+ return stop();
18510
+ }
18511
+ if (frame.channel !== request.channel)
18512
+ return;
18513
+ if (frame.commandId !== CommandType.CMD_VIDEO_FRAME && frame.commandId !== CommandType.CMD_AUDIO_FRAME)
18514
+ return;
18515
+ if (frame.dataType !== RECORDING_DATA_TYPE)
18516
+ return;
18517
+ received = true;
18518
+ lastFrameAt = Date.now();
18519
+ if (draining)
18520
+ return;
18521
+ collected.push({ commandId: frame.commandId, signCode: frame.signCode, raw: Buffer.from(frame.raw) });
18522
+ bytes2 += frame.raw.length;
18523
+ if (bytes2 >= MAX_TRANSFER_BYTES)
18524
+ drain(`reached ${MAX_TRANSFER_BYTES} bytes`);
18525
+ };
18526
+ const onAbort = () => {
18527
+ deliver(request.signal?.reason instanceof Error ? request.signal.reason : new Error("aborted"));
18528
+ drain("was aborted");
18529
+ };
18530
+ const tick = setInterval(() => {
18531
+ const now2 = Date.now();
18532
+ if (!received && now2 - started > FIRST_FRAME_WAIT_MS) {
18533
+ deliver(new RecordingDownloadError("no-data", `the station sent nothing within ${FIRST_FRAME_WAIT_MS} ms`));
18534
+ stop();
18535
+ } else if (received && now2 - lastFrameAt > IDLE_END_MS) {
18536
+ incomplete(draining ?? `went quiet for ${IDLE_END_MS} ms`);
18537
+ stop();
18538
+ } else if (now2 - started > 2 * limitMs && draining) {
18539
+ incomplete(draining);
18540
+ stop();
18541
+ } else if (now2 - started > limitMs) {
18542
+ drain(`ran past ${limitMs} ms`);
18543
+ }
18544
+ }, 250);
18545
+ if (request.signal?.aborted) {
18546
+ onAbort();
18547
+ stop();
18548
+ return { frames, drained };
18549
+ }
18550
+ request.signal?.addEventListener("abort", onAbort, { once: true });
18551
+ session.on("data", onData);
18552
+ try {
18553
+ session.sendStringPairCommand(CommandType.CMD_DOWNLOAD_VIDEO, request.path, request.accountId, request.channel);
18554
+ } catch (error) {
18555
+ deliver(error instanceof Error ? error : new Error(String(error)));
18556
+ stop();
18557
+ }
18558
+ return { frames, drained };
18559
+ }
18560
+ function openAudio(key, nonce, tag2, body) {
18561
+ try {
18562
+ const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
18563
+ decipher.setAAD(VIDEO_GCM_AAD);
18564
+ decipher.setAuthTag(tag2);
18565
+ return Buffer.concat([decipher.update(body), decipher.final()]);
18566
+ } catch {
18567
+ return void 0;
18568
+ }
18569
+ }
18570
+ function decodeRecording(frames, eccPrivateKeyHex) {
18571
+ const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
18572
+ if (eccPrivateKey.length !== 32)
18573
+ throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
18574
+ const decoder = new VideoFrameDecoder(eccPrivateKey);
18575
+ const video = [];
18576
+ const audio = [];
18577
+ const numbers = /* @__PURE__ */ new Set();
18578
+ let firstStamp;
18579
+ let lastStamp;
18580
+ for (const { commandId, raw } of frames) {
18581
+ if (commandId === CommandType.CMD_VIDEO_FRAME) {
18582
+ const header = parseVideoFrameHeader(raw);
18583
+ if (!header)
18584
+ continue;
18585
+ 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;
18586
+ if (!h264?.length)
18587
+ continue;
18588
+ video.push(h264);
18589
+ numbers.add(header.sequence);
18590
+ const stamp = raw.readUIntLE(14, 6);
18591
+ firstStamp ??= stamp;
18592
+ lastStamp = stamp;
18593
+ } else if (commandId === CommandType.CMD_AUDIO_FRAME) {
18594
+ const mediaKey = decoder.currentMediaKey;
18595
+ if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
18596
+ continue;
18597
+ const len = raw.readUInt32LE(0);
18598
+ if (raw.length < AUDIO_BODY_START + len)
18599
+ continue;
18600
+ 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));
18601
+ if (aac?.length)
18602
+ audio.push(aac);
18603
+ }
18604
+ }
18605
+ if (!video.length)
18606
+ throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
18607
+ const ordered = [...numbers];
18608
+ let missingFrames = 0;
18609
+ for (let i = 1; i < ordered.length; i++) {
18610
+ const step = ordered[i] - ordered[i - 1] & 65535;
18611
+ if (step > 1 && step < 32768)
18612
+ missingFrames += step - 1;
18613
+ }
18614
+ const span = ordered.length > 1 ? ordered[ordered.length - 1] - ordered[0] & 65535 : 0;
18615
+ const durationMs = (lastStamp ?? 0) - (firstStamp ?? 0);
18616
+ return {
18617
+ video: Buffer.concat(video),
18618
+ ...audio.length ? { audio: Buffer.concat(audio) } : {},
18619
+ frames: ordered.length,
18620
+ missingFrames,
18621
+ durationMs,
18622
+ fps: durationMs > 0 && span > 0 ? Math.round(span * 1e5 / durationMs) / 100 : 0
18623
+ };
18624
+ }
18625
+
18222
18626
  // dist/transport/p2p/shared-live-source.js
18223
18627
  import { EventEmitter as EventEmitter4 } from "node:events";
18224
18628
  var MAX_TIMER_DELAY_MS = 2147483647;
@@ -20399,6 +20803,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
20399
20803
  talkbacks = /* @__PURE__ */ new Map();
20400
20804
  /** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
20401
20805
  cipherKeyCache = /* @__PURE__ */ new Map();
20806
+ /** The recording download in flight per station; a station serves one at a time. */
20807
+ recordingDownloads = /* @__PURE__ */ new Map();
20402
20808
  constructor(deps) {
20403
20809
  this.deps = deps;
20404
20810
  this.manager = new SessionManager({
@@ -20611,28 +21017,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
20611
21017
  localAddress,
20612
21018
  dskKey,
20613
21019
  noBroadcast: this.deps.noBroadcast,
20614
- resolveCipherKey: async (cipherId) => {
20615
- if (this.cipherKeyCache.has(cipherId))
20616
- return this.cipherKeyCache.get(cipherId);
20617
- let ecc;
20618
- try {
20619
- const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
20620
- ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
20621
- if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
20622
- ecc = ciphers[0].ecc_private_key;
20623
- this.traceOnStation(session, {
20624
- phase: "cipher-fallback",
20625
- cipherId,
20626
- answeredCipherId: Number(ciphers[0].cipher_id)
20627
- });
20628
- }
20629
- } catch (e) {
20630
- this.deps.onError(e instanceof Error ? e : new Error(String(e)));
20631
- }
20632
- if (ecc !== void 0)
20633
- this.cipherKeyCache.set(cipherId, ecc);
20634
- return ecc;
20635
- },
21020
+ resolveCipherKey: (cipherId) => this.cipherKeyFor(session, cipherId, adminUserId, stationSn),
20636
21021
  logger: this.deps.logger ?? noopLogger
20637
21022
  });
20638
21023
  session.on("error", (e) => this.reportError(e));
@@ -20656,6 +21041,33 @@ var P2PCommandRouter = class _P2PCommandRouter {
20656
21041
  session.on("data", (f) => this.deps.onFrame(stationSn, f));
20657
21042
  return session;
20658
21043
  }
21044
+ /**
21045
+ * The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
21046
+ * answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
21047
+ * cached.
21048
+ */
21049
+ async cipherKeyFor(session, cipherId, userId, stationSn) {
21050
+ if (this.cipherKeyCache.has(cipherId))
21051
+ return this.cipherKeyCache.get(cipherId);
21052
+ let ecc;
21053
+ try {
21054
+ const ciphers = await this.deps.mega.getCiphers([cipherId], userId, stationSn);
21055
+ ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
21056
+ if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
21057
+ ecc = ciphers[0].ecc_private_key;
21058
+ this.traceOnStation(session, {
21059
+ phase: "cipher-fallback",
21060
+ cipherId,
21061
+ answeredCipherId: Number(ciphers[0].cipher_id)
21062
+ });
21063
+ }
21064
+ } catch (e) {
21065
+ this.deps.onError(e instanceof Error ? e : new Error(String(e)));
21066
+ }
21067
+ if (ecc !== void 0)
21068
+ this.cipherKeyCache.set(cipherId, ecc);
21069
+ return ecc;
21070
+ }
20659
21071
  /**
20660
21072
  * Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
20661
21073
  * stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
@@ -20783,7 +21195,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
20783
21195
  * start has no level-1 form for.
20784
21196
  */
20785
21197
  mediaProviderFor(sn) {
21198
+ const recordings = this.downloadsRecordings(sn) ? { downloadRecording: (opts) => this.downloadRecording(sn, opts) } : {};
20786
21199
  return {
21200
+ ...recordings,
20787
21201
  snapshotLive: async (opts) => {
20788
21202
  const source = await this.sharedLiveSourceFor(sn, opts ?? {});
20789
21203
  return captureSnapshotFromShared(source, {
@@ -20822,6 +21236,63 @@ var P2PCommandRouter = class _P2PCommandRouter {
20822
21236
  }
20823
21237
  };
20824
21238
  }
21239
+ /**
21240
+ * Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
21241
+ * and frame formats are confirmed.
21242
+ */
21243
+ downloadsRecordings(sn) {
21244
+ const dev = this.recordFor(sn);
21245
+ if (!dev)
21246
+ return false;
21247
+ const parentSn = stationOf(dev);
21248
+ return parentSn !== sn && this.recordFor(parentSn)?.model === "T8010";
21249
+ }
21250
+ /**
21251
+ * Download one recording a HomeBase 2 holds for an attached camera, then decode it
21252
+ * ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
21253
+ * on the camera's channel, so a download starts only once the previous one has drained
21254
+ * ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
21255
+ * while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
21256
+ * turn is held until the transfer drains.
21257
+ */
21258
+ async downloadRecording(sn, opts) {
21259
+ opts.signal?.throwIfAborted();
21260
+ const parentSn = this.stationKeyOf(sn);
21261
+ const previous = this.recordingDownloads.get(parentSn) ?? Promise.resolve();
21262
+ const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, opts));
21263
+ const turn = Promise.allSettled([previous, started.then((t) => t.drained)]).then(() => {
21264
+ if (this.recordingDownloads.get(parentSn) === turn)
21265
+ this.recordingDownloads.delete(parentSn);
21266
+ });
21267
+ this.recordingDownloads.set(parentSn, turn);
21268
+ return started.then((t) => t.recording);
21269
+ }
21270
+ /** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
21271
+ waitTurn(previous, signal) {
21272
+ return abortable(previous.then(() => void 0, () => void 0), signal);
21273
+ }
21274
+ /**
21275
+ * Start one recording download, once the station's previous download has drained: the decoded recording,
21276
+ * and when the station is done sending it.
21277
+ */
21278
+ async startRecordingDownload(sn, opts) {
21279
+ const { session, parentSn, channel, accountId } = await this.resolveSession(sn, { signal: opts.signal });
21280
+ const path = homeBase2RecordingPath(channel, opts.recording);
21281
+ if (!path)
21282
+ throw new RecordingDownloadError("invalid-recording", `no recording named ${opts.recording}`);
21283
+ const eccKey = await this.cipherKeyFor(session, opts.cipherId, accountId, parentSn);
21284
+ if (!eccKey) {
21285
+ throw new RecordingDownloadError("key-unavailable", `cipher ${opts.cipherId} could not be obtained`);
21286
+ }
21287
+ const transfer = receiveRecording(session, {
21288
+ path,
21289
+ accountId,
21290
+ channel,
21291
+ timeoutMs: opts.timeoutMs,
21292
+ signal: opts.signal
21293
+ });
21294
+ return { recording: transfer.frames.then((frames) => decodeRecording(frames, eccKey)), drained: transfer.drained };
21295
+ }
20825
21296
  /**
20826
21297
  * Open a {@link Talkback} on a device's camera channel.
20827
21298
  *
@@ -21170,9 +21641,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
21170
21641
  *
21171
21642
  * Reached two ways, both idempotent: the session's own `close` event, when it died while still the
21172
21643
  * station's registered session, and {@link SessionManagerOpts.onAutoClose}, when the manager closed it
21173
- * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} disposes its own
21174
- * sources first, and {@link replaceUnreachableSession} keeps its source alive on purpose to rewarm it
21175
- * on the replacement session.
21644
+ * unasked. A close a CALLER made is deliberately not routed here — {@link closeAll} and
21645
+ * {@link resolveSession} dispose their own sources first, while {@link replaceUnreachableSession} keeps
21646
+ * its source alive on purpose to rewarm it on the replacement session.
21176
21647
  */
21177
21648
  tearDownStation(stationSn) {
21178
21649
  this.manager.remove(stationSn);
@@ -21491,9 +21962,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
21491
21962
  *
21492
21963
  * A session whose {@link P2PSession.pathAnswering} is false is closed and re-resolved before it is handed
21493
21964
  * over: the station answers every heartbeat, so a path silent past several of them is gone. A session
21494
- * reporting nothing about its path is not reporting that evidence and is handed over as it is. Replaced at
21495
- * most once per resolution, so a station whose replacement is silent too is returned rather than closed
21496
- * again.
21965
+ * reporting nothing about its path is not reporting that evidence and is handed over as it is. Cached live
21966
+ * sources on the replaced session are discarded before closing it (see {@link tearDownStation}).
21967
+ * Replaced at most once per resolution, so a station whose replacement is silent too is returned rather
21968
+ * than closed again.
21497
21969
  */
21498
21970
  async resolveSession(sn, opts = {}, rebuilt = false) {
21499
21971
  const dev = await this.deviceFor(sn);
@@ -21507,6 +21979,10 @@ var P2PCommandRouter = class _P2PCommandRouter {
21507
21979
  }
21508
21980
  if (session.pathAnswering === false && !rebuilt) {
21509
21981
  (this.deps.logger ?? noopLogger).debug(`[p2p] ${parentSn} path stopped answering \u2014 rebuilding before use`);
21982
+ for (const [key, sessionKey] of [...this.liveSessionKeys]) {
21983
+ if (sessionKey === parentSn)
21984
+ this.dropLiveSource(key);
21985
+ }
21510
21986
  await this.manager.close(parentSn).catch((error) => this.reportError(error instanceof Error ? error : new Error(String(error))));
21511
21987
  return await this.resolveSession(sn, opts, true);
21512
21988
  }
@@ -24347,6 +24823,7 @@ var MEDIA_FAILURE_REASONS = [
24347
24823
  // dist/transport/stored-image-cache.js
24348
24824
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
24349
24825
  var MAX_REMEMBERED_URLS = 64;
24826
+ var NOT_YET_PUBLISHED_RETRY_MS = [2e3, 4e3];
24350
24827
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
24351
24828
  var StoredImageCache = class {
24352
24829
  downloader;
@@ -24384,6 +24861,8 @@ var StoredImageCache = class {
24384
24861
  if (state.seenUrls.has(url))
24385
24862
  return;
24386
24863
  state.seenUrls.add(url);
24864
+ clearTimeout(state.retrying);
24865
+ state.retrying = void 0;
24387
24866
  while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
24388
24867
  const oldest = state.seenUrls.values().next();
24389
24868
  if (oldest.done)
@@ -24395,14 +24874,15 @@ var StoredImageCache = class {
24395
24874
  url,
24396
24875
  sequence: ++state.nextSequence,
24397
24876
  observedAt: this.clock(),
24398
- generation: this.generation
24877
+ generation: this.generation,
24878
+ retries: 0
24399
24879
  };
24400
24880
  this.pump();
24401
24881
  }
24402
24882
  /** Return retained bytes without starting or awaiting network work. */
24403
24883
  snapshotStored(deviceKey) {
24404
24884
  const state = this.devices.get(deviceKey);
24405
- const reason = state?.queued || state?.inFlight ? "pending" : state?.reason ?? "not-observed";
24885
+ const reason = state?.queued || state?.inFlight || state?.retrying ? "pending" : state?.reason ?? "not-observed";
24406
24886
  if (reason !== "pending" && state?.lifecycleError)
24407
24887
  return Promise.reject(state.lifecycleError);
24408
24888
  if (state?.retained)
@@ -24436,6 +24916,8 @@ var StoredImageCache = class {
24436
24916
  if (error !== void 0 && this.isLifecycleError(error)) {
24437
24917
  state.lifecycleError = error;
24438
24918
  state.reason = void 0;
24919
+ } else if (image === void 0 && this.retryLater(state, candidate, error)) {
24920
+ state.lifecycleError = void 0;
24439
24921
  } else if (image === void 0) {
24440
24922
  state.lifecycleError = void 0;
24441
24923
  state.reason = "download-failed";
@@ -24452,6 +24934,21 @@ var StoredImageCache = class {
24452
24934
  }
24453
24935
  this.pump();
24454
24936
  }
24937
+ /** Schedule the next attempt after a 404; answers whether one was scheduled. */
24938
+ retryLater(state, candidate, error) {
24939
+ const delay = NOT_YET_PUBLISHED_RETRY_MS[candidate.retries];
24940
+ const tag2 = mediaFailureTag(error);
24941
+ if (delay === void 0 || tag2.cause !== "http-status" || tag2.status !== 404 || state.queued)
24942
+ return false;
24943
+ const timer = setTimeout(() => {
24944
+ state.retrying = void 0;
24945
+ state.queued = { ...candidate, retries: candidate.retries + 1 };
24946
+ this.pump();
24947
+ }, delay);
24948
+ timer.unref?.();
24949
+ state.retrying = timer;
24950
+ return true;
24951
+ }
24455
24952
  isValidJpeg(image) {
24456
24953
  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;
24457
24954
  }
@@ -25047,6 +25544,11 @@ function recordString(raw, key) {
25047
25544
  const v = typeof nested === "string" && nested ? nested : raw[key];
25048
25545
  return typeof v === "string" && v ? v : void 0;
25049
25546
  }
25547
+ function adminUserIdFrom(raw) {
25548
+ const member = (raw ?? {}).member;
25549
+ const id = member?.admin_user_id;
25550
+ return typeof id === "string" && id ? id : void 0;
25551
+ }
25050
25552
  function tuyaDevIdFrom(raw) {
25051
25553
  for (const field of ["tuya_uuid", "tuya_virtual_id", "tuya_device_id", "virtualId"]) {
25052
25554
  const v = recordString(raw, field);
@@ -25204,13 +25706,7 @@ var EufyMega = class extends EventEmitter9 {
25204
25706
  onError: (e) => this.reportError(e),
25205
25707
  // The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id`,
25206
25708
  // falling back to the logged-in account's user id (the confirmed script path does the same).
25207
- resolveAccountId: (dev) => {
25208
- const member = (dev.raw ?? {}).member;
25209
- const adminId = member?.admin_user_id;
25210
- if (typeof adminId === "string" && adminId)
25211
- return adminId;
25212
- return this.mega.auth?.userId ?? "";
25213
- },
25709
+ resolveAccountId: (dev) => adminUserIdFrom(dev.raw) ?? this.mega.auth?.userId ?? "",
25214
25710
  // Fetch + parse a gallery effect from the HTTP catalogue into the serializable spec. The catalogue
25215
25711
  // lives in the http layer (transport/http/light-catalog); the mqtt router owns the frame bytes.
25216
25712
  resolvePreset: (presetId) => resolveLightEffect(this.mega, presetId),
@@ -26657,6 +27153,37 @@ var EufyMega = class extends EventEmitter9 {
26657
27153
  getP2pSessions() {
26658
27154
  return this.p2p.getSessions();
26659
27155
  }
27156
+ /**
27157
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
27158
+ *
27159
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
27160
+ * station, because they were never uploaded — the station holds them.
27161
+ *
27162
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
27163
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
27164
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
27165
+ *
27166
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
27167
+ * returned as they arrive; a caller that wants only named people filters them.
27168
+ */
27169
+ async getStationFaces(stationSn, opts = {}) {
27170
+ if (!this.registry.list().length)
27171
+ await this.getDevices();
27172
+ const station = this.p2p.stationKeyOf(stationSn);
27173
+ const accountId = this.adminUserIdOf(station);
27174
+ if (!accountId)
27175
+ throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
27176
+ await this.p2p.ensureStation(station, opts.signal);
27177
+ const session = this.p2p.getSessions().get(station);
27178
+ if (!session)
27179
+ throw new Error(`getStationFaces: no P2P session for ${station}`);
27180
+ const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
27181
+ return rows.filter((row) => typeof row === "object" && row !== null);
27182
+ }
27183
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
27184
+ adminUserIdOf(stationSn) {
27185
+ return adminUserIdFrom(this.registry.list().find((d) => d.sn === stationSn)?.raw);
27186
+ }
26660
27187
  /**
26661
27188
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
26662
27189
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.
@@ -26817,6 +27344,7 @@ var EufyMega = class extends EventEmitter9 {
26817
27344
  model: rec.model,
26818
27345
  category: dev.category,
26819
27346
  serial: sn,
27347
+ stationSerial: rec.parentSn,
26820
27348
  // Identity metadata for `info`, for a host's device-registry surface. `name` = the app-shown
26821
27349
  // device_name (registry's `name`), not the resolved codec/inferred name. firmware/hardware come
26822
27350
  // straight off the device record: `main_sw_version` / `main_hw_version` — the same fields the v6
@@ -26905,7 +27433,10 @@ var EufyMega = class extends EventEmitter9 {
26905
27433
  thumbnailUrl: ev.thumbnailUrl,
26906
27434
  payload: ev.payload
26907
27435
  };
26908
- for (const out of decodeEvent(signal2, this.capsForEvent(ev.deviceSn))) {
27436
+ const caps = this.capsForEvent(ev.deviceSn);
27437
+ const decoded = decodeEvent(signal2, caps);
27438
+ this.opts.logger?.debug(`[push] in: eventType=${ev.eventType} device=${caps ? "known" : ev.deviceSn ? "unknown" : "none"} events=${decoded.length}`);
27439
+ for (const out of decoded) {
26909
27440
  this.emitSemantic(out.event, out.payload, { edge: true, refresh: out.refresh });
26910
27441
  const dsn = out.payload.deviceSn ?? ev.deviceSn;
26911
27442
  if (dsn)
@@ -27189,26 +27720,16 @@ var SolixClient = class _SolixClient {
27189
27720
  }
27190
27721
  /** Build the encrypted, signed `/passport/login` request body + headers for the negotiated key. */
27191
27722
  async postLogin(kx, verifyCode, limitedToken) {
27192
- const { clientPublicKeyHex, encryptedPassword } = encryptLoginPassword(this.password);
27193
27723
  const bodyObj = {
27194
- email: this.email,
27195
- password: encryptedPassword,
27196
- ab: this.country,
27197
- client_secret_info: { public_key: clientPublicKeyHex },
27724
+ ...loginCredentials(this.email, this.password, this.country),
27198
27725
  answer: "",
27199
27726
  captcha_id: "",
27200
27727
  verify_code: verifyCode ?? "",
27201
27728
  login_id: ""
27202
27729
  };
27203
27730
  const encBody = encryptBody(JSON.stringify(bodyObj), kx.shareKey);
27204
- const ts = nowSec();
27205
- const once = genId();
27206
27731
  return this.post(this.apiHost, SOLIX_ENDPOINTS.login, encBody, this.authHeaders({
27207
- "x-encryption-info": "algo_ecdh",
27208
- "x-key-ident": kx.keyIdent,
27209
- "x-request-ts": ts,
27210
- "x-request-once": once,
27211
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27732
+ ...signedHeaders(kx, encBody),
27212
27733
  ...limitedToken ? { "x-auth-token": limitedToken } : {}
27213
27734
  }));
27214
27735
  }
@@ -27223,13 +27744,12 @@ var SolixClient = class _SolixClient {
27223
27744
  * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
27224
27745
  */
27225
27746
  classifyLogin(data, isVerify) {
27226
- const userId = data.ap_cloud_user_id ?? data.user_id;
27227
- const authToken = data.auth_token;
27228
- if (!userId || !authToken)
27747
+ const reply = readLoginReply(data);
27748
+ if (!reply)
27229
27749
  throw new Error(`Solix login returned no session: ${JSON.stringify(data).slice(0, 160)}`);
27230
- const faInfo = data.fa_info ?? {};
27231
- if (!isVerify && faInfo.info) {
27232
- this.pending2fa = { limitedToken: authToken, userId, geoKey: data.geo_key };
27750
+ const { userId, authToken } = reply;
27751
+ if (!isVerify && reply.twoFactorPending) {
27752
+ this.pending2fa = { limitedToken: authToken, userId, geoKey: reply.geoKey };
27233
27753
  return { status: "2fa", method: "code sent by the passport" };
27234
27754
  }
27235
27755
  this.pending2fa = void 0;
@@ -27238,7 +27758,7 @@ var SolixClient = class _SolixClient {
27238
27758
  userId,
27239
27759
  gtoken: gtoken(userId),
27240
27760
  apiHost: this.apiHost,
27241
- tokenExpiresAt: Number(data.token_expires_at ?? 0) || 0
27761
+ tokenExpiresAt: reply.tokenExpiresAt
27242
27762
  };
27243
27763
  this.persist();
27244
27764
  return { status: "ok", session: this.session_ };
@@ -27393,14 +27913,8 @@ var SolixClient = class _SolixClient {
27393
27913
  throw new Error("not authenticated \u2014 call login() first");
27394
27914
  const kx = await this.keyExchange();
27395
27915
  const encBody = encryptBody(JSON.stringify(payload), kx.shareKey);
27396
- const ts = nowSec();
27397
- const once = genId();
27398
27916
  const env = await this.post(this.apiHost, path, encBody, this.baseHeaders({
27399
- "x-encryption-info": "algo_ecdh",
27400
- "x-key-ident": kx.keyIdent,
27401
- "x-request-ts": ts,
27402
- "x-request-once": once,
27403
- "x-signature": signRequest(kx.shareKey, ts, once, encBody),
27917
+ ...signedHeaders(kx, encBody),
27404
27918
  "x-auth-token": this.session_.authToken,
27405
27919
  gtoken: this.session_.gtoken
27406
27920
  }));
@@ -28168,6 +28682,7 @@ export {
28168
28682
  ROOM_SUCTIONS,
28169
28683
  RTSP_MEMBERS,
28170
28684
  RawDpWriter,
28685
+ RecordingDownloadError,
28171
28686
  RecordingQuality,
28172
28687
  RtspRecordingMode,
28173
28688
  SCENE_INVALID_REASONS,
@@ -28211,6 +28726,7 @@ export {
28211
28726
  VACUUM_DOCK_MEMBERS,
28212
28727
  VACUUM_SCHEDULE_ACTIONS,
28213
28728
  VACUUM_SCHEDULE_WEEKDAYS,
28729
+ VIDEO_GCM_AAD,
28214
28730
  VacuumMapStore,
28215
28731
  VideoFrameDecoder,
28216
28732
  WORKING_MODE_MAPS,
@@ -28230,6 +28746,7 @@ export {
28230
28746
  buildEventIndex,
28231
28747
  buildModelIndex,
28232
28748
  buildRealtimeInit,
28749
+ cameraPowerTier,
28233
28750
  captureSnapshotFromShared,
28234
28751
  cellAtPoint,
28235
28752
  claimedParams,