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/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;