scrypted-kasa 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,49 +0,0 @@
1
- // XOR-AB autokey cipher used by all Kasa "smarthome" endpoints (UDP/9999 discovery,
2
- // the LINKIE2 control protocol). Initial key 0xAB; each output byte becomes the next key,
3
- // so encrypt and decrypt only differ in which side feeds the key forward (the cipher byte
4
- // in both cases — the same primitive, just different direction).
5
-
6
- export function xorEncrypt(plaintext: Buffer): Buffer {
7
- const out = Buffer.allocUnsafe(plaintext.length);
8
- let key = 0xab;
9
- for (let i = 0; i < plaintext.length; i++) {
10
- const c = key ^ plaintext[i];
11
- out[i] = c;
12
- key = c;
13
- }
14
- return out;
15
- }
16
-
17
- export function xorDecrypt(ciphertext: Buffer): Buffer {
18
- const out = Buffer.allocUnsafe(ciphertext.length);
19
- let key = 0xab;
20
- for (let i = 0; i < ciphertext.length; i++) {
21
- const c = ciphertext[i];
22
- out[i] = key ^ c;
23
- key = c;
24
- }
25
- return out;
26
- }
27
-
28
- // In-place variants — overwrite the input buffer instead of allocating a fresh one. Use
29
- // when the caller owns the input (e.g., a freshly-allocated Buffer.from(...) it's about
30
- // to throw away). Saves an allocation + copy on every encrypt/decrypt pair.
31
- export function xorEncryptInPlace(plaintext: Buffer): Buffer {
32
- let key = 0xab;
33
- for (let i = 0; i < plaintext.length; i++) {
34
- const c = key ^ plaintext[i];
35
- plaintext[i] = c;
36
- key = c;
37
- }
38
- return plaintext;
39
- }
40
-
41
- export function xorDecryptInPlace(ciphertext: Buffer): Buffer {
42
- let key = 0xab;
43
- for (let i = 0; i < ciphertext.length; i++) {
44
- const c = ciphertext[i];
45
- ciphertext[i] = key ^ c;
46
- key = c;
47
- }
48
- return ciphertext;
49
- }
@@ -1,232 +0,0 @@
1
- import dgram from 'dgram';
2
- import { networkInterfaces } from 'os';
3
- import { xorDecryptInPlace, xorEncryptInPlace } from './cipher';
4
-
5
- export const KASA_DISCOVERY_PORT = 9999;
6
- const KASA_DISCOVERY_PROBE = '{"system":{"get_sysinfo":{}}}';
7
-
8
- // Encrypted probe payload built once at module load and reused across every discovery
9
- // sweep. dgram.send only reads from the buffer (never mutates), so sharing is safe.
10
- const KASA_DISCOVERY_PROBE_BUFFER = xorEncryptInPlace(Buffer.from(KASA_DISCOVERY_PROBE, 'utf8'));
11
- // Inbound UDP datagrams are owned by us (dgram allocates a fresh buffer per packet) and
12
- // not retained anywhere after the message handler returns, so in-place is safe.
13
- const xorDecryptToString = (b: Buffer) => xorDecryptInPlace(b).toString('utf8');
14
-
15
- export interface KasaSysInfo {
16
- deviceId?: string;
17
- alias?: string;
18
- model?: string;
19
- mac?: string;
20
- mic_mac?: string;
21
- type?: string;
22
- mic_type?: string;
23
- sw_ver?: string;
24
- hw_ver?: string;
25
- [key: string]: unknown;
26
- }
27
-
28
- export interface KasaDiscoveredDevice {
29
- address: string;
30
- deviceId: string;
31
- alias: string;
32
- model: string;
33
- mac: string;
34
- type: string;
35
- sysinfo: KasaSysInfo;
36
- }
37
-
38
- // Compute IPv4 broadcast addresses of every non-loopback interface so the probe reaches every
39
- // LAN even on hosts with multiple NICs. Falls back to the global 255.255.255.255 always.
40
- function broadcastAddresses(): string[] {
41
- const addrs = new Set<string>(['255.255.255.255']);
42
- const ifaces = networkInterfaces();
43
- for (const name of Object.keys(ifaces)) {
44
- for (const info of ifaces[name] || []) {
45
- if (info.family !== 'IPv4' || info.internal) continue;
46
- const ip = info.address.split('.').map(Number);
47
- const mask = info.netmask.split('.').map(Number);
48
- if (ip.length !== 4 || mask.length !== 4) continue;
49
- const broadcast = ip.map((b, i) => (b & mask[i]) | (~mask[i] & 0xff));
50
- addrs.add(broadcast.join('.'));
51
- }
52
- }
53
- return [...addrs];
54
- }
55
-
56
- // All host IPs in each local /24 we're attached to (excluding our own). Used for paced UDP
57
- // unicast sweeps when broadcast misses devices. Networks that aren't exactly /24 are skipped
58
- // — sweeping a /23 or larger would flood, and treating a /25 or smaller as /24 would send
59
- // probes outside the actual subnet (wasted traffic + neighbor confusion).
60
- function* localSubnetIps(): Generator<string> {
61
- // De-dupe across interfaces — multi-homed hosts (Wi-Fi + Ethernet on the same /24,
62
- // VPN tunnels, etc.) would otherwise yield the same address twice and burn ~750 ms
63
- // resending probes that have already been sent. Generator avoids materializing the full
64
- // list (254 IPs per /24) when the caller pauses 3 ms between sends.
65
- const seen = new Set<string>();
66
- const ifaces = networkInterfaces();
67
- for (const name of Object.keys(ifaces)) {
68
- for (const info of ifaces[name] || []) {
69
- if (info.family !== 'IPv4' || info.internal) continue;
70
- const mask = info.netmask.split('.').map(Number);
71
- // Require exact /24 (255.255.255.0). The mask[3] === 0 check rejects /25 etc.
72
- if (mask.length !== 4 || mask[0] !== 255 || mask[1] !== 255 || mask[2] !== 255 || mask[3] !== 0) continue;
73
- const ip = info.address.split('.').map(Number);
74
- if (ip.length !== 4) continue;
75
- const prefix = `${ip[0]}.${ip[1]}.${ip[2]}.`;
76
- for (let host = 1; host < 255; host++) {
77
- if (host === ip[3]) continue;
78
- const addr = prefix + host;
79
- if (seen.has(addr)) continue;
80
- seen.add(addr);
81
- yield addr;
82
- }
83
- }
84
- }
85
- }
86
-
87
- // Best-effort extraction of sysinfo-like fields from a JSON response. Different Kasa device
88
- // families nest the same data under different keys (system.get_sysinfo for plugs, smartlife
89
- // .iot.IPCamera.* for cameras, etc.), so walk the tree and pick up whatever looks right.
90
- function extractSysInfo(json: any): KasaSysInfo | undefined {
91
- if (!json || typeof json !== 'object') return;
92
- const candidates: any[] = [];
93
- const walk = (node: any, depth: number) => {
94
- if (!node || typeof node !== 'object' || depth > 4) return;
95
- if (
96
- typeof node.model === 'string' ||
97
- typeof node.alias === 'string' ||
98
- typeof node.deviceId === 'string' ||
99
- typeof node.dev_id === 'string'
100
- )
101
- candidates.push(node);
102
- for (const v of Object.values(node)) walk(v, depth + 1);
103
- };
104
- walk(json, 0);
105
- if (!candidates.length) return;
106
- // Prefer the candidate with the most identifying fields populated.
107
- candidates.sort((a, b) => fieldCount(b) - fieldCount(a));
108
- const best = candidates[0];
109
- return {
110
- deviceId: best.deviceId || best.dev_id,
111
- alias: best.alias,
112
- model: best.model || best.hwId || best.hw_id,
113
- mac: best.mic_mac || best.mac,
114
- type: best.type || best.mic_type || best.deviceType,
115
- ...best,
116
- };
117
- }
118
-
119
- function fieldCount(o: any): number {
120
- let n = 0;
121
- if (o.model) n++;
122
- if (o.alias) n++;
123
- if (o.deviceId || o.dev_id) n++;
124
- if (o.mac || o.mic_mac) n++;
125
- if (o.type || o.mic_type) n++;
126
- return n;
127
- }
128
-
129
- // One-shot UDP discovery: send a broadcast, then a paced unicast probe at every IP on the
130
- // local /24, all on a single socket while listening for replies. Cameras that ignore
131
- // broadcast (newer KC420WS firmware does) still answer the unicast hit. Pacing the unicast
132
- // sends a few ms apart prevents the network/kernel from dropping the burst — sending 250
133
- // packets back-to-back was the reason the earlier UDP-only attempt missed cameras.
134
- export async function discoverKasa(durationMs: number = 2500, console?: Console): Promise<KasaDiscoveredDevice[]> {
135
- const found = new Map<string, KasaDiscoveredDevice>();
136
- const probe = KASA_DISCOVERY_PROBE_BUFFER;
137
- const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
138
-
139
- return new Promise((resolve, reject) => {
140
- let done = false;
141
- const finish = () => {
142
- if (done) return;
143
- done = true;
144
- try {
145
- socket.close();
146
- } catch {}
147
- resolve([...found.values()]);
148
- };
149
-
150
- socket.on('error', e => {
151
- if (done) return;
152
- done = true;
153
- try {
154
- socket.close();
155
- } catch {}
156
- reject(e);
157
- });
158
-
159
- socket.on('message', (msg, rinfo) => {
160
- try {
161
- const json = JSON.parse(xorDecryptToString(msg));
162
- const sys = extractSysInfo(json);
163
- if (!sys) return;
164
- const deviceId = sys.deviceId || sys.mic_mac || sys.mac;
165
- if (!deviceId) return;
166
- if (found.has(deviceId)) return;
167
- // Slim the retained sysinfo to fields actually consumed by adoption
168
- // (classifyKasa, isDimmer, bulbCapabilities, firmware tag). Full sysinfo
169
- // contains ~20 fields, most unused (oemId, latitude/longitude, c_opt, etc.).
170
- // Cached entries live up to 5 minutes per device, so dropping the rest noticeably
171
- // shrinks retained heap when many devices are on the LAN.
172
- const slim: KasaSysInfo = {
173
- deviceId: sys.deviceId,
174
- alias: sys.alias,
175
- model: sys.model,
176
- mac: sys.mac,
177
- mic_mac: sys.mic_mac,
178
- type: sys.type,
179
- mic_type: sys.mic_type,
180
- sw_ver: sys.sw_ver,
181
- hw_ver: sys.hw_ver,
182
- dev_name: (sys as any).dev_name,
183
- feature: (sys as any).feature,
184
- brightness: (sys as any).brightness,
185
- is_color: (sys as any).is_color,
186
- is_variable_color_temp: (sys as any).is_variable_color_temp,
187
- children: (sys as any).children,
188
- };
189
- found.set(deviceId, {
190
- address: rinfo.address,
191
- deviceId,
192
- alias: sys.alias || '',
193
- model: sys.model || '',
194
- mac: (sys.mic_mac || sys.mac || '').toString(),
195
- type: (sys.type || sys.mic_type || '').toString(),
196
- sysinfo: slim,
197
- });
198
- } catch (e) {
199
- console?.warn('kasa discovery: failed to parse reply from', rinfo.address, e);
200
- }
201
- });
202
-
203
- socket.bind(0, async () => {
204
- socket.setBroadcast(true);
205
- // Hard deadline starts now; unicast pacing happens during the same window.
206
- setTimeout(finish, durationMs);
207
-
208
- // socket.send throws synchronously (ERR_SOCKET_DGRAM_NOT_RUNNING) if the
209
- // deadline timer fires and closes the socket mid-sweep. Wrap both loops so
210
- // that race can't crash the discovery promise — we just stop sending.
211
- try {
212
- for (const addr of broadcastAddresses()) socket.send(probe, KASA_DISCOVERY_PORT, addr);
213
- } catch {
214
- return;
215
- }
216
-
217
- // Paced unicast — ~3 ms between sends keeps a /24 sweep under 1 s of network
218
- // traffic without flooding. Suppress per-target send errors (EHOSTDOWN spam from
219
- // dead IPs is uninteresting and would dominate the log).
220
- for (const addr of localSubnetIps()) {
221
- if (done) break;
222
- try {
223
- socket.send(probe, KASA_DISCOVERY_PORT, addr, () => {});
224
- } catch {
225
- // Socket was closed (deadline elapsed) — stop sending.
226
- break;
227
- }
228
- await new Promise(r => setTimeout(r, 3));
229
- }
230
- });
231
- });
232
- }
@@ -1,20 +0,0 @@
1
- // Shared formatting helpers for the per-device + plugin-level Readme tabs. Scrypted's
2
- // markdown renderer is plain CommonMark — it doesn't recognize GFM pipe tables, so we
3
- // render tabular data inside fenced code blocks with manual column padding.
4
-
5
- // Format a hex MAC (e.g. "F0090D49721F") with colon separators (e.g. "F0:09:0D:49:72:1F").
6
- // Returns the input unchanged if it isn't a 12-character hex string — Kasa devices
7
- // occasionally surface MACs in already-colon-formatted form, and we don't want to mangle
8
- // either case.
9
- export function formatKasaMac(mac: string | undefined): string {
10
- if (!mac) return '?';
11
- if (!/^[0-9A-Fa-f]{12}$/.test(mac)) return mac;
12
- return mac.match(/.{2}/g)!.join(':').toUpperCase();
13
- }
14
-
15
- // Render a list of [label, value] pairs as a left-padded plain-text block. Caller is
16
- // expected to wrap the output in triple-backtick fences so monospace alignment renders.
17
- export function renderKv(rows: [string, string][]): string {
18
- const labelWidth = Math.max(...rows.map(([k]) => k.length));
19
- return rows.map(([k, v]) => `${k.padEnd(labelWidth)} ${v}`).join('\n');
20
- }
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "rootDir": "src",
4
- "outDir": "build",
5
- "module": "Node16",
6
- "target": "ES2021",
7
- "resolveJsonModule": true,
8
- "moduleResolution": "Node16",
9
- "esModuleInterop": true,
10
- "sourceMap": true
11
- },
12
- "include": [
13
- "src/**/*"
14
- ]
15
- }