scrypted-kasa 1.3.1 → 1.3.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/dist/plugin.zip +0 -0
- package/package.json +4 -1
- package/.github/dependabot.yml +0 -14
- package/.github/workflows/ci.yml +0 -40
- package/.github/workflows/publish.yml +0 -52
- package/.nvmrc +0 -1
- package/.prettierignore +0 -6
- package/.prettierrc.json +0 -11
- package/eslint.config.mjs +0 -33
- package/src/camera/api.ts +0 -354
- package/src/camera/intercom.ts +0 -186
- package/src/camera/linkie.ts +0 -185
- package/src/camera/rtp.ts +0 -238
- package/src/camera/rtsp.ts +0 -270
- package/src/camera/stream.ts +0 -346
- package/src/iot/bulb.ts +0 -223
- package/src/iot/device.ts +0 -250
- package/src/iot/dimmer.ts +0 -19
- package/src/iot/plug.ts +0 -5
- package/src/iot/protocol.ts +0 -182
- package/src/iot/switch.ts +0 -5
- package/src/main.ts +0 -1429
- package/src/shared/cipher.ts +0 -49
- package/src/shared/discovery.ts +0 -232
- package/src/shared/readme.ts +0 -20
- package/tsconfig.json +0 -15
package/src/main.ts
DELETED
|
@@ -1,1429 +0,0 @@
|
|
|
1
|
-
import sdk, {
|
|
2
|
-
AdoptDevice,
|
|
3
|
-
Device,
|
|
4
|
-
DeviceCreator,
|
|
5
|
-
DeviceCreatorSettings,
|
|
6
|
-
DeviceDiscovery,
|
|
7
|
-
DeviceProvider,
|
|
8
|
-
DiscoveredDevice,
|
|
9
|
-
FFmpegInput,
|
|
10
|
-
Intercom,
|
|
11
|
-
MediaObject,
|
|
12
|
-
OnOff,
|
|
13
|
-
Readme,
|
|
14
|
-
RequestMediaStreamOptions,
|
|
15
|
-
ResponseMediaStreamOptions,
|
|
16
|
-
ScryptedDeviceBase,
|
|
17
|
-
ScryptedDeviceType,
|
|
18
|
-
ScryptedInterface,
|
|
19
|
-
ScryptedMimeTypes,
|
|
20
|
-
ScryptedNativeId,
|
|
21
|
-
Setting,
|
|
22
|
-
Settings,
|
|
23
|
-
SettingValue,
|
|
24
|
-
VideoCamera,
|
|
25
|
-
} from '@scrypted/sdk';
|
|
26
|
-
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
27
|
-
import child_process, { ChildProcess } from 'child_process';
|
|
28
|
-
import { randomBytes } from 'crypto';
|
|
29
|
-
import { KASA_DEFAULT_PORT, KasaClient } from './camera/api';
|
|
30
|
-
import { KASA_TALK_PORT, KasaTalkSession } from './camera/intercom';
|
|
31
|
-
import { KasaLinkieClient } from './camera/linkie';
|
|
32
|
-
import { KasaStreamHandle, registerLiveCleanup, spawnKasaStream } from './camera/stream';
|
|
33
|
-
import { KasaBulb } from './iot/bulb';
|
|
34
|
-
import { KasaDimmer } from './iot/dimmer';
|
|
35
|
-
import { KasaPlug } from './iot/plug';
|
|
36
|
-
import { KASA_IOT_PORT } from './iot/protocol';
|
|
37
|
-
import { KasaSwitch } from './iot/switch';
|
|
38
|
-
import { discoverKasa, KasaDiscoveredDevice } from './shared/discovery';
|
|
39
|
-
import { formatKasaMac, renderKv } from './shared/readme';
|
|
40
|
-
|
|
41
|
-
// G.711 µ-law packetization: 8000 samples/sec * 1 byte/sample = 160 bytes/20ms.
|
|
42
|
-
// 20 ms is the standard RTP packetization for PCMU and matches what the Kasa app appears
|
|
43
|
-
// to use for its uplink chunks during active talk.
|
|
44
|
-
const TALK_CHUNK_BYTES = 160;
|
|
45
|
-
|
|
46
|
-
// Models in this set are TP-Link Kasa cameras (sysinfo.type === 'IOT.IPCAMERA' is the primary
|
|
47
|
-
// filter; this is a defense-in-depth allowlist for ambiguous replies).
|
|
48
|
-
const KASA_CAMERA_TYPES = new Set(['IOT.IPCAMERA']);
|
|
49
|
-
|
|
50
|
-
// Promise wrapper with externally-callable resolve/reject. Used here as a kill switch for
|
|
51
|
-
// camera streaming: any teardown source resolves it once and every owned resource registers
|
|
52
|
-
// a cleanup against the same promise.
|
|
53
|
-
class Deferred<T> {
|
|
54
|
-
finished = false;
|
|
55
|
-
resolve!: (value: T) => void;
|
|
56
|
-
reject!: (error: Error) => void;
|
|
57
|
-
promise: Promise<T>;
|
|
58
|
-
|
|
59
|
-
constructor() {
|
|
60
|
-
this.promise = new Promise<T>((resolve, reject) => {
|
|
61
|
-
this.resolve = v => {
|
|
62
|
-
this.finished = true;
|
|
63
|
-
resolve(v);
|
|
64
|
-
};
|
|
65
|
-
this.reject = e => {
|
|
66
|
-
this.finished = true;
|
|
67
|
-
reject(e);
|
|
68
|
-
};
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// All values stored under the `kasaClass` storage key live in this union. classifyKasa()
|
|
74
|
-
// only returns the four families that auto-discovery can detect from sysinfo; 'dimmer' is
|
|
75
|
-
// added by adoptIotDevice() when the device exposes brightness, and the manual 'Add device'
|
|
76
|
-
// dialog can persist any of the five.
|
|
77
|
-
type KasaDeviceClass = 'camera' | 'plug' | 'switch' | 'dimmer' | 'bulb';
|
|
78
|
-
|
|
79
|
-
// Classify a discovered device by its sysinfo. Returns undefined for device families we
|
|
80
|
-
// don't model (e.g. multi-outlet plug strips, hubs).
|
|
81
|
-
function classifyKasa(d: KasaDiscoveredDevice): KasaDeviceClass | undefined {
|
|
82
|
-
if (KASA_CAMERA_TYPES.has(d.type)) return 'camera';
|
|
83
|
-
if (d.type === 'IOT.SMARTBULB') return 'bulb';
|
|
84
|
-
if (d.type === 'IOT.SMARTPLUGSWITCH') {
|
|
85
|
-
// The Kasa app distinguishes outlets from light switches by `dev_name` /
|
|
86
|
-
// `description`. HS200/210/220 are wall-mounted switches; everything else is an
|
|
87
|
-
// outlet. Multi-outlet strips have a `children` array and are skipped — they need
|
|
88
|
-
// per-outlet handling we don't do yet.
|
|
89
|
-
if (Array.isArray((d.sysinfo as any)?.children) && (d.sysinfo as any).children.length) return undefined;
|
|
90
|
-
const devName: string = (d.sysinfo as any)?.dev_name || '';
|
|
91
|
-
if (/switch|dimmer/i.test(devName)) return 'switch';
|
|
92
|
-
return 'plug';
|
|
93
|
-
}
|
|
94
|
-
return undefined;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function isDimmer(d: KasaDiscoveredDevice): boolean {
|
|
98
|
-
const sys = d.sysinfo as any;
|
|
99
|
-
if (typeof sys?.brightness === 'number') return true;
|
|
100
|
-
const feature: string = sys?.feature || '';
|
|
101
|
-
if (/DIM/.test(feature)) return true;
|
|
102
|
-
// KS230 (3-way dimmer) doesn't report `brightness` in sysinfo at idle, and its
|
|
103
|
-
// `feature` string doesn't include DIM either — but `dev_name` always says "Dimmer".
|
|
104
|
-
const devName: string = sys?.dev_name || '';
|
|
105
|
-
return /dimmer/i.test(devName);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
interface BulbCapabilities {
|
|
109
|
-
isColor: boolean;
|
|
110
|
-
isVariableColorTemp: boolean;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function bulbCapabilities(d: KasaDiscoveredDevice): BulbCapabilities {
|
|
114
|
-
const sys = d.sysinfo as any;
|
|
115
|
-
return {
|
|
116
|
-
isColor: sys?.is_color === 1,
|
|
117
|
-
isVariableColorTemp: sys?.is_variable_color_temp === 1,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const { deviceManager, mediaManager, systemManager } = sdk;
|
|
122
|
-
|
|
123
|
-
// Walk every known device's state to collect existing room names. Used to populate the room
|
|
124
|
-
// dropdown so the user picks an existing room instead of typing a new one. Cached for 2 s so
|
|
125
|
-
// rapid back-to-back calls (Scrypted's UI sometimes re-fetches on every render of the Add
|
|
126
|
-
// Device dialog) don't repeatedly walk the entire system state, which can be hundreds of KB.
|
|
127
|
-
let knownRoomsCache: { rooms: string[]; at: number } | undefined;
|
|
128
|
-
function getKnownRooms(): string[] {
|
|
129
|
-
const now = Date.now();
|
|
130
|
-
if (knownRoomsCache && now - knownRoomsCache.at < 2000) return knownRoomsCache.rooms;
|
|
131
|
-
const rooms = new Set<string>();
|
|
132
|
-
const states = systemManager.getSystemState();
|
|
133
|
-
for (const id of Object.keys(states)) {
|
|
134
|
-
const room = states[id]?.room?.value;
|
|
135
|
-
if (typeof room === 'string' && room.trim()) rooms.add(room.trim());
|
|
136
|
-
}
|
|
137
|
-
const out = [...rooms].sort((a, b) => a.localeCompare(b));
|
|
138
|
-
knownRoomsCache = { rooms: out, at: now };
|
|
139
|
-
return out;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Child device for cameras with a spotlight (e.g. KC420WS — the Kasa app calls this
|
|
143
|
-
// the "spotlight"). Backed by the LINKIE2 `smartlife.cam.ipcamera.dayNight.set_force_lamp_state`
|
|
144
|
-
// command. (The protocol-level name is "force_lamp" — internally the camera firmware
|
|
145
|
-
// treats this as a generic forced-lamp state, but in the user-facing UI it's a spotlight.)
|
|
146
|
-
class KasaCameraSpotlight extends ScryptedDeviceBase implements OnOff {
|
|
147
|
-
constructor(
|
|
148
|
-
public camera: KasaCamera,
|
|
149
|
-
nativeId: string,
|
|
150
|
-
) {
|
|
151
|
-
super(nativeId);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async turnOn(): Promise<void> {
|
|
155
|
-
await this.camera.linkie().setForceLampState(true);
|
|
156
|
-
this.on = true;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async turnOff(): Promise<void> {
|
|
160
|
-
await this.camera.linkie().setForceLampState(false);
|
|
161
|
-
this.on = false;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Child device for the siren. Backed by smartlife.cam.ipcamera.siren.set_state. The
|
|
166
|
-
// camera auto-stops after the duration set in the Kasa app (default 30 s), so the `on`
|
|
167
|
-
// state shown in Scrypted may not reflect that auto-off until something polls again.
|
|
168
|
-
class KasaCameraSiren extends ScryptedDeviceBase implements OnOff {
|
|
169
|
-
constructor(
|
|
170
|
-
public camera: KasaCamera,
|
|
171
|
-
nativeId: string,
|
|
172
|
-
) {
|
|
173
|
-
super(nativeId);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async turnOn(): Promise<void> {
|
|
177
|
-
await this.camera.linkie().setSirenState(true);
|
|
178
|
-
this.on = true;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async turnOff(): Promise<void> {
|
|
182
|
-
await this.camera.linkie().setSirenState(false);
|
|
183
|
-
this.on = false;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Intercom, DeviceProvider, OnOff, Readme {
|
|
188
|
-
private intercomSession?: KasaTalkSession;
|
|
189
|
-
private intercomFfmpeg?: ChildProcess;
|
|
190
|
-
private spotlight?: KasaCameraSpotlight;
|
|
191
|
-
private siren?: KasaCameraSiren;
|
|
192
|
-
// Kill switches for any in-flight video streams. Stored so we can tear them down when
|
|
193
|
-
// the user changes network/auth settings — otherwise the existing TCP connection keeps
|
|
194
|
-
// talking to the old IP and clients see "phantom" video from a stale endpoint.
|
|
195
|
-
private activeStreamKills = new Set<Deferred<void>>();
|
|
196
|
-
|
|
197
|
-
constructor(nativeId: string) {
|
|
198
|
-
super(nativeId);
|
|
199
|
-
// Probe-and-register child devices once settings are available. process.nextTick
|
|
200
|
-
// defers past constructor so storageSettings is fully wired.
|
|
201
|
-
process.nextTick(() =>
|
|
202
|
-
this.refreshChildDevices().catch(e => this.console.warn('refreshChildDevices failed', e)),
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
private cachedLinkie?: { client: KasaLinkieClient; ip: string; username: string; password: string };
|
|
207
|
-
linkie(): KasaLinkieClient {
|
|
208
|
-
const { ip, username, password } = this.storageSettings.values;
|
|
209
|
-
// Don't pass storageSettings.port — that's the stream port (19443). LINKIE2 lives
|
|
210
|
-
// on its own fixed port (10443) which the client supplies as a default.
|
|
211
|
-
const c = this.cachedLinkie;
|
|
212
|
-
if (c && c.ip === ip && c.username === username && c.password === password) return c.client;
|
|
213
|
-
const client = new KasaLinkieClient({ ip, username, password }, this.console);
|
|
214
|
-
this.cachedLinkie = { client, ip, username, password };
|
|
215
|
-
return client;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private get spotlightNativeId(): string {
|
|
219
|
-
return `${this.nativeId}-spotlight`;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
private get sirenNativeId(): string {
|
|
223
|
-
return `${this.nativeId}-siren`;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Shared in-flight promise so concurrent triggers (constructor, putSetting,
|
|
227
|
-
// post-adoption) don't run the LINKIE probe loop in parallel against the same camera.
|
|
228
|
-
// The probes are sequential per call; running two in parallel would double the
|
|
229
|
-
// request load and racing onDeviceDiscovered() could re-register children twice.
|
|
230
|
-
private refreshChildDevicesInFlight?: Promise<void>;
|
|
231
|
-
async refreshChildDevices(): Promise<void> {
|
|
232
|
-
if (this.refreshChildDevicesInFlight) return this.refreshChildDevicesInFlight;
|
|
233
|
-
this.refreshChildDevicesInFlight = this.refreshChildDevicesInternal().finally(() => {
|
|
234
|
-
this.refreshChildDevicesInFlight = undefined;
|
|
235
|
-
});
|
|
236
|
-
return this.refreshChildDevicesInFlight;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
private async refreshChildDevicesInternal(): Promise<void> {
|
|
240
|
-
const { ip, username, password } = this.storageSettings.values;
|
|
241
|
-
if (!ip || !username || !password) return;
|
|
242
|
-
|
|
243
|
-
const linkie = this.linkie();
|
|
244
|
-
// Probe each capability sequentially. The Kasa iOS app issues LINKIE2 calls
|
|
245
|
-
// serially; firing them in parallel against the same camera occasionally drops
|
|
246
|
-
// responses (likely camera-side request serialization).
|
|
247
|
-
const ledState = await linkie.getLedStatus();
|
|
248
|
-
const lampState = await linkie.getForceLampState();
|
|
249
|
-
const sirenState = await linkie.getSirenState();
|
|
250
|
-
|
|
251
|
-
if (ledState !== undefined) this.on = ledState === 'on';
|
|
252
|
-
|
|
253
|
-
if (lampState !== undefined) {
|
|
254
|
-
await deviceManager.onDeviceDiscovered({
|
|
255
|
-
nativeId: this.spotlightNativeId,
|
|
256
|
-
name: `${this.name || 'Kasa Camera'} Spotlight`,
|
|
257
|
-
type: ScryptedDeviceType.Light,
|
|
258
|
-
interfaces: [ScryptedInterface.OnOff],
|
|
259
|
-
providerNativeId: this.nativeId,
|
|
260
|
-
// Inherit the camera's room so children show up next to it in the UI.
|
|
261
|
-
// Empty string would clear an existing room assignment, so pass undefined.
|
|
262
|
-
room: this.room || undefined,
|
|
263
|
-
});
|
|
264
|
-
if (!this.spotlight) this.spotlight = new KasaCameraSpotlight(this, this.spotlightNativeId);
|
|
265
|
-
this.spotlight.on = lampState === 'on';
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (sirenState !== undefined) {
|
|
269
|
-
await deviceManager.onDeviceDiscovered({
|
|
270
|
-
nativeId: this.sirenNativeId,
|
|
271
|
-
name: `${this.name || 'Kasa Camera'} Siren`,
|
|
272
|
-
type: ScryptedDeviceType.Switch,
|
|
273
|
-
interfaces: [ScryptedInterface.OnOff],
|
|
274
|
-
providerNativeId: this.nativeId,
|
|
275
|
-
room: this.room || undefined,
|
|
276
|
-
});
|
|
277
|
-
if (!this.siren) this.siren = new KasaCameraSiren(this, this.sirenNativeId);
|
|
278
|
-
this.siren.on = sirenState === 'on';
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
async getDevice(nativeId: string): Promise<any> {
|
|
283
|
-
if (nativeId === this.spotlightNativeId) {
|
|
284
|
-
if (!this.spotlight) this.spotlight = new KasaCameraSpotlight(this, this.spotlightNativeId);
|
|
285
|
-
return this.spotlight;
|
|
286
|
-
}
|
|
287
|
-
if (nativeId === this.sirenNativeId) {
|
|
288
|
-
if (!this.siren) this.siren = new KasaCameraSiren(this, this.sirenNativeId);
|
|
289
|
-
return this.siren;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async releaseDevice(_id: string, _nativeId: string): Promise<void> {
|
|
294
|
-
// No persistent resources per child to release.
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
storageSettings = new StorageSettings(this, {
|
|
298
|
-
ip: {
|
|
299
|
-
title: 'IP Address',
|
|
300
|
-
placeholder: '192.168.1.100',
|
|
301
|
-
},
|
|
302
|
-
port: {
|
|
303
|
-
title: 'Port',
|
|
304
|
-
type: 'number',
|
|
305
|
-
defaultValue: KASA_DEFAULT_PORT,
|
|
306
|
-
},
|
|
307
|
-
username: {
|
|
308
|
-
title: 'Username (Kasa Email)',
|
|
309
|
-
placeholder: 'user@example.com',
|
|
310
|
-
description: 'The TP-Link/Kasa account email associated with the camera.',
|
|
311
|
-
},
|
|
312
|
-
password: {
|
|
313
|
-
title: 'Password (Kasa Account)',
|
|
314
|
-
type: 'password',
|
|
315
|
-
description: 'The TP-Link/Kasa account password.',
|
|
316
|
-
},
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
getSettings(): Promise<Setting[]> {
|
|
320
|
-
return this.storageSettings.getSettings();
|
|
321
|
-
}
|
|
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
|
-
|
|
364
|
-
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
365
|
-
await this.storageSettings.putSetting(key, value);
|
|
366
|
-
if (key === 'ip' || key === 'port' || key === 'username' || key === 'password') {
|
|
367
|
-
// Tear down active streams + intercom — they're holding TCP sockets to the old
|
|
368
|
-
// endpoint, which keeps delivering frames from the wrong camera until something
|
|
369
|
-
// else closes them. Clients will reconnect against the new settings.
|
|
370
|
-
for (const kill of [...this.activeStreamKills]) kill.resolve();
|
|
371
|
-
this.stopIntercom().catch(() => {});
|
|
372
|
-
// Re-probe child devices when network or auth settings change. Manually-added
|
|
373
|
-
// cameras get their credentials filled in here (rather than at adoption), so this
|
|
374
|
-
// is the moment the spotlight first becomes detectable.
|
|
375
|
-
this.refreshChildDevices().catch(e => this.console.warn('refreshChildDevices failed', e));
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// OnOff drives the camera's status LED. HomeKit binds its CameraOperatingModeIndicator
|
|
380
|
-
// characteristic to this when "Link Status Indicator" is enabled in the HomeKit per-camera
|
|
381
|
-
// settings, so the user can toggle the LED from HomeKit. In Scrypted's UI this surfaces
|
|
382
|
-
// as a plain on/off control on the camera page.
|
|
383
|
-
async turnOn(): Promise<void> {
|
|
384
|
-
await this.linkie().setLedStatus(true);
|
|
385
|
-
this.on = true;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
async turnOff(): Promise<void> {
|
|
389
|
-
await this.linkie().setLedStatus(false);
|
|
390
|
-
this.on = false;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
|
|
394
|
-
return [
|
|
395
|
-
{
|
|
396
|
-
container: 'rtsp',
|
|
397
|
-
id: 'mixed',
|
|
398
|
-
name: 'Mixed',
|
|
399
|
-
video: {
|
|
400
|
-
codec: 'h264',
|
|
401
|
-
},
|
|
402
|
-
audio: {
|
|
403
|
-
codec: 'pcm_mulaw',
|
|
404
|
-
},
|
|
405
|
-
tool: 'scrypted',
|
|
406
|
-
userConfigurable: false,
|
|
407
|
-
},
|
|
408
|
-
];
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async getVideoStream(_options?: RequestMediaStreamOptions): Promise<MediaObject> {
|
|
412
|
-
const { ip, port, username, password } = this.storageSettings.values;
|
|
413
|
-
|
|
414
|
-
if (!ip || !username || !password)
|
|
415
|
-
throw new Error('Kasa camera is not configured. Set IP, username, and password.');
|
|
416
|
-
|
|
417
|
-
const kasa = await KasaClient.connect({
|
|
418
|
-
ip,
|
|
419
|
-
port,
|
|
420
|
-
username,
|
|
421
|
-
password,
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// Single shared kill switch: any teardown source (kasa close, RTSP client
|
|
425
|
-
// disconnect, pump error) resolves it, and every owned resource registers a
|
|
426
|
-
// cleanup against it. spawnKasaStream owns the actual resource teardown; we just
|
|
427
|
-
// surface the kill switch to settings-change handlers via activeStreamKills.
|
|
428
|
-
const kill = new Deferred<void>();
|
|
429
|
-
this.activeStreamKills.add(kill);
|
|
430
|
-
kill.promise.finally(() => this.activeStreamKills.delete(kill));
|
|
431
|
-
kasa.body.on('close', () => kill.resolve());
|
|
432
|
-
kasa.body.on('error', () => kill.resolve());
|
|
433
|
-
|
|
434
|
-
// Read cached SPS/PPS from per-device storage. Lets spawnKasaStream skip the
|
|
435
|
-
// ~1-2s preScanSpsPps wait on cold starts (HomeKit's perceived latency dominates
|
|
436
|
-
// here). Cache survives plugin restarts. Stale-cache risk is bounded — in-band
|
|
437
|
-
// SPS/PPS in the live bitstream supersede whatever we put in the SDP.
|
|
438
|
-
const storage = deviceManager.getDeviceStorage(this.nativeId);
|
|
439
|
-
const cachedSpsB64 = storage?.getItem('cachedSps') || undefined;
|
|
440
|
-
const cachedPpsB64 = storage?.getItem('cachedPps') || undefined;
|
|
441
|
-
const cachedSps = cachedSpsB64 ? Buffer.from(cachedSpsB64, 'base64') : undefined;
|
|
442
|
-
const cachedPps = cachedPpsB64 ? Buffer.from(cachedPpsB64, 'base64') : undefined;
|
|
443
|
-
|
|
444
|
-
let stream: KasaStreamHandle;
|
|
445
|
-
try {
|
|
446
|
-
stream = await spawnKasaStream({
|
|
447
|
-
kasa,
|
|
448
|
-
console: this.console,
|
|
449
|
-
cachedSps,
|
|
450
|
-
cachedPps,
|
|
451
|
-
onFreshSpsPps: (sps, pps) => {
|
|
452
|
-
storage?.setItem('cachedSps', sps.toString('base64'));
|
|
453
|
-
storage?.setItem('cachedPps', pps.toString('base64'));
|
|
454
|
-
},
|
|
455
|
-
});
|
|
456
|
-
} catch (e) {
|
|
457
|
-
kasa.destroy();
|
|
458
|
-
throw e;
|
|
459
|
-
}
|
|
460
|
-
kill.promise.finally(() => stream.kill());
|
|
461
|
-
stream.exited.then(() => kill.resolve());
|
|
462
|
-
|
|
463
|
-
const ffmpegInput: FFmpegInput = {
|
|
464
|
-
url: stream.url,
|
|
465
|
-
mediaStreamOptions: (await this.getVideoStreamOptions())[0],
|
|
466
|
-
inputArguments: ['-rtsp_transport', 'tcp', '-i', stream.url],
|
|
467
|
-
};
|
|
468
|
-
|
|
469
|
-
return mediaManager.createFFmpegMediaObject(ffmpegInput);
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
async startIntercom(media: MediaObject): Promise<void> {
|
|
473
|
-
// Some Scrypted clients call startIntercom again without an intervening stopIntercom
|
|
474
|
-
// (e.g. switching audio sources). Tear down any prior session first so we don't leak
|
|
475
|
-
// an in-flight ffmpeg process or a half-open POST to the camera.
|
|
476
|
-
await this.stopIntercom();
|
|
477
|
-
|
|
478
|
-
// `port` from storage is the receive-stream port (19443); the talk endpoint is on a
|
|
479
|
-
// separate fixed port (KASA_TALK_PORT = 18443), so we don't read it here.
|
|
480
|
-
const { ip, username, password } = this.storageSettings.values;
|
|
481
|
-
if (!ip || !username || !password) throw new Error('Kasa camera is not configured.');
|
|
482
|
-
|
|
483
|
-
const ffmpegInput = await mediaManager.convertMediaObjectToJSON<FFmpegInput>(
|
|
484
|
-
media,
|
|
485
|
-
ScryptedMimeTypes.FFmpegInput,
|
|
486
|
-
);
|
|
487
|
-
|
|
488
|
-
const session = new KasaTalkSession({
|
|
489
|
-
ip,
|
|
490
|
-
port: KASA_TALK_PORT,
|
|
491
|
-
username,
|
|
492
|
-
password,
|
|
493
|
-
console: this.console,
|
|
494
|
-
});
|
|
495
|
-
this.intercomSession = session;
|
|
496
|
-
await session.start();
|
|
497
|
-
|
|
498
|
-
// ffmpeg transcodes whatever audio Scrypted hands us (Opus/AAC/PCM/...) into raw
|
|
499
|
-
// 8 kHz mono G.711 µ-law on stdout, which we chunk into 20 ms blocks and write
|
|
500
|
-
// into the talk session as multipart parts.
|
|
501
|
-
const ffmpegPath = await mediaManager.getFFmpegPath();
|
|
502
|
-
// -loglevel error + -nostats: without these, ffmpeg emits a `size= time= bitrate=`
|
|
503
|
-
// progress line every ~0.5 s during the talkback session. On long talks that's a
|
|
504
|
-
// steady stream of stderr → string allocations + log I/O for no diagnostic value.
|
|
505
|
-
//
|
|
506
|
-
// Low-latency knobs (without these, talkback lagged ~10 s — the symptom was hearing
|
|
507
|
-
// your own voice through the camera speaker only after releasing Push-to-Talk):
|
|
508
|
-
// -fflags +nobuffer : skip libavformat's input prebuffer (typically 1-2 s on SDP/SRTP).
|
|
509
|
-
// -fflags +flush_packets, -flush_packets 1
|
|
510
|
-
// : flush the AVIO output buffer after every encoded packet
|
|
511
|
-
// instead of when the 32 KB buffer fills (~4 s of 8 kHz mulaw).
|
|
512
|
-
// The two forms cover both global flags and the output-muxer
|
|
513
|
-
// explicit option; cheap belt-and-suspenders.
|
|
514
|
-
// -flags low_delay : codec-level low-delay decode/encode flag.
|
|
515
|
-
// -probesize 32, -analyzeduration 0
|
|
516
|
-
// : skip the auto-detect probe phase, which would otherwise
|
|
517
|
-
// wait for ~5 s of audio to estimate stream parameters
|
|
518
|
-
// before producing the first output sample.
|
|
519
|
-
// -rtsp_transport tcp, -reorder_queue_size 0 (RTSP-only, conditional)
|
|
520
|
-
// : force TCP-interleaved RTP and zero the demuxer's
|
|
521
|
-
// out-of-order reordering window. Only emitted when the
|
|
522
|
-
// input URL is RTSP; for non-RTSP inputs ffmpeg would just
|
|
523
|
-
// warn "Option not found" but the conditional keeps the arg
|
|
524
|
-
// list honest about which demuxer each option targets.
|
|
525
|
-
// -max_delay 0 : zero libavformat's max-delay setting. General — applies
|
|
526
|
-
// to several demuxers, including RTSP/RTP — so safe to pass
|
|
527
|
-
// unconditionally.
|
|
528
|
-
// -af aresample=async=0:min_hard_comp=0
|
|
529
|
-
// : disable async resampling. ffmpeg auto-inserts aresample
|
|
530
|
-
// when input/output sample rates differ (Opus 24 kHz →
|
|
531
|
-
// mulaw 8 kHz); async=0 keeps it operating on tiny frames
|
|
532
|
-
// without the multi-second resync window async=1 introduces.
|
|
533
|
-
const inputArgs = ffmpegInput.inputArguments || [];
|
|
534
|
-
const isRtspInput = inputArgs.some(a => typeof a === 'string' && a.startsWith('rtsp://'));
|
|
535
|
-
// prettier-ignore
|
|
536
|
-
const args = [
|
|
537
|
-
'-hide_banner',
|
|
538
|
-
'-loglevel', 'error',
|
|
539
|
-
'-nostats',
|
|
540
|
-
'-fflags', '+nobuffer+flush_packets',
|
|
541
|
-
'-flags', 'low_delay',
|
|
542
|
-
'-probesize', '32',
|
|
543
|
-
'-analyzeduration', '0',
|
|
544
|
-
'-max_delay', '0',
|
|
545
|
-
...(isRtspInput ? ['-rtsp_transport', 'tcp', '-reorder_queue_size', '0'] : []),
|
|
546
|
-
'-thread_queue_size', '1',
|
|
547
|
-
'-use_wallclock_as_timestamps', '1',
|
|
548
|
-
...inputArgs,
|
|
549
|
-
'-vn', '-sn', '-dn',
|
|
550
|
-
'-af', 'aresample=async=0:min_hard_comp=0',
|
|
551
|
-
'-f', 'mulaw',
|
|
552
|
-
'-ar', '8000',
|
|
553
|
-
'-ac', '1',
|
|
554
|
-
'-flush_packets', '1',
|
|
555
|
-
'pipe:1',
|
|
556
|
-
];
|
|
557
|
-
const cp = child_process.spawn(ffmpegPath, args);
|
|
558
|
-
this.intercomFfmpeg = cp;
|
|
559
|
-
|
|
560
|
-
// Process-level cleanup: if our worker terminates while the intercom is active,
|
|
561
|
-
// ffmpeg + the kasa POST socket would survive. Register a kill so worker exit
|
|
562
|
-
// takes them down. Unregistered when the session closes normally.
|
|
563
|
-
const unregisterCleanup = registerLiveCleanup(() => {
|
|
564
|
-
try {
|
|
565
|
-
cp.kill('SIGKILL');
|
|
566
|
-
} catch {}
|
|
567
|
-
session.close();
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
// Queue of incoming chunks plus byte total — avoids repeatedly Buffer.concat'ing the
|
|
571
|
-
// accumulator on every stdout chunk (O(n²) over a long talk session). We only allocate
|
|
572
|
-
// a fresh buffer when we have ≥ TALK_CHUNK_BYTES across the queue and need to emit.
|
|
573
|
-
const queue: Buffer[] = [];
|
|
574
|
-
let queuedBytes = 0;
|
|
575
|
-
const takeChunk = (): Buffer => {
|
|
576
|
-
if (queue.length === 1 && queue[0].length === TALK_CHUNK_BYTES) {
|
|
577
|
-
queuedBytes -= TALK_CHUNK_BYTES;
|
|
578
|
-
return queue.shift()!;
|
|
579
|
-
}
|
|
580
|
-
const out = Buffer.allocUnsafe(TALK_CHUNK_BYTES);
|
|
581
|
-
let written = 0;
|
|
582
|
-
while (written < TALK_CHUNK_BYTES) {
|
|
583
|
-
const head = queue[0];
|
|
584
|
-
const need = TALK_CHUNK_BYTES - written;
|
|
585
|
-
if (head.length <= need) {
|
|
586
|
-
head.copy(out, written);
|
|
587
|
-
written += head.length;
|
|
588
|
-
queue.shift();
|
|
589
|
-
} else {
|
|
590
|
-
head.copy(out, written, 0, need);
|
|
591
|
-
queue[0] = head.subarray(need);
|
|
592
|
-
written = TALK_CHUNK_BYTES;
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
queuedBytes -= TALK_CHUNK_BYTES;
|
|
596
|
-
return out;
|
|
597
|
-
};
|
|
598
|
-
cp.stdout!.on('data', (chunk: Buffer) => {
|
|
599
|
-
queue.push(chunk);
|
|
600
|
-
queuedBytes += chunk.length;
|
|
601
|
-
while (queuedBytes >= TALK_CHUNK_BYTES) {
|
|
602
|
-
const ok = session.writeAudio(takeChunk());
|
|
603
|
-
// Backpressure: if the camera's HTTPS upload can't keep up, pause ffmpeg's
|
|
604
|
-
// stdout until the body drains. Without this the PassThrough buffer would
|
|
605
|
-
// grow without bound on a flaky network.
|
|
606
|
-
if (!ok) {
|
|
607
|
-
cp.stdout!.pause();
|
|
608
|
-
session.onDrain(() => cp.stdout!.resume());
|
|
609
|
-
break;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
});
|
|
613
|
-
cp.stderr!.on('data', d => this.console.log('intercom ffmpeg:', d.toString().trim()));
|
|
614
|
-
cp.on('exit', () => {
|
|
615
|
-
this.console.log('intercom ffmpeg exited');
|
|
616
|
-
session.close();
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
// The camera can drop the talk uplink on its own (network blip, request error,
|
|
620
|
-
// or the camera ending the response). Without observing that, ffmpeg would keep
|
|
621
|
-
// running and consuming CPU long after the session is dead.
|
|
622
|
-
session.onClose(() => {
|
|
623
|
-
unregisterCleanup();
|
|
624
|
-
if (this.intercomSession === session) {
|
|
625
|
-
this.intercomSession = undefined;
|
|
626
|
-
this.intercomFfmpeg = undefined;
|
|
627
|
-
try {
|
|
628
|
-
cp.kill();
|
|
629
|
-
} catch {}
|
|
630
|
-
}
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
async stopIntercom(): Promise<void> {
|
|
635
|
-
const session = this.intercomSession;
|
|
636
|
-
const cp = this.intercomFfmpeg;
|
|
637
|
-
this.intercomSession = undefined;
|
|
638
|
-
this.intercomFfmpeg = undefined;
|
|
639
|
-
session?.close();
|
|
640
|
-
try {
|
|
641
|
-
cp?.kill();
|
|
642
|
-
} catch {}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
// Called by KasaPlugin.releaseDevice when this camera is removed (or the plugin is
|
|
646
|
-
// uninstalled). Tears down everything that holds a kasa-side connection or a child
|
|
647
|
-
// process — without this, Scrypted's worker-thread runtime can leave ffmpeg processes
|
|
648
|
-
// and kasa HTTPS sockets running after the plugin is gone, since child_process is
|
|
649
|
-
// owned by the host Node process, not the worker.
|
|
650
|
-
release(): void {
|
|
651
|
-
// Each kill triggers cleanupAll inside spawnKasaStream — destroys the kasa
|
|
652
|
-
// connection and closes the local RTSP server.
|
|
653
|
-
for (const kill of [...this.activeStreamKills]) kill.resolve();
|
|
654
|
-
// Stop the intercom too if anything was active. stopIntercom is async only because
|
|
655
|
-
// its signature is — the work it does is synchronous, so fire-and-forget is fine.
|
|
656
|
-
this.stopIntercom().catch(() => {});
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
interface KasaDiscoveryEntry {
|
|
661
|
-
device: KasaDiscoveredDevice;
|
|
662
|
-
// The cached entry expires so a stale IP/MAC mapping doesn't linger across DHCP changes.
|
|
663
|
-
timeout: NodeJS.Timeout;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
interface InventoryEntry {
|
|
667
|
-
name: string;
|
|
668
|
-
model: string;
|
|
669
|
-
firmware: string;
|
|
670
|
-
ip: string;
|
|
671
|
-
mac: string;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const INVENTORY_GROUPS = ['Cameras', 'Bulbs / Dimmers', 'Plugs', 'Switches'] as const;
|
|
675
|
-
type InventoryGroup = (typeof INVENTORY_GROUPS)[number];
|
|
676
|
-
|
|
677
|
-
class KasaPlugin
|
|
678
|
-
extends ScryptedDeviceBase
|
|
679
|
-
implements DeviceProvider, DeviceCreator, DeviceDiscovery, Readme, Settings
|
|
680
|
-
{
|
|
681
|
-
devices = new Map<string, KasaCamera | KasaPlug | KasaSwitch | KasaDimmer | KasaBulb>();
|
|
682
|
-
discoveredDevices = new Map<string, KasaDiscoveryEntry>();
|
|
683
|
-
// In-flight scan so concurrent scan=true calls share one network round-trip instead of
|
|
684
|
-
// each kicking off its own broadcast + TCP sweep.
|
|
685
|
-
private scanInFlight?: Promise<void>;
|
|
686
|
-
// Suppress redundant re-scans for this long after one completes. Scrypted's discovery
|
|
687
|
-
// UI fires scan=true on every type-filter click; a fresh scan + onDeviceEvent on every
|
|
688
|
-
// click resets the user-applied filter. Returning cached results skips both.
|
|
689
|
-
private static SCAN_COOLDOWN_MS = 5000;
|
|
690
|
-
private lastScanAt = 0;
|
|
691
|
-
|
|
692
|
-
constructor(nativeId?: string) {
|
|
693
|
-
super(nativeId);
|
|
694
|
-
this.systemDevice = {
|
|
695
|
-
deviceCreator: 'Device',
|
|
696
|
-
// Singular noun — Scrypted's UI auto-pluralizes the label, so 'Kasa Devices'
|
|
697
|
-
// would render as "Kasa Devicess".
|
|
698
|
-
deviceDiscovery: 'Kasa Device',
|
|
699
|
-
};
|
|
700
|
-
// Devices adopted before Readme was added need their interface lists topped up so
|
|
701
|
-
// the Readme tab actually shows on the device page. Defer to next tick so the SDK
|
|
702
|
-
// is fully wired before we walk the device manager.
|
|
703
|
-
process.nextTick(() => this.migrateAddReadme().catch(e => this.console.warn('migrateAddReadme failed', e)));
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// Plugin Settings tab — single button that re-runs UDP discovery and refreshes the
|
|
707
|
-
// model / firmware / IP / MAC reported on every adopted device. Useful after a
|
|
708
|
-
// firmware update, a device rename in the Kasa app, or a DHCP-rotated IP.
|
|
709
|
-
async getSettings(): Promise<Setting[]> {
|
|
710
|
-
return [
|
|
711
|
-
{
|
|
712
|
-
key: 'refresh',
|
|
713
|
-
title: 'Refresh devices',
|
|
714
|
-
description:
|
|
715
|
-
'Re-run UDP discovery and update model, firmware, IP, and MAC for every adopted ' +
|
|
716
|
-
'Kasa device. Devices not currently reachable on the LAN are skipped.',
|
|
717
|
-
type: 'button',
|
|
718
|
-
},
|
|
719
|
-
];
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
async putSetting(key: string, _value: SettingValue): Promise<void> {
|
|
723
|
-
if (key === 'refresh') await this.refreshAllDevices();
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Shared in-flight promise so two clicks of the Refresh button don't kick off two
|
|
727
|
-
// overlapping discovery sweeps. Same pattern as scanInFlight in discoverDevices.
|
|
728
|
-
private refreshInFlight?: Promise<void>;
|
|
729
|
-
|
|
730
|
-
// Re-run UDP discovery and update each adopted device's `info` (model, firmware, IP,
|
|
731
|
-
// MAC) from the fresh sysinfo response. Devices that don't respond to the sweep
|
|
732
|
-
// (powered off, on a different subnet) are left alone — we don't want to clobber
|
|
733
|
-
// good cached data with a one-shot network blip.
|
|
734
|
-
private async refreshAllDevices(): Promise<void> {
|
|
735
|
-
if (this.refreshInFlight) return this.refreshInFlight;
|
|
736
|
-
this.refreshInFlight = this.refreshAllDevicesInternal().finally(() => {
|
|
737
|
-
this.refreshInFlight = undefined;
|
|
738
|
-
});
|
|
739
|
-
return this.refreshInFlight;
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
private async refreshAllDevicesInternal(): Promise<void> {
|
|
743
|
-
this.console.log('refresh: running UDP discovery sweep');
|
|
744
|
-
const responses = await discoverKasa(2500, this.console).catch(e => {
|
|
745
|
-
this.console.error('refresh: discovery failed', e);
|
|
746
|
-
return [] as KasaDiscoveredDevice[];
|
|
747
|
-
});
|
|
748
|
-
|
|
749
|
-
// Auto-discovered devices use the Kasa deviceId as their nativeId, but
|
|
750
|
-
// manually-created ones (createDevice) get a random nativeId — those would
|
|
751
|
-
// never match here. Build a MAC index too so we can fall back to matching by
|
|
752
|
-
// the MAC the device's `info` was stamped with.
|
|
753
|
-
const normalizeMac = (mac: string | undefined): string => (mac ? mac.replace(/[:-]/g, '').toLowerCase() : '');
|
|
754
|
-
const byDeviceId = new Map(responses.map(r => [r.deviceId, r]));
|
|
755
|
-
const byMac = new Map<string, KasaDiscoveredDevice>();
|
|
756
|
-
for (const r of responses) {
|
|
757
|
-
const k = normalizeMac(r.mac);
|
|
758
|
-
if (k) byMac.set(k, r);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
// Prefer the freshly-observed string when it's non-empty, otherwise keep the
|
|
762
|
-
// existing value. Discovery responses occasionally surface empty fields (and
|
|
763
|
-
// sw_ver may be missing entirely), and we don't want a refresh to clobber
|
|
764
|
-
// good cached data with an undefined.
|
|
765
|
-
const preferStr = (fresh: string | undefined, existing: string | undefined): string | undefined =>
|
|
766
|
-
fresh && fresh.trim() ? fresh : existing;
|
|
767
|
-
|
|
768
|
-
let updated = 0;
|
|
769
|
-
const missing: string[] = [];
|
|
770
|
-
for (const nativeId of deviceManager.getNativeIds()) {
|
|
771
|
-
if (!nativeId) continue;
|
|
772
|
-
// Spotlight / siren children share the parent camera's sysinfo and are
|
|
773
|
-
// already maintained by KasaCamera.refreshChildDevices.
|
|
774
|
-
if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
|
|
775
|
-
const state = deviceManager.getDeviceState(nativeId);
|
|
776
|
-
if (!state) continue;
|
|
777
|
-
|
|
778
|
-
// Try deviceId match first (auto-discovered), then MAC match (covers
|
|
779
|
-
// manually-created devices and any deviceId mismatches).
|
|
780
|
-
let fresh = byDeviceId.get(nativeId);
|
|
781
|
-
if (!fresh) {
|
|
782
|
-
const macKey = normalizeMac(state.info?.mac);
|
|
783
|
-
if (macKey) fresh = byMac.get(macKey);
|
|
784
|
-
}
|
|
785
|
-
if (!fresh) {
|
|
786
|
-
missing.push(state.name || nativeId);
|
|
787
|
-
continue;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const existing = state.info || {};
|
|
791
|
-
const sw_ver = typeof fresh.sysinfo?.sw_ver === 'string' ? fresh.sysinfo.sw_ver : undefined;
|
|
792
|
-
const newInfo = {
|
|
793
|
-
...existing,
|
|
794
|
-
manufacturer: 'TP-Link Kasa',
|
|
795
|
-
model: preferStr(fresh.model, existing.model),
|
|
796
|
-
mac: preferStr(fresh.mac, existing.mac),
|
|
797
|
-
// IP always takes the freshly-observed value — capturing a DHCP-rotated
|
|
798
|
-
// address is the whole point of the refresh.
|
|
799
|
-
ip: fresh.address,
|
|
800
|
-
firmware: preferStr(sw_ver, existing.firmware),
|
|
801
|
-
};
|
|
802
|
-
|
|
803
|
-
// Re-publish without touching providedInterfaces — refreshing metadata, not
|
|
804
|
-
// capabilities. Mixin-provided interfaces (HomeKit, etc.) live in `interfaces`
|
|
805
|
-
// and would be incorrectly re-claimed if we passed those back.
|
|
806
|
-
await deviceManager.onDeviceDiscovered({
|
|
807
|
-
nativeId,
|
|
808
|
-
name: state.name || nativeId,
|
|
809
|
-
type: (state.type || ScryptedDeviceType.Unknown) as ScryptedDeviceType,
|
|
810
|
-
interfaces: state.providedInterfaces || [],
|
|
811
|
-
info: newInfo,
|
|
812
|
-
room: state.room || undefined,
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
// Keep per-device storage in sync — that's what the device classes actually
|
|
816
|
-
// use to connect. Update the in-memory storageSettings too if the device is
|
|
817
|
-
// already instantiated, otherwise it would keep using the stale IP until
|
|
818
|
-
// restarted.
|
|
819
|
-
const storage = deviceManager.getDeviceStorage(nativeId);
|
|
820
|
-
storage?.setItem('ip', fresh.address);
|
|
821
|
-
const dev = this.devices.get(nativeId);
|
|
822
|
-
if (dev?.storageSettings) dev.storageSettings.values.ip = fresh.address;
|
|
823
|
-
|
|
824
|
-
updated++;
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
const summary = `refresh: ${updated} updated, ${missing.length} not found on LAN`;
|
|
828
|
-
this.console.log(missing.length ? `${summary} (${missing.join(', ')})` : summary);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// One-time interface migration for devices adopted before this plugin exposed Readme.
|
|
832
|
-
// Walks every nativeId and re-publishes the device with Readme appended to its
|
|
833
|
-
// providedInterfaces. No-ops once every device already advertises Readme, so it's safe
|
|
834
|
-
// to leave running on every plugin start.
|
|
835
|
-
private async migrateAddReadme(): Promise<void> {
|
|
836
|
-
for (const nativeId of deviceManager.getNativeIds()) {
|
|
837
|
-
if (!nativeId) continue;
|
|
838
|
-
// Spotlight / siren children only expose OnOff — no Readme to add. They're
|
|
839
|
-
// re-published by KasaCamera.refreshChildDevices anyway.
|
|
840
|
-
if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
|
|
841
|
-
const state = deviceManager.getDeviceState(nativeId);
|
|
842
|
-
if (!state) continue;
|
|
843
|
-
// providedInterfaces is the list this plugin originally registered; the
|
|
844
|
-
// `interfaces` field also includes mixin-provided interfaces (HomeKit, etc.)
|
|
845
|
-
// which we must NOT re-claim ownership of by passing them back to
|
|
846
|
-
// onDeviceDiscovered.
|
|
847
|
-
const provided = state.providedInterfaces || [];
|
|
848
|
-
if (provided.includes(ScryptedInterface.Readme)) continue;
|
|
849
|
-
await deviceManager.onDeviceDiscovered({
|
|
850
|
-
nativeId,
|
|
851
|
-
name: state.name || nativeId,
|
|
852
|
-
type: (state.type || ScryptedDeviceType.Unknown) as ScryptedDeviceType,
|
|
853
|
-
interfaces: [...provided, ScryptedInterface.Readme],
|
|
854
|
-
info: state.info,
|
|
855
|
-
room: state.room || undefined,
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Walks every adopted device and groups by Scrypted device type for the Readme
|
|
861
|
-
// inventory. Child devices (spotlight / siren under each camera) are filtered out —
|
|
862
|
-
// they share the parent camera's firmware so listing them separately is just noise.
|
|
863
|
-
//
|
|
864
|
-
// IP comes from per-device storage (the value the plugin actually uses to connect)
|
|
865
|
-
// rather than `state.info.ip` (only set at adoption, doesn't reflect a manual edit
|
|
866
|
-
// in per-device Settings or a DHCP-rotated update). Falls back to info.ip if the
|
|
867
|
-
// storage value is missing — defends against legacy adoption paths that didn't
|
|
868
|
-
// initialize the storage.
|
|
869
|
-
private inventory(): Record<InventoryGroup, InventoryEntry[]> {
|
|
870
|
-
const groups: Record<InventoryGroup, InventoryEntry[]> = {
|
|
871
|
-
Cameras: [],
|
|
872
|
-
'Bulbs / Dimmers': [],
|
|
873
|
-
Plugs: [],
|
|
874
|
-
Switches: [],
|
|
875
|
-
};
|
|
876
|
-
|
|
877
|
-
for (const nativeId of deviceManager.getNativeIds()) {
|
|
878
|
-
if (!nativeId) continue;
|
|
879
|
-
if (nativeId.endsWith('-spotlight') || nativeId.endsWith('-siren')) continue;
|
|
880
|
-
|
|
881
|
-
const state = deviceManager.getDeviceState(nativeId);
|
|
882
|
-
if (!state) continue;
|
|
883
|
-
|
|
884
|
-
const type = state.type as ScryptedDeviceType | undefined;
|
|
885
|
-
const info = (state.info || {}) as { model?: string; firmware?: string; ip?: string; mac?: string };
|
|
886
|
-
const storedIp = deviceManager.getDeviceStorage(nativeId)?.getItem('ip');
|
|
887
|
-
const entry: InventoryEntry = {
|
|
888
|
-
name: state.name || '<unknown>',
|
|
889
|
-
model: info.model || '?',
|
|
890
|
-
firmware: info.firmware || '?',
|
|
891
|
-
ip: storedIp || info.ip || '?',
|
|
892
|
-
mac: info.mac || '?',
|
|
893
|
-
};
|
|
894
|
-
|
|
895
|
-
if (type === ScryptedDeviceType.Camera) groups['Cameras'].push(entry);
|
|
896
|
-
else if (type === ScryptedDeviceType.Light) groups['Bulbs / Dimmers'].push(entry);
|
|
897
|
-
else if (type === ScryptedDeviceType.Outlet) groups['Plugs'].push(entry);
|
|
898
|
-
else if (type === ScryptedDeviceType.Switch) groups['Switches'].push(entry);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Sort by name within each group for stable display order across renders.
|
|
902
|
-
for (const entries of Object.values(groups)) entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
903
|
-
return groups;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
// Plugin Readme tab — inventory data formatted as pre-aligned text inside fenced
|
|
907
|
-
// code blocks. We avoid GFM-style pipe tables because Scrypted's markdown renderer
|
|
908
|
-
// is plain CommonMark — it doesn't recognize them and collapses the pipe rows into
|
|
909
|
-
// a single paragraph. A fenced code block renders as monospace preformatted text in
|
|
910
|
-
// any CommonMark renderer and preserves alignment.
|
|
911
|
-
async getReadmeMarkdown(): Promise<string> {
|
|
912
|
-
const groups = this.inventory();
|
|
913
|
-
const lines: string[] = ['# Kasa Plugin', '', 'Adopted devices, grouped by type.', ''];
|
|
914
|
-
|
|
915
|
-
const headers = ['Name', 'Model', 'IP', 'MAC', 'Firmware'];
|
|
916
|
-
let total = 0;
|
|
917
|
-
for (const groupName of INVENTORY_GROUPS) {
|
|
918
|
-
const entries = groups[groupName];
|
|
919
|
-
if (!entries.length) continue;
|
|
920
|
-
total += entries.length;
|
|
921
|
-
|
|
922
|
-
const rows: string[][] = [
|
|
923
|
-
headers,
|
|
924
|
-
...entries.map(e => [e.name, e.model, e.ip, formatKasaMac(e.mac), e.firmware]),
|
|
925
|
-
];
|
|
926
|
-
// Compute per-column widths so columns line up cleanly inside the code block.
|
|
927
|
-
const widths = headers.map((_, col) => Math.max(...rows.map(r => r[col].length)));
|
|
928
|
-
|
|
929
|
-
lines.push(`## ${groupName} (${entries.length})`, '', '```');
|
|
930
|
-
for (const row of rows) {
|
|
931
|
-
// Two-space gutter between columns is enough to keep them visually distinct
|
|
932
|
-
// without making the block too wide for narrow detail panels.
|
|
933
|
-
lines.push(row.map((cell, col) => cell.padEnd(widths[col])).join(' '));
|
|
934
|
-
}
|
|
935
|
-
lines.push('```', '');
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
if (total === 0) lines.push('_No devices adopted yet._');
|
|
939
|
-
return lines.join('\n');
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
async getCreateDeviceSettings(): Promise<Setting[]> {
|
|
943
|
-
return [
|
|
944
|
-
{
|
|
945
|
-
key: 'kasaClass',
|
|
946
|
-
title: 'Type',
|
|
947
|
-
choices: ['Camera', 'Plug', 'Switch', 'Dimmer', 'Bulb'],
|
|
948
|
-
value: 'Camera',
|
|
949
|
-
},
|
|
950
|
-
{
|
|
951
|
-
key: 'name',
|
|
952
|
-
title: 'Name',
|
|
953
|
-
placeholder: 'Front Door, Living Room, etc.',
|
|
954
|
-
},
|
|
955
|
-
{
|
|
956
|
-
key: 'room',
|
|
957
|
-
title: 'Room',
|
|
958
|
-
placeholder: 'Optional, e.g. Living Room',
|
|
959
|
-
choices: getKnownRooms(),
|
|
960
|
-
combobox: true,
|
|
961
|
-
},
|
|
962
|
-
];
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
async createDevice(settings: DeviceCreatorSettings, nativeId?: ScryptedNativeId): Promise<string> {
|
|
966
|
-
nativeId ||= randomBytes(4).toString('hex');
|
|
967
|
-
const room = settings.room?.toString() || undefined;
|
|
968
|
-
const choice = settings.kasaClass?.toString() || 'Camera';
|
|
969
|
-
// Map the user-friendly choice → internal kasaClass marker. Defaults to camera.
|
|
970
|
-
const kasaClass: KasaDeviceClass =
|
|
971
|
-
(
|
|
972
|
-
{
|
|
973
|
-
Camera: 'camera',
|
|
974
|
-
Plug: 'plug',
|
|
975
|
-
Switch: 'switch',
|
|
976
|
-
Dimmer: 'dimmer',
|
|
977
|
-
Bulb: 'bulb',
|
|
978
|
-
} as Record<string, KasaDeviceClass>
|
|
979
|
-
)[choice] || 'camera';
|
|
980
|
-
const name = settings.name?.toString() || (choice === 'Camera' ? 'Kasa Camera' : `Kasa ${choice}`);
|
|
981
|
-
|
|
982
|
-
if (kasaClass === 'camera') {
|
|
983
|
-
await this.registerCamera(nativeId, name, room);
|
|
984
|
-
deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', 'camera');
|
|
985
|
-
return nativeId;
|
|
986
|
-
}
|
|
987
|
-
await this.registerIotDevice(nativeId, name, room, kasaClass);
|
|
988
|
-
return nativeId;
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Register a non-camera Kasa device (plug, switch, dimmer, bulb) with the appropriate
|
|
992
|
-
// Scrypted device type + interfaces. IP/port are left empty; the user fills them in
|
|
993
|
-
// through the per-device settings after creation.
|
|
994
|
-
private async registerIotDevice(
|
|
995
|
-
nativeId: string,
|
|
996
|
-
name: string,
|
|
997
|
-
room: string | undefined,
|
|
998
|
-
kasaClass: KasaDeviceClass,
|
|
999
|
-
): Promise<void> {
|
|
1000
|
-
const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings, ScryptedInterface.Readme];
|
|
1001
|
-
let type: ScryptedDeviceType;
|
|
1002
|
-
switch (kasaClass) {
|
|
1003
|
-
case 'plug':
|
|
1004
|
-
type = ScryptedDeviceType.Outlet;
|
|
1005
|
-
break;
|
|
1006
|
-
case 'switch':
|
|
1007
|
-
type = ScryptedDeviceType.Switch;
|
|
1008
|
-
break;
|
|
1009
|
-
case 'dimmer':
|
|
1010
|
-
type = ScryptedDeviceType.Light;
|
|
1011
|
-
interfaces.push(ScryptedInterface.Brightness);
|
|
1012
|
-
break;
|
|
1013
|
-
case 'bulb':
|
|
1014
|
-
type = ScryptedDeviceType.Light;
|
|
1015
|
-
interfaces.push(ScryptedInterface.Brightness);
|
|
1016
|
-
// Color/color-temp interfaces are probed from sysinfo on adoption; manual-
|
|
1017
|
-
// create bulbs default to brightness only. The user can re-discover if they
|
|
1018
|
-
// need full color support detected automatically.
|
|
1019
|
-
break;
|
|
1020
|
-
default:
|
|
1021
|
-
throw new Error(`unknown kasaClass: ${kasaClass}`);
|
|
1022
|
-
}
|
|
1023
|
-
await deviceManager.onDeviceDiscovered({
|
|
1024
|
-
nativeId,
|
|
1025
|
-
name,
|
|
1026
|
-
type,
|
|
1027
|
-
interfaces,
|
|
1028
|
-
info: { manufacturer: 'TP-Link Kasa' },
|
|
1029
|
-
room: room || undefined,
|
|
1030
|
-
});
|
|
1031
|
-
deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', kasaClass);
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
private async registerCamera(
|
|
1035
|
-
nativeId: string,
|
|
1036
|
-
name: string,
|
|
1037
|
-
room?: string,
|
|
1038
|
-
info?: { model?: string; mac?: string; ip?: string; serialNumber?: string; firmware?: string },
|
|
1039
|
-
): Promise<void> {
|
|
1040
|
-
const device: Device = {
|
|
1041
|
-
nativeId,
|
|
1042
|
-
name,
|
|
1043
|
-
type: ScryptedDeviceType.Camera,
|
|
1044
|
-
interfaces: [
|
|
1045
|
-
ScryptedInterface.VideoCamera,
|
|
1046
|
-
ScryptedInterface.Settings,
|
|
1047
|
-
ScryptedInterface.Intercom,
|
|
1048
|
-
// Required so Scrypted routes child-device lookups (e.g. the spotlight)
|
|
1049
|
-
// through KasaCamera.getDevice rather than treating the camera as a leaf.
|
|
1050
|
-
ScryptedInterface.DeviceProvider,
|
|
1051
|
-
// OnOff drives the camera's status LED — HomeKit binds its
|
|
1052
|
-
// CameraOperatingModeIndicator characteristic to this.
|
|
1053
|
-
ScryptedInterface.OnOff,
|
|
1054
|
-
ScryptedInterface.Readme,
|
|
1055
|
-
],
|
|
1056
|
-
info: {
|
|
1057
|
-
manufacturer: 'TP-Link Kasa',
|
|
1058
|
-
model: info?.model || undefined,
|
|
1059
|
-
mac: info?.mac || undefined,
|
|
1060
|
-
ip: info?.ip || undefined,
|
|
1061
|
-
serialNumber: info?.serialNumber || undefined,
|
|
1062
|
-
firmware: info?.firmware || undefined,
|
|
1063
|
-
},
|
|
1064
|
-
// Empty string would clear the room on re-discovery; pass undefined to leave alone.
|
|
1065
|
-
room: room || undefined,
|
|
1066
|
-
};
|
|
1067
|
-
await deviceManager.onDeviceDiscovered(device);
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
async discoverDevices(scan?: boolean): Promise<DiscoveredDevice[]> {
|
|
1071
|
-
// Discovery never runs unless explicitly requested (scan === true). When it does, an
|
|
1072
|
-
// in-flight scan is shared across overlapping callers so a single click never produces
|
|
1073
|
-
// more than one network round-trip; calls without scan=true just return the cache.
|
|
1074
|
-
if (scan) {
|
|
1075
|
-
if (this.scanInFlight) {
|
|
1076
|
-
await this.scanInFlight;
|
|
1077
|
-
} else if (Date.now() - this.lastScanAt < KasaPlugin.SCAN_COOLDOWN_MS) {
|
|
1078
|
-
// Recent scan already completed — return cached list without re-scanning
|
|
1079
|
-
// or re-firing onDeviceEvent (which would reset UI filters).
|
|
1080
|
-
} else {
|
|
1081
|
-
this.scanInFlight = this.runScan().finally(() => {
|
|
1082
|
-
this.scanInFlight = undefined;
|
|
1083
|
-
this.lastScanAt = Date.now();
|
|
1084
|
-
});
|
|
1085
|
-
await this.scanInFlight;
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
const defaults = this.getDefaultCredentials();
|
|
1090
|
-
const rooms = getKnownRooms();
|
|
1091
|
-
const out: DiscoveredDevice[] = [];
|
|
1092
|
-
for (const { device } of this.discoveredDevices.values()) {
|
|
1093
|
-
const cls = classifyKasa(device);
|
|
1094
|
-
if (!cls) continue;
|
|
1095
|
-
out.push(this.buildDiscoveredDevice(device, cls, rooms, defaults));
|
|
1096
|
-
}
|
|
1097
|
-
return out;
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
private buildDiscoveredDevice(
|
|
1101
|
-
device: KasaDiscoveredDevice,
|
|
1102
|
-
cls: KasaDeviceClass,
|
|
1103
|
-
rooms: string[],
|
|
1104
|
-
defaults: { username: string; password: string },
|
|
1105
|
-
): DiscoveredDevice {
|
|
1106
|
-
const info = {
|
|
1107
|
-
manufacturer: 'TP-Link Kasa',
|
|
1108
|
-
model: device.model,
|
|
1109
|
-
mac: device.mac,
|
|
1110
|
-
ip: device.address,
|
|
1111
|
-
};
|
|
1112
|
-
const fallbackName = device.alias || device.model || 'Kasa Device';
|
|
1113
|
-
|
|
1114
|
-
// Common settings on every adoption form. Cameras add username/password below.
|
|
1115
|
-
const baseSettings: Setting[] = [
|
|
1116
|
-
{ key: 'name', title: 'Name', value: fallbackName },
|
|
1117
|
-
{ key: 'room', title: 'Room', placeholder: 'Optional, e.g. Living Room', choices: rooms, combobox: true },
|
|
1118
|
-
];
|
|
1119
|
-
|
|
1120
|
-
if (cls === 'camera') {
|
|
1121
|
-
return {
|
|
1122
|
-
nativeId: device.deviceId,
|
|
1123
|
-
name: fallbackName,
|
|
1124
|
-
description: `${device.model || 'Kasa Camera'} @ ${device.address}`,
|
|
1125
|
-
type: ScryptedDeviceType.Camera,
|
|
1126
|
-
interfaces: [
|
|
1127
|
-
ScryptedInterface.VideoCamera,
|
|
1128
|
-
ScryptedInterface.Settings,
|
|
1129
|
-
ScryptedInterface.Intercom,
|
|
1130
|
-
ScryptedInterface.DeviceProvider,
|
|
1131
|
-
ScryptedInterface.OnOff,
|
|
1132
|
-
ScryptedInterface.Readme,
|
|
1133
|
-
],
|
|
1134
|
-
info,
|
|
1135
|
-
// Cameras need the cloud account credentials too — auth on the stream/talk
|
|
1136
|
-
// endpoints. Plugs/bulbs are local-only with no auth.
|
|
1137
|
-
settings: [
|
|
1138
|
-
...baseSettings,
|
|
1139
|
-
{
|
|
1140
|
-
key: 'username',
|
|
1141
|
-
title: 'Username (Kasa Email)',
|
|
1142
|
-
placeholder: 'user@example.com',
|
|
1143
|
-
value: defaults.username,
|
|
1144
|
-
},
|
|
1145
|
-
{ key: 'password', title: 'Password (Kasa Account)', type: 'password', value: defaults.password },
|
|
1146
|
-
],
|
|
1147
|
-
};
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
// Plug, Switch, Bulb — all share the same simpler adoption form.
|
|
1151
|
-
const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings, ScryptedInterface.Readme];
|
|
1152
|
-
let type: ScryptedDeviceType;
|
|
1153
|
-
if (cls === 'bulb') {
|
|
1154
|
-
type = ScryptedDeviceType.Light;
|
|
1155
|
-
interfaces.push(ScryptedInterface.Brightness);
|
|
1156
|
-
const caps = bulbCapabilities(device);
|
|
1157
|
-
if (caps.isColor) interfaces.push(ScryptedInterface.ColorSettingHsv);
|
|
1158
|
-
if (caps.isVariableColorTemp) interfaces.push(ScryptedInterface.ColorSettingTemperature);
|
|
1159
|
-
} else {
|
|
1160
|
-
// Dimmer plug/switch (HS220, KS230, ...) is almost always wired to a light, so
|
|
1161
|
-
// expose as Light. Plain plugs → Outlet; plain switches → Switch.
|
|
1162
|
-
const dimmer = isDimmer(device);
|
|
1163
|
-
if (dimmer) {
|
|
1164
|
-
type = ScryptedDeviceType.Light;
|
|
1165
|
-
interfaces.push(ScryptedInterface.Brightness);
|
|
1166
|
-
} else {
|
|
1167
|
-
type = cls === 'switch' ? ScryptedDeviceType.Switch : ScryptedDeviceType.Outlet;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
return {
|
|
1172
|
-
nativeId: device.deviceId,
|
|
1173
|
-
name: fallbackName,
|
|
1174
|
-
description: `${device.model || 'Kasa Device'} @ ${device.address}`,
|
|
1175
|
-
type,
|
|
1176
|
-
interfaces,
|
|
1177
|
-
info,
|
|
1178
|
-
settings: baseSettings,
|
|
1179
|
-
};
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// Single-pass UDP discovery: broadcast + paced unicast sweep on the local /24, all on
|
|
1183
|
-
// one socket. Fast (~2.5 s) because there's no TCP handshake step and no second pass.
|
|
1184
|
-
private async runScan(): Promise<void> {
|
|
1185
|
-
try {
|
|
1186
|
-
const udpResults = await discoverKasa(2500, this.console).catch(e => {
|
|
1187
|
-
this.console.error('kasa udp discovery failed', e);
|
|
1188
|
-
return [] as KasaDiscoveredDevice[];
|
|
1189
|
-
});
|
|
1190
|
-
|
|
1191
|
-
const skipped: string[] = [];
|
|
1192
|
-
const classCounts: Record<string, number> = {};
|
|
1193
|
-
let alreadyAdopted = 0;
|
|
1194
|
-
const existingNativeIds = new Set(deviceManager.getNativeIds());
|
|
1195
|
-
for (const d of udpResults) {
|
|
1196
|
-
if (existingNativeIds.has(d.deviceId)) {
|
|
1197
|
-
alreadyAdopted++;
|
|
1198
|
-
continue;
|
|
1199
|
-
}
|
|
1200
|
-
const cls = classifyKasa(d);
|
|
1201
|
-
if (!cls) {
|
|
1202
|
-
skipped.push(`${d.alias || d.model || d.deviceId} (${d.type})`);
|
|
1203
|
-
continue;
|
|
1204
|
-
}
|
|
1205
|
-
classCounts[cls] = (classCounts[cls] || 0) + 1;
|
|
1206
|
-
this.upsertDiscovered(d.deviceId, d);
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
const summaryParts = Object.entries(classCounts).map(([k, v]) => `${v} new ${k}(s)`);
|
|
1210
|
-
if (alreadyAdopted) summaryParts.push(`${alreadyAdopted} already adopted`);
|
|
1211
|
-
const summary = summaryParts.join(', ') || '0 supported devices';
|
|
1212
|
-
this.console.log(
|
|
1213
|
-
`kasa discovery: ${udpResults.length} responder(s), ${summary}` +
|
|
1214
|
-
(skipped.length ? `, unsupported: ${skipped.join(', ')}` : ''),
|
|
1215
|
-
);
|
|
1216
|
-
void this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, undefined);
|
|
1217
|
-
} catch (e) {
|
|
1218
|
-
this.console.error('kasa discovery failed', e);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// In typical home setups, every Kasa camera shares the same TP-Link account, so reuse
|
|
1223
|
-
// the credentials from any already-configured camera as defaults for newly discovered
|
|
1224
|
-
// ones. The user can still override per-camera in the adoption form.
|
|
1225
|
-
private getDefaultCredentials(): { username: string; password: string } {
|
|
1226
|
-
for (const nativeId of deviceManager.getNativeIds()) {
|
|
1227
|
-
if (!nativeId) continue;
|
|
1228
|
-
const storage = deviceManager.getDeviceStorage(nativeId);
|
|
1229
|
-
const username = storage?.getItem('username');
|
|
1230
|
-
const password = storage?.getItem('password');
|
|
1231
|
-
if (username && password) return { username, password };
|
|
1232
|
-
}
|
|
1233
|
-
return { username: '', password: '' };
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
private upsertDiscovered(deviceId: string, device: KasaDiscoveredDevice) {
|
|
1237
|
-
const existing = this.discoveredDevices.get(deviceId);
|
|
1238
|
-
if (existing) clearTimeout(existing.timeout);
|
|
1239
|
-
// Use unref() so the cache-expiry timer doesn't keep the plugin process awake; the
|
|
1240
|
-
// entries are best-effort and can be flushed whenever the process idles down.
|
|
1241
|
-
const timeout = setTimeout(() => this.discoveredDevices.delete(deviceId), 5 * 60 * 1000);
|
|
1242
|
-
timeout.unref?.();
|
|
1243
|
-
this.discoveredDevices.set(deviceId, { device, timeout });
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
async adoptDevice(adopt: AdoptDevice): Promise<string> {
|
|
1247
|
-
// ScryptedNativeId is `string | undefined`; the AdoptDevice flow always passes
|
|
1248
|
-
// a real id (it came from a DiscoveredDevice we ourselves emitted), but TS6
|
|
1249
|
-
// tightened the inference and won't narrow it for us.
|
|
1250
|
-
const nativeId = adopt.nativeId;
|
|
1251
|
-
if (!nativeId) throw new Error('adoptDevice called without a nativeId');
|
|
1252
|
-
|
|
1253
|
-
const entry = this.discoveredDevices.get(nativeId);
|
|
1254
|
-
if (!entry) throw new Error('kasa device not found in discovered set; rescan and try again');
|
|
1255
|
-
|
|
1256
|
-
const { device } = entry;
|
|
1257
|
-
const cls = classifyKasa(device);
|
|
1258
|
-
if (!cls) throw new Error(`kasa device type ${device.type} is not supported for adoption`);
|
|
1259
|
-
|
|
1260
|
-
const name = adopt.settings.name?.toString() || device.alias || device.model || 'Kasa Device';
|
|
1261
|
-
const room = adopt.settings.room?.toString() || undefined;
|
|
1262
|
-
|
|
1263
|
-
let id: string;
|
|
1264
|
-
if (cls === 'camera') id = await this.adoptCamera(adopt, nativeId, device, name, room);
|
|
1265
|
-
else id = await this.adoptIotDevice(adopt, nativeId, device, cls, name, room);
|
|
1266
|
-
|
|
1267
|
-
clearTimeout(entry.timeout);
|
|
1268
|
-
this.discoveredDevices.delete(nativeId);
|
|
1269
|
-
void this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, undefined);
|
|
1270
|
-
return id;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
private async adoptCamera(
|
|
1274
|
-
adopt: AdoptDevice,
|
|
1275
|
-
nativeId: string,
|
|
1276
|
-
device: KasaDiscoveredDevice,
|
|
1277
|
-
name: string,
|
|
1278
|
-
room?: string,
|
|
1279
|
-
): Promise<string> {
|
|
1280
|
-
// deviceId is the Kasa-issued 40-char hex per-unit identifier — treat it as the
|
|
1281
|
-
// serial number, which is what HomeKit and the UI expect under that label.
|
|
1282
|
-
await this.registerCamera(nativeId, name, room, {
|
|
1283
|
-
model: device.model,
|
|
1284
|
-
mac: device.mac,
|
|
1285
|
-
ip: device.address,
|
|
1286
|
-
serialNumber: device.deviceId,
|
|
1287
|
-
firmware: typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined,
|
|
1288
|
-
});
|
|
1289
|
-
deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', 'camera');
|
|
1290
|
-
const camera = (await this.getDevice(nativeId)) as KasaCamera;
|
|
1291
|
-
|
|
1292
|
-
camera.storageSettings.values.ip = device.address;
|
|
1293
|
-
camera.storageSettings.values.port = KASA_DEFAULT_PORT;
|
|
1294
|
-
if (adopt.settings.username) camera.storageSettings.values.username = adopt.settings.username.toString();
|
|
1295
|
-
if (adopt.settings.password) camera.storageSettings.values.password = adopt.settings.password.toString();
|
|
1296
|
-
|
|
1297
|
-
// Now that credentials are set, probe for child devices (spotlight, siren, etc.).
|
|
1298
|
-
camera.refreshChildDevices().catch(e => this.console.warn('post-adopt refreshChildDevices failed', e));
|
|
1299
|
-
|
|
1300
|
-
return camera.id;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
private async adoptIotDevice(
|
|
1304
|
-
adopt: AdoptDevice,
|
|
1305
|
-
nativeId: string,
|
|
1306
|
-
device: KasaDiscoveredDevice,
|
|
1307
|
-
cls: KasaDeviceClass,
|
|
1308
|
-
name: string,
|
|
1309
|
-
room?: string,
|
|
1310
|
-
): Promise<string> {
|
|
1311
|
-
const interfaces: ScryptedInterface[] = [
|
|
1312
|
-
ScryptedInterface.OnOff,
|
|
1313
|
-
ScryptedInterface.Settings,
|
|
1314
|
-
ScryptedInterface.Readme,
|
|
1315
|
-
];
|
|
1316
|
-
let type: ScryptedDeviceType;
|
|
1317
|
-
const caps = bulbCapabilities(device);
|
|
1318
|
-
// The marker we persist for getDevice routing. 'plug'/'switch' for plain on/off
|
|
1319
|
-
// devices, 'dimmer' for anything with brightness control, 'bulb' for true bulbs.
|
|
1320
|
-
let storedClass: KasaDeviceClass = cls;
|
|
1321
|
-
|
|
1322
|
-
if (cls === 'bulb') {
|
|
1323
|
-
type = ScryptedDeviceType.Light;
|
|
1324
|
-
interfaces.push(ScryptedInterface.Brightness);
|
|
1325
|
-
if (caps.isColor) interfaces.push(ScryptedInterface.ColorSettingHsv);
|
|
1326
|
-
if (caps.isVariableColorTemp) interfaces.push(ScryptedInterface.ColorSettingTemperature);
|
|
1327
|
-
} else if (isDimmer(device)) {
|
|
1328
|
-
// Dimmer plug or dimmer switch — both expose as Light with Brightness.
|
|
1329
|
-
type = ScryptedDeviceType.Light;
|
|
1330
|
-
interfaces.push(ScryptedInterface.Brightness);
|
|
1331
|
-
storedClass = 'dimmer';
|
|
1332
|
-
} else {
|
|
1333
|
-
type = cls === 'switch' ? ScryptedDeviceType.Switch : ScryptedDeviceType.Outlet;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
const sw_ver = typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined;
|
|
1337
|
-
await deviceManager.onDeviceDiscovered({
|
|
1338
|
-
nativeId,
|
|
1339
|
-
name,
|
|
1340
|
-
type,
|
|
1341
|
-
interfaces,
|
|
1342
|
-
room: room || undefined,
|
|
1343
|
-
info: {
|
|
1344
|
-
manufacturer: 'TP-Link Kasa',
|
|
1345
|
-
model: device.model,
|
|
1346
|
-
mac: device.mac,
|
|
1347
|
-
ip: device.address,
|
|
1348
|
-
serialNumber: device.deviceId,
|
|
1349
|
-
firmware: sw_ver,
|
|
1350
|
-
},
|
|
1351
|
-
});
|
|
1352
|
-
|
|
1353
|
-
// Persist the class marker so getDevice routes to the right implementation —
|
|
1354
|
-
// multiple device classes share the same Scrypted device type (true bulbs and
|
|
1355
|
-
// dimmers both register as Light).
|
|
1356
|
-
deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', storedClass);
|
|
1357
|
-
|
|
1358
|
-
const dev = await this.getDevice(nativeId);
|
|
1359
|
-
dev.storageSettings.values.ip = device.address;
|
|
1360
|
-
dev.storageSettings.values.port = KASA_IOT_PORT;
|
|
1361
|
-
if (cls === 'bulb' && dev instanceof KasaBulb) {
|
|
1362
|
-
dev.storageSettings.values.isColor = caps.isColor;
|
|
1363
|
-
dev.storageSettings.values.isVariableColorTemp = caps.isVariableColorTemp;
|
|
1364
|
-
}
|
|
1365
|
-
await dev.refreshState?.().catch(() => {});
|
|
1366
|
-
|
|
1367
|
-
return dev.id;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// Routes a nativeId to the right device class. Adoption persists a `kasaClass` storage
|
|
1371
|
-
// marker (camera/plug/switch/bulb) which is the source of truth here — the Scrypted
|
|
1372
|
-
// device type alone is ambiguous (e.g. both true bulbs and dimmer plugs are `Light`).
|
|
1373
|
-
async getDevice(nativeId: ScryptedNativeId): Promise<any> {
|
|
1374
|
-
// The DeviceProvider contract types nativeId as nullable; the plugin device itself
|
|
1375
|
-
// (no nativeId) routes here too and we have nothing to instantiate for it.
|
|
1376
|
-
if (!nativeId) return undefined;
|
|
1377
|
-
let dev = this.devices.get(nativeId);
|
|
1378
|
-
if (!dev) {
|
|
1379
|
-
dev = this.instantiateDevice(nativeId);
|
|
1380
|
-
if (!dev) return undefined;
|
|
1381
|
-
this.devices.set(nativeId, dev);
|
|
1382
|
-
}
|
|
1383
|
-
return dev;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
private instantiateDevice(
|
|
1387
|
-
nativeId: string,
|
|
1388
|
-
): KasaCamera | KasaPlug | KasaSwitch | KasaDimmer | KasaBulb | undefined {
|
|
1389
|
-
const storage = deviceManager.getDeviceStorage(nativeId);
|
|
1390
|
-
const kasaClass = storage?.getItem('kasaClass');
|
|
1391
|
-
switch (kasaClass) {
|
|
1392
|
-
case 'camera':
|
|
1393
|
-
return new KasaCamera(nativeId);
|
|
1394
|
-
case 'bulb':
|
|
1395
|
-
return new KasaBulb(nativeId);
|
|
1396
|
-
case 'dimmer':
|
|
1397
|
-
return new KasaDimmer(nativeId);
|
|
1398
|
-
case 'switch':
|
|
1399
|
-
return new KasaSwitch(nativeId);
|
|
1400
|
-
case 'plug':
|
|
1401
|
-
return new KasaPlug(nativeId);
|
|
1402
|
-
}
|
|
1403
|
-
// Legacy fallback for devices adopted before kasaClass added 'dimmer'. Older
|
|
1404
|
-
// adoptions stored a `dimmer=true` flag on the device's KasaPlug/KasaSwitch
|
|
1405
|
-
// storage when the underlying device was a dimmer; promote those to KasaDimmer.
|
|
1406
|
-
if (storage?.getItem('dimmer') === 'true') return new KasaDimmer(nativeId);
|
|
1407
|
-
const state = deviceManager.getDeviceState(nativeId);
|
|
1408
|
-
switch (state?.type) {
|
|
1409
|
-
case ScryptedDeviceType.Camera:
|
|
1410
|
-
return new KasaCamera(nativeId);
|
|
1411
|
-
case ScryptedDeviceType.Switch:
|
|
1412
|
-
return new KasaSwitch(nativeId);
|
|
1413
|
-
case ScryptedDeviceType.Light:
|
|
1414
|
-
return new KasaDimmer(nativeId);
|
|
1415
|
-
case ScryptedDeviceType.Outlet:
|
|
1416
|
-
return new KasaPlug(nativeId);
|
|
1417
|
-
}
|
|
1418
|
-
return undefined;
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
async releaseDevice(_id: string, nativeId: string): Promise<void> {
|
|
1422
|
-
const dev = this.devices.get(nativeId);
|
|
1423
|
-
// Plug/Bulb instances run a state-poll timer that needs to be cleared.
|
|
1424
|
-
if (dev && 'release' in dev && typeof (dev as any).release === 'function') (dev as any).release();
|
|
1425
|
-
this.devices.delete(nativeId);
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
export default KasaPlugin;
|