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/.github/dependabot.yml +14 -0
- package/.github/workflows/ci.yml +35 -0
- package/.github/workflows/publish.yml +52 -0
- package/.nvmrc +1 -0
- package/.prettierignore +6 -0
- package/.prettierrc.json +11 -0
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/eslint.config.mjs +33 -0
- package/package.json +75 -0
- package/src/camera/api.ts +329 -0
- package/src/camera/intercom.ts +163 -0
- package/src/camera/linkie.ts +185 -0
- package/src/camera/rtsp.ts +204 -0
- package/src/camera/stream.ts +428 -0
- package/src/iot/bulb.ts +178 -0
- package/src/iot/device.ts +103 -0
- package/src/iot/dimmer.ts +19 -0
- package/src/iot/plug.ts +5 -0
- package/src/iot/protocol.ts +143 -0
- package/src/iot/switch.ts +5 -0
- package/src/main.ts +1055 -0
- package/src/shared/cipher.ts +49 -0
- package/src/shared/discovery.ts +232 -0
- package/tsconfig.json +14 -0
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;
|