scrypted-kasa 1.1.0 → 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,6 +11,11 @@ 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrypted-kasa",
3
- "version": "1.1.0",
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",
@@ -73,5 +73,11 @@
73
73
  "eslint": "^10.2.1",
74
74
  "prettier": "^3.8.3",
75
75
  "typescript-eslint": "^8.59.1"
76
+ },
77
+ "overrides": {
78
+ "serialize-javascript": "^7.0.5"
79
+ },
80
+ "engines": {
81
+ "node": ">=20"
76
82
  }
77
83
  }
@@ -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;
package/src/iot/device.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { OnOff, Readme, 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
3
  import { formatKasaMac, renderKv } from '../shared/readme';
4
- import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
4
+ import { EmeterReading, KASA_IOT_PORT, getEmeterRealtime, getSysInfo, kasaIotCall } from './protocol';
5
5
 
6
6
  const STATE_POLL_INTERVAL_MS = 30000;
7
7
 
@@ -25,6 +25,14 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
25
25
  type: 'number',
26
26
  defaultValue: KASA_IOT_PORT,
27
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
+ },
28
36
  });
29
37
 
30
38
  private pollTimer?: NodeJS.Timeout;
@@ -48,21 +56,77 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
48
56
  }, jitter);
49
57
  }
50
58
 
51
- getSettings(): Promise<Setting[]> {
52
- 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];
53
106
  }
54
107
 
55
- 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
+ }
56
118
  return this.storageSettings.putSetting(key, value);
57
119
  }
58
120
 
59
121
  // Per-device Readme tab. Surfaces static identity / network info — live state
60
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).
61
125
  async getReadmeMarkdown(): Promise<string> {
62
126
  const info = this.info || {};
63
127
  const ip = this.storageSettings.values.ip || info.ip || '?';
64
128
  const port = this.storageSettings.values.port || KASA_IOT_PORT;
65
- return [
129
+ const lines: string[] = [
66
130
  `# ${this.name || 'Kasa Device'}`,
67
131
  '',
68
132
  '## Device',
@@ -77,11 +141,58 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
77
141
  ]),
78
142
  '```',
79
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(
80
166
  '## Protocol',
81
167
  '',
82
168
  `Local TCP/${port} — Kasa's legacy "smarthome" wire format. No cloud account or`,
83
169
  'credentials required, the plugin reaches the device directly on the LAN.',
84
- ].join('\n');
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;
85
196
  }
86
197
 
87
198
  async turnOn(): Promise<void> {
@@ -107,6 +218,13 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
107
218
  const sys = await getSysInfo(this.iotOptions());
108
219
  if (!sys) return;
109
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
+ }
110
228
  // Subclasses with extra state extend via onSysInfo hook.
111
229
  this.onSysInfo(sys);
112
230
  }
@@ -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
@@ -1206,7 +1206,13 @@ class KasaPlugin
1206
1206
  }
1207
1207
 
1208
1208
  async adoptDevice(adopt: AdoptDevice): Promise<string> {
1209
- 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);
1210
1216
  if (!entry) throw new Error('kasa device not found in discovered set; rescan and try again');
1211
1217
 
1212
1218
  const { device } = entry;
@@ -1217,32 +1223,33 @@ class KasaPlugin
1217
1223
  const room = adopt.settings.room?.toString() || undefined;
1218
1224
 
1219
1225
  let id: string;
1220
- if (cls === 'camera') id = await this.adoptCamera(adopt, device, name, room);
1221
- 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);
1222
1228
 
1223
1229
  clearTimeout(entry.timeout);
1224
- this.discoveredDevices.delete(adopt.nativeId);
1230
+ this.discoveredDevices.delete(nativeId);
1225
1231
  void this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, undefined);
1226
1232
  return id;
1227
1233
  }
1228
1234
 
1229
1235
  private async adoptCamera(
1230
1236
  adopt: AdoptDevice,
1237
+ nativeId: string,
1231
1238
  device: KasaDiscoveredDevice,
1232
1239
  name: string,
1233
1240
  room?: string,
1234
1241
  ): Promise<string> {
1235
1242
  // deviceId is the Kasa-issued 40-char hex per-unit identifier — treat it as the
1236
1243
  // serial number, which is what HomeKit and the UI expect under that label.
1237
- await this.registerCamera(adopt.nativeId, name, room, {
1244
+ await this.registerCamera(nativeId, name, room, {
1238
1245
  model: device.model,
1239
1246
  mac: device.mac,
1240
1247
  ip: device.address,
1241
1248
  serialNumber: device.deviceId,
1242
1249
  firmware: typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined,
1243
1250
  });
1244
- deviceManager.getDeviceStorage(adopt.nativeId).setItem('kasaClass', 'camera');
1245
- 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;
1246
1253
 
1247
1254
  camera.storageSettings.values.ip = device.address;
1248
1255
  camera.storageSettings.values.port = KASA_DEFAULT_PORT;
@@ -1257,6 +1264,7 @@ class KasaPlugin
1257
1264
 
1258
1265
  private async adoptIotDevice(
1259
1266
  adopt: AdoptDevice,
1267
+ nativeId: string,
1260
1268
  device: KasaDiscoveredDevice,
1261
1269
  cls: KasaDeviceClass,
1262
1270
  name: string,
@@ -1289,7 +1297,7 @@ class KasaPlugin
1289
1297
 
1290
1298
  const sw_ver = typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined;
1291
1299
  await deviceManager.onDeviceDiscovered({
1292
- nativeId: adopt.nativeId,
1300
+ nativeId,
1293
1301
  name,
1294
1302
  type,
1295
1303
  interfaces,
@@ -1307,9 +1315,9 @@ class KasaPlugin
1307
1315
  // Persist the class marker so getDevice routes to the right implementation —
1308
1316
  // multiple device classes share the same Scrypted device type (true bulbs and
1309
1317
  // dimmers both register as Light).
1310
- deviceManager.getDeviceStorage(adopt.nativeId).setItem('kasaClass', storedClass);
1318
+ deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', storedClass);
1311
1319
 
1312
- const dev = await this.getDevice(adopt.nativeId);
1320
+ const dev = await this.getDevice(nativeId);
1313
1321
  dev.storageSettings.values.ip = device.address;
1314
1322
  dev.storageSettings.values.port = KASA_IOT_PORT;
1315
1323
  if (cls === 'bulb' && dev instanceof KasaBulb) {
@@ -1324,7 +1332,10 @@ class KasaPlugin
1324
1332
  // Routes a nativeId to the right device class. Adoption persists a `kasaClass` storage
1325
1333
  // marker (camera/plug/switch/bulb) which is the source of truth here — the Scrypted
1326
1334
  // device type alone is ambiguous (e.g. both true bulbs and dimmer plugs are `Light`).
1327
- 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;
1328
1339
  let dev = this.devices.get(nativeId);
1329
1340
  if (!dev) {
1330
1341
  dev = this.instantiateDevice(nativeId);
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",