homebridge-winix-purifiers 2.2.4 → 2.2.6

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,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CharacteristicWrapper = exports.CharacteristicManager = void 0;
4
+ const winix_api_1 = require("winix-api");
4
5
  const errors_1 = require("./errors");
5
6
  /**
6
7
  * Manager to provide a consistent way to get and set characteristics
@@ -62,7 +63,15 @@ class CharacteristicWrapper {
62
63
  }
63
64
  catch (e) {
64
65
  (0, errors_1.assertError)(e);
65
- this.log.error('error calling Winix API:', e.message);
66
+ // Known transient upstream conditions (rate limit, 5xx, DNS, malformed body)
67
+ // still fail the HAP call so HomeKit knows the action didn't land, but log at
68
+ // warn without the implication of a plugin bug.
69
+ if (e instanceof winix_api_1.RateLimitError || e instanceof winix_api_1.UpstreamUnavailableError) {
70
+ this.log.warn(`Winix API transient: ${e.constructor.name}: ${e.message}`);
71
+ }
72
+ else {
73
+ this.log.error('error calling Winix API:', e.message);
74
+ }
66
75
  throw new this.platform.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
67
76
  }
68
77
  }
package/dist/device.js CHANGED
@@ -5,6 +5,9 @@ const winix_api_1 = require("winix-api");
5
5
  const MAX_BACKOFF_MS = 5 * 60 * 1000;
6
6
  const COMMAND_DELAY_MS = 1500;
7
7
  const UNREACHABLE_THRESHOLD = 3;
8
+ // 90s clears WinixClient's internal 60s rate-limit cooldown plus a small buffer,
9
+ // so the first poll after a rate-limited initialFetch actually has a chance to succeed.
10
+ const FAILED_FETCH_RETRY_MS = 90 * 1000;
8
11
  class Device {
9
12
  constructor(deviceId, pollIntervalMs, log, client) {
10
13
  this.deviceId = deviceId;
@@ -41,8 +44,8 @@ class Device {
41
44
  this.log.debug('device:initialFetch()', JSON.stringify(this.state));
42
45
  }
43
46
  catch (e) {
44
- if (e instanceof winix_api_1.RateLimitError) {
45
- this.log.warn('device:initialFetch() rate limited, using defaults');
47
+ if (e instanceof winix_api_1.RateLimitError || e instanceof winix_api_1.UpstreamUnavailableError) {
48
+ this.log.warn(`device:initialFetch() ${e.constructor.name}, using defaults (will retry soon)`);
46
49
  return;
47
50
  }
48
51
  this.log.warn('device:initialFetch() failed, using defaults:', e.message);
@@ -50,10 +53,14 @@ class Device {
50
53
  }
51
54
  startPolling(onUpdate) {
52
55
  this.onUpdate = onUpdate;
53
- // Stagger the first poll with a random delay to avoid all devices
54
- // hitting the API at the same time
55
- const jitter = Math.floor(Math.random() * this.pollIntervalMs);
56
- this.schedulePoll(jitter);
56
+ // If initialFetch failed (rate limited, upstream unavailable, etc.), the device
57
+ // is unreachable in HomeKit until the first successful poll. Skip the full-interval
58
+ // jitter and retry soon to close that window. Otherwise stagger the first poll
59
+ // with a random delay to avoid all devices hitting the API at the same time.
60
+ const delay = this.hasReceivedData
61
+ ? Math.floor(Math.random() * this.pollIntervalMs)
62
+ : Math.min(FAILED_FETCH_RETRY_MS, this.pollIntervalMs);
63
+ this.schedulePoll(delay);
57
64
  }
58
65
  resetPollTimer(delayMs = 3000) {
59
66
  this.schedulePoll(delayMs);
@@ -168,9 +175,18 @@ class Device {
168
175
  this.schedulePoll(this.pollIntervalMs);
169
176
  return;
170
177
  }
178
+ // Transient upstream conditions (Winix "no data", 5xx, DNS/connect failures,
179
+ // malformed bodies). Keep last-known state and don't count toward unreachable.
180
+ if (e instanceof winix_api_1.NoDataError || e instanceof winix_api_1.UpstreamUnavailableError) {
181
+ this.log.debug(`device:poll() ${e.constructor.name}, retaining last-known state`);
182
+ this.schedulePoll(this.pollIntervalMs);
183
+ return;
184
+ }
171
185
  this.consecutiveFailures++;
172
186
  const backoffMs = Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveFailures), MAX_BACKOFF_MS);
173
- this.log.error(`device:poll() error: ${e.message} (retry in ${Math.round(backoffMs / 1000)}s)`);
187
+ const err = e;
188
+ this.log.error(`device:poll() error: ${err.constructor?.name ?? 'Error'}: ${err.message} ` +
189
+ `(retry in ${Math.round(backoffMs / 1000)}s)`);
174
190
  this.schedulePoll(backoffMs);
175
191
  return;
176
192
  }
package/dist/winix.js CHANGED
@@ -10,6 +10,7 @@ const promises_1 = require("node:fs/promises");
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const TOKEN_DIRECTORY_NAME = 'winix-purifiers';
12
12
  const TOKEN_FILE_NAME = 'token.json';
13
+ const MIN_RELOGIN_INTERVAL_MS = 5 * 60 * 1000;
13
14
  class NotConfiguredError extends Error {
14
15
  constructor() {
15
16
  super('Account not configured');
@@ -25,6 +26,7 @@ exports.UnauthenticatedError = UnauthenticatedError;
25
26
  class WinixHandler {
26
27
  constructor(storagePath, encryptionKey) {
27
28
  this.encryptionKey = encryptionKey;
29
+ this.lastReloginAt = 0;
28
30
  this.refreshTokenPath = node_path_1.default.join(storagePath, TOKEN_DIRECTORY_NAME, TOKEN_FILE_NAME);
29
31
  }
30
32
  /**
@@ -110,10 +112,19 @@ class WinixHandler {
110
112
  devices = await this.winix.getDevices();
111
113
  }
112
114
  catch (e) {
113
- if (!(e instanceof winix_api_1.RefreshTokenExpiredError)) {
115
+ if (e instanceof winix_api_1.RefreshTokenExpiredError) {
116
+ await this.login(this.auth.username, this.auth.password);
117
+ return await this.winix.getDevices();
118
+ }
119
+ if (!(e instanceof winix_api_1.MobileSessionInvalidError)) {
120
+ throw e;
121
+ }
122
+ // Mobile session invalidated (MULTI LOGIN / "user is not valid"). Re-login
123
+ // is throttled to prevent ping-pong with another active session.
124
+ if (Date.now() - this.lastReloginAt < MIN_RELOGIN_INTERVAL_MS) {
114
125
  throw e;
115
126
  }
116
- // if we get a refresh token expiry, we need to re-login and get devices again
127
+ this.lastReloginAt = Date.now();
117
128
  await this.login(this.auth.username, this.auth.password);
118
129
  return await this.winix.getDevices();
119
130
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Winix Air Purifiers",
3
3
  "name": "homebridge-winix-purifiers",
4
- "version": "2.2.4",
4
+ "version": "2.2.6",
5
5
  "description": "Homebridge plugin for Winix air purifiers",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -46,6 +46,7 @@
46
46
  "test": "vitest run",
47
47
  "test:integration": "vitest run --config vitest.integration.config.ts tests/integration/integration.test.ts --reporter=verbose",
48
48
  "test:integration:rate-limit": "vitest run --config vitest.integration.config.ts tests/integration/integration-rate-limit.test.ts --reporter=verbose",
49
+ "test:integration:session-recovery": "node --env-file-if-exists=.env ./node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts tests/integration/integration-session-recovery.test.ts --reporter=verbose",
49
50
  "test:watch": "vitest",
50
51
  "test:coverage": "vitest run --coverage",
51
52
  "validate": "yarn lint && yarn build && yarn test",
@@ -65,7 +66,7 @@
65
66
  ],
66
67
  "dependencies": {
67
68
  "@homebridge/plugin-ui-utils": "1.0.3",
68
- "winix-api": "2.0.0"
69
+ "winix-api": "2.0.2"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@types/node": "20.11.0",