scrypted-kasa 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +2 -2
- package/.github/workflows/publish.yml +2 -2
- package/.nvmrc +1 -1
- package/README.md +1 -1
- package/package.json +5 -3
- package/src/camera/api.ts +36 -11
- package/src/camera/rtsp.ts +1 -1
- package/src/camera/stream.ts +67 -2
- package/src/iot/bulb.ts +46 -1
- package/src/iot/device.ts +31 -2
- package/src/iot/protocol.ts +1 -1
- package/src/main.ts +332 -7
- package/src/shared/readme.ts +20 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -15,9 +15,9 @@ jobs:
|
|
|
15
15
|
check:
|
|
16
16
|
runs-on: ubuntu-latest
|
|
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'
|
|
@@ -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.1.0",
|
|
4
4
|
"description": "TP-Link Kasa plugin for Scrypted — cameras (video, two-way audio, spotlight, siren, status LED), plugs/outlets, wall switches, dimmers, and smart bulbs (on/off, brightness, color, color temperature).",
|
|
5
5
|
"author": "Sergey Vilgelm <sergey@vilgelm.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
"ScryptedDeviceCreator",
|
|
59
59
|
"DeviceProvider",
|
|
60
60
|
"DeviceCreator",
|
|
61
|
-
"DeviceDiscovery"
|
|
61
|
+
"DeviceDiscovery",
|
|
62
|
+
"Readme",
|
|
63
|
+
"Settings"
|
|
62
64
|
],
|
|
63
65
|
"pluginDependencies": [
|
|
64
66
|
"@scrypted/prebuffer-mixin"
|
|
@@ -67,7 +69,7 @@
|
|
|
67
69
|
"devDependencies": {
|
|
68
70
|
"@eslint/js": "^10.0.1",
|
|
69
71
|
"@scrypted/sdk": "^0.5.59",
|
|
70
|
-
"@types/node": "^
|
|
72
|
+
"@types/node": "^25.6.0",
|
|
71
73
|
"eslint": "^10.2.1",
|
|
72
74
|
"prettier": "^3.8.3",
|
|
73
75
|
"typescript-eslint": "^8.59.1"
|
package/src/camera/api.ts
CHANGED
|
@@ -14,11 +14,26 @@ export const KASA_STREAM_PATH = '/https/stream/mixed';
|
|
|
14
14
|
// retaining memory.
|
|
15
15
|
const MAX_HEADER_BYTES = 32 * 1024;
|
|
16
16
|
|
|
17
|
-
//
|
|
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) {
|
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();
|
|
@@ -278,6 +330,19 @@ export async function spawnKasaStream(opts: KasaStreamOptions): Promise<KasaStre
|
|
|
278
330
|
const videoPipe = cp.stdio[3] as Writable;
|
|
279
331
|
const audioPipe = cp.stdio[4] as Writable;
|
|
280
332
|
|
|
333
|
+
// Attach 'error' listeners on both pipes IMMEDIATELY. When cleanupAll calls cp.kill,
|
|
334
|
+
// ffmpeg's stdio FDs close forcibly and any in-flight pump write throws ECONNRESET /
|
|
335
|
+
// EPIPE on the writable side. Without listeners, Node turns those into
|
|
336
|
+
// uncaughtException and crashes the whole plugin worker (taking down every camera).
|
|
337
|
+
// The `_e` arg is intentional — we just want the stream to be informed there's a
|
|
338
|
+
// listener.
|
|
339
|
+
videoPipe.on('error', _e => {
|
|
340
|
+
/* tear down via the kasa pump's own try/finally */
|
|
341
|
+
});
|
|
342
|
+
audioPipe.on('error', _e => {
|
|
343
|
+
/* tear down via the kasa pump's own try/finally */
|
|
344
|
+
});
|
|
345
|
+
|
|
281
346
|
let resolveExited!: () => void;
|
|
282
347
|
const exited = new Promise<void>(r => {
|
|
283
348
|
resolveExited = r;
|
package/src/iot/bulb.ts
CHANGED
|
@@ -4,12 +4,14 @@ import {
|
|
|
4
4
|
ColorSettingHsv,
|
|
5
5
|
ColorSettingTemperature,
|
|
6
6
|
OnOff,
|
|
7
|
+
Readme,
|
|
7
8
|
ScryptedDeviceBase,
|
|
8
9
|
Setting,
|
|
9
10
|
Settings,
|
|
10
11
|
SettingValue,
|
|
11
12
|
} from '@scrypted/sdk';
|
|
12
13
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
14
|
+
import { formatKasaMac, renderKv } from '../shared/readme';
|
|
13
15
|
import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
|
|
14
16
|
|
|
15
17
|
const STATE_POLL_INTERVAL_MS = 30000;
|
|
@@ -26,7 +28,7 @@ const STATE_POLL_INTERVAL_MS = 30000;
|
|
|
26
28
|
// in `getTemperatureMinK`/`getTemperatureMaxK` and fall back to a safe pair otherwise.
|
|
27
29
|
export class KasaBulb
|
|
28
30
|
extends ScryptedDeviceBase
|
|
29
|
-
implements OnOff, Brightness, ColorSettingHsv, ColorSettingTemperature, Settings
|
|
31
|
+
implements OnOff, Brightness, ColorSettingHsv, ColorSettingTemperature, Settings, Readme
|
|
30
32
|
{
|
|
31
33
|
storageSettings = new StorageSettings(this, {
|
|
32
34
|
ip: {
|
|
@@ -86,6 +88,49 @@ export class KasaBulb
|
|
|
86
88
|
return this.storageSettings.putSetting(key, value);
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// Per-device Readme tab. Bulbs don't share KasaIotDevice's base implementation
|
|
92
|
+
// (different inheritance chain because of the color/brightness mixins), so the
|
|
93
|
+
// structure is duplicated here on purpose. Adds the static capability list (color /
|
|
94
|
+
// color-temp range) since those depend on per-model sysinfo flags. Live state is not
|
|
95
|
+
// duplicated here — the device page already shows it.
|
|
96
|
+
async getReadmeMarkdown(): Promise<string> {
|
|
97
|
+
const info = this.info || {};
|
|
98
|
+
const ip = this.storageSettings.values.ip || info.ip || '?';
|
|
99
|
+
const port = this.storageSettings.values.port || KASA_IOT_PORT;
|
|
100
|
+
const { isColor, isVariableColorTemp, colorTemperatureMinK, colorTemperatureMaxK } =
|
|
101
|
+
this.storageSettings.values;
|
|
102
|
+
|
|
103
|
+
const capabilities: string[] = ['Brightness'];
|
|
104
|
+
if (isColor) capabilities.push('Color (HSV)');
|
|
105
|
+
if (isVariableColorTemp)
|
|
106
|
+
capabilities.push(`Color temperature (${colorTemperatureMinK}–${colorTemperatureMaxK} K)`);
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
`# ${this.name || 'Kasa Bulb'}`,
|
|
110
|
+
'',
|
|
111
|
+
'## Device',
|
|
112
|
+
'',
|
|
113
|
+
'```',
|
|
114
|
+
renderKv([
|
|
115
|
+
['Model', info.model || '?'],
|
|
116
|
+
['Firmware', info.firmware || '?'],
|
|
117
|
+
['MAC', formatKasaMac(info.mac)],
|
|
118
|
+
['IP', ip],
|
|
119
|
+
['Port', String(port)],
|
|
120
|
+
]),
|
|
121
|
+
'```',
|
|
122
|
+
'',
|
|
123
|
+
'## Capabilities',
|
|
124
|
+
'',
|
|
125
|
+
...capabilities.map(c => `- ${c}`),
|
|
126
|
+
'',
|
|
127
|
+
'## Protocol',
|
|
128
|
+
'',
|
|
129
|
+
`Local TCP/${port} — Kasa's legacy "smarthome" wire format. All bulb state changes`,
|
|
130
|
+
'are issued via `smartlife.iot.smartbulb.lightingservice.transition_light_state`.',
|
|
131
|
+
].join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
89
134
|
async turnOn(): Promise<void> {
|
|
90
135
|
await this.lightState({ on_off: 1 });
|
|
91
136
|
this.on = true;
|
package/src/iot/device.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { OnOff, ScryptedDeviceBase, Setting, Settings, SettingValue } from '@scrypted/sdk';
|
|
1
|
+
import { OnOff, Readme, ScryptedDeviceBase, Setting, Settings, SettingValue } from '@scrypted/sdk';
|
|
2
2
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
3
|
+
import { formatKasaMac, renderKv } from '../shared/readme';
|
|
3
4
|
import { KASA_IOT_PORT, getSysInfo, kasaIotCall } from './protocol';
|
|
4
5
|
|
|
5
6
|
const STATE_POLL_INTERVAL_MS = 30000;
|
|
@@ -13,7 +14,7 @@ const STATE_POLL_INTERVAL_MS = 30000;
|
|
|
13
14
|
// query: {"system":{"get_sysinfo":{}}}
|
|
14
15
|
//
|
|
15
16
|
// Multi-outlet strips (HS300, KP303) aren't modeled — they need per-child relay handling.
|
|
16
|
-
export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff, Settings {
|
|
17
|
+
export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff, Settings, Readme {
|
|
17
18
|
storageSettings = new StorageSettings(this, {
|
|
18
19
|
ip: {
|
|
19
20
|
title: 'IP Address',
|
|
@@ -55,6 +56,34 @@ export abstract class KasaIotDevice extends ScryptedDeviceBase implements OnOff,
|
|
|
55
56
|
return this.storageSettings.putSetting(key, value);
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// Per-device Readme tab. Surfaces static identity / network info — live state
|
|
60
|
+
// (on/off, brightness) is not duplicated here, the device page already shows it.
|
|
61
|
+
async getReadmeMarkdown(): Promise<string> {
|
|
62
|
+
const info = this.info || {};
|
|
63
|
+
const ip = this.storageSettings.values.ip || info.ip || '?';
|
|
64
|
+
const port = this.storageSettings.values.port || KASA_IOT_PORT;
|
|
65
|
+
return [
|
|
66
|
+
`# ${this.name || 'Kasa Device'}`,
|
|
67
|
+
'',
|
|
68
|
+
'## Device',
|
|
69
|
+
'',
|
|
70
|
+
'```',
|
|
71
|
+
renderKv([
|
|
72
|
+
['Model', info.model || '?'],
|
|
73
|
+
['Firmware', info.firmware || '?'],
|
|
74
|
+
['MAC', formatKasaMac(info.mac)],
|
|
75
|
+
['IP', ip],
|
|
76
|
+
['Port', String(port)],
|
|
77
|
+
]),
|
|
78
|
+
'```',
|
|
79
|
+
'',
|
|
80
|
+
'## Protocol',
|
|
81
|
+
'',
|
|
82
|
+
`Local TCP/${port} — Kasa's legacy "smarthome" wire format. No cloud account or`,
|
|
83
|
+
'credentials required, the plugin reaches the device directly on the LAN.',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
async turnOn(): Promise<void> {
|
|
59
88
|
await this.callIot({ system: { set_relay_state: { state: 1 } } });
|
|
60
89
|
this.on = true;
|
package/src/iot/protocol.ts
CHANGED
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',
|
|
582
661
|
};
|
|
662
|
+
// Devices adopted before Readme was added need their interface lists topped up so
|
|
663
|
+
// the Readme tab actually shows on the device page. Defer to next tick so the SDK
|
|
664
|
+
// is fully wired before we walk the device manager.
|
|
665
|
+
process.nextTick(() => this.migrateAddReadme().catch(e => this.console.warn('migrateAddReadme failed', e)));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Plugin Settings tab — single button that re-runs UDP discovery and refreshes the
|
|
669
|
+
// model / firmware / IP / MAC reported on every adopted device. Useful after a
|
|
670
|
+
// firmware update, a device rename in the Kasa app, or a DHCP-rotated IP.
|
|
671
|
+
async getSettings(): Promise<Setting[]> {
|
|
672
|
+
return [
|
|
673
|
+
{
|
|
674
|
+
key: 'refresh',
|
|
675
|
+
title: 'Refresh devices',
|
|
676
|
+
description:
|
|
677
|
+
'Re-run UDP discovery and update model, firmware, IP, and MAC for every adopted ' +
|
|
678
|
+
'Kasa device. Devices not currently reachable on the LAN are skipped.',
|
|
679
|
+
type: 'button',
|
|
680
|
+
},
|
|
681
|
+
];
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async putSetting(key: string, _value: SettingValue): Promise<void> {
|
|
685
|
+
if (key === 'refresh') await this.refreshAllDevices();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Shared in-flight promise so two clicks of the Refresh button don't kick off two
|
|
689
|
+
// overlapping discovery sweeps. Same pattern as scanInFlight in discoverDevices.
|
|
690
|
+
private refreshInFlight?: Promise<void>;
|
|
691
|
+
|
|
692
|
+
// Re-run UDP discovery and update each adopted device's `info` (model, firmware, IP,
|
|
693
|
+
// MAC) from the fresh sysinfo response. Devices that don't respond to the sweep
|
|
694
|
+
// (powered off, on a different subnet) are left alone — we don't want to clobber
|
|
695
|
+
// good cached data with a one-shot network blip.
|
|
696
|
+
private async refreshAllDevices(): Promise<void> {
|
|
697
|
+
if (this.refreshInFlight) return this.refreshInFlight;
|
|
698
|
+
this.refreshInFlight = this.refreshAllDevicesInternal().finally(() => {
|
|
699
|
+
this.refreshInFlight = undefined;
|
|
700
|
+
});
|
|
701
|
+
return this.refreshInFlight;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private async refreshAllDevicesInternal(): Promise<void> {
|
|
705
|
+
this.console.log('refresh: running UDP discovery sweep');
|
|
706
|
+
const responses = await discoverKasa(2500, this.console).catch(e => {
|
|
707
|
+
this.console.error('refresh: discovery failed', e);
|
|
708
|
+
return [] as KasaDiscoveredDevice[];
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Auto-discovered devices use the Kasa deviceId as their nativeId, but
|
|
712
|
+
// manually-created ones (createDevice) get a random nativeId — those would
|
|
713
|
+
// never match here. Build a MAC index too so we can fall back to matching by
|
|
714
|
+
// the MAC the device's `info` was stamped with.
|
|
715
|
+
const normalizeMac = (mac: string | undefined): string => (mac ? mac.replace(/[:-]/g, '').toLowerCase() : '');
|
|
716
|
+
const byDeviceId = new Map(responses.map(r => [r.deviceId, r]));
|
|
717
|
+
const byMac = new Map<string, KasaDiscoveredDevice>();
|
|
718
|
+
for (const r of responses) {
|
|
719
|
+
const k = normalizeMac(r.mac);
|
|
720
|
+
if (k) byMac.set(k, r);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Prefer the freshly-observed string when it's non-empty, otherwise keep the
|
|
724
|
+
// existing value. Discovery responses occasionally surface empty fields (and
|
|
725
|
+
// sw_ver may be missing entirely), and we don't want a refresh to clobber
|
|
726
|
+
// good cached data with an undefined.
|
|
727
|
+
const preferStr = (fresh: string | undefined, existing: string | undefined): string | undefined =>
|
|
728
|
+
fresh && fresh.trim() ? fresh : existing;
|
|
729
|
+
|
|
730
|
+
let updated = 0;
|
|
731
|
+
const missing: string[] = [];
|
|
732
|
+
for (const nativeId of deviceManager.getNativeIds()) {
|
|
733
|
+
if (!nativeId) continue;
|
|
734
|
+
// Spotlight / siren children share the parent camera's sysinfo and are
|
|
735
|
+
// already maintained by KasaCamera.refreshChildDevices.
|
|
736
|
+
if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
|
|
737
|
+
const state = deviceManager.getDeviceState(nativeId);
|
|
738
|
+
if (!state) continue;
|
|
739
|
+
|
|
740
|
+
// Try deviceId match first (auto-discovered), then MAC match (covers
|
|
741
|
+
// manually-created devices and any deviceId mismatches).
|
|
742
|
+
let fresh = byDeviceId.get(nativeId);
|
|
743
|
+
if (!fresh) {
|
|
744
|
+
const macKey = normalizeMac(state.info?.mac);
|
|
745
|
+
if (macKey) fresh = byMac.get(macKey);
|
|
746
|
+
}
|
|
747
|
+
if (!fresh) {
|
|
748
|
+
missing.push(state.name || nativeId);
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const existing = state.info || {};
|
|
753
|
+
const sw_ver = typeof fresh.sysinfo?.sw_ver === 'string' ? fresh.sysinfo.sw_ver : undefined;
|
|
754
|
+
const newInfo = {
|
|
755
|
+
...existing,
|
|
756
|
+
manufacturer: 'TP-Link Kasa',
|
|
757
|
+
model: preferStr(fresh.model, existing.model),
|
|
758
|
+
mac: preferStr(fresh.mac, existing.mac),
|
|
759
|
+
// IP always takes the freshly-observed value — capturing a DHCP-rotated
|
|
760
|
+
// address is the whole point of the refresh.
|
|
761
|
+
ip: fresh.address,
|
|
762
|
+
firmware: preferStr(sw_ver, existing.firmware),
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
// Re-publish without touching providedInterfaces — refreshing metadata, not
|
|
766
|
+
// capabilities. Mixin-provided interfaces (HomeKit, etc.) live in `interfaces`
|
|
767
|
+
// and would be incorrectly re-claimed if we passed those back.
|
|
768
|
+
await deviceManager.onDeviceDiscovered({
|
|
769
|
+
nativeId,
|
|
770
|
+
name: state.name || nativeId,
|
|
771
|
+
type: (state.type || ScryptedDeviceType.Unknown) as ScryptedDeviceType,
|
|
772
|
+
interfaces: state.providedInterfaces || [],
|
|
773
|
+
info: newInfo,
|
|
774
|
+
room: state.room || undefined,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// Keep per-device storage in sync — that's what the device classes actually
|
|
778
|
+
// use to connect. Update the in-memory storageSettings too if the device is
|
|
779
|
+
// already instantiated, otherwise it would keep using the stale IP until
|
|
780
|
+
// restarted.
|
|
781
|
+
const storage = deviceManager.getDeviceStorage(nativeId);
|
|
782
|
+
storage?.setItem('ip', fresh.address);
|
|
783
|
+
const dev = this.devices.get(nativeId);
|
|
784
|
+
if (dev?.storageSettings) dev.storageSettings.values.ip = fresh.address;
|
|
785
|
+
|
|
786
|
+
updated++;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const summary = `refresh: ${updated} updated, ${missing.length} not found on LAN`;
|
|
790
|
+
this.console.log(missing.length ? `${summary} (${missing.join(', ')})` : summary);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// One-time interface migration for devices adopted before this plugin exposed Readme.
|
|
794
|
+
// Walks every nativeId and re-publishes the device with Readme appended to its
|
|
795
|
+
// providedInterfaces. No-ops once every device already advertises Readme, so it's safe
|
|
796
|
+
// to leave running on every plugin start.
|
|
797
|
+
private async migrateAddReadme(): Promise<void> {
|
|
798
|
+
for (const nativeId of deviceManager.getNativeIds()) {
|
|
799
|
+
if (!nativeId) continue;
|
|
800
|
+
// Spotlight / siren children only expose OnOff — no Readme to add. They're
|
|
801
|
+
// re-published by KasaCamera.refreshChildDevices anyway.
|
|
802
|
+
if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
|
|
803
|
+
const state = deviceManager.getDeviceState(nativeId);
|
|
804
|
+
if (!state) continue;
|
|
805
|
+
// providedInterfaces is the list this plugin originally registered; the
|
|
806
|
+
// `interfaces` field also includes mixin-provided interfaces (HomeKit, etc.)
|
|
807
|
+
// which we must NOT re-claim ownership of by passing them back to
|
|
808
|
+
// onDeviceDiscovered.
|
|
809
|
+
const provided = state.providedInterfaces || [];
|
|
810
|
+
if (provided.includes(ScryptedInterface.Readme)) continue;
|
|
811
|
+
await deviceManager.onDeviceDiscovered({
|
|
812
|
+
nativeId,
|
|
813
|
+
name: state.name || nativeId,
|
|
814
|
+
type: (state.type || ScryptedDeviceType.Unknown) as ScryptedDeviceType,
|
|
815
|
+
interfaces: [...provided, ScryptedInterface.Readme],
|
|
816
|
+
info: state.info,
|
|
817
|
+
room: state.room || undefined,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Walks every adopted device and groups by Scrypted device type for the Readme
|
|
823
|
+
// inventory. Child devices (spotlight / siren under each camera) are filtered out —
|
|
824
|
+
// they share the parent camera's firmware so listing them separately is just noise.
|
|
825
|
+
//
|
|
826
|
+
// IP comes from per-device storage (the value the plugin actually uses to connect)
|
|
827
|
+
// rather than `state.info.ip` (only set at adoption, doesn't reflect a manual edit
|
|
828
|
+
// in per-device Settings or a DHCP-rotated update). Falls back to info.ip if the
|
|
829
|
+
// storage value is missing — defends against legacy adoption paths that didn't
|
|
830
|
+
// initialize the storage.
|
|
831
|
+
private inventory(): Record<InventoryGroup, InventoryEntry[]> {
|
|
832
|
+
const groups: Record<InventoryGroup, InventoryEntry[]> = {
|
|
833
|
+
Cameras: [],
|
|
834
|
+
'Bulbs / Dimmers': [],
|
|
835
|
+
Plugs: [],
|
|
836
|
+
Switches: [],
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
for (const nativeId of deviceManager.getNativeIds()) {
|
|
840
|
+
if (!nativeId) continue;
|
|
841
|
+
if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
|
|
842
|
+
|
|
843
|
+
const state = deviceManager.getDeviceState(nativeId);
|
|
844
|
+
if (!state) continue;
|
|
845
|
+
|
|
846
|
+
const type = state.type as ScryptedDeviceType | undefined;
|
|
847
|
+
const info = (state.info || {}) as { model?: string; firmware?: string; ip?: string; mac?: string };
|
|
848
|
+
const storedIp = deviceManager.getDeviceStorage(nativeId)?.getItem('ip');
|
|
849
|
+
const entry: InventoryEntry = {
|
|
850
|
+
name: state.name || '<unknown>',
|
|
851
|
+
model: info.model || '?',
|
|
852
|
+
firmware: info.firmware || '?',
|
|
853
|
+
ip: storedIp || info.ip || '?',
|
|
854
|
+
mac: info.mac || '?',
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
if (type === ScryptedDeviceType.Camera) groups['Cameras'].push(entry);
|
|
858
|
+
else if (type === ScryptedDeviceType.Light) groups['Bulbs / Dimmers'].push(entry);
|
|
859
|
+
else if (type === ScryptedDeviceType.Outlet) groups['Plugs'].push(entry);
|
|
860
|
+
else if (type === ScryptedDeviceType.Switch) groups['Switches'].push(entry);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Sort by name within each group for stable display order across renders.
|
|
864
|
+
for (const entries of Object.values(groups)) entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
865
|
+
return groups;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Plugin Readme tab — inventory data formatted as pre-aligned text inside fenced
|
|
869
|
+
// code blocks. We avoid GFM-style pipe tables because Scrypted's markdown renderer
|
|
870
|
+
// is plain CommonMark — it doesn't recognize them and collapses the pipe rows into
|
|
871
|
+
// a single paragraph. A fenced code block renders as monospace preformatted text in
|
|
872
|
+
// any CommonMark renderer and preserves alignment.
|
|
873
|
+
async getReadmeMarkdown(): Promise<string> {
|
|
874
|
+
const groups = this.inventory();
|
|
875
|
+
const lines: string[] = ['# Kasa Plugin', '', 'Adopted devices, grouped by type.', ''];
|
|
876
|
+
|
|
877
|
+
const headers = ['Name', 'Model', 'IP', 'MAC', 'Firmware'];
|
|
878
|
+
let total = 0;
|
|
879
|
+
for (const groupName of INVENTORY_GROUPS) {
|
|
880
|
+
const entries = groups[groupName];
|
|
881
|
+
if (!entries.length) continue;
|
|
882
|
+
total += entries.length;
|
|
883
|
+
|
|
884
|
+
const rows: string[][] = [
|
|
885
|
+
headers,
|
|
886
|
+
...entries.map(e => [e.name, e.model, e.ip, formatKasaMac(e.mac), e.firmware]),
|
|
887
|
+
];
|
|
888
|
+
// Compute per-column widths so columns line up cleanly inside the code block.
|
|
889
|
+
const widths = headers.map((_, col) => Math.max(...rows.map(r => r[col].length)));
|
|
890
|
+
|
|
891
|
+
lines.push(`## ${groupName} (${entries.length})`, '', '```');
|
|
892
|
+
for (const row of rows) {
|
|
893
|
+
// Two-space gutter between columns is enough to keep them visually distinct
|
|
894
|
+
// without making the block too wide for narrow detail panels.
|
|
895
|
+
lines.push(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
|
|
896
|
+
}
|
|
897
|
+
lines.push('```', '');
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (total === 0) lines.push('_No devices adopted yet._');
|
|
901
|
+
return lines.join('\n');
|
|
583
902
|
}
|
|
584
903
|
|
|
585
904
|
async getCreateDeviceSettings(): Promise<Setting[]> {
|
|
@@ -640,7 +959,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
|
|
|
640
959
|
room: string | undefined,
|
|
641
960
|
kasaClass: KasaDeviceClass,
|
|
642
961
|
): Promise<void> {
|
|
643
|
-
const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
|
|
962
|
+
const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings, ScryptedInterface.Readme];
|
|
644
963
|
let type: ScryptedDeviceType;
|
|
645
964
|
switch (kasaClass) {
|
|
646
965
|
case 'plug':
|
|
@@ -694,6 +1013,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
|
|
|
694
1013
|
// OnOff drives the camera's status LED — HomeKit binds its
|
|
695
1014
|
// CameraOperatingModeIndicator characteristic to this.
|
|
696
1015
|
ScryptedInterface.OnOff,
|
|
1016
|
+
ScryptedInterface.Readme,
|
|
697
1017
|
],
|
|
698
1018
|
info: {
|
|
699
1019
|
manufacturer: 'TP-Link Kasa',
|
|
@@ -771,6 +1091,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
|
|
|
771
1091
|
ScryptedInterface.Intercom,
|
|
772
1092
|
ScryptedInterface.DeviceProvider,
|
|
773
1093
|
ScryptedInterface.OnOff,
|
|
1094
|
+
ScryptedInterface.Readme,
|
|
774
1095
|
],
|
|
775
1096
|
info,
|
|
776
1097
|
// Cameras need the cloud account credentials too — auth on the stream/talk
|
|
@@ -789,7 +1110,7 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
|
|
|
789
1110
|
}
|
|
790
1111
|
|
|
791
1112
|
// Plug, Switch, Bulb — all share the same simpler adoption form.
|
|
792
|
-
const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
|
|
1113
|
+
const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings, ScryptedInterface.Readme];
|
|
793
1114
|
let type: ScryptedDeviceType;
|
|
794
1115
|
if (cls === 'bulb') {
|
|
795
1116
|
type = ScryptedDeviceType.Light;
|
|
@@ -941,7 +1262,11 @@ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCre
|
|
|
941
1262
|
name: string,
|
|
942
1263
|
room?: string,
|
|
943
1264
|
): Promise<string> {
|
|
944
|
-
const interfaces: ScryptedInterface[] = [
|
|
1265
|
+
const interfaces: ScryptedInterface[] = [
|
|
1266
|
+
ScryptedInterface.OnOff,
|
|
1267
|
+
ScryptedInterface.Settings,
|
|
1268
|
+
ScryptedInterface.Readme,
|
|
1269
|
+
];
|
|
945
1270
|
let type: ScryptedDeviceType;
|
|
946
1271
|
const caps = bulbCapabilities(device);
|
|
947
1272
|
// The marker we persist for getDevice routing. 'plug'/'switch' for plain on/off
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Shared formatting helpers for the per-device + plugin-level Readme tabs. Scrypted's
|
|
2
|
+
// markdown renderer is plain CommonMark — it doesn't recognize GFM pipe tables, so we
|
|
3
|
+
// render tabular data inside fenced code blocks with manual column padding.
|
|
4
|
+
|
|
5
|
+
// Format a hex MAC (e.g. "F0090D49721F") with colon separators (e.g. "F0:09:0D:49:72:1F").
|
|
6
|
+
// Returns the input unchanged if it isn't a 12-character hex string — Kasa devices
|
|
7
|
+
// occasionally surface MACs in already-colon-formatted form, and we don't want to mangle
|
|
8
|
+
// either case.
|
|
9
|
+
export function formatKasaMac(mac: string | undefined): string {
|
|
10
|
+
if (!mac) return '?';
|
|
11
|
+
if (!/^[0-9A-Fa-f]{12}$/.test(mac)) return mac;
|
|
12
|
+
return mac.match(/.{2}/g)!.join(':').toUpperCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Render a list of [label, value] pairs as a left-padded plain-text block. Caller is
|
|
16
|
+
// expected to wrap the output in triple-backtick fences so monospace alignment renders.
|
|
17
|
+
export function renderKv(rows: [string, string][]): string {
|
|
18
|
+
const labelWidth = Math.max(...rows.map(([k]) => k.length));
|
|
19
|
+
return rows.map(([k, v]) => `${k.padEnd(labelWidth)} ${v}`).join('\n');
|
|
20
|
+
}
|