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.
- package/.github/workflows/ci.yml +7 -2
- package/.github/workflows/publish.yml +2 -2
- package/.nvmrc +1 -1
- package/README.md +1 -1
- package/package.json +11 -3
- package/src/camera/api.ts +36 -11
- package/src/camera/rtsp.ts +5 -2
- package/src/camera/stream.ts +54 -2
- package/src/iot/bulb.ts +46 -1
- package/src/iot/device.ts +153 -6
- package/src/iot/protocol.ts +40 -1
- package/src/main.ts +354 -18
- package/src/shared/readme.ts +20 -0
- package/tsconfig.json +1 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -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@
|
|
23
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
19
24
|
|
|
20
|
-
- uses: actions/setup-node@
|
|
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@
|
|
18
|
+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
19
19
|
|
|
20
|
-
- uses: actions/setup-node@
|
|
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
|
-
|
|
1
|
+
v25
|
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/scrypted-kasa)
|
|
4
4
|
[](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.
|
|
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": "^
|
|
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
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
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
|
-
|
|
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(
|
|
122
|
-
req.destroy(new Error(`kasa stream
|
|
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();
|
package/src/camera/rtsp.ts
CHANGED
|
@@ -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
|
-
|
|
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/camera/stream.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/iot/protocol.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
924
|
-
const camera = (await this.getDevice(
|
|
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[] = [
|
|
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
|
|
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(
|
|
1318
|
+
deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', storedClass);
|
|
986
1319
|
|
|
987
|
-
const dev = await this.getDevice(
|
|
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:
|
|
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
|
+
}
|