scrypted-kasa 1.2.3 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrypted-kasa",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "TP-Link Kasa plugin for Scrypted — cameras (video, two-way audio, spotlight, siren, status LED), plugs/outlets, wall switches, dimmers, and smart bulbs (on/off, brightness, color, color temperature).",
5
5
  "author": "Sergey Vilgelm <sergey@vilgelm.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,238 @@
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
+ }
@@ -35,6 +35,15 @@ interface RtspRequest {
35
35
  // TCP-interleaved transport only — that's what Scrypted's pipeline asks for by default and
36
36
  // it sidesteps every NAT/UDP-loss complication. We build the SDP upstream and just serve it
37
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
+
38
47
  export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions): void {
39
48
  const sessionId = randomBytes(8).toString('hex');
40
49
  let videoChannels: RtspChannelPair | undefined;
@@ -42,6 +51,13 @@ export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions):
42
51
  let buffered: Buffer = Buffer.alloc(0);
43
52
  let isPlaying = false;
44
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
+ });
45
61
 
46
62
  const notifyTeardown = () => {
47
63
  if (teardownNotified) return;
@@ -51,6 +67,22 @@ export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions):
51
67
 
52
68
  const send: RtspSendInterleaved = (channel, packet) => {
53
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;
54
86
  // Interleaved frame: '$' + 1-byte channel + 2-byte BE length + RTP packet bytes.
55
87
  // Header + body in one Buffer to avoid splitting writes (a small write between the
56
88
  // two would let an out-of-order chunk arrive in the middle of an interleaved frame).
@@ -64,7 +96,9 @@ export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions):
64
96
  // ERR_STREAM_DESTROYED. Catch it; the next packet's destroyed check will gate
65
97
  // further sends.
66
98
  try {
67
- return client.write(out);
99
+ const ok = client.write(out);
100
+ if (!ok) backpressured = true;
101
+ return ok;
68
102
  } catch {
69
103
  return false;
70
104
  }
@@ -1,17 +1,12 @@
1
- import child_process from 'child_process';
2
- import dgram from 'dgram';
3
- import { once } from 'events';
4
1
  import net from 'net';
5
- import { Writable } from 'stream';
6
2
  import { findSpsPps, KasaClient, KasaMimeG711U, KasaMimeVideo, KasaPart } from './api';
3
+ import { G711_CLOCK_HZ, G711RtpPacketizer, H264_CLOCK_HZ, H264RtpPacketizer, nowNtp } from './rtp';
7
4
  import { handleRtspClient } from './rtsp';
8
5
 
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.
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.
15
10
  const liveCleanups = new Set<() => void>();
16
11
  let exitHookInstalled = false;
17
12
  function installExitHook() {
@@ -47,7 +42,6 @@ const SPS_PPS_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
47
42
 
48
43
  export interface KasaStreamOptions {
49
44
  kasa: KasaClient;
50
- ffmpegPath: string;
51
45
  console: Console;
52
46
  // Optional cached SPS/PPS from a previous session. When present we skip the
53
47
  // preScanSpsPps wait (which holds getVideoStream up to ~2s on KC420WS waiting for the
@@ -68,77 +62,6 @@ export interface KasaStreamHandle {
68
62
  exited: Promise<void>;
69
63
  }
70
64
 
71
- interface BoundUdp {
72
- socket: dgram.Socket;
73
- port: number;
74
- }
75
-
76
- interface UdpPair {
77
- rtp: BoundUdp;
78
- rtcp: BoundUdp;
79
- }
80
-
81
- // 1 MB UDP receive buffer — comfortably absorbs an IDR-frame burst on the localhost hop
82
- // from ffmpeg, which can be 100+ RTP packets arriving back-to-back. The default Linux
83
- // SO_RCVBUF (~200 KB) overflows on 1080p H.264 IDRs and drops fragments, surfacing as
84
- // "fua packet missing" in downstream consumers.
85
- const UDP_RECV_BUFFER_BYTES = 1024 * 1024;
86
-
87
- async function bindUdp(port: number = 0): Promise<BoundUdp> {
88
- const sock = dgram.createSocket('udp4');
89
- await new Promise<void>((resolve, reject) => {
90
- const onError = (e: Error) => {
91
- sock.removeListener('listening', onListen);
92
- reject(e);
93
- };
94
- const onListen = () => {
95
- sock.removeListener('error', onError);
96
- resolve();
97
- };
98
- sock.once('error', onError);
99
- sock.once('listening', onListen);
100
- sock.bind(port, '127.0.0.1');
101
- });
102
- // setRecvBufferSize tries to bump SO_RCVBUF; the kernel caps it at net.core.rmem_max
103
- // (often 200 KB on stock Linux, 2+ MB on most server distros). We don't fail if the
104
- // bump is rejected — small buffer just means we may drop on big bursts.
105
- try {
106
- sock.setRecvBufferSize(UDP_RECV_BUFFER_BYTES);
107
- } catch {
108
- /* best-effort */
109
- }
110
- return { socket: sock, port: (sock.address() as { port: number }).port };
111
- }
112
-
113
- // Bind a (RTP, RTP+1) UDP pair so RTCP gets the conventional next port. ffmpeg's `-f rtp`
114
- // output assumes RTP+1 for RTCP unless rtcpport= overrides; binding the pair ourselves
115
- // keeps the wiring straightforward. Retry up to 10 times in case RTP+1 is already taken.
116
- async function bindUdpPair(): Promise<UdpPair> {
117
- for (let attempt = 0; attempt < 10; attempt++) {
118
- const rtp = await bindUdp();
119
- try {
120
- const rtcp = await bindUdp(rtp.port + 1);
121
- return { rtp, rtcp };
122
- } catch {
123
- rtp.socket.close();
124
- }
125
- }
126
- throw new Error('failed to bind sequential RTP/RTCP udp pair after 10 tries');
127
- }
128
-
129
- // Reserve a TCP port on localhost for the RTSP server. Brief TOCTOU window between close
130
- // and re-bind is accepted — nothing else snatches loopback ports in those few ms.
131
- async function reserveTcpPort(): Promise<number> {
132
- return new Promise<number>((resolve, reject) => {
133
- const probe = net.createServer();
134
- probe.once('error', reject);
135
- probe.listen(0, '127.0.0.1', () => {
136
- const port = (probe.address() as net.AddressInfo).port;
137
- probe.close(() => resolve(port));
138
- });
139
- });
140
- }
141
-
142
65
  // Build the SDP we serve on DESCRIBE. We embed sprop-parameter-sets so consumers can decode
143
66
  // without waiting for the next IDR — important for HomeKit's strict first-frame timeouts.
144
67
  function buildSdp(sps: Buffer, pps: Buffer): string {
@@ -183,68 +106,38 @@ async function preScanSpsPps(kasa: KasaClient): Promise<{ sps: Buffer; pps: Buff
183
106
  return { sps: found.sps!, pps: found.pps!, buffered };
184
107
  }
185
108
 
186
- // Replays the buffered parts captured during the SPS/PPS scan (so ffmpeg sees the
187
- // SPS+PPS+IDR sequence required to start decoding) then continues pumping live parts. We
188
- // honor backpressure: if ffmpeg falls behind (CPU spike, paused consumer, etc.), ignoring
189
- // drain would let Node's write buffer grow without bound. Awaiting drain throttles the
190
- // camera read loop instead — the camera's TCP receive window will fill up and the kernel
191
- // will pause it for us.
192
- async function pumpKasa(
193
- kasa: KasaClient,
194
- videoPipe: Writable,
195
- audioPipe: Writable,
196
- prebuffered: KasaPart[],
197
- ): Promise<void> {
198
- const writePart = async (part: KasaPart) => {
199
- const pipe =
200
- part.contentType === KasaMimeVideo ? videoPipe : part.contentType === KasaMimeG711U ? audioPipe : undefined;
201
- if (!pipe || pipe.writableEnded || pipe.destroyed) return;
202
- if (!pipe.write(part.body)) {
203
- // Race 'drain' against teardown. If the pipe closes/errors before draining,
204
- // 'drain' never fires and the pump would hang forever. AbortController removes
205
- // the losing listeners after the race so frequent backpressure cycles don't
206
- // accumulate listeners on the pipe.
207
- const ac = new AbortController();
208
- try {
209
- await Promise.race([
210
- once(pipe, 'drain', { signal: ac.signal }).catch(() => {}),
211
- once(pipe, 'close', { signal: ac.signal }).catch(() => {}),
212
- once(pipe, 'error', { signal: ac.signal }).catch(() => {}),
213
- ]);
214
- } finally {
215
- ac.abort();
216
- }
217
- }
218
- };
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
+ }
219
121
 
220
- try {
221
- for (const part of prebuffered) await writePart(part);
222
- // Release prebuffered parts (up to a few hundred KB each) — they were captured
223
- // during the SPS/PPS scan and are only needed for this single replay.
224
- prebuffered.length = 0;
225
- while (true) {
226
- const part = await kasa.readPart();
227
- await writePart(part);
228
- }
229
- } finally {
230
- try {
231
- videoPipe.end();
232
- } catch {}
233
- try {
234
- audioPipe.end();
235
- } catch {}
236
- }
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;
237
128
  }
238
129
 
239
130
  // Orchestrates the kasa→consumer streaming pipeline:
240
- // kasa multipart → ffmpeg pipe inputs (codec copy) → RTP UDP outputs
241
- // → minimal RTSP server forwards UDP RTP as TCP-interleaved frames
242
- // → consumer reads via rtsp:// URL
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.
243
136
  //
244
- // Returns a handle whose `kill()` tears down everything (kasa, ffmpeg, UDP sockets, TCP
245
- // server, active client) and whose `exited` promise resolves once that teardown is done.
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.
246
139
  export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStreamHandle> {
247
- const { kasa, ffmpegPath, console, cachedSps, cachedPps, onFreshSpsPps } = opts;
140
+ const { kasa, console, cachedSps, cachedPps, onFreshSpsPps } = opts;
248
141
 
249
142
  // Fast path: caller supplied cached SPS/PPS from a previous session. Skip the scan and
250
143
  // start streaming live parts immediately — saves the ~1-2s preScanSpsPps wait on cold
@@ -263,8 +156,6 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
263
156
  (cachedSps[0] & 0x1f) === 7 &&
264
157
  (cachedPps[0] & 0x1f) === 8;
265
158
  if ((cachedSps || cachedPps) && !cacheValid) {
266
- // Caller will overwrite the bad cache via onFreshSpsPps below once the slow path
267
- // produces fresh values; nothing to do here beyond logging and falling through.
268
159
  console.warn('[kasa-stream] cached SPS/PPS failed validation; falling back to in-band scan');
269
160
  }
270
161
 
@@ -280,8 +171,6 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
280
171
  sps = scanned.sps;
281
172
  pps = scanned.pps;
282
173
  buffered = scanned.buffered;
283
- // Persist for next time. Pass the original error object (not just .message) so
284
- // stack/context survive — non-Error throws would lose everything otherwise.
285
174
  try {
286
175
  onFreshSpsPps?.(sps, pps);
287
176
  } catch (e) {
@@ -290,73 +179,30 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
290
179
  }
291
180
  const sdp = buildSdp(sps, pps);
292
181
 
293
- const videoUdp = await bindUdpPair();
294
- const audioUdp = await bindUdpPair();
182
+ // Packetizers carry seq + SSRC across the whole session.
183
+ const h264 = new H264RtpPacketizer();
184
+ const g711 = new G711RtpPacketizer();
295
185
 
296
- // Two RTP outputs, one per track. `-vn`/`-an` per output filter the unwanted side
297
- // rather than using `-map`. Explicit -payload_type lines pin the PTs to the values our
298
- // SDP advertises (96 for H.264, 0 for PCMU) so a future ffmpeg default change can't
299
- // silently break the SDP/stream contract.
300
- // prettier-ignore
301
- const args = [
302
- '-hide_banner',
303
- '-loglevel', 'error',
304
- '-thread_queue_size', '1024',
305
- '-f', 'h264',
306
- '-i', 'pipe:3',
307
- '-thread_queue_size', '1024',
308
- '-f', 'mulaw',
309
- '-ar', '8000',
310
- '-ac', '1',
311
- '-i', 'pipe:4',
312
- '-vcodec', 'copy', '-an', '-dn', '-sn',
313
- '-f', 'rtp',
314
- '-payload_type', '96',
315
- `rtp://127.0.0.1:${videoUdp.rtp.port}?rtcpport=${videoUdp.rtcp.port}`,
316
- '-acodec', 'copy', '-vn', '-dn', '-sn',
317
- '-f', 'rtp',
318
- '-payload_type', '0',
319
- `rtp://127.0.0.1:${audioUdp.rtp.port}?rtcpport=${audioUdp.rtcp.port}`,
320
- ];
321
-
322
- const cp = child_process.spawn(ffmpegPath, args, {
323
- stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
324
- });
325
- cp.stderr?.on('data', d => {
326
- const text = d.toString().trim();
327
- if (text) console.log('[kasa-ffmpeg]', text);
328
- });
329
-
330
- const videoPipe = cp.stdio[3] as Writable;
331
- const audioPipe = cp.stdio[4] as Writable;
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;
332
195
 
333
- // Attach 'error' listeners on both pipes IMMEDIATELY. When cleanupAll calls cp.kill,
334
- // ffmpeg's stdio FDs close forcibly and any in-flight pump write throws ECONNRESET /
335
- // EPIPE on the writable side. Without listeners, Node turns those into
336
- // uncaughtException and crashes the whole plugin worker (taking down every camera).
337
- // The `_e` arg is intentional — we just want the stream to be informed there's a
338
- // listener.
339
- videoPipe.on('error', _e => {
340
- /* tear down via the kasa pump's own try/finally */
341
- });
342
- audioPipe.on('error', _e => {
343
- /* tear down via the kasa pump's own try/finally */
344
- });
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;
345
200
 
346
201
  let resolveExited!: () => void;
347
202
  const exited = new Promise<void>(r => {
348
203
  resolveExited = r;
349
204
  });
350
205
 
351
- // Forward-declare anything cleanupAll touches that's initialized later in the function.
352
- // ffmpeg's `exit`/`error` events or a pump throw can fire cleanupAll while we're still
353
- // awaiting `reserveTcpPort()` below — referencing TDZ const bindings here would throw
354
- // an uncaught ReferenceError and skip the rest of teardown. Explicit `= undefined` so
355
- // eslint's prefer-const sees the later reassignment.
356
- let tcpServer: net.Server | undefined = undefined;
357
- let activeClient: net.Socket | undefined = undefined;
358
- let unregisterCleanup: (() => void) | undefined = undefined;
359
-
360
206
  let killed = false;
361
207
  const cleanupAll = () => {
362
208
  if (killed) return;
@@ -364,6 +210,10 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
364
210
  try {
365
211
  unregisterCleanup?.();
366
212
  } catch {}
213
+ if (rtcpInterval) {
214
+ clearInterval(rtcpInterval);
215
+ rtcpInterval = undefined;
216
+ }
367
217
  try {
368
218
  tcpServer?.close();
369
219
  } catch {}
@@ -373,70 +223,61 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
373
223
  try {
374
224
  kasa.destroy();
375
225
  } catch {}
376
- try {
377
- cp.kill('SIGKILL');
378
- } catch {}
379
- try {
380
- videoUdp.rtp.socket.close();
381
- } catch {}
382
- try {
383
- videoUdp.rtcp.socket.close();
384
- } catch {}
385
- try {
386
- audioUdp.rtp.socket.close();
387
- } catch {}
388
- try {
389
- audioUdp.rtcp.socket.close();
390
- } catch {}
226
+ active = null;
391
227
  resolveExited();
392
228
  };
393
- // Register with the process-level cleanup registry so worker termination kills the
394
- // ffmpeg subprocess + closes the kasa connection even if our normal teardown path
395
- // (release(), kill switch, ffmpeg exit) didn't run.
396
229
  unregisterCleanup = registerLiveCleanup(cleanupAll);
397
230
 
398
- // ffmpeg exit (or spawn error) is itself a teardown trigger.
399
- cp.once('exit', cleanupAll);
400
- cp.once('error', cleanupAll);
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
+ };
401
264
 
402
- // Pump kasa → ffmpeg pipes. If the pump throws (kasa stream ended/errored), tear down.
403
- // We suppress the warn when `killed` is already set, because that means our own
404
- // cleanupAll destroyed the kasa connection and the resulting "stream ended" throw is
405
- // expected close-sequence noise, not a real error.
406
- pumpKasa(kasa, videoPipe, audioPipe, buffered)
407
- .catch(e => {
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) {
408
275
  if (!killed) console.warn('[kasa-stream] pump error', (e as Error).message);
409
- })
410
- .finally(cleanupAll);
411
-
412
- // Forwarding state — populated atomically when the RTSP client PLAYs, used by the
413
- // always-attached UDP message handlers below. One reference instead of two so the
414
- // handlers can't observe a half-set state under any scheduling.
415
- interface ActiveForwarder {
416
- send: (channel: number, packet: Buffer) => boolean;
417
- videoRtp: number;
418
- videoRtcp: number;
419
- audioRtp: number;
420
- audioRtcp: number;
421
- }
422
- let active: ActiveForwarder | null = null;
423
-
424
- // Attach UDP listeners IMMEDIATELY (not on PLAY) so the kernel buffer gets drained as
425
- // soon as ffmpeg starts producing packets. Without this, packets sent before the RTSP
426
- // client connects pile up in SO_RCVBUF and either get dropped on overflow, or block
427
- // libuv from polling the socket cleanly. Until `active` is set, we just discard.
428
- videoUdp.rtp.socket.on('message', msg => {
429
- if (active) active.send(active.videoRtp, msg);
430
- });
431
- videoUdp.rtcp.socket.on('message', msg => {
432
- if (active) active.send(active.videoRtcp, msg);
433
- });
434
- audioUdp.rtp.socket.on('message', msg => {
435
- if (active) active.send(active.audioRtp, msg);
436
- });
437
- audioUdp.rtcp.socket.on('message', msg => {
438
- if (active) active.send(active.audioRtcp, msg);
439
- });
276
+ } finally {
277
+ cleanupAll();
278
+ }
279
+ };
280
+ void pump();
440
281
 
441
282
  // Bind the RTSP listener last, after all the supporting pieces are in place.
442
283
  const rtspPort = await reserveTcpPort();
@@ -472,15 +313,27 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
472
313
  audioRtp: channels.audio.rtp,
473
314
  audioRtcp: channels.audio.rtcp,
474
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);
475
328
  },
476
329
  onTeardown: cleanupAll,
477
330
  });
478
331
  });
479
332
 
480
333
  await new Promise<void>((resolve, reject) => {
481
- tcpServer.once('error', reject);
482
- tcpServer.listen(rtspPort, '127.0.0.1', () => {
483
- tcpServer.removeListener('error', reject);
334
+ tcpServer!.once('error', reject);
335
+ tcpServer!.listen(rtspPort, '127.0.0.1', () => {
336
+ tcpServer!.removeListener('error', reject);
484
337
  resolve();
485
338
  });
486
339
  });
package/src/main.ts CHANGED
@@ -421,8 +421,8 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
421
421
  password,
422
422
  });
423
423
 
424
- // Single shared kill switch: any teardown source (kasa close, ffmpeg exit, RTSP
425
- // client disconnect, pump error) resolves it, and every owned resource registers a
424
+ // Single shared kill switch: any teardown source (kasa close, RTSP client
425
+ // disconnect, pump error) resolves it, and every owned resource registers a
426
426
  // cleanup against it. spawnKasaStream owns the actual resource teardown; we just
427
427
  // surface the kill switch to settings-change handlers via activeStreamKills.
428
428
  const kill = new Deferred<void>();
@@ -431,7 +431,6 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
431
431
  kasa.body.on('close', () => kill.resolve());
432
432
  kasa.body.on('error', () => kill.resolve());
433
433
 
434
- const ffmpegPath = await mediaManager.getFFmpegPath();
435
434
  // Read cached SPS/PPS from per-device storage. Lets spawnKasaStream skip the
436
435
  // ~1-2s preScanSpsPps wait on cold starts (HomeKit's perceived latency dominates
437
436
  // here). Cache survives plugin restarts. Stale-cache risk is bounded — in-band
@@ -446,7 +445,6 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
446
445
  try {
447
446
  stream = await spawnKasaStream({
448
447
  kasa,
449
- ffmpegPath,
450
448
  console: this.console,
451
449
  cachedSps,
452
450
  cachedPps,
@@ -610,8 +608,8 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
610
608
  // and kasa HTTPS sockets running after the plugin is gone, since child_process is
611
609
  // owned by the host Node process, not the worker.
612
610
  release(): void {
613
- // Each kill triggers cleanupAll inside spawnKasaStream — kills ffmpeg, destroys
614
- // the kasa connection, closes UDP sockets and the local RTSP server.
611
+ // Each kill triggers cleanupAll inside spawnKasaStream — destroys the kasa
612
+ // connection and closes the local RTSP server.
615
613
  for (const kill of [...this.activeStreamKills]) kill.resolve();
616
614
  // Stop the intercom too if anything was active. stopIntercom is async only because
617
615
  // its signature is — the work it does is synchronous, so fire-and-forget is fine.