scrypted-kasa 1.0.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/src/main.ts ADDED
@@ -0,0 +1,1055 @@
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
+ RequestMediaStreamOptions,
14
+ ResponseMediaStreamOptions,
15
+ ScryptedDeviceBase,
16
+ ScryptedDeviceType,
17
+ ScryptedInterface,
18
+ ScryptedMimeTypes,
19
+ ScryptedNativeId,
20
+ Setting,
21
+ Settings,
22
+ SettingValue,
23
+ VideoCamera,
24
+ } from '@scrypted/sdk';
25
+ import { StorageSettings } from '@scrypted/sdk/storage-settings';
26
+ import child_process, { ChildProcess } from 'child_process';
27
+ import { randomBytes } from 'crypto';
28
+ import { KASA_DEFAULT_PORT, KasaClient } from './camera/api';
29
+ import { KASA_TALK_PORT, KasaTalkSession } from './camera/intercom';
30
+ import { KasaLinkieClient } from './camera/linkie';
31
+ import { KasaStreamHandle, registerLiveCleanup, spawnKasaStream } from './camera/stream';
32
+ import { KasaBulb } from './iot/bulb';
33
+ import { KasaDimmer } from './iot/dimmer';
34
+ import { KasaPlug } from './iot/plug';
35
+ import { KASA_IOT_PORT } from './iot/protocol';
36
+ import { KasaSwitch } from './iot/switch';
37
+ import { discoverKasa, KasaDiscoveredDevice } from './shared/discovery';
38
+
39
+ // G.711 µ-law packetization: 8000 samples/sec * 1 byte/sample = 160 bytes/20ms.
40
+ // 20 ms is the standard RTP packetization for PCMU and matches what the Kasa app appears
41
+ // to use for its uplink chunks during active talk.
42
+ const TALK_CHUNK_BYTES = 160;
43
+
44
+ // Models in this set are TP-Link Kasa cameras (sysinfo.type === 'IOT.IPCAMERA' is the primary
45
+ // filter; this is a defense-in-depth allowlist for ambiguous replies).
46
+ const KASA_CAMERA_TYPES = new Set(['IOT.IPCAMERA']);
47
+
48
+ // Promise wrapper with externally-callable resolve/reject. Used here as a kill switch for
49
+ // camera streaming: any teardown source resolves it once and every owned resource registers
50
+ // a cleanup against the same promise.
51
+ class Deferred<T> {
52
+ finished = false;
53
+ resolve!: (value: T) => void;
54
+ reject!: (error: Error) => void;
55
+ promise: Promise<T>;
56
+
57
+ constructor() {
58
+ this.promise = new Promise<T>((resolve, reject) => {
59
+ this.resolve = v => {
60
+ this.finished = true;
61
+ resolve(v);
62
+ };
63
+ this.reject = e => {
64
+ this.finished = true;
65
+ reject(e);
66
+ };
67
+ });
68
+ }
69
+ }
70
+
71
+ // All values stored under the `kasaClass` storage key live in this union. classifyKasa()
72
+ // only returns the four families that auto-discovery can detect from sysinfo; 'dimmer' is
73
+ // added by adoptIotDevice() when the device exposes brightness, and the manual 'Add device'
74
+ // dialog can persist any of the five.
75
+ type KasaDeviceClass = 'camera' | 'plug' | 'switch' | 'dimmer' | 'bulb';
76
+
77
+ // Classify a discovered device by its sysinfo. Returns undefined for device families we
78
+ // don't model (e.g. multi-outlet plug strips, hubs).
79
+ function classifyKasa(d: KasaDiscoveredDevice): KasaDeviceClass | undefined {
80
+ if (KASA_CAMERA_TYPES.has(d.type)) return 'camera';
81
+ if (d.type === 'IOT.SMARTBULB') return 'bulb';
82
+ if (d.type === 'IOT.SMARTPLUGSWITCH') {
83
+ // The Kasa app distinguishes outlets from light switches by `dev_name` /
84
+ // `description`. HS200/210/220 are wall-mounted switches; everything else is an
85
+ // outlet. Multi-outlet strips have a `children` array and are skipped — they need
86
+ // per-outlet handling we don't do yet.
87
+ if (Array.isArray((d.sysinfo as any)?.children) && (d.sysinfo as any).children.length) return undefined;
88
+ const devName: string = (d.sysinfo as any)?.dev_name || '';
89
+ if (/switch|dimmer/i.test(devName)) return 'switch';
90
+ return 'plug';
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ function isDimmer(d: KasaDiscoveredDevice): boolean {
96
+ const sys = d.sysinfo as any;
97
+ if (typeof sys?.brightness === 'number') return true;
98
+ const feature: string = sys?.feature || '';
99
+ if (/DIM/.test(feature)) return true;
100
+ // KS230 (3-way dimmer) doesn't report `brightness` in sysinfo at idle, and its
101
+ // `feature` string doesn't include DIM either — but `dev_name` always says "Dimmer".
102
+ const devName: string = sys?.dev_name || '';
103
+ return /dimmer/i.test(devName);
104
+ }
105
+
106
+ interface BulbCapabilities {
107
+ isColor: boolean;
108
+ isVariableColorTemp: boolean;
109
+ }
110
+
111
+ function bulbCapabilities(d: KasaDiscoveredDevice): BulbCapabilities {
112
+ const sys = d.sysinfo as any;
113
+ return {
114
+ isColor: sys?.is_color === 1,
115
+ isVariableColorTemp: sys?.is_variable_color_temp === 1,
116
+ };
117
+ }
118
+
119
+ const { deviceManager, mediaManager, systemManager } = sdk;
120
+
121
+ // Walk every known device's state to collect existing room names. Used to populate the room
122
+ // dropdown so the user picks an existing room instead of typing a new one. Cached for 2 s so
123
+ // rapid back-to-back calls (Scrypted's UI sometimes re-fetches on every render of the Add
124
+ // Device dialog) don't repeatedly walk the entire system state, which can be hundreds of KB.
125
+ let knownRoomsCache: { rooms: string[]; at: number } | undefined;
126
+ function getKnownRooms(): string[] {
127
+ const now = Date.now();
128
+ if (knownRoomsCache && now - knownRoomsCache.at < 2000) return knownRoomsCache.rooms;
129
+ const rooms = new Set<string>();
130
+ const states = systemManager.getSystemState();
131
+ for (const id of Object.keys(states)) {
132
+ const room = states[id]?.room?.value;
133
+ if (typeof room === 'string' && room.trim()) rooms.add(room.trim());
134
+ }
135
+ const out = [...rooms].sort((a, b) => a.localeCompare(b));
136
+ knownRoomsCache = { rooms: out, at: now };
137
+ return out;
138
+ }
139
+
140
+ // Child device for cameras with a spotlight (e.g. KC420WS — the Kasa app calls this
141
+ // the "spotlight"). Backed by the LINKIE2 `smartlife.cam.ipcamera.dayNight.set_force_lamp_state`
142
+ // command. (The protocol-level name is "force_lamp" — internally the camera firmware
143
+ // treats this as a generic forced-lamp state, but in the user-facing UI it's a spotlight.)
144
+ class KasaCameraSpotlight extends ScryptedDeviceBase implements OnOff {
145
+ constructor(
146
+ public camera: KasaCamera,
147
+ nativeId: string,
148
+ ) {
149
+ super(nativeId);
150
+ }
151
+
152
+ async turnOn(): Promise<void> {
153
+ await this.camera.linkie().setForceLampState(true);
154
+ this.on = true;
155
+ }
156
+
157
+ async turnOff(): Promise<void> {
158
+ await this.camera.linkie().setForceLampState(false);
159
+ this.on = false;
160
+ }
161
+ }
162
+
163
+ // Child device for the siren. Backed by smartlife.cam.ipcamera.siren.set_state. The
164
+ // camera auto-stops after the duration set in the Kasa app (default 30 s), so the `on`
165
+ // state shown in Scrypted may not reflect that auto-off until something polls again.
166
+ class KasaCameraSiren extends ScryptedDeviceBase implements OnOff {
167
+ constructor(
168
+ public camera: KasaCamera,
169
+ nativeId: string,
170
+ ) {
171
+ super(nativeId);
172
+ }
173
+
174
+ async turnOn(): Promise<void> {
175
+ await this.camera.linkie().setSirenState(true);
176
+ this.on = true;
177
+ }
178
+
179
+ async turnOff(): Promise<void> {
180
+ await this.camera.linkie().setSirenState(false);
181
+ this.on = false;
182
+ }
183
+ }
184
+
185
+ class KasaCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Intercom, DeviceProvider, OnOff {
186
+ private intercomSession?: KasaTalkSession;
187
+ private intercomFfmpeg?: ChildProcess;
188
+ private spotlight?: KasaCameraSpotlight;
189
+ private siren?: KasaCameraSiren;
190
+ // Kill switches for any in-flight video streams. Stored so we can tear them down when
191
+ // the user changes network/auth settings — otherwise the existing TCP connection keeps
192
+ // talking to the old IP and clients see "phantom" video from a stale endpoint.
193
+ private activeStreamKills = new Set<Deferred<void>>();
194
+
195
+ constructor(nativeId: string) {
196
+ super(nativeId);
197
+ // Probe-and-register child devices once settings are available. process.nextTick
198
+ // defers past constructor so storageSettings is fully wired.
199
+ process.nextTick(() =>
200
+ this.refreshChildDevices().catch(e => this.console.warn('refreshChildDevices failed', e)),
201
+ );
202
+ }
203
+
204
+ private cachedLinkie?: { client: KasaLinkieClient; ip: string; username: string; password: string };
205
+ linkie(): KasaLinkieClient {
206
+ const { ip, username, password } = this.storageSettings.values;
207
+ // Don't pass storageSettings.port — that's the stream port (19443). LINKIE2 lives
208
+ // on its own fixed port (10443) which the client supplies as a default.
209
+ const c = this.cachedLinkie;
210
+ if (c && c.ip === ip && c.username === username && c.password === password) return c.client;
211
+ const client = new KasaLinkieClient({ ip, username, password }, this.console);
212
+ this.cachedLinkie = { client, ip, username, password };
213
+ return client;
214
+ }
215
+
216
+ private get spotlightNativeId(): string {
217
+ return `${this.nativeId}-spotlight`;
218
+ }
219
+
220
+ private get sirenNativeId(): string {
221
+ return `${this.nativeId}-siren`;
222
+ }
223
+
224
+ // Shared in-flight promise so concurrent triggers (constructor, putSetting,
225
+ // post-adoption) don't run the LINKIE probe loop in parallel against the same camera.
226
+ // The probes are sequential per call; running two in parallel would double the
227
+ // request load and racing onDeviceDiscovered() could re-register children twice.
228
+ private refreshChildDevicesInFlight?: Promise<void>;
229
+ async refreshChildDevices(): Promise<void> {
230
+ if (this.refreshChildDevicesInFlight) return this.refreshChildDevicesInFlight;
231
+ this.refreshChildDevicesInFlight = this.refreshChildDevicesInternal().finally(() => {
232
+ this.refreshChildDevicesInFlight = undefined;
233
+ });
234
+ return this.refreshChildDevicesInFlight;
235
+ }
236
+
237
+ private async refreshChildDevicesInternal(): Promise<void> {
238
+ const { ip, username, password } = this.storageSettings.values;
239
+ if (!ip || !username || !password) return;
240
+
241
+ const linkie = this.linkie();
242
+ // Probe each capability sequentially. The Kasa iOS app issues LINKIE2 calls
243
+ // serially; firing them in parallel against the same camera occasionally drops
244
+ // responses (likely camera-side request serialization).
245
+ const ledState = await linkie.getLedStatus();
246
+ const lampState = await linkie.getForceLampState();
247
+ const sirenState = await linkie.getSirenState();
248
+
249
+ if (ledState !== undefined) this.on = ledState === 'on';
250
+
251
+ if (lampState !== undefined) {
252
+ await deviceManager.onDeviceDiscovered({
253
+ nativeId: this.spotlightNativeId,
254
+ name: `${this.name || 'Kasa Camera'} Spotlight`,
255
+ type: ScryptedDeviceType.Light,
256
+ interfaces: [ScryptedInterface.OnOff],
257
+ providerNativeId: this.nativeId,
258
+ // Inherit the camera's room so children show up next to it in the UI.
259
+ // Empty string would clear an existing room assignment, so pass undefined.
260
+ room: this.room || undefined,
261
+ });
262
+ if (!this.spotlight) this.spotlight = new KasaCameraSpotlight(this, this.spotlightNativeId);
263
+ this.spotlight.on = lampState === 'on';
264
+ }
265
+
266
+ if (sirenState !== undefined) {
267
+ await deviceManager.onDeviceDiscovered({
268
+ nativeId: this.sirenNativeId,
269
+ name: `${this.name || 'Kasa Camera'} Siren`,
270
+ type: ScryptedDeviceType.Switch,
271
+ interfaces: [ScryptedInterface.OnOff],
272
+ providerNativeId: this.nativeId,
273
+ room: this.room || undefined,
274
+ });
275
+ if (!this.siren) this.siren = new KasaCameraSiren(this, this.sirenNativeId);
276
+ this.siren.on = sirenState === 'on';
277
+ }
278
+ }
279
+
280
+ async getDevice(nativeId: string): Promise<any> {
281
+ if (nativeId === this.spotlightNativeId) {
282
+ if (!this.spotlight) this.spotlight = new KasaCameraSpotlight(this, this.spotlightNativeId);
283
+ return this.spotlight;
284
+ }
285
+ if (nativeId === this.sirenNativeId) {
286
+ if (!this.siren) this.siren = new KasaCameraSiren(this, this.sirenNativeId);
287
+ return this.siren;
288
+ }
289
+ }
290
+
291
+ async releaseDevice(_id: string, _nativeId: string): Promise<void> {
292
+ // No persistent resources per child to release.
293
+ }
294
+
295
+ storageSettings = new StorageSettings(this, {
296
+ ip: {
297
+ title: 'IP Address',
298
+ placeholder: '192.168.1.100',
299
+ },
300
+ port: {
301
+ title: 'Port',
302
+ type: 'number',
303
+ defaultValue: KASA_DEFAULT_PORT,
304
+ },
305
+ username: {
306
+ title: 'Username (Kasa Email)',
307
+ placeholder: 'user@example.com',
308
+ description: 'The TP-Link/Kasa account email associated with the camera.',
309
+ },
310
+ password: {
311
+ title: 'Password (Kasa Account)',
312
+ type: 'password',
313
+ description: 'The TP-Link/Kasa account password.',
314
+ },
315
+ });
316
+
317
+ getSettings(): Promise<Setting[]> {
318
+ return this.storageSettings.getSettings();
319
+ }
320
+
321
+ async putSetting(key: string, value: SettingValue): Promise<void> {
322
+ await this.storageSettings.putSetting(key, value);
323
+ if (key === 'ip' || key === 'port' || key === 'username' || key === 'password') {
324
+ // Tear down active streams + intercom — they're holding TCP sockets to the old
325
+ // endpoint, which keeps delivering frames from the wrong camera until something
326
+ // else closes them. Clients will reconnect against the new settings.
327
+ for (const kill of [...this.activeStreamKills]) kill.resolve();
328
+ this.stopIntercom().catch(() => {});
329
+ // Re-probe child devices when network or auth settings change. Manually-added
330
+ // cameras get their credentials filled in here (rather than at adoption), so this
331
+ // is the moment the spotlight first becomes detectable.
332
+ this.refreshChildDevices().catch(e => this.console.warn('refreshChildDevices failed', e));
333
+ }
334
+ }
335
+
336
+ // OnOff drives the camera's status LED. HomeKit binds its CameraOperatingModeIndicator
337
+ // characteristic to this when "Link Status Indicator" is enabled in the HomeKit per-camera
338
+ // settings, so the user can toggle the LED from HomeKit. In Scrypted's UI this surfaces
339
+ // as a plain on/off control on the camera page.
340
+ async turnOn(): Promise<void> {
341
+ await this.linkie().setLedStatus(true);
342
+ this.on = true;
343
+ }
344
+
345
+ async turnOff(): Promise<void> {
346
+ await this.linkie().setLedStatus(false);
347
+ this.on = false;
348
+ }
349
+
350
+ async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
351
+ return [
352
+ {
353
+ container: 'rtsp',
354
+ id: 'mixed',
355
+ name: 'Mixed',
356
+ video: {
357
+ codec: 'h264',
358
+ },
359
+ audio: {
360
+ codec: 'pcm_mulaw',
361
+ },
362
+ tool: 'scrypted',
363
+ userConfigurable: false,
364
+ },
365
+ ];
366
+ }
367
+
368
+ async getVideoStream(_options?: RequestMediaStreamOptions): Promise<MediaObject> {
369
+ const { ip, port, username, password } = this.storageSettings.values;
370
+
371
+ if (!ip || !username || !password)
372
+ throw new Error('Kasa camera is not configured. Set IP, username, and password.');
373
+
374
+ const kasa = await KasaClient.connect({
375
+ ip,
376
+ port,
377
+ username,
378
+ password,
379
+ });
380
+
381
+ // Single shared kill switch: any teardown source (kasa close, ffmpeg exit, RTSP
382
+ // client disconnect, pump error) resolves it, and every owned resource registers a
383
+ // cleanup against it. spawnKasaStream owns the actual resource teardown; we just
384
+ // surface the kill switch to settings-change handlers via activeStreamKills.
385
+ const kill = new Deferred<void>();
386
+ this.activeStreamKills.add(kill);
387
+ kill.promise.finally(() => this.activeStreamKills.delete(kill));
388
+ kasa.body.on('close', () => kill.resolve());
389
+ kasa.body.on('error', () => kill.resolve());
390
+
391
+ const ffmpegPath = await mediaManager.getFFmpegPath();
392
+ let stream: KasaStreamHandle;
393
+ try {
394
+ stream = await spawnKasaStream({ kasa, ffmpegPath, console: this.console });
395
+ } catch (e) {
396
+ kasa.destroy();
397
+ throw e;
398
+ }
399
+ kill.promise.finally(() => stream.kill());
400
+ stream.exited.then(() => kill.resolve());
401
+
402
+ const ffmpegInput: FFmpegInput = {
403
+ url: stream.url,
404
+ mediaStreamOptions: (await this.getVideoStreamOptions())[0],
405
+ inputArguments: ['-rtsp_transport', 'tcp', '-i', stream.url],
406
+ };
407
+
408
+ return mediaManager.createFFmpegMediaObject(ffmpegInput);
409
+ }
410
+
411
+ async startIntercom(media: MediaObject): Promise<void> {
412
+ // Some Scrypted clients call startIntercom again without an intervening stopIntercom
413
+ // (e.g. switching audio sources). Tear down any prior session first so we don't leak
414
+ // an in-flight ffmpeg process or a half-open POST to the camera.
415
+ await this.stopIntercom();
416
+
417
+ // `port` from storage is the receive-stream port (19443); the talk endpoint is on a
418
+ // separate fixed port (KASA_TALK_PORT = 18443), so we don't read it here.
419
+ const { ip, username, password } = this.storageSettings.values;
420
+ if (!ip || !username || !password) throw new Error('Kasa camera is not configured.');
421
+
422
+ const ffmpegInput = await mediaManager.convertMediaObjectToJSON<FFmpegInput>(
423
+ media,
424
+ ScryptedMimeTypes.FFmpegInput,
425
+ );
426
+
427
+ const session = new KasaTalkSession({
428
+ ip,
429
+ port: KASA_TALK_PORT,
430
+ username,
431
+ password,
432
+ console: this.console,
433
+ });
434
+ this.intercomSession = session;
435
+ await session.start();
436
+
437
+ // ffmpeg transcodes whatever audio Scrypted hands us (Opus/AAC/PCM/...) into raw
438
+ // 8 kHz mono G.711 µ-law on stdout, which we chunk into 20 ms blocks and write
439
+ // into the talk session as multipart parts.
440
+ const ffmpegPath = await mediaManager.getFFmpegPath();
441
+ // -loglevel error + -nostats: without these, ffmpeg emits a `size= time= bitrate=`
442
+ // progress line every ~0.5 s during the talkback session. On long talks that's a
443
+ // steady stream of stderr → string allocations + log I/O for no diagnostic value.
444
+ // prettier-ignore
445
+ const args = [
446
+ '-hide_banner',
447
+ '-loglevel', 'error',
448
+ '-nostats',
449
+ ...(ffmpegInput.inputArguments || []),
450
+ '-vn', '-sn', '-dn',
451
+ '-f', 'mulaw',
452
+ '-ar', '8000',
453
+ '-ac', '1',
454
+ 'pipe:1',
455
+ ];
456
+ const cp = child_process.spawn(ffmpegPath, args);
457
+ this.intercomFfmpeg = cp;
458
+
459
+ // Process-level cleanup: if our worker terminates while the intercom is active,
460
+ // ffmpeg + the kasa POST socket would survive. Register a kill so worker exit
461
+ // takes them down. Unregistered when the session closes normally.
462
+ const unregisterCleanup = registerLiveCleanup(() => {
463
+ try {
464
+ cp.kill('SIGKILL');
465
+ } catch {}
466
+ session.close();
467
+ });
468
+
469
+ // Queue of incoming chunks plus byte total — avoids repeatedly Buffer.concat'ing the
470
+ // accumulator on every stdout chunk (O(n²) over a long talk session). We only allocate
471
+ // a fresh buffer when we have ≥ TALK_CHUNK_BYTES across the queue and need to emit.
472
+ const queue: Buffer[] = [];
473
+ let queuedBytes = 0;
474
+ const takeChunk = (): Buffer => {
475
+ if (queue.length === 1 && queue[0].length === TALK_CHUNK_BYTES) {
476
+ queuedBytes -= TALK_CHUNK_BYTES;
477
+ return queue.shift()!;
478
+ }
479
+ const out = Buffer.allocUnsafe(TALK_CHUNK_BYTES);
480
+ let written = 0;
481
+ while (written < TALK_CHUNK_BYTES) {
482
+ const head = queue[0];
483
+ const need = TALK_CHUNK_BYTES - written;
484
+ if (head.length <= need) {
485
+ head.copy(out, written);
486
+ written += head.length;
487
+ queue.shift();
488
+ } else {
489
+ head.copy(out, written, 0, need);
490
+ queue[0] = head.subarray(need);
491
+ written = TALK_CHUNK_BYTES;
492
+ }
493
+ }
494
+ queuedBytes -= TALK_CHUNK_BYTES;
495
+ return out;
496
+ };
497
+ cp.stdout!.on('data', (chunk: Buffer) => {
498
+ queue.push(chunk);
499
+ queuedBytes += chunk.length;
500
+ while (queuedBytes >= TALK_CHUNK_BYTES) {
501
+ const ok = session.writeAudio(takeChunk());
502
+ // Backpressure: if the camera's HTTPS upload can't keep up, pause ffmpeg's
503
+ // stdout until the body drains. Without this the PassThrough buffer would
504
+ // grow without bound on a flaky network.
505
+ if (!ok) {
506
+ cp.stdout!.pause();
507
+ session.onDrain(() => cp.stdout!.resume());
508
+ break;
509
+ }
510
+ }
511
+ });
512
+ cp.stderr!.on('data', d => this.console.log('intercom ffmpeg:', d.toString().trim()));
513
+ cp.on('exit', () => {
514
+ this.console.log('intercom ffmpeg exited');
515
+ session.close();
516
+ });
517
+
518
+ // The camera can drop the talk uplink on its own (network blip, request error,
519
+ // or the camera ending the response). Without observing that, ffmpeg would keep
520
+ // running and consuming CPU long after the session is dead.
521
+ session.onClose(() => {
522
+ unregisterCleanup();
523
+ if (this.intercomSession === session) {
524
+ this.intercomSession = undefined;
525
+ this.intercomFfmpeg = undefined;
526
+ try {
527
+ cp.kill();
528
+ } catch {}
529
+ }
530
+ });
531
+ }
532
+
533
+ async stopIntercom(): Promise<void> {
534
+ const session = this.intercomSession;
535
+ const cp = this.intercomFfmpeg;
536
+ this.intercomSession = undefined;
537
+ this.intercomFfmpeg = undefined;
538
+ session?.close();
539
+ try {
540
+ cp?.kill();
541
+ } catch {}
542
+ }
543
+
544
+ // Called by KasaPlugin.releaseDevice when this camera is removed (or the plugin is
545
+ // uninstalled). Tears down everything that holds a kasa-side connection or a child
546
+ // process — without this, Scrypted's worker-thread runtime can leave ffmpeg processes
547
+ // and kasa HTTPS sockets running after the plugin is gone, since child_process is
548
+ // owned by the host Node process, not the worker.
549
+ release(): void {
550
+ // Each kill triggers cleanupAll inside spawnKasaStream — kills ffmpeg, destroys
551
+ // the kasa connection, closes UDP sockets and the local RTSP server.
552
+ for (const kill of [...this.activeStreamKills]) kill.resolve();
553
+ // Stop the intercom too if anything was active. stopIntercom is async only because
554
+ // its signature is — the work it does is synchronous, so fire-and-forget is fine.
555
+ this.stopIntercom().catch(() => {});
556
+ }
557
+ }
558
+
559
+ interface KasaDiscoveryEntry {
560
+ device: KasaDiscoveredDevice;
561
+ // The cached entry expires so a stale IP/MAC mapping doesn't linger across DHCP changes.
562
+ timeout: NodeJS.Timeout;
563
+ }
564
+
565
+ class KasaPlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator, DeviceDiscovery {
566
+ devices = new Map<string, KasaCamera | KasaPlug | KasaSwitch | KasaDimmer | KasaBulb>();
567
+ discoveredDevices = new Map<string, KasaDiscoveryEntry>();
568
+ // In-flight scan so concurrent scan=true calls share one network round-trip instead of
569
+ // each kicking off its own broadcast + TCP sweep.
570
+ private scanInFlight?: Promise<void>;
571
+ // Suppress redundant re-scans for this long after one completes. Scrypted's discovery
572
+ // UI fires scan=true on every type-filter click; a fresh scan + onDeviceEvent on every
573
+ // click resets the user-applied filter. Returning cached results skips both.
574
+ private static SCAN_COOLDOWN_MS = 5000;
575
+ private lastScanAt = 0;
576
+
577
+ constructor(nativeId?: string) {
578
+ super(nativeId);
579
+ this.systemDevice = {
580
+ deviceCreator: 'Device',
581
+ deviceDiscovery: 'Kasa Devices',
582
+ };
583
+ }
584
+
585
+ async getCreateDeviceSettings(): Promise<Setting[]> {
586
+ return [
587
+ {
588
+ key: 'kasaClass',
589
+ title: 'Type',
590
+ choices: ['Camera', 'Plug', 'Switch', 'Dimmer', 'Bulb'],
591
+ value: 'Camera',
592
+ },
593
+ {
594
+ key: 'name',
595
+ title: 'Name',
596
+ placeholder: 'Front Door, Living Room, etc.',
597
+ },
598
+ {
599
+ key: 'room',
600
+ title: 'Room',
601
+ placeholder: 'Optional, e.g. Living Room',
602
+ choices: getKnownRooms(),
603
+ combobox: true,
604
+ },
605
+ ];
606
+ }
607
+
608
+ async createDevice(settings: DeviceCreatorSettings, nativeId?: ScryptedNativeId): Promise<string> {
609
+ nativeId ||= randomBytes(4).toString('hex');
610
+ const room = settings.room?.toString() || undefined;
611
+ const choice = settings.kasaClass?.toString() || 'Camera';
612
+ // Map the user-friendly choice → internal kasaClass marker. Defaults to camera.
613
+ const kasaClass: KasaDeviceClass =
614
+ (
615
+ {
616
+ Camera: 'camera',
617
+ Plug: 'plug',
618
+ Switch: 'switch',
619
+ Dimmer: 'dimmer',
620
+ Bulb: 'bulb',
621
+ } as Record<string, KasaDeviceClass>
622
+ )[choice] || 'camera';
623
+ const name = settings.name?.toString() || (choice === 'Camera' ? 'Kasa Camera' : `Kasa ${choice}`);
624
+
625
+ if (kasaClass === 'camera') {
626
+ await this.registerCamera(nativeId, name, room);
627
+ deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', 'camera');
628
+ return nativeId;
629
+ }
630
+ await this.registerIotDevice(nativeId, name, room, kasaClass);
631
+ return nativeId;
632
+ }
633
+
634
+ // Register a non-camera Kasa device (plug, switch, dimmer, bulb) with the appropriate
635
+ // Scrypted device type + interfaces. IP/port are left empty; the user fills them in
636
+ // through the per-device settings after creation.
637
+ private async registerIotDevice(
638
+ nativeId: string,
639
+ name: string,
640
+ room: string | undefined,
641
+ kasaClass: KasaDeviceClass,
642
+ ): Promise<void> {
643
+ const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
644
+ let type: ScryptedDeviceType;
645
+ switch (kasaClass) {
646
+ case 'plug':
647
+ type = ScryptedDeviceType.Outlet;
648
+ break;
649
+ case 'switch':
650
+ type = ScryptedDeviceType.Switch;
651
+ break;
652
+ case 'dimmer':
653
+ type = ScryptedDeviceType.Light;
654
+ interfaces.push(ScryptedInterface.Brightness);
655
+ break;
656
+ case 'bulb':
657
+ type = ScryptedDeviceType.Light;
658
+ interfaces.push(ScryptedInterface.Brightness);
659
+ // Color/color-temp interfaces are probed from sysinfo on adoption; manual-
660
+ // create bulbs default to brightness only. The user can re-discover if they
661
+ // need full color support detected automatically.
662
+ break;
663
+ default:
664
+ throw new Error(`unknown kasaClass: ${kasaClass}`);
665
+ }
666
+ await deviceManager.onDeviceDiscovered({
667
+ nativeId,
668
+ name,
669
+ type,
670
+ interfaces,
671
+ info: { manufacturer: 'TP-Link Kasa' },
672
+ room: room || undefined,
673
+ });
674
+ deviceManager.getDeviceStorage(nativeId).setItem('kasaClass', kasaClass);
675
+ }
676
+
677
+ private async registerCamera(
678
+ nativeId: string,
679
+ name: string,
680
+ room?: string,
681
+ info?: { model?: string; mac?: string; ip?: string; serialNumber?: string; firmware?: string },
682
+ ): Promise<void> {
683
+ const device: Device = {
684
+ nativeId,
685
+ name,
686
+ type: ScryptedDeviceType.Camera,
687
+ interfaces: [
688
+ ScryptedInterface.VideoCamera,
689
+ ScryptedInterface.Settings,
690
+ ScryptedInterface.Intercom,
691
+ // Required so Scrypted routes child-device lookups (e.g. the spotlight)
692
+ // through KasaCamera.getDevice rather than treating the camera as a leaf.
693
+ ScryptedInterface.DeviceProvider,
694
+ // OnOff drives the camera's status LED — HomeKit binds its
695
+ // CameraOperatingModeIndicator characteristic to this.
696
+ ScryptedInterface.OnOff,
697
+ ],
698
+ info: {
699
+ manufacturer: 'TP-Link Kasa',
700
+ model: info?.model || undefined,
701
+ mac: info?.mac || undefined,
702
+ ip: info?.ip || undefined,
703
+ serialNumber: info?.serialNumber || undefined,
704
+ firmware: info?.firmware || undefined,
705
+ },
706
+ // Empty string would clear the room on re-discovery; pass undefined to leave alone.
707
+ room: room || undefined,
708
+ };
709
+ await deviceManager.onDeviceDiscovered(device);
710
+ }
711
+
712
+ async discoverDevices(scan?: boolean): Promise<DiscoveredDevice[]> {
713
+ // Discovery never runs unless explicitly requested (scan === true). When it does, an
714
+ // in-flight scan is shared across overlapping callers so a single click never produces
715
+ // more than one network round-trip; calls without scan=true just return the cache.
716
+ if (scan) {
717
+ if (this.scanInFlight) {
718
+ await this.scanInFlight;
719
+ } else if (Date.now() - this.lastScanAt < KasaPlugin.SCAN_COOLDOWN_MS) {
720
+ // Recent scan already completed — return cached list without re-scanning
721
+ // or re-firing onDeviceEvent (which would reset UI filters).
722
+ } else {
723
+ this.scanInFlight = this.runScan().finally(() => {
724
+ this.scanInFlight = undefined;
725
+ this.lastScanAt = Date.now();
726
+ });
727
+ await this.scanInFlight;
728
+ }
729
+ }
730
+
731
+ const defaults = this.getDefaultCredentials();
732
+ const rooms = getKnownRooms();
733
+ const out: DiscoveredDevice[] = [];
734
+ for (const { device } of this.discoveredDevices.values()) {
735
+ const cls = classifyKasa(device);
736
+ if (!cls) continue;
737
+ out.push(this.buildDiscoveredDevice(device, cls, rooms, defaults));
738
+ }
739
+ return out;
740
+ }
741
+
742
+ private buildDiscoveredDevice(
743
+ device: KasaDiscoveredDevice,
744
+ cls: KasaDeviceClass,
745
+ rooms: string[],
746
+ defaults: { username: string; password: string },
747
+ ): DiscoveredDevice {
748
+ const info = {
749
+ manufacturer: 'TP-Link Kasa',
750
+ model: device.model,
751
+ mac: device.mac,
752
+ ip: device.address,
753
+ };
754
+ const fallbackName = device.alias || device.model || 'Kasa Device';
755
+
756
+ // Common settings on every adoption form. Cameras add username/password below.
757
+ const baseSettings: Setting[] = [
758
+ { key: 'name', title: 'Name', value: fallbackName },
759
+ { key: 'room', title: 'Room', placeholder: 'Optional, e.g. Living Room', choices: rooms, combobox: true },
760
+ ];
761
+
762
+ if (cls === 'camera') {
763
+ return {
764
+ nativeId: device.deviceId,
765
+ name: fallbackName,
766
+ description: `${device.model || 'Kasa Camera'} @ ${device.address}`,
767
+ type: ScryptedDeviceType.Camera,
768
+ interfaces: [
769
+ ScryptedInterface.VideoCamera,
770
+ ScryptedInterface.Settings,
771
+ ScryptedInterface.Intercom,
772
+ ScryptedInterface.DeviceProvider,
773
+ ScryptedInterface.OnOff,
774
+ ],
775
+ info,
776
+ // Cameras need the cloud account credentials too — auth on the stream/talk
777
+ // endpoints. Plugs/bulbs are local-only with no auth.
778
+ settings: [
779
+ ...baseSettings,
780
+ {
781
+ key: 'username',
782
+ title: 'Username (Kasa Email)',
783
+ placeholder: 'user@example.com',
784
+ value: defaults.username,
785
+ },
786
+ { key: 'password', title: 'Password (Kasa Account)', type: 'password', value: defaults.password },
787
+ ],
788
+ };
789
+ }
790
+
791
+ // Plug, Switch, Bulb — all share the same simpler adoption form.
792
+ const interfaces = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
793
+ let type: ScryptedDeviceType;
794
+ if (cls === 'bulb') {
795
+ type = ScryptedDeviceType.Light;
796
+ interfaces.push(ScryptedInterface.Brightness);
797
+ const caps = bulbCapabilities(device);
798
+ if (caps.isColor) interfaces.push(ScryptedInterface.ColorSettingHsv);
799
+ if (caps.isVariableColorTemp) interfaces.push(ScryptedInterface.ColorSettingTemperature);
800
+ } else {
801
+ // Dimmer plug/switch (HS220, KS230, ...) is almost always wired to a light, so
802
+ // expose as Light. Plain plugs → Outlet; plain switches → Switch.
803
+ const dimmer = isDimmer(device);
804
+ if (dimmer) {
805
+ type = ScryptedDeviceType.Light;
806
+ interfaces.push(ScryptedInterface.Brightness);
807
+ } else {
808
+ type = cls === 'switch' ? ScryptedDeviceType.Switch : ScryptedDeviceType.Outlet;
809
+ }
810
+ }
811
+
812
+ return {
813
+ nativeId: device.deviceId,
814
+ name: fallbackName,
815
+ description: `${device.model || 'Kasa Device'} @ ${device.address}`,
816
+ type,
817
+ interfaces,
818
+ info,
819
+ settings: baseSettings,
820
+ };
821
+ }
822
+
823
+ // Single-pass UDP discovery: broadcast + paced unicast sweep on the local /24, all on
824
+ // one socket. Fast (~2.5 s) because there's no TCP handshake step and no second pass.
825
+ private async runScan(): Promise<void> {
826
+ try {
827
+ const udpResults = await discoverKasa(2500, this.console).catch(e => {
828
+ this.console.error('kasa udp discovery failed', e);
829
+ return [] as KasaDiscoveredDevice[];
830
+ });
831
+
832
+ const skipped: string[] = [];
833
+ const classCounts: Record<string, number> = {};
834
+ let alreadyAdopted = 0;
835
+ const existingNativeIds = new Set(deviceManager.getNativeIds());
836
+ for (const d of udpResults) {
837
+ if (existingNativeIds.has(d.deviceId)) {
838
+ alreadyAdopted++;
839
+ continue;
840
+ }
841
+ const cls = classifyKasa(d);
842
+ if (!cls) {
843
+ skipped.push(`${d.alias || d.model || d.deviceId} (${d.type})`);
844
+ continue;
845
+ }
846
+ classCounts[cls] = (classCounts[cls] || 0) + 1;
847
+ this.upsertDiscovered(d.deviceId, d);
848
+ }
849
+
850
+ const summaryParts = Object.entries(classCounts).map(([k, v]) => `${v} new ${k}(s)`);
851
+ if (alreadyAdopted) summaryParts.push(`${alreadyAdopted} already adopted`);
852
+ const summary = summaryParts.join(', ') || '0 supported devices';
853
+ this.console.log(
854
+ `kasa discovery: ${udpResults.length} responder(s), ${summary}` +
855
+ (skipped.length ? `, unsupported: ${skipped.join(', ')}` : ''),
856
+ );
857
+ void this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, undefined);
858
+ } catch (e) {
859
+ this.console.error('kasa discovery failed', e);
860
+ }
861
+ }
862
+
863
+ // In typical home setups, every Kasa camera shares the same TP-Link account, so reuse
864
+ // the credentials from any already-configured camera as defaults for newly discovered
865
+ // ones. The user can still override per-camera in the adoption form.
866
+ private getDefaultCredentials(): { username: string; password: string } {
867
+ for (const nativeId of deviceManager.getNativeIds()) {
868
+ if (!nativeId) continue;
869
+ const storage = deviceManager.getDeviceStorage(nativeId);
870
+ const username = storage?.getItem('username');
871
+ const password = storage?.getItem('password');
872
+ if (username && password) return { username, password };
873
+ }
874
+ return { username: '', password: '' };
875
+ }
876
+
877
+ private upsertDiscovered(deviceId: string, device: KasaDiscoveredDevice) {
878
+ const existing = this.discoveredDevices.get(deviceId);
879
+ if (existing) clearTimeout(existing.timeout);
880
+ // Use unref() so the cache-expiry timer doesn't keep the plugin process awake; the
881
+ // entries are best-effort and can be flushed whenever the process idles down.
882
+ const timeout = setTimeout(() => this.discoveredDevices.delete(deviceId), 5 * 60 * 1000);
883
+ timeout.unref?.();
884
+ this.discoveredDevices.set(deviceId, { device, timeout });
885
+ }
886
+
887
+ async adoptDevice(adopt: AdoptDevice): Promise<string> {
888
+ const entry = this.discoveredDevices.get(adopt.nativeId);
889
+ if (!entry) throw new Error('kasa device not found in discovered set; rescan and try again');
890
+
891
+ const { device } = entry;
892
+ const cls = classifyKasa(device);
893
+ if (!cls) throw new Error(`kasa device type ${device.type} is not supported for adoption`);
894
+
895
+ const name = adopt.settings.name?.toString() || device.alias || device.model || 'Kasa Device';
896
+ const room = adopt.settings.room?.toString() || undefined;
897
+
898
+ let id: string;
899
+ if (cls === 'camera') id = await this.adoptCamera(adopt, device, name, room);
900
+ else id = await this.adoptIotDevice(adopt, device, cls, name, room);
901
+
902
+ clearTimeout(entry.timeout);
903
+ this.discoveredDevices.delete(adopt.nativeId);
904
+ void this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, undefined);
905
+ return id;
906
+ }
907
+
908
+ private async adoptCamera(
909
+ adopt: AdoptDevice,
910
+ device: KasaDiscoveredDevice,
911
+ name: string,
912
+ room?: string,
913
+ ): Promise<string> {
914
+ // deviceId is the Kasa-issued 40-char hex per-unit identifier — treat it as the
915
+ // serial number, which is what HomeKit and the UI expect under that label.
916
+ await this.registerCamera(adopt.nativeId, name, room, {
917
+ model: device.model,
918
+ mac: device.mac,
919
+ ip: device.address,
920
+ serialNumber: device.deviceId,
921
+ firmware: typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined,
922
+ });
923
+ deviceManager.getDeviceStorage(adopt.nativeId).setItem('kasaClass', 'camera');
924
+ const camera = (await this.getDevice(adopt.nativeId)) as KasaCamera;
925
+
926
+ camera.storageSettings.values.ip = device.address;
927
+ camera.storageSettings.values.port = KASA_DEFAULT_PORT;
928
+ if (adopt.settings.username) camera.storageSettings.values.username = adopt.settings.username.toString();
929
+ if (adopt.settings.password) camera.storageSettings.values.password = adopt.settings.password.toString();
930
+
931
+ // Now that credentials are set, probe for child devices (spotlight, siren, etc.).
932
+ camera.refreshChildDevices().catch(e => this.console.warn('post-adopt refreshChildDevices failed', e));
933
+
934
+ return camera.id;
935
+ }
936
+
937
+ private async adoptIotDevice(
938
+ adopt: AdoptDevice,
939
+ device: KasaDiscoveredDevice,
940
+ cls: KasaDeviceClass,
941
+ name: string,
942
+ room?: string,
943
+ ): Promise<string> {
944
+ const interfaces: ScryptedInterface[] = [ScryptedInterface.OnOff, ScryptedInterface.Settings];
945
+ let type: ScryptedDeviceType;
946
+ const caps = bulbCapabilities(device);
947
+ // The marker we persist for getDevice routing. 'plug'/'switch' for plain on/off
948
+ // devices, 'dimmer' for anything with brightness control, 'bulb' for true bulbs.
949
+ let storedClass: KasaDeviceClass = cls;
950
+
951
+ if (cls === 'bulb') {
952
+ type = ScryptedDeviceType.Light;
953
+ interfaces.push(ScryptedInterface.Brightness);
954
+ if (caps.isColor) interfaces.push(ScryptedInterface.ColorSettingHsv);
955
+ if (caps.isVariableColorTemp) interfaces.push(ScryptedInterface.ColorSettingTemperature);
956
+ } else if (isDimmer(device)) {
957
+ // Dimmer plug or dimmer switch — both expose as Light with Brightness.
958
+ type = ScryptedDeviceType.Light;
959
+ interfaces.push(ScryptedInterface.Brightness);
960
+ storedClass = 'dimmer';
961
+ } else {
962
+ type = cls === 'switch' ? ScryptedDeviceType.Switch : ScryptedDeviceType.Outlet;
963
+ }
964
+
965
+ const sw_ver = typeof device.sysinfo?.sw_ver === 'string' ? device.sysinfo.sw_ver : undefined;
966
+ await deviceManager.onDeviceDiscovered({
967
+ nativeId: adopt.nativeId,
968
+ name,
969
+ type,
970
+ interfaces,
971
+ room: room || undefined,
972
+ info: {
973
+ manufacturer: 'TP-Link Kasa',
974
+ model: device.model,
975
+ mac: device.mac,
976
+ ip: device.address,
977
+ serialNumber: device.deviceId,
978
+ firmware: sw_ver,
979
+ },
980
+ });
981
+
982
+ // Persist the class marker so getDevice routes to the right implementation —
983
+ // multiple device classes share the same Scrypted device type (true bulbs and
984
+ // dimmers both register as Light).
985
+ deviceManager.getDeviceStorage(adopt.nativeId).setItem('kasaClass', storedClass);
986
+
987
+ const dev = await this.getDevice(adopt.nativeId);
988
+ dev.storageSettings.values.ip = device.address;
989
+ dev.storageSettings.values.port = KASA_IOT_PORT;
990
+ if (cls === 'bulb' && dev instanceof KasaBulb) {
991
+ dev.storageSettings.values.isColor = caps.isColor;
992
+ dev.storageSettings.values.isVariableColorTemp = caps.isVariableColorTemp;
993
+ }
994
+ await dev.refreshState?.().catch(() => {});
995
+
996
+ return dev.id;
997
+ }
998
+
999
+ // Routes a nativeId to the right device class. Adoption persists a `kasaClass` storage
1000
+ // marker (camera/plug/switch/bulb) which is the source of truth here — the Scrypted
1001
+ // device type alone is ambiguous (e.g. both true bulbs and dimmer plugs are `Light`).
1002
+ async getDevice(nativeId: string): Promise<any> {
1003
+ let dev = this.devices.get(nativeId);
1004
+ if (!dev) {
1005
+ dev = this.instantiateDevice(nativeId);
1006
+ if (!dev) return undefined;
1007
+ this.devices.set(nativeId, dev);
1008
+ }
1009
+ return dev;
1010
+ }
1011
+
1012
+ private instantiateDevice(
1013
+ nativeId: string,
1014
+ ): KasaCamera | KasaPlug | KasaSwitch | KasaDimmer | KasaBulb | undefined {
1015
+ const storage = deviceManager.getDeviceStorage(nativeId);
1016
+ const kasaClass = storage?.getItem('kasaClass');
1017
+ switch (kasaClass) {
1018
+ case 'camera':
1019
+ return new KasaCamera(nativeId);
1020
+ case 'bulb':
1021
+ return new KasaBulb(nativeId);
1022
+ case 'dimmer':
1023
+ return new KasaDimmer(nativeId);
1024
+ case 'switch':
1025
+ return new KasaSwitch(nativeId);
1026
+ case 'plug':
1027
+ return new KasaPlug(nativeId);
1028
+ }
1029
+ // Legacy fallback for devices adopted before kasaClass added 'dimmer'. Older
1030
+ // adoptions stored a `dimmer=true` flag on the device's KasaPlug/KasaSwitch
1031
+ // storage when the underlying device was a dimmer; promote those to KasaDimmer.
1032
+ if (storage?.getItem('dimmer') === 'true') return new KasaDimmer(nativeId);
1033
+ const state = deviceManager.getDeviceState(nativeId);
1034
+ switch (state?.type) {
1035
+ case ScryptedDeviceType.Camera:
1036
+ return new KasaCamera(nativeId);
1037
+ case ScryptedDeviceType.Switch:
1038
+ return new KasaSwitch(nativeId);
1039
+ case ScryptedDeviceType.Light:
1040
+ return new KasaDimmer(nativeId);
1041
+ case ScryptedDeviceType.Outlet:
1042
+ return new KasaPlug(nativeId);
1043
+ }
1044
+ return undefined;
1045
+ }
1046
+
1047
+ async releaseDevice(_id: string, nativeId: string): Promise<void> {
1048
+ const dev = this.devices.get(nativeId);
1049
+ // Plug/Bulb instances run a state-poll timer that needs to be cleared.
1050
+ if (dev && 'release' in dev && typeof (dev as any).release === 'function') (dev as any).release();
1051
+ this.devices.delete(nativeId);
1052
+ }
1053
+ }
1054
+
1055
+ export default KasaPlugin;