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 +3 -19
- package/config.schema.json +6 -6
- package/dist/accessory.js +82 -106
- package/dist/device.js +103 -62
- package/dist/platform.js +5 -3
- package/package.json +1 -3
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
|
-
"
|
|
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
|
-
| `
|
|
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
|
|
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.
|
package/config.schema.json
CHANGED
|
@@ -46,12 +46,12 @@
|
|
|
46
46
|
"maximum": 100,
|
|
47
47
|
"required": true
|
|
48
48
|
},
|
|
49
|
-
"
|
|
50
|
-
"title": "
|
|
51
|
-
"description": "
|
|
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":
|
|
54
|
-
"minimum":
|
|
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
|
-
"
|
|
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
|
|
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
|
|
26
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
155
|
+
this.sendHomekitUpdate();
|
|
156
|
+
this.device.resetPollTimer();
|
|
149
157
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
196
|
+
this.sendHomekitUpdate();
|
|
197
|
+
this.device.resetPollTimer();
|
|
201
198
|
}
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
+
this.sendHomekitUpdate();
|
|
216
|
+
this.device.resetPollTimer();
|
|
226
217
|
}
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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.
|
|
260
|
+
this.sendHomekitUpdate();
|
|
261
|
+
this.device.resetPollTimer();
|
|
275
262
|
}
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
+
sendHomekitUpdate() {
|
|
313
289
|
this.log.debug('accessory:sendHomekitUpdate()');
|
|
314
|
-
if (!this.device.
|
|
315
|
-
this.log.debug('accessory:sendHomekitUpdate(): skipping
|
|
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, } =
|
|
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
|
|
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,
|
|
9
|
+
constructor(deviceId, pollIntervalMs, log) {
|
|
11
10
|
this.deviceId = deviceId;
|
|
12
|
-
this.
|
|
11
|
+
this.pollIntervalMs = pollIntervalMs;
|
|
13
12
|
this.log = log;
|
|
14
|
-
this.
|
|
15
|
-
this.
|
|
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.
|
|
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
|
-
|
|
30
|
-
|
|
62
|
+
// Getters - all synchronous, return from in-memory state
|
|
63
|
+
getPower() {
|
|
31
64
|
return this.state.power;
|
|
32
65
|
}
|
|
33
|
-
|
|
34
|
-
await this.ensureUpdated();
|
|
66
|
+
getMode() {
|
|
35
67
|
return this.state.mode;
|
|
36
68
|
}
|
|
37
|
-
|
|
38
|
-
await this.ensureUpdated();
|
|
69
|
+
getAirflow() {
|
|
39
70
|
return this.state.airflow;
|
|
40
71
|
}
|
|
41
|
-
|
|
42
|
-
await this.ensureUpdated();
|
|
72
|
+
getAirQuality() {
|
|
43
73
|
return this.state.airQuality;
|
|
44
74
|
}
|
|
45
|
-
|
|
46
|
-
await this.ensureUpdated();
|
|
75
|
+
getPlasmawave() {
|
|
47
76
|
return this.state.plasmawave;
|
|
48
77
|
}
|
|
49
|
-
|
|
50
|
-
await this.ensureUpdated();
|
|
78
|
+
getAmbientLight() {
|
|
51
79
|
return this.state.ambientLight;
|
|
52
80
|
}
|
|
53
|
-
|
|
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 =
|
|
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
|
-
//
|
|
67
|
-
if (
|
|
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
|
-
|
|
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
|
-
//
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
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.
|
|
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",
|