homebridge-winix-purifiers 2.1.7 → 2.2.0

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)
@@ -93,7 +92,7 @@ While not recommended, if manual setup is required, add the following to the `pl
93
92
  "exposeAutoSwitch": false,
94
93
  "exposeSleepSwitch": false,
95
94
  "filterReplacementIndicatorPercentage": 10,
96
- "cacheIntervalSeconds": 300,
95
+ "pollIntervalSeconds": 30,
97
96
  "deviceRefreshIntervalMinutes": 60,
98
97
  "auth": {
99
98
  "username": "your-email@domain.com",
@@ -130,7 +129,7 @@ While not recommended, if manual setup is required, add the following to the `pl
130
129
  | `exposeAutoSwitch` | `false` | Whether to expose switches for Auto mode on/off. |
131
130
  | `exposeSleepSwitch` | `false` | Whether to expose switches for Sleep mode on/off. |
132
131
  | `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. |
132
+ | `pollIntervalSeconds` | `30` | Time, in seconds, for how often to poll the Winix API for device state updates. Minimum 15. |
134
133
  | `deviceRefreshIntervalMinutes` | `60` | Time, in minutes, for how often to poll Winix to refresh the device list. |
135
134
  | `auth.username` | `""` | Your Winix account username (email). This field is meant to be read-only in the UI. |
136
135
  | `auth.password` | `""` | Your Winix account password (encrypted). This field is meant to be read-only in the UI. See below for manual generation. |
@@ -154,7 +153,7 @@ configuration file. To do this, you must first encrypt your password using the p
154
153
 
155
154
  1. **Clone the repository**:
156
155
 
157
- You’ll need to clone this plugin's repository locally to run the encryption script. Make sure you have **Node.js 20+**
156
+ You’ll need to clone this plugin's repository locally to run the encryption script. Make sure you have **Node.js 22+**
158
157
  installed on your machine.
159
158
 
160
159
  ```bash
@@ -192,13 +191,6 @@ This ensures that your password is securely stored within the configuration file
192
191
 
193
192
  ## FAQ
194
193
 
195
- ### Upgrading from the old plugin architecture (v1.x.x) to the new one (v2.x.x)?
196
-
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).
201
-
202
194
  ### Using HOOBS?
203
195
 
204
196
  If you're using [HOOBS](https://hoobs.org), you can install the plugin directly from the HOOBS interface. You will not
@@ -209,14 +201,6 @@ details on obtaining the required `auth` values. See
209
201
  [Encrypting Your Password](#encrypting-your-password-for-manual-setup-and-hoobs-users) for instructions on encrypting
210
202
  your Winix password.
211
203
 
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)).
219
-
220
204
  ### Missing “Auto/Manual” switch in Home app?
221
205
 
222
206
  Please see [this issue](https://github.com/regaw-leinad/homebridge-winix-purifiers/issues/1) for more details.
@@ -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.0",
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
66
  "winix-api": "1.7.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",