@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.
- package/dist/client/eufy-mega.d.ts +18 -2
- package/dist/client/types.d.ts +25 -0
- package/dist/index.js +139 -19
- package/dist/index.js.map +3 -3
- package/dist/model/capabilities/doorbell.d.ts +8 -9
- package/dist/model/capabilities/motion.d.ts +3 -4
- package/dist/model/param-dictionary.d.ts +3 -3
- package/dist/model/types.d.ts +10 -11
- package/dist/transport/p2p/p2p-session.d.ts +23 -0
- package/package.json +1 -1
|
@@ -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.
|
package/dist/client/types.d.ts
CHANGED
|
@@ -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.
|
|
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: "
|
|
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: "
|
|
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
|
-
*
|
|
6894
|
-
*
|
|
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"
|
|
6906
|
-
*
|
|
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: "
|
|
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: "
|
|
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.
|