scrypted-kasa 1.3.0 → 1.3.2
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/plugin.zip +0 -0
- package/package.json +4 -1
- package/.github/dependabot.yml +0 -14
- package/.github/workflows/ci.yml +0 -40
- package/.github/workflows/publish.yml +0 -52
- package/.nvmrc +0 -1
- package/.prettierignore +0 -6
- package/.prettierrc.json +0 -11
- package/eslint.config.mjs +0 -33
- package/src/camera/api.ts +0 -354
- package/src/camera/intercom.ts +0 -163
- package/src/camera/linkie.ts +0 -185
- package/src/camera/rtp.ts +0 -238
- package/src/camera/rtsp.ts +0 -270
- package/src/camera/stream.ts +0 -346
- package/src/iot/bulb.ts +0 -223
- package/src/iot/device.ts +0 -250
- package/src/iot/dimmer.ts +0 -19
- package/src/iot/plug.ts +0 -5
- package/src/iot/protocol.ts +0 -182
- package/src/iot/switch.ts +0 -5
- package/src/main.ts +0 -1389
- package/src/shared/cipher.ts +0 -49
- package/src/shared/discovery.ts +0 -232
- package/src/shared/readme.ts +0 -20
- package/tsconfig.json +0 -15
package/src/camera/intercom.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import { ClientRequest } from 'http';
|
|
3
|
-
import https from 'https';
|
|
4
|
-
import { PassThrough } from 'stream';
|
|
5
|
-
|
|
6
|
-
export const KASA_TALK_PORT = 18443;
|
|
7
|
-
const KASA_TALK_PATH = '/https/speaker/audio/g711block';
|
|
8
|
-
const TALK_BOUNDARY = 'audio-boundary--';
|
|
9
|
-
|
|
10
|
-
// Talk-channel auth uses md5(plaintext) as the password rather than the base64(plaintext)
|
|
11
|
-
// the receive stream uses. Same camera, different scheme — observed on KC420WS firmware
|
|
12
|
-
// 2.3.26 via mitmproxy capture of the official Kasa iOS app.
|
|
13
|
-
function md5Hex(plaintext: string): string {
|
|
14
|
-
return crypto.createHash('md5').update(plaintext, 'utf8').digest('hex');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// The Kasa app sends X-APP-ID/playerId of the form "tty.<32 base64-ish chars>". The token
|
|
18
|
-
// looks app-generated (not negotiated with the camera), so we mint our own per-session.
|
|
19
|
-
function generatePlayerId(): string {
|
|
20
|
-
return 'tty.' + crypto.randomBytes(24).toString('base64').replace(/[+/=]/g, '').slice(0, 32);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface KasaTalkOptions {
|
|
24
|
-
ip: string;
|
|
25
|
-
port?: number;
|
|
26
|
-
username: string;
|
|
27
|
-
password: string;
|
|
28
|
-
console?: Console;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// One ongoing POST per talk session. Multipart parts wrap raw G.711 µ-law audio blocks
|
|
32
|
-
// (Content-Type: audio/g711u) interleaved with empty audio/heartbeat parts that the app
|
|
33
|
-
// uses to keep the connection alive during silence (~one every 3 s). The body is chunked
|
|
34
|
-
// transfer encoded; the request never completes until the session is closed.
|
|
35
|
-
export class KasaTalkSession {
|
|
36
|
-
private request?: ClientRequest;
|
|
37
|
-
private body = new PassThrough();
|
|
38
|
-
private playerId = '';
|
|
39
|
-
private heartbeatTimer?: NodeJS.Timeout;
|
|
40
|
-
private closed = false;
|
|
41
|
-
private closeCallbacks?: (() => void)[] = [];
|
|
42
|
-
// Resolves once the session is torn down (by the caller, by the camera ending the
|
|
43
|
-
// response, or by an error). Lets the camera's startIntercom await teardown to clean
|
|
44
|
-
// up its ffmpeg process when the uplink dies on its own.
|
|
45
|
-
readonly closedPromise: Promise<void>;
|
|
46
|
-
private resolveClosed!: () => void;
|
|
47
|
-
|
|
48
|
-
constructor(public options: KasaTalkOptions) {
|
|
49
|
-
this.closedPromise = new Promise<void>(resolve => {
|
|
50
|
-
this.resolveClosed = resolve;
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async start(): Promise<void> {
|
|
55
|
-
this.playerId = generatePlayerId();
|
|
56
|
-
const port = this.options.port || KASA_TALK_PORT;
|
|
57
|
-
const auth =
|
|
58
|
-
'Basic ' + Buffer.from(`${this.options.username}:${md5Hex(this.options.password)}`).toString('base64');
|
|
59
|
-
|
|
60
|
-
const search = new URLSearchParams({ playerId: this.playerId });
|
|
61
|
-
|
|
62
|
-
const req = https.request({
|
|
63
|
-
host: this.options.ip,
|
|
64
|
-
port,
|
|
65
|
-
method: 'POST',
|
|
66
|
-
path: `${KASA_TALK_PATH}?${search.toString()}`,
|
|
67
|
-
rejectUnauthorized: false,
|
|
68
|
-
headers: {
|
|
69
|
-
Authorization: auth,
|
|
70
|
-
'X-APP-ID': this.playerId,
|
|
71
|
-
'Content-Type': `multipart/x-mixed-replace;boundary=${TALK_BOUNDARY}`,
|
|
72
|
-
'Transfer-Encoding': 'chunked',
|
|
73
|
-
Connection: 'keep-alive',
|
|
74
|
-
Accept: '*/*',
|
|
75
|
-
},
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
this.request = req;
|
|
79
|
-
|
|
80
|
-
req.on('error', e => {
|
|
81
|
-
// Suppress when the close came first (deliberate teardown via close()): ending
|
|
82
|
-
// the body mid-flight makes the in-flight HTTPS request emit "socket hang up"
|
|
83
|
-
// one tick later, which isn't a real error.
|
|
84
|
-
if (!this.closed) this.options.console?.warn('kasa talk request error', e.message);
|
|
85
|
-
this.close();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
req.on('response', res => {
|
|
89
|
-
this.options.console?.log(`kasa talk response: ${res.statusCode}`);
|
|
90
|
-
res.on('data', () => {
|
|
91
|
-
/* drain */
|
|
92
|
-
});
|
|
93
|
-
res.on('end', () => this.close());
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
// Body is a PassThrough so we can write parts incrementally; pipe it into the request.
|
|
97
|
-
this.body.pipe(req);
|
|
98
|
-
|
|
99
|
-
// Heartbeat immediately + every 3 s, mirroring the Kasa app's idle pattern.
|
|
100
|
-
this.writePart('audio/heartbeat', Buffer.alloc(0));
|
|
101
|
-
this.heartbeatTimer = setInterval(() => {
|
|
102
|
-
this.writePart('audio/heartbeat', Buffer.alloc(0));
|
|
103
|
-
}, 3000);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Returns false when the underlying stream is asking for backpressure (Node's standard
|
|
107
|
-
// contract). Caller should pause its source until `drain` fires on `body`. We surface
|
|
108
|
-
// this so an unhealthy/slow camera socket can't grow the PassThrough buffer without bound.
|
|
109
|
-
writeAudio(chunk: Buffer): boolean {
|
|
110
|
-
if (!chunk.length) return true;
|
|
111
|
-
return this.writePart('audio/g711u', chunk);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
onDrain(cb: () => void): void {
|
|
115
|
-
this.body.once('drain', cb);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Subscribe to the session's teardown event. If the session is already closed, the
|
|
119
|
-
// callback is invoked on the next microtask so callers don't have to special-case timing.
|
|
120
|
-
onClose(cb: () => void): void {
|
|
121
|
-
if (this.closed) {
|
|
122
|
-
queueMicrotask(cb);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
this.closeCallbacks!.push(cb);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
private writePart(contentType: string, body: Buffer): boolean {
|
|
129
|
-
if (this.closed) return true;
|
|
130
|
-
const header = `--${TALK_BOUNDARY}\r\nContent-Length: ${body.length}\r\nContent-Type: ${contentType}\r\n\r\n`;
|
|
131
|
-
// Only the last write's return value matters for backpressure — if any of these
|
|
132
|
-
// fills the buffer, Node will emit `drain` after the buffer empties.
|
|
133
|
-
this.body.write(header);
|
|
134
|
-
if (body.length) this.body.write(body);
|
|
135
|
-
return this.body.write('\r\n');
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
close(): void {
|
|
139
|
-
if (this.closed) return;
|
|
140
|
-
this.closed = true;
|
|
141
|
-
clearInterval(this.heartbeatTimer);
|
|
142
|
-
try {
|
|
143
|
-
this.body.end();
|
|
144
|
-
} catch {}
|
|
145
|
-
try {
|
|
146
|
-
this.request?.end();
|
|
147
|
-
} catch {}
|
|
148
|
-
const cbs = this.closeCallbacks;
|
|
149
|
-
// Drop the array reference before invoking callbacks so V8 can reclaim it; any
|
|
150
|
-
// late onClose() registration after this point will be served via queueMicrotask.
|
|
151
|
-
this.closeCallbacks = undefined;
|
|
152
|
-
if (cbs) {
|
|
153
|
-
for (const cb of cbs) {
|
|
154
|
-
try {
|
|
155
|
-
cb();
|
|
156
|
-
} catch (e) {
|
|
157
|
-
this.options.console?.warn('kasa talk onClose handler threw', e);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
this.resolveClosed();
|
|
162
|
-
}
|
|
163
|
-
}
|
package/src/camera/linkie.ts
DELETED
|
@@ -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
|
-
}
|