@mega-yfue/eufy-sdk 0.5.0-beta.7 → 0.5.0-beta.71
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/device-registry.d.ts +24 -2
- package/dist/client/eufy-mega.d.ts +83 -10
- package/dist/client/types.d.ts +1 -1
- package/dist/core/contracts.d.ts +71 -30
- package/dist/core/store.d.ts +4 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/index.js +3611 -603
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/arming.d.ts +39 -4
- package/dist/model/capabilities/camera.d.ts +24 -10
- package/dist/model/capabilities/dp-catalog.d.ts +4 -3
- package/dist/model/capabilities/index.d.ts +16 -3
- package/dist/model/capabilities/light.d.ts +3 -0
- package/dist/model/capabilities/lock.d.ts +21 -7
- package/dist/model/capabilities/motion.d.ts +5 -0
- package/dist/model/capabilities/mower.d.ts +230 -0
- package/dist/model/capabilities/rtsp.d.ts +16 -3
- package/dist/model/capabilities/smart-drop.d.ts +20 -0
- package/dist/model/capabilities/suction.d.ts +5 -5
- package/dist/model/capabilities/types.d.ts +10 -0
- package/dist/model/capabilities/vacuum-clean.d.ts +19 -13
- package/dist/model/classify.d.ts +1 -2
- package/dist/model/device-family.d.ts +8 -0
- package/dist/model/param-dictionary.d.ts +6 -0
- package/dist/model/param-namespace.d.ts +1 -1
- package/dist/model/registry.d.ts +3 -2
- package/dist/model/types.d.ts +26 -12
- package/dist/transport/abortable.d.ts +8 -0
- package/dist/transport/http/mega-client.d.ts +34 -2
- package/dist/transport/mqtt/broker-discovery.d.ts +2 -1
- package/dist/transport/mqtt/command-router.d.ts +3 -2
- package/dist/transport/p2p/codec.d.ts +16 -0
- package/dist/transport/p2p/command-router.d.ts +27 -35
- package/dist/transport/p2p/fmp4.d.ts +12 -2
- package/dist/transport/p2p/live-trace.d.ts +11 -2
- package/dist/transport/p2p/p2p-session.d.ts +59 -12
- package/dist/transport/p2p/readable-egress.d.ts +4 -0
- package/dist/transport/p2p/recording-download.d.ts +4 -3
- package/dist/transport/p2p/shared-live-source.d.ts +27 -13
- package/dist/transport/p2p/talkback.d.ts +21 -10
- package/dist/transport/p2p/video.d.ts +2 -0
- package/dist/transport/rtc/call-live.d.ts +41 -0
- package/dist/transport/rtc/command-router.d.ts +141 -0
- package/dist/transport/rtc/live.d.ts +69 -0
- package/dist/transport/rtc/peer.d.ts +122 -0
- package/dist/transport/rtc/portal-keepalive.d.ts +11 -0
- package/dist/transport/rtc/portal-packet.d.ts +72 -0
- package/dist/transport/rtc/ptcs-framer.d.ts +86 -0
- package/dist/transport/rtc/scall-sdp.d.ts +50 -0
- package/dist/transport/rtc/session.d.ts +95 -0
- package/dist/transport/rtc/signaling.d.ts +139 -0
- package/dist/transport/token-echo.d.ts +8 -0
- package/dist/transport/tuya/account.d.ts +8 -5
- package/dist/transport/tuya/client.d.ts +19 -5
- package/dist/transport/tuya/command-router.d.ts +10 -14
- package/dist/transport/tuya/dp-codec.d.ts +2 -23
- package/dist/transport/tuya/et3.d.ts +22 -0
- package/dist/transport/tuya/request.d.ts +18 -8
- package/dist/transport/tuya/sign.d.ts +7 -0
- package/package.json +2 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebRTC peer for a T9000 session: libdatachannel through `node-datachannel`, driven the way the
|
|
3
|
+
* portal drives a browser's RTCPeerConnection.
|
|
4
|
+
*
|
|
5
|
+
* The hub is the offerer: it sends its SDP once it has granted the session, and the peer answers. Six
|
|
6
|
+
* data channels are declared in the portal's order (`WebrtcDataChannel` first, the command channel,
|
|
7
|
+
* then audio, idr, video, notify, download). Every one carries PTCS-framed portal packets; which logical
|
|
8
|
+
* channel a reassembled frame belongs to is the PTCS header's channel, not the data channel it rode on.
|
|
9
|
+
*
|
|
10
|
+
* ICE defaults to relay-only. The T9000 grants a TURN allocation in its `scall 100` reply and completes DTLS only
|
|
11
|
+
* over the relay pair; on a host pair ICE connects and the DTLS handshake never completes. The peer
|
|
12
|
+
* plays the DTLS client (`active`) and opens its channels on the client's even SCTP stream ids, which
|
|
13
|
+
* is what the hub pairs with.
|
|
14
|
+
*
|
|
15
|
+
* With {@link RtcPeerOptions.mediaTrack} the remote is a T8223 doorbell instead: its offer bundles an Opus
|
|
16
|
+
* audio, an H.264 video and an `m=application` section, but it never completes the SCTP association. The
|
|
17
|
+
* application section is rejected before the offer is applied, no data channel is declared, and the
|
|
18
|
+
* video m-line's RTP is emitted as `rtp`.
|
|
19
|
+
*
|
|
20
|
+
* The native peer is injectable.
|
|
21
|
+
*/
|
|
22
|
+
import { EventEmitter } from "node:events";
|
|
23
|
+
import type { PeerConnection, RtcConfig } from "node-datachannel";
|
|
24
|
+
import { type Logger } from "../../core/logger.js";
|
|
25
|
+
export interface TurnConfig {
|
|
26
|
+
turn_addr: string;
|
|
27
|
+
turn_port: number;
|
|
28
|
+
turn_user: string;
|
|
29
|
+
turn_password: string;
|
|
30
|
+
alt_turn_addr?: string;
|
|
31
|
+
alt_turn_port?: number;
|
|
32
|
+
}
|
|
33
|
+
export type NativePeerFactory = (name: string, config: RtcConfig) => PeerConnection;
|
|
34
|
+
export interface RtcPeerOptions {
|
|
35
|
+
createPeer?: NativePeerFactory;
|
|
36
|
+
/** Candidate policy for the station's exchange; the default is relay-only. */
|
|
37
|
+
iceTransportPolicy?: "relay" | "all";
|
|
38
|
+
/**
|
|
39
|
+
* The remote streams its video as RTP on the offer's video m-line and serves no data channel: the
|
|
40
|
+
* offer's application section is rejected and the video track's packets are emitted as `rtp`.
|
|
41
|
+
*/
|
|
42
|
+
mediaTrack?: boolean;
|
|
43
|
+
logger?: Logger;
|
|
44
|
+
}
|
|
45
|
+
export interface RtcPeerEvents {
|
|
46
|
+
commandChannelOpen: [];
|
|
47
|
+
/** The command channel went away while the peer itself may still be up. */
|
|
48
|
+
commandChannelClosed: [];
|
|
49
|
+
data: [frame: Buffer, linkType: number];
|
|
50
|
+
/** The hub echoed the portal keepalive on the command channel. */
|
|
51
|
+
keepaliveEcho: [];
|
|
52
|
+
/** One RTP (or multiplexed RTCP) packet from the offer's video m-line, in a {@link RtcPeerOptions.mediaTrack} peer. */
|
|
53
|
+
rtp: [packet: Buffer];
|
|
54
|
+
iceCandidate: [candidate: string];
|
|
55
|
+
iceGatheringComplete: [];
|
|
56
|
+
connectionState: [state: string];
|
|
57
|
+
error: [err: Error];
|
|
58
|
+
}
|
|
59
|
+
/** The portal's channel list; index 0 is the command channel. */
|
|
60
|
+
export declare const DATA_CHANNEL_LABELS: readonly ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
|
|
61
|
+
export declare const COMMAND_CHANNEL: "WebrtcDataChannel";
|
|
62
|
+
export declare class RtcPeer extends EventEmitter<RtcPeerEvents> {
|
|
63
|
+
private readonly opts;
|
|
64
|
+
private pc?;
|
|
65
|
+
private readonly channels;
|
|
66
|
+
private framer?;
|
|
67
|
+
private readonly wireTally;
|
|
68
|
+
private commandOpen;
|
|
69
|
+
private remoteSet;
|
|
70
|
+
private remoteMid;
|
|
71
|
+
private handlingOffer;
|
|
72
|
+
private channelsCreated;
|
|
73
|
+
private videoMid?;
|
|
74
|
+
private videoTrack?;
|
|
75
|
+
private gatheringDone;
|
|
76
|
+
/** Set by the framer's wire callback when the native channel refused a packet mid-send. */
|
|
77
|
+
private wireSendFailed;
|
|
78
|
+
private readonly pending;
|
|
79
|
+
private localAnswer?;
|
|
80
|
+
private readonly logger;
|
|
81
|
+
constructor(opts?: RtcPeerOptions);
|
|
82
|
+
/** Create the native peer on the relay the hub granted in `scall 100`. */
|
|
83
|
+
init(turn: TurnConfig): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
|
|
86
|
+
*
|
|
87
|
+
* The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
|
|
88
|
+
* `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
|
|
89
|
+
* only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
|
|
90
|
+
* a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
|
|
91
|
+
* The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
|
|
92
|
+
* is taken as the local description: one read in `have-local-offer` is an offer. A
|
|
93
|
+
* {@link RtcPeerOptions.mediaTrack} peer applies the offer with its application section rejected,
|
|
94
|
+
* declares no channels, and adds remote candidates on the offer's first (bundle-tagged) mid.
|
|
95
|
+
*/
|
|
96
|
+
handleRemoteOffer(offerSdp: string): Promise<string>;
|
|
97
|
+
addRemoteCandidate(candidate: string): void;
|
|
98
|
+
/**
|
|
99
|
+
* Send bytes on the command channel as they are, without PTCS framing; false when it isn't open. The
|
|
100
|
+
* portal's data-channel keepalive rides the wire unframed.
|
|
101
|
+
*/
|
|
102
|
+
sendRaw(bytes: Buffer): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Send one portal packet on the command channel, through the framer. False when the channel is not
|
|
105
|
+
* usable or the native send refused a wire packet, so a dropped frame does not read as success.
|
|
106
|
+
*/
|
|
107
|
+
sendCommand(portalPacket: Buffer): boolean;
|
|
108
|
+
/** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
|
|
109
|
+
close(): void;
|
|
110
|
+
private acceptsCandidate;
|
|
111
|
+
private createChannels;
|
|
112
|
+
/**
|
|
113
|
+
* Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
|
|
114
|
+
* message before that has nothing to reassemble into and is dropped. The hub's echo of the portal
|
|
115
|
+
* keepalive on the command channel is unframed and surfaces as `keepaliveEcho` instead.
|
|
116
|
+
*/
|
|
117
|
+
private wireChannel;
|
|
118
|
+
/** Take the offer's video m-line as the one track whose RTP is emitted; any other track is left alone. */
|
|
119
|
+
private wireVideoTrack;
|
|
120
|
+
private addNow;
|
|
121
|
+
private flushPending;
|
|
122
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The portal's data-channel keepalive for an RTC session.
|
|
3
|
+
*
|
|
4
|
+
* {@link PORTAL_KEEPALIVE} is the 36 bytes the portal sends unframed on the data channel — a 20-byte
|
|
5
|
+
* prefix then a bare `XZYH 1139`, no portal packet header — and the hub echoes it. Sent every
|
|
6
|
+
* {@link PORTAL_KEEPALIVE_MS}, it stops the hub idling the session out.
|
|
7
|
+
*/
|
|
8
|
+
/** The portal's 36-byte data-channel keepalive: a 20-byte prefix and a bare `XZYH 1139`. */
|
|
9
|
+
export declare const PORTAL_KEEPALIVE: Buffer<ArrayBuffer>;
|
|
10
|
+
/** How often {@link PORTAL_KEEPALIVE} goes out on an RTC session. */
|
|
11
|
+
export declare const PORTAL_KEEPALIVE_MS = 29000;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command framing a HomeBase S1 Pro (T9000) speaks over its WebRTC data channel — the wire the
|
|
3
|
+
* security.eufy.com web client builds, reproduced byte-exact.
|
|
4
|
+
*
|
|
5
|
+
* A packet is a 16-byte header behind the ASCII magic `XZYH`, then a JSON body, sent in the clear (the
|
|
6
|
+
* DTLS channel is the encryption). Replies come back with `isResponse = 1`, the same `segment` the
|
|
7
|
+
* request carried, and a body whose first four bytes are a little-endian result code.
|
|
8
|
+
*
|
|
9
|
+
* Every offset is the portal bundle's header builder and parser (`Gr()` / `Qr()`) and its
|
|
10
|
+
* `worker_sctp_send`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PORTAL_HEADER_LENGTH = 16;
|
|
13
|
+
/** The SET_PAYLOAD envelope. */
|
|
14
|
+
export declare const PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
15
|
+
/** The channel a station-wide command is addressed to. */
|
|
16
|
+
export declare const PORTAL_STATION_CHANNEL = 255;
|
|
17
|
+
/** Portal link types — which logical channel a frame belongs to (`worker_sctp_send`). */
|
|
18
|
+
export declare const PortalLinkType: {
|
|
19
|
+
/** Command channel: requests out, acknowledgements back. */
|
|
20
|
+
readonly COMMAND: 1;
|
|
21
|
+
/** Station-originated frames: pushes, notify payloads, control results. */
|
|
22
|
+
readonly NOTIFY: 3;
|
|
23
|
+
/** Live video. */
|
|
24
|
+
readonly LIVE: 5;
|
|
25
|
+
};
|
|
26
|
+
export interface PortalHeader {
|
|
27
|
+
commandId: number;
|
|
28
|
+
paramLength: number;
|
|
29
|
+
segment: number;
|
|
30
|
+
channel: number;
|
|
31
|
+
isResponse: number;
|
|
32
|
+
devType: number;
|
|
33
|
+
}
|
|
34
|
+
export interface PortalRequest {
|
|
35
|
+
commandId: number;
|
|
36
|
+
channel: number;
|
|
37
|
+
/** 1..255, wraps; echoed by the reply so it can be matched. */
|
|
38
|
+
segment: number;
|
|
39
|
+
payload: Record<string, unknown>;
|
|
40
|
+
isResponse?: number;
|
|
41
|
+
devType?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface PortalResponse {
|
|
44
|
+
commandId: number;
|
|
45
|
+
channel: number;
|
|
46
|
+
segment: number;
|
|
47
|
+
isResponse: number;
|
|
48
|
+
linkType: number;
|
|
49
|
+
/** Command-channel replies only: the int32 result code (0 = ok). */
|
|
50
|
+
errCode?: number;
|
|
51
|
+
/** Notify frames: the nested `cmd` the JSON body carries. */
|
|
52
|
+
cmd?: number;
|
|
53
|
+
data?: unknown;
|
|
54
|
+
}
|
|
55
|
+
/** The 16-byte header (portal `Gr()`). */
|
|
56
|
+
export declare function buildPortalHeader(commandId: number, paramLength: number, channel: number, segment: number, isResponse?: number, devType?: number): Buffer;
|
|
57
|
+
/** Parse the header (portal `Qr()`); `undefined` when the magic is missing or the buffer is short. */
|
|
58
|
+
export declare function parsePortalHeader(buf: Buffer): PortalHeader | undefined;
|
|
59
|
+
/** Whether a buffer starts a portal packet. */
|
|
60
|
+
export declare function isPortalPacket(buf: Buffer): boolean;
|
|
61
|
+
/** A complete request packet: header + body. */
|
|
62
|
+
export declare function buildPortalPacket(req: PortalRequest): Buffer;
|
|
63
|
+
/**
|
|
64
|
+
* Parse an inbound packet. On the command channel the body opens with the int32 result code and may
|
|
65
|
+
* carry JSON after it; on the notify channel the whole body is JSON with a nested `cmd`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function parsePortalPacket(buf: Buffer, linkType?: number): PortalResponse | undefined;
|
|
68
|
+
/** A 1..255 segment counter that never yields 0 (the portal reserves it). */
|
|
69
|
+
export declare class SegmentCounter {
|
|
70
|
+
private current;
|
|
71
|
+
next(): number;
|
|
72
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The PTCS packetiser: a clean-room implementation of the framing the portal wraps around every portal
|
|
3
|
+
* packet on `WebrtcDataChannel` (the portal's `libsctp` module). Nothing of that module ships here.
|
|
4
|
+
*
|
|
5
|
+
* Every wire packet is the same size, `28 + payload bytes`, zero-padded. The size is carried per packet,
|
|
6
|
+
* so the receiver accepts any:
|
|
7
|
+
*
|
|
8
|
+
* 0 "PTCS"
|
|
9
|
+
* 4 u8 3 constant
|
|
10
|
+
* 5 u8 channel the logical channel the frame belongs to: 0 command, 2 notify, 5 live
|
|
11
|
+
* 6 u16 sequence a frame counter: every packet of a frame carries the same value, and it
|
|
12
|
+
* steps once per frame
|
|
13
|
+
* 8 u32 frame id one value per frame, shared by all its packets — the portal uses a ms clock
|
|
14
|
+
* 12 u32 frame length total bytes of the frame
|
|
15
|
+
* 16 u16 packet index 0-based position of this packet in the frame
|
|
16
|
+
* 18 u16 0x4000 | (last ? 0x0400 : 0) | payload length (payload ≤ 1023 fits the low bits)
|
|
17
|
+
* 20 8 × 0
|
|
18
|
+
* 28 payload `payload length` bytes, then zeros to the packet size
|
|
19
|
+
*
|
|
20
|
+
* The format carries no forward error correction. The receiver completes a frame only when every index
|
|
21
|
+
* up to the `last` one is present and their lengths add up; frames whose packets stop arriving are
|
|
22
|
+
* dropped after {@link PTCS_STALE_MS}.
|
|
23
|
+
*/
|
|
24
|
+
export declare const PTCS_HEADER_LENGTH = 28;
|
|
25
|
+
/** The portal's packet payload size: 1028-byte packets, 1000 bytes of payload after the header. */
|
|
26
|
+
export declare const PTCS_DEFAULT_PAYLOAD_BYTES = 1000;
|
|
27
|
+
/** SCTP channel ids as the portal numbers them, and the link type each maps to on receive. */
|
|
28
|
+
export declare const PtcsChannel: {
|
|
29
|
+
readonly COMMAND: 0;
|
|
30
|
+
readonly NOTIFY: 2;
|
|
31
|
+
readonly LIVE: 5;
|
|
32
|
+
};
|
|
33
|
+
/** The link type a received frame belongs to: notify and live on their channels, command otherwise. */
|
|
34
|
+
export declare function linkTypeForChannel(channel: number): number;
|
|
35
|
+
/** How long a partly received frame is kept before it is dropped. */
|
|
36
|
+
export declare const PTCS_STALE_MS = 15000;
|
|
37
|
+
export interface PtcsHeader {
|
|
38
|
+
channel: number;
|
|
39
|
+
/** The sender's running packet counter — see the header map. */
|
|
40
|
+
sequence: number;
|
|
41
|
+
frameId: number;
|
|
42
|
+
frameLength: number;
|
|
43
|
+
index: number;
|
|
44
|
+
last: boolean;
|
|
45
|
+
payloadLength: number;
|
|
46
|
+
}
|
|
47
|
+
export declare function parsePtcsHeader(packet: Buffer): PtcsHeader | undefined;
|
|
48
|
+
/** Split one frame into wire packets. */
|
|
49
|
+
export declare function packetize(frame: Buffer, opts: {
|
|
50
|
+
channel?: number;
|
|
51
|
+
frameId: number;
|
|
52
|
+
payloadBytes?: number;
|
|
53
|
+
sequence?: number;
|
|
54
|
+
}): Buffer[];
|
|
55
|
+
/** Reassemble frames from wire packets, in any order, one frame at a time per (channel, id). */
|
|
56
|
+
export declare class PtcsReassembler {
|
|
57
|
+
private readonly onFrame;
|
|
58
|
+
private readonly now;
|
|
59
|
+
private readonly partials;
|
|
60
|
+
constructor(onFrame: (frame: Buffer, channel: number) => void, now?: () => number);
|
|
61
|
+
push(packet: Buffer): boolean;
|
|
62
|
+
/** Drop frames that stopped arriving; call periodically. */
|
|
63
|
+
expire(): void;
|
|
64
|
+
}
|
|
65
|
+
/** Frame ids the way the portal draws them — a millisecond clock, nudged forward on a collision. */
|
|
66
|
+
export declare function frameIdClock(now?: () => number): () => number;
|
|
67
|
+
/**
|
|
68
|
+
* The framer between portal packets and the data channel: PTCS out, PTCS in, with the portal's channel
|
|
69
|
+
* mapping. An inbound bare `XZYH` packet, which the hub sometimes answers with, passes through as a
|
|
70
|
+
* command frame. Frame ids are a millisecond clock and the frame counter starts at 0.
|
|
71
|
+
*/
|
|
72
|
+
export declare class PtcsFramer {
|
|
73
|
+
private onWire?;
|
|
74
|
+
private onFrame?;
|
|
75
|
+
private reassembler?;
|
|
76
|
+
private sweep?;
|
|
77
|
+
private ready;
|
|
78
|
+
private readonly nextFrameId;
|
|
79
|
+
private sequence;
|
|
80
|
+
/** Arm the two callbacks; `sendFrame` may be called from here on. */
|
|
81
|
+
init(onWirePacket: (packet: Buffer) => void, onFrame: (frame: Buffer, linkType: number) => void): void;
|
|
82
|
+
isReady(): boolean;
|
|
83
|
+
sendFrame(portalPacket: Buffer): void;
|
|
84
|
+
recvPacket(wirePacket: Buffer): void;
|
|
85
|
+
destroy(): void;
|
|
86
|
+
}
|
|
@@ -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,95 @@
|
|
|
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.
|
|
19
|
+
*
|
|
20
|
+
* A session whose peer is a {@link RtcPeerOptions.mediaTrack} peer has no command channel: `close` fires
|
|
21
|
+
* when the peer fails or closes, and the video track's packets are emitted as `rtp`.
|
|
22
|
+
*
|
|
23
|
+
* Both the signalling client and the peer are injectable.
|
|
24
|
+
*/
|
|
25
|
+
import { EventEmitter } from "node:events";
|
|
26
|
+
import { RtcPeer, type RtcPeerOptions } from "./peer.js";
|
|
27
|
+
import { RtcSignalingClient, type RtcSignalingOptions } from "./signaling.js";
|
|
28
|
+
export interface RtcSessionOptions extends RtcSignalingOptions {
|
|
29
|
+
peer?: RtcPeerOptions;
|
|
30
|
+
createSignaling?: (opts: RtcSignalingOptions) => RtcSignalingClient;
|
|
31
|
+
createPeer?: (opts: RtcPeerOptions) => RtcPeer;
|
|
32
|
+
}
|
|
33
|
+
export interface RtcSessionEvents {
|
|
34
|
+
connected: [];
|
|
35
|
+
close: [];
|
|
36
|
+
error: [err: Error];
|
|
37
|
+
/** A reassembled command or notify frame: portal packet bytes + the link type it arrived on. */
|
|
38
|
+
commandData: [frame: Buffer, linkType: number];
|
|
39
|
+
/** A reassembled live-video frame. */
|
|
40
|
+
mediaData: [frame: Buffer];
|
|
41
|
+
/** The hub echoed the portal keepalive on the command channel. */
|
|
42
|
+
keepaliveEcho: [];
|
|
43
|
+
/** One packet of the video track, in a {@link RtcPeerOptions.mediaTrack} session. */
|
|
44
|
+
rtp: [packet: Buffer];
|
|
45
|
+
}
|
|
46
|
+
export declare class RtcSession extends EventEmitter<RtcSessionEvents> {
|
|
47
|
+
private readonly opts;
|
|
48
|
+
private readonly signaling;
|
|
49
|
+
private readonly peer;
|
|
50
|
+
private readonly logger;
|
|
51
|
+
private turn?;
|
|
52
|
+
private authOk;
|
|
53
|
+
private authWaiter?;
|
|
54
|
+
private connected;
|
|
55
|
+
private closed;
|
|
56
|
+
private closeAnnounced;
|
|
57
|
+
private sdpHandled;
|
|
58
|
+
private chain;
|
|
59
|
+
constructor(opts: RtcSessionOptions);
|
|
60
|
+
get isConnected(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Start the sequence; resolves once the call is sent. `connected` fires when the channel opens. A session
|
|
63
|
+
* closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
|
|
64
|
+
* placing the call.
|
|
65
|
+
*/
|
|
66
|
+
connect(): Promise<void>;
|
|
67
|
+
/** Send one portal packet; false when the command channel isn't open. */
|
|
68
|
+
sendCommand(portalPacket: Buffer): boolean;
|
|
69
|
+
/** Send unframed bytes on the command channel; false when it isn't open. */
|
|
70
|
+
sendRaw(bytes: Buffer): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
|
|
73
|
+
* `connect()` still waiting for the signalling auth is rejected and its timer stopped.
|
|
74
|
+
*/
|
|
75
|
+
close(): void;
|
|
76
|
+
/**
|
|
77
|
+
* Trickle one local candidate, or the empty end-of-candidates. The native peer can report one after the
|
|
78
|
+
* signalling socket has closed, and sending then throws inside a native callback, where the throw can't
|
|
79
|
+
* be caught and aborts the process. A candidate with no open socket is dropped.
|
|
80
|
+
*/
|
|
81
|
+
private trickle;
|
|
82
|
+
private announceClose;
|
|
83
|
+
private waitForAuth;
|
|
84
|
+
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
85
|
+
private settleAuth;
|
|
86
|
+
private onSignaling;
|
|
87
|
+
/** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
|
|
88
|
+
private onCall;
|
|
89
|
+
/**
|
|
90
|
+
* An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
|
|
91
|
+
* is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
|
|
92
|
+
* is reported and left unanswered.
|
|
93
|
+
*/
|
|
94
|
+
private onInfo;
|
|
95
|
+
}
|
|
@@ -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
|
-
/**
|
|
2
|
-
|
|
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 (`"
|
|
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("
|
|
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
|
-
/** `"
|
|
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
|
-
/**
|
|
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
|
|
72
|
-
* Builds the request without needing a working signer; the signer is
|
|
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
|
|
88
|
+
getDeviceDps<T = unknown>(devId: string): Promise<TuyaEnvelope<T>>;
|
|
75
89
|
/**
|
|
76
|
-
* CONTROL: publish data-points to a device (`
|
|
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
|