@mega-yfue/eufy-sdk 0.5.0-beta.0 → 0.5.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/eufy-mega.d.ts +18 -6
- package/dist/core/store.d.ts +2 -0
- package/dist/index.js +1379 -39
- package/dist/index.js.map +4 -4
- package/dist/model/device-family.d.ts +2 -0
- package/dist/model/registry.d.ts +3 -2
- package/dist/model/types.d.ts +6 -0
- package/dist/transport/http/mega-client.d.ts +17 -0
- package/dist/transport/p2p/command-router.d.ts +8 -5
- package/dist/transport/p2p/live-stream.d.ts +3 -0
- package/dist/transport/p2p/p2p-session.d.ts +7 -2
- package/dist/transport/p2p/video.d.ts +3 -2
- package/dist/transport/rtc/command-router.d.ts +72 -0
- package/dist/transport/rtc/peer.d.ts +93 -0
- package/dist/transport/rtc/portal-packet.d.ts +66 -0
- package/dist/transport/rtc/ptcs-framer.d.ts +85 -0
- package/dist/transport/rtc/scall-sdp.d.ts +42 -0
- package/dist/transport/rtc/session.d.ts +78 -0
- package/dist/transport/rtc/signaling.d.ts +137 -0
- package/package.json +9 -1
|
@@ -43,6 +43,8 @@ export declare const isOutdoorPanTilt: (ctx: FamilyContext) => boolean;
|
|
|
43
43
|
export declare const isFloodLight: (ctx: FamilyContext) => boolean;
|
|
44
44
|
/** HomeBase-family hub (has a speaker/alarm) — a station EXCLUDING the NVRs, per `HOMEBASE_TYPES`. */
|
|
45
45
|
export declare const isHomeBase: (ctx: FamilyContext) => boolean;
|
|
46
|
+
/** HomeBase S1 Pro (`STATION_9000`), the station the app drives over its WebRTC control channel. */
|
|
47
|
+
export declare const isStation9000: (ctx: FamilyContext) => boolean;
|
|
46
48
|
/** Wired doorbell (DeviceType.DOORBELL). */
|
|
47
49
|
export declare const isWiredDoorbell: (ctx: FamilyContext) => boolean;
|
|
48
50
|
/**
|
package/dist/model/registry.d.ts
CHANGED
|
@@ -48,7 +48,8 @@ export declare function resolveDevice(rec: CloudRecord): ResolvedDevice;
|
|
|
48
48
|
* The property manifest for a device, from its capabilities and the record's facts. The single place
|
|
49
49
|
* an {@link AvailabilityContext} is built — so a family-gate (`available`) and a per-model enum
|
|
50
50
|
* (`enumValuesFor`) are decided from the same truthful, session-free view on every path (initial
|
|
51
|
-
* resolve and {@link Device.reresolve}). Populated only from what a record carries
|
|
52
|
-
*
|
|
51
|
+
* resolve and {@link Device.reresolve}). Populated only from what a record carries: its cloud params and
|
|
52
|
+
* the params its device reported over realtime, the same evidence a bound getter is gated on. Never
|
|
53
|
+
* transport fields a live session hasn't produced.
|
|
53
54
|
*/
|
|
54
55
|
export declare function resolveProperties(rec: CloudRecord, codec: Codec, capabilities: Capability[]): PropertySpec[];
|
package/dist/model/types.d.ts
CHANGED
|
@@ -286,6 +286,12 @@ export interface CloudRecord {
|
|
|
286
286
|
parentSn?: string;
|
|
287
287
|
/** Reported param_type → raw value. Presence of a param is a capability signal. */
|
|
288
288
|
params?: Record<number, string>;
|
|
289
|
+
/**
|
|
290
|
+
* Params the device reported over its realtime channel, keyed in the device's own namespace. A line
|
|
291
|
+
* whose state exists only on that channel carries none of it in {@link params}, so this is the
|
|
292
|
+
* evidence an availability gate on such a param is decided from.
|
|
293
|
+
*/
|
|
294
|
+
dpParams?: Record<number, string>;
|
|
289
295
|
}
|
|
290
296
|
/** The fully-resolved device shape produced by the 3-tier resolver. */
|
|
291
297
|
export interface ResolvedDevice {
|
|
@@ -209,6 +209,8 @@ export interface SignedRetry {
|
|
|
209
209
|
export declare class MegaHttpClient {
|
|
210
210
|
private readonly cfg;
|
|
211
211
|
private region;
|
|
212
|
+
/** The host `estimate_domain` answered, persisted with the session. */
|
|
213
|
+
private estimatedDomain;
|
|
212
214
|
private bootstrapDomain?;
|
|
213
215
|
private sessionKey?;
|
|
214
216
|
/** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
|
|
@@ -275,6 +277,21 @@ export declare class MegaHttpClient {
|
|
|
275
277
|
} | undefined;
|
|
276
278
|
/** The active region shard (e.g. `"eu-pr"`, `"us-pr"`), set after {@link login} or a region override. */
|
|
277
279
|
get regionShard(): RegionShard;
|
|
280
|
+
/**
|
|
281
|
+
* The shard the RTC signalling signs on: the regional prefix of the estimated domain
|
|
282
|
+
* (`…-ie-…`/`…-ie.` → `ie-pr`), else {@link regionShard}. The eu/us classification cannot name shards
|
|
283
|
+
* like `ie-pr`, whose accounts need the IE sign host and cluster.
|
|
284
|
+
*/
|
|
285
|
+
get rtcShard(): string;
|
|
286
|
+
/**
|
|
287
|
+
* The credentials the RTC signalling needs; `undefined` while logged out. The sign is refused
|
|
288
|
+
* ("gtoken not equal userid") unless its gtoken is the one every authed HTTP call carries.
|
|
289
|
+
*/
|
|
290
|
+
rtcIdentity(): {
|
|
291
|
+
authToken: string;
|
|
292
|
+
userId: string;
|
|
293
|
+
gtoken: string;
|
|
294
|
+
} | undefined;
|
|
278
295
|
/**
|
|
279
296
|
* The name commands attribute themselves to — {@link MegaClientConfig.accountName} when the config
|
|
280
297
|
* pins one (trimmed; blank counts as unset), otherwise the logged-in account's display name, which
|
|
@@ -258,11 +258,14 @@ export declare class P2PCommandRouter {
|
|
|
258
258
|
*/
|
|
259
259
|
dispatchCommand(sn: string, cmd: Command): Promise<void>;
|
|
260
260
|
/**
|
|
261
|
-
* Restart
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
261
|
+
* Restart the station that owns `sn`'s P2P session. `RESTART_HUB` (1034) is a station-scalar on the
|
|
262
|
+
* broadcast channel 255 whose body is `[u32 value][account_id padded]`, value `0`. ✅ Wire-confirmed
|
|
263
|
+
* byte-exact as a level-2 frame from a capture of the app's own HomeBase Restart (2026-08-03).
|
|
264
|
+
*
|
|
265
|
+
* The seal follows the session, as {@link sendBySessionLevel} defines it: a keyed session takes the
|
|
266
|
+
* level-2 frame, a keyless one — a standalone camera, which never negotiates a key — the same body
|
|
267
|
+
* sealed level-1. Both are replayed {@link DIRECT_CMD_SENDS}× at 200ms, as every other unacknowledged
|
|
268
|
+
* control here is.
|
|
266
269
|
*/
|
|
267
270
|
rebootStation(sn: string): Promise<void>;
|
|
268
271
|
/**
|
|
@@ -103,6 +103,9 @@ export declare class LiveStream extends EventEmitter {
|
|
|
103
103
|
private lastCodec;
|
|
104
104
|
/** Rebuilds an access unit the station split across several frames — see {@link AccessUnitAssembler}. */
|
|
105
105
|
private readonly units;
|
|
106
|
+
/** Whether a missing video datagram or incomplete unit invalidated the current reference chain. */
|
|
107
|
+
private awaitingKeyframeAfterGap;
|
|
108
|
+
private readonly videoGapHandler;
|
|
106
109
|
/** The channel inbound media must be tagged with, once {@link acceptsMedia} trusts the station's tag. */
|
|
107
110
|
private mediaChannel?;
|
|
108
111
|
private tracedFirstVideoCommand;
|
|
@@ -277,8 +277,8 @@ export declare class P2PSession extends EventEmitter {
|
|
|
277
277
|
private decryptLevel2;
|
|
278
278
|
get isConnected(): boolean;
|
|
279
279
|
/**
|
|
280
|
-
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and
|
|
281
|
-
* less or refuses.
|
|
280
|
+
* Ask the OS for {@link RECEIVE_BUFFER_BYTES} on a bound socket, and log once per session when it grants
|
|
281
|
+
* less (debug) or refuses (warn).
|
|
282
282
|
*
|
|
283
283
|
* The request is made here rather than through `createSocket`'s `recvBufferSize`: Node applies that option
|
|
284
284
|
* inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
|
|
@@ -435,6 +435,11 @@ export declare class P2PSession extends EventEmitter {
|
|
|
435
435
|
* "direct" commands (e.g. CAMERA_SWITCH 1035) carry a binary struct, not JSON.
|
|
436
436
|
*/
|
|
437
437
|
sendRawLevel2Bytes(payload: Buffer, channel: number, outerCmd?: number, signCode?: number): boolean;
|
|
438
|
+
/**
|
|
439
|
+
* The level-1 twin of {@link sendRawLevel2Bytes}: the plaintext `payload` sealed AES-128-ECB under the
|
|
440
|
+
* level-1 key, signCode 1 — the seal of the level-1 media start. Returns `false` when not connected.
|
|
441
|
+
*/
|
|
442
|
+
sendRawLevel1Bytes(payload: Buffer, channel: number, outerCmd: number): boolean;
|
|
438
443
|
/**
|
|
439
444
|
* Stop the realtime media stream (`CMD_STOP_REALTIME_MEDIA`, 1004) on a camera `channel`, in the shape
|
|
440
445
|
* its runtime topology takes, as {@link startLiveMedia} selects its start:
|
|
@@ -144,9 +144,10 @@ export declare class AccessUnitAssembler {
|
|
|
144
144
|
* `decode` extracts the payload of a frame, whatever the stream's encryption: it is called for EVERY
|
|
145
145
|
* frame, including a continuation, because a continuation carries its own wrapped key ahead of its share
|
|
146
146
|
* of the payload (measured: 129 bytes beyond what its header declares, exactly as a unit's first frame
|
|
147
|
-
* carries).
|
|
147
|
+
* carries). A continuation without its matching open unit has no Annex-B start code and cannot be emitted
|
|
148
|
+
* as a complete unit, even if its repeated header says keyframe.
|
|
148
149
|
*/
|
|
149
150
|
push(payload: Buffer, decode: (payload: Buffer) => Buffer | undefined): AssembledAccessUnit[];
|
|
150
151
|
/** Forget an incomplete unit, counting and reporting it. */
|
|
151
|
-
|
|
152
|
+
discard(): void;
|
|
152
153
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RTC command router: drives a **HomeBase S1 Pro (T9000)** over the portal's WebRTC data channel instead
|
|
3
|
+
* of P2P.
|
|
4
|
+
*
|
|
5
|
+
* The T9000 answers no P2P lookup, but it accepts the portal's control channel: sign → WS join → scall →
|
|
6
|
+
* SDP answer → ICE over the relay the hub grants → DTLS → SCTP data channels, with commands riding
|
|
7
|
+
* `WebrtcDataChannel` as portal packets (see {@link buildPortalPacket}).
|
|
8
|
+
*
|
|
9
|
+
* `set-payload` rides the `1350` SET_PAYLOAD envelope, inner `{account_id, cmd, mValue3, payload}`;
|
|
10
|
+
* `set-json` rides the `1700` CONTROL_PAYLOAD envelope, inner `{account_id, cmd, commandType, data}`.
|
|
11
|
+
* A command to the station goes on the station channel `255`; an attached device's command shares the
|
|
12
|
+
* station's session and keeps the device's channel. Other command kinds are refused.
|
|
13
|
+
*
|
|
14
|
+
* One session per station, reused across commands and closed after {@link IDLE_CLOSE_MS} without one.
|
|
15
|
+
* Sends on a session are serialised; an ACK is correlated by its outer command and segment, since it
|
|
16
|
+
* carries no inner command. An ACK is not an observation of the resulting state.
|
|
17
|
+
*/
|
|
18
|
+
import type { Command } from "../../core/contracts.js";
|
|
19
|
+
import type { Logger } from "../../core/logger.js";
|
|
20
|
+
import { RtcSession, type RtcSessionOptions } from "./session.js";
|
|
21
|
+
export interface RtcIdentity {
|
|
22
|
+
authToken: string;
|
|
23
|
+
userId: string;
|
|
24
|
+
/** The `gtoken` header value the mega session's authed HTTP calls carry. */
|
|
25
|
+
gtoken: string;
|
|
26
|
+
}
|
|
27
|
+
/** Where a command goes: the station's session, and whether it addresses the station or a device on it. */
|
|
28
|
+
export interface RtcRoute {
|
|
29
|
+
stationSn: string;
|
|
30
|
+
/**
|
|
31
|
+
* The station's `member.admin_user_id`, the account the session and the payload name; the logged-in
|
|
32
|
+
* user when the station's record carries none.
|
|
33
|
+
*/
|
|
34
|
+
adminUserId?: string;
|
|
35
|
+
/** True for a device attached to the station, which keeps the command's own channel. */
|
|
36
|
+
attached: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface RtcCommandRouterDeps {
|
|
39
|
+
/** The logged-in session's credentials; `undefined` while logged out. */
|
|
40
|
+
identity: () => RtcIdentity | undefined;
|
|
41
|
+
/** The mega shard the account signs on (`"ie-pr"`, `"eu-pr"`, `"us-pr"`); picks the smart host. */
|
|
42
|
+
shard: () => string;
|
|
43
|
+
/** ISO country sent on the sign request (default `US`). */
|
|
44
|
+
country?: string;
|
|
45
|
+
logger?: Logger;
|
|
46
|
+
onError?: (e: Error) => void;
|
|
47
|
+
createSession?: (opts: RtcSessionOptions) => RtcSession;
|
|
48
|
+
}
|
|
49
|
+
export declare class RtcCommandRouter {
|
|
50
|
+
private readonly deps;
|
|
51
|
+
private readonly sessions;
|
|
52
|
+
constructor(deps: RtcCommandRouterDeps);
|
|
53
|
+
dispatchCommand(route: RtcRoute, cmd: Command): Promise<void>;
|
|
54
|
+
/** Tear down every station session (logout / shutdown). */
|
|
55
|
+
close(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Completes on the command ACK or a correlated control-result notification. The notification must
|
|
58
|
+
* match the request channel, parameter and segment and contain a structured payload. Receipt does
|
|
59
|
+
* not establish physical actuation. The command is sent once, bounded by {@link ACK_TIMEOUT_MS}.
|
|
60
|
+
*/
|
|
61
|
+
private sendAwaitAck;
|
|
62
|
+
private stationSession;
|
|
63
|
+
/**
|
|
64
|
+
* One bounded bring-up: the session is up when its command channel opens, and it fails, with the
|
|
65
|
+
* session closed and every listener gone, when `connect()` throws, the session closes first, or
|
|
66
|
+
* {@link CONNECT_TIMEOUT_MS} passes. A single promise, so an early failure cannot leave a second one
|
|
67
|
+
* rejecting unheard.
|
|
68
|
+
*/
|
|
69
|
+
private bringUp;
|
|
70
|
+
private touch;
|
|
71
|
+
private drop;
|
|
72
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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 is relay-only. The hub 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
|
+
* The native peer is injectable.
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from "node:events";
|
|
18
|
+
import type { PeerConnection, RtcConfig } from "node-datachannel";
|
|
19
|
+
import { type Logger } from "../../core/logger.js";
|
|
20
|
+
export interface TurnConfig {
|
|
21
|
+
turn_addr: string;
|
|
22
|
+
turn_port: number;
|
|
23
|
+
turn_user: string;
|
|
24
|
+
turn_password: string;
|
|
25
|
+
alt_turn_addr?: string;
|
|
26
|
+
alt_turn_port?: number;
|
|
27
|
+
}
|
|
28
|
+
export type NativePeerFactory = (name: string, config: RtcConfig) => PeerConnection;
|
|
29
|
+
export interface RtcPeerOptions {
|
|
30
|
+
createPeer?: NativePeerFactory;
|
|
31
|
+
logger?: Logger;
|
|
32
|
+
}
|
|
33
|
+
export interface RtcPeerEvents {
|
|
34
|
+
commandChannelOpen: [];
|
|
35
|
+
/** The command channel went away while the peer itself may still be up. */
|
|
36
|
+
commandChannelClosed: [];
|
|
37
|
+
data: [frame: Buffer, linkType: number];
|
|
38
|
+
iceCandidate: [candidate: string];
|
|
39
|
+
iceGatheringComplete: [];
|
|
40
|
+
connectionState: [state: string];
|
|
41
|
+
error: [err: Error];
|
|
42
|
+
}
|
|
43
|
+
/** The portal's channel list; index 0 is the command channel. */
|
|
44
|
+
export declare const DATA_CHANNEL_LABELS: readonly ["WebrtcDataChannel", "audio", "idr", "video", "notify", "download"];
|
|
45
|
+
export declare const COMMAND_CHANNEL: "WebrtcDataChannel";
|
|
46
|
+
export declare class RtcPeer extends EventEmitter<RtcPeerEvents> {
|
|
47
|
+
private readonly opts;
|
|
48
|
+
private pc?;
|
|
49
|
+
private readonly channels;
|
|
50
|
+
private framer?;
|
|
51
|
+
private readonly wireTally;
|
|
52
|
+
private commandOpen;
|
|
53
|
+
private remoteSet;
|
|
54
|
+
private handlingOffer;
|
|
55
|
+
private channelsCreated;
|
|
56
|
+
private gatheringDone;
|
|
57
|
+
/** Set by the framer's wire callback when the native channel refused a packet mid-send. */
|
|
58
|
+
private wireSendFailed;
|
|
59
|
+
private readonly pending;
|
|
60
|
+
private localAnswer?;
|
|
61
|
+
private readonly logger;
|
|
62
|
+
constructor(opts?: RtcPeerOptions);
|
|
63
|
+
/** Create the native peer on the relay the hub granted in `scall 100`. */
|
|
64
|
+
init(turn: TurnConfig): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* The hub offered: apply the offer, declare the channels, return the answer SDP to signal back.
|
|
67
|
+
*
|
|
68
|
+
* The offer is pinned to `passive` before it is applied, so the native peer has one legal answer,
|
|
69
|
+
* `active`: the role is fixed where it is decided, since rewriting the answer afterwards would change
|
|
70
|
+
* only what is announced. The offer goes in before the channels are declared: `createDataChannel` on
|
|
71
|
+
* a peer without a remote description starts its own negotiation and leaves it in `have-local-offer`.
|
|
72
|
+
* The data channels need no m-line of their own; the offer's SCTP m-line carries them. Only an ANSWER
|
|
73
|
+
* is taken as the local description: one read in `have-local-offer` is an offer.
|
|
74
|
+
*/
|
|
75
|
+
handleRemoteOffer(offerSdp: string): Promise<string>;
|
|
76
|
+
addRemoteCandidate(candidate: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Send one portal packet on the command channel, through the framer. False when the channel is not
|
|
79
|
+
* usable or the native send refused a wire packet, so a dropped frame does not read as success.
|
|
80
|
+
*/
|
|
81
|
+
sendCommand(portalPacket: Buffer): boolean;
|
|
82
|
+
/** Tear the peer down. A pending answer is rejected, so a `handleRemoteOffer` in flight settles. */
|
|
83
|
+
close(): void;
|
|
84
|
+
private acceptsCandidate;
|
|
85
|
+
private createChannels;
|
|
86
|
+
/**
|
|
87
|
+
* Wire one data channel. Every channel feeds the framer once the command channel has opened it; a
|
|
88
|
+
* message before that has nothing to reassemble into and is dropped.
|
|
89
|
+
*/
|
|
90
|
+
private wireChannel;
|
|
91
|
+
private addNow;
|
|
92
|
+
private flushPending;
|
|
93
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
/** Portal link types — which logical channel a frame belongs to (`worker_sctp_send`). */
|
|
14
|
+
export declare const PortalLinkType: {
|
|
15
|
+
/** Command channel: requests out, acknowledgements back. */
|
|
16
|
+
readonly COMMAND: 1;
|
|
17
|
+
/** Station-originated frames: pushes, notify payloads, control results. */
|
|
18
|
+
readonly NOTIFY: 3;
|
|
19
|
+
};
|
|
20
|
+
export interface PortalHeader {
|
|
21
|
+
commandId: number;
|
|
22
|
+
paramLength: number;
|
|
23
|
+
segment: number;
|
|
24
|
+
channel: number;
|
|
25
|
+
isResponse: number;
|
|
26
|
+
devType: number;
|
|
27
|
+
}
|
|
28
|
+
export interface PortalRequest {
|
|
29
|
+
commandId: number;
|
|
30
|
+
channel: number;
|
|
31
|
+
/** 1..255, wraps; echoed by the reply so it can be matched. */
|
|
32
|
+
segment: number;
|
|
33
|
+
payload: Record<string, unknown>;
|
|
34
|
+
isResponse?: number;
|
|
35
|
+
devType?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface PortalResponse {
|
|
38
|
+
commandId: number;
|
|
39
|
+
channel: number;
|
|
40
|
+
segment: number;
|
|
41
|
+
isResponse: number;
|
|
42
|
+
linkType: number;
|
|
43
|
+
/** Command-channel replies only: the int32 result code (0 = ok). */
|
|
44
|
+
errCode?: number;
|
|
45
|
+
/** Notify frames: the nested `cmd` the JSON body carries. */
|
|
46
|
+
cmd?: number;
|
|
47
|
+
data?: unknown;
|
|
48
|
+
}
|
|
49
|
+
/** The 16-byte header (portal `Gr()`). */
|
|
50
|
+
export declare function buildPortalHeader(commandId: number, paramLength: number, channel: number, segment: number, isResponse?: number, devType?: number): Buffer;
|
|
51
|
+
/** Parse the header (portal `Qr()`); `undefined` when the magic is missing or the buffer is short. */
|
|
52
|
+
export declare function parsePortalHeader(buf: Buffer): PortalHeader | undefined;
|
|
53
|
+
/** Whether a buffer starts a portal packet. */
|
|
54
|
+
export declare function isPortalPacket(buf: Buffer): boolean;
|
|
55
|
+
/** A complete request packet: header + body. */
|
|
56
|
+
export declare function buildPortalPacket(req: PortalRequest): Buffer;
|
|
57
|
+
/**
|
|
58
|
+
* Parse an inbound packet. On the command channel the body opens with the int32 result code and may
|
|
59
|
+
* carry JSON after it; on the notify channel the whole body is JSON with a nested `cmd`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function parsePortalPacket(buf: Buffer, linkType?: number): PortalResponse | undefined;
|
|
62
|
+
/** A 1..255 segment counter that never yields 0 (the portal reserves it). */
|
|
63
|
+
export declare class SegmentCounter {
|
|
64
|
+
private current;
|
|
65
|
+
next(): number;
|
|
66
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
|
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
|
+
};
|
|
32
|
+
/** The link type a received frame belongs to: notify on the notify channel, command otherwise. */
|
|
33
|
+
export declare function linkTypeForChannel(channel: number): number;
|
|
34
|
+
/** How long a partly received frame is kept before it is dropped. */
|
|
35
|
+
export declare const PTCS_STALE_MS = 15000;
|
|
36
|
+
export interface PtcsHeader {
|
|
37
|
+
channel: number;
|
|
38
|
+
/** The sender's running packet counter — see the header map. */
|
|
39
|
+
sequence: number;
|
|
40
|
+
frameId: number;
|
|
41
|
+
frameLength: number;
|
|
42
|
+
index: number;
|
|
43
|
+
last: boolean;
|
|
44
|
+
payloadLength: number;
|
|
45
|
+
}
|
|
46
|
+
export declare function parsePtcsHeader(packet: Buffer): PtcsHeader | undefined;
|
|
47
|
+
/** Split one frame into wire packets. */
|
|
48
|
+
export declare function packetize(frame: Buffer, opts: {
|
|
49
|
+
channel?: number;
|
|
50
|
+
frameId: number;
|
|
51
|
+
payloadBytes?: number;
|
|
52
|
+
sequence?: number;
|
|
53
|
+
}): Buffer[];
|
|
54
|
+
/** Reassemble frames from wire packets, in any order, one frame at a time per (channel, id). */
|
|
55
|
+
export declare class PtcsReassembler {
|
|
56
|
+
private readonly onFrame;
|
|
57
|
+
private readonly now;
|
|
58
|
+
private readonly partials;
|
|
59
|
+
constructor(onFrame: (frame: Buffer, channel: number) => void, now?: () => number);
|
|
60
|
+
push(packet: Buffer): boolean;
|
|
61
|
+
/** Drop frames that stopped arriving; call periodically. */
|
|
62
|
+
expire(): void;
|
|
63
|
+
}
|
|
64
|
+
/** Frame ids the way the portal draws them — a millisecond clock, nudged forward on a collision. */
|
|
65
|
+
export declare function frameIdClock(now?: () => number): () => number;
|
|
66
|
+
/**
|
|
67
|
+
* The framer between portal packets and the data channel: PTCS out, PTCS in, with the portal's channel
|
|
68
|
+
* mapping. An inbound bare `XZYH` packet, which the hub sometimes answers with, passes through as a
|
|
69
|
+
* command frame. Frame ids are a millisecond clock and the frame counter starts at 0.
|
|
70
|
+
*/
|
|
71
|
+
export declare class PtcsFramer {
|
|
72
|
+
private onWire?;
|
|
73
|
+
private onFrame?;
|
|
74
|
+
private reassembler?;
|
|
75
|
+
private sweep?;
|
|
76
|
+
private ready;
|
|
77
|
+
private readonly nextFrameId;
|
|
78
|
+
private sequence;
|
|
79
|
+
/** Arm the two callbacks; `sendFrame` may be called from here on. */
|
|
80
|
+
init(onWirePacket: (packet: Buffer) => void, onFrame: (frame: Buffer, linkType: number) => void): void;
|
|
81
|
+
isReady(): boolean;
|
|
82
|
+
sendFrame(portalPacket: Buffer): void;
|
|
83
|
+
recvPacket(wirePacket: Buffer): void;
|
|
84
|
+
destroy(): void;
|
|
85
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
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;
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
* Both the signalling client and the peer are injectable.
|
|
21
|
+
*/
|
|
22
|
+
import { EventEmitter } from "node:events";
|
|
23
|
+
import { RtcPeer, type RtcPeerOptions } from "./peer.js";
|
|
24
|
+
import { RtcSignalingClient, type RtcSignalingOptions } from "./signaling.js";
|
|
25
|
+
export interface RtcSessionOptions extends RtcSignalingOptions {
|
|
26
|
+
peer?: RtcPeerOptions;
|
|
27
|
+
createSignaling?: (opts: RtcSignalingOptions) => RtcSignalingClient;
|
|
28
|
+
createPeer?: (opts: RtcPeerOptions) => RtcPeer;
|
|
29
|
+
}
|
|
30
|
+
export interface RtcSessionEvents {
|
|
31
|
+
connected: [];
|
|
32
|
+
close: [];
|
|
33
|
+
error: [err: Error];
|
|
34
|
+
/** A reassembled frame: portal packet bytes + the link type it arrived on. */
|
|
35
|
+
commandData: [frame: Buffer, linkType: number];
|
|
36
|
+
}
|
|
37
|
+
export declare class RtcSession extends EventEmitter<RtcSessionEvents> {
|
|
38
|
+
private readonly opts;
|
|
39
|
+
private readonly signaling;
|
|
40
|
+
private readonly peer;
|
|
41
|
+
private readonly logger;
|
|
42
|
+
private turn?;
|
|
43
|
+
private authOk;
|
|
44
|
+
private authWaiter?;
|
|
45
|
+
private connected;
|
|
46
|
+
private closed;
|
|
47
|
+
private closeAnnounced;
|
|
48
|
+
private sdpHandled;
|
|
49
|
+
private chain;
|
|
50
|
+
constructor(opts: RtcSessionOptions);
|
|
51
|
+
get isConnected(): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Start the sequence; resolves once `scall` is sent. `connected` fires when the channel opens. A session
|
|
54
|
+
* closed by the time the sign fetch or the auth completes rejects instead of opening the socket or
|
|
55
|
+
* placing the call.
|
|
56
|
+
*/
|
|
57
|
+
connect(): Promise<void>;
|
|
58
|
+
/** Send one portal packet; false when the command channel isn't open. */
|
|
59
|
+
sendCommand(portalPacket: Buffer): boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Hang up and tear both sides down; `close` fires once, here if nothing announced it before. A
|
|
62
|
+
* `connect()` still waiting for the signalling auth is rejected and its timer stopped.
|
|
63
|
+
*/
|
|
64
|
+
close(): void;
|
|
65
|
+
private announceClose;
|
|
66
|
+
private waitForAuth;
|
|
67
|
+
/** Settle a pending auth wait: resolve it, or reject it with `err`; either way its timer stops. */
|
|
68
|
+
private settleAuth;
|
|
69
|
+
private onSignaling;
|
|
70
|
+
/** A `scall` status: `100` grants the relay, `200` is acknowledged, `486`/`408` fail the session. */
|
|
71
|
+
private onCall;
|
|
72
|
+
/**
|
|
73
|
+
* An `info`: trickle ICE, in either of the two shapes the hub sends, or the hub's SDP offer. The offer
|
|
74
|
+
* is answered only once a TURN grant is in: the peer is relay-only, so an offer ahead of `scall 100`
|
|
75
|
+
* is reported and left unanswered.
|
|
76
|
+
*/
|
|
77
|
+
private onInfo;
|
|
78
|
+
}
|