@mega-yfue/eufy-sdk 0.6.0-beta.14 → 0.6.0-beta.16
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 +60 -8
- package/dist/client/types.d.ts +3 -3
- package/dist/core/contracts.d.ts +3 -1
- package/dist/core/store.d.ts +12 -1
- package/dist/index.js +704 -259
- package/dist/index.js.map +4 -4
- package/dist/transport/http/mega-client.d.ts +3 -2
- package/dist/transport/p2p/audio-frame.d.ts +13 -0
- package/dist/transport/p2p/codec.d.ts +2 -2
- package/dist/transport/p2p/command-router.d.ts +17 -0
- package/dist/transport/p2p/database-wire.d.ts +30 -0
- package/dist/transport/p2p/live-stream.d.ts +33 -7
- package/dist/transport/p2p/live-trace.d.ts +6 -4
- package/dist/transport/p2p/p2p-session.d.ts +80 -16
- package/dist/transport/p2p/readable-egress.d.ts +3 -1
- package/dist/transport/p2p/recording-download.d.ts +22 -0
- package/dist/transport/p2p/shared-live-source.d.ts +2 -1
- package/dist/transport/p2p/video.d.ts +2 -0
- package/dist/transport/push/store.d.ts +7 -6
- package/dist/transport/rtc/command-router.d.ts +63 -3
- package/dist/transport/rtc/portal-packet.d.ts +3 -13
- package/dist/transport/rtc/session.d.ts +4 -2
- package/dist/transport/stored-image-cache.d.ts +14 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SessionEntry } from "../../core/crypto.js";
|
|
2
2
|
import { type SessionStore } from "../../core/store.js";
|
|
3
3
|
import { type Logger } from "../../core/logger.js";
|
|
4
|
+
import type { EufyDevice } from "../../core/types.js";
|
|
4
5
|
import type { SecureMqttCredentials } from "../mqtt/secure-mqtt.js";
|
|
5
6
|
export type RegionShard = "eu-pr" | "us-pr";
|
|
6
7
|
/**
|
|
@@ -354,11 +355,11 @@ export declare class MegaHttpClient {
|
|
|
354
355
|
/** Authed call to a mega service host: app-{service}-{region}.eufy.com. */
|
|
355
356
|
post<T = unknown>(service: string, path: string, body?: unknown, headerOverrides?: Record<string, string>): Promise<T>;
|
|
356
357
|
/**
|
|
357
|
-
* Fetch the per-station DSK key
|
|
358
|
+
* Fetch the per-station DSK key using each station's reported cloud category. Mirrors
|
|
358
359
|
* the legacy `get_dsk_keys` body shape on the mega devicerelation service.
|
|
359
360
|
* The P2P *local* lookup path does not need this; only the cloud path does.
|
|
360
361
|
*/
|
|
361
|
-
getDskKeys(
|
|
362
|
+
getDskKeys(stations: ReadonlyArray<Pick<EufyDevice, "sn" | "category">>, priorDsks?: Record<string, string>): Promise<Record<string, {
|
|
362
363
|
dskKey: string;
|
|
363
364
|
expiration: number;
|
|
364
365
|
}>>;
|
|
@@ -12,3 +12,16 @@ export declare const AUDIO_TYPE_OFFSET = 5;
|
|
|
12
12
|
* itself when a stream produces no audio at all, to feed its decoder silence.
|
|
13
13
|
*/
|
|
14
14
|
export declare const AUDIO_CODECS: Readonly<Record<number, AudioCodec>>;
|
|
15
|
+
/**
|
|
16
|
+
* Whether an audio frame is sealed: after its header, a 16-byte GCM tag and a 12-byte nonce precede the
|
|
17
|
+
* `u32@0` bytes its header declares, where a frame in the clear carries those bytes straight after the header.
|
|
18
|
+
* A stream whose keyframes wrap their media key in an ECIES envelope seals its audio this way, measured on
|
|
19
|
+
* HomeBase recordings and on HomeBase 3 live streams answering a P-256 start.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isSealedAudio(frame: Buffer): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The payload of a sealed audio frame ({@link isSealedAudio}), opened with AES-256-GCM under `mediaKey`, the
|
|
24
|
+
* media key of the keyframe before it, with the video frames' AAD. Answers `undefined` for a frame that is not
|
|
25
|
+
* sealed or does not authenticate under that key.
|
|
26
|
+
*/
|
|
27
|
+
export declare function openSealedAudio(frame: Buffer, mediaKey: Buffer): Buffer | undefined;
|
|
@@ -224,8 +224,8 @@ export declare function buildRawCommandPayload(data: Buffer, channel?: number, s
|
|
|
224
224
|
export declare function buildAckPayload(dataTypeHeader: Buffer, seqNo: number): Buffer;
|
|
225
225
|
/** Parse the 16-byte data-frame header that follows the "XZYH" magic. */
|
|
226
226
|
export declare function parseDataFrameHeader(frame: Buffer): P2PDataFrameHeader;
|
|
227
|
-
/**
|
|
228
|
-
export declare function parseLookupAddr(msg: Buffer): Address;
|
|
227
|
+
/** A LOOKUP_ADDR message's leading address record, or `undefined` for a message too short to carry it. */
|
|
228
|
+
export declare function parseLookupAddr(msg: Buffer): Address | undefined;
|
|
229
229
|
/** The relay servers a LIST_REQ_ACK names, in the order listed, without the unspecified address; records past the message's end are dropped. */
|
|
230
230
|
export declare function parseRelayList(msg: Buffer): Address[];
|
|
231
231
|
/** A RLY_PORT_ACK's mark and relay port, or `undefined` for a message too short to carry them. */
|
|
@@ -102,6 +102,18 @@ export declare class P2PCommandRouter {
|
|
|
102
102
|
* {@link liveSources} is a claim in flight.
|
|
103
103
|
*/
|
|
104
104
|
private readonly liveSessionKeys;
|
|
105
|
+
/**
|
|
106
|
+
* The live source build in flight per `${parentSn}:${channel}`, settled once its connection is opened
|
|
107
|
+
* or refused.
|
|
108
|
+
*
|
|
109
|
+
* A build awaits its media session before it can file the source in {@link liveSources}, so a second
|
|
110
|
+
* request for the same camera in that window finds nothing cached. Building a second source there
|
|
111
|
+
* overwrites the first, and the orphan's teardown then closes the connection the cached one streams
|
|
112
|
+
* on. A request that finds a build here waits for it, until its own signal aborts, then resolves its
|
|
113
|
+
* session again and reuses what the build filed, or builds its own when that build failed. The entry
|
|
114
|
+
* is removed when the build settles and its promise never rejects, so each wait ends on a later state.
|
|
115
|
+
*/
|
|
116
|
+
private readonly liveSourceBuilds;
|
|
105
117
|
/**
|
|
106
118
|
* The open talkback per `${parentSn}:${channel}`, if any. The device plays one audio stream at a
|
|
107
119
|
* time and the session carries one audio sequence, so this path is exclusive where a live pull is
|
|
@@ -220,6 +232,11 @@ export declare class P2PCommandRouter {
|
|
|
220
232
|
* so a start issued before the key arrives is dropped as `media-command-unsent`, and the stream then
|
|
221
233
|
* shows nothing until a later keepalive tick happens to find the key. A connected session that cannot
|
|
222
234
|
* carry the start is not a connected session, so this refuses rather than returning one.
|
|
235
|
+
*
|
|
236
|
+
* A session refused its key within the grace, or never connected, is closed and dropped from the manager at
|
|
237
|
+
* once. Nothing else releases it: no source holds it, a wired station's session never idles out, and the
|
|
238
|
+
* manager hands the same session to every later open of the camera, so the next open dials a fresh
|
|
239
|
+
* connection instead. A session another open has since replaced is left to its own close.
|
|
223
240
|
*/
|
|
224
241
|
private openMediaSession;
|
|
225
242
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database wire — the ids and the whole-table query a station's on-station table and stored-image
|
|
3
|
+
* reads are built from.
|
|
4
|
+
*
|
|
5
|
+
* A HomeBase answers them over its P2P session and a T9000 over its portal control channel, with the
|
|
6
|
+
* same inner payloads, so both transports build their reads from this one module. The barrel does not
|
|
7
|
+
* re-export it: these are wire ids, not part of the package surface.
|
|
8
|
+
*
|
|
9
|
+
* @module p2p/database-wire
|
|
10
|
+
*/
|
|
11
|
+
/** CMD_DATABASE_IMAGE (1308) — fetch one stored image by its on-station path. */
|
|
12
|
+
export declare const CMD_DATABASE_IMAGE = 1308;
|
|
13
|
+
/** CMD_DATABASE (1306) — query one on-station table; reply carries `{cmd:10000,count,data:[…]}`. */
|
|
14
|
+
export declare const CMD_DATABASE = 1306;
|
|
15
|
+
/**
|
|
16
|
+
* Inner query cmds carried in a {@link CMD_DATABASE} payload. `FULL_TABLE` reads one table whole;
|
|
17
|
+
* `COMBINATION_WITH_AI` is the AI event-history read that bundles the face roster.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DB_QUERY: {
|
|
20
|
+
readonly FULL_TABLE: 10000;
|
|
21
|
+
readonly COMBINATION_WITH_AI: 10011;
|
|
22
|
+
readonly QUERY_LOCAL: 10017;
|
|
23
|
+
};
|
|
24
|
+
/** How long a station's database reply has to arrive whole, when the caller states no bound. */
|
|
25
|
+
export declare const DB_TABLE_TIMEOUT_MS = 15000;
|
|
26
|
+
/**
|
|
27
|
+
* The query params the vendor app sends with a whole-table `CMD_DATABASE` read (inner `cmd 10000`).
|
|
28
|
+
* `count` bounds the row count.
|
|
29
|
+
*/
|
|
30
|
+
export declare function fullTableQuery(count?: number): Record<string, unknown>;
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - **Plaintext** (`signCode 0`, e.g. HomeBase-attached cameras): a fixed 22-byte frame header
|
|
8
8
|
* (`parseVideoFrameHeader`) followed directly by Annex-B NAL units — verified live (960×540,
|
|
9
9
|
* keyframe = header flag bit0). We strip the 22-byte header.
|
|
10
|
-
* - **Encrypted
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - **Encrypted keyframes**: either the AES-256-GCM body under a media key wrapped in an ECIES envelope
|
|
11
|
+
* for an offered P-256 key ({@link VideoFrameDecoder}), or an AES-ECB head under a media key wrapped for
|
|
12
|
+
* the session's RSA key ({@link P2PSession.decodeVideoFrame}). An attached camera's start on a station
|
|
13
|
+
* with a {@link P2PSession.mediaEccPrivateKey} offers that P-256 key first, so its stream decodes with
|
|
14
|
+
* that pair unless `eccPrivateKey` names another.
|
|
13
15
|
*
|
|
14
16
|
* Emits: `video` ({@link LiveVideoFrame} — Annex-B), `audio` ({@link LiveAudioFrame}), `start`, `stop`,
|
|
15
17
|
* `error`.
|
|
@@ -54,7 +56,10 @@ export interface LiveStreamOptions {
|
|
|
54
56
|
* however long the station keeps serving that sibling instead of this one.
|
|
55
57
|
*/
|
|
56
58
|
channel?: number;
|
|
57
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* P-256 private key (32B) whose public point the camera wraps each keyframe's media key for. Defaults, on an
|
|
61
|
+
* attached camera, to the session's `P2PSession.mediaEccPrivateKey`, the pair its start offers.
|
|
62
|
+
*/
|
|
58
63
|
eccPrivateKey?: Buffer;
|
|
59
64
|
/** Admin account id — required for the level-2 (`signCode 8`) media-start payload selecting a camera. */
|
|
60
65
|
accountId?: string;
|
|
@@ -112,6 +117,8 @@ export declare class LiveStream extends EventEmitter {
|
|
|
112
117
|
private tracedFirstVideoUnit;
|
|
113
118
|
private tracedFirstKeyframe;
|
|
114
119
|
private tracedDecodeFailures;
|
|
120
|
+
/** How the frame that opened the latest access unit was read; see {@link settleStartKey}. */
|
|
121
|
+
private unitOpenedBy;
|
|
115
122
|
private tracedFirstForeignFrame;
|
|
116
123
|
private stallTimer?;
|
|
117
124
|
/**
|
|
@@ -198,11 +205,30 @@ export declare class LiveStream extends EventEmitter {
|
|
|
198
205
|
*/
|
|
199
206
|
private acceptsMedia;
|
|
200
207
|
/**
|
|
201
|
-
* The Annex-B payload of a frame that starts an access unit. The
|
|
202
|
-
*
|
|
203
|
-
*
|
|
208
|
+
* The Annex-B payload of a frame that starts an access unit. The authenticated ECIES/GCM
|
|
209
|
+
* {@link VideoFrameDecoder} goes first: its HMAC and GCM checks fail closed on a frame not sealed for its
|
|
210
|
+
* key. The session's RSA path cannot: on Node ≥ 24.5, PKCS#1 v1.5 decryption of a key not wrapped for it
|
|
211
|
+
* returns pseudo-random bytes instead of throwing (implicit rejection), which would decode an ECC
|
|
212
|
+
* keyframe into noise. The session's decode, which also passes plaintext frames, and the header strip
|
|
213
|
+
* follow. A payload that opens an access unit records how it was read: by the ECIES path, or otherwise by
|
|
214
|
+
* its sign code, sealed (`"rsa"`) or in the clear (`"plain"`).
|
|
204
215
|
*/
|
|
205
216
|
private annexbOf;
|
|
217
|
+
/**
|
|
218
|
+
* Tell the session how this keyframe was opened, which answers the attached starts on this channel
|
|
219
|
+
* ({@link P2PSession.settleAttachedKey}), and re-issue the start when the session asks for one: a plaintext
|
|
220
|
+
* keyframe answering the session's P-256 offer, from a camera that did not use the point, which the next
|
|
221
|
+
* start asks with RSA. Only a delivered keyframe answers: a frame no key opens, or a continuation without the
|
|
222
|
+
* frame that opened its unit, delivers nothing.
|
|
223
|
+
*/
|
|
224
|
+
private settleStartKey;
|
|
225
|
+
/**
|
|
226
|
+
* The payload of an audio frame. On a stream with an ECIES decoder, a sealed frame ({@link isSealedAudio}) is
|
|
227
|
+
* opened under the media key of the latest keyframe, and answers `undefined` when it does not open or arrives
|
|
228
|
+
* before any keyframe gave a media key, since its bytes are ciphertext. Every other frame is the bytes after
|
|
229
|
+
* its header.
|
|
230
|
+
*/
|
|
231
|
+
private audioPayload;
|
|
206
232
|
/**
|
|
207
233
|
* Report an access unit the transport could not complete.
|
|
208
234
|
*
|
|
@@ -23,6 +23,8 @@ export type LiveTrace =
|
|
|
23
23
|
topology: "attached" | "own";
|
|
24
24
|
action: "start" | "keepalive";
|
|
25
25
|
level2: boolean;
|
|
26
|
+
/** The media-key type an attached start offered. */
|
|
27
|
+
key?: "ecc" | "rsa";
|
|
26
28
|
}
|
|
27
29
|
/** The device acknowledged a retained start, or that start was repeated / abandoned unacknowledged. */
|
|
28
30
|
| {
|
|
@@ -137,10 +139,10 @@ export type LiveTrace =
|
|
|
137
139
|
*
|
|
138
140
|
* Every ending of a level-2 wait carries one of these reasons, so a start refused for want of a key is
|
|
139
141
|
* accounted for however it ended. `grace-elapsed` is a wait that ran out and states how long was waited;
|
|
140
|
-
* the rest are answered without waiting, because
|
|
141
|
-
*
|
|
142
|
-
* material, `not-negotiating` and `session-closed` about the station or its connection —
|
|
143
|
-
* reason reached under a negotiation has a cipher to name.
|
|
142
|
+
* the rest are answered without waiting, because a concluded negotiation stays concluded until a
|
|
143
|
+
* re-prompt for `CMD_GATEWAYINFO` re-opens it. `no-cipher-key` and `derivation-failed` are about this
|
|
144
|
+
* account's cipher material, `not-negotiating` and `session-closed` about the station or its connection —
|
|
145
|
+
* and only a reason reached under a negotiation has a cipher to name.
|
|
144
146
|
*
|
|
145
147
|
* A `grace-elapsed` start proceeds at level 1 where it has such a form, and not at all where it does not.
|
|
146
148
|
*/
|
|
@@ -180,11 +180,21 @@ export declare class P2PSession extends EventEmitter {
|
|
|
180
180
|
private level2Seq;
|
|
181
181
|
private rsaPrivateKey?;
|
|
182
182
|
private rsaModulusHex?;
|
|
183
|
-
/**
|
|
183
|
+
/** The P-256 pair an attached camera's media start offers; see {@link mediaEccPrivateKey}. */
|
|
184
|
+
private mediaEcc?;
|
|
185
|
+
/**
|
|
186
|
+
* Per attached channel, the media-key type its latest start offered on this connection, or that last answered,
|
|
187
|
+
* and whether a keyframe has answered it. See {@link attachedStartKey} and {@link settleAttachedKey}.
|
|
188
|
+
*/
|
|
189
|
+
private readonly attachedStartKeys;
|
|
190
|
+
/**
|
|
191
|
+
* Whether a level-2 key negotiation kicked off by a GATEWAYINFO reply is in flight. Cleared when it
|
|
192
|
+
* settles, so a later reply — the answer to {@link repromptLevel2Key} — negotiates again.
|
|
193
|
+
*/
|
|
184
194
|
private level2Negotiating;
|
|
185
195
|
/**
|
|
186
196
|
* Whether waiting for a level-2 key can still change the answer: `false` once one has been negotiated,
|
|
187
|
-
* once
|
|
197
|
+
* once a negotiation concluded without one, and from the start when nothing can negotiate one.
|
|
188
198
|
*/
|
|
189
199
|
private level2Pending;
|
|
190
200
|
/** Waiters parked in {@link awaitLevel2Key}, woken the moment the negotiation settles either way. */
|
|
@@ -260,7 +270,8 @@ export declare class P2PSession extends EventEmitter {
|
|
|
260
270
|
* The session grace runs from connect, when the station is prompted for `CMD_GATEWAYINFO`. A negotiation
|
|
261
271
|
* beginning later does not restart it: best-effort media can proceed without the key, and restarting
|
|
262
272
|
* would charge another grace to a source that may already be streaming. A session whose negotiation has
|
|
263
|
-
* settled answers without waiting at all
|
|
273
|
+
* settled answers without waiting at all: the answer stands until {@link repromptLevel2Key} re-opens the
|
|
274
|
+
* wait, since the station is prompted for `CMD_GATEWAYINFO` only on connect and on that re-prompt.
|
|
264
275
|
*
|
|
265
276
|
* An own-session camera whose grace expires here is NOT thereby a camera that will fail to stream. Measured
|
|
266
277
|
* on one account: own-session cameras of two device types negotiated a key, three others never did, and
|
|
@@ -276,12 +287,13 @@ export declare class P2PSession extends EventEmitter {
|
|
|
276
287
|
/**
|
|
277
288
|
* Ask the station for its gateway info a second time, re-opening a negotiation that concluded without a key.
|
|
278
289
|
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
290
|
+
* Without this the station is prompted once on connect, and a negotiation that settles without a key
|
|
291
|
+
* leaves {@link awaitLevel2Key} answering `false` at once for the rest of that connection. That is the
|
|
281
292
|
* right answer for best-effort media, which proceeds at level-1 — but an operation whose ONLY wire is
|
|
282
293
|
* level-2 is then refused for the whole life of that connection, while a fresh session over the same
|
|
283
294
|
* device negotiates a key normally. Measured: a session that had settled refused every such operation
|
|
284
|
-
* until it was rebuilt, at which point the station answered with a cipher id straight away.
|
|
295
|
+
* until it was rebuilt, at which point the station answered with a cipher id straight away. The reply to
|
|
296
|
+
* this ask runs the negotiation again and settles the re-opened wait either way.
|
|
285
297
|
*
|
|
286
298
|
* Bounded to one extra ask per connection, so a burst of such operations cannot turn a silent station into a
|
|
287
299
|
* flood, and answers whether it asked — `false` when a key is already held, when nothing can negotiate one,
|
|
@@ -296,13 +308,14 @@ export declare class P2PSession extends EventEmitter {
|
|
|
296
308
|
* `cipher_id`, resolve that cipher's private keys once (cloud `get_ciphers`, via the configured
|
|
297
309
|
* `resolveCipherKey`), run the ECIES unwrap with the ECC key, and `setLevel2Key()`. A cipher with no
|
|
298
310
|
* ECC key uses its RSA key instead and holds the RSA-decrypted 16-byte key as the AES-128-ECB level-2
|
|
299
|
-
* key.
|
|
311
|
+
* key. Emits `level2Ready` only when the AES-256-GCM key is negotiated, traces
|
|
300
312
|
* `level2-set-payload-only` instead for the AES-128-ECB key, and emits `error` when the ECC unwrap
|
|
301
313
|
* fails; an RSA unwrap failure is only traced (non-fatal either way — level-1 traffic keeps working).
|
|
302
314
|
*
|
|
303
|
-
* Every outcome settles the wait in {@link awaitLevel2Key},
|
|
304
|
-
*
|
|
305
|
-
*
|
|
315
|
+
* Every outcome settles the wait in {@link awaitLevel2Key}, so a later command is told at once rather than
|
|
316
|
+
* left to time out against a key that is not coming. Settling also re-opens the negotiation, so a later
|
|
317
|
+
* GATEWAYINFO reply in the same connection generation runs it again; a lookup that completes after its
|
|
318
|
+
* generation ended settles nothing.
|
|
306
319
|
*/
|
|
307
320
|
private negotiateLevel2Key;
|
|
308
321
|
/**
|
|
@@ -327,7 +340,15 @@ export declare class P2PSession extends EventEmitter {
|
|
|
327
340
|
* inside the bind callback, where a refusal is thrown out of reach of this session and ends the process.
|
|
328
341
|
*/
|
|
329
342
|
private requestReceiveBuffer;
|
|
330
|
-
/**
|
|
343
|
+
/**
|
|
344
|
+
* Open the socket and start the lookup → hole-punch handshake.
|
|
345
|
+
*
|
|
346
|
+
* A socket closed or failing while binding never invokes its bind callback, so the bind wait also
|
|
347
|
+
* settles on the socket's `close` and `error`. On `close`, a connect whose session was closed meanwhile
|
|
348
|
+
* returns without starting the handshake, so it never sends a lookup or arms a timer on a closed
|
|
349
|
+
* session. On `error`, connect closes the session and rejects with {@link StationUnreachableError}
|
|
350
|
+
* carrying the bind error as its `cause`.
|
|
351
|
+
*/
|
|
331
352
|
connect(): Promise<void>;
|
|
332
353
|
/** Cached across every `P2PSession` in this process — the local outbound IPv4 doesn't vary by
|
|
333
354
|
* station, so there's no reason to re-probe it per session (e.g. once per station in a fleet). Only
|
|
@@ -612,6 +633,10 @@ export declare class P2PSession extends EventEmitter {
|
|
|
612
633
|
* Send a CMD_SET_PAYLOAD(1350) wrapping `{account_id, cmd:<subCmd>, mChannel, mValue3:<subCmd>,
|
|
613
634
|
* payload}`, encrypted level-2 (AES-256-GCM, signCode 8) — the app's media-control path. The
|
|
614
635
|
* camera is selected by `mChannel` (= the device's `device_channel`) AND the frame-header channel.
|
|
636
|
+
* A start (HomeBase-controlled camera path, the V6 app's media-start envelope) also carries an
|
|
637
|
+
* `accountId` inside its payload and the media key `key` offers (see {@link attachedStartKey}). A start
|
|
638
|
+
* offering the P-256 point labels itself `ClientOS: "IOS"`, as every simple attached start in the app
|
|
639
|
+
* captures that carry that key form does; the RSA start keeps `"Android"`.
|
|
615
640
|
*/
|
|
616
641
|
private sendMediaPayloadLevel2;
|
|
617
642
|
/**
|
|
@@ -632,6 +657,41 @@ export declare class P2PSession extends EventEmitter {
|
|
|
632
657
|
* (T8171 2560×1440, T8210 640×480). Returns `undefined` on failure (frame is skipped).
|
|
633
658
|
*/
|
|
634
659
|
private rsaUnwrapKey;
|
|
660
|
+
/**
|
|
661
|
+
* The media-key type the next attached start sent on `channel` offers, recorded as offered: ECC, the current
|
|
662
|
+
* app's form, for the first start this connection sends there; RSA for every start after one that no keyframe
|
|
663
|
+
* answered before it was re-issued; and, once a keyframe has answered, the type that opened it, until
|
|
664
|
+
* {@link settleAttachedKey} records another. Only an opened keyframe answers: frames no key opens, and deltas,
|
|
665
|
+
* do not. A re-issue is a full restart on an attached camera, so a camera that cannot read the point is asked
|
|
666
|
+
* with RSA, the start every station answers, and is not asked with the point again while that goes unanswered.
|
|
667
|
+
* A stream decoding with the session's pair reads either answer. A station without {@link mediaEccPrivateKey}
|
|
668
|
+
* is offered RSA only. A start that cannot be sent offers nothing and records nothing.
|
|
669
|
+
*/
|
|
670
|
+
private attachedStartKey;
|
|
671
|
+
/**
|
|
672
|
+
* Record how a keyframe of `channel` was opened, which answers the starts there. A keyframe opened under a
|
|
673
|
+
* key type is an answer of that type, whichever type the latest start offered: a station can serve the
|
|
674
|
+
* P-256 start after the RSA re-issue went out. A plaintext keyframe
|
|
675
|
+
* answering an unanswered P-256 offer is a camera that did not use the point, and the next start there offers
|
|
676
|
+
* RSA; once a P-256 answer has been opened it changes nothing, and answering RSA, it settles RSA. Answers
|
|
677
|
+
* whether a new start is needed. A channel no attached start has offered a key on is left as it is.
|
|
678
|
+
*/
|
|
679
|
+
settleAttachedKey(channel: number, opened: "ecc" | "rsa" | "plain"): boolean;
|
|
680
|
+
/**
|
|
681
|
+
* The 32-byte P-256 private scalar paired with the point an attached camera's media start offers as `key`.
|
|
682
|
+
* A camera answering that start wraps each keyframe's AES-256-GCM media key in an ECIES envelope for this
|
|
683
|
+
* key ({@link VideoFrameDecoder}). Generated once per session, like the RSA pair.
|
|
684
|
+
*
|
|
685
|
+
* `undefined` unless the station is a HomeBase 3 (`T8030`): the app's P-256 start and the device runs
|
|
686
|
+
* that answered it were both observed on that station only.
|
|
687
|
+
*/
|
|
688
|
+
get mediaEccPrivateKey(): Buffer | undefined;
|
|
689
|
+
/**
|
|
690
|
+
* The session's P-256 pair: the private scalar as 32 bytes, and the public point as 128 hex characters,
|
|
691
|
+
* `x ‖ y` without the uncompressed-point prefix, the form the current app's attached-camera start carries in
|
|
692
|
+
* `key`. `ECDH.getPrivateKey()` drops a scalar's leading zero bytes, so the scalar is padded back to 32.
|
|
693
|
+
*/
|
|
694
|
+
private mediaEccPair;
|
|
635
695
|
/**
|
|
636
696
|
* The RSA-1024 public-key modulus (128-byte hex) the station uses to wrap the per-stream media
|
|
637
697
|
* key. Lazily generates a keypair; `rsaPrivateKey` decrypts the media key the station returns.
|
|
@@ -682,11 +742,6 @@ export declare class P2PSession extends EventEmitter {
|
|
|
682
742
|
* and holds no level-2 key.
|
|
683
743
|
*/
|
|
684
744
|
private databasePayload;
|
|
685
|
-
/**
|
|
686
|
-
* Standard "give me the whole table" query params the eufy app uses for a direct
|
|
687
|
-
* `CMD_DATABASE` read (inner `cmd 10000`). `count` bounds the row count.
|
|
688
|
-
*/
|
|
689
|
-
private fullTableQuery;
|
|
690
745
|
/**
|
|
691
746
|
* Request the on-device edge-AI **face roster** over P2P — the phone-free path.
|
|
692
747
|
*
|
|
@@ -797,6 +852,8 @@ export declare class P2PSession extends EventEmitter {
|
|
|
797
852
|
*
|
|
798
853
|
* Only once the data type's reorder wait passes, or {@link REORDER_MAX_DATAGRAMS} pile up, is the datagram
|
|
799
854
|
* treated as lost: a pending frame is discarded and delivery resumes from the earliest held datagram.
|
|
855
|
+
*
|
|
856
|
+
* A datagram shorter than the 8-byte DATA header carries no sequence number and is dropped unacknowledged.
|
|
800
857
|
*/
|
|
801
858
|
private onData;
|
|
802
859
|
/** Hold a datagram that arrived past a hole, and arm the wait for the missing one to be repeated. */
|
|
@@ -830,6 +887,13 @@ export declare class P2PSession extends EventEmitter {
|
|
|
830
887
|
*/
|
|
831
888
|
private resetInboundSequencing;
|
|
832
889
|
private ackTypeHeader;
|
|
890
|
+
/**
|
|
891
|
+
* Decrypt and dispatch one inbound data frame.
|
|
892
|
+
*
|
|
893
|
+
* A `CMD_GATEWAYINFO` (1100) reply carries the ECIES envelope for the level-2 session key. When a
|
|
894
|
+
* cipher-key resolver is configured, no key that seals a set-payload is held and no negotiation is in
|
|
895
|
+
* flight, the reply starts one (async, fire-and-forget); a reply landing while one is in flight is ignored.
|
|
896
|
+
*/
|
|
833
897
|
private handleFrame;
|
|
834
898
|
private send;
|
|
835
899
|
/** Finish the END send on the current socket before closing it; send completion is not a peer ACK. */
|
|
@@ -25,6 +25,8 @@ export interface ReadableEgressOptions {
|
|
|
25
25
|
/**
|
|
26
26
|
* Wrap a shared-source {@link Consumer} in a fresh Readable. The consumer is detached when the
|
|
27
27
|
* Readable is destroyed/ended, so callers own the lifetime by owning the stream.
|
|
28
|
-
* Frames arrive through onMedia, preserving their source-arrival metadata.
|
|
28
|
+
* Frames arrive through onMedia, preserving their source-arrival metadata. The consumer's battery
|
|
29
|
+
* `budget` notice is re-emitted unchanged as a `budget` event carrying a {@link StreamBudgetNotice};
|
|
30
|
+
* without an `extend()` within its grace the shared source ends the consumer and the Readable reaches EOF.
|
|
29
31
|
*/
|
|
30
32
|
export declare function openReadableFromConsumer(consumer: Consumer, opts?: ReadableEgressOptions): Readable;
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download of a recording held on a HomeBase 2 (T8010), over the station's P2P session.
|
|
3
|
+
*
|
|
4
|
+
* The request is `CMD_DOWNLOAD_VIDEO` (1024) in the string-pair shape
|
|
5
|
+
* ({@link buildStringPairCommandPayload}) on the camera's channel. The station answers on
|
|
6
|
+
* {@link RECORDING_DATA_TYPE}, tagged with that channel, with the recording's `CMD_VIDEO_FRAME` (1300) and
|
|
7
|
+
* `CMD_AUDIO_FRAME` (1301) frames, faster than real time, then `CMD_DOWNLOAD_FINISH` (1304) on the control
|
|
8
|
+
* data type, tagged with channel 0 whatever the camera's channel.
|
|
9
|
+
*
|
|
10
|
+
* Frame layouts:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* video, keyframe: the live E2E layout ({@link VideoFrameDecoder}), length = 179 + len exactly
|
|
14
|
+
* video, other frames: the same 22B header (u32@0 = len · u16@6 = frame number · u48@0x0e = stamp in ms),
|
|
15
|
+
* then len bytes of plaintext H.264
|
|
16
|
+
* audio: 16B header (u32@0 = len · u8@5 = codec, 0 = AAC-LC), then 16B GCM tag, 12B GCM nonce and len
|
|
17
|
+
* bytes of AES-256-GCM AAC under the media key of the preceding keyframe, AAD as live video; the
|
|
18
|
+
* plaintext is a whole ADTS frame, header included
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* @module p2p/recording-download
|
|
22
|
+
*/
|
|
1
23
|
import { type RecordingDownload } from "../../core/contracts.js";
|
|
2
24
|
import type { P2PSession } from "./p2p-session.js";
|
|
3
25
|
/** Data type a station sends a recording's frames on: the binary channel (`0xd1 0x03`), not live video's. */
|
|
@@ -256,7 +256,8 @@ export declare class SharedLiveSource {
|
|
|
256
256
|
*
|
|
257
257
|
* The previous stream is dropped rather than stopped through the state machine: it speaks to a session that
|
|
258
258
|
* is gone, and its `stop` would be read as an upstream end. Only a warm-up that asked for a replacement
|
|
259
|
-
* rewarms, so this is inert on a source that is streaming or has already failed.
|
|
259
|
+
* rewarms, so this is inert on a source that is streaming or has already failed. Each call replaces the
|
|
260
|
+
* re-issue ticker rather than adding one, so the start goes out once per interval however often it runs.
|
|
260
261
|
*/
|
|
261
262
|
rewarm(): void;
|
|
262
263
|
/**
|
|
@@ -153,3 +153,5 @@ export declare class AccessUnitAssembler {
|
|
|
153
153
|
/** Forget an incomplete unit, counting and reporting it. */
|
|
154
154
|
discard(): void;
|
|
155
155
|
}
|
|
156
|
+
/** Whether a decoded payload OPENS an access unit — a continuation begins mid-NAL, with no start code. */
|
|
157
|
+
export declare function beginsAccessUnit(body: Buffer): boolean;
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for FCM credentials + seen persistent-ids, so we register the FCM
|
|
3
|
+
* token ONCE and just reconnect (and don't re-receive old pushes) across runs.
|
|
4
|
+
*/
|
|
5
|
+
import { FileSessionStore } from "../../core/store.js";
|
|
1
6
|
import type { FcmCredentials } from "./types.js";
|
|
2
7
|
export interface PersistedPush {
|
|
3
8
|
creds: FcmCredentials;
|
|
@@ -14,10 +19,6 @@ export declare class MemoryFcmStore implements FcmStore {
|
|
|
14
19
|
save(p: PersistedPush): void;
|
|
15
20
|
clear(): void;
|
|
16
21
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
constructor(path: string);
|
|
20
|
-
load(): PersistedPush | null;
|
|
21
|
-
save(p: PersistedPush): void;
|
|
22
|
-
clear(): void;
|
|
22
|
+
/** JSON-file FCM store: the {@link FileSessionStore} file format and write guarantees, on the push record. */
|
|
23
|
+
export declare class FileFcmStore extends FileSessionStore<PersistedPush> implements FcmStore {
|
|
23
24
|
}
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* a live pull holds it open. Sends on a session are serialised; an ACK is correlated by its outer command
|
|
15
15
|
* and segment, since it carries no inner command. An ACK is not an observation of the resulting state.
|
|
16
16
|
*
|
|
17
|
+
* The station's own database is read over the same channel: a `set-payload` with inner `1306`
|
|
18
|
+
* (CMD_DATABASE) answers a table and inner `1308` (CMD_DATABASE_IMAGE) a stored image, each as one
|
|
19
|
+
* notify frame carrying the whole document, zstd-compressed when large. The notify link carries
|
|
20
|
+
* unrelated station reports between them, so a reply is matched by its content, never by arrival order.
|
|
21
|
+
*
|
|
17
22
|
* Live view of an attached camera rides the same session (the hub accepts one RTC session per account),
|
|
18
23
|
* through one shared live source per station: the station serves one camera at a time, so a second
|
|
19
24
|
* camera is refused until the first one's view ends.
|
|
@@ -72,8 +77,48 @@ export declare class RtcCommandRouter {
|
|
|
72
77
|
private readonly lives;
|
|
73
78
|
private readonly warm;
|
|
74
79
|
private readonly callSources;
|
|
80
|
+
private lastTransaction;
|
|
75
81
|
constructor(deps: RtcCommandRouterDeps);
|
|
76
82
|
dispatchCommand(route: RtcRoute, cmd: Command): Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Query one on-station table over the station's control channel and answer its rows.
|
|
85
|
+
*
|
|
86
|
+
* Sends the `1350` envelope with inner `{cmd: 1306, payload: {cmd: 10000, table, payload, transaction}}`,
|
|
87
|
+
* the whole-table query, on the station channel. The hub ACKs on the command link and answers with one notify frame whose body
|
|
88
|
+
* carries `{cmd, table, data, mIntRet, msg, transaction, …}`, echoing the read's `transaction`; that echo
|
|
89
|
+
* is the only thing a reply is attributed by, and every read sends a `transaction` no other read on this
|
|
90
|
+
* router has sent. Resolves with the reply's `data` rows. Rejects with the hub's `msg` on a non-zero
|
|
91
|
+
* `mIntRet`, on an ACK refusing the envelope, at once when `signal` aborts (queued or sent), when the
|
|
92
|
+
* session closes, and when `timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
|
|
93
|
+
*
|
|
94
|
+
* One read or command at a time per station: the read holds the station's send queue until it settles.
|
|
95
|
+
*/
|
|
96
|
+
readTable(route: RtcRoute, table: string, opts?: {
|
|
97
|
+
timeoutMs?: number;
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
}): Promise<unknown[]>;
|
|
100
|
+
/**
|
|
101
|
+
* Fetch one stored image by its on-station path over the station's control channel.
|
|
102
|
+
*
|
|
103
|
+
* Sends the `1350` envelope with inner `{cmd: 1308, payload: [{file}]}` on the station channel. The hub
|
|
104
|
+
* ACKs on the command link and answers with one notify frame whose body is `{file, content}`, `content`
|
|
105
|
+
* the image in base64, and no `transaction`; the reply is the one whose `file` equals `filePath`. A reply
|
|
106
|
+
* arriving after an abandoned read of the same path carries that path's bytes, which are the bytes a
|
|
107
|
+
* later read of it asks for. Resolves with the decoded bytes. Rejects on an ACK refusing the envelope,
|
|
108
|
+
* at once when `signal` aborts (queued or sent), when the session closes, and when `timeoutMs` (default
|
|
109
|
+
* {@link DB_TABLE_TIMEOUT_MS}) elapses with no reply.
|
|
110
|
+
*
|
|
111
|
+
* One read or command at a time per station: the read holds the station's send queue until it settles.
|
|
112
|
+
*/
|
|
113
|
+
readImage(route: RtcRoute, filePath: string, opts?: {
|
|
114
|
+
timeoutMs?: number;
|
|
115
|
+
signal?: AbortSignal;
|
|
116
|
+
}): Promise<Buffer>;
|
|
117
|
+
/**
|
|
118
|
+
* A `transaction` stamp in the vendor app's shape, a decimal epoch-in-milliseconds string, strictly
|
|
119
|
+
* greater than the last one this router issued, so two reads in the same millisecond never share one.
|
|
120
|
+
*/
|
|
121
|
+
private nextTransaction;
|
|
77
122
|
/**
|
|
78
123
|
* The media of a camera on a T9000: live video, a readable feed and a live still over the station's
|
|
79
124
|
* session. Recording has no wire here and is refused; the optional members it has no wire for are absent.
|
|
@@ -108,9 +153,24 @@ export declare class RtcCommandRouter {
|
|
|
108
153
|
private refuseIfWatched;
|
|
109
154
|
private newLiveSource;
|
|
110
155
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
156
|
+
* Send one envelope on the station's session, queued behind its other sends, and settle as
|
|
157
|
+
* {@link sendAwaitAck} does. `body` is the inner payload; the station session, the `account_id` it is
|
|
158
|
+
* stamped with and the packet are resolved here, so a command and a read reach the wire through the same
|
|
159
|
+
* steps. With `reply.signal`, the caller's wait rejects the moment it aborts, also while the send is still
|
|
160
|
+
* queued; a send not yet made by then is skipped.
|
|
161
|
+
*/
|
|
162
|
+
private sendQueued;
|
|
163
|
+
/**
|
|
164
|
+
* Without `reply`, completes on the command ACK or a correlated control-result notification. The
|
|
165
|
+
* notification must match the request channel, parameter and segment and contain a structured payload.
|
|
166
|
+
* Receipt does not establish physical actuation. Bounded by {@link ACK_TIMEOUT_MS}.
|
|
167
|
+
*
|
|
168
|
+
* With `reply`, a zero-result ACK is not the answer: the first notify body (an object) for which
|
|
169
|
+
* `reply.pick` answers a value resolves with it, one `pick` throws on rejects with that error, and every
|
|
170
|
+
* other notify frame is left alone. Bounded by `reply.timeoutMs` (default {@link DB_TABLE_TIMEOUT_MS}),
|
|
171
|
+
* and ended by `reply.signal`; one already aborted is not sent.
|
|
172
|
+
*
|
|
173
|
+
* Either way an ACK with a non-zero result code rejects, and the command is sent once.
|
|
114
174
|
*/
|
|
115
175
|
private sendAwaitAck;
|
|
116
176
|
private newSession;
|
|
@@ -1,14 +1,3 @@
|
|
|
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
1
|
export declare const PORTAL_HEADER_LENGTH = 16;
|
|
13
2
|
/** The SET_PAYLOAD envelope. */
|
|
14
3
|
export declare const PORTAL_CMD_SET_PAYLOAD = 1350;
|
|
@@ -61,8 +50,9 @@ export declare function isPortalPacket(buf: Buffer): boolean;
|
|
|
61
50
|
/** A complete request packet: header + body. */
|
|
62
51
|
export declare function buildPortalPacket(req: PortalRequest): Buffer;
|
|
63
52
|
/**
|
|
64
|
-
* Parse an inbound packet. On the command channel the body opens
|
|
65
|
-
* carry JSON after it; on the notify channel the whole body is JSON
|
|
53
|
+
* Parse an inbound packet, opening a zstd-compressed one first. On the command channel the body opens
|
|
54
|
+
* with the int32 result code and may carry JSON after it; on the notify channel the whole body is JSON
|
|
55
|
+
* with a nested `cmd`. `undefined` for a frame that is neither a packet nor a stream holding one.
|
|
66
56
|
*/
|
|
67
57
|
export declare function parsePortalPacket(buf: Buffer, linkType?: number): PortalResponse | undefined;
|
|
68
58
|
/** A 1..255 segment counter that never yields 0 (the portal reserves it). */
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
*
|
|
16
16
|
* The command data channel is the session: `close` fires once, when the channel closes, the peer fails,
|
|
17
17
|
* the signalling socket drops or {@link RtcSession.close} is called, even while the peer connection is
|
|
18
|
-
* nominally up. A
|
|
19
|
-
*
|
|
18
|
+
* nominally up. A dropped signalling socket closes the peer with it: a hub that still sees the peer
|
|
19
|
+
* answers every later `scall` with 486 until that peer's ICE gives up. A peer that cannot be built when
|
|
20
|
+
* the hub grants the relay ends the session there: the failure is emitted as `error` and `close` fires
|
|
21
|
+
* carrying it.
|
|
20
22
|
*
|
|
21
23
|
* A session whose peer is a {@link RtcPeerOptions.mediaTrack} peer has no command channel: `close` fires
|
|
22
24
|
* when the peer fails or closes, and the video track's packets are emitted as `rtp`.
|
|
@@ -9,6 +9,12 @@ export declare class StoredImageCache {
|
|
|
9
9
|
private readonly activeDevices;
|
|
10
10
|
private activeDownloads;
|
|
11
11
|
private generation;
|
|
12
|
+
/**
|
|
13
|
+
* Bumped by {@link clearLifecycleErrors}. A lifecycle failure from a download started under an older
|
|
14
|
+
* count is not recorded; the candidate is queued again, unless a newer one is already queued, so the
|
|
15
|
+
* current session fetches it.
|
|
16
|
+
*/
|
|
17
|
+
private lifecycle;
|
|
12
18
|
constructor(downloader: (url: string, deviceKey: string) => Promise<Buffer>, logger: Logger, clock?: () => number, isLifecycleError?: (error: unknown) => boolean);
|
|
13
19
|
/**
|
|
14
20
|
* Observe a normalized thumbnail URL and start acquisition eagerly.
|
|
@@ -20,6 +26,14 @@ export declare class StoredImageCache {
|
|
|
20
26
|
observe(deviceKey: string, url: string): void;
|
|
21
27
|
/** Return retained bytes without starting or awaiting network work. */
|
|
22
28
|
snapshotStored(deviceKey: string): Promise<Buffer>;
|
|
29
|
+
/**
|
|
30
|
+
* Forget every device's recorded lifecycle failure, keeping retained bytes and candidate state.
|
|
31
|
+
*
|
|
32
|
+
* A lifecycle failure describes the session that was current when the download ran, so it no longer
|
|
33
|
+
* holds once a login succeeds again; {@link snapshotStored} then answers from the retained image, or
|
|
34
|
+
* with the reason a stored image is absent. A download in flight across the call cannot record one.
|
|
35
|
+
*/
|
|
36
|
+
clearLifecycleErrors(): void;
|
|
23
37
|
/** Invalidate all retained and candidate state. */
|
|
24
38
|
clear(): void;
|
|
25
39
|
private pump;
|