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.
@@ -0,0 +1,329 @@
1
+ import { once } from 'events';
2
+ import https from 'https';
3
+ import type { IncomingMessage } from 'http';
4
+ import type { Readable } from 'stream';
5
+
6
+ // Kasa cameras serve a non-standard mixed multipart stream over HTTPS on this port/path.
7
+ // Reverse-engineered: https://medium.com/@hu3vjeen/reverse-engineering-tp-link-kc100-bac4641bf1cd
8
+ export const KASA_DEFAULT_PORT = 19443;
9
+ export const KASA_STREAM_PATH = '/https/stream/mixed';
10
+
11
+ // Real headers from this camera fit in well under 1 KB. Cap at 32 KB so a malformed framing
12
+ // (or an attacker who substitutes a non-Kasa endpoint) can't grow the queued chunks until
13
+ // the stream eventually ends. Throwing here surfaces the problem instead of silently
14
+ // retaining memory.
15
+ const MAX_HEADER_BYTES = 32 * 1024;
16
+
17
+ // Socket idle timeout. Doubles as a connect timeout (a camera that's reachable on TCP but
18
+ // never finishes TLS / never sends headers will hit this) AND as a stream-stall timeout
19
+ // (real cameras stream continuously; a 10 s silence means the camera or network is dead).
20
+ // Without this, a hung camera causes getVideoStream to block indefinitely.
21
+ const KASA_STREAM_IDLE_TIMEOUT_MS = 10000;
22
+
23
+ export const KasaMimeVideo = 'video/x-h264';
24
+ export const KasaMimeG711U = 'audio/g711u';
25
+
26
+ const H264_NAL_TYPE_SPS = 7;
27
+ const H264_NAL_TYPE_PPS = 8;
28
+
29
+ export interface H264SpsPps {
30
+ sps?: Buffer;
31
+ pps?: Buffer;
32
+ }
33
+
34
+ // Scans an annex-b H.264 buffer (NALs prefixed by 00 00 01 or 00 00 00 01) for SPS (type 7)
35
+ // and PPS (type 8). Mutates `found` so callers can accumulate across multiple parts; once both
36
+ // are set further calls are cheap and idempotent.
37
+ export function findSpsPps(annexb: Buffer, found: H264SpsPps = {}): H264SpsPps {
38
+ const len = annexb.length;
39
+ let i = 0;
40
+ while (i < len) {
41
+ let nalStart = -1;
42
+ if (i + 2 < len && annexb[i] === 0 && annexb[i + 1] === 0) {
43
+ if (annexb[i + 2] === 1) nalStart = i + 3;
44
+ else if (i + 3 < len && annexb[i + 2] === 0 && annexb[i + 3] === 1) nalStart = i + 4;
45
+ }
46
+ if (nalStart < 0) {
47
+ i++;
48
+ continue;
49
+ }
50
+ // Walk forward to the next start code; if none, the NAL runs to end-of-buffer.
51
+ let nalEnd = len;
52
+ for (let j = nalStart + 1; j + 2 < len; j++) {
53
+ if (
54
+ annexb[j] === 0 &&
55
+ annexb[j + 1] === 0 &&
56
+ (annexb[j + 2] === 1 || (j + 3 < len && annexb[j + 2] === 0 && annexb[j + 3] === 1))
57
+ ) {
58
+ nalEnd = j;
59
+ break;
60
+ }
61
+ }
62
+ const nalType = annexb[nalStart] & 0x1f;
63
+ if (nalType === H264_NAL_TYPE_SPS && !found.sps) found.sps = annexb.subarray(nalStart, nalEnd);
64
+ else if (nalType === H264_NAL_TYPE_PPS && !found.pps) found.pps = annexb.subarray(nalStart, nalEnd);
65
+ i = nalEnd;
66
+ }
67
+ return found;
68
+ }
69
+
70
+ export interface KasaConnectOptions {
71
+ ip: string;
72
+ port?: number;
73
+ username: string;
74
+ password: string;
75
+ }
76
+
77
+ export interface KasaPart {
78
+ contentType: string;
79
+ body: Buffer;
80
+ }
81
+
82
+ // Camera quirk: the Basic auth password must itself be base64(plaintext) before the standard
83
+ // Basic auth base64(user:pass) wrapping. Username is the plaintext Kasa account email.
84
+ function encodeKasaPassword(plaintext: string) {
85
+ return Buffer.from(plaintext, 'utf8').toString('base64');
86
+ }
87
+
88
+ export class KasaClient {
89
+ body!: IncomingMessage;
90
+ boundary!: string;
91
+
92
+ static async connect(options: KasaConnectOptions): Promise<KasaClient> {
93
+ const port = options.port || KASA_DEFAULT_PORT;
94
+ // Pre-emptive Basic auth — the camera doesn't send a 401 challenge before serving
95
+ // the multipart stream; the very first request must already include credentials.
96
+ // (No need for the auth-fetch challenge/retry dance other endpoints use.)
97
+ const auth =
98
+ 'Basic ' + Buffer.from(`${options.username}:${encodeKasaPassword(options.password)}`).toString('base64');
99
+
100
+ // Cameras present a self-signed cert. The body is consumed as a long-lived stream
101
+ // rather than buffered to completion, so we don't read the response body here.
102
+ // The setTimeout below covers both the initial connect/handshake and ongoing stream
103
+ // idleness — Node's socket idle timeout resets on activity, so a continuously
104
+ // streaming camera never trips it; only stalls or unanswered handshakes do.
105
+ const response = await new Promise<IncomingMessage>((resolve, reject) => {
106
+ const req = https.request(
107
+ {
108
+ host: options.ip,
109
+ port,
110
+ method: 'GET',
111
+ path: KASA_STREAM_PATH,
112
+ rejectUnauthorized: false,
113
+ headers: {
114
+ Authorization: auth,
115
+ Accept: '*/*',
116
+ Connection: 'keep-alive',
117
+ },
118
+ },
119
+ resolve,
120
+ );
121
+ req.setTimeout(KASA_STREAM_IDLE_TIMEOUT_MS, () => {
122
+ req.destroy(new Error(`kasa stream socket idle timeout after ${KASA_STREAM_IDLE_TIMEOUT_MS}ms`));
123
+ });
124
+ req.on('error', reject);
125
+ req.end();
126
+ });
127
+
128
+ if (response.statusCode && response.statusCode >= 400) {
129
+ response.destroy();
130
+ throw new Error(`kasa stream HTTP ${response.statusCode}`);
131
+ }
132
+
133
+ const contentType = (response.headers['content-type'] || '') as string;
134
+ if (!/multipart\/x-mixed-replace/i.test(contentType)) {
135
+ response.destroy();
136
+ throw new Error(`unexpected content-type: ${contentType}`);
137
+ }
138
+
139
+ const m = /boundary=([^;\s]+)/i.exec(contentType);
140
+ if (!m) {
141
+ response.destroy();
142
+ throw new Error(`missing boundary in content-type: ${contentType}`);
143
+ }
144
+
145
+ const client = new KasaClient();
146
+ client.body = response;
147
+ client.boundary = m[1];
148
+ return client;
149
+ }
150
+
151
+ destroy() {
152
+ this.body?.destroy();
153
+ }
154
+
155
+ async readPart(): Promise<KasaPart> {
156
+ const expected = '--' + this.boundary;
157
+
158
+ // Read the boundary + header block as one buffer (terminated by a blank line) and
159
+ // walk it byte-wise to extract just content-type and content-length. At ~30 parts/s
160
+ // this avoids the regex split and ~6 string allocations per part that a per-line
161
+ // approach would do.
162
+ const headerBlock = await readUntilDoubleCrlf(this.body);
163
+ const len = headerBlock.length;
164
+
165
+ // Pull out the next \r\n- or \n-terminated line as a string. Returns the slice plus
166
+ // the index just after the line terminator. Skips leading blank lines so a stray
167
+ // CRLF before the boundary (some firmwares) doesn't break parsing.
168
+ const nextLine = (start: number): { line: string; next: number } => {
169
+ let i = start;
170
+ // Find next LF.
171
+ while (i < len && headerBlock[i] !== 0x0a) i++;
172
+ // Trim a trailing CR if present.
173
+ const end = i > start && headerBlock[i - 1] === 0x0d ? i - 1 : i;
174
+ return { line: headerBlock.toString('utf8', start, end), next: i + 1 };
175
+ };
176
+
177
+ let pos = 0;
178
+ // Tolerate leading empty lines.
179
+ while (pos < len) {
180
+ const { line, next } = nextLine(pos);
181
+ if (line) {
182
+ if (line !== expected) throw new Error(`unexpected boundary line: ${JSON.stringify(line)}`);
183
+ pos = next;
184
+ break;
185
+ }
186
+ pos = next;
187
+ }
188
+
189
+ let contentType = '';
190
+ let length = -1;
191
+ while (pos < len) {
192
+ const { line, next } = nextLine(pos);
193
+ pos = next;
194
+ if (!line) break;
195
+ // Header names from this camera are stable (`Content-Type:`, `Content-Length:`),
196
+ // but match case-insensitively to tolerate firmware revisions.
197
+ if (line.length > 13 && line.charCodeAt(12) === 0x3a /* : */) {
198
+ const name = line.slice(0, 12);
199
+ if (name === 'Content-Type' || name.toLowerCase() === 'content-type') {
200
+ contentType = line.slice(13).trim();
201
+ continue;
202
+ }
203
+ }
204
+ if (line.length > 15 && line.charCodeAt(14) === 0x3a) {
205
+ const name = line.slice(0, 14);
206
+ if (name === 'Content-Length' || name.toLowerCase() === 'content-length') {
207
+ const n = parseInt(line.slice(15).trim(), 10);
208
+ if (Number.isFinite(n) && n >= 0) length = n;
209
+ continue;
210
+ }
211
+ }
212
+ }
213
+
214
+ if (length < 0) throw new Error('multipart: no/invalid content length');
215
+
216
+ const body = length === 0 ? Buffer.alloc(0) : await readLength(this.body, length);
217
+ return { contentType, body };
218
+ }
219
+ }
220
+
221
+ // Read exactly `length` bytes from a Readable. Returns when the bytes are available;
222
+ // throws if the stream ends before that.
223
+ async function readLength(readable: Readable, length: number): Promise<Buffer> {
224
+ if (readable.readableEnded || readable.destroyed) throw new Error('kasa stream ended before readLength');
225
+
226
+ {
227
+ const ret = readable.read(length);
228
+ if (ret) return ret;
229
+ }
230
+
231
+ return new Promise<Buffer>((resolve, reject) => {
232
+ const onReadable = () => {
233
+ const ret = readable.read(length);
234
+ if (ret) {
235
+ cleanup();
236
+ resolve(ret);
237
+ }
238
+ };
239
+ const onEnd = () => {
240
+ cleanup();
241
+ reject(new Error('kasa stream ended during readLength'));
242
+ };
243
+ const cleanup = () => {
244
+ readable.removeListener('readable', onReadable);
245
+ readable.removeListener('end', onEnd);
246
+ readable.removeListener('close', onEnd);
247
+ readable.removeListener('error', onError);
248
+ };
249
+ const onError = (e: Error) => {
250
+ cleanup();
251
+ reject(e);
252
+ };
253
+ readable.on('readable', onReadable);
254
+ readable.on('end', onEnd);
255
+ readable.on('close', onEnd);
256
+ readable.on('error', onError);
257
+ });
258
+ }
259
+
260
+ // Read up to and including the first blank line (CRLF CRLF or LF LF) and return everything
261
+ // up to and including the terminator as a Buffer. Whatever bytes come after are unshifted
262
+ // back so the body that follows can be read intact. Enforces MAX_HEADER_BYTES so a malformed
263
+ // stream that never sends a terminator can't grow queued chunks unbounded.
264
+ async function readUntilDoubleCrlf(readable: Readable): Promise<Buffer> {
265
+ const queued: Buffer[] = [];
266
+ let queuedLen = 0;
267
+ // crlf encodes how many bytes of \r\n\r\n we've matched: 0,1=\r, 2=\r\n, 3=\r\n\r, 4=done.
268
+ // lfOnly counts consecutive \n bytes (some firmwares emit LF-only framing).
269
+ let crlf = 0;
270
+ let lfOnly = 0;
271
+ while (true) {
272
+ if (readable.readableEnded || readable.destroyed) throw new Error('kasa stream ended before headers');
273
+ const chunk: Buffer | null = readable.read();
274
+ if (!chunk) {
275
+ // Race 'readable' against 'end'/'close'/'error': if the stream has already ended
276
+ // (or terminates while we're waiting), 'readable' will never fire and the await
277
+ // would strand this promise forever. Re-checking readableEnded on the next loop
278
+ // turn lets us throw a real error instead of hanging.
279
+ //
280
+ // AbortController removes the losing once() listeners; without it each iteration
281
+ // would leave 2 dangling listeners attached for the lifetime of the stream and
282
+ // eventually trip MaxListenersExceededWarning on a long stream with many parts.
283
+ const ac = new AbortController();
284
+ try {
285
+ await Promise.race([
286
+ once(readable, 'readable', { signal: ac.signal }).catch(() => {
287
+ /* aborted or end */
288
+ }),
289
+ once(readable, 'end', { signal: ac.signal }).catch(() => {}),
290
+ once(readable, 'close', { signal: ac.signal }).catch(() => {}),
291
+ ]);
292
+ } finally {
293
+ ac.abort();
294
+ }
295
+ continue;
296
+ }
297
+ let split = -1;
298
+ for (let i = 0; i < chunk.length; i++) {
299
+ const b = chunk[i];
300
+ if (crlf === 0 && b === 0x0d) crlf = 1;
301
+ else if (crlf === 1 && b === 0x0a) crlf = 2;
302
+ else if (crlf === 2 && b === 0x0d) crlf = 3;
303
+ else if (crlf === 3 && b === 0x0a) {
304
+ split = i + 1;
305
+ break;
306
+ } else crlf = b === 0x0d ? 1 : 0;
307
+ if (b === 0x0a) {
308
+ if (++lfOnly === 2) {
309
+ split = i + 1;
310
+ break;
311
+ }
312
+ } else lfOnly = 0;
313
+ }
314
+ if (split < 0) {
315
+ queuedLen += chunk.length;
316
+ if (queuedLen > MAX_HEADER_BYTES)
317
+ throw new Error(`multipart header exceeded ${MAX_HEADER_BYTES} bytes without terminator`);
318
+ queued.push(chunk);
319
+ continue;
320
+ }
321
+ const headerPart = chunk.subarray(0, split);
322
+ const rest = chunk.subarray(split);
323
+ if (queuedLen + headerPart.length > MAX_HEADER_BYTES)
324
+ throw new Error(`multipart header exceeded ${MAX_HEADER_BYTES} bytes`);
325
+ queued.push(headerPart);
326
+ if (rest.length) readable.unshift(rest);
327
+ return queued.length === 1 ? queued[0] : Buffer.concat(queued);
328
+ }
329
+ }
@@ -0,0 +1,163 @@
1
+ import crypto from 'crypto';
2
+ import { ClientRequest } from 'http';
3
+ import https from 'https';
4
+ import { PassThrough } from 'stream';
5
+
6
+ export const KASA_TALK_PORT = 18443;
7
+ const KASA_TALK_PATH = '/https/speaker/audio/g711block';
8
+ const TALK_BOUNDARY = 'audio-boundary--';
9
+
10
+ // Talk-channel auth uses md5(plaintext) as the password rather than the base64(plaintext)
11
+ // the receive stream uses. Same camera, different scheme — observed on KC420WS firmware
12
+ // 2.3.26 via mitmproxy capture of the official Kasa iOS app.
13
+ function md5Hex(plaintext: string): string {
14
+ return crypto.createHash('md5').update(plaintext, 'utf8').digest('hex');
15
+ }
16
+
17
+ // The Kasa app sends X-APP-ID/playerId of the form "tty.<32 base64-ish chars>". The token
18
+ // looks app-generated (not negotiated with the camera), so we mint our own per-session.
19
+ function generatePlayerId(): string {
20
+ return 'tty.' + crypto.randomBytes(24).toString('base64').replace(/[+/=]/g, '').slice(0, 32);
21
+ }
22
+
23
+ export interface KasaTalkOptions {
24
+ ip: string;
25
+ port?: number;
26
+ username: string;
27
+ password: string;
28
+ console?: Console;
29
+ }
30
+
31
+ // One ongoing POST per talk session. Multipart parts wrap raw G.711 µ-law audio blocks
32
+ // (Content-Type: audio/g711u) interleaved with empty audio/heartbeat parts that the app
33
+ // uses to keep the connection alive during silence (~one every 3 s). The body is chunked
34
+ // transfer encoded; the request never completes until the session is closed.
35
+ export class KasaTalkSession {
36
+ private request?: ClientRequest;
37
+ private body = new PassThrough();
38
+ private playerId = '';
39
+ private heartbeatTimer?: NodeJS.Timeout;
40
+ private closed = false;
41
+ private closeCallbacks?: (() => void)[] = [];
42
+ // Resolves once the session is torn down (by the caller, by the camera ending the
43
+ // response, or by an error). Lets the camera's startIntercom await teardown to clean
44
+ // up its ffmpeg process when the uplink dies on its own.
45
+ readonly closedPromise: Promise<void>;
46
+ private resolveClosed!: () => void;
47
+
48
+ constructor(public options: KasaTalkOptions) {
49
+ this.closedPromise = new Promise<void>(resolve => {
50
+ this.resolveClosed = resolve;
51
+ });
52
+ }
53
+
54
+ async start(): Promise<void> {
55
+ this.playerId = generatePlayerId();
56
+ const port = this.options.port || KASA_TALK_PORT;
57
+ const auth =
58
+ 'Basic ' + Buffer.from(`${this.options.username}:${md5Hex(this.options.password)}`).toString('base64');
59
+
60
+ const search = new URLSearchParams({ playerId: this.playerId });
61
+
62
+ const req = https.request({
63
+ host: this.options.ip,
64
+ port,
65
+ method: 'POST',
66
+ path: `${KASA_TALK_PATH}?${search.toString()}`,
67
+ rejectUnauthorized: false,
68
+ headers: {
69
+ Authorization: auth,
70
+ 'X-APP-ID': this.playerId,
71
+ 'Content-Type': `multipart/x-mixed-replace;boundary=${TALK_BOUNDARY}`,
72
+ 'Transfer-Encoding': 'chunked',
73
+ Connection: 'keep-alive',
74
+ Accept: '*/*',
75
+ },
76
+ });
77
+
78
+ this.request = req;
79
+
80
+ req.on('error', e => {
81
+ // Suppress when the close came first (deliberate teardown via close()): ending
82
+ // the body mid-flight makes the in-flight HTTPS request emit "socket hang up"
83
+ // one tick later, which isn't a real error.
84
+ if (!this.closed) this.options.console?.warn('kasa talk request error', e.message);
85
+ this.close();
86
+ });
87
+
88
+ req.on('response', res => {
89
+ this.options.console?.log(`kasa talk response: ${res.statusCode}`);
90
+ res.on('data', () => {
91
+ /* drain */
92
+ });
93
+ res.on('end', () => this.close());
94
+ });
95
+
96
+ // Body is a PassThrough so we can write parts incrementally; pipe it into the request.
97
+ this.body.pipe(req);
98
+
99
+ // Heartbeat immediately + every 3 s, mirroring the Kasa app's idle pattern.
100
+ this.writePart('audio/heartbeat', Buffer.alloc(0));
101
+ this.heartbeatTimer = setInterval(() => {
102
+ this.writePart('audio/heartbeat', Buffer.alloc(0));
103
+ }, 3000);
104
+ }
105
+
106
+ // Returns false when the underlying stream is asking for backpressure (Node's standard
107
+ // contract). Caller should pause its source until `drain` fires on `body`. We surface
108
+ // this so an unhealthy/slow camera socket can't grow the PassThrough buffer without bound.
109
+ writeAudio(chunk: Buffer): boolean {
110
+ if (!chunk.length) return true;
111
+ return this.writePart('audio/g711u', chunk);
112
+ }
113
+
114
+ onDrain(cb: () => void): void {
115
+ this.body.once('drain', cb);
116
+ }
117
+
118
+ // Subscribe to the session's teardown event. If the session is already closed, the
119
+ // callback is invoked on the next microtask so callers don't have to special-case timing.
120
+ onClose(cb: () => void): void {
121
+ if (this.closed) {
122
+ queueMicrotask(cb);
123
+ return;
124
+ }
125
+ this.closeCallbacks!.push(cb);
126
+ }
127
+
128
+ private writePart(contentType: string, body: Buffer): boolean {
129
+ if (this.closed) return true;
130
+ const header = `--${TALK_BOUNDARY}\r\nContent-Length: ${body.length}\r\nContent-Type: ${contentType}\r\n\r\n`;
131
+ // Only the last write's return value matters for backpressure — if any of these
132
+ // fills the buffer, Node will emit `drain` after the buffer empties.
133
+ this.body.write(header);
134
+ if (body.length) this.body.write(body);
135
+ return this.body.write('\r\n');
136
+ }
137
+
138
+ close(): void {
139
+ if (this.closed) return;
140
+ this.closed = true;
141
+ clearInterval(this.heartbeatTimer);
142
+ try {
143
+ this.body.end();
144
+ } catch {}
145
+ try {
146
+ this.request?.end();
147
+ } catch {}
148
+ const cbs = this.closeCallbacks;
149
+ // Drop the array reference before invoking callbacks so V8 can reclaim it; any
150
+ // late onClose() registration after this point will be served via queueMicrotask.
151
+ this.closeCallbacks = undefined;
152
+ if (cbs) {
153
+ for (const cb of cbs) {
154
+ try {
155
+ cb();
156
+ } catch (e) {
157
+ this.options.console?.warn('kasa talk onClose handler threw', e);
158
+ }
159
+ }
160
+ }
161
+ this.resolveClosed();
162
+ }
163
+ }
@@ -0,0 +1,185 @@
1
+ import crypto from 'crypto';
2
+ import https from 'https';
3
+ import { xorDecryptInPlace, xorEncryptInPlace } from '../shared/cipher';
4
+
5
+ export const KASA_LINKIE_PORT = 10443;
6
+ const KASA_LINKIE_PATH = '/data/LINKIE2.json';
7
+ const KASA_LINKIE_TIMEOUT_MS = 5000;
8
+
9
+ // Same auth quirk as the talk channel: the camera wants md5_hex(plaintext) as the password.
10
+ // (The receive endpoint takes base64(plaintext) instead — same camera, three different
11
+ // places, three subtly different conventions.)
12
+ function md5Hex(plaintext: string): string {
13
+ return crypto.createHash('md5').update(plaintext, 'utf8').digest('hex');
14
+ }
15
+
16
+ export interface KasaLinkieOptions {
17
+ ip: string;
18
+ port?: number;
19
+ username: string;
20
+ password: string;
21
+ }
22
+
23
+ export interface KasaLinkieClientLogger {
24
+ warn?: Console['warn'];
25
+ }
26
+
27
+ // Speaks the Kasa "LINKIE2" control protocol used by the iOS app for everything that isn't
28
+ // streaming or talk: spotlight, siren, mic config, SD card status, etc. Wire format:
29
+ // - HTTPS POST to https://<ip>:10443/data/LINKIE2.json
30
+ // - Body: application/x-www-form-urlencoded `content=<base64(xor_ab(json))>`
31
+ // - Each request body adds a `context.source` UUID — the camera ignores it but the field
32
+ // is always present in captures, so we generate one per call.
33
+ // - Response: same encoding, with the result merged under the same module/method keys.
34
+ export class KasaLinkieClient {
35
+ constructor(
36
+ public options: KasaLinkieOptions,
37
+ public logger?: KasaLinkieClientLogger,
38
+ ) {}
39
+
40
+ async call(command: Record<string, any>): Promise<any> {
41
+ const body = {
42
+ ...command,
43
+ context: { source: crypto.randomUUID() },
44
+ };
45
+ const json = JSON.stringify(body);
46
+ // Encrypt in-place: the Buffer.from we just allocated isn't shared anywhere else.
47
+ const encrypted = xorEncryptInPlace(Buffer.from(json, 'utf8'));
48
+ const formBody = Buffer.from(`content=${encodeURIComponent(encrypted.toString('base64'))}`);
49
+
50
+ // Send Basic auth pre-emptively. The camera does not respond at all to unauthenticated
51
+ // requests (no 401, no anything), so the standard "wait for challenge then retry"
52
+ // pattern would just hang. Pre-emptive auth matches what curl with `-u` does.
53
+ const auth =
54
+ 'Basic ' + Buffer.from(`${this.options.username}:${md5Hex(this.options.password)}`).toString('base64');
55
+
56
+ const respBuffer = await new Promise<Buffer>((resolve, reject) => {
57
+ const req = https.request(
58
+ {
59
+ host: this.options.ip,
60
+ port: this.options.port || KASA_LINKIE_PORT,
61
+ method: 'POST',
62
+ path: KASA_LINKIE_PATH,
63
+ rejectUnauthorized: false,
64
+ // Mirror the Kasa iOS app's headers — the camera gates /data/LINKIE2.json on the
65
+ // User-Agent (silently drops requests that don't look like the Kasa app).
66
+ headers: {
67
+ Authorization: auth,
68
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
69
+ 'Content-Length': formBody.length,
70
+ 'User-Agent': 'Kasa/1752 CFNetwork/3860.500.112 Darwin/25.4.0',
71
+ Accept: '*/*',
72
+ 'Accept-Language': 'en-US,en;q=0.9',
73
+ // We don't decompress responses (camera never sends compressed today, but
74
+ // advertising 'gzip' would let a future firmware break our base64+XOR decode).
75
+ 'Accept-Encoding': 'identity',
76
+ Connection: 'keep-alive',
77
+ },
78
+ },
79
+ res => {
80
+ if (res.statusCode && res.statusCode >= 400) {
81
+ res.resume();
82
+ reject(new Error(`kasa linkie HTTP ${res.statusCode}`));
83
+ return;
84
+ }
85
+ const chunks: Buffer[] = [];
86
+ res.on('data', c => chunks.push(c));
87
+ res.on('end', () => resolve(Buffer.concat(chunks)));
88
+ res.on('error', reject);
89
+ },
90
+ );
91
+ req.setTimeout(KASA_LINKIE_TIMEOUT_MS, () => {
92
+ req.destroy(new Error(`kasa linkie request timeout after ${KASA_LINKIE_TIMEOUT_MS}ms`));
93
+ });
94
+ req.on('error', reject);
95
+ req.write(formBody);
96
+ req.end();
97
+ });
98
+
99
+ const respB64 = respBuffer.toString('utf8').trim();
100
+ // Same here: the base64-decoded buffer is freshly ours, decrypt in-place.
101
+ const respDecrypted = xorDecryptInPlace(Buffer.from(respB64, 'base64'));
102
+ return JSON.parse(respDecrypted.toString('utf8'));
103
+ }
104
+
105
+ // Spotlight controls under smartlife.cam.ipcamera.dayNight (called "force_lamp" at the
106
+ // protocol level, "spotlight" in the Kasa app UI).
107
+ // Returns 'on' / 'off' for cameras that support it, undefined for cameras that don't
108
+ // (request fails or the module is missing from the response).
109
+
110
+ async getForceLampState(): Promise<'on' | 'off' | undefined> {
111
+ try {
112
+ const r = await this.call({
113
+ 'smartlife.cam.ipcamera.dayNight': { get_force_lamp_state: {} },
114
+ });
115
+ const v = r?.['smartlife.cam.ipcamera.dayNight']?.get_force_lamp_state?.value;
116
+ return v === 'on' || v === 'off' ? v : undefined;
117
+ } catch (e) {
118
+ this.logger?.warn?.('kasa linkie get_force_lamp_state failed:', (e as Error).message);
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ async setForceLampState(on: boolean): Promise<void> {
124
+ const r = await this.call({
125
+ 'smartlife.cam.ipcamera.dayNight': {
126
+ set_force_lamp_state: { value: on ? 'on' : 'off' },
127
+ },
128
+ });
129
+ const errCode = r?.['smartlife.cam.ipcamera.dayNight']?.set_force_lamp_state?.err_code;
130
+ if (errCode !== 0) throw new Error(`set_force_lamp_state err_code=${errCode}`);
131
+ }
132
+
133
+ // Status LED on the camera body, controlled via smartlife.cam.ipcamera.led. Used to
134
+ // back HomeKit's CameraOperatingModeIndicator characteristic via OnOff on the camera.
135
+
136
+ async getLedStatus(): Promise<'on' | 'off' | undefined> {
137
+ try {
138
+ const r = await this.call({
139
+ 'smartlife.cam.ipcamera.led': { get_status: {} },
140
+ });
141
+ const v = r?.['smartlife.cam.ipcamera.led']?.get_status?.value;
142
+ return v === 'on' || v === 'off' ? v : undefined;
143
+ } catch (e) {
144
+ this.logger?.warn?.('kasa linkie get_status (led) failed:', (e as Error).message);
145
+ return undefined;
146
+ }
147
+ }
148
+
149
+ async setLedStatus(on: boolean): Promise<void> {
150
+ const r = await this.call({
151
+ 'smartlife.cam.ipcamera.led': {
152
+ set_status: { value: on ? 'on' : 'off' },
153
+ },
154
+ });
155
+ const errCode = r?.['smartlife.cam.ipcamera.led']?.set_status?.err_code;
156
+ if (errCode !== 0) throw new Error(`led set_status err_code=${errCode}`);
157
+ }
158
+
159
+ // Siren under smartlife.cam.ipcamera.siren. The camera auto-stops after the duration
160
+ // configured in the Kasa app (default 30 s); we just send on/off and let the camera
161
+ // handle the timer.
162
+
163
+ async getSirenState(): Promise<'on' | 'off' | undefined> {
164
+ try {
165
+ const r = await this.call({
166
+ 'smartlife.cam.ipcamera.siren': { get_state: {} },
167
+ });
168
+ const v = r?.['smartlife.cam.ipcamera.siren']?.get_state?.value;
169
+ return v === 'on' || v === 'off' ? v : undefined;
170
+ } catch (e) {
171
+ this.logger?.warn?.('kasa linkie get_state (siren) failed:', (e as Error).message);
172
+ return undefined;
173
+ }
174
+ }
175
+
176
+ async setSirenState(on: boolean): Promise<void> {
177
+ const r = await this.call({
178
+ 'smartlife.cam.ipcamera.siren': {
179
+ set_state: { value: on ? 'on' : 'off' },
180
+ },
181
+ });
182
+ const errCode = r?.['smartlife.cam.ipcamera.siren']?.set_state?.err_code;
183
+ if (errCode !== 0) throw new Error(`siren set_state err_code=${errCode}`);
184
+ }
185
+ }