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

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
@@ -3955,7 +3955,6 @@ var MOTION_CMD = {
3955
3955
  * payload key AND pass mChannel 0 explicitly; the two are separate choices, not linked.)
3956
3956
  *
3957
3957
  * ⚠️ Replay + readback confirmed on a HomeBase-attached T8425 (1719 `0`→`1`→`0`), NOT byte-captured.
3958
- * Provenance is `apk`, not `verified`: a divergent-but-also-accepted frame can't be ruled out.
3959
3958
  */
3960
3959
  HUMAN_ONLY_AT_NIGHT: 1719,
3961
3960
  /**
@@ -3969,7 +3968,7 @@ var MOTION_CMD = {
3969
3968
  * form, so a feature that is on reads as off.
3970
3969
  * Decoded by {@link decodeRadarWdSwitch} to match the app.
3971
3970
  *
3972
- * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured. Provenance `apk`.
3971
+ * ⚠️ Replay + readback confirmed on a T8214 (2706 `0`→`1`→`0`), NOT byte-captured.
3973
3972
  */
3974
3973
  LOITERING_DETECTION: 2706,
3975
3974
  /**
@@ -4293,7 +4292,7 @@ var MOTION_MEMBERS = {
4293
4292
  param: MOTION_CMD.HUMAN_ONLY_AT_NIGHT,
4294
4293
  type: "bool",
4295
4294
  kind: "boolean",
4296
- provenance: "apk",
4295
+ provenance: "verified",
4297
4296
  description: "Restrict AI classification to night-time only (1719). \u26A0\uFE0F Replay + readback confirmed on a HomeBase-attached T8425, not byte-captured.",
4298
4297
  requires: [MOTION_CMD.HUMAN_ONLY_AT_NIGHT],
4299
4298
  write: (v, ctx) => {
@@ -4309,7 +4308,7 @@ var MOTION_MEMBERS = {
4309
4308
  param: MOTION_CMD.LOITERING_DETECTION,
4310
4309
  type: "bool",
4311
4310
  kind: "boolean",
4312
- provenance: "apk",
4311
+ provenance: "verified",
4313
4312
  coerce: (raw) => decodeRadarWdSwitch(raw) ?? false,
4314
4313
  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.",
4315
4314
  requires: [MOTION_CMD.LOITERING_DETECTION],
@@ -6889,9 +6888,9 @@ function parseQuickResponses(voiceList) {
6889
6888
  var DOORBELL_MEMBERS = {
6890
6889
  /**
6891
6890
  * The HOMEBASE as the doorbell's chime — the hub plays the ring, not the wired chime box
6892
- * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame,
6893
- * not merely the param id observed live: direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape
6894
- * as its 1703 sibling — captured, not inferred from the shared param range.
6891
+ * `mechanicalChimeSwitch` drives. `provenance` is "verified" on our own decrypt of the app's frame:
6892
+ * direct-binary `[ch][value][acct]`, 1=on/0=off, the same shape as its 1703 sibling — captured, not
6893
+ * inferred from the shared param range.
6895
6894
  */
6896
6895
  chimeSwitch: {
6897
6896
  param: DOORBELL_CMD.CHIME_SWITCH,
@@ -6902,9 +6901,8 @@ var DOORBELL_MEMBERS = {
6902
6901
  write: (v, ctx) => setScalar(DOORBELL_CMD.CHIME_SWITCH, asBool(v) ? 1 : 0, ctx, "direct-binary")
6903
6902
  },
6904
6903
  /**
6905
- * `provenance` is "verified" not "mega": the actual write wire is confirmed (
6906
- * our own P2P decrypt), not merely the param id observed on a live device. Direct-binary
6907
- * `[ch][value][acct]`, 1=on/0=off — verified live on a T8214 (ON then OFF).
6904
+ * `provenance` is "verified" on our own P2P decrypt of the write wire: direct-binary
6905
+ * `[ch][value][acct]`, 1=on/0=off, verified live on a T8214 (ON then OFF).
6908
6906
  */
6909
6907
  mechanicalChimeSwitch: {
6910
6908
  param: DOORBELL_CMD.MECHANICAL_CHIME_SWITCH,
@@ -6935,7 +6933,7 @@ var DOORBELL_MEMBERS = {
6935
6933
  type: "number",
6936
6934
  unit: "%",
6937
6935
  kind: "percent",
6938
- provenance: "mega",
6936
+ provenance: "verified",
6939
6937
  writtenElsewhere: true,
6940
6938
  description: "Ringtone volume (1708; confirmed on T8214, observed value 80). The WRITE is `audio`'s setRingtoneVolume \u2014 that capability owns every volume wire."
6941
6939
  },
@@ -7040,7 +7038,7 @@ var DOORBELL_MEMBERS = {
7040
7038
  param: 1710,
7041
7039
  type: "string",
7042
7040
  kind: "text",
7043
- provenance: "mega",
7041
+ provenance: "verified",
7044
7042
  description: "Notification config JSON {notification_motion_onoff,notification_ring_onoff,notification_style} (1710; confirmed on T8214)."
7045
7043
  },
7046
7044
  /**
@@ -15266,6 +15264,49 @@ function paramReport(payload) {
15266
15264
  return Object.keys(out).length ? out : void 0;
15267
15265
  }
15268
15266
  var traceSequence = 0;
15267
+ var DB_TABLE_TIMEOUT_MS = 15e3;
15268
+ function firstTableRows(text2) {
15269
+ const decoded = Buffer.from(text2, "latin1").toString("utf8");
15270
+ for (let start = decoded.indexOf("{"); start >= 0; start = decoded.indexOf("{", start + 1)) {
15271
+ const end = closingBrace(decoded, start);
15272
+ if (end < 0)
15273
+ return void 0;
15274
+ let parsed;
15275
+ try {
15276
+ parsed = JSON.parse(decoded.slice(start, end + 1));
15277
+ } catch {
15278
+ continue;
15279
+ }
15280
+ const data = parsed?.data;
15281
+ if (Array.isArray(data))
15282
+ return data;
15283
+ }
15284
+ return void 0;
15285
+ }
15286
+ function closingBrace(text2, start) {
15287
+ let depth = 0;
15288
+ let inString = false;
15289
+ let escaped = false;
15290
+ for (let i = start; i < text2.length; i++) {
15291
+ const c = text2[i];
15292
+ if (inString) {
15293
+ if (escaped)
15294
+ escaped = false;
15295
+ else if (c === "\\")
15296
+ escaped = true;
15297
+ else if (c === '"')
15298
+ inString = false;
15299
+ continue;
15300
+ }
15301
+ if (c === '"')
15302
+ inString = true;
15303
+ else if (c === "{")
15304
+ depth++;
15305
+ else if (c === "}" && --depth === 0)
15306
+ return i;
15307
+ }
15308
+ return -1;
15309
+ }
15269
15310
  var P2PSession = class _P2PSession extends EventEmitter2 {
15270
15311
  cfg;
15271
15312
  socket;
@@ -16495,8 +16536,14 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16495
16536
  * The HomeBase streams back `CMD_DATABASE` (1306) frames `{cmd:10000,count,data:[…]}`,
16496
16537
  * level-1-encrypted — decoded and emitted as `dbChunk` (decrypted text) per frame.
16497
16538
  * Tables: `familiar_faces`, `person_basic_info`, `event_person_list`, `history_record_info`.
16539
+ *
16540
+ * Throws while a {@link readDatabase} is accumulating: every table answers `{data:[…]}` and the
16541
+ * frames tie no chunk to its request, so a second query's reply would be assembled into the first
16542
+ * one's buffer and answered as its rows.
16498
16543
  */
16499
16544
  queryDatabase(table, opts = {}) {
16545
+ if (this.dbReadInFlight)
16546
+ throw new Error(`queryDatabase: ${this.cfg.stationSn} is already reading a table`);
16500
16547
  if (!this.connectAddress)
16501
16548
  throw new Error("not connected");
16502
16549
  const channel = opts.channel ?? STATION_CHANNEL3;
@@ -16559,6 +16606,49 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
16559
16606
  query: this.fullTableQuery()
16560
16607
  });
16561
16608
  }
16609
+ /**
16610
+ * Query one on-station table and answer its rows, once the reply is whole.
16611
+ *
16612
+ * The request half of {@link queryDatabase} with its reply assembled: `CMD_DATABASE` arrives as
16613
+ * several frames whose decrypted text is a fragment of one document, so the fragments are
16614
+ * accumulated here and scanned after each one. Answers that document's `data` rows. Rejects when
16615
+ * `signal` aborts, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no
16616
+ * complete reply.
16617
+ *
16618
+ * One read at a time per session: while one is accumulating, every {@link queryDatabase} on the
16619
+ * session throws, so a second reply cannot land in this buffer.
16620
+ */
16621
+ async readDatabase(table, opts = {}) {
16622
+ if (opts.signal?.aborted)
16623
+ throw new Error("readDatabase: aborted");
16624
+ this.queryDatabase(table, { accountId: opts.accountId, query: this.fullTableQuery() });
16625
+ this.dbReadInFlight = true;
16626
+ try {
16627
+ return await new Promise((resolve, reject) => {
16628
+ let text2 = "";
16629
+ const onChunk = (chunk) => {
16630
+ text2 += chunk.text;
16631
+ const rows = firstTableRows(text2);
16632
+ if (rows)
16633
+ settle(() => resolve(rows));
16634
+ };
16635
+ const timer = setTimeout(() => settle(() => reject(new Error(`readDatabase: ${this.cfg.stationSn} sent no complete ${table}`))), opts.timeoutMs ?? DB_TABLE_TIMEOUT_MS);
16636
+ const onAbort = () => settle(() => reject(new Error("readDatabase: aborted")));
16637
+ const settle = (finish) => {
16638
+ clearTimeout(timer);
16639
+ this.off("dbChunk", onChunk);
16640
+ opts.signal?.removeEventListener("abort", onAbort);
16641
+ finish();
16642
+ };
16643
+ this.on("dbChunk", onChunk);
16644
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
16645
+ });
16646
+ } finally {
16647
+ this.dbReadInFlight = false;
16648
+ }
16649
+ }
16650
+ /** Whether a {@link readDatabase} is accumulating; see {@link queryDatabase} for what it bars. */
16651
+ dbReadInFlight = false;
16562
16652
  /**
16563
16653
  * Request the **face feature rows** over P2P (`face_feature_info`, inner `cmd 10000`). Each row
16564
16654
  * carries `{person_id, face_name, face_id, face_picture_content, face_feature_file_path}` — where
@@ -25380,6 +25470,11 @@ function recordString(raw, key) {
25380
25470
  const v = typeof nested === "string" && nested ? nested : raw[key];
25381
25471
  return typeof v === "string" && v ? v : void 0;
25382
25472
  }
25473
+ function adminUserIdFrom(raw) {
25474
+ const member = (raw ?? {}).member;
25475
+ const id = member?.admin_user_id;
25476
+ return typeof id === "string" && id ? id : void 0;
25477
+ }
25383
25478
  function tuyaDevIdFrom(raw) {
25384
25479
  for (const field of ["tuya_uuid", "tuya_virtual_id", "tuya_device_id", "virtualId"]) {
25385
25480
  const v = recordString(raw, field);
@@ -25537,13 +25632,7 @@ var EufyMega = class extends EventEmitter9 {
25537
25632
  onError: (e) => this.reportError(e),
25538
25633
  // The `a2` account id an `eufy_life` DP frame embeds — the owning member's `admin_user_id`,
25539
25634
  // 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
- },
25635
+ resolveAccountId: (dev) => adminUserIdFrom(dev.raw) ?? this.mega.auth?.userId ?? "",
25547
25636
  // Fetch + parse a gallery effect from the HTTP catalogue into the serializable spec. The catalogue
25548
25637
  // lives in the http layer (transport/http/light-catalog); the mqtt router owns the frame bytes.
25549
25638
  resolvePreset: (presetId) => resolveLightEffect(this.mega, presetId),
@@ -26990,6 +27079,37 @@ var EufyMega = class extends EventEmitter9 {
26990
27079
  getP2pSessions() {
26991
27080
  return this.p2p.getSessions();
26992
27081
  }
27082
+ /**
27083
+ * The people enrolled on a station, read from its own `person_basic_info` table over P2P.
27084
+ *
27085
+ * The cloud roster (`api.getFaces()`) answers empty for an account that keeps its faces on the
27086
+ * station, because they were never uploaded — the station holds them.
27087
+ *
27088
+ * Resolves with the rows the station returned. Rejects on a station that cannot be reached, on a
27089
+ * record stating no `admin_user_id`, and on the reply not arriving whole in time. Assembling that
27090
+ * reply is `P2PSession.readDatabase`'s; this resolves which station and which account id.
27091
+ *
27092
+ * `stranger<n>` names are the station's own placeholders for a face nobody has named, and are
27093
+ * returned as they arrive; a caller that wants only named people filters them.
27094
+ */
27095
+ async getStationFaces(stationSn, opts = {}) {
27096
+ if (!this.registry.list().length)
27097
+ await this.getDevices();
27098
+ const station = this.p2p.stationKeyOf(stationSn);
27099
+ const accountId = this.adminUserIdOf(station);
27100
+ if (!accountId)
27101
+ throw new Error(`getStationFaces: no admin_user_id on record for ${station}`);
27102
+ await this.p2p.ensureStation(station, opts.signal);
27103
+ const session = this.p2p.getSessions().get(station);
27104
+ if (!session)
27105
+ throw new Error(`getStationFaces: no P2P session for ${station}`);
27106
+ const rows = await session.readDatabase("person_basic_info", { accountId, ...opts });
27107
+ return rows.filter((row) => typeof row === "object" && row !== null);
27108
+ }
27109
+ /** The station's own `admin_user_id`, which its database refuses the query without. */
27110
+ adminUserIdOf(stationSn) {
27111
+ return adminUserIdFrom(this.registry.list().find((d) => d.sn === stationSn)?.raw);
27112
+ }
26993
27113
  /**
26994
27114
  * The liveness facts for one device — see {@link DeviceState}. Facts, not an `online` verdict: "how
26995
27115
  * long is too long" is a threshold that belongs to the caller, and it differs per device class.