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.
- package/.github/workflows/ci.yml +5 -0
- package/package.json +7 -1
- package/src/camera/rtsp.ts +4 -1
- package/src/iot/device.ts +125 -7
- package/src/iot/protocol.ts +39 -0
- package/src/main.ts +22 -11
- package/tsconfig.json +1 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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.
|
|
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
|
}
|
package/src/camera/rtsp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/iot/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
1245
|
-
const camera = (await this.getDevice(
|
|
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
|
|
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(
|
|
1318
|
+
deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', storedClass);
|
|
1311
1319
|
|
|
1312
|
-
const dev = await this.getDevice(
|
|
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:
|
|
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);
|