homebridge-winix-purifiers 2.1.7 → 2.2.1

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.
package/README.md CHANGED
@@ -18,7 +18,6 @@
18
18
  * [Properties](#properties)
19
19
  * [Encrypting Your Password](#encrypting-your-password-for-manual-setup-and-hoobs-users)
20
20
  * [FAQ](#faq)
21
- * [Upgrading from the old plugin architecture (v1.x.x) to the new one (v2.x.x)?](#upgrading-from-the-old-plugin-architecture-v1xx-to-the-new-one-v2xx)
22
21
  * [Using HOOBS?](#using-hoobs)
23
22
  * [Missing “Auto/Manual” switch in Home app?](#missing-automanual-switch-in-home-app)
24
23
  * [Having issues moving your purifier to a room in the Home app with the same name?](#having-issues-moving-your-purifier-to-a-room-in-the-home-app-with-the-same-name)
@@ -32,7 +31,7 @@
32
31
  HomeKit.
33
32
  - **Filter Management**: Exposes the remaining filter life and provides an alert when it's time to change the filter,
34
33
  configurable to trigger at a specified percentage of remaining filter life.
35
- - **Efficiency**: Features Winix API response caching to minimize requests and avoid rate limiting.
34
+ - **Efficiency**: Polls the Winix API in the background on a configurable interval to minimize requests and keep state fresh.
36
35
  - **Reliability**: Automatically refreshes device list on a configurable interval to ensure devices are always
37
36
  up-to-date.
38
37
  - **Seamless Authentication**: Automatically refreshes your Winix authentication token in the background, so you no
@@ -79,7 +78,8 @@ Simply provide your Winix account credentials for automatic device discovery and
79
78
 
80
79
  <img src="./assets/link-account.gif" alt="Link Account" />
81
80
 
82
- ### Manual Configuration
81
+ <details>
82
+ <summary><h3>Manual Configuration</h3></summary>
83
83
 
84
84
  While not recommended, if manual setup is required, add the following to the `platforms` section of your `config.json`:
85
85
 
@@ -93,7 +93,7 @@ While not recommended, if manual setup is required, add the following to the `pl
93
93
  "exposeAutoSwitch": false,
94
94
  "exposeSleepSwitch": false,
95
95
  "filterReplacementIndicatorPercentage": 10,
96
- "cacheIntervalSeconds": 300,
96
+ "pollIntervalSeconds": 30,
97
97
  "deviceRefreshIntervalMinutes": 60,
98
98
  "auth": {
99
99
  "username": "your-email@domain.com",
@@ -130,7 +130,7 @@ While not recommended, if manual setup is required, add the following to the `pl
130
130
  | `exposeAutoSwitch` | `false` | Whether to expose switches for Auto mode on/off. |
131
131
  | `exposeSleepSwitch` | `false` | Whether to expose switches for Sleep mode on/off. |
132
132
  | `filterReplacementIndicatorPercentage` | `10` | Percentage of filter life remaining to trigger a filter replacement alert. |
133
- | `cacheIntervalSeconds` | `60` | Time, in seconds, for how long to reuse cached responses from Winix. |
133
+ | `pollIntervalSeconds` | `30` | Time, in seconds, for how often to poll the Winix API for device state updates. Minimum 15. |
134
134
  | `deviceRefreshIntervalMinutes` | `60` | Time, in minutes, for how often to poll Winix to refresh the device list. |
135
135
  | `auth.username` | `""` | Your Winix account username (email). This field is meant to be read-only in the UI. |
136
136
  | `auth.password` | `""` | Your Winix account password (encrypted). This field is meant to be read-only in the UI. See below for manual generation. |
@@ -145,7 +145,10 @@ While not recommended, if manual setup is required, add the following to the `pl
145
145
  | `deviceOverrides[].nameSleepSwitch` | `"Sleep"` | The display name of the Sleep switch. Optional. |
146
146
  | `platform` | `"WinixPurifiers"` | Must always be `"WinixPurifiers"` in order for the plugin to load this config. |
147
147
 
148
- ### Encrypting Your Password (For Manual Setup and HOOBS Users)
148
+ </details>
149
+
150
+ <details>
151
+ <summary><h3>Encrypting Your Password (For Manual Setup and HOOBS Users)</h3></summary>
149
152
 
150
153
  If you're configuring the plugin manually or using HOOBS, you need to store your Winix password securely in the
151
154
  configuration file. To do this, you must first encrypt your password using the provided `encrypt-password` script.
@@ -154,7 +157,7 @@ configuration file. To do this, you must first encrypt your password using the p
154
157
 
155
158
  1. **Clone the repository**:
156
159
 
157
- You’ll need to clone this plugin's repository locally to run the encryption script. Make sure you have **Node.js 20+**
160
+ You’ll need to clone this plugin's repository locally to run the encryption script. Make sure you have **Node.js 22+**
158
161
  installed on your machine.
159
162
 
160
163
  ```bash
@@ -190,16 +193,12 @@ The script will output your encrypted password. Copy the result and paste it int
190
193
 
191
194
  This ensures that your password is securely stored within the configuration file.
192
195
 
193
- ## FAQ
194
-
195
- ### Upgrading from the old plugin architecture (v1.x.x) to the new one (v2.x.x)?
196
+ </details>
196
197
 
197
- Unfortunately, there's no way to directly migrate from the old plugin architecture to the new one.
198
- Please follow the Migration Guide in the Wiki:
199
-
200
- [Migrating from v1.x.x to v2.x.x](https://github.com/regaw-leinad/homebridge-winix-purifiers/wiki/Migrating-from-v1.x.x-to-v2.x.x).
198
+ ## FAQ
201
199
 
202
- ### Using HOOBS?
200
+ <details>
201
+ <summary><h3>Using HOOBS?</h3></summary>
203
202
 
204
203
  If you're using [HOOBS](https://hoobs.org), you can install the plugin directly from the HOOBS interface. You will not
205
204
  be able to use the custom configuration UI, since it is not supported in HOOBS. You will need to use the manual
@@ -209,13 +208,7 @@ details on obtaining the required `auth` values. See
209
208
  [Encrypting Your Password](#encrypting-your-password-for-manual-setup-and-hoobs-users) for instructions on encrypting
210
209
  your Winix password.
211
210
 
212
- ### Auth Error?
213
-
214
- Getting `error generating winix account from existing auth: NotAuthorizedException: Refresh Token has expired`? Winix
215
- refresh tokens expire after 30 days. For now, you will need to generate a new refresh token by re-authenticating with
216
- Winix. Find the `Reauthenticate with Winix` button in the plugin config settings in the Homebridge UI, and sign in with
217
- your email and password. Work has started on a new feature to automate this process
218
- ([branch](https://github.com/regaw-leinad/homebridge-winix-purifiers/tree/password-auth)).
211
+ </details>
219
212
 
220
213
  ### Missing “Auto/Manual” switch in Home app?
221
214
 
@@ -46,12 +46,12 @@
46
46
  "maximum": 100,
47
47
  "required": true
48
48
  },
49
- "cacheIntervalSeconds": {
50
- "title": "Winix Response Cache Interval (seconds)",
51
- "description": "Time, in seconds, for how long to reuse cached responses from Winix",
49
+ "pollIntervalSeconds": {
50
+ "title": "Poll Interval (seconds)",
51
+ "description": "How often to poll the Winix API for device state updates",
52
52
  "type": "integer",
53
- "default": 60,
54
- "minimum": 0,
53
+ "default": 30,
54
+ "minimum": 15,
55
55
  "required": true
56
56
  },
57
57
  "deviceRefreshIntervalMinutes": {
@@ -166,7 +166,7 @@
166
166
  "exposeAutoSwitch",
167
167
  "exposeSleepSwitch",
168
168
  "filterReplacementIndicatorPercentage",
169
- "cacheIntervalSeconds",
169
+ "pollIntervalSeconds",
170
170
  "deviceRefreshIntervalMinutes"
171
171
  ]
172
172
  },
package/dist/accessory.js CHANGED
@@ -10,7 +10,8 @@ const device_1 = require("./device");
10
10
  */
11
11
  const MAX_FILTER_HOURS = 6480;
12
12
  const DEFAULT_FILTER_LIFE_REPLACEMENT_PERCENTAGE = 10;
13
- const DEFAULT_CACHE_INTERVAL_SECONDS = 60;
13
+ const DEFAULT_POLL_INTERVAL_SECONDS = 30;
14
+ const MIN_POLL_INTERVAL_SECONDS = 15;
14
15
  const MIN_AMBIENT_LIGHT = 0.0001;
15
16
  class WinixPurifierAccessory {
16
17
  constructor(platform, config, accessory, override, log) {
@@ -22,8 +23,9 @@ class WinixPurifierAccessory {
22
23
  this.ServiceType = this.platform.Service;
23
24
  this.Characteristic = this.platform.Characteristic;
24
25
  const { deviceId, deviceAlias } = accessory.context.device;
25
- const cacheIntervalMs = (config.cacheIntervalSeconds ?? DEFAULT_CACHE_INTERVAL_SECONDS) * 1000;
26
- this.device = new device_1.Device(deviceId, cacheIntervalMs, this.log);
26
+ const configuredSeconds = config.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS;
27
+ const pollIntervalMs = Math.max(configuredSeconds, MIN_POLL_INTERVAL_SECONDS) * 1000;
28
+ this.device = new device_1.Device(deviceId, pollIntervalMs, this.log);
27
29
  this.servicesInUse = new Set();
28
30
  const deviceSerial = override?.serialNumber ?? 'WNXAI00000000';
29
31
  const deviceName = override?.nameDevice ?? deviceAlias;
@@ -114,6 +116,11 @@ class WinixPurifierAccessory {
114
116
  }
115
117
  this.pruneUnusedServices();
116
118
  }
119
+ async initialize() {
120
+ await this.device.initialFetch();
121
+ this.sendHomekitUpdate();
122
+ this.device.startPolling(() => this.sendHomekitUpdate());
123
+ }
117
124
  /**
118
125
  * Prune any services that are no longer in use.
119
126
  * A service would be pruned if one is initially added,
@@ -128,67 +135,56 @@ class WinixPurifierAccessory {
128
135
  this.accessory.removeService(service);
129
136
  });
130
137
  }
131
- /**
132
- * Get the active state of the purifier.
133
- * This maps to the Power attribute of the Winix device.
134
- */
135
- async getActiveState() {
136
- const power = await this.device.getPower();
138
+ // Get handlers - all synchronous, return from in-memory state
139
+ // If the device has never been reachable, throw so HomeKit shows "No Response"
140
+ ensureReachable() {
141
+ if (!this.device.isReachable()) {
142
+ throw new this.platform.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
143
+ }
144
+ }
145
+ getActiveState() {
146
+ this.ensureReachable();
147
+ const power = this.device.getPower();
137
148
  this.log.debug('accessory:getActiveState()', power);
138
149
  return this.toActiveState(power);
139
150
  }
140
- /**
141
- * Set the active state of the purifier.
142
- * This maps to the Power attribute of the Winix device.
143
- */
144
151
  async setActiveState(state) {
145
152
  const power = state === this.Characteristic.Active.ACTIVE ? winix_api_1.Power.On : winix_api_1.Power.Off;
146
153
  this.log.debug(`accessory:setActiveState(${state})`, power);
147
154
  await this.device.setPower(power);
148
- await this.sendHomekitUpdate();
155
+ this.sendHomekitUpdate();
156
+ this.device.resetPollTimer();
149
157
  }
150
- /**
151
- * Get the current state of the purifier. Either purifying air or inactive.
152
- * Same as power for this implementation.
153
- */
154
- async getCurrentState() {
155
- const power = await this.device.getPower();
158
+ getCurrentState() {
159
+ this.ensureReachable();
160
+ const power = this.device.getPower();
156
161
  this.log.debug('accessory:getCurrentState()', power);
157
162
  return this.toCurrentState(power);
158
163
  }
159
- /**
160
- * Get the target state of the purifier. Either auto or manual mode.
161
- */
162
- async getTargetState() {
163
- const mode = await this.device.getMode();
164
+ getTargetState() {
165
+ this.ensureReachable();
166
+ const mode = this.device.getMode();
164
167
  this.log.debug('accessory:getTargetState()', mode);
165
168
  return this.toTargetState(mode);
166
169
  }
167
- /**
168
- * Set the target state of the purifier. Either auto or manual mode.
169
- */
170
170
  async setTargetState(state) {
171
171
  const newMode = state === this.Characteristic.TargetAirPurifierState.AUTO ? winix_api_1.Mode.Auto : winix_api_1.Mode.Manual;
172
172
  this.log.debug(`accessory:setTargetState(${state})`, newMode);
173
173
  await this.device.setMode(newMode);
174
- if (newMode === winix_api_1.Mode.Manual) {
175
- return;
176
- }
177
- // If we're switching back to auto, the airflow speed will most likely change on the Winix device itself.
178
- // Pause, get the latest airflow speed, then send the update to Homekit
179
- this.scheduleHomekitUpdate();
174
+ this.sendHomekitUpdate();
175
+ this.device.resetPollTimer();
180
176
  }
181
- /**
182
- * Get the rotation speed of the purifier.
183
- */
184
- async getRotationSpeed() {
185
- const airflow = await this.device.getAirflow();
177
+ getRotationSpeed() {
178
+ this.ensureReachable();
179
+ const power = this.device.getPower();
180
+ if (power === winix_api_1.Power.Off) {
181
+ this.log.debug('accessory:getRotationSpeed()', 'off');
182
+ return 0;
183
+ }
184
+ const airflow = this.device.getAirflow();
186
185
  this.log.debug('accessory:getRotationSpeed()', airflow);
187
186
  return this.toRotationSpeed(airflow);
188
187
  }
189
- /**
190
- * Set the rotation speed of the purifier.
191
- */
192
188
  async setRotationSpeed(state) {
193
189
  const airflow = this.toAirflow(state);
194
190
  this.log.debug(`accessory:setRotationSpeed(${state}):`, airflow);
@@ -197,87 +193,76 @@ class WinixPurifierAccessory {
197
193
  return;
198
194
  }
199
195
  await this.device.setAirflow(airflow);
200
- await this.sendHomekitUpdate();
196
+ this.sendHomekitUpdate();
197
+ this.device.resetPollTimer();
201
198
  }
202
- /**
203
- * Get the air quality of the purifier.
204
- */
205
- async getAirQuality() {
206
- const airQuality = await this.device.getAirQuality();
199
+ getAirQuality() {
200
+ this.ensureReachable();
201
+ const airQuality = this.device.getAirQuality();
207
202
  this.log.debug('accessory:getAirQuality():', airQuality);
208
203
  return this.toAirQuality(airQuality);
209
204
  }
210
- /**
211
- * Get the plasmawave state of the purifier.
212
- */
213
- async getPlasmawave() {
214
- const plasmawave = await this.device.getPlasmawave();
205
+ getPlasmawave() {
206
+ this.ensureReachable();
207
+ const plasmawave = this.device.getPlasmawave();
215
208
  this.log.debug('accessory:getPlasmawave():', plasmawave);
216
209
  return this.toSwitch(plasmawave);
217
210
  }
218
- /**
219
- * Set the plasmawave state of the purifier.
220
- */
221
211
  async setPlasmawave(state) {
222
212
  const plasmawave = this.toPlasmawave(state);
223
213
  this.log.debug(`accessory:setPlasmawave(${state}):`, plasmawave);
224
214
  await this.device.setPlasmawave(plasmawave);
225
- await this.sendHomekitUpdate();
215
+ this.sendHomekitUpdate();
216
+ this.device.resetPollTimer();
226
217
  }
227
- /**
228
- * Get the ambient light level of the purifier.
229
- */
230
- async getAmbientLight() {
231
- const ambientLight = await this.device.getAmbientLight();
232
- // Fix ambient light value under 0.0001 warning
218
+ getAmbientLight() {
219
+ this.ensureReachable();
220
+ const ambientLight = this.device.getAmbientLight();
233
221
  const fixedAmbientLight = this.toAmbientLight(ambientLight);
234
222
  this.log.debug('accessory:getAmbientLight():', 'measured:', ambientLight, 'fixed:', fixedAmbientLight);
235
223
  return fixedAmbientLight;
236
224
  }
237
- /**
238
- * Get the auto switch state of the purifier.
239
- */
240
- async getAutoSwitchState() {
241
- const targetState = await this.getTargetState();
242
- // Translate target state (auto/manual mode) to auto switch state
225
+ getAutoSwitchState() {
226
+ this.ensureReachable();
227
+ const power = this.device.getPower();
228
+ if (power === winix_api_1.Power.Off) {
229
+ this.log.debug('accessory:getAutoSwitchState()', 'off');
230
+ return false;
231
+ }
232
+ const targetState = this.getTargetState();
243
233
  const result = targetState === this.Characteristic.TargetAirPurifierState.AUTO;
244
234
  this.log.debug('accessory:getAutoSwitchState()', 'target', targetState, 'result', result);
245
235
  return result;
246
236
  }
247
- /**
248
- * Set the auto switch state of the purifier.
249
- */
250
237
  async setAutoSwitchState(state) {
251
- // Translate auto switch state to target state (auto/manual mode)
252
238
  const proxyState = state ?
253
239
  this.Characteristic.TargetAirPurifierState.AUTO :
254
240
  this.Characteristic.TargetAirPurifierState.MANUAL;
255
241
  this.log.debug(`accessory:setAutoSwitchState(${state})`, proxyState);
256
242
  return await this.setTargetState(proxyState);
257
243
  }
258
- /**
259
- * Get the sleep switch state of the purifier.
260
- */
261
- async getSleepSwitchState() {
262
- const airflow = await this.device.getAirflow();
244
+ getSleepSwitchState() {
245
+ this.ensureReachable();
246
+ const power = this.device.getPower();
247
+ if (power === winix_api_1.Power.Off) {
248
+ this.log.debug('accessory:getSleepSwitchState()', 'off');
249
+ return false;
250
+ }
251
+ const airflow = this.device.getAirflow();
263
252
  const isInSleep = airflow === winix_api_1.Airflow.Sleep;
264
253
  this.log.debug('accessory:getSleepSwitchState()', isInSleep);
265
254
  return isInSleep;
266
255
  }
267
- /**
268
- * Set the sleep switch state of the purifier.
269
- */
270
256
  async setSleepSwitchState(state) {
271
257
  const airflow = state ? winix_api_1.Airflow.Sleep : winix_api_1.Airflow.Low;
272
258
  this.log.debug(`accessory:setSleepSwitchState(${state})`, airflow);
273
259
  await this.device.setAirflow(airflow);
274
- this.scheduleHomekitUpdate();
260
+ this.sendHomekitUpdate();
261
+ this.device.resetPollTimer();
275
262
  }
276
- /**
277
- * Get the filter life level of the purifier.
278
- */
279
- async getFilterLifeLevel() {
280
- const currentFilterHours = await this.device.getFilterHours();
263
+ getFilterLifeLevel() {
264
+ this.ensureReachable();
265
+ const currentFilterHours = this.device.getFilterHours();
281
266
  if (currentFilterHours <= 0) {
282
267
  this.log.debug('accessory:getFilterLifeLevel(): currentFilterHours is not a positive number:', currentFilterHours);
283
268
  return 100;
@@ -287,11 +272,9 @@ class WinixPurifierAccessory {
287
272
  this.log.debug('accessory:getFilterLifeLevel()', remainingPercentage);
288
273
  return remainingPercentage;
289
274
  }
290
- /**
291
- * Get the filter change indication of the purifier.
292
- */
293
- async getFilterChangeIndication() {
294
- const filterLife = await this.getFilterLifeLevel();
275
+ getFilterChangeIndication() {
276
+ this.ensureReachable();
277
+ const filterLife = this.getFilterLifeLevel();
295
278
  const replacementPercentage = this.config.filterReplacementIndicatorPercentage ?? DEFAULT_FILTER_LIFE_REPLACEMENT_PERCENTAGE;
296
279
  const shouldReplaceFilter = filterLife <= replacementPercentage ?
297
280
  this.Characteristic.FilterChangeIndication.CHANGE_FILTER :
@@ -299,27 +282,20 @@ class WinixPurifierAccessory {
299
282
  this.log.debug('accessory:getFilterChangeIndication() filterLife:', filterLife, 'replacementPercentage:', replacementPercentage, 'shouldReplaceFilter:', shouldReplaceFilter);
300
283
  return shouldReplaceFilter;
301
284
  }
302
- scheduleHomekitUpdate() {
303
- this.log.debug('scheduling homekit update');
304
- setTimeout(async () => {
305
- await this.device.update();
306
- await this.sendHomekitUpdate();
307
- }, 1000);
308
- }
309
285
  /**
310
286
  * Send an update to Homekit with the latest device status.
311
287
  */
312
- async sendHomekitUpdate() {
288
+ sendHomekitUpdate() {
313
289
  this.log.debug('accessory:sendHomekitUpdate()');
314
- if (!this.device.hasData()) {
315
- this.log.debug('accessory:sendHomekitUpdate(): skipping update, no status');
290
+ if (!this.device.isReachable()) {
291
+ this.log.debug('accessory:sendHomekitUpdate(): skipping, device not reachable');
316
292
  return;
317
293
  }
318
- const { power, mode, airflow, airQuality, plasmawave, ambientLight, } = await this.device.getState();
294
+ const { power, mode, airflow, airQuality, plasmawave, ambientLight, } = this.device.getState();
319
295
  this.purifier.updateCharacteristic(this.Characteristic.Active, this.toActiveState(power));
320
296
  this.purifier.updateCharacteristic(this.Characteristic.CurrentAirPurifierState, this.toCurrentState(power));
321
297
  this.purifier.updateCharacteristic(this.Characteristic.TargetAirPurifierState, this.toTargetState(mode));
322
- this.purifier.updateCharacteristic(this.Characteristic.RotationSpeed, this.toRotationSpeed(airflow));
298
+ this.purifier.updateCharacteristic(this.Characteristic.RotationSpeed, power === winix_api_1.Power.Off ? 0 : this.toRotationSpeed(airflow));
323
299
  if (this.airQuality !== undefined) {
324
300
  this.airQuality?.updateCharacteristic(this.Characteristic.AirQuality, this.toAirQuality(airQuality));
325
301
  }
@@ -330,10 +306,10 @@ class WinixPurifierAccessory {
330
306
  this.ambientLight?.updateCharacteristic(this.Characteristic.CurrentAmbientLightLevel, this.toAmbientLight(ambientLight));
331
307
  }
332
308
  if (this.autoSwitch !== undefined) {
333
- this.autoSwitch?.updateCharacteristic(this.Characteristic.On, this.toTargetState(mode) === this.Characteristic.TargetAirPurifierState.AUTO);
309
+ this.autoSwitch?.updateCharacteristic(this.Characteristic.On, power === winix_api_1.Power.On && this.toTargetState(mode) === this.Characteristic.TargetAirPurifierState.AUTO);
334
310
  }
335
311
  if (this.sleepSwitch !== undefined) {
336
- this.sleepSwitch?.updateCharacteristic(this.Characteristic.On, airflow === winix_api_1.Airflow.Sleep);
312
+ this.sleepSwitch?.updateCharacteristic(this.Characteristic.On, power === winix_api_1.Power.On && airflow === winix_api_1.Airflow.Sleep);
337
313
  }
338
314
  }
339
315
  toActiveState(power) {
package/dist/device.js CHANGED
@@ -1,18 +1,19 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.Device = void 0;
7
4
  const winix_api_1 = require("winix-api");
8
- const async_lock_1 = __importDefault(require("async-lock"));
5
+ const MAX_BACKOFF_MS = 5 * 60 * 1000;
6
+ const COMMAND_DELAY_MS = 1500;
7
+ const UNREACHABLE_THRESHOLD = 3;
9
8
  class Device {
10
- constructor(deviceId, cacheIntervalMs, log) {
9
+ constructor(deviceId, pollIntervalMs, log) {
11
10
  this.deviceId = deviceId;
12
- this.cacheIntervalMs = cacheIntervalMs;
11
+ this.pollIntervalMs = pollIntervalMs;
13
12
  this.log = log;
14
- this.lastWinixPoll = -1;
15
- this.lock = new async_lock_1.default({ timeout: 3000 });
13
+ this.hasReceivedData = false;
14
+ this.pollTimer = null;
15
+ this.consecutiveFailures = 0;
16
+ this.onUpdate = null;
16
17
  this.state = {
17
18
  power: winix_api_1.Power.Off,
18
19
  mode: winix_api_1.Mode.Auto,
@@ -24,38 +25,68 @@ class Device {
24
25
  };
25
26
  }
26
27
  hasData() {
27
- return this.lastWinixPoll > -1;
28
+ return this.hasReceivedData;
29
+ }
30
+ isReachable() {
31
+ return this.hasReceivedData && this.consecutiveFailures < UNREACHABLE_THRESHOLD;
32
+ }
33
+ async initialFetch() {
34
+ try {
35
+ this.log.debug('device:initialFetch()');
36
+ const newState = await winix_api_1.WinixAPI.getDeviceStatus(this.deviceId);
37
+ Object.assign(this.state, newState);
38
+ this.hasReceivedData = true;
39
+ this.consecutiveFailures = 0;
40
+ this.log.debug('device:initialFetch()', JSON.stringify(this.state));
41
+ }
42
+ catch (e) {
43
+ this.log.warn('device:initialFetch() failed, using defaults:', e.message);
44
+ }
45
+ }
46
+ startPolling(onUpdate) {
47
+ this.onUpdate = onUpdate;
48
+ // Stagger the first poll with a random delay to avoid all devices
49
+ // hitting the API at the same time
50
+ const jitter = Math.floor(Math.random() * this.pollIntervalMs);
51
+ this.schedulePoll(jitter);
52
+ }
53
+ resetPollTimer(delayMs = 3000) {
54
+ this.schedulePoll(delayMs);
55
+ }
56
+ stopPolling() {
57
+ if (this.pollTimer) {
58
+ clearTimeout(this.pollTimer);
59
+ this.pollTimer = null;
60
+ }
28
61
  }
29
- async getPower() {
30
- await this.ensureUpdated();
62
+ // Getters - all synchronous, return from in-memory state
63
+ getPower() {
31
64
  return this.state.power;
32
65
  }
33
- async getMode() {
34
- await this.ensureUpdated();
66
+ getMode() {
35
67
  return this.state.mode;
36
68
  }
37
- async getAirflow() {
38
- await this.ensureUpdated();
69
+ getAirflow() {
39
70
  return this.state.airflow;
40
71
  }
41
- async getAirQuality() {
42
- await this.ensureUpdated();
72
+ getAirQuality() {
43
73
  return this.state.airQuality;
44
74
  }
45
- async getPlasmawave() {
46
- await this.ensureUpdated();
75
+ getPlasmawave() {
47
76
  return this.state.plasmawave;
48
77
  }
49
- async getAmbientLight() {
50
- await this.ensureUpdated();
78
+ getAmbientLight() {
51
79
  return this.state.ambientLight;
52
80
  }
53
- async getFilterHours() {
54
- await this.ensureUpdated();
81
+ getFilterHours() {
55
82
  return this.state.filterHours;
56
83
  }
84
+ getState() {
85
+ return { ...this.state };
86
+ }
87
+ // Setters - async, send commands to Winix API and update state optimistically
57
88
  async setPower(value) {
58
- const initialPower = await this.getPower();
89
+ const initialPower = this.getPower();
59
90
  if (initialPower === value) {
60
91
  this.log.debug('device:setPower(%s)', value, '(no change)');
61
92
  return;
@@ -63,32 +94,45 @@ class Device {
63
94
  this.log.debug('device:setPower()', initialPower, value);
64
95
  await winix_api_1.WinixAPI.setPower(this.deviceId, value);
65
96
  this.state.power = value;
66
- // default to auto mode when turning on from off
67
- if (initialPower === winix_api_1.Power.Off && value === winix_api_1.Power.On) {
97
+ // Side effects observed from device testing
98
+ if (value === winix_api_1.Power.Off) {
68
99
  this.state.mode = winix_api_1.Mode.Auto;
100
+ this.state.plasmawave = winix_api_1.Plasmawave.Off;
101
+ }
102
+ if (value === winix_api_1.Power.On) {
103
+ this.state.plasmawave = winix_api_1.Plasmawave.On;
69
104
  }
70
105
  }
71
106
  async setMode(value) {
72
107
  const turnedOn = await this.ensureOn();
73
- // Don't try to set the mode if it's already set to the same value
74
- // Fixes issues with this being set right around the time of power on
75
- if (!turnedOn && value === await this.getMode()) {
108
+ if (!turnedOn && value === this.getMode()) {
76
109
  this.log.debug('device:setMode(%s)', value, '(no change)');
77
110
  return;
78
111
  }
79
112
  this.log.debug('device:setMode(%s)', value);
80
113
  await winix_api_1.WinixAPI.setMode(this.deviceId, value);
81
114
  this.state.mode = value;
82
- // default to low airflow when switching modes
83
- this.state.airflow = winix_api_1.Airflow.Low;
115
+ // Side effects observed from device testing
116
+ if (value === winix_api_1.Mode.Auto) {
117
+ this.state.airflow = winix_api_1.Airflow.Low;
118
+ }
84
119
  }
85
120
  async setAirflow(value) {
86
121
  this.log.debug('device:setAirflow(%s)', value);
87
- // Device must be on and in manual mode to set airflow
88
122
  await this.ensureOn();
89
- await this.setMode(winix_api_1.Mode.Manual);
123
+ // Device auto-switches to manual when setting airflow, but we need
124
+ // a delay between mode change and airflow command or the airflow
125
+ // command gets dropped by the device
126
+ if (this.state.mode !== winix_api_1.Mode.Manual) {
127
+ await this.setMode(winix_api_1.Mode.Manual);
128
+ await new Promise(r => setTimeout(r, COMMAND_DELAY_MS));
129
+ }
90
130
  await winix_api_1.WinixAPI.setAirflow(this.deviceId, value);
91
131
  this.state.airflow = value;
132
+ // Side effects observed from device testing
133
+ if (value === winix_api_1.Airflow.Sleep) {
134
+ this.state.plasmawave = winix_api_1.Plasmawave.Off;
135
+ }
92
136
  }
93
137
  async setPlasmawave(value) {
94
138
  this.log.debug('device:setPlasmawave()', value);
@@ -96,37 +140,34 @@ class Device {
96
140
  await winix_api_1.WinixAPI.setPlasmawave(this.deviceId, value);
97
141
  this.state.plasmawave = value;
98
142
  }
99
- async getState() {
100
- await this.ensureUpdated();
101
- return {
102
- power: this.state.power,
103
- mode: this.state.mode,
104
- airflow: this.state.airflow,
105
- airQuality: this.state.airQuality,
106
- plasmawave: this.state.plasmawave,
107
- ambientLight: this.state.ambientLight,
108
- filterHours: this.state.filterHours,
109
- };
110
- }
111
- async update() {
112
- this.log.debug('device:update()');
113
- this.state = await winix_api_1.WinixAPI.getDeviceStatus(this.deviceId);
114
- this.log.debug('device:update()', JSON.stringify(this.state));
115
- this.lastWinixPoll = Date.now();
116
- }
117
- async ensureUpdated() {
118
- // Use a lock to ensure only one update is running at a time
119
- await this.lock.acquire('update', async () => {
120
- if (this.shouldUpdate()) {
121
- await this.update();
122
- }
123
- });
124
- }
125
- shouldUpdate() {
126
- return Date.now() - this.lastWinixPoll > this.cacheIntervalMs;
143
+ // Private methods
144
+ schedulePoll(delayMs) {
145
+ if (this.pollTimer) {
146
+ clearTimeout(this.pollTimer);
147
+ }
148
+ this.pollTimer = setTimeout(() => this.poll(), delayMs);
149
+ }
150
+ async poll() {
151
+ try {
152
+ this.log.debug('device:poll()');
153
+ const newState = await winix_api_1.WinixAPI.getDeviceStatus(this.deviceId);
154
+ Object.assign(this.state, newState);
155
+ this.hasReceivedData = true;
156
+ this.consecutiveFailures = 0;
157
+ this.log.debug('device:poll()', JSON.stringify(this.state));
158
+ this.onUpdate?.();
159
+ }
160
+ catch (e) {
161
+ this.consecutiveFailures++;
162
+ const backoffMs = Math.min(this.pollIntervalMs * Math.pow(2, this.consecutiveFailures), MAX_BACKOFF_MS);
163
+ this.log.error(`device:poll() error: ${e.message} (retry in ${Math.round(backoffMs / 1000)}s)`);
164
+ this.schedulePoll(backoffMs);
165
+ return;
166
+ }
167
+ this.schedulePoll(this.pollIntervalMs);
127
168
  }
128
169
  async ensureOn() {
129
- if (await this.getPower() === winix_api_1.Power.On) {
170
+ if (this.state.power === winix_api_1.Power.On) {
130
171
  this.log.debug('device:ensureOn()', 'already on');
131
172
  return false;
132
173
  }
package/dist/platform.js CHANGED
@@ -81,14 +81,14 @@ class WinixPurifierPlatform {
81
81
  this.log.debug('Found', accessory ? 'existing' : 'new', 'accessory:', this.logName(device));
82
82
  if (accessory) {
83
83
  accessory.context.device = device;
84
- const handler = this.createNewAccessoryHandler(accessory);
84
+ const handler = await this.createNewAccessoryHandler(accessory);
85
85
  this.handlers.set(uuid, handler);
86
86
  this.api.updatePlatformAccessories([accessory]);
87
87
  }
88
88
  else {
89
89
  accessory = new this.api.platformAccessory(device.deviceAlias, uuid, 19 /* Categories.AIR_PURIFIER */);
90
90
  accessory.context.device = device;
91
- const handler = this.createNewAccessoryHandler(accessory);
91
+ const handler = await this.createNewAccessoryHandler(accessory);
92
92
  this.accessories.set(uuid, accessory);
93
93
  this.handlers.set(uuid, handler);
94
94
  accessoriesToAdd.push(accessory);
@@ -100,13 +100,14 @@ class WinixPurifierPlatform {
100
100
  }
101
101
  this.removeOldDevices(discoveredUUIDs);
102
102
  }
103
- createNewAccessoryHandler(accessory) {
103
+ async createNewAccessoryHandler(accessory) {
104
104
  // 🫣 suppress warning message about adding characteristics which aren't required / optional, since it isn't accurate
105
105
  this.suppressCharacteristicWarnings(accessory);
106
106
  const deviceOverride = this.deviceOverrides.get(accessory.context.device.deviceId);
107
107
  const log = new logger_1.DeviceLogger(this.log, accessory.context.device);
108
108
  const handler = new accessory_1.WinixPurifierAccessory(this, this.config, accessory, deviceOverride, log);
109
109
  this.unsuppressCharacteristicWarnings(accessory);
110
+ await handler.initialize();
110
111
  return handler;
111
112
  }
112
113
  removeOldDevices(discoveredUUIDs) {
@@ -116,6 +117,7 @@ class WinixPurifierPlatform {
116
117
  return;
117
118
  }
118
119
  this.log.debug('Removing old accessory:', this.logName(accessory.context.device));
120
+ this.handlers.get(accessory.UUID)?.device.stopPolling();
119
121
  this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
120
122
  this.accessories.delete(accessory.UUID);
121
123
  this.handlers.delete(accessory.UUID);
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.1.7",
4
+ "version": "2.2.1",
5
5
  "description": "Homebridge plugin for Winix air purifiers",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -63,11 +63,9 @@
63
63
  ],
64
64
  "dependencies": {
65
65
  "@homebridge/plugin-ui-utils": "1.0.3",
66
- "async-lock": "1.4.1",
67
- "winix-api": "1.7.0"
66
+ "winix-api": "1.8.0"
68
67
  },
69
68
  "devDependencies": {
70
- "@types/async-lock": "1.4.2",
71
69
  "@types/node": "20.11.0",
72
70
  "@typescript-eslint/eslint-plugin": "6.18.1",
73
71
  "@typescript-eslint/parser": "6.18.1",