@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.
- package/dist/client/eufy-mega.d.ts +4 -2
- package/dist/core/contracts.d.ts +13 -4
- package/dist/index.js +338 -57
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/battery.d.ts +6 -1
- package/dist/model/capabilities/index.d.ts +1 -1
- package/dist/model/capabilities/keypad.d.ts +20 -23
- package/dist/model/capabilities/lock.d.ts +23 -11
- package/dist/model/device-family.d.ts +12 -0
- package/dist/transport/http/mega-client.d.ts +9 -0
- package/dist/transport/p2p/codec.d.ts +16 -0
- package/dist/transport/p2p/command-router.d.ts +73 -0
- package/dist/transport/p2p/keyed-payload.d.ts +112 -0
- package/dist/transport/p2p/p2p-session.d.ts +5 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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.
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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:
|
|
23
|
+
readonly param: 1653;
|
|
25
24
|
readonly type: "bool";
|
|
26
25
|
readonly kind: "boolean";
|
|
27
|
-
readonly provenance: "
|
|
26
|
+
readonly provenance: "apk";
|
|
28
27
|
readonly unexposed: true;
|
|
29
28
|
readonly description: string;
|
|
30
29
|
};
|
|
31
30
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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:
|
|
35
|
+
readonly param: 1655;
|
|
38
36
|
readonly type: "bool";
|
|
39
37
|
readonly kind: "boolean";
|
|
40
|
-
readonly provenance: "
|
|
41
|
-
readonly
|
|
42
|
-
readonly description:
|
|
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
|
|
46
|
-
*
|
|
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.
|
|
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`
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* routing
|
|
42
|
-
* the
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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",
|