scrypted-kasa 1.3.0 → 1.3.1

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.3.0",
3
+ "version": "1.3.1",
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",
@@ -1,6 +1,7 @@
1
1
  import crypto from 'crypto';
2
2
  import { ClientRequest } from 'http';
3
3
  import https from 'https';
4
+ import net from 'net';
4
5
  import { PassThrough } from 'stream';
5
6
 
6
7
  export const KASA_TALK_PORT = 18443;
@@ -77,6 +78,21 @@ export class KasaTalkSession {
77
78
 
78
79
  this.request = req;
79
80
 
81
+ // Disable Nagle on the underlying TCP socket as soon as it's available. Each 20 ms
82
+ // audio part we write is small (~230 B) and fewer than the segment size, so Nagle
83
+ // would coalesce parts waiting for an ACK or for the 200 ms deadline — adding
84
+ // user-perceptible chunkiness on top of the inherent network round-trip. The talk
85
+ // session is a constant low-rate stream, exactly the workload Nagle hurts.
86
+ //
87
+ // Cover both timings: the default https.Agent pools connections and may assign a
88
+ // pooled socket synchronously inside https.request(), in which case `'socket'`
89
+ // already fired by the time we get here and our once-listener would never run.
90
+ // Check `req.socket` immediately, and otherwise register a once-listener for the
91
+ // fresh-connection path.
92
+ const setSocketNoDelay = (socket: net.Socket) => socket.setNoDelay(true);
93
+ if (req.socket) setSocketNoDelay(req.socket as net.Socket);
94
+ else req.once('socket', setSocketNoDelay);
95
+
80
96
  req.on('error', e => {
81
97
  // Suppress when the close came first (deliberate teardown via close()): ending
82
98
  // the body mid-flight makes the in-flight HTTPS request emit "socket hang up"
@@ -127,12 +143,19 @@ export class KasaTalkSession {
127
143
 
128
144
  private writePart(contentType: string, body: Buffer): boolean {
129
145
  if (this.closed) return true;
130
- const header = `--${TALK_BOUNDARY}\r\nContent-Length: ${body.length}\r\nContent-Type: ${contentType}\r\n\r\n`;
131
- // Only the last write's return value matters for backpressure — if any of these
132
- // fills the buffer, Node will emit `drain` after the buffer empties.
133
- this.body.write(header);
134
- if (body.length) this.body.write(body);
135
- return this.body.write('\r\n');
146
+ const header = Buffer.from(
147
+ `--${TALK_BOUNDARY}\r\nContent-Length: ${body.length}\r\nContent-Type: ${contentType}\r\n\r\n`,
148
+ );
149
+ // Concat header + body + CRLF into one write. Three separate writes would each
150
+ // become its own chunked-encoding chunk on the HTTP body and (worse) its own TLS
151
+ // record + TCP segment. Cameras and Node's HTTP/TLS stack both prefer one part
152
+ // arriving as one segment — fewer per-packet ACKs and no risk of the camera
153
+ // partially parsing a part header before the body lands.
154
+ const trailer = Buffer.from('\r\n');
155
+ const out = body.length
156
+ ? Buffer.concat([header, body, trailer], header.length + body.length + trailer.length)
157
+ : Buffer.concat([header, trailer], header.length + trailer.length);
158
+ return this.body.write(out);
136
159
  }
137
160
 
138
161
  close(): void {
package/src/main.ts CHANGED
@@ -502,16 +502,56 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
502
502
  // -loglevel error + -nostats: without these, ffmpeg emits a `size= time= bitrate=`
503
503
  // progress line every ~0.5 s during the talkback session. On long talks that's a
504
504
  // steady stream of stderr → string allocations + log I/O for no diagnostic value.
505
+ //
506
+ // Low-latency knobs (without these, talkback lagged ~10 s — the symptom was hearing
507
+ // your own voice through the camera speaker only after releasing Push-to-Talk):
508
+ // -fflags +nobuffer : skip libavformat's input prebuffer (typically 1-2 s on SDP/SRTP).
509
+ // -fflags +flush_packets, -flush_packets 1
510
+ // : flush the AVIO output buffer after every encoded packet
511
+ // instead of when the 32 KB buffer fills (~4 s of 8 kHz mulaw).
512
+ // The two forms cover both global flags and the output-muxer
513
+ // explicit option; cheap belt-and-suspenders.
514
+ // -flags low_delay : codec-level low-delay decode/encode flag.
515
+ // -probesize 32, -analyzeduration 0
516
+ // : skip the auto-detect probe phase, which would otherwise
517
+ // wait for ~5 s of audio to estimate stream parameters
518
+ // before producing the first output sample.
519
+ // -rtsp_transport tcp, -reorder_queue_size 0 (RTSP-only, conditional)
520
+ // : force TCP-interleaved RTP and zero the demuxer's
521
+ // out-of-order reordering window. Only emitted when the
522
+ // input URL is RTSP; for non-RTSP inputs ffmpeg would just
523
+ // warn "Option not found" but the conditional keeps the arg
524
+ // list honest about which demuxer each option targets.
525
+ // -max_delay 0 : zero libavformat's max-delay setting. General — applies
526
+ // to several demuxers, including RTSP/RTP — so safe to pass
527
+ // unconditionally.
528
+ // -af aresample=async=0:min_hard_comp=0
529
+ // : disable async resampling. ffmpeg auto-inserts aresample
530
+ // when input/output sample rates differ (Opus 24 kHz →
531
+ // mulaw 8 kHz); async=0 keeps it operating on tiny frames
532
+ // without the multi-second resync window async=1 introduces.
533
+ const inputArgs = ffmpegInput.inputArguments || [];
534
+ const isRtspInput = inputArgs.some(a => typeof a === 'string' && a.startsWith('rtsp://'));
505
535
  // prettier-ignore
506
536
  const args = [
507
537
  '-hide_banner',
508
538
  '-loglevel', 'error',
509
539
  '-nostats',
510
- ...(ffmpegInput.inputArguments || []),
540
+ '-fflags', '+nobuffer+flush_packets',
541
+ '-flags', 'low_delay',
542
+ '-probesize', '32',
543
+ '-analyzeduration', '0',
544
+ '-max_delay', '0',
545
+ ...(isRtspInput ? ['-rtsp_transport', 'tcp', '-reorder_queue_size', '0'] : []),
546
+ '-thread_queue_size', '1',
547
+ '-use_wallclock_as_timestamps', '1',
548
+ ...inputArgs,
511
549
  '-vn', '-sn', '-dn',
550
+ '-af', 'aresample=async=0:min_hard_comp=0',
512
551
  '-f', 'mulaw',
513
552
  '-ar', '8000',
514
553
  '-ac', '1',
554
+ '-flush_packets', '1',
515
555
  'pipe:1',
516
556
  ];
517
557
  const cp = child_process.spawn(ffmpegPath, args);