@mega-yfue/eufy-sdk 0.5.0-beta.87 → 0.5.0-beta.89

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.
@@ -122,6 +122,11 @@ export declare function cameraPowerTier(model: string | undefined, capabilities:
122
122
  * harvest to report either.
123
123
  */
124
124
  export declare const CELL_PARAMS: readonly number[];
125
+ /**
126
+ * The app's charger-state decode: `charging = value ∉ {0,2}`. The app applies this one rule both to a
127
+ * camera's `BATTERY_STATUS` (2111) and to a keypad's `APP_CMD_KEYPAD_BATTERY_CHARGER_STATE` (1655).
128
+ */
129
+ export declare function isChargingState(v: string | number | boolean): boolean;
125
130
  /**
126
131
  * Every `battery` feature, declared once — the property schema, the evidence-gated getters, the derived
127
132
  * setters, the intent routes and the descriptions all come out of this table. Order is schema order.
@@ -159,7 +164,7 @@ export declare const BATTERY_MEMBERS: {
159
164
  readonly type: "bool";
160
165
  readonly kind: "boolean";
161
166
  readonly provenance: "apk";
162
- readonly coerce: (v: string | number | boolean) => boolean;
167
+ readonly coerce: typeof isChargingState;
163
168
  readonly description: string;
164
169
  };
165
170
  /**
@@ -418,7 +418,7 @@ export interface DeviceActionMap {
418
418
  smoke: SmokeActions;
419
419
  /** CO-detector reads (read-only): `coDetected`, `lastSeen`. */
420
420
  co: CoActions;
421
- /** Security-keypad reads (read-only): `rssi`. */
421
+ /** Security-keypad reads (read-only): `charging`, `rssi`. */
422
422
  keypad: KeypadActions;
423
423
  /** RTSP publish for a NAS/NVR: `publish`/`withdraw` + `published`, `requireAuth`/`allowAnonymous`, `recordingMode`/`setRecordingMode`. One camera at a time per station. */
424
424
  rtsp: RtspActions;
@@ -1,13 +1,12 @@
1
+ import { isChargingState } from "./battery.js";
1
2
  import { type Surface } from "./members.js";
2
3
  import type { CapabilityModule } from "./types.js";
3
4
  /**
4
- * Every `keypad` feature, declared once. Only `rssi` is a verified read.
5
+ * Every `keypad` feature, declared once. `rssi` is a verified read and `charging` follows the app's own
6
+ * decode of the keypad's charger state.
5
7
  *
6
- * The battery pair is `unexposed`: both ids are placeholders, and the keypad's real battery state is
7
- * reported under model-specific params (`keypadBatteryCapState` / `keypadBatteryChargerState`, seen
8
- * live) whose low/charging semantics are not confirmed. They stay in the schema so a diagnosis can
9
- * reach them through `getProperty`, with no typed getter asserting a meaning this SDK cannot back.
10
- * A live probe of the fleet's T8960 confirms neither is reported at all today.
8
+ * `batteryLow` is `unexposed`: the app maps its param to the same low-battery field the entry and motion
9
+ * sensors feed, but its value decode is absent from the app's parser, so the polarity is not evidenced.
11
10
  *
12
11
  * Exported but NOT published: each entry states its wire id and the evidence it was confirmed on,
13
12
  * which the reference site does not carry.
@@ -15,36 +14,34 @@ import type { CapabilityModule } from "./types.js";
15
14
  */
16
15
  export declare const KEYPAD_MEMBERS: {
17
16
  /**
18
- * `unexposed`, so it reaches a diagnosis through `getProperty` but gets no typed getter. 1103 is a
19
- * placeholder AND a likely collision — the app's own param table names 1103 GET_CAMERA_INFO — so a
20
- * getter here would be asserting a meaning nothing backs. The keypad's real battery state arrives
21
- * under `keypadBatteryCapState`, whose low/charging semantics are not confirmed.
17
+ * `APP_CMD_KEYPAD_BATTERY_CAP_STATE` (1653). The app's sensor parser maps it to `sensor_is_low_power`,
18
+ * the field `APP_CMD_ENTRY_SENSOR_BAT_STATE` and `APP_CMD_MOTION_SENSOR_BAT_STATE` decode as a boolean,
19
+ * yet gives 1653 itself no decode case. `unexposed` until a device confirms which value means low; in
20
+ * the schema for `getProperty`, absent from the typed surface.
22
21
  */
23
22
  readonly batteryLow: {
24
- readonly param: 1103;
23
+ readonly param: 1653;
25
24
  readonly type: "bool";
26
25
  readonly kind: "boolean";
27
- readonly provenance: "guessed";
26
+ readonly provenance: "apk";
28
27
  readonly unexposed: true;
29
28
  readonly description: string;
30
29
  };
31
30
  /**
32
- * The charging half of the battery pair, `unexposed` for the same reason as `batteryLow`: 1102 is a
33
- * placeholder id, and the state the keypad actually reports (`keypadBatteryChargerState`) has no
34
- * confirmed value space. In the schema for `getProperty`, absent from the typed surface.
31
+ * `APP_CMD_KEYPAD_BATTERY_CHARGER_STATE` (1655), decoded the way the app's sensor parser decodes it:
32
+ * `charging = value ∉ {0,2}` ({@link isChargingState}).
35
33
  */
36
34
  readonly charging: {
37
- readonly param: 1102;
35
+ readonly param: 1655;
38
36
  readonly type: "bool";
39
37
  readonly kind: "boolean";
40
- readonly provenance: "guessed";
41
- readonly unexposed: true;
42
- readonly description: "Keypad charging flag. UNVERIFIED: placeholder id pending verification.";
38
+ readonly provenance: "apk";
39
+ readonly coerce: typeof isChargingState;
40
+ readonly description: string;
43
41
  };
44
42
  /**
45
- * The keypad's link quality in dBm — the one verified read in this table, on the same param 1141 the
46
- * other sub-1G sensors report signal strength on. Reported as the device measures it: raw dBm, never
47
- * normalised to a bar count.
43
+ * The keypad's link quality in dBm, on the same param 1141 the other sub-1G sensors report signal
44
+ * strength on. Reported as the device measures it: raw dBm, never normalised to a bar count.
48
45
  */
49
46
  readonly rssi: {
50
47
  readonly param: 1141;
@@ -57,5 +54,5 @@ export declare const KEYPAD_MEMBERS: {
57
54
  };
58
55
  /** Bound keypad reads — the object returned by `dev.keypad()`. Read-only. */
59
56
  export type KeypadActions = Surface<typeof KEYPAD_MEMBERS>;
60
- /** `keypad` — security keypad. Battery low/charging and signal. Param ids here are placeholders apart from RSSI. */
57
+ /** `keypad` — security keypad. Charging, battery-low and signal. */
61
58
  export declare const KEYPAD: CapabilityModule;
@@ -33,13 +33,19 @@ export declare const LOCK_SETTING_ID: {
33
33
  /**
34
34
  * Bound lock controls — the object returned by `dev.lock()`.
35
35
  *
36
- * Everything is DERIVED from `LOCK_MEMBERS`. `lock`/`unlock` and the setting toggles drive any
37
- * lock-family actuator — currently the T8531 video smart lock and the T85D0 garage door, which share one
38
- * actuation frame. This module emits ONE transport-neutral intent (identity fields only, no wire bytes,
39
- * no cipher, no routing key) and names no transport: the command sink routes P2P vs MQTT by the device's
40
- * topology, and the chosen transport's command router builds the frame + envelope and re-resolves its own
41
- * routing tail. Which pipe it is does not reach `dev.lock()` — its surface is identical either way,
42
- * the same way P2P-vs-cloud is hidden for live media.
36
+ * Everything is DERIVED from `LOCK_MEMBERS`. `lock`/`unlock` drive every lock-family actuator over the
37
+ * wire its family takes: the shared `ff09` frame on the T8531 video smart lock and the T85D0 garage door,
38
+ * the T8530 video lock's own `set-payload`, and the keyed-payload envelope on the classic Wi-Fi lock (the
39
+ * `T8520`-range unit that never negotiates a session key). The setting toggles ride `ff09` only. This
40
+ * module emits ONE transport-neutral intent per wire (identity fields only, no wire bytes, no cipher, no
41
+ * routing key) and names no transport: the command sink routes P2P vs MQTT by the device's topology, and
42
+ * the chosen transport's command router builds the frame + envelope and re-resolves its own routing tail.
43
+ * Which pipe it is does not reach `dev.lock()` — its surface is identical either way, the same way
44
+ * P2P-vs-cloud is hidden for live media.
45
+ *
46
+ * Classic Wi-Fi lock: `lock()` and `unlock()` are both confirmed on hardware — the bolt moved each way, the
47
+ * device answered result code 0, and the cloud status followed. That device answers every command, so on it
48
+ * the two reject on a refusal or an unanswered command as well as on missing identity.
43
49
  */
44
50
  export type LockActions = Surface<typeof LOCK_MEMBERS>;
45
51
  /**
@@ -96,8 +102,8 @@ export declare const LOCK_MEMBERS: {
96
102
  readonly description: "Lock battery level 0-100 (verified: param 1101, alias 6001).";
97
103
  };
98
104
  /**
99
- * Link quality in dBm as the lock measures it, on the shared param 1141. Both actuation methods here
100
- * are fire-and-forget, so a weak link is silent rather than an error.
105
+ * Link quality in dBm as the lock measures it, on the shared param 1141. On the `ff09` wire the
106
+ * actuations are fire-and-forget, so a weak link is silent rather than an error.
101
107
  */
102
108
  readonly rssi: {
103
109
  readonly param: 1141;
@@ -107,11 +113,17 @@ export declare const LOCK_MEMBERS: {
107
113
  readonly provenance: "verified";
108
114
  readonly description: "Lock signal strength (verified: param 1141 = RSSI).";
109
115
  };
110
- /** Lock the deadbolt/door. Resolves after sending; does not confirm physical state. */
116
+ /**
117
+ * Lock the deadbolt/door. Resolves after sending, without confirming physical state — except on the
118
+ * classic Wi-Fi lock, which answers: there it rejects on a device refusal or no reply as well.
119
+ */
111
120
  readonly lock: import("./members.js").MethodMember<() => Promise<void>> & {
112
121
  available: (ctx: CommandContext) => boolean;
113
122
  };
114
- /** Unlock the deadbolt/door. Resolves after sending; does not confirm physical state. */
123
+ /**
124
+ * Unlock the deadbolt/door. Resolves after sending, without confirming physical state — except on the
125
+ * classic Wi-Fi lock, which answers: there it rejects on a device refusal or no reply as well.
126
+ */
115
127
  readonly unlock: import("./members.js").MethodMember<() => Promise<void>> & {
116
128
  available: (ctx: CommandContext) => boolean;
117
129
  };
@@ -11,6 +11,11 @@ export interface FamilyContext {
11
11
  * `"eufy_home_tuya"` devices are on the ThingClips/Tuya Cloud platform, not Anker AIoT MQTT.
12
12
  */
13
13
  category?: string;
14
+ /**
15
+ * Full device serial, when known. The one family split that no type number expresses: the vendor
16
+ * reuses one DeviceType across generations of a product and distinguishes them by serial.
17
+ */
18
+ serial?: string;
14
19
  }
15
20
  /** Indoor cams, incl. indoor pan/tilt + S350/E30/C-series + mini. */
16
21
  export declare const INDOOR_CAMERA_TYPES: ReadonlySet<number>;
@@ -84,3 +89,10 @@ export declare const isAiotVacuum: (ctx: FamilyContext) => boolean;
84
89
  export declare const isTuyaVacuum: (ctx: FamilyContext) => boolean;
85
90
  /** Video Smart Lock S330, identified by its distinct cloud device type. */
86
91
  export declare const isVideoSmartLock: (ctx: FamilyContext) => boolean;
92
+ /**
93
+ * The classic Wi-Fi smart lock — the unit whose actuation rides a per-command ECDH-wrapped key rather than
94
+ * the shared `ff09` frame: DeviceType 51 with a serial in the `T8520` range whose seventh character is `2`,
95
+ * the one range driven on hardware. The DeviceType alone does not identify it, since later units share the
96
+ * number and take the `ff09` frame, and a device whose serial is unknown is not guessed either way.
97
+ */
98
+ export declare const isClassicWifiLock: (ctx: FamilyContext) => boolean;
@@ -452,6 +452,15 @@ export declare class MegaHttpClient {
452
452
  downloadImage(url: string, p2pDid?: string): Promise<Buffer>;
453
453
  /** The security-app data host for this region (face recognition, media, etc.). */
454
454
  private securityAppHost;
455
+ /**
456
+ * The device's long-lived P-256 public key, as the cloud publishes it for key-wrapped commands
457
+ * (`type=2`): 64 bytes hex with no `04` prefix. A plain authed GET on the security-app host — the
458
+ * one read here that is neither signed nor body-encrypted, matching how the app fetches it.
459
+ * Cached per serial for the client's lifetime; the key does not rotate in normal operation.
460
+ */
461
+ getDevicePublicKey(deviceSn: string): Promise<string>;
462
+ /** Per-serial cache for {@link getDevicePublicKey}. */
463
+ private readonly devicePublicKeys;
455
464
  /**
456
465
  * Signed+encrypted POST to the security-app data host (face recognition, etc.).
457
466
  * Despite the different host, these endpoints use the SAME algo_ecdh pipeline as
@@ -100,6 +100,22 @@ export interface EciesUnwrapOptions {
100
100
  * decrypted bytes, or `undefined` on any parse / HMAC / cipher failure (fails closed, never throws).
101
101
  */
102
102
  export declare function eciesUnwrap(envelope: Buffer, eccPrivateKeyHex: string, options?: EciesUnwrapOptions): Buffer | undefined;
103
+ /** The two random inputs of {@link eciesWrap}, injectable so a spec can pin an envelope's bytes. */
104
+ export interface EciesWrapOptions {
105
+ /** The ephemeral P-256 private key for the exchange (32 bytes); generated fresh when absent. */
106
+ ephemeralPrivateKey?: Buffer;
107
+ /** The AES-128-CBC IV, which the envelope carries (16 bytes); random when absent. */
108
+ iv?: Buffer;
109
+ }
110
+ /**
111
+ * The sealing counterpart of {@link eciesUnwrap} with `verifyHmac` and `pkcs7` both set.
112
+ *
113
+ * Seals `plaintext` for `peerPublicKey` (a P-256 point, compressed or uncompressed) as
114
+ * `ephemeralPub(33, compressed) ‖ iv(16) ‖ ct ‖ HMAC(32)`: `S = ECDH_X(ephemeral, peer)` →
115
+ * `kdf = eufyKDF(S)` → `ct = AES-128-CBC(kdf[0:16], iv, PKCS7(plaintext))`, tagged
116
+ * `HMAC-SHA256(kdf[16:48], iv ‖ ct)`.
117
+ */
118
+ export declare function eciesWrap(plaintext: Buffer, peerPublicKey: Buffer, options?: EciesWrapOptions): Buffer;
103
119
  /**
104
120
  * Derive the P2P **level-2** (signCode 2/8) AES-256-GCM session key from a *decrypted*
105
121
  * `CMD_GATEWAYINFO` (1100) payload and the station's ECC private key (from `get_ciphers`).
@@ -112,6 +112,17 @@ export declare class P2PCommandRouter {
112
112
  private readonly cipherKeyCache;
113
113
  /** The recording download in flight per station; a station serves one at a time. */
114
114
  private readonly recordingDownloads;
115
+ /**
116
+ * The `seq_num` last sent to each device over the keyed-payload wire. The device holds a mark for the life
117
+ * of a P2P connection: the first keyed command on a new connection is accepted at whatever number it
118
+ * carries and becomes the mark, and after that a number not above the mark is refused with the mark
119
+ * published ({@link keyedPayloadSeqMark}). Each accepted command moves this to its number, a refusal
120
+ * moves it to the published mark + 1, and a device absent here has not been sent one in this process and
121
+ * seeds from a small random number — which a new connection takes as sent.
122
+ */
123
+ private readonly keyedSequences;
124
+ /** The keyed-payload command in flight per device, which the next one for that device waits on. */
125
+ private readonly keyedQueue;
115
126
  constructor(deps: P2PRouterDeps);
116
127
  /** Forward one P2P failure once even when both the session listener and startup waiter observe it. */
117
128
  private reportError;
@@ -704,6 +715,68 @@ export declare class P2PCommandRouter {
704
715
  * boolean, so the first pass carries the failure and the rest are repeats.
705
716
  */
706
717
  private sendSetPayloadEnvelope;
718
+ /**
719
+ * **`keyed-payload-actuate` intent** — the classic Wi-Fi lock's actuation: the sealed fields from
720
+ * {@link buildKeyedActuatePayload}, sent as a keyed envelope through {@link P2PSession.sendSetPayload}
721
+ * on the lock's device channel. No session key is waited for — the envelope seals its own per-command
722
+ * key for the device's public key (fetched from the cloud, cached per serial), so it goes as soon as
723
+ * the session is up. The device shows the acting member by display name, which lives on the device
724
+ * record's `member` rather than in the intent, so it is read here and falls back to the account name
725
+ * the intent carries.
726
+ *
727
+ * The device answers, so the outcome is read rather than assumed: {@link awaitKeyedReply} settles each
728
+ * send on the replies, and resolves a {@link KEYED_PAYLOAD_SEQ_ERROR} refusal with the mark the device
729
+ * publishes in the refusal's `CMD_DOORLOCK_P2P_SEQ` (1916) twin. On that mark the command resends once,
730
+ * at mark + 1; a second refusal, or a refusal that published no mark, throws. Commands run one at a time
731
+ * per device: the replies carry no correlation id, so one command's answer must never be read as
732
+ * another's.
733
+ */
734
+ private sendKeyedPayloadActuate;
735
+ /** Run `work` once every keyed-payload command already queued for `sn` has settled. */
736
+ private serializeKeyed;
737
+ /**
738
+ * Send one keyed command at the device's next sequence and, on a sequence refusal, resend once at the
739
+ * mark the refusal published.
740
+ *
741
+ * A device's first number in a process is a small random seed — the scale the vendor app's own counters
742
+ * run at — and every later one is the previous plus one. The device takes the first number on a new
743
+ * connection as sent, and from then on refuses one not above the mark it holds, stating the mark in the
744
+ * refusal's `CMD_DOORLOCK_P2P_SEQ` (1916) twin ({@link awaitKeyedReply} resolves the refusal with it).
745
+ * So a refusal is resolved to that mark and the command resent once at mark + 1, the number the device
746
+ * accepts after a refusal; a second refusal, or a refusal that carried no mark, throws. The accepted
747
+ * number is recorded, so the next command starts just above it.
748
+ *
749
+ * @param engage whether the command drives to the engaged (locked) state, which its own report states.
750
+ * @param send dispatches the envelope at the sequence it is given.
751
+ */
752
+ private sendKeyedAtSequence;
753
+ /**
754
+ * Send one keyed-payload command and read the device's answer for it from the replies on the command's
755
+ * channel: the `SET_PAYLOAD` echo (a `commandResult`), at once, carrying the result as an int32 — or,
756
+ * when the echo was not read and the command changed the device's state, the `NOTIFY_PAYLOAD` about two
757
+ * seconds later carrying the result again as a `P2P_QUERY_STATUS_IN_LOCK` (1955) document. A report
758
+ * is tied to the command by its `slState`: only one stating the state this command drives to, by
759
+ * `engage` ({@link keyedPayloadReportCode}), is read as its answer. A report of the other state is a
760
+ * previous command's, landing after that command settled on its echo, and is ignored.
761
+ *
762
+ * - An accepted result (code `0`) settles the command.
763
+ * - A sequence refusal is stated twice, both about a tenth of a second after the send: the `-151` echo
764
+ * and a `CMD_DOORLOCK_P2P_SEQ` (1916) notify whose `seq_num` is the mark the device holds. The
765
+ * resolved value carries that mark, so the caller resends just above it. Collection runs for
766
+ * {@link KEYED_REFUSAL_TWIN_MS} after the first of the two, so both land and neither is left on the
767
+ * channel for the resend to read as its own answer, and either one alone still resolves the refusal.
768
+ * - No reply within {@link KEYED_REPLY_MS} is a command that never landed, and rejects.
769
+ *
770
+ * Both listeners are armed before the send, and only replies on `channel` are read. The channel names
771
+ * the device, not the command: every other P2P write a lock takes waits for a level-2 key this device
772
+ * never negotiates, so nothing else the SDK sends can draw a reply here, and a future intent on this
773
+ * wrapper has to share {@link serializeKeyed}.
774
+ */
775
+ private awaitKeyedReply;
776
+ /** How long a keyed-payload command waits for any reply before it is taken as never having landed. */
777
+ private static readonly KEYED_REPLY_MS;
778
+ /** How long a refusal collects its two statements — the `-151` echo and the mark notify — before resolving. */
779
+ private static readonly KEYED_REFUSAL_TWIN_MS;
707
780
  /**
708
781
  * **`ff09-actuate` intent** — build the `ff09` AES-128-CBC frame ({@link buildFf09Frame}, shared with the
709
782
  * MQTT transport — see `transport/ff09.ts`) and dispatch it in the `1940` TRANSFER_PAYLOAD envelope
@@ -0,0 +1,112 @@
1
+ import type { Ff09Identity } from "../../core/contracts.js";
2
+ /**
3
+ * Keyed-payload envelope — the `SET_PAYLOAD` (1350) shape the classic Wi-Fi smart lock takes for its
4
+ * actuation. There is no session key: every command carries its OWN fresh AES-128 key, sealed for the
5
+ * device's long-lived P-256 public key (published by the cloud, one per device), and the inner JSON is
6
+ * AES-128-CBC/PKCS#7 under that key. This module builds the two sealed fields; `P2PSession.sendSetPayload`
7
+ * wraps them in the ordinary envelope and carries a keyed one in the clear, as the vendor app sends it.
8
+ *
9
+ * ## Wire
10
+ *
11
+ * ```
12
+ * { key: <keyBlobHex>, account_id: <adminUserId>, cmd: <opcode>, mChannel: <ch>, mValue3: 0,
13
+ * payload: <base64(AES-128-CBC(aesKey, iv, innerJson))> }
14
+ * ```
15
+ * `iv` = ASCII(deviceSn) zero-padded/truncated to 16 bytes. `aesKey` = 16 random bytes, spelled as 32
16
+ * UPPERCASE hex characters — the hex SPELLING is what the key blob seals, not the raw bytes.
17
+ *
18
+ * `key` (hex) is the ECIES envelope {@link eciesWrap} produces for the device's public key over that
19
+ * spelling: `compressedEphemeralPub(33) ‖ nonce(16) ‖ sealedKey(48) ‖ hmac(32)`.
20
+ *
21
+ * Inner JSON for the actuation opcode, `P2P_ON_OFF_LOCK` (1961): `{shortUserId, slOperation: 1|0, userId,
22
+ * userName, seq_num}`.
23
+ *
24
+ * ## Answer
25
+ *
26
+ * The device answers every command at once with an int32 result on the `SET_PAYLOAD` echo — `0`
27
+ * accepted, {@link KEYED_PAYLOAD_SEQ_ERROR} when `seq_num` is not above the mark it holds — and, when the
28
+ * command changed its state, about two seconds later with a `NOTIFY_PAYLOAD` whose JSON carries
29
+ * `P2P_QUERY_STATUS_IN_LOCK` (1955) as `cmd`, the result again under `payload.code` and the state reached
30
+ * under `payload.slState` ({@link keyedPayloadReportCode} reads the code of the report stating the state
31
+ * a command drives to). A command it was already in the state of draws the echo alone.
32
+ *
33
+ * A refusal carries a second `NOTIFY_PAYLOAD`, `CMD_DOORLOCK_P2P_SEQ` (1916), whose `payload.seq_num`
34
+ * ({@link keyedPayloadSeqMark}) is the mark the device holds — so a refused command hands back the exact
35
+ * number to resend above, with no searching. The mark lives with the P2P connection: the first keyed
36
+ * command on a new connection is accepted at whatever number it carries.
37
+ *
38
+ * The command ids are {@link CommandType}'s; the inner field names follow a third-party reverse-engineering
39
+ * project, a name source only.
40
+ */
41
+ /**
42
+ * The int32 result the device answers a command with when its `seq_num` is not above the mark it holds for
43
+ * the connection. The command is dropped untried, so this is a retry signal rather than a failure: resend
44
+ * above the mark. The refusal publishes that mark in its `CMD_DOORLOCK_P2P_SEQ` (1916) twin
45
+ * ({@link keyedPayloadSeqMark}), so the resend goes to the next number rather than searching for it.
46
+ */
47
+ export declare const KEYED_PAYLOAD_SEQ_ERROR = -151;
48
+ /** Knobs a spec injects to make the otherwise random envelope deterministic, plus the sequence it carries. */
49
+ export interface KeyedPayloadRandomness {
50
+ /** The per-command AES key (16 bytes). */
51
+ aesKey?: Buffer;
52
+ /** The CBC nonce sealing that key inside the blob (16 bytes). */
53
+ nonce?: Buffer;
54
+ /** The ephemeral P-256 private key for the ECDH exchange (32 bytes). */
55
+ ephemeralPrivateKey?: Buffer;
56
+ /** The `seq_num` the inner JSON carries, issued by the sender. */
57
+ seqNum: number;
58
+ }
59
+ /**
60
+ * The cipher IV the device derives from its own serial: ASCII bytes, zero-padded to 16. It is the same for
61
+ * every command to a device, which is sound only because {@link buildKeyedActuatePayload} seals each command
62
+ * under a key minted for it alone — no two messages ever share a key with this IV.
63
+ */
64
+ export declare function serialIv(deviceSn: string): Buffer;
65
+ /** Spell 16 key bytes as the 32 uppercase hex characters the wire seals. */
66
+ export declare function spellAesKey(key: Buffer): string;
67
+ /**
68
+ * Seal a per-command AES key for the device's P-256 public key: the {@link eciesWrap} envelope over the
69
+ * key's hex spelling, as hex.
70
+ * @param aesKeyHex the 32-hex-char key spelling (what gets sealed — as ASCII).
71
+ * @param devicePublicKeyHex the device's uncompressed public key WITHOUT the `04` prefix (64 bytes hex).
72
+ */
73
+ export declare function buildKeyBlob(aesKeyHex: string, devicePublicKeyHex: string, rnd?: Pick<KeyedPayloadRandomness, "nonce" | "ephemeralPrivateKey">): string;
74
+ /** AES-128-CBC/PKCS#7 the inner JSON under the per-command key, IV from the serial; base64 out. */
75
+ export declare function sealInnerPayload(innerJson: string, aesKeyHex: string, deviceSn: string): string;
76
+ /** The two sealed fields of a keyed envelope; the sender supplies the rest of the `SET_PAYLOAD` value. */
77
+ export interface KeyedActuatePayload {
78
+ /** The sealed per-command key — the value's `key` field, hex. */
79
+ key: string;
80
+ /** The inner JSON sealed under that key — the value's `payload` field, base64. */
81
+ payload: string;
82
+ }
83
+ /**
84
+ * Build the sealed fields of the classic Wi-Fi lock's actuation.
85
+ * @param id the acting identity + direction.
86
+ * @param devicePublicKeyHex the device's public key from the cloud (no `04` prefix).
87
+ * @param userName the acting member's display name as the device shows it in its log.
88
+ */
89
+ export declare function buildKeyedActuatePayload(id: Ff09Identity, devicePublicKeyHex: string, userName: string, rnd: KeyedPayloadRandomness): KeyedActuatePayload;
90
+ /**
91
+ * The result a `NOTIFY_PAYLOAD` document reports for a keyed command driving to a state. The device sends
92
+ * one once a keyed command has changed its state, `{cmd, payload: {code, slState, …}}` with `cmd`
93
+ * `P2P_QUERY_STATUS_IN_LOCK` (1955) in {@link CommandType}, `payload.code` being the command's result and
94
+ * `payload.slState` the state reached. Answers `payload.code` when it is a number and `payload.slState` is
95
+ * the state the command drives to: `"4"` engaged (locked) when `engage`, `"3"` released (unlocked) when
96
+ * not, the values of the lock's state param 6000 as the device spells them. Answers `undefined` for any
97
+ * other document, a report of the other state included: that one answers some other command.
98
+ */
99
+ export declare function keyedPayloadReportCode(json: {
100
+ cmd?: number;
101
+ payload?: unknown;
102
+ } | undefined, engage: boolean): number | undefined;
103
+ /**
104
+ * The sequence mark a `NOTIFY_PAYLOAD` document publishes. The one that rides a sequence refusal is
105
+ * `{cmd, payload: {lock_cmd, seq_num}}` with `cmd` `CMD_DOORLOCK_P2P_SEQ` (1916) in {@link CommandType},
106
+ * `payload.seq_num` being the mark the device holds on this connection. Answers that number, or
107
+ * `undefined` for any other document.
108
+ */
109
+ export declare function keyedPayloadSeqMark(json: {
110
+ cmd?: number;
111
+ payload?: unknown;
112
+ } | undefined): number | undefined;
@@ -706,6 +706,10 @@ export declare class P2PSession extends EventEmitter {
706
706
  * Low-level: send a `CMD_SET_PAYLOAD` (1350) wrapping `{account_id, cmd:<subCmd>, mChannel,
707
707
  * payload, transaction}` over the level-1 channel. The reply arrives as a `NOTIFY_PAYLOAD`
708
708
  * (1351) frame (level-1 decrypted, surfaced via the `data` event / `frame.json`).
709
+ *
710
+ * `opts.key` makes it a keyed envelope: the sealed per-command key rides ahead of the other fields
711
+ * as the value's `key`, and the value goes out unencrypted, encryption-type 0, with `=` written
712
+ * `=` — the form the vendor app sends one in, and the one form the device reads it in.
709
713
  */
710
714
  sendSetPayload(subCmd: number, payload?: unknown, opts?: {
711
715
  accountId?: string;
@@ -713,6 +717,7 @@ export declare class P2PSession extends EventEmitter {
713
717
  omitPayload?: boolean;
714
718
  wrapCmd?: number;
715
719
  rawValue?: Record<string, unknown>;
720
+ key?: string;
716
721
  }): void;
717
722
  /**
718
723
  * Request the on-device edge-AI face roster over P2P. `COMMAND_GET_LOCAL_FACES` (1194) =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mega-yfue/eufy-sdk",
3
- "version": "0.5.0-beta.87",
3
+ "version": "0.5.0-beta.89",
4
4
  "description": "One typed TypeScript client for the Anker eufy v6 cloud — capability-driven devices, realtime events over P2P/MQTT/push, and live media",
5
5
  "license": "Apache-2.0",
6
6
  "author": "mega-yfue",