scrypted-kasa 1.3.1 → 1.3.3

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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scrypted-kasa",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "TP-Link Kasa plugin for Scrypted — cameras (video, two-way audio, spotlight, siren, status LED), plugs/outlets, wall switches, dimmers, and smart bulbs (on/off, brightness, color, color temperature).",
5
5
  "author": "Sergey Vilgelm <sergey@vilgelm.com>",
6
6
  "license": "MIT",
@@ -12,6 +12,9 @@
12
12
  "url": "https://github.com/sv-tools/scrypted-kasa-plugin/issues"
13
13
  },
14
14
  "homepage": "https://github.com/sv-tools/scrypted-kasa-plugin#readme",
15
+ "files": [
16
+ "dist/plugin.zip"
17
+ ],
15
18
  "scripts": {
16
19
  "scrypted-setup-project": "scrypted-setup-project",
17
20
  "prescrypted-setup-project": "scrypted-package-json",
@@ -1,14 +0,0 @@
1
- version: 2
2
- updates:
3
- - package-ecosystem: npm
4
- directory: "/"
5
- schedule:
6
- interval: weekly
7
- - package-ecosystem: github-actions
8
- directory: "/"
9
- schedule:
10
- interval: weekly
11
- groups:
12
- ci:
13
- patterns:
14
- - "*"
@@ -1,40 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- pull_request:
5
- branches: [main]
6
- push:
7
- branches: [main]
8
-
9
- # Cancel in-progress runs for the same PR/branch when a new commit is pushed.
10
- concurrency:
11
- group: ci-${{ github.workflow }}-${{ github.ref }}
12
- cancel-in-progress: true
13
-
14
- # Read-only is enough — CI just builds and lints, doesn't push commits, comments, or
15
- # release artifacts.
16
- permissions:
17
- contents: read
18
-
19
- jobs:
20
- check:
21
- runs-on: ubuntu-latest
22
- steps:
23
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
24
-
25
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
26
- with:
27
- node-version-file: '.nvmrc'
28
- cache: 'npm'
29
-
30
- - name: Install dependencies
31
- run: npm ci
32
-
33
- - name: Prettier check
34
- run: npm run fmt:check
35
-
36
- - name: ESLint
37
- run: npm run lint
38
-
39
- - name: Build (scrypted-webpack)
40
- run: npm run build
@@ -1,52 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- push:
5
- # Trigger on semver tags like v1.0.7. Push the tag with `git tag v1.0.7 && git push --tags`
6
- # after bumping the version in package.json.
7
- tags: ['v*']
8
-
9
- jobs:
10
- publish:
11
- runs-on: ubuntu-latest
12
- permissions:
13
- contents: read
14
- # id-token is required for npm publish provenance — provenance signs the package
15
- # via the GitHub OIDC token so consumers can verify it was built by this workflow.
16
- id-token: write
17
- steps:
18
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19
-
20
- - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
21
- with:
22
- node-version-file: '.nvmrc'
23
- cache: 'npm'
24
- # Configures npm with the registry URL so NODE_AUTH_TOKEN is honored at publish.
25
- registry-url: 'https://registry.npmjs.org'
26
-
27
- - name: Verify package.json version matches git tag
28
- run: |
29
- PKG_VERSION=$(node -p "require('./package.json').version")
30
- TAG_VERSION="${GITHUB_REF#refs/tags/v}"
31
- if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
32
- echo "::error::package.json version ($PKG_VERSION) does not match git tag ($TAG_VERSION)"
33
- exit 1
34
- fi
35
- echo "Publishing scrypted-kasa@$PKG_VERSION"
36
-
37
- - name: Install dependencies
38
- run: npm ci
39
-
40
- - name: Prettier check
41
- run: npm run fmt:check
42
-
43
- - name: ESLint
44
- run: npm run lint
45
-
46
- - name: Build (scrypted-webpack)
47
- run: npm run build
48
-
49
- - name: Publish to npm
50
- run: npm publish --access public --provenance
51
- env:
52
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- v25
package/.prettierignore DELETED
@@ -1,6 +0,0 @@
1
- node_modules/
2
- out/
3
- build/
4
- dist/
5
- *.log
6
- package-lock.json
package/.prettierrc.json DELETED
@@ -1,11 +0,0 @@
1
- {
2
- "tabWidth": 4,
3
- "useTabs": false,
4
- "singleQuote": true,
5
- "semi": true,
6
- "trailingComma": "all",
7
- "printWidth": 120,
8
- "arrowParens": "avoid",
9
- "bracketSpacing": true,
10
- "endOfLine": "lf"
11
- }
package/eslint.config.mjs DELETED
@@ -1,33 +0,0 @@
1
- import js from '@eslint/js';
2
- import tseslint from 'typescript-eslint';
3
-
4
- // We deliberately don't use `eslint-config-prettier`: typescript-eslint's `recommended`
5
- // preset focuses on code-quality rules, not style. Prettier already owns formatting via
6
- // `npm run fmt` / `fmt:check`, and there's nothing in the rule set below that fights it.
7
- // Skipping the dep also sidesteps the known eslint-config-prettier supply-chain compromise.
8
- export default tseslint.config(
9
- {
10
- ignores: ['node_modules/', 'out/', 'build/', 'dist/'],
11
- },
12
- js.configs.recommended,
13
- ...tseslint.configs.recommended,
14
- {
15
- rules: {
16
- // Allow `any` — used intentionally for kasa sysinfo (untyped JSON shape varies
17
- // per device family) and for narrow casts where TypeScript can't see through
18
- // the runtime tag (e.g. `kasaClass`).
19
- '@typescript-eslint/no-explicit-any': 'off',
20
-
21
- // Treat `_`-prefixed args/vars as intentionally unused (matches the convention
22
- // we already use for ignored handler params like `_options`, `_id`, `_nativeId`).
23
- '@typescript-eslint/no-unused-vars': [
24
- 'warn',
25
- { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
26
- ],
27
-
28
- // Empty catch blocks are a deliberate teardown idiom in this codebase: e.g.
29
- // `try { socket.close(); } catch {}` — best-effort, ignore failure. Don't nag.
30
- 'no-empty': ['warn', { allowEmptyCatch: true }],
31
- },
32
- },
33
- );
package/src/camera/api.ts DELETED
@@ -1,354 +0,0 @@
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
- // Connect-phase idle timeout: while establishing the HTTPS request and waiting for response
18
- // headers, fires only if the socket is idle for this long. Covers TLS handshake hangs and
19
- // unresponsive-but-routable cameras — but it's not a strict wall-clock deadline if there
20
- // is intermittent socket activity without headers arriving. Once headers arrive we clear
21
- // the socket timeout because kasa cameras on marginal Wi-Fi can go briefly silent (channel
22
- // scan, AP roam, transient congestion) and recover; killing the stream on every such gap
23
- // causes more harm than good. Half-open TCP detection is delegated to OS keepalive (see
24
- // setKeepAlive below) so a Wi-Fi drop without a clean FIN still gets cleaned up.
25
- const KASA_STREAM_CONNECT_TIMEOUT_MS = 10000;
26
-
27
- // Initial idle before the OS sends the first TCP keepalive probe on the streaming socket.
28
- // During normal streaming, frame-level data activity resets this timer so probes never
29
- // fire. If the connection dies (Wi-Fi drop, NAT timeout, switch reset), keepalive probes
30
- // eventually cause the OS to close the socket and our `body.on('close')` triggers teardown.
31
- // Note: `socket.setKeepAlive(true, initialDelay)` controls only this initial idle period
32
- // (TCP_KEEPIDLE); total detection/teardown time still depends on OS-level keepalive
33
- // interval (TCP_KEEPINTVL) and probe count (TCP_KEEPCNT). Linux defaults are often too
34
- // slow for a live video stream (TCP_KEEPIDLE may default to 7200 s), so 30 s here just
35
- // starts probing sooner after the stream goes idle.
36
- const KASA_STREAM_KEEPALIVE_INITIAL_DELAY_MS = 30000;
37
-
38
- export const KasaMimeVideo = 'video/x-h264';
39
- export const KasaMimeG711U = 'audio/g711u';
40
-
41
- const H264_NAL_TYPE_SPS = 7;
42
- const H264_NAL_TYPE_PPS = 8;
43
-
44
- export interface H264SpsPps {
45
- sps?: Buffer;
46
- pps?: Buffer;
47
- }
48
-
49
- // Scans an annex-b H.264 buffer (NALs prefixed by 00 00 01 or 00 00 00 01) for SPS (type 7)
50
- // and PPS (type 8). Mutates `found` so callers can accumulate across multiple parts; once both
51
- // are set further calls are cheap and idempotent.
52
- export function findSpsPps(annexb: Buffer, found: H264SpsPps = {}): H264SpsPps {
53
- const len = annexb.length;
54
- let i = 0;
55
- while (i < len) {
56
- let nalStart = -1;
57
- if (i + 2 < len && annexb[i] === 0 && annexb[i + 1] === 0) {
58
- if (annexb[i + 2] === 1) nalStart = i + 3;
59
- else if (i + 3 < len && annexb[i + 2] === 0 && annexb[i + 3] === 1) nalStart = i + 4;
60
- }
61
- if (nalStart < 0) {
62
- i++;
63
- continue;
64
- }
65
- // Walk forward to the next start code; if none, the NAL runs to end-of-buffer.
66
- let nalEnd = len;
67
- for (let j = nalStart + 1; j + 2 < len; j++) {
68
- if (
69
- annexb[j] === 0 &&
70
- annexb[j + 1] === 0 &&
71
- (annexb[j + 2] === 1 || (j + 3 < len && annexb[j + 2] === 0 && annexb[j + 3] === 1))
72
- ) {
73
- nalEnd = j;
74
- break;
75
- }
76
- }
77
- const nalType = annexb[nalStart] & 0x1f;
78
- if (nalType === H264_NAL_TYPE_SPS && !found.sps) found.sps = annexb.subarray(nalStart, nalEnd);
79
- else if (nalType === H264_NAL_TYPE_PPS && !found.pps) found.pps = annexb.subarray(nalStart, nalEnd);
80
- i = nalEnd;
81
- }
82
- return found;
83
- }
84
-
85
- export interface KasaConnectOptions {
86
- ip: string;
87
- port?: number;
88
- username: string;
89
- password: string;
90
- }
91
-
92
- export interface KasaPart {
93
- contentType: string;
94
- body: Buffer;
95
- }
96
-
97
- // Camera quirk: the Basic auth password must itself be base64(plaintext) before the standard
98
- // Basic auth base64(user:pass) wrapping. Username is the plaintext Kasa account email.
99
- function encodeKasaPassword(plaintext: string) {
100
- return Buffer.from(plaintext, 'utf8').toString('base64');
101
- }
102
-
103
- export class KasaClient {
104
- body!: IncomingMessage;
105
- boundary!: string;
106
-
107
- static async connect(options: KasaConnectOptions): Promise<KasaClient> {
108
- const port = options.port || KASA_DEFAULT_PORT;
109
- // Pre-emptive Basic auth — the camera doesn't send a 401 challenge before serving
110
- // the multipart stream; the very first request must already include credentials.
111
- // (No need for the auth-fetch challenge/retry dance other endpoints use.)
112
- const auth =
113
- 'Basic ' + Buffer.from(`${options.username}:${encodeKasaPassword(options.password)}`).toString('base64');
114
-
115
- // Cameras present a self-signed cert. The body is consumed as a long-lived stream
116
- // rather than buffered to completion, so we don't read the response body here.
117
- const response = await new Promise<IncomingMessage>((resolve, reject) => {
118
- const req = https.request(
119
- {
120
- host: options.ip,
121
- port,
122
- method: 'GET',
123
- path: KASA_STREAM_PATH,
124
- rejectUnauthorized: false,
125
- headers: {
126
- Authorization: auth,
127
- Accept: '*/*',
128
- Connection: 'keep-alive',
129
- },
130
- },
131
- response => {
132
- // Headers in — clear the connect-phase idle timeout so it doesn't
133
- // keep firing during normal streaming gaps. setTimeout(0) on the
134
- // underlying socket disables Node's idle-timeout machinery.
135
- response.socket?.setTimeout(0);
136
- // Enable OS-level TCP keepalive as a safety net for half-open
137
- // detection. During normal streaming the data flow keeps this from
138
- // firing, but if Wi-Fi drops / NAT times out without a clean FIN,
139
- // keepalive probes will eventually fail and the kernel will close
140
- // the socket — without keepalive we'd block forever in readPart()
141
- // waiting for a multipart header that never arrives.
142
- response.socket?.setKeepAlive(true, KASA_STREAM_KEEPALIVE_INITIAL_DELAY_MS);
143
- resolve(response);
144
- },
145
- );
146
- req.setTimeout(KASA_STREAM_CONNECT_TIMEOUT_MS, () => {
147
- req.destroy(new Error(`kasa stream connect timeout after ${KASA_STREAM_CONNECT_TIMEOUT_MS}ms`));
148
- });
149
- req.on('error', reject);
150
- req.end();
151
- });
152
-
153
- if (response.statusCode && response.statusCode >= 400) {
154
- response.destroy();
155
- throw new Error(`kasa stream HTTP ${response.statusCode}`);
156
- }
157
-
158
- const contentType = (response.headers['content-type'] || '') as string;
159
- if (!/multipart\/x-mixed-replace/i.test(contentType)) {
160
- response.destroy();
161
- throw new Error(`unexpected content-type: ${contentType}`);
162
- }
163
-
164
- const m = /boundary=([^;\s]+)/i.exec(contentType);
165
- if (!m) {
166
- response.destroy();
167
- throw new Error(`missing boundary in content-type: ${contentType}`);
168
- }
169
-
170
- const client = new KasaClient();
171
- client.body = response;
172
- client.boundary = m[1];
173
- return client;
174
- }
175
-
176
- destroy() {
177
- this.body?.destroy();
178
- }
179
-
180
- async readPart(): Promise<KasaPart> {
181
- const expected = '--' + this.boundary;
182
-
183
- // Read the boundary + header block as one buffer (terminated by a blank line) and
184
- // walk it byte-wise to extract just content-type and content-length. At ~30 parts/s
185
- // this avoids the regex split and ~6 string allocations per part that a per-line
186
- // approach would do.
187
- const headerBlock = await readUntilDoubleCrlf(this.body);
188
- const len = headerBlock.length;
189
-
190
- // Pull out the next \r\n- or \n-terminated line as a string. Returns the slice plus
191
- // the index just after the line terminator. Skips leading blank lines so a stray
192
- // CRLF before the boundary (some firmwares) doesn't break parsing.
193
- const nextLine = (start: number): { line: string; next: number } => {
194
- let i = start;
195
- // Find next LF.
196
- while (i < len && headerBlock[i] !== 0x0a) i++;
197
- // Trim a trailing CR if present.
198
- const end = i > start && headerBlock[i - 1] === 0x0d ? i - 1 : i;
199
- return { line: headerBlock.toString('utf8', start, end), next: i + 1 };
200
- };
201
-
202
- let pos = 0;
203
- // Tolerate leading empty lines.
204
- while (pos < len) {
205
- const { line, next } = nextLine(pos);
206
- if (line) {
207
- if (line !== expected) throw new Error(`unexpected boundary line: ${JSON.stringify(line)}`);
208
- pos = next;
209
- break;
210
- }
211
- pos = next;
212
- }
213
-
214
- let contentType = '';
215
- let length = -1;
216
- while (pos < len) {
217
- const { line, next } = nextLine(pos);
218
- pos = next;
219
- if (!line) break;
220
- // Header names from this camera are stable (`Content-Type:`, `Content-Length:`),
221
- // but match case-insensitively to tolerate firmware revisions.
222
- if (line.length > 13 && line.charCodeAt(12) === 0x3a /* : */) {
223
- const name = line.slice(0, 12);
224
- if (name === 'Content-Type' || name.toLowerCase() === 'content-type') {
225
- contentType = line.slice(13).trim();
226
- continue;
227
- }
228
- }
229
- if (line.length > 15 && line.charCodeAt(14) === 0x3a) {
230
- const name = line.slice(0, 14);
231
- if (name === 'Content-Length' || name.toLowerCase() === 'content-length') {
232
- const n = parseInt(line.slice(15).trim(), 10);
233
- if (Number.isFinite(n) && n >= 0) length = n;
234
- continue;
235
- }
236
- }
237
- }
238
-
239
- if (length < 0) throw new Error('multipart: no/invalid content length');
240
-
241
- const body = length === 0 ? Buffer.alloc(0) : await readLength(this.body, length);
242
- return { contentType, body };
243
- }
244
- }
245
-
246
- // Read exactly `length` bytes from a Readable. Returns when the bytes are available;
247
- // throws if the stream ends before that.
248
- async function readLength(readable: Readable, length: number): Promise<Buffer> {
249
- if (readable.readableEnded || readable.destroyed) throw new Error('kasa stream ended before readLength');
250
-
251
- {
252
- const ret = readable.read(length);
253
- if (ret) return ret;
254
- }
255
-
256
- return new Promise<Buffer>((resolve, reject) => {
257
- const onReadable = () => {
258
- const ret = readable.read(length);
259
- if (ret) {
260
- cleanup();
261
- resolve(ret);
262
- }
263
- };
264
- const onEnd = () => {
265
- cleanup();
266
- reject(new Error('kasa stream ended during readLength'));
267
- };
268
- const cleanup = () => {
269
- readable.removeListener('readable', onReadable);
270
- readable.removeListener('end', onEnd);
271
- readable.removeListener('close', onEnd);
272
- readable.removeListener('error', onError);
273
- };
274
- const onError = (e: Error) => {
275
- cleanup();
276
- reject(e);
277
- };
278
- readable.on('readable', onReadable);
279
- readable.on('end', onEnd);
280
- readable.on('close', onEnd);
281
- readable.on('error', onError);
282
- });
283
- }
284
-
285
- // Read up to and including the first blank line (CRLF CRLF or LF LF) and return everything
286
- // up to and including the terminator as a Buffer. Whatever bytes come after are unshifted
287
- // back so the body that follows can be read intact. Enforces MAX_HEADER_BYTES so a malformed
288
- // stream that never sends a terminator can't grow queued chunks unbounded.
289
- async function readUntilDoubleCrlf(readable: Readable): Promise<Buffer> {
290
- const queued: Buffer[] = [];
291
- let queuedLen = 0;
292
- // 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.
293
- // lfOnly counts consecutive \n bytes (some firmwares emit LF-only framing).
294
- let crlf = 0;
295
- let lfOnly = 0;
296
- while (true) {
297
- if (readable.readableEnded || readable.destroyed) throw new Error('kasa stream ended before headers');
298
- const chunk: Buffer | null = readable.read();
299
- if (!chunk) {
300
- // Race 'readable' against 'end'/'close'/'error': if the stream has already ended
301
- // (or terminates while we're waiting), 'readable' will never fire and the await
302
- // would strand this promise forever. Re-checking readableEnded on the next loop
303
- // turn lets us throw a real error instead of hanging.
304
- //
305
- // AbortController removes the losing once() listeners; without it each iteration
306
- // would leave 2 dangling listeners attached for the lifetime of the stream and
307
- // eventually trip MaxListenersExceededWarning on a long stream with many parts.
308
- const ac = new AbortController();
309
- try {
310
- await Promise.race([
311
- once(readable, 'readable', { signal: ac.signal }).catch(() => {
312
- /* aborted or end */
313
- }),
314
- once(readable, 'end', { signal: ac.signal }).catch(() => {}),
315
- once(readable, 'close', { signal: ac.signal }).catch(() => {}),
316
- ]);
317
- } finally {
318
- ac.abort();
319
- }
320
- continue;
321
- }
322
- let split = -1;
323
- for (let i = 0; i < chunk.length; i++) {
324
- const b = chunk[i];
325
- if (crlf === 0 && b === 0x0d) crlf = 1;
326
- else if (crlf === 1 && b === 0x0a) crlf = 2;
327
- else if (crlf === 2 && b === 0x0d) crlf = 3;
328
- else if (crlf === 3 && b === 0x0a) {
329
- split = i + 1;
330
- break;
331
- } else crlf = b === 0x0d ? 1 : 0;
332
- if (b === 0x0a) {
333
- if (++lfOnly === 2) {
334
- split = i + 1;
335
- break;
336
- }
337
- } else lfOnly = 0;
338
- }
339
- if (split < 0) {
340
- queuedLen += chunk.length;
341
- if (queuedLen > MAX_HEADER_BYTES)
342
- throw new Error(`multipart header exceeded ${MAX_HEADER_BYTES} bytes without terminator`);
343
- queued.push(chunk);
344
- continue;
345
- }
346
- const headerPart = chunk.subarray(0, split);
347
- const rest = chunk.subarray(split);
348
- if (queuedLen + headerPart.length > MAX_HEADER_BYTES)
349
- throw new Error(`multipart header exceeded ${MAX_HEADER_BYTES} bytes`);
350
- queued.push(headerPart);
351
- if (rest.length) readable.unshift(rest);
352
- return queued.length === 1 ? queued[0] : Buffer.concat(queued);
353
- }
354
- }