@mega-yfue/eufy-sdk 0.5.0-beta.8 → 0.5.0-beta.81

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.
Files changed (61) hide show
  1. package/dist/client/device-registry.d.ts +24 -2
  2. package/dist/client/eufy-mega.d.ts +75 -6
  3. package/dist/client/types.d.ts +1 -1
  4. package/dist/core/contracts.d.ts +80 -33
  5. package/dist/core/store.d.ts +4 -0
  6. package/dist/core/types.d.ts +2 -0
  7. package/dist/index.js +3754 -617
  8. package/dist/index.js.map +4 -4
  9. package/dist/model/capabilities/arming.d.ts +39 -4
  10. package/dist/model/capabilities/camera.d.ts +47 -18
  11. package/dist/model/capabilities/dp-catalog.d.ts +4 -3
  12. package/dist/model/capabilities/index.d.ts +16 -3
  13. package/dist/model/capabilities/light.d.ts +3 -0
  14. package/dist/model/capabilities/lock.d.ts +21 -7
  15. package/dist/model/capabilities/motion.d.ts +17 -2
  16. package/dist/model/capabilities/mower.d.ts +230 -0
  17. package/dist/model/capabilities/rtsp.d.ts +16 -3
  18. package/dist/model/capabilities/smart-drop.d.ts +20 -0
  19. package/dist/model/capabilities/suction.d.ts +5 -5
  20. package/dist/model/capabilities/types.d.ts +10 -0
  21. package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
  22. package/dist/model/classify.d.ts +1 -2
  23. package/dist/model/device-family.d.ts +12 -0
  24. package/dist/model/param-dictionary.d.ts +6 -0
  25. package/dist/model/param-namespace.d.ts +1 -1
  26. package/dist/model/registry.d.ts +3 -2
  27. package/dist/model/types.d.ts +26 -12
  28. package/dist/transport/abortable.d.ts +8 -0
  29. package/dist/transport/http/mega-client.d.ts +34 -2
  30. package/dist/transport/mqtt/broker-discovery.d.ts +2 -1
  31. package/dist/transport/mqtt/command-router.d.ts +3 -2
  32. package/dist/transport/p2p/audio-frame.d.ts +14 -0
  33. package/dist/transport/p2p/codec.d.ts +16 -0
  34. package/dist/transport/p2p/command-router.d.ts +19 -30
  35. package/dist/transport/p2p/fmp4.d.ts +12 -2
  36. package/dist/transport/p2p/live-trace.d.ts +25 -2
  37. package/dist/transport/p2p/p2p-session.d.ts +55 -12
  38. package/dist/transport/p2p/readable-egress.d.ts +4 -0
  39. package/dist/transport/p2p/recording-download.d.ts +4 -3
  40. package/dist/transport/p2p/shared-live-source.d.ts +29 -14
  41. package/dist/transport/p2p/talkback.d.ts +21 -10
  42. package/dist/transport/p2p/video.d.ts +2 -0
  43. package/dist/transport/rtc/call-live.d.ts +41 -0
  44. package/dist/transport/rtc/command-router.d.ts +145 -0
  45. package/dist/transport/rtc/live.d.ts +76 -0
  46. package/dist/transport/rtc/peer.d.ts +122 -0
  47. package/dist/transport/rtc/portal-keepalive.d.ts +11 -0
  48. package/dist/transport/rtc/portal-packet.d.ts +72 -0
  49. package/dist/transport/rtc/ptcs-framer.d.ts +86 -0
  50. package/dist/transport/rtc/scall-sdp.d.ts +50 -0
  51. package/dist/transport/rtc/session.d.ts +99 -0
  52. package/dist/transport/rtc/signaling.d.ts +139 -0
  53. package/dist/transport/token-echo.d.ts +8 -0
  54. package/dist/transport/tuya/account.d.ts +8 -5
  55. package/dist/transport/tuya/client.d.ts +19 -5
  56. package/dist/transport/tuya/command-router.d.ts +10 -14
  57. package/dist/transport/tuya/dp-codec.d.ts +2 -23
  58. package/dist/transport/tuya/et3.d.ts +22 -0
  59. package/dist/transport/tuya/request.d.ts +18 -8
  60. package/dist/transport/tuya/sign.d.ts +7 -0
  61. package/package.json +2 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The T9000's signalling does not carry SDP text: an `info` message carries a small JSON — DTLS setup
3
+ * role, ICE ufrag/pwd, fingerprint and candidates — and each side rebuilds the one-m-line SDP from it.
4
+ * This is the portal's own translation (security.eufy.com): the SDP handed to the native peer is the one
5
+ * the hub meant, and the JSON sent back is the one the hub parses.
6
+ */
7
+ export interface ScallSdpJson {
8
+ setup?: string;
9
+ ice?: {
10
+ ufrag?: string;
11
+ pwd?: string;
12
+ fingerprint_type?: string;
13
+ fingerprint?: string;
14
+ };
15
+ candidate?: string[];
16
+ }
17
+ /** The hub's SDP is one SCTP application m-line bundled under mid 2, as the portal template has it. */
18
+ export declare const HUB_SDP_MID = "2";
19
+ /** The max message size the hub advertises; the answer carries the same. */
20
+ export declare const ANKER_MAX_MESSAGE_SIZE = 262144;
21
+ /**
22
+ * Rebuild the hub's offer (or answer) SDP from its scall JSON. The JSON carries the fingerprint as bare
23
+ * hex; the SDP gets colon-separated byte pairs.
24
+ */
25
+ export declare function scallJsonToSdp(json: ScallSdpJson, now?: () => number): string;
26
+ /** Reduce our local SDP to the JSON the hub parses. `actpass` is left out: the hub wants a concrete role. */
27
+ export declare function sdpToScallJson(sdp: string): ScallSdpJson;
28
+ /** The candidate type (`host` / `srflx` / `relay`) named inside an ICE candidate line. */
29
+ export declare function iceCandidateType(candidate: string): string;
30
+ /** Pin `a=max-message-size` to what the hub advertises, whatever libdatachannel wrote. */
31
+ export declare function pinMaxMessageSize(sdp: string): string;
32
+ /**
33
+ * Force a concrete DTLS role into an SDP. The hub offers `actpass`; the peer pins that offer to
34
+ * `passive`, which leaves one legal answer, `active`. The DTLS client opens its data channels on the
35
+ * even SCTP stream ids (RFC 8832), and the hub pairs with those: its command channel is stream 0.
36
+ */
37
+ export declare function forceDtlsRole(sdp: string, role: "active" | "passive"): string;
38
+ /**
39
+ * The wire form of a trickled ICE candidate: `candidate:...`, the attribute's value, which is the shape
40
+ * the hub sends and parses. The native peer hands out the SDP attribute line (`a=candidate:...`).
41
+ */
42
+ export declare function toWireCandidate(candidate: string): string;
43
+ /** The `a=mid` of the first m-section of `kind` in an SDP; `undefined` when it has none. */
44
+ export declare function midOf(sdp: string, kind: "audio" | "video" | "application"): string | undefined;
45
+ /**
46
+ * Reject an offer's `m=application` (data channel) section: port 0 and out of the BUNDLE group. The
47
+ * remote never completes the SCTP association, so a peer waiting on it fails; without it the peer
48
+ * connects on DTLS-SRTP alone.
49
+ */
50
+ export declare function rejectDataSection(sdp: string): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One T9000 command session end to end: sign → signalling socket → `scall` → the hub's SDP offer →
3
+ * our answer → ICE → DTLS → the command data channel open.
4
+ *
5
+ * The signalling exchange, as the portal runs it and as confirmed live:
6
+ *
7
+ * → scall (action 3, channel 0, the hub's own session)
8
+ * ← scall {status: 100, turn} the hub granted the session and handed out TURN credentials
9
+ * ← info {sdp | format:"SDP"} the hub's offer, as scall JSON
10
+ * → info {sdp} the answer
11
+ * ↔ info {candidate} trickle ICE on the same channel; "" ends it
12
+ * ← scall {status: 200} → ack
13
+ * ← scall {status: 486 | 408} busy / timeout: the session fails
14
+ * ← hangup the hub ended it
15
+ *
16
+ * The command data channel is the session: `close` fires once, when the channel closes, the peer fails,
17
+ * the signalling socket drops or {@link RtcSession.close} is called, even while the peer connection is
18
+ * nominally up. A peer that cannot be built when the hub grants the relay ends the session there: the
19
+ * failure is emitted as `error` and `close` fires carrying it.
20
+ *
21
+ * A session whose peer is a {@link RtcPeerOptions.mediaTrack} peer has no command channel: `close` fires
22
+ * when the peer fails or closes, and the video track's packets are emitted as `rtp`.
23
+ *
24
+ * Both the signalling client and the peer are injectable.
25
+ */
26
+ import { EventEmitter } from "node:events";
27
+ import { RtcPeer, type RtcPeerOptions } from "./peer.js";
28
+ import { RtcSignalingClient, type RtcSignalingOptions } from "./signaling.js";
29
+ export interface RtcSessionOptions extends RtcSignalingOptions {
30
+ peer?: RtcPeerOptions;
31
+ createSignaling?: (opts: RtcSignalingOptions) => RtcSignalingClient;
32
+ createPeer?: (opts: RtcPeerOptions) => RtcPeer;
33
+ }
34
+ export interface RtcSessionEvents {
35
+ connected: [];
36
+ /** The session ended; `err` is the failure that ended it, when one did. */
37
+ close: [err?: Error];
38
+ error: [err: Error];
39
+ /** A reassembled command or notify frame: portal packet bytes + the link type it arrived on. */
40
+ commandData: [frame: Buffer, linkType: number];
41
+ /** A reassembled live-video frame. */
42
+ mediaData: [frame: Buffer];
43
+ /** The hub echoed the portal keepalive on the command channel. */
44
+ keepaliveEcho: [];
45
+ /** One packet of the video track, in a {@link RtcPeerOptions.mediaTrack} session. */
46
+ rtp: [packet: Buffer];
47
+ }
48
+ export declare class RtcSession extends EventEmitter<RtcSessionEvents> {
49
+ private readonly opts;
50
+ private readonly signaling;
51
+ private readonly peer;
52
+ private readonly logger;
53
+ private turn?;
54
+ private authOk;
55
+ private authWaiter?;
56
+ private connected;
57
+ private closed;
58
+ private closeAnnounced;
59
+ private sdpHandled;
60
+ private chain;
61
+ constructor(opts: RtcSessionOptions);
62
+ get isConnected(): boolean;
63
+ /**
64
+ * Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
65
+ * closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
66
+ * placing the call.
67
+ */
68
+ connect(): Promise<void>;
69
+ /** Send one portal packet; false when the command channel isn't open. */
70
+ sendCommand(portalPacket: Buffer): boolean;
71
+ /** Send unframed bytes on the command channel; false when it isn't open. */
72
+ sendRaw(bytes: Buffer): boolean;
73
+ /**
74
+ * Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
75
+ * `connect()` still waiting for the signalling auth is rejected and its timer stopped.
76
+ */
77
+ close(): void;
78
+ /** {@link RtcSession.close}, with the failure that ended the session handed to `close` when one did. */
79
+ private shutdown;
80
+ /**
81
+ * Trickle one local candidate, or the empty end-of-candidates. The native peer can report one after the
82
+ * signalling socket has closed, and sending then throws inside a native callback, where the throw can't
83
+ * be caught and aborts the process. A candidate with no open socket is dropped.
84
+ */
85
+ private trickle;
86
+ private announceClose;
87
+ private waitForAuth;
88
+ /** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
89
+ private settleAuth;
90
+ private onSignaling;
91
+ /** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
92
+ private onCall;
93
+ /**
94
+ * An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
95
+ * is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
96
+ * is reported and left unanswered.
97
+ */
98
+ private onInfo;
99
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The T9000's signalling channel: the WebSocket through which a client and a HomeBase S1 Pro agree on a
3
+ * WebRTC session. It is the wire the security.eufy.com web client uses (`/v1/rtc/ws/join`), and the hub
4
+ * accepts it from any client presenting the account's mega token.
5
+ *
6
+ * Sequence:
7
+ *
8
+ * 1. `GET https://<smart host>/v1/smart/nvr/ws/sign` with the mega token → a `sign` blob.
9
+ * 2. WebSocket to `wss://<smart host>/v1/rtc/ws/join?reqtype=nvr`, subprotocols `["v1", <base64url
10
+ * JSON>]` carrying the cluster region, station serial, token, `gtoken` and the sign. HTTP headers on
11
+ * the upgrade alone are refused: the JSON subprotocol is what authenticates.
12
+ * 3. `action 1` auth on open; `action 3` session messages after: `scall` (start), `info` (SDP and
13
+ * trickle ICE), `ack`, `hangup`. Every session message carries an HMAC-SHA256 `account` over
14
+ * `channelId + adminUserId + ts`, keyed by the token.
15
+ *
16
+ * Region is two values: the HTTP sign request names the account's **country** (`Web-Country`), the
17
+ * WebSocket payload names the **cluster**, the shard prefix uppercased (`ie-pr` → `IE`). The smart host
18
+ * is `security-smart` for `us` and `security-smart-<prefix>` for any other shard.
19
+ *
20
+ * `fetch` and the `WebSocket` constructor are injectable.
21
+ */
22
+ import { EventEmitter } from "node:events";
23
+ import { type Logger } from "../../core/logger.js";
24
+ /** The portal's origin; the sign endpoint and the socket upgrade both check it. */
25
+ export declare const PORTAL_ORIGIN = "https://security.eufy.com";
26
+ /** The hub drops an idle signalling socket after ~83 s; re-sending auth inside that keeps it. */
27
+ export declare const SIGNALING_KEEPALIVE_MS = 25000;
28
+ /** How long the socket has to open after the sign. */
29
+ export declare const SIGNALING_CONNECT_TIMEOUT_MS = 15000;
30
+ /** Outer wire envelope. */
31
+ export interface RtcWsEnvelope {
32
+ msgid: string;
33
+ data: string;
34
+ }
35
+ /** Inner message (the envelope's `data`, JSON-parsed). */
36
+ export interface RtcInnerMessage {
37
+ code?: number;
38
+ action?: number;
39
+ sessionId?: string;
40
+ sn?: string;
41
+ subSn?: string;
42
+ channelId?: number;
43
+ isResponse?: number;
44
+ dataType?: string;
45
+ source?: string;
46
+ ts?: number;
47
+ data?: string;
48
+ msgid?: string;
49
+ }
50
+ /** What a socket event carries; only the fields each event type fills are read. */
51
+ export interface SignalingSocketEvent {
52
+ data?: unknown;
53
+ code?: number;
54
+ reason?: string;
55
+ }
56
+ /** The subset of a `WebSocket` the client uses. */
57
+ export interface SignalingSocket {
58
+ readonly readyState: number;
59
+ send(data: string): void;
60
+ close(code?: number, reason?: string): void;
61
+ addEventListener(type: "open" | "message" | "close" | "error", listener: (ev: SignalingSocketEvent) => void): void;
62
+ }
63
+ export interface SignalingSocketInit {
64
+ /** The subprotocols; slot 2 carries the base64url auth JSON. */
65
+ protocols: string[];
66
+ /** The upgrade Origin, which the server checks like the sign call. */
67
+ origin: string;
68
+ }
69
+ export type SignalingSocketFactory = (url: string, init: SignalingSocketInit) => SignalingSocket;
70
+ export interface RtcSignalingOptions {
71
+ /** Full SDP (`call`) or compact SDP (`scall`, the default). */
72
+ signalingMode?: "call" | "scall";
73
+ /** The mega session's auth token. */
74
+ authToken: string;
75
+ /** The `gtoken` header value the mega session's authed HTTP calls carry. */
76
+ gtoken: string;
77
+ stationSn: string;
78
+ /** The station's `member.admin_user_id`, the account the session HMAC names. */
79
+ adminUserId: string;
80
+ /** The mega shard the account lives on; picks the host and the cluster name. */
81
+ shard: string;
82
+ /** The account's ISO country, sent on the sign request (`Web-Country`). */
83
+ country: string;
84
+ fetch?: typeof fetch;
85
+ createSocket?: SignalingSocketFactory;
86
+ logger?: Logger;
87
+ now?: () => number;
88
+ makeMsgId?: () => string;
89
+ }
90
+ export interface RtcSignalingEvents {
91
+ message: [inner: RtcInnerMessage, envelope: RtcWsEnvelope];
92
+ open: [];
93
+ close: [code: number, reason: string];
94
+ error: [err: Error];
95
+ }
96
+ /** The portal's `account` field: HMAC-SHA256 of `channelId + adminUserId + ts`, keyed by the token. */
97
+ export declare function sessionAccount(channelId: number, adminUserId: string, ts: number, authToken: string): string;
98
+ export declare class RtcSignalingClient extends EventEmitter<RtcSignalingEvents> {
99
+ private readonly opts;
100
+ private ws?;
101
+ private sign?;
102
+ private keepalive?;
103
+ private readonly wsUrl;
104
+ private readonly signUrl;
105
+ private readonly wsRegion;
106
+ private readonly fetchImpl;
107
+ private readonly createSocket;
108
+ private readonly logger;
109
+ private readonly now;
110
+ private readonly makeMsgId;
111
+ constructor(opts: RtcSignalingOptions);
112
+ get isOpen(): boolean;
113
+ /** Step 1: the sign blob the socket and every auth message carry. A non-JSON body is reported by status. */
114
+ fetchSign(): Promise<string>;
115
+ /**
116
+ * Step 2: open the socket and send the first auth; resolves on `open`. A socket that closes or errors
117
+ * before it ever opened rejects, so the call always settles.
118
+ */
119
+ connect(): Promise<void>;
120
+ /** `action 1`, also what keeps the socket alive when re-sent. */
121
+ sendAuth(sign?: string): void;
122
+ /**
123
+ * `action 3`, a session message on channel 0, the hub's own session; `scall` opens the negotiation.
124
+ * `subSn` is empty for the hub's session.
125
+ */
126
+ sendSession(dataType: string, payload?: Record<string, unknown>): void;
127
+ sendCall(): void;
128
+ sendAck(): void;
129
+ /** The SDP answer, encoded for the negotiated call format, in an `info`. */
130
+ sendInfoSdp(sdp: string): void;
131
+ /** Trickle a candidate on the session's channel, like its SDP answer. An empty candidate ends them. */
132
+ sendInfoCandidate(candidate: string): void;
133
+ sendHangup(): void;
134
+ close(): void;
135
+ private startKeepalive;
136
+ private stopKeepalive;
137
+ private sendEnvelope;
138
+ private handleWireMessage;
139
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Strip a token the gateway echoed back into its own error message.
3
+ *
4
+ * `"token not exist, token = <the rejected token>"` is the vendor's wording, and that message travels into the
5
+ * error the SDK surfaces. A credential that has just been rejected is still a credential, and it is
6
+ * never the part of the message that explains anything.
7
+ */
8
+ export declare function withoutTokenEcho(text: string): string;
@@ -1,14 +1,17 @@
1
- /** Username/plaintext prefix the app prepends to the eufy user id. */
2
- export declare const TUYA_USERNAME_PREFIX = "eufyhome-";
1
+ /**
2
+ * Username/plaintext prefix the app prepends to the eufy user id. The production app uses `eh-`;
3
+ * `eufyhome-` is the prefix of its QA environment only.
4
+ */
5
+ export declare const TUYA_USERNAME_PREFIX = "eh-";
3
6
  /** AES-128-CBC key used to derive the Tuya password (16 bytes). */
4
7
  export declare const TUYA_PASSWORD_KEY: Buffer<ArrayBuffer>;
5
8
  /** AES-128-CBC IV used to derive the Tuya password (16 bytes). */
6
9
  export declare const TUYA_PASSWORD_IV: Buffer<ArrayBuffer>;
7
- /** The Tuya username for a eufy user id (`"eufyhome-" + eufyUserId`). */
10
+ /** The Tuya username for a eufy user id (`"eh-" + eufyUserId`). */
8
11
  export declare function tuyaUsername(eufyUserId: string): string;
9
12
  /**
10
13
  * Derive the Tuya password for a eufy user id:
11
- * `UPPERCASE-HEX( AES-128-CBC-NoPadding( pad16("eufyhome-"+eufyUserId), KEY, IV ) )`.
14
+ * `UPPERCASE-HEX( AES-128-CBC-NoPadding( pad16("eh-"+eufyUserId), KEY, IV ) )`.
12
15
  * Deterministic (fixed KEY/IV) — the same user id always yields the same password.
13
16
  */
14
17
  export declare function deriveTuyaPassword(eufyUserId: string): string;
@@ -29,7 +32,7 @@ export declare function isoToDialCode(iso: string): string | undefined;
29
32
  export declare function resolveCountryCode(phoneCode?: string, region?: string, isoCode?: string): string;
30
33
  /** A derived Tuya login identity — everything needed to call the uid token/password login actions. */
31
34
  export interface TuyaAccount {
32
- /** `"eufyhome-" + eufyUserId`. */
35
+ /** `"eh-" + eufyUserId`. */
33
36
  username: string;
34
37
  /** AES-derived password (uppercase hex). See {@link deriveTuyaPassword}. */
35
38
  password: string;
@@ -50,8 +50,21 @@ export declare class TuyaClient {
50
50
  * Requires a working signer (the sign step).
51
51
  */
52
52
  buildRequest(action: TuyaAction): Record<string, string>;
53
- /** Build + POST an action, returning the parsed envelope. Requires a working signer. */
53
+ /**
54
+ * Build + POST an action, returning the parsed envelope. Requires a working signer.
55
+ *
56
+ * On an `et=3` reply the envelope's `result` is a base64 AES-128-GCM blob, not an object; this
57
+ * decrypts it (keyed off the request's `requestId` and the session's `ecode`) and returns the
58
+ * decrypted inner envelope, so callers read `result` as an object regardless. A non-string `result`
59
+ * (an error, or an already-plain reply) passes through untouched.
60
+ */
54
61
  call<T = unknown>(action: TuyaAction): Promise<TuyaEnvelope<T>>;
62
+ /**
63
+ * Decode an et=3 reply. When `result` is a non-empty string it is the encrypted blob: decrypt and
64
+ * parse it (a wrong key or tampered blob fails the GCM tag in {@link decryptReply} and throws). The
65
+ * plaintext is itself the standard `{success, t, result}` envelope, which becomes the returned one.
66
+ */
67
+ private decodeReply;
55
68
  /**
56
69
  * Log into the Tuya cloud from a eufy user id (wire-confirmed from the eufy Security app).
57
70
  *
@@ -68,12 +81,13 @@ export declare class TuyaClient {
68
81
  */
69
82
  login(eufyUserId: string, phoneCode?: string): Promise<TuyaLoginResult>;
70
83
  /**
71
- * READ/dump a device's cached data-points (`thing.m.device.cache.dp.get`).
72
- * Builds the request without needing a working signer; the signer is only exercised on send.
84
+ * READ a device's current data-points (`smartlife.m.device.dp.get`). The envelope's `result` is a
85
+ * flat `{ "<dpId>": value }` map. Builds the request without needing a working signer; the signer is
86
+ * only exercised on send.
73
87
  */
74
- getDeviceDps<T = unknown>(devId: string, dpCacheType?: number): Promise<TuyaEnvelope<T>>;
88
+ getDeviceDps<T = unknown>(devId: string): Promise<TuyaEnvelope<T>>;
75
89
  /**
76
- * CONTROL: publish data-points to a device (`thing.m.device.dp.publish`). `dps` is
90
+ * CONTROL: publish data-points to a device (`smartlife.m.device.dp.publish`). `dps` is
77
91
  * `{ "<dpId>": <value> }`. `gwId` is the gateway/parent id (equals `devId` for a standalone gw).
78
92
  *
79
93
  * ⚠️ UNVERIFIED write — refuses to send by default. The `dp.publish` param shape
@@ -32,9 +32,9 @@ export declare class TuyaCommandRouter {
32
32
  /** Promise that resolves once Tuya login has completed. Reset by {@link bind}. */
33
33
  private loginOnce;
34
34
  /**
35
- * eufy SN → Tuya device ids, populated by the facade via {@link registerDevice}.
36
- * The facade extracts the Tuya id from the `raw` device record fields (`tuya_uuid`,
37
- * `tuya_virtual_id`, `tuya_device_id`, `virtualId`) and registers it once the device list loads.
35
+ * eufy SN → Tuya device ids, populated via {@link registerDevice} once the device list loads.
36
+ * The Tuya id is the record's first non-empty `tuya_uuid` / `tuya_virtual_id` / `tuya_device_id` /
37
+ * `virtualId`, or the device's serial when the record carries none of them.
38
38
  */
39
39
  private readonly snMap;
40
40
  constructor(config?: TuyaCommandRouterConfig);
@@ -48,21 +48,17 @@ export declare class TuyaCommandRouter {
48
48
  */
49
49
  bind(userId: string, regionShard?: string, isoCode?: string): void;
50
50
  /**
51
- * Register a eufy SN → Tuya devId mapping. Called by the facade for each `eufy_home_tuya`
52
- * device after the cloud device list loads. The facade extracts the Tuya id from the device's
53
- * raw record (`tuya_uuid` / `tuya_virtual_id` / `tuya_device_id` / `virtualId` fields).
54
- * `gwId` defaults to `devId` — standalone devices share the two.
51
+ * Register a eufy SN → Tuya devId mapping for an `eufy_home_tuya` device. `devId` is the record's
52
+ * first non-empty `tuya_uuid` / `tuya_virtual_id` / `tuya_device_id` / `virtualId`, or the device's
53
+ * serial when the record carries none of them. `gwId` defaults to `devId` — standalone devices
54
+ * share the two.
55
55
  */
56
56
  registerDevice(sn: string, devId: string, gwId?: string): void;
57
57
  private ensureLoggedIn;
58
58
  /**
59
- * Fetch a device's cached DPs from the Tuya cloud (`thing.m.device.cache.dp.get`) and deliver
60
- * the raw DP map to the caller. Used for initial state hydration after MQTT subscribe — gets the
61
- * last-known state without waiting for the first realtime push.
62
- *
63
- * The response shape from `getDeviceDps` is not yet pinned from a live capture. The defensive
64
- * extraction tries both `result.dps` (a nested map) and bare `result` (a flat map), and returns
65
- * `null` when neither yields a non-empty record.
59
+ * Read a device's current DPs from the Tuya cloud (`smartlife.m.device.dp.get`) and answer the DP
60
+ * map: the reply's flat `result`, kept to integer DP keys with scalar values. Answers `null` for an
61
+ * unregistered device, a failed reply, or a reply with no DPs.
66
62
  */
67
63
  fetchDps(sn: string): Promise<Record<string, unknown> | null>;
68
64
  /**
@@ -1,14 +1,9 @@
1
1
  /**
2
- * Inbound Tuya DP event parsing and routing for eufy Home/Clean (`eufy_home_tuya`) devices.
2
+ * Inbound Tuya DP parsing and routing for eufy Home/Clean (`eufy_home_tuya`) devices.
3
3
  *
4
- * {@link parseTuyaDpReport} unwraps the Anker MQTT `{head, payload}` envelope and extracts the
5
- * raw DP map. {@link parseTuyaDpEvent} then validates and normalises it to a numeric-keyed record.
4
+ * {@link parseTuyaDpEvent} validates a Tuya cloud DP map and normalises it to a numeric-keyed record.
6
5
  * {@link TuyaDpRouter} delivers the result to the registered {@link TuyaDpInbound} listener so the
7
6
  * capability layer can consume Tuya state through the same typed getters as AIoT.
8
- *
9
- * Inbound messages arrive on `cmd/eufy_home/{model}/{sn}/res` with `sign_code: 0` (no additional
10
- * encryption — confirmed from clean-device captures). If a future capture shows `sign_code ≠ 0` the
11
- * payload will be AES-128-ECB encrypted with the device's localKey before the JSON can be parsed.
12
7
  */
13
8
  import type { TuyaDpInbound } from "../../core/contracts.js";
14
9
  /**
@@ -21,22 +16,6 @@ import type { TuyaDpInbound } from "../../core/contracts.js";
21
16
  * valid DP entry was found.
22
17
  */
23
18
  export declare function parseTuyaDpEvent(payload: unknown): Record<number, boolean | number | string> | null;
24
- /**
25
- * Extract a raw DP map from an inbound Anker MQTT message for a `eufy_home_tuya` device.
26
- *
27
- * Messages arrive in the same `{head, payload}` envelope the AIoT path uses, where `payload` is a
28
- * JSON **string**:
29
- * ```json
30
- * { "head": { "cmd": 65537, "cmd_status": 2, "sign_code": 0, ... },
31
- * "payload": "{\"t\":\"…\",\"protocol\":2,\"data\":{\"104\":80,\"106\":0}}" }
32
- * ```
33
- * Two DP layouts inside the decoded payload are handled: Tuya-native (`data.dps`) and AIoT-direct
34
- * (`data` with integer-keyed DP ids). Falls back to flat `envelope.dps` / `envelope.data.dps`
35
- * shapes for any pre-parsed delivery. Returns `undefined` when no recognised shape is found.
36
- *
37
- * @internal
38
- */
39
- export declare function parseTuyaDpReport(raw: unknown): Record<string, unknown> | undefined;
40
19
  /**
41
20
  * Routes validated inbound Tuya DP events to the registered {@link TuyaDpInbound} listener.
42
21
  *
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The per-request AES-128 key, `getEncryptoKey(requestId, ecode)`: take the lowercase hex of
3
+ * `HMAC-SHA256(key = requestId, msg = K[+ "_" + ecode])`, keep the first 16 hex characters, and use
4
+ * those characters as 16 ASCII bytes. `K` is the app HMAC key ({@link resolveSignKey}).
5
+ *
6
+ * `ecode` is the session's per-login encryption code (`User.ecode`, read from the decrypted
7
+ * `login.reg` reply). `ecode === null` selects the pre-session variant `msg = K` used to open the
8
+ * `token.get` and `login.reg` replies themselves, before any `ecode` is known.
9
+ */
10
+ export declare function deriveBodyKey(requestId: string, ecode: string | null): Buffer;
11
+ /**
12
+ * Encrypt an `et=3` request `postData`: AES-128-GCM under a random 12-byte nonce, laid out
13
+ * `nonce(12) ‖ ciphertext ‖ tag(16)` then base64 — the inverse of {@link decryptReply}, keyed by the
14
+ * same {@link deriveBodyKey}. No AAD. The request is signed over this blob.
15
+ */
16
+ export declare function encryptBody(key: Buffer, json: string): string;
17
+ /**
18
+ * Decrypt an `et=3` reply `result`: base64 → AES-128-GCM-decrypt-verify (`nonce = blob[0:12]`,
19
+ * `tag = blob[-16:]`, ciphertext between), gunzip when the plaintext is gzip-framed, and return the
20
+ * plaintext JSON string. Throws when the blob is too short or the GCM tag fails to authenticate.
21
+ */
22
+ export declare function decryptReply(key: Buffer, resultB64: string): string;
@@ -2,9 +2,10 @@
2
2
  * Tuya / Thingclips `api.json` request assembly + transport.
3
3
  *
4
4
  * Every call is a single `POST https://a1.tuyaeu.com/api.json` (EU) whose body is
5
- * **form-urlencoded plaintext params** (NOT an encrypted blob) plus a `sign`. This module builds
6
- * that param map for a given action, form-encodes it, POSTs it, and parses Tuya's response
7
- * envelope. The one non-plaintext piece — the `sign` digest — is delegated to a {@link TuyaSigner}.
5
+ * **form-urlencoded params** plus a `sign`. Under `et=3` the `postData` param is an AES-128-GCM blob
6
+ * and the reply `result` is encrypted the same way, with the same per-request key. This module builds
7
+ * that param map for a given action, form-encodes it, POSTs it, and parses Tuya's response envelope.
8
+ * The `sign` digest is delegated to a {@link TuyaSigner}.
8
9
  */
9
10
  /** Injectable POST transport — takes (url, form-body), returns the parsed JSON envelope. Native `fetch` by default. */
10
11
  export type TuyaHttpPost = (url: string, body: string) => Promise<unknown>;
@@ -24,7 +25,10 @@ export declare const TUYA_SDK_VERSION = "7.5.0";
24
25
  export declare const TUYA_APP_VERSION = "6.0.51_26722";
25
26
  /** A single action to call: the `a` (action) + `v` (version) + optional pre-serialized `postData`. */
26
27
  export interface TuyaAction {
27
- /** Action name, e.g. `thing.m.device.dp.publish`. */
28
+ /**
29
+ * Action name as it goes on the wire, e.g. `smartlife.m.device.dp.publish`. The vendor SDK names
30
+ * its actions `thing.*` and rewrites a leading `thing` to `smartlife` before sending.
31
+ */
28
32
  a: string;
29
33
  /** Per-action version, e.g. `"1.0"` / `"2.0"`. */
30
34
  v: string;
@@ -43,6 +47,11 @@ export interface TuyaSession {
43
47
  * `"7cbfe6d8"` for this app's appId. Hardcode it.
44
48
  */
45
49
  chKey: string;
50
+ /**
51
+ * Per-login encryption code (`User.ecode`), read from the decrypted `login.reg` reply. Absent until
52
+ * login completes; it keys et=3 body encryption and reply decryption for every session call.
53
+ */
54
+ ecode?: string;
46
55
  }
47
56
  /**
48
57
  * The constant / environment fields of a request. All are plaintext and (except the sign-relevant
@@ -127,14 +136,14 @@ export interface SendOptions {
127
136
  export declare function sendApiRequest<T = unknown>(params: Record<string, string>, opts?: SendOptions): Promise<TuyaEnvelope<T>>;
128
137
  /**
129
138
  * CONTROL: publish device data-points. `dps` is `{ "<dpId>": <value> }`; note it is JSON-stringified
130
- * INSIDE the postData (Tuya nests the dps map as a string). Action `thing.m.device.dp.publish` v2.0.
139
+ * INSIDE the postData (Tuya nests the dps map as a string). Action `smartlife.m.device.dp.publish` v2.0.
131
140
  */
132
141
  export declare function buildPublishDpsAction(devId: string, gwId: string, dps: Record<string, unknown>): TuyaAction;
133
142
  /**
134
- * READ/dump: fetch a device's cached data-points. Action `thing.m.device.cache.dp.get` v2.0.
135
- * TODO(verify): `dpCacheType` default (1) was not pinned from the capture.
143
+ * READ: a device's current data-points. Action `smartlife.m.device.dp.get` v1.0, postData `{ devId }`.
144
+ * The reply `result` is a flat `{ "<dpId>": value }` map.
136
145
  */
137
- export declare function buildGetDeviceDpsAction(devId: string, dpCacheType?: number): TuyaAction;
146
+ export declare function buildGetDeviceDpsAction(devId: string): TuyaAction;
138
147
  /**
139
148
  * LOGIN step 1: fetch a pre-login RSA token for a Tuya uid.
140
149
  * Wire-confirmed from the eufy Security app (`com.oceanwing.battery.cam`):
@@ -146,6 +155,7 @@ export declare function buildUsernameTokenGetAction(countryCode: string, usernam
146
155
  * LOGIN step 2: uid password login + auto-register.
147
156
  * Wire-confirmed from the eufy Security app: wire action
148
157
  * `smartlife.m.user.uid.password.login.reg`, v=1.0. `passwd` = hex of RSA-PKCS1-encrypt(MD5hex(aesPassword)).
158
+ * `options` is the JSON string `{"group": 1}`, as the app sends it.
149
159
  * On success returns `{ sid, uid }`.
150
160
  */
151
161
  export declare function buildPasswordLoginRegAction(countryCode: string, uid: string, passwd: string, token: string): TuyaAction;
@@ -44,6 +44,13 @@ export declare class StubSigner implements TuyaSigner {
44
44
  * The env var `TUYA_SIGN_KEY` can override this for non-standard builds.
45
45
  */
46
46
  export declare const TUYA_SIGN_K: string;
47
+ /**
48
+ * Resolve the app-wide HMAC key {@link TUYA_SIGN_K}: an explicit `override` wins, then the
49
+ * `TUYA_SIGN_KEY` env var (for custom builds), then the built-in constant. Shared by the request
50
+ * signer ({@link HmacSigner}) and the et=3 body-key derivation, which key off the same `K`.
51
+ * @internal
52
+ */
53
+ export declare function resolveSignKey(override?: string): string;
47
54
  /**
48
55
  * The real {@link TuyaSigner}: `sign = HMAC-SHA256(K, preimage)` as lowercase hex.
49
56
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mega-yfue/eufy-sdk",
3
- "version": "0.5.0-beta.8",
3
+ "version": "0.5.0-beta.81",
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",
@@ -74,6 +74,7 @@
74
74
  "dependencies": {
75
75
  "jpeg-js": "^0.4.4",
76
76
  "mqtt": "^5.16.0",
77
+ "node-datachannel": "^0.33.4",
77
78
  "protobufjs": "^8.8.0"
78
79
  },
79
80
  "devDependencies": {