scrypted-kasa 1.0.0
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/.github/dependabot.yml +14 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +52 -0
- package/.nvmrc +1 -0
- package/.prettierignore +6 -0
- package/.prettierrc.json +11 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/eslint.config.mjs +33 -0
- package/package.json +75 -0
- package/src/camera/api.ts +329 -0
- package/src/camera/intercom.ts +163 -0
- package/src/camera/linkie.ts +185 -0
- package/src/camera/rtsp.ts +204 -0
- package/src/camera/stream.ts +428 -0
- package/src/iot/bulb.ts +178 -0
- package/src/iot/device.ts +103 -0
- package/src/iot/dimmer.ts +19 -0
- package/src/iot/plug.ts +5 -0
- package/src/iot/protocol.ts +143 -0
- package/src/iot/switch.ts +5 -0
- package/src/main.ts +1055 -0
- package/src/shared/cipher.ts +49 -0
- package/src/shared/discovery.ts +232 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import net from 'net';
|
|
3
|
+
|
|
4
|
+
const RTSP_INTERLEAVED_MAGIC = 0x24; // '$'
|
|
5
|
+
|
|
6
|
+
export interface RtspChannelPair {
|
|
7
|
+
rtp: number;
|
|
8
|
+
rtcp: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RtspChannels {
|
|
12
|
+
video: RtspChannelPair;
|
|
13
|
+
audio: RtspChannelPair;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RtspSendInterleaved {
|
|
17
|
+
(channel: number, packet: Buffer): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RtspHandlerOptions {
|
|
21
|
+
sdp: string;
|
|
22
|
+
onPlay: (send: RtspSendInterleaved, channels: RtspChannels) => void;
|
|
23
|
+
onTeardown: () => void;
|
|
24
|
+
console: Console;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RtspRequest {
|
|
28
|
+
method: string;
|
|
29
|
+
uri: string;
|
|
30
|
+
headers: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Minimal RTSP server-side handler for ONE TCP client. Supports the methods Scrypted (and
|
|
34
|
+
// any standard RTSP client) actually uses: OPTIONS, DESCRIBE, SETUP, PLAY, PAUSE, TEARDOWN.
|
|
35
|
+
// TCP-interleaved transport only — that's what Scrypted's pipeline asks for by default and
|
|
36
|
+
// it sidesteps every NAT/UDP-loss complication. We build the SDP upstream and just serve it
|
|
37
|
+
// on DESCRIBE; we never parse one.
|
|
38
|
+
export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions): void {
|
|
39
|
+
const sessionId = randomBytes(8).toString('hex');
|
|
40
|
+
let videoChannels: RtspChannelPair | undefined;
|
|
41
|
+
let audioChannels: RtspChannelPair | undefined;
|
|
42
|
+
let buffered: Buffer = Buffer.alloc(0);
|
|
43
|
+
let isPlaying = false;
|
|
44
|
+
let teardownNotified = false;
|
|
45
|
+
|
|
46
|
+
const notifyTeardown = () => {
|
|
47
|
+
if (teardownNotified) return;
|
|
48
|
+
teardownNotified = true;
|
|
49
|
+
opts.onTeardown();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const send: RtspSendInterleaved = (channel, packet) => {
|
|
53
|
+
if (client.destroyed) return false;
|
|
54
|
+
// Interleaved frame: '$' + 1-byte channel + 2-byte BE length + RTP packet bytes.
|
|
55
|
+
// Header + body in one Buffer to avoid splitting writes (a small write between the
|
|
56
|
+
// two would let an out-of-order chunk arrive in the middle of an interleaved frame).
|
|
57
|
+
const out = Buffer.allocUnsafe(4 + packet.length);
|
|
58
|
+
out[0] = RTSP_INTERLEAVED_MAGIC;
|
|
59
|
+
out[1] = channel;
|
|
60
|
+
out.writeUInt16BE(packet.length, 2);
|
|
61
|
+
packet.copy(out, 4);
|
|
62
|
+
// The destroyed check above is racy — the socket can be torn down between the
|
|
63
|
+
// check and write (different microtask), in which case write throws
|
|
64
|
+
// ERR_STREAM_DESTROYED. Catch it; the next packet's destroyed check will gate
|
|
65
|
+
// further sends.
|
|
66
|
+
try {
|
|
67
|
+
return client.write(out);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const respond = (
|
|
74
|
+
cseq: string,
|
|
75
|
+
status: number,
|
|
76
|
+
statusText: string,
|
|
77
|
+
headers: Record<string, string | number> = {},
|
|
78
|
+
body?: string,
|
|
79
|
+
) => {
|
|
80
|
+
const lines = [`RTSP/1.0 ${status} ${statusText}`, `CSeq: ${cseq}`];
|
|
81
|
+
if (body !== undefined) headers['Content-Length'] = Buffer.byteLength(body);
|
|
82
|
+
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`);
|
|
83
|
+
const message = lines.join('\r\n') + '\r\n\r\n' + (body || '');
|
|
84
|
+
client.write(message);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleRequest = (req: RtspRequest) => {
|
|
88
|
+
const cseq = req.headers['cseq'] || '0';
|
|
89
|
+
switch (req.method) {
|
|
90
|
+
case 'OPTIONS':
|
|
91
|
+
respond(cseq, 200, 'OK', {
|
|
92
|
+
Public: 'OPTIONS, DESCRIBE, SETUP, PLAY, PAUSE, TEARDOWN',
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
|
|
96
|
+
case 'DESCRIBE':
|
|
97
|
+
respond(cseq, 200, 'OK', { 'Content-Type': 'application/sdp' }, opts.sdp);
|
|
98
|
+
return;
|
|
99
|
+
|
|
100
|
+
case 'SETUP': {
|
|
101
|
+
const transport = req.headers['transport'] || '';
|
|
102
|
+
const m = /interleaved=(\d+)-(\d+)/i.exec(transport);
|
|
103
|
+
if (!m) {
|
|
104
|
+
// We only support TCP-interleaved transport. UDP would require allocating
|
|
105
|
+
// server-side ports, parsing client_port, and sending RTP via dgram —
|
|
106
|
+
// pointless for a localhost-only stream.
|
|
107
|
+
respond(cseq, 461, 'Unsupported Transport');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const rtp = parseInt(m[1], 10);
|
|
111
|
+
const rtcp = parseInt(m[2], 10);
|
|
112
|
+
const trackMatch = req.uri.match(/trackID=(\d+)/i);
|
|
113
|
+
const trackId = trackMatch ? parseInt(trackMatch[1], 10) : -1;
|
|
114
|
+
if (trackId === 0) videoChannels = { rtp, rtcp };
|
|
115
|
+
else if (trackId === 1) audioChannels = { rtp, rtcp };
|
|
116
|
+
else {
|
|
117
|
+
respond(cseq, 404, 'Not Found');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
respond(cseq, 200, 'OK', { Transport: transport, Session: sessionId });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case 'PLAY':
|
|
125
|
+
if (!videoChannels || !audioChannels) {
|
|
126
|
+
respond(cseq, 412, 'Precondition Failed');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
respond(cseq, 200, 'OK', { Session: sessionId, Range: 'npt=0.000-' });
|
|
130
|
+
if (!isPlaying) {
|
|
131
|
+
isPlaying = true;
|
|
132
|
+
try {
|
|
133
|
+
opts.onPlay(send, { video: videoChannels, audio: audioChannels });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
opts.console.warn('rtsp onPlay handler error', e);
|
|
136
|
+
client.destroy();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
|
|
141
|
+
case 'PAUSE':
|
|
142
|
+
respond(cseq, 200, 'OK', { Session: sessionId });
|
|
143
|
+
return;
|
|
144
|
+
|
|
145
|
+
case 'TEARDOWN':
|
|
146
|
+
respond(cseq, 200, 'OK', { Session: sessionId });
|
|
147
|
+
client.end();
|
|
148
|
+
return;
|
|
149
|
+
|
|
150
|
+
default:
|
|
151
|
+
respond(cseq, 405, 'Method Not Allowed');
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
|
|
156
|
+
client.on('data', chunk => {
|
|
157
|
+
buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]);
|
|
158
|
+
// Parse messages until we run out of complete ones in the buffer.
|
|
159
|
+
while (buffered.length > 0) {
|
|
160
|
+
// Skip any interleaved frames the client might send back at us. (Standard
|
|
161
|
+
// clients don't, but defending against the bidirectional channel costs nothing
|
|
162
|
+
// and is robust against odd consumers that probe.)
|
|
163
|
+
if (buffered[0] === RTSP_INTERLEAVED_MAGIC) {
|
|
164
|
+
if (buffered.length < 4) return;
|
|
165
|
+
const len = buffered.readUInt16BE(2);
|
|
166
|
+
if (buffered.length < 4 + len) return;
|
|
167
|
+
buffered = buffered.subarray(4 + len);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const headerEndIdx = buffered.indexOf(HEADER_TERMINATOR);
|
|
171
|
+
if (headerEndIdx < 0) return;
|
|
172
|
+
const headerStr = buffered.subarray(0, headerEndIdx).toString('utf8');
|
|
173
|
+
const headerLines = headerStr.split('\r\n');
|
|
174
|
+
const requestLine = headerLines.shift() || '';
|
|
175
|
+
const [method, uri] = requestLine.split(' ');
|
|
176
|
+
const headers: Record<string, string> = {};
|
|
177
|
+
for (const line of headerLines) {
|
|
178
|
+
const colon = line.indexOf(':');
|
|
179
|
+
if (colon < 0) continue;
|
|
180
|
+
headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
181
|
+
}
|
|
182
|
+
const contentLength = parseInt(headers['content-length'] || '0', 10);
|
|
183
|
+
const totalLen = headerEndIdx + HEADER_TERMINATOR.length + contentLength;
|
|
184
|
+
if (buffered.length < totalLen) return;
|
|
185
|
+
// We currently ignore request bodies — none of the methods we handle need one.
|
|
186
|
+
buffered = buffered.subarray(totalLen);
|
|
187
|
+
if (!method || !uri) {
|
|
188
|
+
opts.console.warn('rtsp: malformed request line', JSON.stringify(requestLine));
|
|
189
|
+
client.destroy();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
handleRequest({ method: method.toUpperCase(), uri, headers });
|
|
194
|
+
} catch (e) {
|
|
195
|
+
opts.console.warn('rtsp handleRequest threw', e);
|
|
196
|
+
client.destroy();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
client.on('close', notifyTeardown);
|
|
203
|
+
client.on('error', notifyTeardown);
|
|
204
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import child_process from 'child_process';
|
|
2
|
+
import dgram from 'dgram';
|
|
3
|
+
import { once } from 'events';
|
|
4
|
+
import net from 'net';
|
|
5
|
+
import { Writable } from 'stream';
|
|
6
|
+
import { findSpsPps, KasaClient, KasaMimeG711U, KasaMimeVideo, KasaPart } from './api';
|
|
7
|
+
import { handleRtspClient } from './rtsp';
|
|
8
|
+
|
|
9
|
+
// Process-level cleanup registry. Scrypted runs plugins as worker threads, and child
|
|
10
|
+
// processes spawned by a worker survive the worker's termination — they're owned by the
|
|
11
|
+
// host Node process. So when our plugin gets unloaded (or even when the worker crashes),
|
|
12
|
+
// any in-flight ffmpeg subprocess + kasa HTTPS connection would otherwise leak. We catch
|
|
13
|
+
// that here: every spawnKasaStream and intercom-ffmpeg registers its kill function and
|
|
14
|
+
// removes itself on normal teardown. On worker exit, the hook nukes anything still live.
|
|
15
|
+
const liveCleanups = new Set<() => void>();
|
|
16
|
+
let exitHookInstalled = false;
|
|
17
|
+
function installExitHook() {
|
|
18
|
+
if (exitHookInstalled) return;
|
|
19
|
+
exitHookInstalled = true;
|
|
20
|
+
process.once('exit', () => {
|
|
21
|
+
for (const cleanup of liveCleanups) {
|
|
22
|
+
try {
|
|
23
|
+
cleanup();
|
|
24
|
+
} catch {
|
|
25
|
+
/* best-effort, can't do anything from inside 'exit' */
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
liveCleanups.clear();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function registerLiveCleanup(cleanup: () => void): () => void {
|
|
33
|
+
installExitHook();
|
|
34
|
+
liveCleanups.add(cleanup);
|
|
35
|
+
return () => liveCleanups.delete(cleanup);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Hard ceiling on the upfront SPS/PPS scan. Real cameras emit them well under a second; if
|
|
39
|
+
// we don't see them in this window the camera is misbehaving and there's no point holding
|
|
40
|
+
// the call.
|
|
41
|
+
const SPS_PPS_TIMEOUT_MS = 10000;
|
|
42
|
+
// Belt-and-suspenders caps on the prebuffer. The timeout already bounds the wait, but a
|
|
43
|
+
// camera that streams at high bitrate and never emits SPS/PPS could fill memory before the
|
|
44
|
+
// timer fires. Either trip throws.
|
|
45
|
+
const SPS_PPS_MAX_BUFFERED_PARTS = 1024;
|
|
46
|
+
const SPS_PPS_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
export interface KasaStreamOptions {
|
|
49
|
+
kasa: KasaClient;
|
|
50
|
+
ffmpegPath: string;
|
|
51
|
+
console: Console;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface KasaStreamHandle {
|
|
55
|
+
url: string;
|
|
56
|
+
kill(): void;
|
|
57
|
+
exited: Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface BoundUdp {
|
|
61
|
+
socket: dgram.Socket;
|
|
62
|
+
port: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface UdpPair {
|
|
66
|
+
rtp: BoundUdp;
|
|
67
|
+
rtcp: BoundUdp;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 1 MB UDP receive buffer — comfortably absorbs an IDR-frame burst on the localhost hop
|
|
71
|
+
// from ffmpeg, which can be 100+ RTP packets arriving back-to-back. The default Linux
|
|
72
|
+
// SO_RCVBUF (~200 KB) overflows on 1080p H.264 IDRs and drops fragments, surfacing as
|
|
73
|
+
// "fua packet missing" in downstream consumers.
|
|
74
|
+
const UDP_RECV_BUFFER_BYTES = 1024 * 1024;
|
|
75
|
+
|
|
76
|
+
async function bindUdp(port: number = 0): Promise<BoundUdp> {
|
|
77
|
+
const sock = dgram.createSocket('udp4');
|
|
78
|
+
await new Promise<void>((resolve, reject) => {
|
|
79
|
+
const onError = (e: Error) => {
|
|
80
|
+
sock.removeListener('listening', onListen);
|
|
81
|
+
reject(e);
|
|
82
|
+
};
|
|
83
|
+
const onListen = () => {
|
|
84
|
+
sock.removeListener('error', onError);
|
|
85
|
+
resolve();
|
|
86
|
+
};
|
|
87
|
+
sock.once('error', onError);
|
|
88
|
+
sock.once('listening', onListen);
|
|
89
|
+
sock.bind(port, '127.0.0.1');
|
|
90
|
+
});
|
|
91
|
+
// setRecvBufferSize tries to bump SO_RCVBUF; the kernel caps it at net.core.rmem_max
|
|
92
|
+
// (often 200 KB on stock Linux, 2+ MB on most server distros). We don't fail if the
|
|
93
|
+
// bump is rejected — small buffer just means we may drop on big bursts.
|
|
94
|
+
try {
|
|
95
|
+
sock.setRecvBufferSize(UDP_RECV_BUFFER_BYTES);
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort */
|
|
98
|
+
}
|
|
99
|
+
return { socket: sock, port: (sock.address() as { port: number }).port };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Bind a (RTP, RTP+1) UDP pair so RTCP gets the conventional next port. ffmpeg's `-f rtp`
|
|
103
|
+
// output assumes RTP+1 for RTCP unless rtcpport= overrides; binding the pair ourselves
|
|
104
|
+
// keeps the wiring straightforward. Retry up to 10 times in case RTP+1 is already taken.
|
|
105
|
+
async function bindUdpPair(): Promise<UdpPair> {
|
|
106
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
107
|
+
const rtp = await bindUdp();
|
|
108
|
+
try {
|
|
109
|
+
const rtcp = await bindUdp(rtp.port + 1);
|
|
110
|
+
return { rtp, rtcp };
|
|
111
|
+
} catch {
|
|
112
|
+
rtp.socket.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw new Error('failed to bind sequential RTP/RTCP udp pair after 10 tries');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Reserve a TCP port on localhost for the RTSP server. Brief TOCTOU window between close
|
|
119
|
+
// and re-bind is accepted — nothing else snatches loopback ports in those few ms.
|
|
120
|
+
async function reserveTcpPort(): Promise<number> {
|
|
121
|
+
return new Promise<number>((resolve, reject) => {
|
|
122
|
+
const probe = net.createServer();
|
|
123
|
+
probe.once('error', reject);
|
|
124
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
125
|
+
const port = (probe.address() as net.AddressInfo).port;
|
|
126
|
+
probe.close(() => resolve(port));
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Build the SDP we serve on DESCRIBE. We embed sprop-parameter-sets so consumers can decode
|
|
132
|
+
// without waiting for the next IDR — important for HomeKit's strict first-frame timeouts.
|
|
133
|
+
function buildSdp(sps: Buffer, pps: Buffer): string {
|
|
134
|
+
// RFC 6184: profile-level-id is the 3 SPS bytes (profile_idc, profile-iop, level_idc)
|
|
135
|
+
// immediately following the NAL header byte, encoded as 6 hex chars.
|
|
136
|
+
const profileLevelId = sps.subarray(1, 4).toString('hex').toUpperCase();
|
|
137
|
+
const spropParameterSets = sps.toString('base64') + ',' + pps.toString('base64');
|
|
138
|
+
return (
|
|
139
|
+
[
|
|
140
|
+
'v=0',
|
|
141
|
+
'o=- 0 0 IN IP4 127.0.0.1',
|
|
142
|
+
's=Kasa',
|
|
143
|
+
'c=IN IP4 127.0.0.1',
|
|
144
|
+
't=0 0',
|
|
145
|
+
'm=video 0 RTP/AVP 96',
|
|
146
|
+
'a=rtpmap:96 H264/90000',
|
|
147
|
+
`a=fmtp:96 packetization-mode=1; profile-level-id=${profileLevelId}; sprop-parameter-sets=${spropParameterSets}`,
|
|
148
|
+
'a=control:trackID=0',
|
|
149
|
+
'm=audio 0 RTP/AVP 0',
|
|
150
|
+
'a=rtpmap:0 PCMU/8000',
|
|
151
|
+
'a=control:trackID=1',
|
|
152
|
+
].join('\r\n') + '\r\n'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function preScanSpsPps(kasa: KasaClient): Promise<{ sps: Buffer; pps: Buffer; buffered: KasaPart[] }> {
|
|
157
|
+
const buffered: KasaPart[] = [];
|
|
158
|
+
let bufferedBytes = 0;
|
|
159
|
+
const found: { sps?: Buffer; pps?: Buffer } = {};
|
|
160
|
+
const deadline = Date.now() + SPS_PPS_TIMEOUT_MS;
|
|
161
|
+
while (!found.sps || !found.pps) {
|
|
162
|
+
if (Date.now() > deadline) throw new Error('timed out waiting for H.264 SPS/PPS');
|
|
163
|
+
if (buffered.length >= SPS_PPS_MAX_BUFFERED_PARTS)
|
|
164
|
+
throw new Error(`H.264 SPS/PPS not found within ${SPS_PPS_MAX_BUFFERED_PARTS} parts`);
|
|
165
|
+
if (bufferedBytes >= SPS_PPS_MAX_BUFFERED_BYTES)
|
|
166
|
+
throw new Error(`H.264 SPS/PPS not found within ${SPS_PPS_MAX_BUFFERED_BYTES} prebuffered bytes`);
|
|
167
|
+
const part = await kasa.readPart();
|
|
168
|
+
buffered.push(part);
|
|
169
|
+
bufferedBytes += part.body.length;
|
|
170
|
+
if (part.contentType === KasaMimeVideo) findSpsPps(part.body, found);
|
|
171
|
+
}
|
|
172
|
+
return { sps: found.sps!, pps: found.pps!, buffered };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Replays the buffered parts captured during the SPS/PPS scan (so ffmpeg sees the
|
|
176
|
+
// SPS+PPS+IDR sequence required to start decoding) then continues pumping live parts. We
|
|
177
|
+
// honor backpressure: if ffmpeg falls behind (CPU spike, paused consumer, etc.), ignoring
|
|
178
|
+
// drain would let Node's write buffer grow without bound. Awaiting drain throttles the
|
|
179
|
+
// camera read loop instead — the camera's TCP receive window will fill up and the kernel
|
|
180
|
+
// will pause it for us.
|
|
181
|
+
async function pumpKasa(
|
|
182
|
+
kasa: KasaClient,
|
|
183
|
+
videoPipe: Writable,
|
|
184
|
+
audioPipe: Writable,
|
|
185
|
+
prebuffered: KasaPart[],
|
|
186
|
+
): Promise<void> {
|
|
187
|
+
const writePart = async (part: KasaPart) => {
|
|
188
|
+
const pipe =
|
|
189
|
+
part.contentType === KasaMimeVideo ? videoPipe : part.contentType === KasaMimeG711U ? audioPipe : undefined;
|
|
190
|
+
if (!pipe || pipe.writableEnded || pipe.destroyed) return;
|
|
191
|
+
if (!pipe.write(part.body)) {
|
|
192
|
+
// Race 'drain' against teardown. If the pipe closes/errors before draining,
|
|
193
|
+
// 'drain' never fires and the pump would hang forever. AbortController removes
|
|
194
|
+
// the losing listeners after the race so frequent backpressure cycles don't
|
|
195
|
+
// accumulate listeners on the pipe.
|
|
196
|
+
const ac = new AbortController();
|
|
197
|
+
try {
|
|
198
|
+
await Promise.race([
|
|
199
|
+
once(pipe, 'drain', { signal: ac.signal }).catch(() => {}),
|
|
200
|
+
once(pipe, 'close', { signal: ac.signal }).catch(() => {}),
|
|
201
|
+
once(pipe, 'error', { signal: ac.signal }).catch(() => {}),
|
|
202
|
+
]);
|
|
203
|
+
} finally {
|
|
204
|
+
ac.abort();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
for (const part of prebuffered) await writePart(part);
|
|
211
|
+
// Release prebuffered parts (up to a few hundred KB each) — they were captured
|
|
212
|
+
// during the SPS/PPS scan and are only needed for this single replay.
|
|
213
|
+
prebuffered.length = 0;
|
|
214
|
+
while (true) {
|
|
215
|
+
const part = await kasa.readPart();
|
|
216
|
+
await writePart(part);
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
try {
|
|
220
|
+
videoPipe.end();
|
|
221
|
+
} catch {}
|
|
222
|
+
try {
|
|
223
|
+
audioPipe.end();
|
|
224
|
+
} catch {}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Orchestrates the kasa→consumer streaming pipeline:
|
|
229
|
+
// kasa multipart → ffmpeg pipe inputs (codec copy) → RTP UDP outputs
|
|
230
|
+
// → minimal RTSP server forwards UDP RTP as TCP-interleaved frames
|
|
231
|
+
// → consumer reads via rtsp:// URL
|
|
232
|
+
//
|
|
233
|
+
// Returns a handle whose `kill()` tears down everything (kasa, ffmpeg, UDP sockets, TCP
|
|
234
|
+
// server, active client) and whose `exited` promise resolves once that teardown is done.
|
|
235
|
+
export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStreamHandle> {
|
|
236
|
+
const { kasa, ffmpegPath, console } = opts;
|
|
237
|
+
|
|
238
|
+
const { sps, pps, buffered } = await preScanSpsPps(kasa);
|
|
239
|
+
const sdp = buildSdp(sps, pps);
|
|
240
|
+
|
|
241
|
+
const videoUdp = await bindUdpPair();
|
|
242
|
+
const audioUdp = await bindUdpPair();
|
|
243
|
+
|
|
244
|
+
// Two RTP outputs, one per track. `-vn`/`-an` per output filter the unwanted side
|
|
245
|
+
// rather than using `-map`. Explicit -payload_type lines pin the PTs to the values our
|
|
246
|
+
// SDP advertises (96 for H.264, 0 for PCMU) so a future ffmpeg default change can't
|
|
247
|
+
// silently break the SDP/stream contract.
|
|
248
|
+
// prettier-ignore
|
|
249
|
+
const args = [
|
|
250
|
+
'-hide_banner',
|
|
251
|
+
'-loglevel', 'error',
|
|
252
|
+
'-thread_queue_size', '1024',
|
|
253
|
+
'-f', 'h264',
|
|
254
|
+
'-i', 'pipe:3',
|
|
255
|
+
'-thread_queue_size', '1024',
|
|
256
|
+
'-f', 'mulaw',
|
|
257
|
+
'-ar', '8000',
|
|
258
|
+
'-ac', '1',
|
|
259
|
+
'-i', 'pipe:4',
|
|
260
|
+
'-vcodec', 'copy', '-an', '-dn', '-sn',
|
|
261
|
+
'-f', 'rtp',
|
|
262
|
+
'-payload_type', '96',
|
|
263
|
+
`rtp://127.0.0.1:${videoUdp.rtp.port}?rtcpport=${videoUdp.rtcp.port}`,
|
|
264
|
+
'-acodec', 'copy', '-vn', '-dn', '-sn',
|
|
265
|
+
'-f', 'rtp',
|
|
266
|
+
'-payload_type', '0',
|
|
267
|
+
`rtp://127.0.0.1:${audioUdp.rtp.port}?rtcpport=${audioUdp.rtcp.port}`,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
const cp = child_process.spawn(ffmpegPath, args, {
|
|
271
|
+
stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
|
|
272
|
+
});
|
|
273
|
+
cp.stderr?.on('data', d => {
|
|
274
|
+
const text = d.toString().trim();
|
|
275
|
+
if (text) console.log('[kasa-ffmpeg]', text);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const videoPipe = cp.stdio[3] as Writable;
|
|
279
|
+
const audioPipe = cp.stdio[4] as Writable;
|
|
280
|
+
|
|
281
|
+
let resolveExited!: () => void;
|
|
282
|
+
const exited = new Promise<void>(r => {
|
|
283
|
+
resolveExited = r;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Forward-declare anything cleanupAll touches that's initialized later in the function.
|
|
287
|
+
// ffmpeg's `exit`/`error` events or a pump throw can fire cleanupAll while we're still
|
|
288
|
+
// awaiting `reserveTcpPort()` below — referencing TDZ const bindings here would throw
|
|
289
|
+
// an uncaught ReferenceError and skip the rest of teardown. Explicit `= undefined` so
|
|
290
|
+
// eslint's prefer-const sees the later reassignment.
|
|
291
|
+
let tcpServer: net.Server | undefined = undefined;
|
|
292
|
+
let activeClient: net.Socket | undefined = undefined;
|
|
293
|
+
let unregisterCleanup: (() => void) | undefined = undefined;
|
|
294
|
+
|
|
295
|
+
let killed = false;
|
|
296
|
+
const cleanupAll = () => {
|
|
297
|
+
if (killed) return;
|
|
298
|
+
killed = true;
|
|
299
|
+
try {
|
|
300
|
+
unregisterCleanup?.();
|
|
301
|
+
} catch {}
|
|
302
|
+
try {
|
|
303
|
+
tcpServer?.close();
|
|
304
|
+
} catch {}
|
|
305
|
+
try {
|
|
306
|
+
activeClient?.destroy();
|
|
307
|
+
} catch {}
|
|
308
|
+
try {
|
|
309
|
+
kasa.destroy();
|
|
310
|
+
} catch {}
|
|
311
|
+
try {
|
|
312
|
+
cp.kill('SIGKILL');
|
|
313
|
+
} catch {}
|
|
314
|
+
try {
|
|
315
|
+
videoUdp.rtp.socket.close();
|
|
316
|
+
} catch {}
|
|
317
|
+
try {
|
|
318
|
+
videoUdp.rtcp.socket.close();
|
|
319
|
+
} catch {}
|
|
320
|
+
try {
|
|
321
|
+
audioUdp.rtp.socket.close();
|
|
322
|
+
} catch {}
|
|
323
|
+
try {
|
|
324
|
+
audioUdp.rtcp.socket.close();
|
|
325
|
+
} catch {}
|
|
326
|
+
resolveExited();
|
|
327
|
+
};
|
|
328
|
+
// Register with the process-level cleanup registry so worker termination kills the
|
|
329
|
+
// ffmpeg subprocess + closes the kasa connection even if our normal teardown path
|
|
330
|
+
// (release(), kill switch, ffmpeg exit) didn't run.
|
|
331
|
+
unregisterCleanup = registerLiveCleanup(cleanupAll);
|
|
332
|
+
|
|
333
|
+
// ffmpeg exit (or spawn error) is itself a teardown trigger.
|
|
334
|
+
cp.once('exit', cleanupAll);
|
|
335
|
+
cp.once('error', cleanupAll);
|
|
336
|
+
|
|
337
|
+
// Pump kasa → ffmpeg pipes. If the pump throws (kasa stream ended/errored), tear down.
|
|
338
|
+
// We suppress the warn when `killed` is already set, because that means our own
|
|
339
|
+
// cleanupAll destroyed the kasa connection and the resulting "stream ended" throw is
|
|
340
|
+
// expected close-sequence noise, not a real error.
|
|
341
|
+
pumpKasa(kasa, videoPipe, audioPipe, buffered)
|
|
342
|
+
.catch(e => {
|
|
343
|
+
if (!killed) console.warn('[kasa-stream] pump error', (e as Error).message);
|
|
344
|
+
})
|
|
345
|
+
.finally(cleanupAll);
|
|
346
|
+
|
|
347
|
+
// Forwarding state — populated atomically when the RTSP client PLAYs, used by the
|
|
348
|
+
// always-attached UDP message handlers below. One reference instead of two so the
|
|
349
|
+
// handlers can't observe a half-set state under any scheduling.
|
|
350
|
+
interface ActiveForwarder {
|
|
351
|
+
send: (channel: number, packet: Buffer) => boolean;
|
|
352
|
+
videoRtp: number;
|
|
353
|
+
videoRtcp: number;
|
|
354
|
+
audioRtp: number;
|
|
355
|
+
audioRtcp: number;
|
|
356
|
+
}
|
|
357
|
+
let active: ActiveForwarder | null = null;
|
|
358
|
+
|
|
359
|
+
// Attach UDP listeners IMMEDIATELY (not on PLAY) so the kernel buffer gets drained as
|
|
360
|
+
// soon as ffmpeg starts producing packets. Without this, packets sent before the RTSP
|
|
361
|
+
// client connects pile up in SO_RCVBUF and either get dropped on overflow, or block
|
|
362
|
+
// libuv from polling the socket cleanly. Until `active` is set, we just discard.
|
|
363
|
+
videoUdp.rtp.socket.on('message', msg => {
|
|
364
|
+
if (active) active.send(active.videoRtp, msg);
|
|
365
|
+
});
|
|
366
|
+
videoUdp.rtcp.socket.on('message', msg => {
|
|
367
|
+
if (active) active.send(active.videoRtcp, msg);
|
|
368
|
+
});
|
|
369
|
+
audioUdp.rtp.socket.on('message', msg => {
|
|
370
|
+
if (active) active.send(active.audioRtp, msg);
|
|
371
|
+
});
|
|
372
|
+
audioUdp.rtcp.socket.on('message', msg => {
|
|
373
|
+
if (active) active.send(active.audioRtcp, msg);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Bind the RTSP listener last, after all the supporting pieces are in place.
|
|
377
|
+
const rtspPort = await reserveTcpPort();
|
|
378
|
+
const url = `rtsp://127.0.0.1:${rtspPort}/kasa`;
|
|
379
|
+
|
|
380
|
+
tcpServer = net.createServer();
|
|
381
|
+
|
|
382
|
+
tcpServer.on('connection', client => {
|
|
383
|
+
// tcpServer.close() in cleanupAll only stops accepting NEW connections; an inflight
|
|
384
|
+
// accept can still fire 'connection' after we've started teardown. Reject those so
|
|
385
|
+
// the late client doesn't become activeClient and leak.
|
|
386
|
+
if (killed) {
|
|
387
|
+
client.destroy();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (activeClient) {
|
|
391
|
+
// We only serve one consumer per stream session. Reject anything else.
|
|
392
|
+
client.destroy();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
// RTSP-over-TCP-interleaved frames are small (~4 B header + RTP packet). Disable
|
|
396
|
+
// Nagle so frame headers don't get held up to 200 ms hoping to coalesce.
|
|
397
|
+
client.setNoDelay(true);
|
|
398
|
+
activeClient = client;
|
|
399
|
+
handleRtspClient(client, {
|
|
400
|
+
sdp,
|
|
401
|
+
console,
|
|
402
|
+
onPlay(send, channels) {
|
|
403
|
+
active = {
|
|
404
|
+
send,
|
|
405
|
+
videoRtp: channels.video.rtp,
|
|
406
|
+
videoRtcp: channels.video.rtcp,
|
|
407
|
+
audioRtp: channels.audio.rtp,
|
|
408
|
+
audioRtcp: channels.audio.rtcp,
|
|
409
|
+
};
|
|
410
|
+
},
|
|
411
|
+
onTeardown: cleanupAll,
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
await new Promise<void>((resolve, reject) => {
|
|
416
|
+
tcpServer.once('error', reject);
|
|
417
|
+
tcpServer.listen(rtspPort, '127.0.0.1', () => {
|
|
418
|
+
tcpServer.removeListener('error', reject);
|
|
419
|
+
resolve();
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
url,
|
|
425
|
+
kill: cleanupAll,
|
|
426
|
+
exited,
|
|
427
|
+
};
|
|
428
|
+
}
|