scrypted-kasa 1.0.0 → 1.1.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.
@@ -15,9 +15,9 @@ jobs:
15
15
  check:
16
16
  runs-on: ubuntu-latest
17
17
  steps:
18
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
18
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19
19
 
20
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
20
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
21
21
  with:
22
22
  node-version-file: '.nvmrc'
23
23
  cache: 'npm'
@@ -15,9 +15,9 @@ jobs:
15
15
  # via the GitHub OIDC token so consumers can verify it was built by this workflow.
16
16
  id-token: write
17
17
  steps:
18
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
18
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19
19
 
20
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
20
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
21
21
  with:
22
22
  node-version-file: '.nvmrc'
23
23
  cache: 'npm'
package/.nvmrc CHANGED
@@ -1 +1 @@
1
- v24
1
+ v25
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/scrypted-kasa.svg)](https://www.npmjs.com/package/scrypted-kasa)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
5
 
6
- Adds support for the TP-Link Kasa device family to Scrypted: cameras, plugs/outlets,
6
+ Adds support for the TP-Link Kasa device family to [Scrypted](https://www.scrypted.app): cameras, plugs/outlets,
7
7
  switches/dimmers, and bulbs. Discovery is unified — one UDP/9999 sweep finds everything
8
8
  and the plugin routes each device to the right Scrypted interfaces.
9
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrypted-kasa",
3
- "version": "1.0.0",
3
+ "version": "1.1.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",
@@ -58,7 +58,9 @@
58
58
  "ScryptedDeviceCreator",
59
59
  "DeviceProvider",
60
60
  "DeviceCreator",
61
- "DeviceDiscovery"
61
+ "DeviceDiscovery",
62
+ "Readme",
63
+ "Settings"
62
64
  ],
63
65
  "pluginDependencies": [
64
66
  "@scrypted/prebuffer-mixin"
@@ -67,7 +69,7 @@
67
69
  "devDependencies": {
68
70
  "@eslint/js": "^10.0.1",
69
71
  "@scrypted/sdk": "^0.5.59",
70
- "@types/node": "^24.12.2",
72
+ "@types/node": "^25.6.0",
71
73
  "eslint": "^10.2.1",
72
74
  "prettier": "^3.8.3",
73
75
  "typescript-eslint": "^8.59.1"
package/src/camera/api.ts CHANGED
@@ -14,11 +14,26 @@ export const KASA_STREAM_PATH = '/https/stream/mixed';
14
14
  // retaining memory.
15
15
  const MAX_HEADER_BYTES = 32 * 1024;
16
16
 
17
- // Socket idle timeout. Doubles as a connect timeout (a camera that's reachable on TCP but
18
- // never finishes TLS / never sends headers will hit this) AND as a stream-stall timeout
19
- // (real cameras stream continuously; a 10 s silence means the camera or network is dead).
20
- // Without this, a hung camera causes getVideoStream to block indefinitely.
21
- const KASA_STREAM_IDLE_TIMEOUT_MS = 10000;
17
+ // Connect-phase idle timeout: while establishing the HTTPS request and waiting for response
18
+ // headers, fires only if the socket is idle for this long. Covers TLS handshake hangs and
19
+ // unresponsive-but-routable cameras — but it's not a strict wall-clock deadline if there
20
+ // is intermittent socket activity without headers arriving. Once headers arrive we clear
21
+ // the socket timeout because kasa cameras on marginal Wi-Fi can go briefly silent (channel
22
+ // scan, AP roam, transient congestion) and recover; killing the stream on every such gap
23
+ // causes more harm than good. Half-open TCP detection is delegated to OS keepalive (see
24
+ // setKeepAlive below) so a Wi-Fi drop without a clean FIN still gets cleaned up.
25
+ const KASA_STREAM_CONNECT_TIMEOUT_MS = 10000;
26
+
27
+ // Initial idle before the OS sends the first TCP keepalive probe on the streaming socket.
28
+ // During normal streaming, frame-level data activity resets this timer so probes never
29
+ // fire. If the connection dies (Wi-Fi drop, NAT timeout, switch reset), keepalive probes
30
+ // eventually cause the OS to close the socket and our `body.on('close')` triggers teardown.
31
+ // Note: `socket.setKeepAlive(true, initialDelay)` controls only this initial idle period
32
+ // (TCP_KEEPIDLE); total detection/teardown time still depends on OS-level keepalive
33
+ // interval (TCP_KEEPINTVL) and probe count (TCP_KEEPCNT). Linux defaults are often too
34
+ // slow for a live video stream (TCP_KEEPIDLE may default to 7200 s), so 30 s here just
35
+ // starts probing sooner after the stream goes idle.
36
+ const KASA_STREAM_KEEPALIVE_INITIAL_DELAY_MS = 30000;
22
37
 
23
38
  export const KasaMimeVideo = 'video/x-h264';
24
39
  export const KasaMimeG711U = 'audio/g711u';
@@ -99,9 +114,6 @@ export class KasaClient {
99
114
 
100
115
  // Cameras present a self-signed cert. The body is consumed as a long-lived stream
101
116
  // rather than buffered to completion, so we don't read the response body here.
102
- // The setTimeout below covers both the initial connect/handshake and ongoing stream
103
- // idleness — Node's socket idle timeout resets on activity, so a continuously
104
- // streaming camera never trips it; only stalls or unanswered handshakes do.
105
117
  const response = await new Promise<IncomingMessage>((resolve, reject) => {
106
118
  const req = https.request(
107
119
  {
@@ -116,10 +128,23 @@ export class KasaClient {
116
128
  Connection: 'keep-alive',
117
129
  },
118
130
  },
119
- resolve,
131
+ response => {
132
+ // Headers in — clear the connect-phase idle timeout so it doesn't
133
+ // keep firing during normal streaming gaps. setTimeout(0) on the
134
+ // underlying socket disables Node's idle-timeout machinery.
135
+ response.socket?.setTimeout(0);
136
+ // Enable OS-level TCP keepalive as a safety net for half-open
137
+ // detection. During normal streaming the data flow keeps this from
138
+ // firing, but if Wi-Fi drops / NAT times out without a clean FIN,
139
+ // keepalive probes will eventually fail and the kernel will close
140
+ // the socket — without keepalive we'd block forever in readPart()
141
+ // waiting for a multipart header that never arrives.
142
+ response.socket?.setKeepAlive(true, KASA_STREAM_KEEPALIVE_INITIAL_DELAY_MS);
143
+ resolve(response);
144
+ },
120
145
  );
121
- req.setTimeout(KASA_STREAM_IDLE_TIMEOUT_MS, () => {
122
- req.destroy(new Error(`kasa stream socket idle timeout after ${KASA_STREAM_IDLE_TIMEOUT_MS}ms`));
146
+ req.setTimeout(KASA_STREAM_CONNECT_TIMEOUT_MS, () => {
147
+ req.destroy(new Error(`kasa stream connect timeout after ${KASA_STREAM_CONNECT_TIMEOUT_MS}ms`));
123
148
  });
124
149
  req.on('error', reject);
125
150
  req.end();
@@ -153,7 +153,7 @@ export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions):
153
153
  };
154
154
 
155
155
  const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
156
- client.on('data', chunk => {
156
+ client.on('data', (chunk: Buffer) => {
157
157
  buffered = buffered.length === 0 ? chunk : Buffer.concat([buffered, chunk]);
158
158
  // Parse messages until we run out of complete ones in the buffer.
159
159
  while (buffered.length > 0) {
@@ -49,6 +49,17 @@ export interface KasaStreamOptions {
49
49
  kasa: KasaClient;
50
50
  ffmpegPath: string;
51
51
  console: Console;
52
+ // Optional cached SPS/PPS from a previous session. When present we skip the
53
+ // preScanSpsPps wait (which holds getVideoStream up to ~2s on KC420WS waiting for the
54
+ // next IDR) and start streaming live parts immediately. Cached values come from this
55
+ // camera's own previous bitstream so they're correct unless the encoder profile
56
+ // changed (resolution / bitrate setting); even when stale, the in-band SPS/PPS that
57
+ // arrive with the next IDR will replace them at the consumer's decoder.
58
+ cachedSps?: Buffer;
59
+ cachedPps?: Buffer;
60
+ // Called when we successfully scanned fresh SPS/PPS from the live bitstream. The
61
+ // caller persists these so subsequent sessions can use the cached path.
62
+ onFreshSpsPps?: (sps: Buffer, pps: Buffer) => void;
52
63
  }
53
64
 
54
65
  export interface KasaStreamHandle {
@@ -233,9 +244,50 @@ async function pumpKasa(
233
244
  // Returns a handle whose `kill()` tears down everything (kasa, ffmpeg, UDP sockets, TCP
234
245
  // server, active client) and whose `exited` promise resolves once that teardown is done.
235
246
  export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStreamHandle> {
236
- const { kasa, ffmpegPath, console } = opts;
247
+ const { kasa, ffmpegPath, console, cachedSps, cachedPps, onFreshSpsPps } = opts;
248
+
249
+ // Fast path: caller supplied cached SPS/PPS from a previous session. Skip the scan and
250
+ // start streaming live parts immediately — saves the ~1-2s preScanSpsPps wait on cold
251
+ // starts, which dominates HomeKit's perceived stream-open latency.
252
+ // Validate cached buffers before trusting them. Storage corruption / schema drift /
253
+ // mistakenly-stored garbage would otherwise produce an SDP with an invalid
254
+ // profile-level-id (derived from sps[1..4]) or sprop-parameter-sets, and consumers
255
+ // would fail SETUP rather than recovering from in-band SPS/PPS.
256
+ // H.264 NAL header byte: low 5 bits = nal_unit_type. SPS = 7, PPS = 8.
257
+ // SPS minimum length = 4 (NAL header byte + 3 profile bytes used in the SDP).
258
+ const cacheValid =
259
+ !!cachedSps &&
260
+ !!cachedPps &&
261
+ cachedSps.length >= 4 &&
262
+ cachedPps.length >= 1 &&
263
+ (cachedSps[0] & 0x1f) === 7 &&
264
+ (cachedPps[0] & 0x1f) === 8;
265
+ 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
+ console.warn('[kasa-stream] cached SPS/PPS failed validation; falling back to in-band scan');
269
+ }
237
270
 
238
- const { sps, pps, buffered } = await preScanSpsPps(kasa);
271
+ let sps: Buffer;
272
+ let pps: Buffer;
273
+ let buffered: KasaPart[];
274
+ if (cacheValid) {
275
+ sps = cachedSps!;
276
+ pps = cachedPps!;
277
+ buffered = [];
278
+ } else {
279
+ const scanned = await preScanSpsPps(kasa);
280
+ sps = scanned.sps;
281
+ pps = scanned.pps;
282
+ 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
+ try {
286
+ onFreshSpsPps?.(sps, pps);
287
+ } catch (e) {
288
+ console.warn('[kasa-stream] onFreshSpsPps callback threw', e);
289
+ }
290
+ }
239
291
  const sdp = buildSdp(sps, pps);
240
292
 
241
293
  const videoUdp = await bindUdpPair();
@@ -278,6 +330,19 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
278
330
  const videoPipe = cp.stdio[3] as Writable;
279
331
  const audioPipe = cp.stdio[4] as Writable;
280
332
 
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
+ });
345
+
281
346
  let resolveExited!: () => void;
282
347
  const exited = new Promise<void>(r => {
283
348
  resolveExited = r;
package/src/iot/bulb.ts CHANGED
@@ -4,12 +4,14 @@ import {
4
4
  ColorSettingHsv,
5
5
  ColorSettingTemperature,
6
6
  OnOff,
7
+ Readme,
7
8
  ScryptedDeviceBase,
8
9
  Setting,
9
10
  Settings,
10
11
  SettingValue,
11
12
  } from '@scrypted/sdk';
12
13
  import { StorageSettings } from '@scrypted/sdk/storage-settings';
14
+ import { formatKasaMac, renderKv } from '../shared/readme';
13
15
  import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
14
16
 
15
17
  const STATE_POLL_INTERVAL_MS = 30000;
@@ -26,7 +28,7 @@ const STATE_POLL_INTERVAL_MS = 30000;
26
28
  // in `getTemperatureMinK`/`getTemperatureMaxK` and fall back to a safe pair otherwise.
27
29
  export class KasaBulb
28
30
  extends ScryptedDeviceBase
29
- implements OnOff, Brightness, ColorSettingHsv, ColorSettingTemperature, Settings
31
+ implements OnOff, Brightness, ColorSettingHsv, ColorSettingTemperature, Settings, Readme
30
32
  {
31
33
  storageSettings = new StorageSettings(this, {
32
34
  ip: {
@@ -86,6 +88,49 @@ export class KasaBulb
86
88
  return this.storageSettings.putSetting(key, value);
87
89
  }
88
90
 
91
+ // Per-device Readme tab. Bulbs don't share KasaIotDevice's base implementation
92
+ // (different inheritance chain because of the color/brightness mixins), so the
93
+ // structure is duplicated here on purpose. Adds the static capability list (color /
94
+ // color-temp range) since those depend on per-model sysinfo flags. Live state is not
95
+ // duplicated here — the device page already shows it.
96
+ async getReadmeMarkdown(): Promise<string> {
97
+ const info = this.info || {};
98
+ const ip = this.storageSettings.values.ip || info.ip || '?';
99
+ const port = this.storageSettings.values.port || KASA_IOT_PORT;
100
+ const { isColor, isVariableColorTemp, colorTemperatureMinK, colorTemperatureMaxK } =
101
+ this.storageSettings.values;
102
+
103
+ const capabilities: string[] = ['Brightness'];
104
+ if (isColor) capabilities.push('Color (HSV)');
105
+ if (isVariableColorTemp)
106
+ capabilities.push(`Color temperature (${colorTemperatureMinK}–${colorTemperatureMaxK} K)`);
107
+
108
+ return [
109
+ `# ${this.name || 'Kasa Bulb'}`,
110
+ '',
111
+ '## Device',
112
+ '',
113
+ '```',
114
+ renderKv([
115
+ ['Model', info.model || '?'],
116
+ ['Firmware', info.firmware || '?'],
117
+ ['MAC', formatKasaMac(info.mac)],
118
+ ['IP', ip],
119
+ ['Port', String(port)],
120
+ ]),
121
+ '```',
122
+ '',
123
+ '## Capabilities',
124
+ '',
125
+ ...capabilities.map(c => `- ${c}`),
126
+ '',
127
+ '## Protocol',
128
+ '',
129
+ `Local TCP/${port} — Kasa's legacy "smarthome" wire format. All bulb state changes`,
130
+ 'are issued via `smartlife.iot.smartbulb.lightingservice.transition_light_state`.',
131
+ ].join('\n');
132
+ }
133
+
89
134
  async turnOn(): Promise<void> {
90
135
  await this.lightState({ on_off: 1 });
91
136
  this.on = true;
package/src/iot/device.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { OnOff, ScryptedDeviceBase, Setting, Settings, SettingValue } from '@scrypted/sdk';
1
+ import { OnOff, Readme, ScryptedDeviceBase, Setting, Settings, SettingValue } from '@scrypted/sdk';
2
2
  import { StorageSettings } from '@scrypted/sdk/storage-settings';
3
+ import { formatKasaMac, renderKv } from '../shared/readme';
3
4
  import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
4
5
 
5
6
  const STATE_POLL_INTERVAL_MS = 30000;
@@ -13,7 +14,7 @@ const STATE_POLL_INTERVAL_MS = 30000;
13
14
  // query: {"system":{"get_sysinfo":{}}}
14
15
  //
15
16
  // Multi-outlet strips (HS300, KP303) aren't modeled — they need per-child relay handling.
16
- export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff, Settings {
17
+ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff, Settings, Readme {
17
18
  storageSettings = new StorageSettings(this, {
18
19
  ip: {
19
20
  title: 'IP Address',
@@ -55,6 +56,34 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
55
56
  return this.storageSettings.putSetting(key, value);
56
57
  }
57
58
 
59
+ // Per-device Readme tab. Surfaces static identity / network info — live state
60
+ // (on/off, brightness) is not duplicated here, the device page already shows it.
61
+ async getReadmeMarkdown(): Promise<string> {
62
+ const info = this.info || {};
63
+ const ip = this.storageSettings.values.ip || info.ip || '?';
64
+ const port = this.storageSettings.values.port || KASA_IOT_PORT;
65
+ return [
66
+ `# ${this.name || 'Kasa Device'}`,
67
+ '',
68
+ '## Device',
69
+ '',
70
+ '```',
71
+ renderKv([
72
+ ['Model', info.model || '?'],
73
+ ['Firmware', info.firmware || '?'],
74
+ ['MAC', formatKasaMac(info.mac)],
75
+ ['IP', ip],
76
+ ['Port', String(port)],
77
+ ]),
78
+ '```',
79
+ '',
80
+ '## Protocol',
81
+ '',
82
+ `Local TCP/${port} — Kasa's legacy "smarthome" wire format. No cloud account or`,
83
+ 'credentials required, the plugin reaches the device directly on the LAN.',
84
+ ].join('\n');
85
+ }
86
+
58
87
  async turnOn(): Promise<void> {
59
88
  await this.callIot({ system: { set_relay_state: { state: 1 } } });
60
89
  this.on = true;
@@ -90,7 +90,7 @@ export async function kasaIotCall(options: KasaIotOptions, command: Record<strin
90
90
  return out;
91
91
  };
92
92
 
93
- socket.on('data', chunk => {
93
+ socket.on('data', (chunk: Buffer) => {
94
94
  chunks.push(chunk);
95
95
  queuedBytes += chunk.length;
96
96
  if (expected < 0) {
package/src/main.ts CHANGED
@@ -10,6 +10,7 @@ import sdk, {
10
10
  Intercom,
11
11
  MediaObject,
12
12
  OnOff,
13
+ Readme,
13
14
  RequestMediaStreamOptions,
14
15
  ResponseMediaStreamOptions,
15
16
  ScryptedDeviceBase,
@@ -35,6 +36,7 @@ import { KasaPlug } from './iot/plug';
35
36
  import { KASA_IOT_PORT } from './iot/protocol';
36
37
  import { KasaSwitch } from './iot/switch';
37
38
  import { discoverKasa, KasaDiscoveredDevice } from './shared/discovery';
39
+ import { formatKasaMac, renderKv } from './shared/readme';
38
40
 
39
41
  // G.711 µ-law packetization: 8000 samples/sec * 1 byte/sample = 160 bytes/20ms.
40
42
  // 20 ms is the standard RTP packetization for PCMU and matches what the Kasa app appears
@@ -182,7 +184,7 @@ class KasaCameraSiren extends ScryptedDeviceBase implements OnOff {
182
184
  }
183
185
  }
184
186
 
185
- class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Intercom, DeviceProvider, OnOff {
187
+ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Intercom, DeviceProvider, OnOff, Readme {
186
188
  private intercomSession?: KasaTalkSession;
187
189
  private intercomFfmpeg?: ChildProcess;
188
190
  private spotlight?: KasaCameraSpotlight;
@@ -318,6 +320,47 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
318
320
  return this.storageSettings.getSettings();
319
321
  }
320
322
 
323
+ // Per-camera Readme tab. Surfaces what the user can't easily see otherwise: the
324
+ // firmware/serial the adopted device reports and which HTTPS ports the plugin is
325
+ // talking to (stream / talk / control). Live state (LED on/off, spotlight on/off,
326
+ // motion, ...) is not duplicated here — the device page already shows it live.
327
+ async getReadmeMarkdown(): Promise<string> {
328
+ const info = this.info || {};
329
+ const { ip, port } = this.storageSettings.values;
330
+ const ipLine = ip || info.ip || '?';
331
+ const streamPort = port || KASA_DEFAULT_PORT;
332
+ return [
333
+ `# ${this.name || 'Kasa Camera'}`,
334
+ '',
335
+ '## Device',
336
+ '',
337
+ '```',
338
+ renderKv([
339
+ ['Model', info.model || '?'],
340
+ ['Firmware', info.firmware || '?'],
341
+ ['Serial', info.serialNumber || '?'],
342
+ ['MAC', formatKasaMac(info.mac)],
343
+ ['IP', ipLine],
344
+ ['Stream port', String(streamPort)],
345
+ ]),
346
+ '```',
347
+ '',
348
+ '## Endpoints',
349
+ '',
350
+ 'The plugin talks to the camera over three separate HTTPS endpoints. The talk and',
351
+ 'control endpoints are fixed in the camera firmware; the stream port can be edited',
352
+ "in the camera's Settings tab if needed:",
353
+ '',
354
+ '```',
355
+ renderKv([
356
+ ['Stream', `https://${ipLine}:${streamPort}/https/stream/mixed`],
357
+ ['Talk', `https://${ipLine}:${KASA_TALK_PORT}/https/speaker/audio/g711block`],
358
+ ['Control', `https://${ipLine}:10443/data/LINKIE2.json`],
359
+ ]),
360
+ '```',
361
+ ].join('\n');
362
+ }
363
+
321
364
  async putSetting(key: string, value: SettingValue): Promise<void> {
322
365
  await this.storageSettings.putSetting(key, value);
323
366
  if (key === 'ip' || key === 'port' || key === 'username' || key === 'password') {
@@ -389,9 +432,29 @@ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, In
389
432
  kasa.body.on('error', () => kill.resolve());
390
433
 
391
434
  const ffmpegPath = await mediaManager.getFFmpegPath();
435
+ // Read cached SPS/PPS from per-device storage. Lets spawnKasaStream skip the
436
+ // ~1-2s preScanSpsPps wait on cold starts (HomeKit's perceived latency dominates
437
+ // here). Cache survives plugin restarts. Stale-cache risk is bounded — in-band
438
+ // SPS/PPS in the live bitstream supersede whatever we put in the SDP.
439
+ const storage = deviceManager.getDeviceStorage(this.nativeId);
440
+ const cachedSpsB64 = storage?.getItem('cachedSps') || undefined;
441
+ const cachedPpsB64 = storage?.getItem('cachedPps') || undefined;
442
+ const cachedSps = cachedSpsB64 ? Buffer.from(cachedSpsB64, 'base64') : undefined;
443
+ const cachedPps = cachedPpsB64 ? Buffer.from(cachedPpsB64, 'base64') : undefined;
444
+
392
445
  let stream: KasaStreamHandle;
393
446
  try {
394
- stream = await spawnKasaStream({ kasa, ffmpegPath, console: this.console });
447
+ stream = await spawnKasaStream({
448
+ kasa,
449
+ ffmpegPath,
450
+ console: this.console,
451
+ cachedSps,
452
+ cachedPps,
453
+ onFreshSpsPps: (sps, pps) => {
454
+ storage?.setItem('cachedSps', sps.toString('base64'));
455
+ storage?.setItem('cachedPps', pps.toString('base64'));
456
+ },
457
+ });
395
458
  } catch (e) {
396
459
  kasa.destroy();
397
460
  throw e;
@@ -562,7 +625,21 @@ interface KasaDiscoveryEntry {
562
625
  timeout: NodeJS.Timeout;
563
626
  }
564
627
 
565
- class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator, DeviceDiscovery {
628
+ interface InventoryEntry {
629
+ name: string;
630
+ model: string;
631
+ firmware: string;
632
+ ip: string;
633
+ mac: string;
634
+ }
635
+
636
+ const INVENTORY_GROUPS = ['Cameras', 'Bulbs / Dimmers', 'Plugs', 'Switches'] as const;
637
+ type InventoryGroup = (typeof INVENTORY_GROUPS)[number];
638
+
639
+ class KasaPlugin
640
+ extends ScryptedDeviceBase
641
+ implements DeviceProvider, DeviceCreator, DeviceDiscovery, Readme, Settings
642
+ {
566
643
  devices = new Map<string, KasaCamera | KasaPlug | KasaSwitch | KasaDimmer | KasaBulb>();
567
644
  discoveredDevices = new Map<string, KasaDiscoveryEntry>();
568
645
  // In-flight scan so concurrent scan=true calls share one network round-trip instead of
@@ -578,8 +655,250 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
578
655
  super(nativeId);
579
656
  this.systemDevice = {
580
657
  deviceCreator: 'Device',
581
- deviceDiscovery: 'Kasa Devices',
658
+ // Singular noun — Scrypted's UI auto-pluralizes the label, so 'Kasa Devices'
659
+ // would render as "Kasa Devicess".
660
+ deviceDiscovery: 'Kasa Device',
582
661
  };
662
+ // Devices adopted before Readme was added need their interface lists topped up so
663
+ // the Readme tab actually shows on the device page. Defer to next tick so the SDK
664
+ // is fully wired before we walk the device manager.
665
+ process.nextTick(() => this.migrateAddReadme().catch(e => this.console.warn('migrateAddReadme failed', e)));
666
+ }
667
+
668
+ // Plugin Settings tab — single button that re-runs UDP discovery and refreshes the
669
+ // model / firmware / IP / MAC reported on every adopted device. Useful after a
670
+ // firmware update, a device rename in the Kasa app, or a DHCP-rotated IP.
671
+ async getSettings(): Promise<Setting[]> {
672
+ return [
673
+ {
674
+ key: 'refresh',
675
+ title: 'Refresh devices',
676
+ description:
677
+ 'Re-run UDP discovery and update model, firmware, IP, and MAC for every adopted ' +
678
+ 'Kasa device. Devices not currently reachable on the LAN are skipped.',
679
+ type: 'button',
680
+ },
681
+ ];
682
+ }
683
+
684
+ async putSetting(key: string, _value: SettingValue): Promise<void> {
685
+ if (key === 'refresh') await this.refreshAllDevices();
686
+ }
687
+
688
+ // Shared in-flight promise so two clicks of the Refresh button don't kick off two
689
+ // overlapping discovery sweeps. Same pattern as scanInFlight in discoverDevices.
690
+ private refreshInFlight?: Promise<void>;
691
+
692
+ // Re-run UDP discovery and update each adopted device's `info` (model, firmware, IP,
693
+ // MAC) from the fresh sysinfo response. Devices that don't respond to the sweep
694
+ // (powered off, on a different subnet) are left alone — we don't want to clobber
695
+ // good cached data with a one-shot network blip.
696
+ private async refreshAllDevices(): Promise<void> {
697
+ if (this.refreshInFlight) return this.refreshInFlight;
698
+ this.refreshInFlight = this.refreshAllDevicesInternal().finally(() => {
699
+ this.refreshInFlight = undefined;
700
+ });
701
+ return this.refreshInFlight;
702
+ }
703
+
704
+ private async refreshAllDevicesInternal(): Promise<void> {
705
+ this.console.log('refresh: running UDP discovery sweep');
706
+ const responses = await discoverKasa(2500, this.console).catch(e => {
707
+ this.console.error('refresh: discovery failed', e);
708
+ return [] as KasaDiscoveredDevice[];
709
+ });
710
+
711
+ // Auto-discovered devices use the Kasa deviceId as their nativeId, but
712
+ // manually-created ones (createDevice) get a random nativeId — those would
713
+ // never match here. Build a MAC index too so we can fall back to matching by
714
+ // the MAC the device's `info` was stamped with.
715
+ const normalizeMac = (mac: string | undefined): string => (mac ? mac.replace(/[:-]/g, '').toLowerCase() : '');
716
+ const byDeviceId = new Map(responses.map(r => [r.deviceId, r]));
717
+ const byMac = new Map<string, KasaDiscoveredDevice>();
718
+ for (const r of responses) {
719
+ const k = normalizeMac(r.mac);
720
+ if (k) byMac.set(k, r);
721
+ }
722
+
723
+ // Prefer the freshly-observed string when it's non-empty, otherwise keep the
724
+ // existing value. Discovery responses occasionally surface empty fields (and
725
+ // sw_ver may be missing entirely), and we don't want a refresh to clobber
726
+ // good cached data with an undefined.
727
+ const preferStr = (fresh: string | undefined, existing: string | undefined): string | undefined =>
728
+ fresh && fresh.trim() ? fresh : existing;
729
+
730
+ let updated = 0;
731
+ const missing: string[] = [];
732
+ for (const nativeId of deviceManager.getNativeIds()) {
733
+ if (!nativeId) continue;
734
+ // Spotlight / siren children share the parent camera's sysinfo and are
735
+ // already maintained by KasaCamera.refreshChildDevices.
736
+ if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
737
+ const state = deviceManager.getDeviceState(nativeId);
738
+ if (!state) continue;
739
+
740
+ // Try deviceId match first (auto-discovered), then MAC match (covers
741
+ // manually-created devices and any deviceId mismatches).
742
+ let fresh = byDeviceId.get(nativeId);
743
+ if (!fresh) {
744
+ const macKey = normalizeMac(state.info?.mac);
745
+ if (macKey) fresh = byMac.get(macKey);
746
+ }
747
+ if (!fresh) {
748
+ missing.push(state.name || nativeId);
749
+ continue;
750
+ }
751
+
752
+ const existing = state.info || {};
753
+ const sw_ver = typeof fresh.sysinfo?.sw_ver === 'string' ? fresh.sysinfo.sw_ver : undefined;
754
+ const newInfo = {
755
+ ...existing,
756
+ manufacturer: 'TP-Link Kasa',
757
+ model: preferStr(fresh.model, existing.model),
758
+ mac: preferStr(fresh.mac, existing.mac),
759
+ // IP always takes the freshly-observed value — capturing a DHCP-rotated
760
+ // address is the whole point of the refresh.
761
+ ip: fresh.address,
762
+ firmware: preferStr(sw_ver, existing.firmware),
763
+ };
764
+
765
+ // Re-publish without touching providedInterfaces — refreshing metadata, not
766
+ // capabilities. Mixin-provided interfaces (HomeKit, etc.) live in `interfaces`
767
+ // and would be incorrectly re-claimed if we passed those back.
768
+ await deviceManager.onDeviceDiscovered({
769
+ nativeId,
770
+ name: state.name || nativeId,
771
+ type: (state.type || ScryptedDeviceType.Unknown) as ScryptedDeviceType,
772
+ interfaces: state.providedInterfaces || [],
773
+ info: newInfo,
774
+ room: state.room || undefined,
775
+ });
776
+
777
+ // Keep per-device storage in sync — that's what the device classes actually
778
+ // use to connect. Update the in-memory storageSettings too if the device is
779
+ // already instantiated, otherwise it would keep using the stale IP until
780
+ // restarted.
781
+ const storage = deviceManager.getDeviceStorage(nativeId);
782
+ storage?.setItem('ip', fresh.address);
783
+ const dev = this.devices.get(nativeId);
784
+ if (dev?.storageSettings) dev.storageSettings.values.ip = fresh.address;
785
+
786
+ updated++;
787
+ }
788
+
789
+ const summary = `refresh: ${updated} updated, ${missing.length} not found on LAN`;
790
+ this.console.log(missing.length ? `${summary} (${missing.join(', ')})` : summary);
791
+ }
792
+
793
+ // One-time interface migration for devices adopted before this plugin exposed Readme.
794
+ // Walks every nativeId and re-publishes the device with Readme appended to its
795
+ // providedInterfaces. No-ops once every device already advertises Readme, so it's safe
796
+ // to leave running on every plugin start.
797
+ private async migrateAddReadme(): Promise<void> {
798
+ for (const nativeId of deviceManager.getNativeIds()) {
799
+ if (!nativeId) continue;
800
+ // Spotlight / siren children only expose OnOff — no Readme to add. They're
801
+ // re-published by KasaCamera.refreshChildDevices anyway.
802
+ if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
803
+ const state = deviceManager.getDeviceState(nativeId);
804
+ if (!state) continue;
805
+ // providedInterfaces is the list this plugin originally registered; the
806
+ // `interfaces` field also includes mixin-provided interfaces (HomeKit, etc.)
807
+ // which we must NOT re-claim ownership of by passing them back to
808
+ // onDeviceDiscovered.
809
+ const provided = state.providedInterfaces || [];
810
+ if (provided.includes(ScryptedInterface.Readme)) continue;
811
+ await deviceManager.onDeviceDiscovered({
812
+ nativeId,
813
+ name: state.name || nativeId,
814
+ type: (state.type || ScryptedDeviceType.Unknown) as ScryptedDeviceType,
815
+ interfaces: [...provided, ScryptedInterface.Readme],
816
+ info: state.info,
817
+ room: state.room || undefined,
818
+ });
819
+ }
820
+ }
821
+
822
+ // Walks every adopted device and groups by Scrypted device type for the Readme
823
+ // inventory. Child devices (spotlight / siren under each camera) are filtered out —
824
+ // they share the parent camera's firmware so listing them separately is just noise.
825
+ //
826
+ // IP comes from per-device storage (the value the plugin actually uses to connect)
827
+ // rather than `state.info.ip` (only set at adoption, doesn't reflect a manual edit
828
+ // in per-device Settings or a DHCP-rotated update). Falls back to info.ip if the
829
+ // storage value is missing — defends against legacy adoption paths that didn't
830
+ // initialize the storage.
831
+ private inventory(): Record<InventoryGroup, InventoryEntry[]> {
832
+ const groups: Record<InventoryGroup, InventoryEntry[]> = {
833
+ Cameras: [],
834
+ 'Bulbs / Dimmers': [],
835
+ Plugs: [],
836
+ Switches: [],
837
+ };
838
+
839
+ for (const nativeId of deviceManager.getNativeIds()) {
840
+ if (!nativeId) continue;
841
+ if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
842
+
843
+ const state = deviceManager.getDeviceState(nativeId);
844
+ if (!state) continue;
845
+
846
+ const type = state.type as ScryptedDeviceType | undefined;
847
+ const info = (state.info || {}) as { model?: string; firmware?: string; ip?: string; mac?: string };
848
+ const storedIp = deviceManager.getDeviceStorage(nativeId)?.getItem('ip');
849
+ const entry: InventoryEntry = {
850
+ name: state.name || '<unknown>',
851
+ model: info.model || '?',
852
+ firmware: info.firmware || '?',
853
+ ip: storedIp || info.ip || '?',
854
+ mac: info.mac || '?',
855
+ };
856
+
857
+ if (type === ScryptedDeviceType.Camera) groups['Cameras'].push(entry);
858
+ else if (type === ScryptedDeviceType.Light) groups['Bulbs / Dimmers'].push(entry);
859
+ else if (type === ScryptedDeviceType.Outlet) groups['Plugs'].push(entry);
860
+ else if (type === ScryptedDeviceType.Switch) groups['Switches'].push(entry);
861
+ }
862
+
863
+ // Sort by name within each group for stable display order across renders.
864
+ for (const entries of Object.values(groups)) entries.sort((a, b) => a.name.localeCompare(b.name));
865
+ return groups;
866
+ }
867
+
868
+ // Plugin Readme tab — inventory data formatted as pre-aligned text inside fenced
869
+ // code blocks. We avoid GFM-style pipe tables because Scrypted's markdown renderer
870
+ // is plain CommonMark — it doesn't recognize them and collapses the pipe rows into
871
+ // a single paragraph. A fenced code block renders as monospace preformatted text in
872
+ // any CommonMark renderer and preserves alignment.
873
+ async getReadmeMarkdown(): Promise<string> {
874
+ const groups = this.inventory();
875
+ const lines: string[] = ['# Kasa Plugin', '', 'Adopted devices, grouped by type.', ''];
876
+
877
+ const headers = ['Name', 'Model', 'IP', 'MAC', 'Firmware'];
878
+ let total = 0;
879
+ for (const groupName of INVENTORY_GROUPS) {
880
+ const entries = groups[groupName];
881
+ if (!entries.length) continue;
882
+ total += entries.length;
883
+
884
+ const rows: string[][] = [
885
+ headers,
886
+ ...entries.map(e => [e.name, e.model, e.ip, formatKasaMac(e.mac), e.firmware]),
887
+ ];
888
+ // Compute per-column widths so columns line up cleanly inside the code block.
889
+ const widths = headers.map((_, col) => Math.max(...rows.map(r => r[col].length)));
890
+
891
+ lines.push(`## ${groupName} (${entries.length})`, '', '```');
892
+ for (const row of rows) {
893
+ // Two-space gutter between columns is enough to keep them visually distinct
894
+ // without making the block too wide for narrow detail panels.
895
+ lines.push(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
896
+ }
897
+ lines.push('```', '');
898
+ }
899
+
900
+ if (total === 0) lines.push('_No devices adopted yet._');
901
+ return lines.join('\n');
583
902
  }
584
903
 
585
904
  async getCreateDeviceSettings(): Promise<Setting[]> {
@@ -640,7 +959,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
640
959
  room: string | undefined,
641
960
  kasaClass: KasaDeviceClass,
642
961
  ): Promise<void> {
643
- const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
962
+ const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings, ScryptedInterface.Readme];
644
963
  let type: ScryptedDeviceType;
645
964
  switch (kasaClass) {
646
965
  case 'plug':
@@ -694,6 +1013,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
694
1013
  // OnOff drives the camera's status LED — HomeKit binds its
695
1014
  // CameraOperatingModeIndicator characteristic to this.
696
1015
  ScryptedInterface.OnOff,
1016
+ ScryptedInterface.Readme,
697
1017
  ],
698
1018
  info: {
699
1019
  manufacturer: 'TP-Link Kasa',
@@ -771,6 +1091,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
771
1091
  ScryptedInterface.Intercom,
772
1092
  ScryptedInterface.DeviceProvider,
773
1093
  ScryptedInterface.OnOff,
1094
+ ScryptedInterface.Readme,
774
1095
  ],
775
1096
  info,
776
1097
  // Cameras need the cloud account credentials too — auth on the stream/talk
@@ -789,7 +1110,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
789
1110
  }
790
1111
 
791
1112
  // Plug, Switch, Bulb — all share the same simpler adoption form.
792
- const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
1113
+ const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings, ScryptedInterface.Readme];
793
1114
  let type: ScryptedDeviceType;
794
1115
  if (cls === 'bulb') {
795
1116
  type = ScryptedDeviceType.Light;
@@ -941,7 +1262,11 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
941
1262
  name: string,
942
1263
  room?: string,
943
1264
  ): Promise<string> {
944
- const interfaces: ScryptedInterface[] = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
1265
+ const interfaces: ScryptedInterface[] = [
1266
+ ScryptedInterface.OnOff,
1267
+ ScryptedInterface.Settings,
1268
+ ScryptedInterface.Readme,
1269
+ ];
945
1270
  let type: ScryptedDeviceType;
946
1271
  const caps = bulbCapabilities(device);
947
1272
  // The marker we persist for getDevice routing. 'plug'/'switch' for plain on/off
@@ -0,0 +1,20 @@
1
+ // Shared formatting helpers for the per-device + plugin-level Readme tabs. Scrypted's
2
+ // markdown renderer is plain CommonMark — it doesn't recognize GFM pipe tables, so we
3
+ // render tabular data inside fenced code blocks with manual column padding.
4
+
5
+ // Format a hex MAC (e.g. "F0090D49721F") with colon separators (e.g. "F0:09:0D:49:72:1F").
6
+ // Returns the input unchanged if it isn't a 12-character hex string — Kasa devices
7
+ // occasionally surface MACs in already-colon-formatted form, and we don't want to mangle
8
+ // either case.
9
+ export function formatKasaMac(mac: string | undefined): string {
10
+ if (!mac) return '?';
11
+ if (!/^[0-9A-Fa-f]{12}$/.test(mac)) return mac;
12
+ return mac.match(/.{2}/g)!.join(':').toUpperCase();
13
+ }
14
+
15
+ // Render a list of [label, value] pairs as a left-padded plain-text block. Caller is
16
+ // expected to wrap the output in triple-backtick fences so monospace alignment renders.
17
+ export function renderKv(rows: [string, string][]): string {
18
+ const labelWidth = Math.max(...rows.map(([k]) => k.length));
19
+ return rows.map(([k, v]) => `${k.padEnd(labelWidth)} ${v}`).join('\n');
20
+ }