@mega-yfue/eufy-sdk 0.4.0-beta.14 → 0.4.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/core/contracts.d.ts +43 -0
- package/dist/index.js +403 -106
- package/dist/index.js.map +4 -4
- package/dist/model/capabilities/camera.d.ts +1 -0
- package/dist/transport/p2p/adts.d.ts +6 -0
- package/dist/transport/p2p/codec.d.ts +13 -0
- package/dist/transport/p2p/command-router.d.ts +29 -0
- package/dist/transport/p2p/p2p-session.d.ts +5 -0
- package/dist/transport/p2p/recording-download.d.ts +54 -0
- package/dist/transport/p2p/video.d.ts +2 -0
- package/package.json +1 -1
package/dist/core/contracts.d.ts
CHANGED
|
@@ -150,6 +150,39 @@ export declare class DeviceChannelUnresolvedError extends Error {
|
|
|
150
150
|
cause?: unknown;
|
|
151
151
|
});
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Why {@link MediaProvider.downloadRecording} returned no recording.
|
|
155
|
+
*
|
|
156
|
+
* - `invalid-recording` — the recording name is not one the station could hold.
|
|
157
|
+
* - `key-unavailable` — the key the recording was stored under could not be obtained.
|
|
158
|
+
* - `no-data` — the station sent nothing within the wait.
|
|
159
|
+
* - `incomplete` — the transfer ended before the station's finish frame.
|
|
160
|
+
* - `undecodable` — data arrived but no video frame could be decoded from it.
|
|
161
|
+
*/
|
|
162
|
+
export type RecordingDownloadFailureReason = "invalid-recording" | "key-unavailable" | "no-data" | "incomplete" | "undecodable";
|
|
163
|
+
/** Thrown by {@link MediaProvider.downloadRecording} when no recording could be returned. */
|
|
164
|
+
export declare class RecordingDownloadError extends Error {
|
|
165
|
+
readonly reason: RecordingDownloadFailureReason;
|
|
166
|
+
constructor(reason: RecordingDownloadFailureReason, message: string, options?: {
|
|
167
|
+
cause?: unknown;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* A recording downloaded from the station, as elementary streams.
|
|
172
|
+
*
|
|
173
|
+
* `video` is Annex-B H.264 in decode order, `audio` AAC-LC 16 kHz mono in ADTS framing (absent when the
|
|
174
|
+
* recording carried none that could be decoded). `frames` counts the distinct video frames received,
|
|
175
|
+
* `missingFrames` the ones the camera numbered but that never arrived, and `durationMs` spans the first
|
|
176
|
+
* to the last received frame by the camera's own timestamps; `fps` is derived from those two stamps.
|
|
177
|
+
*/
|
|
178
|
+
export interface RecordingDownload {
|
|
179
|
+
video: Buffer;
|
|
180
|
+
audio?: Buffer;
|
|
181
|
+
frames: number;
|
|
182
|
+
missingFrames: number;
|
|
183
|
+
durationMs: number;
|
|
184
|
+
fps: number;
|
|
185
|
+
}
|
|
153
186
|
/**
|
|
154
187
|
* How a live stream ended before its first video keyframe: the warm-up deadline elapsed, the source
|
|
155
188
|
* reported an error, or the source ended on its own.
|
|
@@ -804,6 +837,16 @@ export interface MediaProvider {
|
|
|
804
837
|
talkback?(opts?: {
|
|
805
838
|
encoder?: AacEncoder;
|
|
806
839
|
} & SharedSourceHints): Promise<TalkbackHandle>;
|
|
840
|
+
/**
|
|
841
|
+
* Download one recording the camera's station stored, identified by the recording name and cipher id of
|
|
842
|
+
* the event. One download runs at a time per station. Rejects with {@link RecordingDownloadError}.
|
|
843
|
+
* Optional, and absent on a camera whose station's recording download is unverified.
|
|
844
|
+
*/
|
|
845
|
+
downloadRecording?(opts: {
|
|
846
|
+
recording: string;
|
|
847
|
+
cipherId: number;
|
|
848
|
+
timeoutMs?: number;
|
|
849
|
+
} & AbortableCall): Promise<RecordingDownload>;
|
|
807
850
|
/**
|
|
808
851
|
* Generic P2P request/reply query: send a `SET_PAYLOAD` sub-command and resolve with the reply
|
|
809
852
|
* frame's `payload` (the reply whose `cmd` echoes `subCmd`). Transport-only — the caller owns the
|
package/dist/index.js
CHANGED
|
@@ -1147,6 +1147,14 @@ var DeviceChannelUnresolvedError = class extends Error {
|
|
|
1147
1147
|
this.name = "DeviceChannelUnresolvedError";
|
|
1148
1148
|
}
|
|
1149
1149
|
};
|
|
1150
|
+
var RecordingDownloadError = class extends Error {
|
|
1151
|
+
reason;
|
|
1152
|
+
constructor(reason, message, options) {
|
|
1153
|
+
super(message, options);
|
|
1154
|
+
this.reason = reason;
|
|
1155
|
+
this.name = "RecordingDownloadError";
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1150
1158
|
var LiveStreamStartError = class extends Error {
|
|
1151
1159
|
reason;
|
|
1152
1160
|
stage;
|
|
@@ -5702,6 +5710,7 @@ var CAMERA_MEMBERS = {
|
|
|
5702
5710
|
refuseWhenDisabled(ctx, read);
|
|
5703
5711
|
return m.recordFragments({ powered: poweredOf(ctx), ...opts });
|
|
5704
5712
|
}), "Continuous fragmented-MP4 (CMAF) recording."),
|
|
5713
|
+
downloadRecording: provided("media", (m) => m.downloadRecording && ((opts) => m.downloadRecording(opts)), "Download a recording the station holds \u2192 H.264 and AAC elementary streams."),
|
|
5705
5714
|
/**
|
|
5706
5715
|
* Push audio from the host to this camera's speaker. Gated on the **speaker** param specifically —
|
|
5707
5716
|
* not the `audio` capability, which resolves on a microphone alone and would advertise a speaker the
|
|
@@ -14342,6 +14351,7 @@ __export(codec_exports, {
|
|
|
14342
14351
|
buildLookupWithKeyPayload2: () => buildLookupWithKeyPayload2,
|
|
14343
14352
|
buildRawCommandPayload: () => buildRawCommandPayload,
|
|
14344
14353
|
buildStringCommandPayload: () => buildStringCommandPayload,
|
|
14354
|
+
buildStringPairCommandPayload: () => buildStringPairCommandPayload,
|
|
14345
14355
|
buildVoidCommandPayload: () => buildVoidCommandPayload,
|
|
14346
14356
|
decodeP2PCloudIPs: () => decodeP2PCloudIPs,
|
|
14347
14357
|
decryptP2PData: () => decryptP2PData,
|
|
@@ -14378,6 +14388,11 @@ var ResponseMessageType = {
|
|
|
14378
14388
|
LOOKUP_ADDR: Buffer.from([241, 64]),
|
|
14379
14389
|
LOOKUP_ADDR2: Buffer.from([241, 130]),
|
|
14380
14390
|
CAM_ID: Buffer.from([241, 66]),
|
|
14391
|
+
/**
|
|
14392
|
+
* The device's own address record, sent in answer to CHECK_CAM ahead of CAM_ID: its 20-byte device id, one
|
|
14393
|
+
* record in `encodeSelfAddress`'s shape naming the address it answers from, then 8 zero bytes (44-byte payload).
|
|
14394
|
+
*/
|
|
14395
|
+
CAM_ADDR: Buffer.from([241, 67]),
|
|
14381
14396
|
TURN_SERVER_CAM_ID: Buffer.from([241, 132]),
|
|
14382
14397
|
PING: Buffer.from([241, 224]),
|
|
14383
14398
|
PONG: Buffer.from([241, 225]),
|
|
@@ -14589,6 +14604,12 @@ function buildIntStringCommandPayload(value, valueSub, strValue, channel = 0, ke
|
|
|
14589
14604
|
data
|
|
14590
14605
|
]);
|
|
14591
14606
|
}
|
|
14607
|
+
function buildStringPairCommandPayload(strValue, strValueSub, channel = 0, key, encType = 1) {
|
|
14608
|
+
const encrypted = !!key && key.length === 16;
|
|
14609
|
+
const body = Buffer.concat([Buffer.alloc(5), stringWithLength(strValue), stringWithLength(strValueSub)]);
|
|
14610
|
+
const data = encrypted ? encryptP2PData(paddingP2PData(body), key) : body;
|
|
14611
|
+
return buildRawCommandPayload(data, channel, encrypted ? encType : 0);
|
|
14612
|
+
}
|
|
14592
14613
|
function buildRawCommandPayload(data, channel = 0, signCode = 0, magic = [1, 0], streamId = 0) {
|
|
14593
14614
|
const header = Buffer.allocUnsafe(2);
|
|
14594
14615
|
header.writeUInt16LE(data.length, 0);
|
|
@@ -15737,6 +15758,9 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15737
15758
|
* straddling a level-2 wait on a camera whose failure to deliver video had a separate cause, and did not
|
|
15738
15759
|
* recur across later probes of it. Correlate an unmodelled type against a WORKING session before reading it
|
|
15739
15760
|
* as a cause.
|
|
15761
|
+
*
|
|
15762
|
+
* CAM_ADDR is recognised and dropped: it names the address the device is already answering from, and the
|
|
15763
|
+
* CAM_ID that follows it is what completes the connect.
|
|
15740
15764
|
*/
|
|
15741
15765
|
onMessage(msg, rinfo, socket = this.socket) {
|
|
15742
15766
|
if (!socket)
|
|
@@ -15767,7 +15791,7 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
15767
15791
|
} else if (hasHeader(msg, ResponseMessageType.DATA)) {
|
|
15768
15792
|
if (this.connected)
|
|
15769
15793
|
this.onData(msg, { host: rinfo.address, port: rinfo.port });
|
|
15770
|
-
} else {
|
|
15794
|
+
} else if (!hasHeader(msg, ResponseMessageType.CAM_ADDR)) {
|
|
15771
15795
|
this.logger.debug(`[p2p] ${this.cfg.stationSn} UNHANDLED payload hex: ${msg.toString("hex")}`);
|
|
15772
15796
|
}
|
|
15773
15797
|
}
|
|
@@ -16058,6 +16082,17 @@ var P2PSession = class _P2PSession extends EventEmitter2 {
|
|
|
16058
16082
|
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
16059
16083
|
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16060
16084
|
}
|
|
16085
|
+
/** Send a level-1 {@link buildStringPairCommandPayload} command on `channel`. */
|
|
16086
|
+
sendStringPairCommand(commandType, strValue, strValueSub, channel) {
|
|
16087
|
+
if (!this.connectAddress)
|
|
16088
|
+
throw new Error(`P2P session ${this.cfg.stationSn} is not connected`);
|
|
16089
|
+
const data = Buffer.concat([
|
|
16090
|
+
buildCommandHeader(this.seqNumber, commandType),
|
|
16091
|
+
buildStringPairCommandPayload(strValue, strValueSub, channel, this.level1Key, 1)
|
|
16092
|
+
]);
|
|
16093
|
+
this.seqNumber = this.seqNumber + 1 & 65535;
|
|
16094
|
+
this.send(this.connectAddress, RequestMessageType.DATA, data);
|
|
16095
|
+
}
|
|
16061
16096
|
/**
|
|
16062
16097
|
* Send a **level-2 (AES-256-GCM, signCode 8) control payload** to a HomeBase-attached device. The
|
|
16063
16098
|
* target camera is selected by `channel` (= device_channel) + the `mChannel` envelope — the same
|
|
@@ -18236,6 +18271,282 @@ function annexbToJpeg(annexb, spawn2) {
|
|
|
18236
18271
|
});
|
|
18237
18272
|
}
|
|
18238
18273
|
|
|
18274
|
+
// dist/transport/p2p/recording-download.js
|
|
18275
|
+
import { createDecipheriv as createDecipheriv7 } from "node:crypto";
|
|
18276
|
+
|
|
18277
|
+
// dist/transport/p2p/adts.js
|
|
18278
|
+
var PROFILE_AAC_LC = 1;
|
|
18279
|
+
var FREQ_INDEX_16K = 8;
|
|
18280
|
+
var CHANNELS_MONO = 1;
|
|
18281
|
+
var AAC_SAMPLES_PER_FRAME = 1024;
|
|
18282
|
+
var AAC_SAMPLE_RATE = 16e3;
|
|
18283
|
+
var AAC_FRAME_MS = AAC_SAMPLES_PER_FRAME * 1e3 / AAC_SAMPLE_RATE;
|
|
18284
|
+
var MAX_AUDIO_FRAME_BYTES = 640;
|
|
18285
|
+
var HEADER_LEN_NO_CRC = 7;
|
|
18286
|
+
function parseAdtsHeader(buf, offset = 0) {
|
|
18287
|
+
if (offset + HEADER_LEN_NO_CRC > buf.length)
|
|
18288
|
+
return void 0;
|
|
18289
|
+
if (buf[offset] !== 255 || (buf[offset + 1] & 240) !== 240)
|
|
18290
|
+
return void 0;
|
|
18291
|
+
const headerLength = buf[offset + 1] & 1 ? 7 : 9;
|
|
18292
|
+
const frameLength = (buf[offset + 3] & 3) << 11 | buf[offset + 4] << 3 | (buf[offset + 5] & 224) >> 5;
|
|
18293
|
+
if (frameLength < headerLength)
|
|
18294
|
+
return void 0;
|
|
18295
|
+
return {
|
|
18296
|
+
frameLength,
|
|
18297
|
+
headerLength,
|
|
18298
|
+
profile: (buf[offset + 2] & 192) >> 6,
|
|
18299
|
+
frequencyIndex: (buf[offset + 2] & 60) >> 2,
|
|
18300
|
+
channels: (buf[offset + 2] & 1) << 2 | (buf[offset + 3] & 192) >> 6
|
|
18301
|
+
};
|
|
18302
|
+
}
|
|
18303
|
+
function buildAdtsHeader(payloadLength) {
|
|
18304
|
+
const frameLength = payloadLength + HEADER_LEN_NO_CRC;
|
|
18305
|
+
return Buffer.from([
|
|
18306
|
+
255,
|
|
18307
|
+
241,
|
|
18308
|
+
PROFILE_AAC_LC << 6 | FREQ_INDEX_16K << 2 | CHANNELS_MONO >> 2,
|
|
18309
|
+
(CHANNELS_MONO & 3) << 6 | frameLength >> 11 & 3,
|
|
18310
|
+
frameLength >> 3 & 255,
|
|
18311
|
+
(frameLength & 7) << 5 | 31,
|
|
18312
|
+
252
|
|
18313
|
+
]);
|
|
18314
|
+
}
|
|
18315
|
+
function isSupportedAdts(h) {
|
|
18316
|
+
return h.profile === PROFILE_AAC_LC && h.frequencyIndex === FREQ_INDEX_16K && h.channels === CHANNELS_MONO;
|
|
18317
|
+
}
|
|
18318
|
+
function describeAdts(h) {
|
|
18319
|
+
const rate = ADTS_SAMPLE_RATES[h.frequencyIndex] ?? `index ${h.frequencyIndex}`;
|
|
18320
|
+
const profile = h.profile === PROFILE_AAC_LC ? "AAC-LC" : `profile field ${h.profile}`;
|
|
18321
|
+
return `${profile}, ${rate} Hz, ${h.channels} channel(s)`;
|
|
18322
|
+
}
|
|
18323
|
+
var ADTS_SAMPLE_RATES = [
|
|
18324
|
+
96e3,
|
|
18325
|
+
88200,
|
|
18326
|
+
64e3,
|
|
18327
|
+
48e3,
|
|
18328
|
+
44100,
|
|
18329
|
+
32e3,
|
|
18330
|
+
24e3,
|
|
18331
|
+
22050,
|
|
18332
|
+
16e3,
|
|
18333
|
+
12e3,
|
|
18334
|
+
11025,
|
|
18335
|
+
8e3,
|
|
18336
|
+
7350
|
|
18337
|
+
];
|
|
18338
|
+
var AdtsFrameReader = class {
|
|
18339
|
+
buffered = Buffer.alloc(0);
|
|
18340
|
+
/**
|
|
18341
|
+
* Append `chunk` and return every complete frame now available, each a standalone buffer that still
|
|
18342
|
+
* carries its own ADTS header (the device expects the header on the wire).
|
|
18343
|
+
*/
|
|
18344
|
+
push(chunk) {
|
|
18345
|
+
this.buffered = this.buffered.length ? Buffer.concat([this.buffered, chunk]) : chunk;
|
|
18346
|
+
const frames = [];
|
|
18347
|
+
let at = 0;
|
|
18348
|
+
for (; ; ) {
|
|
18349
|
+
const h = parseAdtsHeader(this.buffered, at);
|
|
18350
|
+
if (!h) {
|
|
18351
|
+
if (at + HEADER_LEN_NO_CRC > this.buffered.length)
|
|
18352
|
+
break;
|
|
18353
|
+
at++;
|
|
18354
|
+
continue;
|
|
18355
|
+
}
|
|
18356
|
+
if (at + h.frameLength > this.buffered.length)
|
|
18357
|
+
break;
|
|
18358
|
+
frames.push(this.buffered.subarray(at, at + h.frameLength));
|
|
18359
|
+
at += h.frameLength;
|
|
18360
|
+
}
|
|
18361
|
+
this.buffered = at ? this.buffered.subarray(at) : this.buffered;
|
|
18362
|
+
return frames;
|
|
18363
|
+
}
|
|
18364
|
+
/** Bytes held back awaiting the rest of their frame — non-zero mid-stream, zero on a clean boundary. */
|
|
18365
|
+
get pending() {
|
|
18366
|
+
return this.buffered.length;
|
|
18367
|
+
}
|
|
18368
|
+
};
|
|
18369
|
+
|
|
18370
|
+
// dist/transport/p2p/recording-download.js
|
|
18371
|
+
var RECORDING_DATA_TYPE = 3;
|
|
18372
|
+
var VIDEO_HEADER_LEN2 = 22;
|
|
18373
|
+
var AUDIO_HEADER_LEN2 = 16;
|
|
18374
|
+
var AUDIO_NONCE_START = 32;
|
|
18375
|
+
var AUDIO_BODY_START = 44;
|
|
18376
|
+
var AUDIO_CODEC_AAC_LC = 0;
|
|
18377
|
+
var FIRST_FRAME_WAIT_MS = 2e4;
|
|
18378
|
+
var IDLE_END_MS = 15e3;
|
|
18379
|
+
var DEFAULT_TRANSFER_MS = 12e4;
|
|
18380
|
+
var MAX_TRANSFER_BYTES = 32 * 1024 * 1024;
|
|
18381
|
+
function homeBase2RecordingPath(channel, recording) {
|
|
18382
|
+
if (!/^\d{14}$/.test(recording) || !Number.isInteger(channel) || channel < 0 || channel > 99)
|
|
18383
|
+
return void 0;
|
|
18384
|
+
return `/media/mmcblk0p1/Camera${String(channel).padStart(2, "0")}/${recording}.dat`;
|
|
18385
|
+
}
|
|
18386
|
+
function receiveRecording(session, request) {
|
|
18387
|
+
let resolveFrames;
|
|
18388
|
+
let rejectFrames;
|
|
18389
|
+
const frames = new Promise((resolve, reject) => {
|
|
18390
|
+
resolveFrames = resolve;
|
|
18391
|
+
rejectFrames = reject;
|
|
18392
|
+
});
|
|
18393
|
+
let resolveDrained;
|
|
18394
|
+
const drained = new Promise((resolve) => resolveDrained = resolve);
|
|
18395
|
+
const limitMs = request.timeoutMs ?? DEFAULT_TRANSFER_MS;
|
|
18396
|
+
let collected = [];
|
|
18397
|
+
const started = Date.now();
|
|
18398
|
+
let lastFrameAt = started;
|
|
18399
|
+
let received = false;
|
|
18400
|
+
let bytes2 = 0;
|
|
18401
|
+
let draining;
|
|
18402
|
+
let delivered = false;
|
|
18403
|
+
let listening = true;
|
|
18404
|
+
const deliver = (error) => {
|
|
18405
|
+
if (delivered)
|
|
18406
|
+
return;
|
|
18407
|
+
delivered = true;
|
|
18408
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
18409
|
+
if (error)
|
|
18410
|
+
rejectFrames(error);
|
|
18411
|
+
else
|
|
18412
|
+
resolveFrames(collected);
|
|
18413
|
+
};
|
|
18414
|
+
const stop = () => {
|
|
18415
|
+
if (!listening)
|
|
18416
|
+
return;
|
|
18417
|
+
listening = false;
|
|
18418
|
+
clearInterval(tick);
|
|
18419
|
+
session.off("data", onData);
|
|
18420
|
+
resolveDrained();
|
|
18421
|
+
};
|
|
18422
|
+
const incomplete = (why) => deliver(new RecordingDownloadError("incomplete", `the recording transfer ${why} before its finish frame`));
|
|
18423
|
+
const drain = (why) => {
|
|
18424
|
+
draining ??= why;
|
|
18425
|
+
collected = [];
|
|
18426
|
+
};
|
|
18427
|
+
const onData = (frame) => {
|
|
18428
|
+
if (frame.channel !== request.channel)
|
|
18429
|
+
return;
|
|
18430
|
+
if (frame.commandId === CommandType.CMD_DOWNLOAD_FINISH) {
|
|
18431
|
+
if (draining)
|
|
18432
|
+
incomplete(draining);
|
|
18433
|
+
else
|
|
18434
|
+
deliver();
|
|
18435
|
+
return stop();
|
|
18436
|
+
}
|
|
18437
|
+
if (frame.commandId !== CommandType.CMD_VIDEO_FRAME && frame.commandId !== CommandType.CMD_AUDIO_FRAME)
|
|
18438
|
+
return;
|
|
18439
|
+
if (frame.dataType !== RECORDING_DATA_TYPE)
|
|
18440
|
+
return;
|
|
18441
|
+
received = true;
|
|
18442
|
+
lastFrameAt = Date.now();
|
|
18443
|
+
if (draining)
|
|
18444
|
+
return;
|
|
18445
|
+
collected.push({ commandId: frame.commandId, signCode: frame.signCode, raw: Buffer.from(frame.raw) });
|
|
18446
|
+
bytes2 += frame.raw.length;
|
|
18447
|
+
if (bytes2 >= MAX_TRANSFER_BYTES)
|
|
18448
|
+
drain(`reached ${MAX_TRANSFER_BYTES} bytes`);
|
|
18449
|
+
};
|
|
18450
|
+
const onAbort = () => {
|
|
18451
|
+
deliver(request.signal?.reason instanceof Error ? request.signal.reason : new Error("aborted"));
|
|
18452
|
+
drain("was aborted");
|
|
18453
|
+
};
|
|
18454
|
+
const tick = setInterval(() => {
|
|
18455
|
+
const now2 = Date.now();
|
|
18456
|
+
if (!received && now2 - started > FIRST_FRAME_WAIT_MS) {
|
|
18457
|
+
deliver(new RecordingDownloadError("no-data", `the station sent nothing within ${FIRST_FRAME_WAIT_MS} ms`));
|
|
18458
|
+
stop();
|
|
18459
|
+
} else if (received && now2 - lastFrameAt > IDLE_END_MS) {
|
|
18460
|
+
incomplete(draining ?? `went quiet for ${IDLE_END_MS} ms`);
|
|
18461
|
+
stop();
|
|
18462
|
+
} else if (now2 - started > 2 * limitMs && draining) {
|
|
18463
|
+
incomplete(draining);
|
|
18464
|
+
stop();
|
|
18465
|
+
} else if (now2 - started > limitMs) {
|
|
18466
|
+
drain(`ran past ${limitMs} ms`);
|
|
18467
|
+
}
|
|
18468
|
+
}, 250);
|
|
18469
|
+
if (request.signal?.aborted) {
|
|
18470
|
+
onAbort();
|
|
18471
|
+
stop();
|
|
18472
|
+
return { frames, drained };
|
|
18473
|
+
}
|
|
18474
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
18475
|
+
session.on("data", onData);
|
|
18476
|
+
try {
|
|
18477
|
+
session.sendStringPairCommand(CommandType.CMD_DOWNLOAD_VIDEO, request.path, request.accountId, request.channel);
|
|
18478
|
+
} catch (error) {
|
|
18479
|
+
deliver(error instanceof Error ? error : new Error(String(error)));
|
|
18480
|
+
stop();
|
|
18481
|
+
}
|
|
18482
|
+
return { frames, drained };
|
|
18483
|
+
}
|
|
18484
|
+
function openAudio(key, nonce, tag2, body) {
|
|
18485
|
+
try {
|
|
18486
|
+
const decipher = createDecipheriv7("aes-256-gcm", key, nonce);
|
|
18487
|
+
decipher.setAAD(VIDEO_GCM_AAD);
|
|
18488
|
+
decipher.setAuthTag(tag2);
|
|
18489
|
+
return Buffer.concat([decipher.update(body), decipher.final()]);
|
|
18490
|
+
} catch {
|
|
18491
|
+
return void 0;
|
|
18492
|
+
}
|
|
18493
|
+
}
|
|
18494
|
+
function decodeRecording(frames, eccPrivateKeyHex) {
|
|
18495
|
+
const eccPrivateKey = Buffer.from(eccPrivateKeyHex, "hex");
|
|
18496
|
+
if (eccPrivateKey.length !== 32)
|
|
18497
|
+
throw new RecordingDownloadError("undecodable", "the cipher key is not a P-256 key");
|
|
18498
|
+
const decoder = new VideoFrameDecoder(eccPrivateKey);
|
|
18499
|
+
const video = [];
|
|
18500
|
+
const audio = [];
|
|
18501
|
+
const numbers = /* @__PURE__ */ new Set();
|
|
18502
|
+
let firstStamp;
|
|
18503
|
+
let lastStamp;
|
|
18504
|
+
for (const { commandId, raw } of frames) {
|
|
18505
|
+
if (commandId === CommandType.CMD_VIDEO_FRAME) {
|
|
18506
|
+
const header = parseVideoFrameHeader(raw);
|
|
18507
|
+
if (!header)
|
|
18508
|
+
continue;
|
|
18509
|
+
const h264 = header.keyframe ? decoder.decodeFrame(raw)?.h264 : raw.length >= VIDEO_HEADER_LEN2 + header.payloadLength ? raw.subarray(VIDEO_HEADER_LEN2, VIDEO_HEADER_LEN2 + header.payloadLength) : void 0;
|
|
18510
|
+
if (!h264?.length)
|
|
18511
|
+
continue;
|
|
18512
|
+
video.push(h264);
|
|
18513
|
+
numbers.add(header.sequence);
|
|
18514
|
+
const stamp = raw.readUIntLE(14, 6);
|
|
18515
|
+
firstStamp ??= stamp;
|
|
18516
|
+
lastStamp = stamp;
|
|
18517
|
+
} else if (commandId === CommandType.CMD_AUDIO_FRAME) {
|
|
18518
|
+
const mediaKey = decoder.currentMediaKey;
|
|
18519
|
+
if (!mediaKey || raw.length < AUDIO_BODY_START || raw[5] !== AUDIO_CODEC_AAC_LC)
|
|
18520
|
+
continue;
|
|
18521
|
+
const len = raw.readUInt32LE(0);
|
|
18522
|
+
if (raw.length < AUDIO_BODY_START + len)
|
|
18523
|
+
continue;
|
|
18524
|
+
const aac = openAudio(mediaKey, raw.subarray(AUDIO_NONCE_START, AUDIO_BODY_START), raw.subarray(AUDIO_HEADER_LEN2, AUDIO_NONCE_START), raw.subarray(AUDIO_BODY_START, AUDIO_BODY_START + len));
|
|
18525
|
+
if (aac?.length)
|
|
18526
|
+
audio.push(buildAdtsHeader(aac.length), aac);
|
|
18527
|
+
}
|
|
18528
|
+
}
|
|
18529
|
+
if (!video.length)
|
|
18530
|
+
throw new RecordingDownloadError("undecodable", "no video frame of the recording decoded");
|
|
18531
|
+
const ordered = [...numbers];
|
|
18532
|
+
let missingFrames = 0;
|
|
18533
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
18534
|
+
const step = ordered[i] - ordered[i - 1] & 65535;
|
|
18535
|
+
if (step > 1 && step < 32768)
|
|
18536
|
+
missingFrames += step - 1;
|
|
18537
|
+
}
|
|
18538
|
+
const span = ordered.length > 1 ? ordered[ordered.length - 1] - ordered[0] & 65535 : 0;
|
|
18539
|
+
const durationMs = (lastStamp ?? 0) - (firstStamp ?? 0);
|
|
18540
|
+
return {
|
|
18541
|
+
video: Buffer.concat(video),
|
|
18542
|
+
...audio.length ? { audio: Buffer.concat(audio) } : {},
|
|
18543
|
+
frames: ordered.length,
|
|
18544
|
+
missingFrames,
|
|
18545
|
+
durationMs,
|
|
18546
|
+
fps: durationMs > 0 && span > 0 ? Math.round(span * 1e5 / durationMs) / 100 : 0
|
|
18547
|
+
};
|
|
18548
|
+
}
|
|
18549
|
+
|
|
18239
18550
|
// dist/transport/p2p/shared-live-source.js
|
|
18240
18551
|
import { EventEmitter as EventEmitter4 } from "node:events";
|
|
18241
18552
|
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
@@ -19358,89 +19669,6 @@ function openReadableFromConsumer(consumer, opts = {}) {
|
|
|
19358
19669
|
// dist/transport/p2p/talkback.js
|
|
19359
19670
|
import { EventEmitter as EventEmitter5 } from "node:events";
|
|
19360
19671
|
import { Writable } from "node:stream";
|
|
19361
|
-
|
|
19362
|
-
// dist/transport/p2p/adts.js
|
|
19363
|
-
var PROFILE_AAC_LC = 1;
|
|
19364
|
-
var FREQ_INDEX_16K = 8;
|
|
19365
|
-
var CHANNELS_MONO = 1;
|
|
19366
|
-
var AAC_SAMPLES_PER_FRAME = 1024;
|
|
19367
|
-
var AAC_SAMPLE_RATE = 16e3;
|
|
19368
|
-
var AAC_FRAME_MS = AAC_SAMPLES_PER_FRAME * 1e3 / AAC_SAMPLE_RATE;
|
|
19369
|
-
var MAX_AUDIO_FRAME_BYTES = 640;
|
|
19370
|
-
var HEADER_LEN_NO_CRC = 7;
|
|
19371
|
-
function parseAdtsHeader(buf, offset = 0) {
|
|
19372
|
-
if (offset + HEADER_LEN_NO_CRC > buf.length)
|
|
19373
|
-
return void 0;
|
|
19374
|
-
if (buf[offset] !== 255 || (buf[offset + 1] & 240) !== 240)
|
|
19375
|
-
return void 0;
|
|
19376
|
-
const headerLength = buf[offset + 1] & 1 ? 7 : 9;
|
|
19377
|
-
const frameLength = (buf[offset + 3] & 3) << 11 | buf[offset + 4] << 3 | (buf[offset + 5] & 224) >> 5;
|
|
19378
|
-
if (frameLength < headerLength)
|
|
19379
|
-
return void 0;
|
|
19380
|
-
return {
|
|
19381
|
-
frameLength,
|
|
19382
|
-
headerLength,
|
|
19383
|
-
profile: (buf[offset + 2] & 192) >> 6,
|
|
19384
|
-
frequencyIndex: (buf[offset + 2] & 60) >> 2,
|
|
19385
|
-
channels: (buf[offset + 2] & 1) << 2 | (buf[offset + 3] & 192) >> 6
|
|
19386
|
-
};
|
|
19387
|
-
}
|
|
19388
|
-
function isSupportedAdts(h) {
|
|
19389
|
-
return h.profile === PROFILE_AAC_LC && h.frequencyIndex === FREQ_INDEX_16K && h.channels === CHANNELS_MONO;
|
|
19390
|
-
}
|
|
19391
|
-
function describeAdts(h) {
|
|
19392
|
-
const rate = ADTS_SAMPLE_RATES[h.frequencyIndex] ?? `index ${h.frequencyIndex}`;
|
|
19393
|
-
const profile = h.profile === PROFILE_AAC_LC ? "AAC-LC" : `profile field ${h.profile}`;
|
|
19394
|
-
return `${profile}, ${rate} Hz, ${h.channels} channel(s)`;
|
|
19395
|
-
}
|
|
19396
|
-
var ADTS_SAMPLE_RATES = [
|
|
19397
|
-
96e3,
|
|
19398
|
-
88200,
|
|
19399
|
-
64e3,
|
|
19400
|
-
48e3,
|
|
19401
|
-
44100,
|
|
19402
|
-
32e3,
|
|
19403
|
-
24e3,
|
|
19404
|
-
22050,
|
|
19405
|
-
16e3,
|
|
19406
|
-
12e3,
|
|
19407
|
-
11025,
|
|
19408
|
-
8e3,
|
|
19409
|
-
7350
|
|
19410
|
-
];
|
|
19411
|
-
var AdtsFrameReader = class {
|
|
19412
|
-
buffered = Buffer.alloc(0);
|
|
19413
|
-
/**
|
|
19414
|
-
* Append `chunk` and return every complete frame now available, each a standalone buffer that still
|
|
19415
|
-
* carries its own ADTS header (the device expects the header on the wire).
|
|
19416
|
-
*/
|
|
19417
|
-
push(chunk) {
|
|
19418
|
-
this.buffered = this.buffered.length ? Buffer.concat([this.buffered, chunk]) : chunk;
|
|
19419
|
-
const frames = [];
|
|
19420
|
-
let at = 0;
|
|
19421
|
-
for (; ; ) {
|
|
19422
|
-
const h = parseAdtsHeader(this.buffered, at);
|
|
19423
|
-
if (!h) {
|
|
19424
|
-
if (at + HEADER_LEN_NO_CRC > this.buffered.length)
|
|
19425
|
-
break;
|
|
19426
|
-
at++;
|
|
19427
|
-
continue;
|
|
19428
|
-
}
|
|
19429
|
-
if (at + h.frameLength > this.buffered.length)
|
|
19430
|
-
break;
|
|
19431
|
-
frames.push(this.buffered.subarray(at, at + h.frameLength));
|
|
19432
|
-
at += h.frameLength;
|
|
19433
|
-
}
|
|
19434
|
-
this.buffered = at ? this.buffered.subarray(at) : this.buffered;
|
|
19435
|
-
return frames;
|
|
19436
|
-
}
|
|
19437
|
-
/** Bytes held back awaiting the rest of their frame — non-zero mid-stream, zero on a clean boundary. */
|
|
19438
|
-
get pending() {
|
|
19439
|
-
return this.buffered.length;
|
|
19440
|
-
}
|
|
19441
|
-
};
|
|
19442
|
-
|
|
19443
|
-
// dist/transport/p2p/talkback.js
|
|
19444
19672
|
var DEFAULT_HIGH_WATER_FRAMES = Math.ceil(2e3 / AAC_FRAME_MS);
|
|
19445
19673
|
var DEFAULT_IDLE_TIMEOUT_MS = 3e4;
|
|
19446
19674
|
var Talkback = class extends EventEmitter5 {
|
|
@@ -20416,6 +20644,8 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
20416
20644
|
talkbacks = /* @__PURE__ */ new Map();
|
|
20417
20645
|
/** cipher_id → ECC private key (one eufylife get_ciphers call per cipher), shared across (re)opens. */
|
|
20418
20646
|
cipherKeyCache = /* @__PURE__ */ new Map();
|
|
20647
|
+
/** The recording download in flight per station; a station serves one at a time. */
|
|
20648
|
+
recordingDownloads = /* @__PURE__ */ new Map();
|
|
20419
20649
|
constructor(deps) {
|
|
20420
20650
|
this.deps = deps;
|
|
20421
20651
|
this.manager = new SessionManager({
|
|
@@ -20628,28 +20858,7 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
20628
20858
|
localAddress,
|
|
20629
20859
|
dskKey,
|
|
20630
20860
|
noBroadcast: this.deps.noBroadcast,
|
|
20631
|
-
resolveCipherKey:
|
|
20632
|
-
if (this.cipherKeyCache.has(cipherId))
|
|
20633
|
-
return this.cipherKeyCache.get(cipherId);
|
|
20634
|
-
let ecc;
|
|
20635
|
-
try {
|
|
20636
|
-
const ciphers = await this.deps.mega.getCiphers([cipherId], adminUserId, stationSn);
|
|
20637
|
-
ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
|
|
20638
|
-
if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
|
|
20639
|
-
ecc = ciphers[0].ecc_private_key;
|
|
20640
|
-
this.traceOnStation(session, {
|
|
20641
|
-
phase: "cipher-fallback",
|
|
20642
|
-
cipherId,
|
|
20643
|
-
answeredCipherId: Number(ciphers[0].cipher_id)
|
|
20644
|
-
});
|
|
20645
|
-
}
|
|
20646
|
-
} catch (e) {
|
|
20647
|
-
this.deps.onError(e instanceof Error ? e : new Error(String(e)));
|
|
20648
|
-
}
|
|
20649
|
-
if (ecc !== void 0)
|
|
20650
|
-
this.cipherKeyCache.set(cipherId, ecc);
|
|
20651
|
-
return ecc;
|
|
20652
|
-
},
|
|
20861
|
+
resolveCipherKey: (cipherId) => this.cipherKeyFor(session, cipherId, adminUserId, stationSn),
|
|
20653
20862
|
logger: this.deps.logger ?? noopLogger
|
|
20654
20863
|
});
|
|
20655
20864
|
session.on("error", (e) => this.reportError(e));
|
|
@@ -20673,6 +20882,33 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
20673
20882
|
session.on("data", (f) => this.deps.onFrame(stationSn, f));
|
|
20674
20883
|
return session;
|
|
20675
20884
|
}
|
|
20885
|
+
/**
|
|
20886
|
+
* The ECC private key of `cipherId`, from the router's cache or one `get_ciphers` call. When the cloud
|
|
20887
|
+
* answers with another cipher, that one is used and traced on `session`. Only a successful lookup is
|
|
20888
|
+
* cached.
|
|
20889
|
+
*/
|
|
20890
|
+
async cipherKeyFor(session, cipherId, userId, stationSn) {
|
|
20891
|
+
if (this.cipherKeyCache.has(cipherId))
|
|
20892
|
+
return this.cipherKeyCache.get(cipherId);
|
|
20893
|
+
let ecc;
|
|
20894
|
+
try {
|
|
20895
|
+
const ciphers = await this.deps.mega.getCiphers([cipherId], userId, stationSn);
|
|
20896
|
+
ecc = ciphers.find((c) => Number(c.cipher_id) === cipherId)?.ecc_private_key;
|
|
20897
|
+
if (ecc === void 0 && ciphers[0]?.ecc_private_key !== void 0) {
|
|
20898
|
+
ecc = ciphers[0].ecc_private_key;
|
|
20899
|
+
this.traceOnStation(session, {
|
|
20900
|
+
phase: "cipher-fallback",
|
|
20901
|
+
cipherId,
|
|
20902
|
+
answeredCipherId: Number(ciphers[0].cipher_id)
|
|
20903
|
+
});
|
|
20904
|
+
}
|
|
20905
|
+
} catch (e) {
|
|
20906
|
+
this.deps.onError(e instanceof Error ? e : new Error(String(e)));
|
|
20907
|
+
}
|
|
20908
|
+
if (ecc !== void 0)
|
|
20909
|
+
this.cipherKeyCache.set(cipherId, ecc);
|
|
20910
|
+
return ecc;
|
|
20911
|
+
}
|
|
20676
20912
|
/**
|
|
20677
20913
|
* Open (or reuse) a station's P2P session and await its completed handshake. An optional abort only
|
|
20678
20914
|
* stops this wait; session ownership remains with {@link SessionManager} and its normal teardown.
|
|
@@ -20800,7 +21036,9 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
20800
21036
|
* start has no level-1 form for.
|
|
20801
21037
|
*/
|
|
20802
21038
|
mediaProviderFor(sn) {
|
|
21039
|
+
const recordings = this.downloadsRecordings(sn) ? { downloadRecording: (opts) => this.downloadRecording(sn, opts) } : {};
|
|
20803
21040
|
return {
|
|
21041
|
+
...recordings,
|
|
20804
21042
|
snapshotLive: async (opts) => {
|
|
20805
21043
|
const source = await this.sharedLiveSourceFor(sn, opts ?? {});
|
|
20806
21044
|
return captureSnapshotFromShared(source, {
|
|
@@ -20839,6 +21077,63 @@ var P2PCommandRouter = class _P2PCommandRouter {
|
|
|
20839
21077
|
}
|
|
20840
21078
|
};
|
|
20841
21079
|
}
|
|
21080
|
+
/**
|
|
21081
|
+
* Whether `sn` is a camera attached to a HomeBase 2 (T8010), the one station whose recording path layout
|
|
21082
|
+
* and frame formats are confirmed.
|
|
21083
|
+
*/
|
|
21084
|
+
downloadsRecordings(sn) {
|
|
21085
|
+
const dev = this.recordFor(sn);
|
|
21086
|
+
if (!dev)
|
|
21087
|
+
return false;
|
|
21088
|
+
const parentSn = stationOf(dev);
|
|
21089
|
+
return parentSn !== sn && this.recordFor(parentSn)?.model === "T8010";
|
|
21090
|
+
}
|
|
21091
|
+
/**
|
|
21092
|
+
* Download one recording a HomeBase 2 holds for an attached camera, then decode it
|
|
21093
|
+
* ({@link decodeRecording}). Downloads queue per station: the station streams one recording at a time
|
|
21094
|
+
* on the camera's channel, so a download starts only once the previous one has drained
|
|
21095
|
+
* ({@link RecordingTransfer.drained}), even when that one's caller already has its answer. An abort
|
|
21096
|
+
* while waiting rejects without sending anything; an abort mid-transfer rejects at once and the station's
|
|
21097
|
+
* turn is held until the transfer drains.
|
|
21098
|
+
*/
|
|
21099
|
+
async downloadRecording(sn, opts) {
|
|
21100
|
+
opts.signal?.throwIfAborted();
|
|
21101
|
+
const parentSn = this.stationKeyOf(sn);
|
|
21102
|
+
const previous = this.recordingDownloads.get(parentSn) ?? Promise.resolve();
|
|
21103
|
+
const started = this.waitTurn(previous, opts.signal).then(() => this.startRecordingDownload(sn, opts));
|
|
21104
|
+
const turn = Promise.allSettled([previous, started.then((t) => t.drained)]).then(() => {
|
|
21105
|
+
if (this.recordingDownloads.get(parentSn) === turn)
|
|
21106
|
+
this.recordingDownloads.delete(parentSn);
|
|
21107
|
+
});
|
|
21108
|
+
this.recordingDownloads.set(parentSn, turn);
|
|
21109
|
+
return started.then((t) => t.recording);
|
|
21110
|
+
}
|
|
21111
|
+
/** Resolve once `previous` settles, or reject as soon as `signal` aborts. */
|
|
21112
|
+
waitTurn(previous, signal) {
|
|
21113
|
+
return abortable(previous.then(() => void 0, () => void 0), signal);
|
|
21114
|
+
}
|
|
21115
|
+
/**
|
|
21116
|
+
* Start one recording download, once the station's previous download has drained: the decoded recording,
|
|
21117
|
+
* and when the station is done sending it.
|
|
21118
|
+
*/
|
|
21119
|
+
async startRecordingDownload(sn, opts) {
|
|
21120
|
+
const { session, parentSn, channel, accountId } = await this.resolveSession(sn, { signal: opts.signal });
|
|
21121
|
+
const path = homeBase2RecordingPath(channel, opts.recording);
|
|
21122
|
+
if (!path)
|
|
21123
|
+
throw new RecordingDownloadError("invalid-recording", `no recording named ${opts.recording}`);
|
|
21124
|
+
const eccKey = await this.cipherKeyFor(session, opts.cipherId, accountId, parentSn);
|
|
21125
|
+
if (!eccKey) {
|
|
21126
|
+
throw new RecordingDownloadError("key-unavailable", `cipher ${opts.cipherId} could not be obtained`);
|
|
21127
|
+
}
|
|
21128
|
+
const transfer = receiveRecording(session, {
|
|
21129
|
+
path,
|
|
21130
|
+
accountId,
|
|
21131
|
+
channel,
|
|
21132
|
+
timeoutMs: opts.timeoutMs,
|
|
21133
|
+
signal: opts.signal
|
|
21134
|
+
});
|
|
21135
|
+
return { recording: transfer.frames.then((frames) => decodeRecording(frames, eccKey)), drained: transfer.drained };
|
|
21136
|
+
}
|
|
20842
21137
|
/**
|
|
20843
21138
|
* Open a {@link Talkback} on a device's camera channel.
|
|
20844
21139
|
*
|
|
@@ -28189,6 +28484,7 @@ export {
|
|
|
28189
28484
|
ROOM_SUCTIONS,
|
|
28190
28485
|
RTSP_MEMBERS,
|
|
28191
28486
|
RawDpWriter,
|
|
28487
|
+
RecordingDownloadError,
|
|
28192
28488
|
RecordingQuality,
|
|
28193
28489
|
RtspRecordingMode,
|
|
28194
28490
|
SCENE_INVALID_REASONS,
|
|
@@ -28232,6 +28528,7 @@ export {
|
|
|
28232
28528
|
VACUUM_DOCK_MEMBERS,
|
|
28233
28529
|
VACUUM_SCHEDULE_ACTIONS,
|
|
28234
28530
|
VACUUM_SCHEDULE_WEEKDAYS,
|
|
28531
|
+
VIDEO_GCM_AAD,
|
|
28235
28532
|
VacuumMapStore,
|
|
28236
28533
|
VideoFrameDecoder,
|
|
28237
28534
|
WORKING_MODE_MAPS,
|