@mega-yfue/eufy-sdk 0.4.0-beta.16 → 0.4.0-beta.17

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.
@@ -20,8 +20,8 @@ import { type P2PSession } from "../transport/p2p/p2p-session.js";
20
20
  import { type CleanRecordPage } from "../model/clean-records.js";
21
21
  import { type AvailabilityObservation, type EufyDevice } from "../core/types.js";
22
22
  import { Device, type DeviceInspection } from "../model/index.js";
23
- import type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, DeviceState, RealtimeReadiness, WaitForRealtimeOptions } from "./types.js";
24
- export type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, AnyDeviceEvent, DeviceState, RealtimePlaneReadiness, RealtimeReadiness, WaitForRealtimeOptions, } from "./types.js";
23
+ import type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, DeviceState, RealtimeReadiness, StationFace, StationFacesOptions, WaitForRealtimeOptions } from "./types.js";
24
+ export type { EufyMegaOptions, EufyMegaEvent, EufyMegaEventMap, AnyDeviceEvent, DeviceState, RealtimePlaneReadiness, RealtimeReadiness, StationFace, StationFacesOptions, WaitForRealtimeOptions, } from "./types.js";
25
25
  export interface EufyMega {
26
26
  on<E extends EufyMegaEvent>(event: E, listener: (...args: EufyMegaEventMap[E]) => void): this;
27
27
  once<E extends EufyMegaEvent>(event: E, listener: (...args: EufyMegaEventMap[E]) => void): this;
@@ -764,6 +764,22 @@ export declare class EufyMega extends EventEmitter {
764
764
  * every event the station's own session already delivers.
765
765
  */
766
766
  getP2pSessions(): Map<string, P2PSession>;
767
+ /**
768
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
769
+ *
770
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
771
+ * station, because they were never uploaded — the station holds them.
772
+ *
773
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
774
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
775
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
776
+ *
777
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
778
+ * returned as they arrive; a caller that wants only named people filters them.
779
+ */
780
+ getStationFaces(stationSn: string, opts?: StationFacesOptions): Promise<StationFace[]>;
781
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
782
+ private adminUserIdOf;
767
783
  /**
768
784
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
769
785
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.
@@ -221,6 +221,31 @@ export interface DeviceState {
221
221
  */
222
222
  lastSeenMs?: number;
223
223
  }
224
+ /**
225
+ * One person enrolled on a station, as its `person_basic_info` table states them.
226
+ *
227
+ * The station's own table, not the cloud's: an account that keeps its faces on the station never
228
+ * uploaded them, so the cloud roster answers empty for it while this answers the household.
229
+ *
230
+ * `person_id` is the id an `IDENTITY_PERSON_DETECTION` push carries, which is what makes a detection
231
+ * nameable. Every field is optional because the row is the station's to shape — an absent one is a
232
+ * column that row did not carry, never a default — and unrecognised columns are kept verbatim, so a
233
+ * station that states more than this is not truncated by it. Keys are the table's own, uncased.
234
+ */
235
+ export interface StationFace {
236
+ person_id?: number;
237
+ /** The name this person was enrolled under. `stranger<n>` is the station's own placeholder. */
238
+ name?: string;
239
+ relation?: number | string;
240
+ group_id?: number | string;
241
+ [column: string]: unknown;
242
+ }
243
+ /** How long to wait for a station's database reply, and how to give up early. */
244
+ export interface StationFacesOptions {
245
+ /** Default 15s. A full table is a handful of frames; a station that will not answer says nothing. */
246
+ timeoutMs?: number;
247
+ signal?: AbortSignal;
248
+ }
224
249
  /**
225
250
  * A single semantic event tagged with its name — the payload of the catch-all `"event"` listener.
226
251
  * A discriminated union over {@link DeviceEventMap}, so switching on `e.eventName` narrows `e` to that
package/dist/index.js CHANGED
@@ -15266,6 +15266,49 @@ function paramReport(payload) {
15266
15266
  return Object.keys(out).length ? out : void 0;
15267
15267
  }
15268
15268
  var traceSequence = 0;
15269
+ var DB_TABLE_TIMEOUT_MS = 15e3;
15270
+ function firstTableRows(text2) {
15271
+ const decoded = Buffer.from(text2, "latin1").toString("utf8");
15272
+ for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
15273
+ const end = closingBrace(decoded, start);
15274
+ if (end < 0)
15275
+ return void 0;
15276
+ let parsed;
15277
+ try {
15278
+ parsed = JSON.parse(decoded.slice(start, end + 1));
15279
+ } catch {
15280
+ continue;
15281
+ }
15282
+ const data = parsed?.data;
15283
+ if (Array.isArray(data))
15284
+ return data;
15285
+ }
15286
+ return void 0;
15287
+ }
15288
+ function closingBrace(text2, start) {
15289
+ let depth = 0;
15290
+ let inString = false;
15291
+ let escaped = false;
15292
+ for (let i = start; i < text2.length; i++) {
15293
+ const c = text2[i];
15294
+ if (inString) {
15295
+ if (escaped)
15296
+ escaped = false;
15297
+ else if (c === "\\")
15298
+ escaped = true;
15299
+ else if (c === '"')
15300
+ inString = false;
15301
+ continue;
15302
+ }
15303
+ if (c === '"')
15304
+ inString = true;
15305
+ else if (c === "{")
15306
+ depth++;
15307
+ else if (c === "}" && --depth === 0)
15308
+ return i;
15309
+ }
15310
+ return -1;
15311
+ }
15269
15312
  var P2PSession = class _P2PSession extends EventEmitter2 {
15270
15313
  cfg;
15271
15314
  socket;
@@ -16495,8 +16538,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16495
16538
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
16496
16539
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
16497
16540
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
16541
+ *
16542
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
16543
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
16544
+ * one's buffer and answered as its rows.
16498
16545
  */
16499
16546
  queryDatabase(table, opts = {}) {
16547
+ if (this.dbReadInFlight)
16548
+ throw new Error(`queryDatabase: ${this.cfg.stationSn} is already reading a table`);
16500
16549
  if (!this.connectAddress)
16501
16550
  throw new Error("not connected");
16502
16551
  const channel = opts.channel ?? STATION_CHANNEL3;
@@ -16559,6 +16608,49 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16559
16608
  query: this.fullTableQuery()
16560
16609
  });
16561
16610
  }
16611
+ /**
16612
+ * Query one on-station table and answer its rows, once the reply is whole.
16613
+ *
16614
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
16615
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
16616
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16617
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16618
+ * complete reply.
16619
+ *
16620
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16621
+ * session throws, so a second reply cannot land in this buffer.
16622
+ */
16623
+ async readDatabase(table, opts = {}) {
16624
+ if (opts.signal?.aborted)
16625
+ throw new Error("readDatabase: aborted");
16626
+ this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16627
+ this.dbReadInFlight = true;
16628
+ try {
16629
+ return await new Promise((resolve, reject) => {
16630
+ let text2 = "";
16631
+ const onChunk = (chunk) => {
16632
+ text2 += chunk.text;
16633
+ const rows = firstTableRows(text2);
16634
+ if (rows)
16635
+ settle(() => resolve(rows));
16636
+ };
16637
+ const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16638
+ const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
16639
+ const settle = (finish) => {
16640
+ clearTimeout(timer);
16641
+ this.off("dbChunk", onChunk);
16642
+ opts.signal?.removeEventListener("abort", onAbort);
16643
+ finish();
16644
+ };
16645
+ this.on("dbChunk", onChunk);
16646
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
16647
+ });
16648
+ } finally {
16649
+ this.dbReadInFlight = false;
16650
+ }
16651
+ }
16652
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
16653
+ dbReadInFlight = false;
16562
16654
  /**
16563
16655
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
16564
16656
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -25380,6 +25472,11 @@ function recordString(raw, key) {
25380
25472
  const v = typeof nested === "string" && nested ? nested : raw[key];
25381
25473
  return typeof v === "string" && v ? v : void 0;
25382
25474
  }
25475
+ function adminUserIdFrom(raw) {
25476
+ const member = (raw ?? {}).member;
25477
+ const id = member?.admin_user_id;
25478
+ return typeof id === "string" && id ? id : void 0;
25479
+ }
25383
25480
  function tuyaDevIdFrom(raw) {
25384
25481
  for (const field of ["tuya_uuid", "tuya_virtual_id", "tuya_device_id", "virtualId"]) {
25385
25482
  const v = recordString(raw, field);
@@ -25537,13 +25634,7 @@ var EufyMega = class extends EventEmitter9 {
25537
25634
  onError: (e) => this.reportError(e),
25538
25635
  // The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id`,
25539
25636
  // falling back to the logged-in account's user id (the confirmed script path does the same).
25540
- resolveAccountId: (dev) => {
25541
- const member = (dev.raw ?? {}).member;
25542
- const adminId = member?.admin_user_id;
25543
- if (typeof adminId === "string" && adminId)
25544
- return adminId;
25545
- return this.mega.auth?.userId ?? "";
25546
- },
25637
+ resolveAccountId: (dev) => adminUserIdFrom(dev.raw) ?? this.mega.auth?.userId ?? "",
25547
25638
  // Fetch + parse a gallery effect from the HTTP catalogue into the serializable spec. The catalogue
25548
25639
  // lives in the http layer (transport/http/light-catalog); the mqtt router owns the frame bytes.
25549
25640
  resolvePreset: (presetId) => resolveLightEffect(this.mega, presetId),
@@ -26990,6 +27081,37 @@ var EufyMega = class extends EventEmitter9 {
26990
27081
  getP2pSessions() {
26991
27082
  return this.p2p.getSessions();
26992
27083
  }
27084
+ /**
27085
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
27086
+ *
27087
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
27088
+ * station, because they were never uploaded — the station holds them.
27089
+ *
27090
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
27091
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
27092
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
27093
+ *
27094
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
27095
+ * returned as they arrive; a caller that wants only named people filters them.
27096
+ */
27097
+ async getStationFaces(stationSn, opts = {}) {
27098
+ if (!this.registry.list().length)
27099
+ await this.getDevices();
27100
+ const station = this.p2p.stationKeyOf(stationSn);
27101
+ const accountId = this.adminUserIdOf(station);
27102
+ if (!accountId)
27103
+ throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
27104
+ await this.p2p.ensureStation(station, opts.signal);
27105
+ const session = this.p2p.getSessions().get(station);
27106
+ if (!session)
27107
+ throw new Error(`getStationFaces: no P2P session for ${station}`);
27108
+ const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
27109
+ return rows.filter((row) => typeof row === "object" && row !== null);
27110
+ }
27111
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
27112
+ adminUserIdOf(stationSn) {
27113
+ return adminUserIdFrom(this.registry.list().find((d) => d.sn === stationSn)?.raw);
27114
+ }
26993
27115
  /**
26994
27116
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
26995
27117
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.