Skip to content

Commit d19bb54

Browse files
committed
refactor: extract shared utilities and split large functions
Extract duplicated httpPost, cookie parsers, AuthError, delay, and modelSupportsEmeter into shared modules. Split probeCandidate and platform constructor into focused helper methods.
1 parent 12b1a3f commit d19bb54

9 files changed

Lines changed: 318 additions & 412 deletions

File tree

src/TplinkSmarthomePlatform.ts

Lines changed: 113 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ export class TplinkSmarthomePlatform implements DynamicPlatformPlugin {
4949

5050
private readonly categories: Map<Categories, string> = new Map();
5151

52+
private readonly legacyClient: Client;
53+
54+
private readonly klapDiscovery: KlapDiscovery | undefined;
55+
5256
constructor(
5357
public readonly log: Logging,
5458
config: PlatformConfig,
@@ -90,6 +94,12 @@ export class TplinkSmarthomePlatform implements DynamicPlatformPlugin {
9094
this.categories.set(Categories.OUTLET, "OUTLET");
9195
this.categories.set(Categories.SWITCH, "SWITCH");
9296

97+
this.legacyClient = this.setupLegacyDiscovery();
98+
this.klapDiscovery = this.setupKlapDiscovery();
99+
this.setupLifecycleHandlers();
100+
}
101+
102+
private setupLegacyDiscovery(): Client {
93103
const tplinkApiLogger: Logging = Object.assign(() => {}, this.log, {
94104
prefix: `${this.log.prefix || PLATFORM_NAME}.API`,
95105
});
@@ -136,136 +146,145 @@ export class TplinkSmarthomePlatform implements DynamicPlatformPlugin {
136146
}
137147
});
138148

149+
return client;
150+
}
151+
152+
private setupKlapDiscovery(): KlapDiscovery | undefined {
139153
// KLAP/AES discovery for newer devices (port 80)
140154
// Only enabled when Kasa credentials are configured
141-
let klapDiscovery: KlapDiscovery | undefined;
142-
143-
if (this.config.kasaCredentials) {
155+
if (!this.config.kasaCredentials) {
144156
this.log.info(
145-
"Kasa credentials configured, enabling KLAP/AES discovery for newer devices",
157+
"No Kasa credentials configured. Newer devices using KLAP/AES protocol will not be discovered. " +
158+
"Set kasaUsername and kasaPassword in config to enable.",
146159
);
160+
return undefined;
161+
}
147162

148-
klapDiscovery = new KlapDiscovery({
149-
credentials: this.config.kasaCredentials,
150-
devices: this.config.discoveryOptions.devices,
151-
broadcast: this.config.discoveryOptions.broadcast,
152-
discoveryInterval: this.config.discoveryOptions.discoveryInterval,
153-
timeout: this.config.defaultSendOptions.timeout,
154-
});
163+
this.log.info(
164+
"Kasa credentials configured, enabling KLAP/AES discovery for newer devices",
165+
);
155166

156-
klapDiscovery.on("device-new", (device: KlapPlug | KlapBulb) => {
157-
this.log.info(
158-
`[KLAP] Device First Online: ${chalk.blue(`[${device.alias}]`)} %s [%s]`,
159-
device.deviceType,
160-
device.id,
161-
device.host,
162-
device.port,
163-
);
164-
this.foundDevice(device);
165-
});
167+
const klapDiscovery = new KlapDiscovery({
168+
credentials: this.config.kasaCredentials,
169+
devices: this.config.discoveryOptions.devices,
170+
broadcast: this.config.discoveryOptions.broadcast,
171+
discoveryInterval: this.config.discoveryOptions.discoveryInterval,
172+
timeout: this.config.defaultSendOptions.timeout,
173+
});
174+
175+
klapDiscovery.on("device-new", (device: KlapPlug | KlapBulb) => {
176+
this.log.info(
177+
`[KLAP] Device First Online: ${chalk.blue(`[${device.alias}]`)} %s [%s]`,
178+
device.deviceType,
179+
device.id,
180+
device.host,
181+
device.port,
182+
);
183+
this.foundDevice(device);
184+
});
166185

167-
klapDiscovery.on("device-online", (device: KlapPlug | KlapBulb) => {
186+
klapDiscovery.on("device-online", (device: KlapPlug | KlapBulb) => {
187+
this.log.debug(
188+
`[KLAP] Device Online: ${chalk.blue(`[${device.alias}]`)} %s [%s]`,
189+
device.deviceType,
190+
device.id,
191+
device.host,
192+
device.port,
193+
);
194+
this.foundDevice(device);
195+
});
196+
197+
klapDiscovery.on("device-offline", (device: KlapPlug | KlapBulb) => {
198+
const deviceAccessory = this.homekitDevicesById.get(device.id);
199+
if (deviceAccessory !== undefined) {
168200
this.log.debug(
169-
`[KLAP] Device Online: ${chalk.blue(`[${device.alias}]`)} %s [%s]`,
201+
`[KLAP] Device Offline: ${chalk.blue(`[${device.alias}]`)} %s [%s]`,
202+
deviceAccessory.homebridgeAccessory.displayName,
170203
device.deviceType,
171204
device.id,
172205
device.host,
173206
device.port,
174207
);
175-
this.foundDevice(device);
176-
});
208+
}
209+
});
177210

178-
klapDiscovery.on("device-offline", (device: KlapPlug | KlapBulb) => {
179-
const deviceAccessory = this.homekitDevicesById.get(device.id);
180-
if (deviceAccessory !== undefined) {
181-
this.log.debug(
182-
`[KLAP] Device Offline: ${chalk.blue(`[${device.alias}]`)} %s [%s]`,
183-
deviceAccessory.homebridgeAccessory.displayName,
184-
device.deviceType,
185-
device.id,
186-
device.host,
187-
device.port,
188-
);
189-
}
190-
});
211+
klapDiscovery.on("error", (err: Error) => {
212+
this.log.error("[KLAP] Discovery error: %s", err.message);
213+
this.log.debug("[KLAP] %O", err);
214+
});
191215

192-
klapDiscovery.on("error", (err: Error) => {
193-
this.log.error("[KLAP] Discovery error: %s", err.message);
194-
this.log.debug("[KLAP] %O", err);
195-
});
196-
} else {
197-
this.log.info(
198-
"No Kasa credentials configured. Newer devices using KLAP/AES protocol will not be discovered. " +
199-
"Set kasaUsername and kasaPassword in config to enable.",
200-
);
201-
}
216+
return klapDiscovery;
217+
}
202218

219+
private setupLifecycleHandlers(): void {
203220
this.api.on(APIEvent.DID_FINISH_LAUNCHING, () => {
204221
this.log.debug(APIEvent.DID_FINISH_LAUNCHING);
205222

206-
client.startDiscovery({
223+
this.legacyClient.startDiscovery({
207224
...this.config.discoveryOptions,
208225
filterCallback: (si: Sysinfo) => {
209226
return si.deviceId != null && si.deviceId.length > 0;
210227
},
211228
});
212229

213230
// Start KLAP/AES discovery alongside legacy (if credentials configured)
214-
if (klapDiscovery) {
215-
klapDiscovery.start();
231+
if (this.klapDiscovery) {
232+
this.klapDiscovery.start();
216233
}
217234

218-
const refreshEmeterForAccessories = async (accessories: HomekitDevice[]) => {
219-
for (const acc of accessories) {
220-
const device = acc.tplinkDevice;
221-
if (device.supportsEmeter) {
222-
this.log.debug(`getEmeterRealtime ${chalk.blue(`[${device.alias}]`)}`);
223-
await device.emeter.getRealtime().catch((reason) => {
224-
this.log.error("[%s] %s", device.alias, "emeter.getRealtime()");
225-
this.log.error(reason);
226-
});
227-
}
228-
}
229-
};
230-
231-
const refreshEmeter = async () => {
232-
this.log.debug(`${chalk.magenta("refreshEmeter()")}`);
233-
if (this.config.emeterPollingInterval <= 0) return;
234-
235-
try {
236-
const deviceAccessories = this.deviceAccessoriesByHost;
237-
const promises: Promise<unknown>[] = [];
238-
239-
for (const accForHost of deviceAccessories.values()) {
240-
promises.push(refreshEmeterForAccessories(accForHost));
241-
}
242-
await Promise.all(promises);
243-
} catch (err) {
244-
this.log.error(`Error in ${chalk.magenta("refreshEmeter()")}:`);
245-
this.log.error(String(err));
246-
} finally {
247-
this.log.debug(
248-
`Scheduling next run of ${chalk.magenta("refreshEmeter()")} in %d(ms)`,
249-
this.config.emeterPollingInterval,
250-
);
251-
setTimeout(() => {
252-
refreshEmeter();
253-
}, this.config.emeterPollingInterval);
254-
}
255-
};
256-
257-
if (this.config.emeterPollingInterval > 0) refreshEmeter();
235+
if (this.config.emeterPollingInterval > 0) this.refreshEmeter();
258236
});
259237

260238
this.api.on("shutdown", () => {
261239
this.log.debug("shutdown");
262-
client.stopDiscovery();
263-
if (klapDiscovery) {
264-
klapDiscovery.stop();
240+
this.legacyClient.stopDiscovery();
241+
if (this.klapDiscovery) {
242+
this.klapDiscovery.stop();
265243
}
266244
});
267245
}
268246

247+
private async refreshEmeter(): Promise<void> {
248+
this.log.debug(`${chalk.magenta("refreshEmeter()")}`);
249+
if (this.config.emeterPollingInterval <= 0) return;
250+
251+
try {
252+
const deviceAccessories = this.deviceAccessoriesByHost;
253+
const promises: Promise<unknown>[] = [];
254+
255+
for (const accForHost of deviceAccessories.values()) {
256+
promises.push(this.refreshEmeterForAccessories(accForHost));
257+
}
258+
await Promise.all(promises);
259+
} catch (err) {
260+
this.log.error(`Error in ${chalk.magenta("refreshEmeter()")}:`);
261+
this.log.error(String(err));
262+
} finally {
263+
this.log.debug(
264+
`Scheduling next run of ${chalk.magenta("refreshEmeter()")} in %d(ms)`,
265+
this.config.emeterPollingInterval,
266+
);
267+
setTimeout(() => {
268+
this.refreshEmeter();
269+
}, this.config.emeterPollingInterval);
270+
}
271+
}
272+
273+
private async refreshEmeterForAccessories(
274+
accessories: HomekitDevice[],
275+
): Promise<void> {
276+
for (const acc of accessories) {
277+
const device = acc.tplinkDevice;
278+
if (device.supportsEmeter) {
279+
this.log.debug(`getEmeterRealtime ${chalk.blue(`[${device.alias}]`)}`);
280+
await device.emeter.getRealtime().catch((reason) => {
281+
this.log.error("[%s] %s", device.alias, "emeter.getRealtime()");
282+
this.log.error(reason);
283+
});
284+
}
285+
}
286+
}
287+
269288
/**
270289
* Return string representation of Service/Characteristic for logging
271290
*

src/klap/AesTransport.ts

Lines changed: 6 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,23 @@
1-
import * as http from "node:http";
21
import {
32
aesDecrypt,
43
aesEncrypt,
54
decryptAesSessionKey,
65
generateAesKeyPair,
76
generateAesLoginHash,
87
} from "./crypto.js";
8+
import { httpPost } from "./http.js";
9+
import {
10+
AuthError,
11+
parseSessionCookie,
12+
parseTimeoutCookie,
13+
} from "./transport-utils.js";
914
import type { AesSessionState, KasaCredentials } from "./types.js";
1015

1116
const DEFAULT_TIMEOUT = 10_000;
1217
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (conservative)
1318

1419
const DEFAULT_CREDENTIALS: KasaCredentials = { username: "", password: "" };
1520

16-
interface HttpResponse {
17-
statusCode: number;
18-
headers: http.IncomingHttpHeaders;
19-
body: Buffer;
20-
}
21-
22-
function httpPost(
23-
url: string,
24-
body: Buffer | string,
25-
headers: Record<string, string>,
26-
timeoutMs: number,
27-
): Promise<HttpResponse> {
28-
return new Promise((resolve, reject) => {
29-
const parsed = new URL(url);
30-
const reqBody = typeof body === "string" ? Buffer.from(body, "utf-8") : body;
31-
32-
const req = http.request(
33-
{
34-
hostname: parsed.hostname,
35-
port: parsed.port || 80,
36-
path: parsed.pathname + parsed.search,
37-
method: "POST",
38-
headers: {
39-
...headers,
40-
"Content-Length": String(reqBody.length),
41-
},
42-
timeout: timeoutMs,
43-
},
44-
(res) => {
45-
const chunks: Buffer[] = [];
46-
res.on("data", (chunk: Buffer) => chunks.push(chunk));
47-
res.on("end", () => {
48-
resolve({
49-
statusCode: res.statusCode ?? 0,
50-
headers: res.headers,
51-
body: Buffer.concat(chunks),
52-
});
53-
});
54-
res.on("error", reject);
55-
},
56-
);
57-
58-
req.on("error", reject);
59-
req.on("timeout", () => {
60-
req.destroy(new Error(`HTTP request timed out after ${timeoutMs}ms`));
61-
});
62-
63-
req.write(reqBody);
64-
req.end();
65-
});
66-
}
67-
68-
/**
69-
* Parse TP_SESSIONID from Set-Cookie header(s).
70-
*
71-
* The device may return one or more Set-Cookie values. We look for
72-
* TP_SESSIONID or SESSIONID and return the raw cookie string suitable
73-
* for sending back in a Cookie header.
74-
*/
75-
function parseSessionCookie(headers: http.IncomingHttpHeaders): string | undefined {
76-
const raw = headers["set-cookie"];
77-
if (!raw) return undefined;
78-
79-
const cookies = Array.isArray(raw) ? raw : [raw];
80-
for (const cookie of cookies) {
81-
const match = cookie.match(/(?:TP_SESSIONID|SESSIONID)=([^;]+)/i);
82-
if (match) {
83-
return `TP_SESSIONID=${match[1]}`;
84-
}
85-
}
86-
return undefined;
87-
}
88-
89-
/**
90-
* Parse the TIMEOUT value from Set-Cookie headers (seconds).
91-
*/
92-
function parseTimeoutCookie(headers: http.IncomingHttpHeaders): number | undefined {
93-
const raw = headers["set-cookie"];
94-
if (!raw) return undefined;
95-
96-
const cookies = Array.isArray(raw) ? raw : [raw];
97-
for (const cookie of cookies) {
98-
const match = cookie.match(/TIMEOUT=(\d+)/i);
99-
if (match) {
100-
return parseInt(match[1], 10);
101-
}
102-
}
103-
return undefined;
104-
}
105-
106-
class AuthError extends Error {
107-
constructor(message: string) {
108-
super(message);
109-
this.name = "AuthError";
110-
}
111-
}
112-
11321
const AES_COMMON_HEADERS: Record<string, string> = {
11422
"Content-Type": "application/json",
11523
requestByApp: "true",

0 commit comments

Comments
 (0)