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 +1 -1
- package/src/camera/rtp.ts +238 -0
- package/src/camera/rtsp.ts +35 -1
- package/src/camera/stream.ts +115 -262
- package/src/main.ts +4 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrypted-kasa",
|
|
3
|
-
"version": "1.
|
|
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
|
+
}
|
package/src/camera/rtsp.ts
CHANGED
|
@@ -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
|
-
|
|
99
|
+
const ok = client.write(out);
|
|
100
|
+
if (!ok) backpressured = true;
|
|
101
|
+
return ok;
|
|
68
102
|
} catch {
|
|
69
103
|
return false;
|
|
70
104
|
}
|
package/src/camera/stream.ts
CHANGED
|
@@ -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
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 →
|
|
241
|
-
//
|
|
242
|
-
//
|
|
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,
|
|
245
|
-
//
|
|
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,
|
|
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
|
-
|
|
294
|
-
const
|
|
182
|
+
// Packetizers carry seq + SSRC across the whole session.
|
|
183
|
+
const h264 = new H264RtpPacketizer();
|
|
184
|
+
const g711 = new G711RtpPacketizer();
|
|
295
185
|
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
482
|
-
tcpServer
|
|
483
|
-
tcpServer
|
|
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,
|
|
425
|
-
//
|
|
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 —
|
|
614
|
-
//
|
|
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.
|