scrypted-kasa 1.3.1 → 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 -186
- 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 -1429
- 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/rtsp.ts
DELETED
|
@@ -1,270 +0,0 @@
|
|
|
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: Map<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
|
-
// Hard cap on Node's userland write queue (`socket.bufferSize`). If the consumer falls
|
|
39
|
-
// behind by more than this, we give up rather than letting RAM grow unbounded. 2 MB at
|
|
40
|
-
// 4 Mbps video is ~4 s of data — past that, the stream is unrecoverable for live
|
|
41
|
-
// consumption anyway, and most receivers would resync faster from a fresh PLAY than
|
|
42
|
-
// from catching up to a stale backlog. Note: this is Node's queue, not the kernel TCP
|
|
43
|
-
// send buffer (SO_SNDBUF) — the kernel buffer is bounded by net.ipv4.tcp_wmem and
|
|
44
|
-
// applies backpressure via TCP window before this cap would ever be hit.
|
|
45
|
-
const RTSP_WRITE_BUFFER_HARD_CAP = 2 * 1024 * 1024;
|
|
46
|
-
|
|
47
|
-
export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions): void {
|
|
48
|
-
const sessionId = randomBytes(8).toString('hex');
|
|
49
|
-
let videoChannels: RtspChannelPair | undefined;
|
|
50
|
-
let audioChannels: RtspChannelPair | undefined;
|
|
51
|
-
let buffered: Buffer = Buffer.alloc(0);
|
|
52
|
-
let isPlaying = false;
|
|
53
|
-
let teardownNotified = false;
|
|
54
|
-
// Drop sends while Node's userland write queue is back-pressured (client.write()
|
|
55
|
-
// returned false). Cleared on 'drain'. Without this, a slow consumer makes the queue
|
|
56
|
-
// grow unbounded — the live-video equivalent of bufferbloat.
|
|
57
|
-
let backpressured = false;
|
|
58
|
-
client.on('drain', () => {
|
|
59
|
-
backpressured = false;
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const notifyTeardown = () => {
|
|
63
|
-
if (teardownNotified) return;
|
|
64
|
-
teardownNotified = true;
|
|
65
|
-
opts.onTeardown();
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const send: RtspSendInterleaved = (channel, packet) => {
|
|
69
|
-
if (client.destroyed) return false;
|
|
70
|
-
// Cap check runs BEFORE the backpressured early-return: a single write that
|
|
71
|
-
// pushes bufferSize over the cap will set backpressured=true on the way out, and
|
|
72
|
-
// without the cap check up here the next send would short-circuit on the
|
|
73
|
-
// backpressure flag and never observe the oversize backlog. With the cap check
|
|
74
|
-
// first, a permanently stuck consumer (paused but not closed) gets torn down on
|
|
75
|
-
// the next send instead of holding bytes resident forever.
|
|
76
|
-
if (client.bufferSize > RTSP_WRITE_BUFFER_HARD_CAP) {
|
|
77
|
-
opts.console.warn(
|
|
78
|
-
'rtsp: client write buffer exceeded',
|
|
79
|
-
RTSP_WRITE_BUFFER_HARD_CAP,
|
|
80
|
-
'bytes — destroying client',
|
|
81
|
-
);
|
|
82
|
-
client.destroy();
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
if (backpressured) return false;
|
|
86
|
-
// Interleaved frame: '$' + 1-byte channel + 2-byte BE length + RTP packet bytes.
|
|
87
|
-
// Header + body in one Buffer to avoid splitting writes (a small write between the
|
|
88
|
-
// two would let an out-of-order chunk arrive in the middle of an interleaved frame).
|
|
89
|
-
const out = Buffer.allocUnsafe(4 + packet.length);
|
|
90
|
-
out[0] = RTSP_INTERLEAVED_MAGIC;
|
|
91
|
-
out[1] = channel;
|
|
92
|
-
out.writeUInt16BE(packet.length, 2);
|
|
93
|
-
packet.copy(out, 4);
|
|
94
|
-
// The destroyed check above is racy — the socket can be torn down between the
|
|
95
|
-
// check and write (different microtask), in which case write throws
|
|
96
|
-
// ERR_STREAM_DESTROYED. Catch it; the next packet's destroyed check will gate
|
|
97
|
-
// further sends.
|
|
98
|
-
try {
|
|
99
|
-
const ok = client.write(out);
|
|
100
|
-
if (!ok) backpressured = true;
|
|
101
|
-
return ok;
|
|
102
|
-
} catch {
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const respond = (
|
|
108
|
-
cseq: string,
|
|
109
|
-
status: number,
|
|
110
|
-
statusText: string,
|
|
111
|
-
headers: Record<string, string | number> = {},
|
|
112
|
-
body?: string,
|
|
113
|
-
) => {
|
|
114
|
-
const lines = [`RTSP/1.0 ${status} ${statusText}`, `CSeq: ${cseq}`];
|
|
115
|
-
if (body !== undefined) headers['Content-Length'] = Buffer.byteLength(body);
|
|
116
|
-
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${v}`);
|
|
117
|
-
const message = lines.join('\r\n') + '\r\n\r\n' + (body || '');
|
|
118
|
-
client.write(message);
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const handleRequest = (req: RtspRequest) => {
|
|
122
|
-
const cseq = req.headers.get('cseq') || '0';
|
|
123
|
-
switch (req.method) {
|
|
124
|
-
case 'OPTIONS':
|
|
125
|
-
respond(cseq, 200, 'OK', {
|
|
126
|
-
Public: 'OPTIONS, DESCRIBE, SETUP, PLAY, PAUSE, TEARDOWN',
|
|
127
|
-
});
|
|
128
|
-
return;
|
|
129
|
-
|
|
130
|
-
case 'DESCRIBE':
|
|
131
|
-
respond(cseq, 200, 'OK', { 'Content-Type': 'application/sdp' }, opts.sdp);
|
|
132
|
-
return;
|
|
133
|
-
|
|
134
|
-
case 'SETUP': {
|
|
135
|
-
const transport = req.headers.get('transport') || '';
|
|
136
|
-
const m = /interleaved=(\d+)-(\d+)/i.exec(transport);
|
|
137
|
-
if (!m) {
|
|
138
|
-
// We only support TCP-interleaved transport. UDP would require allocating
|
|
139
|
-
// server-side ports, parsing client_port, and sending RTP via dgram —
|
|
140
|
-
// pointless for a localhost-only stream.
|
|
141
|
-
respond(cseq, 461, 'Unsupported Transport');
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
const rtp = parseInt(m[1], 10);
|
|
145
|
-
const rtcp = parseInt(m[2], 10);
|
|
146
|
-
const trackMatch = req.uri.match(/trackID=(\d+)/i);
|
|
147
|
-
const trackId = trackMatch ? parseInt(trackMatch[1], 10) : -1;
|
|
148
|
-
if (trackId === 0) videoChannels = { rtp, rtcp };
|
|
149
|
-
else if (trackId === 1) audioChannels = { rtp, rtcp };
|
|
150
|
-
else {
|
|
151
|
-
respond(cseq, 404, 'Not Found');
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
respond(cseq, 200, 'OK', { Transport: transport, Session: sessionId });
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
case 'PLAY':
|
|
159
|
-
if (!videoChannels || !audioChannels) {
|
|
160
|
-
respond(cseq, 412, 'Precondition Failed');
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
respond(cseq, 200, 'OK', { Session: sessionId, Range: 'npt=0.000-' });
|
|
164
|
-
if (!isPlaying) {
|
|
165
|
-
isPlaying = true;
|
|
166
|
-
try {
|
|
167
|
-
opts.onPlay(send, { video: videoChannels, audio: audioChannels });
|
|
168
|
-
} catch (e) {
|
|
169
|
-
opts.console.warn('rtsp onPlay handler error', e);
|
|
170
|
-
client.destroy();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return;
|
|
174
|
-
|
|
175
|
-
case 'PAUSE':
|
|
176
|
-
respond(cseq, 200, 'OK', { Session: sessionId });
|
|
177
|
-
return;
|
|
178
|
-
|
|
179
|
-
case 'TEARDOWN':
|
|
180
|
-
respond(cseq, 200, 'OK', { Session: sessionId });
|
|
181
|
-
client.end();
|
|
182
|
-
return;
|
|
183
|
-
|
|
184
|
-
default:
|
|
185
|
-
respond(cseq, 405, 'Method Not Allowed');
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
|
|
190
|
-
// Caps on accumulated buffer size. Server is localhost-only, but a misbehaving or
|
|
191
|
-
// malicious client could trickle bytes without ever sending the header terminator,
|
|
192
|
-
// or advertise a giant Content-Length, forcing `buffered` to grow unbounded via
|
|
193
|
-
// Buffer.concat. Real RTSP requests for the methods we accept are well under 1 KB.
|
|
194
|
-
const RTSP_MAX_HEADER_BYTES = 8 * 1024;
|
|
195
|
-
const RTSP_MAX_BODY_BYTES = 4 * 1024;
|
|
196
|
-
client.on('data', (chunk: Buffer) => {
|
|
197
|
-
buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]);
|
|
198
|
-
// Parse messages until we run out of complete ones in the buffer.
|
|
199
|
-
while (buffered.length > 0) {
|
|
200
|
-
// Skip any interleaved frames the client might send back at us. (Standard
|
|
201
|
-
// clients don't, but defending against the bidirectional channel costs nothing
|
|
202
|
-
// and is robust against odd consumers that probe.)
|
|
203
|
-
if (buffered[0] === RTSP_INTERLEAVED_MAGIC) {
|
|
204
|
-
if (buffered.length < 4) return;
|
|
205
|
-
const len = buffered.readUInt16BE(2);
|
|
206
|
-
if (buffered.length < 4 + len) return;
|
|
207
|
-
buffered = buffered.subarray(4 + len);
|
|
208
|
-
continue;
|
|
209
|
-
}
|
|
210
|
-
const headerEndIdx = buffered.indexOf(HEADER_TERMINATOR);
|
|
211
|
-
// Apply the cap whether or not the terminator has arrived: a single oversized
|
|
212
|
-
// chunk that contains the terminator would otherwise slip past a check that
|
|
213
|
-
// only fires while we're still waiting for it.
|
|
214
|
-
const headerLen = headerEndIdx < 0 ? buffered.length : headerEndIdx;
|
|
215
|
-
if (headerLen > RTSP_MAX_HEADER_BYTES) {
|
|
216
|
-
opts.console.warn('rtsp: header section exceeded cap, dropping client');
|
|
217
|
-
client.destroy();
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (headerEndIdx < 0) return;
|
|
221
|
-
const headerStr = buffered.subarray(0, headerEndIdx).toString('utf8');
|
|
222
|
-
const headerLines = headerStr.split('\r\n');
|
|
223
|
-
const requestLine = headerLines.shift() || '';
|
|
224
|
-
const [method, uri] = requestLine.split(' ');
|
|
225
|
-
// Map (not a plain object) so a malicious peer can't reach Object.prototype via
|
|
226
|
-
// a crafted header name like `__proto__`. Practical risk is tiny — the server
|
|
227
|
-
// only listens on localhost — but Map is the pattern CodeQL accepts.
|
|
228
|
-
const headers = new Map<string, string>();
|
|
229
|
-
for (const line of headerLines) {
|
|
230
|
-
const colon = line.indexOf(':');
|
|
231
|
-
if (colon < 0) continue;
|
|
232
|
-
headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim());
|
|
233
|
-
}
|
|
234
|
-
// Strict validation: parseInt('1abc', 10) === 1, which would let us buffer
|
|
235
|
-
// 1 body byte and then desync the parser on the rest. Require a pure decimal
|
|
236
|
-
// string before trusting the length.
|
|
237
|
-
const rawContentLength = headers.get('content-length');
|
|
238
|
-
if (rawContentLength !== undefined && !/^\d+$/.test(rawContentLength)) {
|
|
239
|
-
opts.console.warn('rtsp: invalid content-length', rawContentLength);
|
|
240
|
-
client.destroy();
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
const contentLength = rawContentLength ? parseInt(rawContentLength, 10) : 0;
|
|
244
|
-
if (contentLength > RTSP_MAX_BODY_BYTES) {
|
|
245
|
-
opts.console.warn('rtsp: oversized content-length', rawContentLength);
|
|
246
|
-
client.destroy();
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
const totalLen = headerEndIdx + HEADER_TERMINATOR.length + contentLength;
|
|
250
|
-
if (buffered.length < totalLen) return;
|
|
251
|
-
// We currently ignore request bodies — none of the methods we handle need one.
|
|
252
|
-
buffered = buffered.subarray(totalLen);
|
|
253
|
-
if (!method || !uri) {
|
|
254
|
-
opts.console.warn('rtsp: malformed request line', JSON.stringify(requestLine));
|
|
255
|
-
client.destroy();
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
try {
|
|
259
|
-
handleRequest({ method: method.toUpperCase(), uri, headers });
|
|
260
|
-
} catch (e) {
|
|
261
|
-
opts.console.warn('rtsp handleRequest threw', e);
|
|
262
|
-
client.destroy();
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
client.on('close', notifyTeardown);
|
|
269
|
-
client.on('error', notifyTeardown);
|
|
270
|
-
}
|
package/src/camera/stream.ts
DELETED
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
import net from 'net';
|
|
2
|
-
import { findSpsPps, KasaClient, KasaMimeG711U, KasaMimeVideo, KasaPart } from './api';
|
|
3
|
-
import { G711_CLOCK_HZ, G711RtpPacketizer, H264_CLOCK_HZ, H264RtpPacketizer, nowNtp } from './rtp';
|
|
4
|
-
import { handleRtspClient } from './rtsp';
|
|
5
|
-
|
|
6
|
-
// Process-level cleanup registry. Scrypted runs plugins as worker threads; resources
|
|
7
|
-
// owned by the host Node process (TCP servers, kasa HTTPS connections) survive worker
|
|
8
|
-
// termination otherwise. Every spawnKasaStream registers its kill function and removes
|
|
9
|
-
// itself on normal teardown. On worker exit, the hook nukes anything still live.
|
|
10
|
-
const liveCleanups = new Set<() => void>();
|
|
11
|
-
let exitHookInstalled = false;
|
|
12
|
-
function installExitHook() {
|
|
13
|
-
if (exitHookInstalled) return;
|
|
14
|
-
exitHookInstalled = true;
|
|
15
|
-
process.once('exit', () => {
|
|
16
|
-
for (const cleanup of liveCleanups) {
|
|
17
|
-
try {
|
|
18
|
-
cleanup();
|
|
19
|
-
} catch {
|
|
20
|
-
/* best-effort, can't do anything from inside 'exit' */
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
liveCleanups.clear();
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function registerLiveCleanup(cleanup: () => void): () => void {
|
|
28
|
-
installExitHook();
|
|
29
|
-
liveCleanups.add(cleanup);
|
|
30
|
-
return () => liveCleanups.delete(cleanup);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Hard ceiling on the upfront SPS/PPS scan. Real cameras emit them well under a second; if
|
|
34
|
-
// we don't see them in this window the camera is misbehaving and there's no point holding
|
|
35
|
-
// the call.
|
|
36
|
-
const SPS_PPS_TIMEOUT_MS = 10000;
|
|
37
|
-
// Belt-and-suspenders caps on the prebuffer. The timeout already bounds the wait, but a
|
|
38
|
-
// camera that streams at high bitrate and never emits SPS/PPS could fill memory before the
|
|
39
|
-
// timer fires. Either trip throws.
|
|
40
|
-
const SPS_PPS_MAX_BUFFERED_PARTS = 1024;
|
|
41
|
-
const SPS_PPS_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
|
|
42
|
-
|
|
43
|
-
export interface KasaStreamOptions {
|
|
44
|
-
kasa: KasaClient;
|
|
45
|
-
console: Console;
|
|
46
|
-
// Optional cached SPS/PPS from a previous session. When present we skip the
|
|
47
|
-
// preScanSpsPps wait (which holds getVideoStream up to ~2s on KC420WS waiting for the
|
|
48
|
-
// next IDR) and start streaming live parts immediately. Cached values come from this
|
|
49
|
-
// camera's own previous bitstream so they're correct unless the encoder profile
|
|
50
|
-
// changed (resolution / bitrate setting); even when stale, the in-band SPS/PPS that
|
|
51
|
-
// arrive with the next IDR will replace them at the consumer's decoder.
|
|
52
|
-
cachedSps?: Buffer;
|
|
53
|
-
cachedPps?: Buffer;
|
|
54
|
-
// Called when we successfully scanned fresh SPS/PPS from the live bitstream. The
|
|
55
|
-
// caller persists these so subsequent sessions can use the cached path.
|
|
56
|
-
onFreshSpsPps?: (sps: Buffer, pps: Buffer) => void;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface KasaStreamHandle {
|
|
60
|
-
url: string;
|
|
61
|
-
kill(): void;
|
|
62
|
-
exited: Promise<void>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Build the SDP we serve on DESCRIBE. We embed sprop-parameter-sets so consumers can decode
|
|
66
|
-
// without waiting for the next IDR — important for HomeKit's strict first-frame timeouts.
|
|
67
|
-
function buildSdp(sps: Buffer, pps: Buffer): string {
|
|
68
|
-
// RFC 6184: profile-level-id is the 3 SPS bytes (profile_idc, profile-iop, level_idc)
|
|
69
|
-
// immediately following the NAL header byte, encoded as 6 hex chars.
|
|
70
|
-
const profileLevelId = sps.subarray(1, 4).toString('hex').toUpperCase();
|
|
71
|
-
const spropParameterSets = sps.toString('base64') + ',' + pps.toString('base64');
|
|
72
|
-
return (
|
|
73
|
-
[
|
|
74
|
-
'v=0',
|
|
75
|
-
'o=- 0 0 IN IP4 127.0.0.1',
|
|
76
|
-
's=Kasa',
|
|
77
|
-
'c=IN IP4 127.0.0.1',
|
|
78
|
-
't=0 0',
|
|
79
|
-
'm=video 0 RTP/AVP 96',
|
|
80
|
-
'a=rtpmap:96 H264/90000',
|
|
81
|
-
`a=fmtp:96 packetization-mode=1; profile-level-id=${profileLevelId}; sprop-parameter-sets=${spropParameterSets}`,
|
|
82
|
-
'a=control:trackID=0',
|
|
83
|
-
'm=audio 0 RTP/AVP 0',
|
|
84
|
-
'a=rtpmap:0 PCMU/8000',
|
|
85
|
-
'a=control:trackID=1',
|
|
86
|
-
].join('\r\n') + '\r\n'
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function preScanSpsPps(kasa: KasaClient): Promise<{ sps: Buffer; pps: Buffer; buffered: KasaPart[] }> {
|
|
91
|
-
const buffered: KasaPart[] = [];
|
|
92
|
-
let bufferedBytes = 0;
|
|
93
|
-
const found: { sps?: Buffer; pps?: Buffer } = {};
|
|
94
|
-
const deadline = Date.now() + SPS_PPS_TIMEOUT_MS;
|
|
95
|
-
while (!found.sps || !found.pps) {
|
|
96
|
-
if (Date.now() > deadline) throw new Error('timed out waiting for H.264 SPS/PPS');
|
|
97
|
-
if (buffered.length >= SPS_PPS_MAX_BUFFERED_PARTS)
|
|
98
|
-
throw new Error(`H.264 SPS/PPS not found within ${SPS_PPS_MAX_BUFFERED_PARTS} parts`);
|
|
99
|
-
if (bufferedBytes >= SPS_PPS_MAX_BUFFERED_BYTES)
|
|
100
|
-
throw new Error(`H.264 SPS/PPS not found within ${SPS_PPS_MAX_BUFFERED_BYTES} prebuffered bytes`);
|
|
101
|
-
const part = await kasa.readPart();
|
|
102
|
-
buffered.push(part);
|
|
103
|
-
bufferedBytes += part.body.length;
|
|
104
|
-
if (part.contentType === KasaMimeVideo) findSpsPps(part.body, found);
|
|
105
|
-
}
|
|
106
|
-
return { sps: found.sps!, pps: found.pps!, buffered };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Reserve a TCP port on localhost for the RTSP server. Brief TOCTOU window between close
|
|
110
|
-
// and re-bind is accepted — nothing else snatches loopback ports in those few ms.
|
|
111
|
-
async function reserveTcpPort(): Promise<number> {
|
|
112
|
-
return new Promise<number>((resolve, reject) => {
|
|
113
|
-
const probe = net.createServer();
|
|
114
|
-
probe.once('error', reject);
|
|
115
|
-
probe.listen(0, '127.0.0.1', () => {
|
|
116
|
-
const port = (probe.address() as net.AddressInfo).port;
|
|
117
|
-
probe.close(() => resolve(port));
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Compute an RTP timestamp at `clockHz` from a process.hrtime delta in nanoseconds.
|
|
123
|
-
// 32-bit unsigned wrap is the RTP norm — receivers handle wrap.
|
|
124
|
-
function nanosToRtp(deltaNs: bigint, clockHz: number): number {
|
|
125
|
-
// BigInt multiply keeps precision for clockHz=90000 over hours of streaming.
|
|
126
|
-
const ticks = (deltaNs * BigInt(clockHz)) / 1_000_000_000n;
|
|
127
|
-
return Number(ticks & 0xffffffffn) >>> 0;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Orchestrates the kasa→consumer streaming pipeline:
|
|
131
|
-
// kasa multipart → native RTP packetizer → RTSP TCP-interleaved frames → consumer
|
|
132
|
-
//
|
|
133
|
-
// No ffmpeg subprocess, no UDP loopback hop, no extra forwarder layer. The kasa parts
|
|
134
|
-
// already carry the codecs we serve (annex-b H.264, G.711 µ-law); ffmpeg's only job in
|
|
135
|
-
// the previous design was RTP packetization, which is well-defined enough to do directly.
|
|
136
|
-
//
|
|
137
|
-
// Returns a handle whose `kill()` tears down everything (kasa, TCP server, active
|
|
138
|
-
// client) and whose `exited` promise resolves once that teardown is done.
|
|
139
|
-
export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStreamHandle> {
|
|
140
|
-
const { kasa, console, cachedSps, cachedPps, onFreshSpsPps } = opts;
|
|
141
|
-
|
|
142
|
-
// Fast path: caller supplied cached SPS/PPS from a previous session. Skip the scan and
|
|
143
|
-
// start streaming live parts immediately — saves the ~1-2s preScanSpsPps wait on cold
|
|
144
|
-
// starts, which dominates HomeKit's perceived stream-open latency.
|
|
145
|
-
// Validate cached buffers before trusting them. Storage corruption / schema drift /
|
|
146
|
-
// mistakenly-stored garbage would otherwise produce an SDP with an invalid
|
|
147
|
-
// profile-level-id (derived from sps[1..4]) or sprop-parameter-sets, and consumers
|
|
148
|
-
// would fail SETUP rather than recovering from in-band SPS/PPS.
|
|
149
|
-
// H.264 NAL header byte: low 5 bits = nal_unit_type. SPS = 7, PPS = 8.
|
|
150
|
-
// SPS minimum length = 4 (NAL header byte + 3 profile bytes used in the SDP).
|
|
151
|
-
const cacheValid =
|
|
152
|
-
!!cachedSps &&
|
|
153
|
-
!!cachedPps &&
|
|
154
|
-
cachedSps.length >= 4 &&
|
|
155
|
-
cachedPps.length >= 1 &&
|
|
156
|
-
(cachedSps[0] & 0x1f) === 7 &&
|
|
157
|
-
(cachedPps[0] & 0x1f) === 8;
|
|
158
|
-
if ((cachedSps || cachedPps) && !cacheValid) {
|
|
159
|
-
console.warn('[kasa-stream] cached SPS/PPS failed validation; falling back to in-band scan');
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
let sps: Buffer;
|
|
163
|
-
let pps: Buffer;
|
|
164
|
-
let buffered: KasaPart[];
|
|
165
|
-
if (cacheValid) {
|
|
166
|
-
sps = cachedSps!;
|
|
167
|
-
pps = cachedPps!;
|
|
168
|
-
buffered = [];
|
|
169
|
-
} else {
|
|
170
|
-
const scanned = await preScanSpsPps(kasa);
|
|
171
|
-
sps = scanned.sps;
|
|
172
|
-
pps = scanned.pps;
|
|
173
|
-
buffered = scanned.buffered;
|
|
174
|
-
try {
|
|
175
|
-
onFreshSpsPps?.(sps, pps);
|
|
176
|
-
} catch (e) {
|
|
177
|
-
console.warn('[kasa-stream] onFreshSpsPps callback threw', e);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
const sdp = buildSdp(sps, pps);
|
|
181
|
-
|
|
182
|
-
// Packetizers carry seq + SSRC across the whole session.
|
|
183
|
-
const h264 = new H264RtpPacketizer();
|
|
184
|
-
const g711 = new G711RtpPacketizer();
|
|
185
|
-
|
|
186
|
-
// Active forwarder — populated when the RTSP client PLAYs, cleared on teardown.
|
|
187
|
-
interface ActiveForwarder {
|
|
188
|
-
send: (channel: number, packet: Buffer) => boolean;
|
|
189
|
-
videoRtp: number;
|
|
190
|
-
videoRtcp: number;
|
|
191
|
-
audioRtp: number;
|
|
192
|
-
audioRtcp: number;
|
|
193
|
-
}
|
|
194
|
-
let active: ActiveForwarder | null = null;
|
|
195
|
-
|
|
196
|
-
let tcpServer: net.Server | undefined = undefined;
|
|
197
|
-
let activeClient: net.Socket | undefined = undefined;
|
|
198
|
-
let unregisterCleanup: (() => void) | undefined = undefined;
|
|
199
|
-
let rtcpInterval: NodeJS.Timeout | undefined = undefined;
|
|
200
|
-
|
|
201
|
-
let resolveExited!: () => void;
|
|
202
|
-
const exited = new Promise<void>(r => {
|
|
203
|
-
resolveExited = r;
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
let killed = false;
|
|
207
|
-
const cleanupAll = () => {
|
|
208
|
-
if (killed) return;
|
|
209
|
-
killed = true;
|
|
210
|
-
try {
|
|
211
|
-
unregisterCleanup?.();
|
|
212
|
-
} catch {}
|
|
213
|
-
if (rtcpInterval) {
|
|
214
|
-
clearInterval(rtcpInterval);
|
|
215
|
-
rtcpInterval = undefined;
|
|
216
|
-
}
|
|
217
|
-
try {
|
|
218
|
-
tcpServer?.close();
|
|
219
|
-
} catch {}
|
|
220
|
-
try {
|
|
221
|
-
activeClient?.destroy();
|
|
222
|
-
} catch {}
|
|
223
|
-
try {
|
|
224
|
-
kasa.destroy();
|
|
225
|
-
} catch {}
|
|
226
|
-
active = null;
|
|
227
|
-
resolveExited();
|
|
228
|
-
};
|
|
229
|
-
unregisterCleanup = registerLiveCleanup(cleanupAll);
|
|
230
|
-
|
|
231
|
-
// Pump kasa parts directly into the active forwarder. No intermediate process, pipe,
|
|
232
|
-
// or UDP hop. Drops parts until the RTSP client PLAYs (i.e. while `active` is null —
|
|
233
|
-
// start-up race only; we don't implement PAUSE semantics, since live consumers
|
|
234
|
-
// either keep playing or TEARDOWN). Backpressure: handled inside rtsp.ts `send` —
|
|
235
|
-
// drops while Node's userland write queue is full and tears down the client if the
|
|
236
|
-
// queued bytes cross a hard cap. From here we just call send and let it decide.
|
|
237
|
-
const pump = async () => {
|
|
238
|
-
const t0 = process.hrtime.bigint();
|
|
239
|
-
const replay = buffered;
|
|
240
|
-
buffered = [];
|
|
241
|
-
// Audio RTP timestamps must advance by sample count, not wall-clock — RTP
|
|
242
|
-
// receivers use the timestamp to drive playout, and any wall-clock-derived jitter
|
|
243
|
-
// would surface as audible discontinuities. Initialize once on first audio part
|
|
244
|
-
// (using elapsed time so audio and video share a roughly aligned origin), then
|
|
245
|
-
// chain via the packetizer's return value across every subsequent part.
|
|
246
|
-
let audioTs: number | undefined;
|
|
247
|
-
const handlePart = (part: KasaPart) => {
|
|
248
|
-
if (killed) return;
|
|
249
|
-
if (!active) return;
|
|
250
|
-
const fwd = active;
|
|
251
|
-
const elapsed = process.hrtime.bigint() - t0;
|
|
252
|
-
if (part.contentType === KasaMimeVideo) {
|
|
253
|
-
const ts = nanosToRtp(elapsed, H264_CLOCK_HZ);
|
|
254
|
-
h264.packetize(part.body, ts, pkt => {
|
|
255
|
-
if (active === fwd) fwd.send(fwd.videoRtp, pkt);
|
|
256
|
-
});
|
|
257
|
-
} else if (part.contentType === KasaMimeG711U) {
|
|
258
|
-
if (audioTs === undefined) audioTs = nanosToRtp(elapsed, G711_CLOCK_HZ);
|
|
259
|
-
audioTs = g711.packetize(part.body, audioTs, pkt => {
|
|
260
|
-
if (active === fwd) fwd.send(fwd.audioRtp, pkt);
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
for (const part of replay) handlePart(part);
|
|
267
|
-
// Release prebuffered parts (up to a few hundred KB each) — they were captured
|
|
268
|
-
// during the SPS/PPS scan and are only needed for this single replay.
|
|
269
|
-
replay.length = 0;
|
|
270
|
-
while (!killed) {
|
|
271
|
-
const part = await kasa.readPart();
|
|
272
|
-
handlePart(part);
|
|
273
|
-
}
|
|
274
|
-
} catch (e) {
|
|
275
|
-
if (!killed) console.warn('[kasa-stream] pump error', (e as Error).message);
|
|
276
|
-
} finally {
|
|
277
|
-
cleanupAll();
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
void pump();
|
|
281
|
-
|
|
282
|
-
// Bind the RTSP listener last, after all the supporting pieces are in place.
|
|
283
|
-
const rtspPort = await reserveTcpPort();
|
|
284
|
-
const url = `rtsp://127.0.0.1:${rtspPort}/kasa`;
|
|
285
|
-
|
|
286
|
-
tcpServer = net.createServer();
|
|
287
|
-
|
|
288
|
-
tcpServer.on('connection', client => {
|
|
289
|
-
// tcpServer.close() in cleanupAll only stops accepting NEW connections; an inflight
|
|
290
|
-
// accept can still fire 'connection' after we've started teardown. Reject those so
|
|
291
|
-
// the late client doesn't become activeClient and leak.
|
|
292
|
-
if (killed) {
|
|
293
|
-
client.destroy();
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
if (activeClient) {
|
|
297
|
-
// We only serve one consumer per stream session. Reject anything else.
|
|
298
|
-
client.destroy();
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
// RTSP-over-TCP-interleaved frames are small (~4 B header + RTP packet). Disable
|
|
302
|
-
// Nagle so frame headers don't get held up to 200 ms hoping to coalesce.
|
|
303
|
-
client.setNoDelay(true);
|
|
304
|
-
activeClient = client;
|
|
305
|
-
handleRtspClient(client, {
|
|
306
|
-
sdp,
|
|
307
|
-
console,
|
|
308
|
-
onPlay(send, channels) {
|
|
309
|
-
active = {
|
|
310
|
-
send,
|
|
311
|
-
videoRtp: channels.video.rtp,
|
|
312
|
-
videoRtcp: channels.video.rtcp,
|
|
313
|
-
audioRtp: channels.audio.rtp,
|
|
314
|
-
audioRtcp: channels.audio.rtcp,
|
|
315
|
-
};
|
|
316
|
-
// Periodic RTCP Sender Reports. Receivers map each track's RTP timeline
|
|
317
|
-
// to wall-clock NTP via SRs to keep audio/video in sync — the prior ffmpeg
|
|
318
|
-
// pipeline emitted them, so generating a minimal SR every 5 s keeps
|
|
319
|
-
// behavior parity. A single SR per track is ~28 B; cost is negligible.
|
|
320
|
-
rtcpInterval = setInterval(() => {
|
|
321
|
-
if (!active || killed) return;
|
|
322
|
-
const ntp = nowNtp();
|
|
323
|
-
const vsr = h264.senderReport(ntp);
|
|
324
|
-
if (vsr) active.send(active.videoRtcp, vsr);
|
|
325
|
-
const asr = g711.senderReport(ntp);
|
|
326
|
-
if (asr) active.send(active.audioRtcp, asr);
|
|
327
|
-
}, 5000);
|
|
328
|
-
},
|
|
329
|
-
onTeardown: cleanupAll,
|
|
330
|
-
});
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
await new Promise<void>((resolve, reject) => {
|
|
334
|
-
tcpServer!.once('error', reject);
|
|
335
|
-
tcpServer!.listen(rtspPort, '127.0.0.1', () => {
|
|
336
|
-
tcpServer!.removeListener('error', reject);
|
|
337
|
-
resolve();
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
url,
|
|
343
|
-
kill: cleanupAll,
|
|
344
|
-
exited,
|
|
345
|
-
};
|
|
346
|
-
}
|