scrypted-kasa 1.3.1 → 1.3.3

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.
@@ -1,186 +0,0 @@
1
- import crypto from 'crypto';
2
- import { ClientRequest } from 'http';
3
- import https from 'https';
4
- import net from 'net';
5
- import { PassThrough } from 'stream';
6
-
7
- export const KASA_TALK_PORT = 18443;
8
- const KASA_TALK_PATH = '/https/speaker/audio/g711block';
9
- const TALK_BOUNDARY = 'audio-boundary--';
10
-
11
- // Talk-channel auth uses md5(plaintext) as the password rather than the base64(plaintext)
12
- // the receive stream uses. Same camera, different scheme — observed on KC420WS firmware
13
- // 2.3.26 via mitmproxy capture of the official Kasa iOS app.
14
- function md5Hex(plaintext: string): string {
15
- return crypto.createHash('md5').update(plaintext, 'utf8').digest('hex');
16
- }
17
-
18
- // The Kasa app sends X-APP-ID/playerId of the form "tty.<32 base64-ish chars>". The token
19
- // looks app-generated (not negotiated with the camera), so we mint our own per-session.
20
- function generatePlayerId(): string {
21
- return 'tty.' + crypto.randomBytes(24).toString('base64').replace(/[+/=]/g, '').slice(0, 32);
22
- }
23
-
24
- export interface KasaTalkOptions {
25
- ip: string;
26
- port?: number;
27
- username: string;
28
- password: string;
29
- console?: Console;
30
- }
31
-
32
- // One ongoing POST per talk session. Multipart parts wrap raw G.711 µ-law audio blocks
33
- // (Content-Type: audio/g711u) interleaved with empty audio/heartbeat parts that the app
34
- // uses to keep the connection alive during silence (~one every 3 s). The body is chunked
35
- // transfer encoded; the request never completes until the session is closed.
36
- export class KasaTalkSession {
37
- private request?: ClientRequest;
38
- private body = new PassThrough();
39
- private playerId = '';
40
- private heartbeatTimer?: NodeJS.Timeout;
41
- private closed = false;
42
- private closeCallbacks?: (() => void)[] = [];
43
- // Resolves once the session is torn down (by the caller, by the camera ending the
44
- // response, or by an error). Lets the camera's startIntercom await teardown to clean
45
- // up its ffmpeg process when the uplink dies on its own.
46
- readonly closedPromise: Promise<void>;
47
- private resolveClosed!: () => void;
48
-
49
- constructor(public options: KasaTalkOptions) {
50
- this.closedPromise = new Promise<void>(resolve => {
51
- this.resolveClosed = resolve;
52
- });
53
- }
54
-
55
- async start(): Promise<void> {
56
- this.playerId = generatePlayerId();
57
- const port = this.options.port || KASA_TALK_PORT;
58
- const auth =
59
- 'Basic ' + Buffer.from(`${this.options.username}:${md5Hex(this.options.password)}`).toString('base64');
60
-
61
- const search = new URLSearchParams({ playerId: this.playerId });
62
-
63
- const req = https.request({
64
- host: this.options.ip,
65
- port,
66
- method: 'POST',
67
- path: `${KASA_TALK_PATH}?${search.toString()}`,
68
- rejectUnauthorized: false,
69
- headers: {
70
- Authorization: auth,
71
- 'X-APP-ID': this.playerId,
72
- 'Content-Type': `multipart/x-mixed-replace;boundary=${TALK_BOUNDARY}`,
73
- 'Transfer-Encoding': 'chunked',
74
- Connection: 'keep-alive',
75
- Accept: '*/*',
76
- },
77
- });
78
-
79
- this.request = req;
80
-
81
- // Disable Nagle on the underlying TCP socket as soon as it's available. Each 20 ms
82
- // audio part we write is small (~230 B) and fewer than the segment size, so Nagle
83
- // would coalesce parts waiting for an ACK or for the 200 ms deadline — adding
84
- // user-perceptible chunkiness on top of the inherent network round-trip. The talk
85
- // session is a constant low-rate stream, exactly the workload Nagle hurts.
86
- //
87
- // Cover both timings: the default https.Agent pools connections and may assign a
88
- // pooled socket synchronously inside https.request(), in which case `'socket'`
89
- // already fired by the time we get here and our once-listener would never run.
90
- // Check `req.socket` immediately, and otherwise register a once-listener for the
91
- // fresh-connection path.
92
- const setSocketNoDelay = (socket: net.Socket) => socket.setNoDelay(true);
93
- if (req.socket) setSocketNoDelay(req.socket as net.Socket);
94
- else req.once('socket', setSocketNoDelay);
95
-
96
- req.on('error', e => {
97
- // Suppress when the close came first (deliberate teardown via close()): ending
98
- // the body mid-flight makes the in-flight HTTPS request emit "socket hang up"
99
- // one tick later, which isn't a real error.
100
- if (!this.closed) this.options.console?.warn('kasa talk request error', e.message);
101
- this.close();
102
- });
103
-
104
- req.on('response', res => {
105
- this.options.console?.log(`kasa talk response: ${res.statusCode}`);
106
- res.on('data', () => {
107
- /* drain */
108
- });
109
- res.on('end', () => this.close());
110
- });
111
-
112
- // Body is a PassThrough so we can write parts incrementally; pipe it into the request.
113
- this.body.pipe(req);
114
-
115
- // Heartbeat immediately + every 3 s, mirroring the Kasa app's idle pattern.
116
- this.writePart('audio/heartbeat', Buffer.alloc(0));
117
- this.heartbeatTimer = setInterval(() => {
118
- this.writePart('audio/heartbeat', Buffer.alloc(0));
119
- }, 3000);
120
- }
121
-
122
- // Returns false when the underlying stream is asking for backpressure (Node's standard
123
- // contract). Caller should pause its source until `drain` fires on `body`. We surface
124
- // this so an unhealthy/slow camera socket can't grow the PassThrough buffer without bound.
125
- writeAudio(chunk: Buffer): boolean {
126
- if (!chunk.length) return true;
127
- return this.writePart('audio/g711u', chunk);
128
- }
129
-
130
- onDrain(cb: () => void): void {
131
- this.body.once('drain', cb);
132
- }
133
-
134
- // Subscribe to the session's teardown event. If the session is already closed, the
135
- // callback is invoked on the next microtask so callers don't have to special-case timing.
136
- onClose(cb: () => void): void {
137
- if (this.closed) {
138
- queueMicrotask(cb);
139
- return;
140
- }
141
- this.closeCallbacks!.push(cb);
142
- }
143
-
144
- private writePart(contentType: string, body: Buffer): boolean {
145
- if (this.closed) return true;
146
- const header = Buffer.from(
147
- `--${TALK_BOUNDARY}\r\nContent-Length: ${body.length}\r\nContent-Type: ${contentType}\r\n\r\n`,
148
- );
149
- // Concat header + body + CRLF into one write. Three separate writes would each
150
- // become its own chunked-encoding chunk on the HTTP body and (worse) its own TLS
151
- // record + TCP segment. Cameras and Node's HTTP/TLS stack both prefer one part
152
- // arriving as one segment — fewer per-packet ACKs and no risk of the camera
153
- // partially parsing a part header before the body lands.
154
- const trailer = Buffer.from('\r\n');
155
- const out = body.length
156
- ? Buffer.concat([header, body, trailer], header.length + body.length + trailer.length)
157
- : Buffer.concat([header, trailer], header.length + trailer.length);
158
- return this.body.write(out);
159
- }
160
-
161
- close(): void {
162
- if (this.closed) return;
163
- this.closed = true;
164
- clearInterval(this.heartbeatTimer);
165
- try {
166
- this.body.end();
167
- } catch {}
168
- try {
169
- this.request?.end();
170
- } catch {}
171
- const cbs = this.closeCallbacks;
172
- // Drop the array reference before invoking callbacks so V8 can reclaim it; any
173
- // late onClose() registration after this point will be served via queueMicrotask.
174
- this.closeCallbacks = undefined;
175
- if (cbs) {
176
- for (const cb of cbs) {
177
- try {
178
- cb();
179
- } catch (e) {
180
- this.options.console?.warn('kasa talk onClose handler threw', e);
181
- }
182
- }
183
- }
184
- this.resolveClosed();
185
- }
186
- }
@@ -1,185 +0,0 @@
1
- import crypto from 'crypto';
2
- import https from 'https';
3
- import { xorDecryptInPlace, xorEncryptInPlace } from '../shared/cipher';
4
-
5
- export const KASA_LINKIE_PORT = 10443;
6
- const KASA_LINKIE_PATH = '/data/LINKIE2.json';
7
- const KASA_LINKIE_TIMEOUT_MS = 5000;
8
-
9
- // Same auth quirk as the talk channel: the camera wants md5_hex(plaintext) as the password.
10
- // (The receive endpoint takes base64(plaintext) instead — same camera, three different
11
- // places, three subtly different conventions.)
12
- function md5Hex(plaintext: string): string {
13
- return crypto.createHash('md5').update(plaintext, 'utf8').digest('hex');
14
- }
15
-
16
- export interface KasaLinkieOptions {
17
- ip: string;
18
- port?: number;
19
- username: string;
20
- password: string;
21
- }
22
-
23
- export interface KasaLinkieClientLogger {
24
- warn?: Console['warn'];
25
- }
26
-
27
- // Speaks the Kasa "LINKIE2" control protocol used by the iOS app for everything that isn't
28
- // streaming or talk: spotlight, siren, mic config, SD card status, etc. Wire format:
29
- // - HTTPS POST to https://<ip>:10443/data/LINKIE2.json
30
- // - Body: application/x-www-form-urlencoded `content=<base64(xor_ab(json))>`
31
- // - Each request body adds a `context.source` UUID — the camera ignores it but the field
32
- // is always present in captures, so we generate one per call.
33
- // - Response: same encoding, with the result merged under the same module/method keys.
34
- export class KasaLinkieClient {
35
- constructor(
36
- public options: KasaLinkieOptions,
37
- public logger?: KasaLinkieClientLogger,
38
- ) {}
39
-
40
- async call(command: Record<string, any>): Promise<any> {
41
- const body = {
42
- ...command,
43
- context: { source: crypto.randomUUID() },
44
- };
45
- const json = JSON.stringify(body);
46
- // Encrypt in-place: the Buffer.from we just allocated isn't shared anywhere else.
47
- const encrypted = xorEncryptInPlace(Buffer.from(json, 'utf8'));
48
- const formBody = Buffer.from(`content=${encodeURIComponent(encrypted.toString('base64'))}`);
49
-
50
- // Send Basic auth pre-emptively. The camera does not respond at all to unauthenticated
51
- // requests (no 401, no anything), so the standard "wait for challenge then retry"
52
- // pattern would just hang. Pre-emptive auth matches what curl with `-u` does.
53
- const auth =
54
- 'Basic ' + Buffer.from(`${this.options.username}:${md5Hex(this.options.password)}`).toString('base64');
55
-
56
- const respBuffer = await new Promise<Buffer>((resolve, reject) => {
57
- const req = https.request(
58
- {
59
- host: this.options.ip,
60
- port: this.options.port || KASA_LINKIE_PORT,
61
- method: 'POST',
62
- path: KASA_LINKIE_PATH,
63
- rejectUnauthorized: false,
64
- // Mirror the Kasa iOS app's headers — the camera gates /data/LINKIE2.json on the
65
- // User-Agent (silently drops requests that don't look like the Kasa app).
66
- headers: {
67
- Authorization: auth,
68
- 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
69
- 'Content-Length': formBody.length,
70
- 'User-Agent': 'Kasa/1752 CFNetwork/3860.500.112 Darwin/25.4.0',
71
- Accept: '*/*',
72
- 'Accept-Language': 'en-US,en;q=0.9',
73
- // We don't decompress responses (camera never sends compressed today, but
74
- // advertising 'gzip' would let a future firmware break our base64+XOR decode).
75
- 'Accept-Encoding': 'identity',
76
- Connection: 'keep-alive',
77
- },
78
- },
79
- res => {
80
- if (res.statusCode && res.statusCode >= 400) {
81
- res.resume();
82
- reject(new Error(`kasa linkie HTTP ${res.statusCode}`));
83
- return;
84
- }
85
- const chunks: Buffer[] = [];
86
- res.on('data', c => chunks.push(c));
87
- res.on('end', () => resolve(Buffer.concat(chunks)));
88
- res.on('error', reject);
89
- },
90
- );
91
- req.setTimeout(KASA_LINKIE_TIMEOUT_MS, () => {
92
- req.destroy(new Error(`kasa linkie request timeout after ${KASA_LINKIE_TIMEOUT_MS}ms`));
93
- });
94
- req.on('error', reject);
95
- req.write(formBody);
96
- req.end();
97
- });
98
-
99
- const respB64 = respBuffer.toString('utf8').trim();
100
- // Same here: the base64-decoded buffer is freshly ours, decrypt in-place.
101
- const respDecrypted = xorDecryptInPlace(Buffer.from(respB64, 'base64'));
102
- return JSON.parse(respDecrypted.toString('utf8'));
103
- }
104
-
105
- // Spotlight controls under smartlife.cam.ipcamera.dayNight (called "force_lamp" at the
106
- // protocol level, "spotlight" in the Kasa app UI).
107
- // Returns 'on' / 'off' for cameras that support it, undefined for cameras that don't
108
- // (request fails or the module is missing from the response).
109
-
110
- async getForceLampState(): Promise<'on' | 'off' | undefined> {
111
- try {
112
- const r = await this.call({
113
- 'smartlife.cam.ipcamera.dayNight': { get_force_lamp_state: {} },
114
- });
115
- const v = r?.['smartlife.cam.ipcamera.dayNight']?.get_force_lamp_state?.value;
116
- return v === 'on' || v === 'off' ? v : undefined;
117
- } catch (e) {
118
- this.logger?.warn?.('kasa linkie get_force_lamp_state failed:', (e as Error).message);
119
- return undefined;
120
- }
121
- }
122
-
123
- async setForceLampState(on: boolean): Promise<void> {
124
- const r = await this.call({
125
- 'smartlife.cam.ipcamera.dayNight': {
126
- set_force_lamp_state: { value: on ? 'on' : 'off' },
127
- },
128
- });
129
- const errCode = r?.['smartlife.cam.ipcamera.dayNight']?.set_force_lamp_state?.err_code;
130
- if (errCode !== 0) throw new Error(`set_force_lamp_state err_code=${errCode}`);
131
- }
132
-
133
- // Status LED on the camera body, controlled via smartlife.cam.ipcamera.led. Used to
134
- // back HomeKit's CameraOperatingModeIndicator characteristic via OnOff on the camera.
135
-
136
- async getLedStatus(): Promise<'on' | 'off' | undefined> {
137
- try {
138
- const r = await this.call({
139
- 'smartlife.cam.ipcamera.led': { get_status: {} },
140
- });
141
- const v = r?.['smartlife.cam.ipcamera.led']?.get_status?.value;
142
- return v === 'on' || v === 'off' ? v : undefined;
143
- } catch (e) {
144
- this.logger?.warn?.('kasa linkie get_status (led) failed:', (e as Error).message);
145
- return undefined;
146
- }
147
- }
148
-
149
- async setLedStatus(on: boolean): Promise<void> {
150
- const r = await this.call({
151
- 'smartlife.cam.ipcamera.led': {
152
- set_status: { value: on ? 'on' : 'off' },
153
- },
154
- });
155
- const errCode = r?.['smartlife.cam.ipcamera.led']?.set_status?.err_code;
156
- if (errCode !== 0) throw new Error(`led set_status err_code=${errCode}`);
157
- }
158
-
159
- // Siren under smartlife.cam.ipcamera.siren. The camera auto-stops after the duration
160
- // configured in the Kasa app (default 30 s); we just send on/off and let the camera
161
- // handle the timer.
162
-
163
- async getSirenState(): Promise<'on' | 'off' | undefined> {
164
- try {
165
- const r = await this.call({
166
- 'smartlife.cam.ipcamera.siren': { get_state: {} },
167
- });
168
- const v = r?.['smartlife.cam.ipcamera.siren']?.get_state?.value;
169
- return v === 'on' || v === 'off' ? v : undefined;
170
- } catch (e) {
171
- this.logger?.warn?.('kasa linkie get_state (siren) failed:', (e as Error).message);
172
- return undefined;
173
- }
174
- }
175
-
176
- async setSirenState(on: boolean): Promise<void> {
177
- const r = await this.call({
178
- 'smartlife.cam.ipcamera.siren': {
179
- set_state: { value: on ? 'on' : 'off' },
180
- },
181
- });
182
- const errCode = r?.['smartlife.cam.ipcamera.siren']?.set_state?.err_code;
183
- if (errCode !== 0) throw new Error(`siren set_state err_code=${errCode}`);
184
- }
185
- }
package/src/camera/rtp.ts DELETED
@@ -1,238 +0,0 @@
1
- import { randomBytes } from 'crypto';
2
-
3
- // Native RTP packetization for H.264 (RFC 6184) and G.711 µ-law (RFC 3551). Replaces
4
- // the ffmpeg subprocess we used purely for codec-copy RTP packetization — the camera
5
- // already gives us H.264 + G.711, so spawning ffmpeg per stream just to wrap the bytes
6
- // in RTP headers was waste (one process, two pipes, two UDP socket pairs per camera).
7
- // This module produces the same wire format consumers already speak.
8
-
9
- const RTP_VERSION = 2;
10
- const RTP_HEADER_LEN = 12;
11
- export const PT_H264 = 96;
12
- export const PT_PCMU = 0;
13
- export const H264_CLOCK_HZ = 90000;
14
- export const G711_CLOCK_HZ = 8000;
15
-
16
- // RTCP Sender Report: 28 bytes, no report blocks (RC=0). RFC 3550 § 6.4.1.
17
- const RTCP_SR_LEN = 28;
18
- const RTCP_PT_SR = 200;
19
- // NTP epoch (1900-01-01) precedes Unix epoch (1970-01-01) by this many seconds.
20
- const NTP_UNIX_EPOCH_OFFSET_S = 2208988800;
21
-
22
- export interface NtpTime {
23
- seconds: number;
24
- fraction: number;
25
- }
26
-
27
- // Wall-clock as an NTP 64-bit fixed-point timestamp (32-bit seconds since 1900,
28
- // 32-bit fraction-of-second). Used in RTCP Sender Reports so receivers can map
29
- // each track's RTP timeline back to a common wall-clock — required for A/V sync
30
- // across two RTP streams that have independent random initial timestamps.
31
- export function nowNtp(): NtpTime {
32
- const ms = Date.now();
33
- const seconds = Math.floor(ms / 1000) + NTP_UNIX_EPOCH_OFFSET_S;
34
- // 2^32 / 1000 = 4294967.296 — multiply ms-fraction by that to get the 32-bit
35
- // fraction word. Math.floor (not round) so the result is always strictly less
36
- // than 2^32; otherwise an unlucky float rounding to exactly 2^32 would wrap to
37
- // 0 via `>>> 0` without carrying into `seconds`.
38
- const fraction = Math.floor(((ms % 1000) / 1000) * 0x1_0000_0000);
39
- return { seconds: seconds >>> 0, fraction: fraction >>> 0 };
40
- }
41
-
42
- function writeSenderReport(
43
- ssrc: number,
44
- ntp: NtpTime,
45
- rtpTimestamp: number,
46
- packetCount: number,
47
- octetCount: number,
48
- ): Buffer {
49
- const buf = Buffer.allocUnsafe(RTCP_SR_LEN);
50
- // V=2, P=0, RC=0 → 0b10_0_00000 = 0x80
51
- buf[0] = RTP_VERSION << 6;
52
- buf[1] = RTCP_PT_SR;
53
- // Length in 32-bit words minus 1 → (28/4) - 1 = 6.
54
- buf.writeUInt16BE(6, 2);
55
- buf.writeUInt32BE(ssrc >>> 0, 4);
56
- buf.writeUInt32BE(ntp.seconds >>> 0, 8);
57
- buf.writeUInt32BE(ntp.fraction >>> 0, 12);
58
- buf.writeUInt32BE(rtpTimestamp >>> 0, 16);
59
- buf.writeUInt32BE(packetCount >>> 0, 20);
60
- buf.writeUInt32BE(octetCount >>> 0, 24);
61
- return buf;
62
- }
63
-
64
- // FU-A fragmentation cutoff. We serve via RTSP/TCP-interleaved (no MTU constraint),
65
- // but H.264 RTP receivers expect large NAL units to come as FU-A — fragmenting matches
66
- // the wire shape they would see from any standard RTSP server. 1400 keeps headroom
67
- // under a 1500-byte Ethernet MTU even though we never hit a real link.
68
- const RTP_PAYLOAD_MTU = 1400;
69
- // FU-A header overhead inside the RTP payload: 1 byte FU indicator + 1 byte FU header.
70
- const FU_A_HEADER_OVERHEAD = 2;
71
-
72
- // G.711 µ-law: 8 kHz mono, 1 byte per sample. 20 ms = 160 samples = 160 bytes per RTP
73
- // packet, the standard sizing in RFC 3551.
74
- const G711_SAMPLES_PER_PACKET = 160;
75
-
76
- function writeRtpHeader(
77
- buf: Buffer,
78
- payloadType: number,
79
- seq: number,
80
- timestamp: number,
81
- ssrc: number,
82
- marker: boolean,
83
- ): void {
84
- buf[0] = RTP_VERSION << 6;
85
- buf[1] = (marker ? 0x80 : 0) | (payloadType & 0x7f);
86
- buf.writeUInt16BE(seq & 0xffff, 2);
87
- buf.writeUInt32BE(timestamp >>> 0, 4);
88
- buf.writeUInt32BE(ssrc >>> 0, 8);
89
- }
90
-
91
- // Yields each NAL unit body (without the start code) found in an annex-b H.264 buffer.
92
- // Handles both 3-byte (00 00 01) and 4-byte (00 00 00 01) start codes.
93
- //
94
- // Single-pass via Buffer.indexOf — each byte is examined at most once, and the inner
95
- // scan is in native code (Boyer–Moore-ish), which matters for large IDR access units
96
- // containing many slice NALs (KC420WS IDRs run 80+ KB across dozens of slices).
97
- const NAL_START_CODE_3 = Buffer.from([0, 0, 1]);
98
- export function* annexbNalUnits(annexb: Buffer): IterableIterator<Buffer> {
99
- const len = annexb.length;
100
- // body offset of the currently-open NAL, or -1 until the first start code is seen.
101
- let nalStart = -1;
102
- let searchFrom = 0;
103
- while (searchFrom < len) {
104
- const hit = annexb.indexOf(NAL_START_CODE_3, searchFrom);
105
- if (hit < 0) break;
106
- // 4-byte form (00 00 00 01) shows up as a 3-byte hit one byte in; treat the
107
- // leading 0 as part of the start code so the NAL we're closing ends just before
108
- // the full 4-byte sequence.
109
- const codeStart = hit > 0 && annexb[hit - 1] === 0 ? hit - 1 : hit;
110
- if (nalStart >= 0 && codeStart > nalStart) {
111
- // Skip empty NALs (back-to-back start codes) defensively — not valid in a
112
- // well-formed bitstream but a malformed part shouldn't poison the stream.
113
- yield annexb.subarray(nalStart, codeStart);
114
- }
115
- nalStart = hit + 3;
116
- searchFrom = nalStart;
117
- }
118
- if (nalStart >= 0 && len > nalStart) yield annexb.subarray(nalStart, len);
119
- }
120
-
121
- export type RtpSink = (packet: Buffer) => void;
122
-
123
- // One packetizer per stream session — owns the seq counter and SSRC. RTP requires both
124
- // to be stable across all packets of a stream, with seq monotonically increasing.
125
- export class H264RtpPacketizer {
126
- private seq: number;
127
- private packetCount = 0;
128
- private octetCount = 0;
129
- private lastTimestamp = 0;
130
- readonly ssrc: number;
131
-
132
- constructor() {
133
- this.seq = randomBytes(2).readUInt16BE(0);
134
- this.ssrc = randomBytes(4).readUInt32BE(0);
135
- }
136
-
137
- // Emits RTP packets for one access unit. All packets share `timestamp` (90 kHz),
138
- // and the marker bit is set on the last packet of the AU per RFC 6184 § 5.1. Caller
139
- // typically passes one annex-b buffer per video part = one AU.
140
- packetize(annexb: Buffer, timestamp: number, sink: RtpSink): void {
141
- const nals: Buffer[] = [];
142
- for (const nal of annexbNalUnits(annexb)) nals.push(nal);
143
- if (nals.length === 0) return;
144
-
145
- for (let i = 0; i < nals.length; i++) {
146
- const nal = nals[i];
147
- const isLastNal = i === nals.length - 1;
148
-
149
- if (nal.length <= RTP_PAYLOAD_MTU) {
150
- const pkt = Buffer.allocUnsafe(RTP_HEADER_LEN + nal.length);
151
- writeRtpHeader(pkt, PT_H264, this.seq++, timestamp, this.ssrc, isLastNal);
152
- nal.copy(pkt, RTP_HEADER_LEN);
153
- sink(pkt);
154
- this.packetCount++;
155
- this.octetCount = (this.octetCount + nal.length) >>> 0;
156
- continue;
157
- }
158
-
159
- // FU-A: split nal[0]'s F+NRI bits into the FU indicator (with type=28), and
160
- // its 5-bit NAL type into the FU header along with start/end markers. The
161
- // payload is nal.subarray(1) — the original NAL header byte is reconstructed
162
- // by the receiver from FU indicator NRI + FU header type.
163
- const nalHeader = nal[0];
164
- const fuIndicator = (nalHeader & 0xe0) | 28;
165
- const fuTypeBits = nalHeader & 0x1f;
166
- const payload = nal.subarray(1);
167
- const chunkSize = RTP_PAYLOAD_MTU - FU_A_HEADER_OVERHEAD;
168
- let offset = 0;
169
- while (offset < payload.length) {
170
- const remaining = payload.length - offset;
171
- const take = Math.min(chunkSize, remaining);
172
- const isFirstFu = offset === 0;
173
- const isLastFu = take === remaining;
174
- const fuHeader = (isFirstFu ? 0x80 : 0) | (isLastFu ? 0x40 : 0) | fuTypeBits;
175
- const pkt = Buffer.allocUnsafe(RTP_HEADER_LEN + FU_A_HEADER_OVERHEAD + take);
176
- writeRtpHeader(pkt, PT_H264, this.seq++, timestamp, this.ssrc, isLastNal && isLastFu);
177
- pkt[RTP_HEADER_LEN] = fuIndicator;
178
- pkt[RTP_HEADER_LEN + 1] = fuHeader;
179
- payload.copy(pkt, RTP_HEADER_LEN + FU_A_HEADER_OVERHEAD, offset, offset + take);
180
- sink(pkt);
181
- this.packetCount++;
182
- this.octetCount = (this.octetCount + FU_A_HEADER_OVERHEAD + take) >>> 0;
183
- offset += take;
184
- }
185
- }
186
- this.lastTimestamp = timestamp >>> 0;
187
- }
188
-
189
- // Build an RTCP Sender Report describing the latest emission. `ntp` should be sampled
190
- // close to the moment of the SR's wire emission so the NTP↔RTP timestamp pair stays
191
- // accurate. Returns undefined when no packets have been emitted yet — receivers can
192
- // tolerate a missing first SR, and emitting one with all zeros confuses jitter math.
193
- senderReport(ntp: NtpTime): Buffer | undefined {
194
- if (this.packetCount === 0) return;
195
- return writeSenderReport(this.ssrc, ntp, this.lastTimestamp, this.packetCount, this.octetCount);
196
- }
197
- }
198
-
199
- // G.711 µ-law packetizer — chunks a µ-law byte stream into 20 ms RTP packets. The caller
200
- // supplies the timestamp of the FIRST sample in `mulaw`; the packetizer advances by
201
- // sample count internally to keep packet-to-packet timing exact regardless of how the
202
- // camera batched samples into multipart parts. Returns the next-sample timestamp so the
203
- // caller can chain across parts if it wants sample-accurate timing.
204
- export class G711RtpPacketizer {
205
- private seq: number;
206
- private packetCount = 0;
207
- private octetCount = 0;
208
- private lastTimestamp = 0;
209
- readonly ssrc: number;
210
-
211
- constructor() {
212
- this.seq = randomBytes(2).readUInt16BE(0);
213
- this.ssrc = randomBytes(4).readUInt32BE(0);
214
- }
215
-
216
- packetize(mulaw: Buffer, timestampStart: number, sink: RtpSink): number {
217
- let offset = 0;
218
- let ts = timestampStart >>> 0;
219
- while (offset < mulaw.length) {
220
- const take = Math.min(G711_SAMPLES_PER_PACKET, mulaw.length - offset);
221
- const pkt = Buffer.allocUnsafe(RTP_HEADER_LEN + take);
222
- writeRtpHeader(pkt, PT_PCMU, this.seq++, ts, this.ssrc, false);
223
- mulaw.copy(pkt, RTP_HEADER_LEN, offset, offset + take);
224
- sink(pkt);
225
- this.packetCount++;
226
- this.octetCount = (this.octetCount + take) >>> 0;
227
- this.lastTimestamp = ts;
228
- ts = (ts + take) >>> 0;
229
- offset += take;
230
- }
231
- return ts;
232
- }
233
-
234
- senderReport(ntp: NtpTime): Buffer | undefined {
235
- if (this.packetCount === 0) return;
236
- return writeSenderReport(this.ssrc, ntp, this.lastTimestamp, this.packetCount, this.octetCount);
237
- }
238
- }