Skip to content

Commit ca94e5d

Browse files
committed
Refactor platform internals, improve robustness and device lifecycle
- Centralize Govee API URLs and headers in govee-api.ts, removing duplication across HTTP client and UI server - Replace node-rsa/pem dependencies with openssl execSync for PFX extraction, reducing native module footprint - Add createDebouncedGuard utility and replace manual debounce pattern across light, heater2, fan-light, and humidifier device handlers - Add destroy() lifecycle method to GoveeDeviceBase; implement in cooler, heater, and valve devices to clean up timers on shutdown - Move module-level global state (devicesInHB, awsDevices, etc.) to platform instance properties - Replace global process error handlers for BLE with Noble error event - Add retry limits to HTTP login/getDevices to prevent infinite retries - Add external update debounce guards to outlet, purifier, tap, tv, and valve devices - Add debug logging config option, lower HTTP refresh minimum to 30s - Fix settings.ts plugin name, add LAN message validation, safe JSON parsing for deviceSettings - Improve AWS sync with re-entrancy guard - Harden UI server with Array.isArray checks for device responses
1 parent b02c505 commit ca94e5d

28 files changed

Lines changed: 358 additions & 309 deletions

config.schema.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,17 @@
3939
"default": false,
4040
"description": "If enabled, device state changes will not be logged."
4141
},
42+
"debug": {
43+
"title": "Debug Logging",
44+
"type": "boolean",
45+
"default": false,
46+
"description": "If enabled, verbose debug logging will be output to the Homebridge log."
47+
},
4248
"httpRefreshTime": {
4349
"title": "HTTP Refresh Time (seconds)",
4450
"type": "integer",
45-
"minimum": 60,
46-
"default": 60,
51+
"minimum": 30,
52+
"default": 30,
4753
"description": "How often to poll the Govee API for device updates."
4854
},
4955
"awsDisable": {
@@ -673,7 +679,8 @@
673679
"items": [
674680
"ignoreMatter",
675681
"disableDeviceLogging",
676-
"colourSafeMode"
682+
"colourSafeMode",
683+
"debug"
677684
]
678685
},
679686
{

homebridge-ui/server.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ class GoveeUiServer extends HomebridgePluginUiServer {
5151
const devices = await goveeGetDevices(loginResult.token, loginResult.clientId);
5252

5353
return {
54-
devices: devices.map(device => ({
54+
devices: Array.isArray(devices) ? devices.map(device => ({
5555
deviceId: device.device,
5656
deviceName: device.deviceName,
5757
model: device.sku,
58-
})),
58+
})) : [],
5959
};
6060
} catch (err) {
6161
const message = err.response?.data?.message || err.message || 'Discovery failed';
@@ -100,8 +100,8 @@ class GoveeUiServer extends HomebridgePluginUiServer {
100100
return { devices: [] };
101101
}
102102

103-
const devices = JSON.parse(storedData);
104-
return { devices: devices || [] };
103+
const devices = typeof storedData === 'string' ? JSON.parse(storedData) : storedData;
104+
return { devices: Array.isArray(devices) ? devices : [] };
105105
} catch (err) {
106106
console.error('Failed to get cached devices:', err);
107107
return { devices: [] };

nodemon.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
],
55
"ext": "ts",
66
"ignore": [],
7-
"exec": "tsc && npx homebridge -U ./test/hbConfig -D -P .",
7+
"exec": "tsc && npx homebridge-config-ui-x run -U ./test/hbConfig -D",
88
"signal": "SIGTERM",
99
"env": {
1010
"NODE_OPTIONS": "--trace-warnings"
1111
}
12-
}
12+
}

package.json

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,7 @@
5353
"axios": "^1.13.2",
5454
"fakegato-history": "^0.6.7",
5555
"node-persist": "^4.0.4",
56-
"node-rsa": "^1.1.1",
57-
"p-queue": "^9.0.1",
58-
"pem": "^1.14.8"
56+
"p-queue": "^9.0.1"
5957
},
6058
"optionalDependencies": {
6159
"@stoprocent/bluetooth-hci-socket": "^2.2.3",
@@ -66,8 +64,6 @@
6664
"@types/aws-iot-device-sdk": "^2.2.8",
6765
"@types/node": "^24.10.1",
6866
"@types/node-persist": "^3.1.8",
69-
"@types/node-rsa": "^1.1.4",
70-
"@types/pem": "^1.14.4",
7167
"eslint": "^9.39.1",
7268
"homebridge": "^2.0.0-beta.55",
7369
"homebridge-config-ui-x": "^4.6.7",

src/connection/ble.ts

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,6 @@ import { isValidPeripheral } from '../utils/validation.js';
99

1010
process.env.NOBLE_REPORT_ALL_HCI_EVENTS = '1';
1111

12-
process.on('uncaughtException', (err) => {
13-
if (err.message && err.message.includes('BLEManager')) {
14-
console.error('[BLE] native ble crash detected:', err.message);
15-
console.error('[BLE] this is a known issue with Noble on some macos systems, ble functionality may be limited.');
16-
} else {
17-
throw err;
18-
}
19-
});
20-
21-
process.on('unhandledRejection', (reason) => {
22-
if (reason && reason.toString().includes('BLEManager')) {
23-
console.error('[BLE] unhandled ble rejection:', reason);
24-
console.error('[BLE] this is a known issue with Noble on some macos systems, ble functionality may be limited.');
25-
} else {
26-
throw reason;
27-
}
28-
});
29-
3012
const H5075_UUID = 'ec88';
3113
const H5101_UUID = '0001';
3214
const CONTROL_CHARACTERISTIC_UUID = '000102030405060708090a0b0c0d1910';
@@ -157,6 +139,18 @@ export default class BLEClient {
157139
this.btClient.on('scanStop', this.eventHandlers.scanStop);
158140
this.btClient.on('warning', this.eventHandlers.warning);
159141
this.btClient.on('discover', this.eventHandlers.discover);
142+
143+
// Handle Noble-specific errors without installing global process handlers
144+
this.btClient.on('error', (err: Error) => {
145+
if (this.isShuttingDown) {
146+
return;
147+
}
148+
if (err.message && err.message.includes('BLEManager')) {
149+
this.log.warn('[BLE] native ble error detected: %s. BLE functionality may be limited.', err.message);
150+
} else {
151+
this.log.warn('[BLE] adapter error: %s.', err.message);
152+
}
153+
});
160154
} catch (err) {
161155
this.log.warn('[BLE] failed to setup event listeners:', (err as Error).message);
162156
}

src/connection/http.ts

Lines changed: 34 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { Buffer } from 'node:buffer';
2-
31
import axios, { type AxiosError } from 'axios';
42

53
import type { GoveeHTTPDeviceInfo, GoveeLogging, GoveePluginConfig, HTTPLoginResult } from '../types.js';
64
import platformConsts from '../utils/constants.js';
75
import { parseError, sleep } from '../utils/functions.js';
6+
import { GOVEE_API_URLS, goveeHeaders } from '../utils/govee-api.js';
87
import platformLang from '../utils/lang-en.js';
98

109
interface HTTPPlatformRef {
@@ -21,16 +20,16 @@ interface HTTPPlatformRef {
2120
};
2221
}
2322

23+
const MAX_RETRIES = 3;
24+
const RETRY_DELAY_MS = 30000;
25+
2426
export default class HTTPClient {
2527
private log: GoveeLogging;
2628
private password: string;
2729
private token?: string;
2830
private tokenTTR?: string;
2931
private username: string;
30-
private appVersion: string;
31-
private userAgent: string;
3232
private clientId: string;
33-
private base64Tried = false;
3433

3534
constructor(platform: HTTPPlatformRef) {
3635
this.log = platform.log;
@@ -39,14 +38,15 @@ export default class HTTPClient {
3938
this.tokenTTR = platform.accountTokenTTR;
4039
this.username = platform.config.username || '';
4140

42-
this.appVersion = '5.6.01';
43-
this.userAgent = `GoveeHome/${this.appVersion} (com.ihoment.GoVeeSensor; build:2; iOS 16.5.0) Alamofire/5.6.4`;
44-
4541
let clientSuffix = platform.api.hap.uuid.generate(this.username).replace(/-/g, '');
4642
clientSuffix = clientSuffix.substring(0, clientSuffix.length - 2);
4743
this.clientId = `hb${clientSuffix}`;
4844
}
4945

46+
private headers(token?: string): Record<string, string | number> {
47+
return goveeHeaders(token || this.token!, this.clientId);
48+
}
49+
5050
/**
5151
* Set the token from cached credentials
5252
*/
@@ -57,12 +57,12 @@ export default class HTTPClient {
5757
}
5858
}
5959

60-
async login(): Promise<HTTPLoginResult> {
60+
async login(retryCount = 0): Promise<HTTPLoginResult> {
6161
try {
6262
this.log.debug('[HTTP] Attempting login for user: %s', this.username);
6363

6464
const res = await axios({
65-
url: 'https://app2.govee.com/account/rest/account/v1/login',
65+
url: GOVEE_API_URLS.login,
6666
method: 'post',
6767
data: {
6868
email: this.username,
@@ -81,26 +81,13 @@ export default class HTTPClient {
8181

8282
if (!res.data.client || !res.data.client.token) {
8383
this.log.debug('[HTTP] Login response missing client/token. Message: %s', res.data.message || 'none');
84-
if (res.data.message && res.data.message.replace(/\s+/g, '') === 'Incorrectpassword') {
85-
if (this.base64Tried) {
86-
throw new Error(res.data.message || platformLang.noToken);
87-
} else {
88-
this.log.debug('[HTTP] Trying base64 decoded password');
89-
this.base64Tried = true;
90-
this.password = Buffer.from(this.password, 'base64')
91-
.toString('utf8')
92-
.replace(/\r\n|\n|\r/g, '')
93-
.trim();
94-
return await this.login();
95-
}
96-
}
9784
throw new Error(res.data.message || platformLang.noToken);
9885
}
9986

10087
this.log.debug('[HTTP] Primary login successful, fetching TTR token...');
10188

10289
const ttrRes = await axios({
103-
url: 'https://community-api.govee.com/os/v1/login',
90+
url: GOVEE_API_URLS.loginTTR,
10491
method: 'post',
10592
data: {
10693
email: this.username,
@@ -117,17 +104,9 @@ export default class HTTPClient {
117104
this.log.debug('[HTTP] Fetching IoT credentials...');
118105

119106
const iotRes = await axios({
120-
url: 'https://app2.govee.com/app/v1/account/iot/key',
107+
url: GOVEE_API_URLS.iotKey,
121108
method: 'get',
122-
headers: {
123-
'Authorization': `Bearer ${this.token}`,
124-
'appVersion': this.appVersion,
125-
'clientId': this.clientId,
126-
'clientType': 1,
127-
'iotVersion': 0,
128-
'timestamp': Date.now(),
129-
'User-Agent': this.userAgent,
130-
},
109+
headers: this.headers(),
131110
});
132111

133112
this.log.debug('[HTTP] IoT credentials received. Endpoint: %s', iotRes.data.data.endpoint);
@@ -145,9 +124,13 @@ export default class HTTPClient {
145124
} catch (err) {
146125
const axiosErr = err as AxiosError;
147126
if (axiosErr.code && platformConsts.httpRetryCodes.includes(axiosErr.code)) {
148-
this.log.warn('[HTTP] %s [login() - %s].', platformLang.httpRetry, axiosErr.code);
149-
await sleep(30000);
150-
return this.login();
127+
if (retryCount >= MAX_RETRIES) {
128+
this.log.warn('[HTTP] login() failed after %d retries [%s].', MAX_RETRIES, axiosErr.code);
129+
throw err;
130+
}
131+
this.log.warn('[HTTP] %s [login() - %s] (attempt %d/%d).', platformLang.httpRetry, axiosErr.code, retryCount + 1, MAX_RETRIES);
132+
await sleep(RETRY_DELAY_MS);
133+
return this.login(retryCount + 1);
151134
}
152135
throw err;
153136
}
@@ -156,24 +139,16 @@ export default class HTTPClient {
156139
async logout(): Promise<void> {
157140
try {
158141
await axios({
159-
url: 'https://app2.govee.com/account/rest/account/v1/logout',
142+
url: GOVEE_API_URLS.logout,
160143
method: 'post',
161-
headers: {
162-
'Authorization': `Bearer ${this.token}`,
163-
'appVersion': this.appVersion,
164-
'clientId': this.clientId,
165-
'clientType': 1,
166-
'iotVersion': 0,
167-
'timestamp': Date.now(),
168-
'User-Agent': this.userAgent,
169-
},
144+
headers: this.headers(),
170145
});
171146
} catch (err) {
172147
this.log.warn('[HTTP] %s %s.', platformLang.logoutFail, parseError(err as Error));
173148
}
174149
}
175150

176-
async getDevices(isSync = true): Promise<GoveeHTTPDeviceInfo[]> {
151+
async getDevices(isSync = true, retryCount = 0): Promise<GoveeHTTPDeviceInfo[]> {
177152
try {
178153
if (!this.token) {
179154
this.log.debug('[HTTP] getDevices called but no token exists');
@@ -183,17 +158,9 @@ export default class HTTPClient {
183158
this.log.debug('[HTTP] Fetching device list...');
184159

185160
const res = await axios({
186-
url: 'https://app2.govee.com/device/rest/devices/v1/list',
161+
url: GOVEE_API_URLS.devices,
187162
method: 'post',
188-
headers: {
189-
'Authorization': `Bearer ${this.token}`,
190-
'appVersion': this.appVersion,
191-
'clientId': this.clientId,
192-
'clientType': 1,
193-
'iotVersion': 0,
194-
'timestamp': Date.now(),
195-
'User-Agent': this.userAgent,
196-
},
163+
headers: this.headers(),
197164
timeout: 30000,
198165
});
199166

@@ -217,54 +184,27 @@ export default class HTTPClient {
217184
} catch (err) {
218185
const axiosErr = err as AxiosError;
219186
if (!isSync && axiosErr.code && platformConsts.httpRetryCodes.includes(axiosErr.code)) {
220-
this.log.warn('[HTTP] %s [getDevices() - %s].', platformLang.httpRetry, axiosErr.code);
221-
await sleep(30000);
222-
return this.getDevices();
187+
if (retryCount >= MAX_RETRIES) {
188+
this.log.warn('[HTTP] getDevices() failed after %d retries [%s].', MAX_RETRIES, axiosErr.code);
189+
throw err;
190+
}
191+
this.log.warn('[HTTP] %s [getDevices() - %s] (attempt %d/%d).', platformLang.httpRetry, axiosErr.code, retryCount + 1, MAX_RETRIES);
192+
await sleep(RETRY_DELAY_MS);
193+
return this.getDevices(isSync, retryCount + 1);
223194
}
224195
throw err;
225196
}
226197
}
227198

228-
async getTapToRuns(): Promise<unknown[]> {
229-
const res = await axios({
230-
url: 'https://app2.govee.com/bff-app/v1/exec-plat/home',
231-
method: 'get',
232-
headers: {
233-
'Authorization': `Bearer ${this.tokenTTR}`,
234-
'appVersion': this.appVersion,
235-
'clientId': this.clientId,
236-
'clientType': 1,
237-
'iotVersion': 0,
238-
'timestamp': Date.now(),
239-
'User-Agent': this.userAgent,
240-
},
241-
timeout: 10000,
242-
});
243-
244-
if (!res?.data?.data?.components) {
245-
throw new Error('not a valid response');
246-
}
247-
248-
return res.data.data.components;
249-
}
250-
251199
async getLeakDeviceWarning(deviceId: string, deviceSku: string): Promise<unknown[]> {
252200
if (!this.token) {
253201
throw new Error(platformLang.noTokenExists);
254202
}
255203

256204
const res = await axios({
257-
url: 'https://app2.govee.com/leak/rest/device/v1/warnMessage',
205+
url: GOVEE_API_URLS.leakWarning,
258206
method: 'post',
259-
headers: {
260-
'Authorization': `Bearer ${this.token}`,
261-
'appVersion': this.appVersion,
262-
'clientId': this.clientId,
263-
'clientType': 1,
264-
'iotVersion': 0,
265-
'timestamp': Date.now(),
266-
'User-Agent': this.userAgent,
267-
},
207+
headers: this.headers(),
268208
data: {
269209
device: deviceId.replaceAll(':', ''),
270210
limit: 50,

0 commit comments

Comments
 (0)