scrypted-kasa 1.0.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,13 +11,18 @@ concurrency:
11
11
  group: ci-${{ github.workflow }}-${{ github.ref }}
12
12
  cancel-in-progress: true
13
13
 
14
+ # Read-only is enough — CI just builds and lints, doesn't push commits, comments, or
15
+ # release artifacts.
16
+ permissions:
17
+ contents: read
18
+
14
19
  jobs:
15
20
  check:
16
21
  runs-on: ubuntu-latest
17
22
  steps:
18
- - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
23
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19
24
 
20
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
25
+ - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
21
26
  with:
22
27
  node-version-file: '.nvmrc'
23
28
  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.1",
3
+ "version": "1.2.2",
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,9 +69,15 @@
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"
76
+ },
77
+ "overrides": {
78
+ "serialize-javascript": "^7.0.5"
79
+ },
80
+ "engines": {
81
+ "node": ">=20"
74
82
  }
75
83
  }
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) {
@@ -173,7 +173,10 @@ export function handleRtspClient(client: net.Socket, opts: RtspHandlerOptions):
173
173
  const headerLines = headerStr.split('\r\n');
174
174
  const requestLine = headerLines.shift() || '';
175
175
  const [method, uri] = requestLine.split(' ');
176
- const headers: Record<string, string> = {};
176
+ // Null-prototype map so a malicious peer can't reach Object.prototype via a
177
+ // crafted header name like `__proto__`. Practical risk is tiny — the server
178
+ // only listens on localhost — but the fix is one line and shuts up CodeQL.
179
+ const headers = Object.create(null) as Record<string, string>;
177
180
  for (const line of headerLines) {
178
181
  const colon = line.indexOf(':');
179
182
  if (colon < 0) continue;
@@ -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();
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,6 +1,7 @@
1
- import { OnOff, ScryptedDeviceBase, Setting, Settings, SettingValue } from '@scrypted/sdk';
1
+ import { OnOff, Readme, ScryptedDeviceBase, ScryptedInterface, Setting, Settings, SettingValue } from '@scrypted/sdk';
2
2
  import { StorageSettings } from '@scrypted/sdk/storage-settings';
3
- import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
3
+ import { formatKasaMac, renderKv } from '../shared/readme';
4
+ import { EmeterReading, KASA_IOT_PORT, getEmeterRealtime, getSysInfo, kasaIotCall } from './protocol';
4
5
 
5
6
  const STATE_POLL_INTERVAL_MS = 30000;
6
7
 
@@ -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',
@@ -24,6 +25,14 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
24
25
  type: 'number',
25
26
  defaultValue: KASA_IOT_PORT,
26
27
  },
28
+ // Energy-monitoring capability flag derived from sysinfo.feature (devices report
29
+ // "TIM:ENE" when they have an emeter). Hidden because users don't toggle it —
30
+ // it's set at adoption and re-evaluated on every sysinfo poll. Drives whether
31
+ // the Readme tab fetches and displays the live wattage.
32
+ hasEmeter: {
33
+ type: 'boolean',
34
+ hide: true,
35
+ },
27
36
  });
28
37
 
29
38
  private pollTimer?: NodeJS.Timeout;
@@ -47,14 +56,145 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
47
56
  }, jitter);
48
57
  }
49
58
 
50
- getSettings(): Promise<Setting[]> {
51
- return this.storageSettings.getSettings();
59
+ async getSettings(): Promise<Setting[]> {
60
+ const base = await this.storageSettings.getSettings();
61
+ if (!this.storageSettings.values.hasEmeter) return base;
62
+
63
+ // Devices with an emeter get a live readout block + a Refresh button so the user
64
+ // can force a fresh sample without waiting for the 5 s cache to expire. Reads go
65
+ // through the same cached emeterRealtime() the Readme uses, so opening the
66
+ // Settings tab right after viewing the Readme is free.
67
+ const reading = await this.emeterRealtime();
68
+ const energy: Setting[] = [
69
+ {
70
+ key: 'energyPower',
71
+ group: 'Energy',
72
+ title: 'Power',
73
+ readonly: true,
74
+ value: reading ? `${reading.powerW.toFixed(1)} W` : '?',
75
+ },
76
+ {
77
+ key: 'energyVoltage',
78
+ group: 'Energy',
79
+ title: 'Voltage',
80
+ readonly: true,
81
+ value: reading ? `${reading.voltageV.toFixed(1)} V` : '?',
82
+ },
83
+ {
84
+ key: 'energyCurrent',
85
+ group: 'Energy',
86
+ title: 'Current',
87
+ readonly: true,
88
+ value: reading ? `${reading.currentA.toFixed(3)} A` : '?',
89
+ },
90
+ {
91
+ key: 'energyTotal',
92
+ group: 'Energy',
93
+ title: 'Total energy',
94
+ readonly: true,
95
+ value: reading ? `${(reading.totalWh / 1000).toFixed(3)} kWh` : '?',
96
+ },
97
+ {
98
+ key: 'refreshEnergy',
99
+ group: 'Energy',
100
+ title: 'Refresh',
101
+ description: 'Discard the cached emeter sample and read fresh values from the device.',
102
+ type: 'button',
103
+ },
104
+ ];
105
+ return [...base, ...energy];
52
106
  }
53
107
 
54
- putSetting(key: string, value: SettingValue): Promise<void> {
108
+ async putSetting(key: string, value: SettingValue): Promise<void> {
109
+ if (key === 'refreshEnergy') {
110
+ // Drop the cached value and fetch fresh; the new reading goes back into the
111
+ // cache so subsequent renders see it. Emit a Settings event so the UI knows
112
+ // to re-render with the updated readout fields.
113
+ this.emeterCache = undefined;
114
+ await this.emeterRealtime();
115
+ await this.onDeviceEvent(ScryptedInterface.Settings, undefined);
116
+ return;
117
+ }
55
118
  return this.storageSettings.putSetting(key, value);
56
119
  }
57
120
 
121
+ // Per-device Readme tab. Surfaces static identity / network info — live state
122
+ // (on/off, brightness) is not duplicated here, the device page already shows it.
123
+ // Devices that report energy-monitoring capability also get a live Energy section
124
+ // (lazy fetch — only fires when the user actually opens the Readme tab).
125
+ async getReadmeMarkdown(): Promise<string> {
126
+ const info = this.info || {};
127
+ const ip = this.storageSettings.values.ip || info.ip || '?';
128
+ const port = this.storageSettings.values.port || KASA_IOT_PORT;
129
+ const lines: string[] = [
130
+ `# ${this.name || 'Kasa Device'}`,
131
+ '',
132
+ '## Device',
133
+ '',
134
+ '```',
135
+ renderKv([
136
+ ['Model', info.model || '?'],
137
+ ['Firmware', info.firmware || '?'],
138
+ ['MAC', formatKasaMac(info.mac)],
139
+ ['IP', ip],
140
+ ['Port', String(port)],
141
+ ]),
142
+ '```',
143
+ '',
144
+ ];
145
+
146
+ if (this.storageSettings.values.hasEmeter) {
147
+ const reading = await this.emeterRealtime();
148
+ const fmt = (n: number, digits: number) => n.toFixed(digits);
149
+ const rows: [string, string][] = reading
150
+ ? [
151
+ ['Power', `${fmt(reading.powerW, 1)} W`],
152
+ ['Voltage', `${fmt(reading.voltageV, 1)} V`],
153
+ ['Current', `${fmt(reading.currentA, 3)} A`],
154
+ ['Total', `${fmt(reading.totalWh / 1000, 3)} kWh`],
155
+ ]
156
+ : [
157
+ ['Power', '?'],
158
+ ['Voltage', '?'],
159
+ ['Current', '?'],
160
+ ['Total', '?'],
161
+ ];
162
+ lines.push('## Energy', '', '```', renderKv(rows), '```', '');
163
+ }
164
+
165
+ lines.push(
166
+ '## Protocol',
167
+ '',
168
+ `Local TCP/${port} — Kasa's legacy "smarthome" wire format. No cloud account or`,
169
+ 'credentials required, the plugin reaches the device directly on the LAN.',
170
+ );
171
+ return lines.join('\n');
172
+ }
173
+
174
+ // Cache the most recent emeter reading for a short window so quick re-renders of
175
+ // the Readme tab (focus events etc.) don't fan out into multiple TCP queries to the
176
+ // plug. Errors cache as `undefined` so an offline plug doesn't keep retrying on
177
+ // every render either — the next miss after the window will try again.
178
+ private static EMETER_CACHE_MS = 5000;
179
+ private emeterCache?: { value: EmeterReading | undefined; at: number };
180
+ private emeterInFlight?: Promise<EmeterReading | undefined>;
181
+
182
+ async emeterRealtime(): Promise<EmeterReading | undefined> {
183
+ const now = Date.now();
184
+ if (this.emeterCache && now - this.emeterCache.at < KasaIotDevice.EMETER_CACHE_MS) {
185
+ return this.emeterCache.value;
186
+ }
187
+ if (this.emeterInFlight) return this.emeterInFlight;
188
+ this.emeterInFlight = getEmeterRealtime(this.iotOptions())
189
+ .catch(() => undefined)
190
+ .finally(() => {
191
+ this.emeterInFlight = undefined;
192
+ });
193
+ const value = await this.emeterInFlight;
194
+ this.emeterCache = { value, at: Date.now() };
195
+ return value;
196
+ }
197
+
58
198
  async turnOn(): Promise<void> {
59
199
  await this.callIot({ system: { set_relay_state: { state: 1 } } });
60
200
  this.on = true;
@@ -78,6 +218,13 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
78
218
  const sys = await getSysInfo(this.iotOptions());
79
219
  if (!sys) return;
80
220
  if (typeof sys.relay_state === 'number') this.on = sys.relay_state === 1;
221
+ // Energy-monitoring capability is advertised in sysinfo.feature as "ENE"
222
+ // (e.g. "TIM:ENE"). Cache the boolean so the Readme tab knows whether to
223
+ // bother fetching live wattage.
224
+ const hasEmeter = /ENE/i.test(typeof sys.feature === 'string' ? sys.feature : '');
225
+ if (this.storageSettings.values.hasEmeter !== hasEmeter) {
226
+ this.storageSettings.values.hasEmeter = hasEmeter;
227
+ }
81
228
  // Subclasses with extra state extend via onSysInfo hook.
82
229
  this.onSysInfo(sys);
83
230
  }
@@ -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) {
@@ -141,3 +141,42 @@ export async function getSysInfo(options: KasaIotOptions): Promise<KasaSysInfoCo
141
141
  const r = await kasaIotCall(options, { system: { get_sysinfo: {} } });
142
142
  return r?.system?.get_sysinfo;
143
143
  }
144
+
145
+ // Energy-monitoring readout for plugs that report sysinfo.feature containing "ENE"
146
+ // (HS110, KP115, KP125, EP25, HS220, ...). The plug answers `emeter.get_realtime` with
147
+ // either the newer milli-units schema or the older base-unit schema:
148
+ //
149
+ // newer: { voltage_mv, current_ma, power_mw, total_wh, err_code }
150
+ // older: { voltage, current, power, total, err_code } // V, A, W, kWh
151
+ //
152
+ // Normalize to a single V/A/W/Wh shape so callers don't care which firmware they're on.
153
+ // Returns undefined on err_code or when neither schema matches.
154
+ export interface EmeterReading {
155
+ voltageV: number;
156
+ currentA: number;
157
+ powerW: number;
158
+ totalWh: number;
159
+ }
160
+
161
+ export async function getEmeterRealtime(options: KasaIotOptions): Promise<EmeterReading | undefined> {
162
+ const r = await kasaIotCall(options, { emeter: { get_realtime: {} } });
163
+ const e = r?.emeter?.get_realtime;
164
+ if (!e || e.err_code) return undefined;
165
+ if (typeof e.voltage_mv === 'number') {
166
+ return {
167
+ voltageV: e.voltage_mv / 1000,
168
+ currentA: (e.current_ma ?? 0) / 1000,
169
+ powerW: (e.power_mw ?? 0) / 1000,
170
+ totalWh: e.total_wh ?? 0,
171
+ };
172
+ }
173
+ if (typeof e.voltage === 'number') {
174
+ return {
175
+ voltageV: e.voltage,
176
+ currentA: e.current ?? 0,
177
+ powerW: e.power ?? 0,
178
+ totalWh: (e.total ?? 0) * 1000,
179
+ };
180
+ }
181
+ return undefined;
182
+ }
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',
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: [],
582
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;
@@ -885,7 +1206,13 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
885
1206
  }
886
1207
 
887
1208
  async adoptDevice(adopt: AdoptDevice): Promise<string> {
888
- const entry = this.discoveredDevices.get(adopt.nativeId);
1209
+ // ScryptedNativeId is `string | undefined`; the AdoptDevice flow always passes
1210
+ // a real id (it came from a DiscoveredDevice we ourselves emitted), but TS6
1211
+ // tightened the inference and won't narrow it for us.
1212
+ const nativeId = adopt.nativeId;
1213
+ if (!nativeId) throw new Error('adoptDevice called without a nativeId');
1214
+
1215
+ const entry = this.discoveredDevices.get(nativeId);
889
1216
  if (!entry) throw new Error('kasa device not found in discovered set; rescan and try again');
890
1217
 
891
1218
  const { device } = entry;
@@ -896,32 +1223,33 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
896
1223
  const room = adopt.settings.room?.toString() || undefined;
897
1224
 
898
1225
  let id: string;
899
- if (cls === 'camera') id = await this.adoptCamera(adopt, device, name, room);
900
- else id = await this.adoptIotDevice(adopt, device, cls, name, room);
1226
+ if (cls === 'camera') id = await this.adoptCamera(adopt, nativeId, device, name, room);
1227
+ else id = await this.adoptIotDevice(adopt, nativeId, device, cls, name, room);
901
1228
 
902
1229
  clearTimeout(entry.timeout);
903
- this.discoveredDevices.delete(adopt.nativeId);
1230
+ this.discoveredDevices.delete(nativeId);
904
1231
  void this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, undefined);
905
1232
  return id;
906
1233
  }
907
1234
 
908
1235
  private async adoptCamera(
909
1236
  adopt: AdoptDevice,
1237
+ nativeId: string,
910
1238
  device: KasaDiscoveredDevice,
911
1239
  name: string,
912
1240
  room?: string,
913
1241
  ): Promise<string> {
914
1242
  // deviceId is the Kasa-issued 40-char hex per-unit identifier — treat it as the
915
1243
  // serial number, which is what HomeKit and the UI expect under that label.
916
- await this.registerCamera(adopt.nativeId, name, room, {
1244
+ await this.registerCamera(nativeId, name, room, {
917
1245
  model: device.model,
918
1246
  mac: device.mac,
919
1247
  ip: device.address,
920
1248
  serialNumber: device.deviceId,
921
1249
  firmware: typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined,
922
1250
  });
923
- deviceManager.getDeviceStorage(adopt.nativeId).setItem('kasaClass', 'camera');
924
- const camera = (await this.getDevice(adopt.nativeId)) as KasaCamera;
1251
+ deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', 'camera');
1252
+ const camera = (await this.getDevice(nativeId)) as KasaCamera;
925
1253
 
926
1254
  camera.storageSettings.values.ip = device.address;
927
1255
  camera.storageSettings.values.port = KASA_DEFAULT_PORT;
@@ -936,12 +1264,17 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
936
1264
 
937
1265
  private async adoptIotDevice(
938
1266
  adopt: AdoptDevice,
1267
+ nativeId: string,
939
1268
  device: KasaDiscoveredDevice,
940
1269
  cls: KasaDeviceClass,
941
1270
  name: string,
942
1271
  room?: string,
943
1272
  ): Promise<string> {
944
- const interfaces: ScryptedInterface[] = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
1273
+ const interfaces: ScryptedInterface[] = [
1274
+ ScryptedInterface.OnOff,
1275
+ ScryptedInterface.Settings,
1276
+ ScryptedInterface.Readme,
1277
+ ];
945
1278
  let type: ScryptedDeviceType;
946
1279
  const caps = bulbCapabilities(device);
947
1280
  // The marker we persist for getDevice routing. 'plug'/'switch' for plain on/off
@@ -964,7 +1297,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
964
1297
 
965
1298
  const sw_ver = typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined;
966
1299
  await deviceManager.onDeviceDiscovered({
967
- nativeId: adopt.nativeId,
1300
+ nativeId,
968
1301
  name,
969
1302
  type,
970
1303
  interfaces,
@@ -982,9 +1315,9 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
982
1315
  // Persist the class marker so getDevice routes to the right implementation —
983
1316
  // multiple device classes share the same Scrypted device type (true bulbs and
984
1317
  // dimmers both register as Light).
985
- deviceManager.getDeviceStorage(adopt.nativeId).setItem('kasaClass', storedClass);
1318
+ deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', storedClass);
986
1319
 
987
- const dev = await this.getDevice(adopt.nativeId);
1320
+ const dev = await this.getDevice(nativeId);
988
1321
  dev.storageSettings.values.ip = device.address;
989
1322
  dev.storageSettings.values.port = KASA_IOT_PORT;
990
1323
  if (cls === 'bulb' && dev instanceof KasaBulb) {
@@ -999,7 +1332,10 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
999
1332
  // Routes a nativeId to the right device class. Adoption persists a `kasaClass` storage
1000
1333
  // marker (camera/plug/switch/bulb) which is the source of truth here — the Scrypted
1001
1334
  // device type alone is ambiguous (e.g. both true bulbs and dimmer plugs are `Light`).
1002
- async getDevice(nativeId: string): Promise<any> {
1335
+ async getDevice(nativeId: ScryptedNativeId): Promise<any> {
1336
+ // The DeviceProvider contract types nativeId as nullable; the plugin device itself
1337
+ // (no nativeId) routes here too and we have nothing to instantiate for it.
1338
+ if (!nativeId) return undefined;
1003
1339
  let dev = this.devices.get(nativeId);
1004
1340
  if (!dev) {
1005
1341
  dev = this.instantiateDevice(nativeId);
@@ -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
+ }
package/tsconfig.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "rootDir": "src",
3
4
  "outDir": "build",
4
5
  "module": "Node16",
5
6
  "target": "ES2021",