homebridge-blueair-plugin 1.1.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.
Files changed (44) hide show
  1. package/.eslintignore +3 -0
  2. package/.gitattributes +3 -0
  3. package/CHANGELOG.md +12 -0
  4. package/LICENSE +176 -0
  5. package/README.md +99 -0
  6. package/branding/Blueair.png +0 -0
  7. package/branding/Homebridge_x_Blueair.svg +82 -0
  8. package/dist/accessory.d.ts +111 -0
  9. package/dist/accessory.d.ts.map +1 -0
  10. package/dist/accessory.js +821 -0
  11. package/dist/accessory.js.map +1 -0
  12. package/dist/api/BlueAirAwsApi.d.ts +79 -0
  13. package/dist/api/BlueAirAwsApi.d.ts.map +1 -0
  14. package/dist/api/BlueAirAwsApi.js +216 -0
  15. package/dist/api/BlueAirAwsApi.js.map +1 -0
  16. package/dist/api/Consts.d.ts +36 -0
  17. package/dist/api/Consts.d.ts.map +1 -0
  18. package/dist/api/Consts.js +52 -0
  19. package/dist/api/Consts.js.map +1 -0
  20. package/dist/api/GigyaApi.d.ts +19 -0
  21. package/dist/api/GigyaApi.d.ts.map +1 -0
  22. package/dist/api/GigyaApi.js +79 -0
  23. package/dist/api/GigyaApi.js.map +1 -0
  24. package/dist/constants.d.ts +40 -0
  25. package/dist/constants.d.ts.map +1 -0
  26. package/dist/constants.js +146 -0
  27. package/dist/constants.js.map +1 -0
  28. package/dist/device.d.ts +49 -0
  29. package/dist/device.d.ts.map +1 -0
  30. package/dist/device.js +136 -0
  31. package/dist/device.js.map +1 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/platform.d.ts +35 -0
  37. package/dist/platform.d.ts.map +1 -0
  38. package/dist/platform.js +277 -0
  39. package/dist/platform.js.map +1 -0
  40. package/dist/utils.d.ts +96 -0
  41. package/dist/utils.d.ts.map +1 -0
  42. package/dist/utils.js +98 -0
  43. package/dist/utils.js.map +1 -0
  44. package/package.json +54 -0
@@ -0,0 +1,821 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BlueAirAccessory = exports.BlueAirDevice = void 0;
4
+ const utils_1 = require("./utils");
5
+ const constants_1 = require("./constants");
6
+ // Re-export BlueAirDevice for backward compatibility
7
+ var device_1 = require("./device");
8
+ Object.defineProperty(exports, "BlueAirDevice", { enumerable: true, get: function () { return device_1.BlueAirDevice; } });
9
+ class BlueAirAccessory {
10
+ constructor(platform, accessory, device, configDev, deviceType = "air-purifier") {
11
+ this.platform = platform;
12
+ this.accessory = accessory;
13
+ this.device = device;
14
+ this.configDev = configDev;
15
+ this.optionalServices = new Map();
16
+ this.humidityAutoControlEnabled = false;
17
+ this.lastManualOverride = 0;
18
+ this.lastAutoAdjust = 0;
19
+ this.loggedNoWritableTargetHumidity = false;
20
+ // Dynamic capabilities detected from device state keys
21
+ this.capabilities = {
22
+ hasBrightness: false,
23
+ hasNightLight: false,
24
+ hasNightMode: false,
25
+ hasAutoMode: false,
26
+ hasHumidity: false,
27
+ hasHumidityTarget: false,
28
+ hasWaterLevel: false,
29
+ hasTemperature: false,
30
+ hasAirQuality: false,
31
+ hasGermShield: false,
32
+ hasFilterUsage: false,
33
+ hasChildLock: false,
34
+ hasFanSpeed: false,
35
+ };
36
+ this.deviceType = deviceType;
37
+ // Initialize last brightness from device state
38
+ this.lastBrightness = this.device.state.brightness || 0;
39
+ // Initialize debouncers
40
+ this.fanSpeedDebouncer = new utils_1.Debouncer(constants_1.DEBOUNCE_DELAY_MS, this.executeFanSpeedChange.bind(this));
41
+ this.nlBrightnessDebouncer = new utils_1.Debouncer(constants_1.DEBOUNCE_DELAY_MS, this.executeNlBrightnessChange.bind(this));
42
+ this.humidityDebouncer = new utils_1.Debouncer(constants_1.DEBOUNCE_DELAY_MS, this.executeHumidityChange.bind(this));
43
+ // Detect capabilities from available state keys
44
+ this.initCapabilities();
45
+ this.setupAccessoryInformation();
46
+ this.setupMainService();
47
+ this.setupOptionalServices();
48
+ this.setupEventListeners();
49
+ }
50
+ initCapabilities() {
51
+ this.capabilities = (0, utils_1.detectCapabilities)(this.device.state, this.device.sensorData);
52
+ this.platform.log.info(`[${this.device.name}] Detected capabilities: ${(0, utils_1.formatCapabilities)(this.capabilities)}`);
53
+ }
54
+ setupAccessoryInformation() {
55
+ const typeLabel = this.deviceType === "humidifier" ? "Humidifier" : "Purifier";
56
+ this.accessory
57
+ .getService(this.platform.Service.AccessoryInformation)
58
+ .setCharacteristic(this.platform.Characteristic.Manufacturer, "BlueAir")
59
+ .setCharacteristic(this.platform.Characteristic.Model, this.configDev.model || `BlueAir ${typeLabel}`)
60
+ .setCharacteristic(this.platform.Characteristic.SerialNumber, this.configDev.serialNumber || "BlueAir Device");
61
+ if (this.configDev.room) {
62
+ this.accessory.displayName = `${this.configDev.name} (${this.configDev.room})`;
63
+ }
64
+ this.platform.log.info(`[${this.configDev.name}] Initializing ${typeLabel} accessory (Model: ${this.configDev.model || "Unknown"})`);
65
+ }
66
+ setupMainService() {
67
+ if (this.deviceType === "humidifier") {
68
+ this.setupHumidifierService();
69
+ }
70
+ else {
71
+ this.setupAirPurifierService();
72
+ }
73
+ // Common setup for all device types
74
+ this.setupCommonServiceCharacteristics();
75
+ }
76
+ /**
77
+ * Setup characteristics common to both Air Purifier and Humidifier services
78
+ */
79
+ setupCommonServiceCharacteristics() {
80
+ const C = this.platform.Characteristic;
81
+ // Active state
82
+ this.service
83
+ .getCharacteristic(C.Active)
84
+ .onGet(this.getActive.bind(this))
85
+ .onSet(this.setActive.bind(this));
86
+ // Lock physical controls (child lock)
87
+ this.service
88
+ .getCharacteristic(C.LockPhysicalControls)
89
+ .onGet(this.getLockPhysicalControls.bind(this))
90
+ .onSet(this.setLockPhysicalControls.bind(this));
91
+ // Rotation speed (fan speed) - discrete steps matching device capability
92
+ this.service
93
+ .getCharacteristic(C.RotationSpeed)
94
+ .setProps({ minValue: 0, maxValue: 100, minStep: constants_1.FAN_SPEED_HOMEKIT_STEP })
95
+ .onGet(this.getRotationSpeed.bind(this))
96
+ .onSet(this.setRotationSpeed.bind(this));
97
+ // Filter maintenance service
98
+ this.filterMaintenanceService =
99
+ this.accessory.getService(this.platform.Service.FilterMaintenance) ||
100
+ this.accessory.addService(this.platform.Service.FilterMaintenance);
101
+ this.filterMaintenanceService
102
+ .getCharacteristic(C.FilterChangeIndication)
103
+ .onGet(this.getFilterChangeIndication.bind(this));
104
+ this.filterMaintenanceService
105
+ .getCharacteristic(C.FilterLifeLevel)
106
+ .onGet(this.getFilterLifeLevel.bind(this));
107
+ // Link for grouping in Home app
108
+ this.service.addLinkedService(this.filterMaintenanceService);
109
+ }
110
+ setupAirPurifierService() {
111
+ this.service =
112
+ this.accessory.getService(this.platform.Service.AirPurifier) ||
113
+ this.accessory.addService(this.platform.Service.AirPurifier);
114
+ this.service.setCharacteristic(this.platform.Characteristic.Name, this.configDev.name);
115
+ this.service
116
+ .getCharacteristic(this.platform.Characteristic.CurrentAirPurifierState)
117
+ .onGet(this.getCurrentAirPurifierState.bind(this));
118
+ this.service
119
+ .getCharacteristic(this.platform.Characteristic.TargetAirPurifierState)
120
+ .onGet(this.getTargetAirPurifierState.bind(this))
121
+ .onSet(this.setTargetAirPurifierState.bind(this));
122
+ // Add humidity to main control screen if available
123
+ if (this.capabilities.hasHumidity) {
124
+ this.service
125
+ .getCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity)
126
+ .onGet(this.getCurrentRelativeHumidity.bind(this));
127
+ }
128
+ }
129
+ setupHumidifierService() {
130
+ this.service =
131
+ this.accessory.getService(this.platform.Service.HumidifierDehumidifier) ||
132
+ this.accessory.addService(this.platform.Service.HumidifierDehumidifier);
133
+ this.service.setCharacteristic(this.platform.Characteristic.Name, this.configDev.name);
134
+ this.service
135
+ .getCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity)
136
+ .onGet(this.getCurrentRelativeHumidity.bind(this));
137
+ // RelativeHumidityHumidifierThreshold for target humidity
138
+ this.service
139
+ .getCharacteristic(this.platform.Characteristic.RelativeHumidityHumidifierThreshold)
140
+ .setProps({ minValue: 0, maxValue: 100, minStep: 1 })
141
+ .onGet(this.getTargetRelativeHumidity.bind(this))
142
+ .onSet(this.setTargetRelativeHumidity.bind(this));
143
+ this.service
144
+ .getCharacteristic(this.platform.Characteristic.CurrentHumidifierDehumidifierState)
145
+ .onGet(this.getCurrentHumidifierState.bind(this));
146
+ // Lock to HUMIDIFIER mode only
147
+ this.service
148
+ .getCharacteristic(this.platform.Characteristic.TargetHumidifierDehumidifierState)
149
+ .setProps({
150
+ validValues: [
151
+ this.platform.Characteristic.TargetHumidifierDehumidifierState
152
+ .HUMIDIFIER,
153
+ ],
154
+ })
155
+ .onGet(this.getTargetHumidifierState.bind(this))
156
+ .onSet(this.setTargetHumidifierState.bind(this));
157
+ // Water level
158
+ this.service
159
+ .getCharacteristic(this.platform.Characteristic.WaterLevel)
160
+ .onGet(this.getWaterLevel.bind(this));
161
+ // Optional separate Fan tile for Home app visibility
162
+ if (this.configDev.showFanTile !== false) {
163
+ this.setupFanService();
164
+ }
165
+ }
166
+ /**
167
+ * Setup separate Fan service for humidifiers (optional)
168
+ */
169
+ setupFanService() {
170
+ const C = this.platform.Characteristic;
171
+ this.fanService =
172
+ this.accessory.getService(this.platform.Service.Fanv2) ||
173
+ this.accessory.addService(this.platform.Service.Fanv2, "Fan", "Fan");
174
+ this.fanService.setCharacteristic(C.Name, this.configDev.name + " Fan");
175
+ this.fanService
176
+ .getCharacteristic(C.Active)
177
+ .onGet(this.getActive.bind(this))
178
+ .onSet(this.setActive.bind(this));
179
+ this.fanService
180
+ .getCharacteristic(C.RotationSpeed)
181
+ .setProps({ minValue: 0, maxValue: 100, minStep: constants_1.FAN_SPEED_HOMEKIT_STEP })
182
+ .onGet(this.getRotationSpeed.bind(this))
183
+ .onSet(this.setRotationSpeed.bind(this));
184
+ // Link for better UI grouping
185
+ this.service.addLinkedService(this.fanService);
186
+ }
187
+ /**
188
+ * Helper to set up or remove an optional service based on config.
189
+ * Returns the service if enabled, undefined otherwise.
190
+ *
191
+ * Note: By default, optional services are NOT linked to the primary service.
192
+ * This means they appear as separate tiles in the Home app, which provides
193
+ * a cleaner UX than stacked controls in the main control popup.
194
+ * The primary service (purifier/humidifier) handles the main device controls.
195
+ */
196
+ setupOptionalService(serviceConstructor, subtype, displayName, enabled, configure, linkToMain = false) {
197
+ const existingService = this.accessory.getServiceById(serviceConstructor, subtype);
198
+ if (enabled) {
199
+ const service = existingService !== null && existingService !== void 0 ? existingService : this.accessory.addService(serviceConstructor, `${this.device.name} ${displayName}`, subtype);
200
+ service.setCharacteristic(this.platform.Characteristic.Name, `${this.device.name} ${displayName}`);
201
+ configure(service);
202
+ this.optionalServices.set(subtype, service);
203
+ // Only link if explicitly requested
204
+ if (linkToMain) {
205
+ this.service.addLinkedService(service);
206
+ }
207
+ return service;
208
+ }
209
+ else if (existingService) {
210
+ // Remove any existing link before removing service
211
+ try {
212
+ this.service.removeLinkedService(existingService);
213
+ }
214
+ catch (_a) {
215
+ // Ignore if not linked
216
+ }
217
+ this.accessory.removeService(existingService);
218
+ this.optionalServices.delete(subtype);
219
+ }
220
+ return undefined;
221
+ }
222
+ setupOptionalServices() {
223
+ const C = this.platform.Characteristic;
224
+ const S = this.platform.Service;
225
+ // Remove legacy "Led" service from cache (renamed to DisplayBrightness)
226
+ this.setupOptionalService(S.Lightbulb, "Led", "Led", false, // Always remove - replaced by DisplayBrightness
227
+ () => { });
228
+ // Display brightness - only if device has brightness capability
229
+ this.setupOptionalService(S.Lightbulb, "DisplayBrightness", "Display Brightness", this.capabilities.hasBrightness && this.configDev.led !== false, (svc) => {
230
+ svc.setCharacteristic(C.ConfiguredName, `${this.device.name} Display`);
231
+ svc
232
+ .getCharacteristic(C.On)
233
+ .onGet(this.getDisplayOn.bind(this))
234
+ .onSet(this.setDisplayOn.bind(this));
235
+ svc
236
+ .getCharacteristic(C.Brightness)
237
+ .onGet(this.getDisplayBrightness.bind(this))
238
+ .onSet(this.setDisplayBrightness.bind(this));
239
+ });
240
+ // Night light brightness - only if device has nlbrightness capability
241
+ this.setupOptionalService(S.Lightbulb, "NightLight", "Night Light", this.capabilities.hasNightLight && this.configDev.nightLight !== false, (svc) => {
242
+ svc.setCharacteristic(C.ConfiguredName, `${this.device.name} Night Light`);
243
+ svc
244
+ .getCharacteristic(C.On)
245
+ .onGet(this.getNightLightOn.bind(this))
246
+ .onSet(this.setNightLightOn.bind(this));
247
+ svc
248
+ .getCharacteristic(C.Brightness)
249
+ .setProps({ minValue: 0, maxValue: 100, minStep: constants_1.NL_HOMEKIT_STEP })
250
+ .onGet(this.getNightLightBrightness.bind(this))
251
+ .onSet(this.setNightLightBrightness.bind(this));
252
+ });
253
+ // Temperature sensor - only if device has temperature data
254
+ this.setupOptionalService(S.TemperatureSensor, "Temperature", "Temperature", this.capabilities.hasTemperature &&
255
+ this.configDev.temperatureSensor !== false, (svc) => {
256
+ svc
257
+ .getCharacteristic(C.CurrentTemperature)
258
+ .onGet(this.getCurrentTemperature.bind(this));
259
+ });
260
+ // Night mode switch removed - night mode is triggered automatically when fan speed is low
261
+ this.setupOptionalService(S.Switch, "NightMode", "Night Mode", false, // Always remove
262
+ () => { });
263
+ // Humidity sensor - only if device has humidity data
264
+ this.setupOptionalService(S.HumiditySensor, "Humidity", "Humidity", this.capabilities.hasHumidity && this.configDev.humiditySensor !== false, (svc) => {
265
+ svc
266
+ .getCharacteristic(C.CurrentRelativeHumidity)
267
+ .onGet(this.getCurrentRelativeHumidity.bind(this));
268
+ });
269
+ // Air quality sensor - only if device has air quality data
270
+ this.setupOptionalService(S.AirQualitySensor, "AirQuality", "Air Quality", this.capabilities.hasAirQuality &&
271
+ this.configDev.airQualitySensor !== false, (svc) => {
272
+ svc
273
+ .getCharacteristic(C.AirQuality)
274
+ .onGet(this.getAirQuality.bind(this));
275
+ svc
276
+ .getCharacteristic(C.PM2_5Density)
277
+ .onGet(this.getPM2_5Density.bind(this));
278
+ svc
279
+ .getCharacteristic(C.PM10Density)
280
+ .onGet(this.getPM10Density.bind(this));
281
+ svc
282
+ .getCharacteristic(C.VOCDensity)
283
+ .onGet(this.getVOCDensity.bind(this));
284
+ });
285
+ // Germ shield switch - only if device has germshield capability
286
+ this.setupOptionalService(S.Switch, "GermShield", "Germ Shield", this.capabilities.hasGermShield && this.configDev.germShield !== false, (svc) => {
287
+ svc.setCharacteristic(C.ConfiguredName, `${this.device.name} Germ Shield`);
288
+ svc
289
+ .getCharacteristic(C.On)
290
+ .onGet(this.getGermShield.bind(this))
291
+ .onSet(this.setGermShield.bind(this));
292
+ });
293
+ }
294
+ setupEventListeners() {
295
+ this.device.on("stateUpdated", this.updateCharacteristics.bind(this));
296
+ }
297
+ /** Helper to get an optional service by subtype */
298
+ getOptionalService(subtype) {
299
+ return this.optionalServices.get(subtype);
300
+ }
301
+ /** Helper to update a characteristic on an optional service if it exists */
302
+ updateOptionalCharacteristic(subtype, characteristic, value) {
303
+ var _a;
304
+ (_a = this.getOptionalService(subtype)) === null || _a === void 0 ? void 0 : _a.updateCharacteristic(characteristic, value);
305
+ }
306
+ updateCharacteristics(changedStates) {
307
+ var _a, _b;
308
+ const C = this.platform.Characteristic;
309
+ for (const key of Object.keys(changedStates)) {
310
+ this.platform.log.debug(`[${this.device.name}] ${key} changed`);
311
+ switch (key) {
312
+ case "standby":
313
+ this.handleStandbyChange();
314
+ break;
315
+ case "automode":
316
+ this.handleAutomodeChange();
317
+ break;
318
+ case "childlock":
319
+ this.service.updateCharacteristic(C.LockPhysicalControls, this.getLockPhysicalControls());
320
+ break;
321
+ case "fanspeed":
322
+ this.handleFanspeedChange();
323
+ break;
324
+ case "filterusage":
325
+ (_a = this.filterMaintenanceService) === null || _a === void 0 ? void 0 : _a.updateCharacteristic(C.FilterChangeIndication, this.getFilterChangeIndication());
326
+ (_b = this.filterMaintenanceService) === null || _b === void 0 ? void 0 : _b.updateCharacteristic(C.FilterLifeLevel, this.getFilterLifeLevel());
327
+ break;
328
+ case "temperature":
329
+ this.updateOptionalCharacteristic("Temperature", C.CurrentTemperature, this.getCurrentTemperature());
330
+ break;
331
+ case "humidity":
332
+ // Update humidity on main service
333
+ this.service.updateCharacteristic(C.CurrentRelativeHumidity, this.getCurrentRelativeHumidity());
334
+ // Also update optional humidity sensor tile
335
+ this.updateOptionalCharacteristic("Humidity", C.CurrentRelativeHumidity, this.getCurrentRelativeHumidity());
336
+ break;
337
+ case "brightness":
338
+ this.updateOptionalCharacteristic("DisplayBrightness", C.On, this.getDisplayOn());
339
+ this.updateOptionalCharacteristic("DisplayBrightness", C.Brightness, this.getDisplayBrightness());
340
+ break;
341
+ case "nlbrightness":
342
+ this.updateOptionalCharacteristic("NightLight", C.On, this.getNightLightOn());
343
+ this.updateOptionalCharacteristic("NightLight", C.Brightness, this.getNightLightBrightness());
344
+ break;
345
+ case "pm25":
346
+ case "pm10":
347
+ case "voc":
348
+ this.handleAirQualityChange(key);
349
+ break;
350
+ case "germshield":
351
+ this.updateOptionalCharacteristic("GermShield", C.On, this.getGermShield());
352
+ break;
353
+ case "nightmode":
354
+ // Update the dropdown to reflect night mode state for humidifiers
355
+ if (this.deviceType === "humidifier") {
356
+ this.service.updateCharacteristic(C.TargetHumidifierDehumidifierState, this.getTargetHumidifierState());
357
+ }
358
+ break;
359
+ case "wlevel":
360
+ // Update water level for humidifiers
361
+ if (this.deviceType === "humidifier") {
362
+ this.service.updateCharacteristic(C.WaterLevel, this.getWaterLevel());
363
+ }
364
+ break;
365
+ case "autorh":
366
+ // Update target humidity for humidifiers
367
+ if (this.deviceType === "humidifier") {
368
+ this.service.updateCharacteristic(C.RelativeHumidityHumidifierThreshold, this.getTargetRelativeHumidity());
369
+ }
370
+ break;
371
+ }
372
+ }
373
+ }
374
+ handleStandbyChange() {
375
+ var _a;
376
+ const C = this.platform.Characteristic;
377
+ this.service.updateCharacteristic(C.Active, this.getActive());
378
+ if (this.deviceType === "air-purifier") {
379
+ this.service.updateCharacteristic(C.CurrentAirPurifierState, this.getCurrentAirPurifierState());
380
+ this.service.updateCharacteristic(C.TargetAirPurifierState, this.getTargetAirPurifierState());
381
+ }
382
+ else {
383
+ this.service.updateCharacteristic(C.CurrentHumidifierDehumidifierState, this.getCurrentHumidifierState());
384
+ this.service.updateCharacteristic(C.TargetHumidifierDehumidifierState, this.getTargetHumidifierState());
385
+ this.service.updateCharacteristic(C.CurrentRelativeHumidity, this.getCurrentRelativeHumidity());
386
+ this.service.updateCharacteristic(C.RelativeHumidityHumidifierThreshold, this.getTargetRelativeHumidity());
387
+ (_a = this.fanService) === null || _a === void 0 ? void 0 : _a.updateCharacteristic(C.RotationSpeed, this.getRotationSpeed());
388
+ }
389
+ this.service.updateCharacteristic(C.RotationSpeed, this.getRotationSpeed());
390
+ this.updateOptionalCharacteristic("DisplayBrightness", C.On, this.getDisplayOn());
391
+ this.updateOptionalCharacteristic("GermShield", C.On, this.getGermShield());
392
+ this.updateOptionalCharacteristic("NightMode", C.On, this.getNightMode());
393
+ this.maybeAutoAdjustFanSpeed();
394
+ }
395
+ handleAutomodeChange() {
396
+ const C = this.platform.Characteristic;
397
+ if (this.deviceType === "air-purifier") {
398
+ this.service.updateCharacteristic(C.TargetAirPurifierState, this.getTargetAirPurifierState());
399
+ }
400
+ else {
401
+ this.service.updateCharacteristic(C.TargetHumidifierDehumidifierState, this.getTargetHumidifierState());
402
+ }
403
+ }
404
+ handleFanspeedChange() {
405
+ var _a;
406
+ const C = this.platform.Characteristic;
407
+ this.service.updateCharacteristic(C.RotationSpeed, this.getRotationSpeed());
408
+ (_a = this.fanService) === null || _a === void 0 ? void 0 : _a.updateCharacteristic(C.RotationSpeed, this.getRotationSpeed());
409
+ if (this.deviceType === "air-purifier") {
410
+ this.service.updateCharacteristic(C.CurrentAirPurifierState, this.getCurrentAirPurifierState());
411
+ }
412
+ else {
413
+ this.service.updateCharacteristic(C.CurrentHumidifierDehumidifierState, this.getCurrentHumidifierState());
414
+ // Update target state dropdown (Auto/Manual/Sleep) based on fanspeed
415
+ this.service.updateCharacteristic(C.TargetHumidifierDehumidifierState, this.getTargetHumidifierState());
416
+ }
417
+ }
418
+ handleAirQualityChange(sensor) {
419
+ const C = this.platform.Characteristic;
420
+ const aqService = this.getOptionalService("AirQuality");
421
+ if (!aqService) {
422
+ return;
423
+ }
424
+ switch (sensor) {
425
+ case "pm25":
426
+ aqService.updateCharacteristic(C.PM2_5Density, this.getPM2_5Density());
427
+ break;
428
+ case "pm10":
429
+ aqService.updateCharacteristic(C.PM10Density, this.getPM10Density());
430
+ break;
431
+ case "voc":
432
+ aqService.updateCharacteristic(C.VOCDensity, this.getVOCDensity());
433
+ break;
434
+ }
435
+ aqService.updateCharacteristic(C.AirQuality, this.getAirQuality());
436
+ }
437
+ // Common getters/setters
438
+ getActive() {
439
+ return this.device.state.standby === false
440
+ ? this.platform.Characteristic.Active.ACTIVE
441
+ : this.platform.Characteristic.Active.INACTIVE;
442
+ }
443
+ async setActive(value) {
444
+ const isActive = value === this.platform.Characteristic.Active.ACTIVE;
445
+ this.platform.log.info(`[${this.device.name}] HomeKit → setActive: ${isActive ? "ON" : "OFF"}`);
446
+ await this.device.setState("standby", !isActive);
447
+ }
448
+ getCurrentTemperature() {
449
+ return this.device.sensorData.temperature || 0;
450
+ }
451
+ // Display brightness (main LED display)
452
+ getDisplayOn() {
453
+ return (this.device.state.brightness !== undefined &&
454
+ this.device.state.brightness > 0 &&
455
+ this.device.state.nightmode !== true);
456
+ }
457
+ async setDisplayOn(value) {
458
+ const turnOn = value;
459
+ this.platform.log.info(`[${this.device.name}] HomeKit → setDisplayOn: ${turnOn ? "ON" : "OFF"}`);
460
+ // Save current brightness before turning off, restore when turning on
461
+ if (!turnOn) {
462
+ this.lastBrightness = this.device.state.brightness || 0;
463
+ }
464
+ const brightness = turnOn ? this.lastBrightness || 100 : 0;
465
+ await this.device.setState("brightness", brightness);
466
+ }
467
+ getDisplayBrightness() {
468
+ return this.device.state.brightness || 0;
469
+ }
470
+ async setDisplayBrightness(value) {
471
+ const brightness = value;
472
+ this.platform.log.info(`[${this.device.name}] HomeKit → setDisplayBrightness: ${brightness}%`);
473
+ // Update lastBrightness when user sets a non-zero value
474
+ if (brightness > 0) {
475
+ this.lastBrightness = brightness;
476
+ }
477
+ await this.device.setState("brightness", brightness);
478
+ }
479
+ // Night light uses imported NL_LEVELS and helper functions from constants.ts
480
+ getNightLightOn() {
481
+ return (this.device.state.nlbrightness !== undefined &&
482
+ this.device.state.nlbrightness > 0 &&
483
+ this.device.state.nlbrightness !== 0);
484
+ }
485
+ async setNightLightOn(value) {
486
+ this.platform.log.info(`[${this.device.name}] HomeKit → setNightLightOn: ${value ? "ON" : "OFF"}`);
487
+ const deviceBrightness = value ? constants_1.NL_LEVELS.NORMAL : constants_1.NL_LEVELS.OFF;
488
+ await this.device.setState("nlbrightness", deviceBrightness);
489
+ }
490
+ getNightLightBrightness() {
491
+ return (0, constants_1.nlDeviceToHomeKit)(this.device.state.nlbrightness || 0);
492
+ }
493
+ async setNightLightBrightness(value) {
494
+ const hkValue = value;
495
+ const deviceValue = (0, constants_1.nlHomeKitToDevice)(hkValue);
496
+ this.nlBrightnessDebouncer.call(deviceValue);
497
+ }
498
+ /** Debounced execution for night light brightness */
499
+ async executeNlBrightnessChange(deviceValue) {
500
+ this.platform.log.info(`[${this.device.name}] HomeKit → setNightLightBrightness: ${(0, constants_1.nlDeviceToName)(deviceValue)} (${deviceValue})`);
501
+ await this.device.setState("nlbrightness", deviceValue);
502
+ }
503
+ getNightMode() {
504
+ return this.device.state.nightmode === true;
505
+ }
506
+ async setNightMode(value) {
507
+ this.platform.log.info(`[${this.device.name}] HomeKit → setNightMode: ${value ? "ON" : "OFF"}`);
508
+ await this.device.setState("nightmode", value);
509
+ }
510
+ // Air Purifier specific
511
+ getCurrentAirPurifierState() {
512
+ if (this.device.state.standby === false) {
513
+ return this.device.state.automode && this.device.state.fanspeed === 0
514
+ ? this.platform.Characteristic.CurrentAirPurifierState.IDLE
515
+ : this.platform.Characteristic.CurrentAirPurifierState.PURIFYING_AIR;
516
+ }
517
+ return this.platform.Characteristic.CurrentAirPurifierState.INACTIVE;
518
+ }
519
+ getTargetAirPurifierState() {
520
+ return this.device.state.automode
521
+ ? this.platform.Characteristic.TargetAirPurifierState.AUTO
522
+ : this.platform.Characteristic.TargetAirPurifierState.MANUAL;
523
+ }
524
+ async setTargetAirPurifierState(value) {
525
+ const isAuto = value === this.platform.Characteristic.TargetAirPurifierState.AUTO;
526
+ this.platform.log.info(`[${this.device.name}] HomeKit → setTargetAirPurifierState: ${isAuto ? "AUTO" : "MANUAL"}`);
527
+ await this.device.setState("automode", isAuto);
528
+ }
529
+ getLockPhysicalControls() {
530
+ return this.device.state.childlock
531
+ ? this.platform.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED
532
+ : this.platform.Characteristic.LockPhysicalControls.CONTROL_LOCK_DISABLED;
533
+ }
534
+ async setLockPhysicalControls(value) {
535
+ const isLocked = value ===
536
+ this.platform.Characteristic.LockPhysicalControls.CONTROL_LOCK_ENABLED;
537
+ this.platform.log.info(`[${this.device.name}] HomeKit → setLockPhysicalControls: ${isLocked ? "LOCKED" : "UNLOCKED"}`);
538
+ await this.device.setState("childlock", isLocked);
539
+ }
540
+ // Fan speed uses discrete HomeKit values (0, 25, 50, 75, 100) to prevent slider jumping
541
+ // 0% = Off, 25% = Sleep/Night, 50% = Low, 75% = Medium, 100% = High
542
+ getRotationSpeed() {
543
+ var _a;
544
+ const isStandby = this.device.state.standby !== false;
545
+ const isNightMode = this.device.state.nightmode === true;
546
+ const deviceSpeed = (_a = this.device.state.fanspeed) !== null && _a !== void 0 ? _a : 0;
547
+ return (0, constants_1.fanSpeedDeviceToHomeKit)(deviceSpeed, isNightMode, isStandby);
548
+ }
549
+ async setRotationSpeed(value) {
550
+ const hkSpeed = value;
551
+ const fanState = (0, constants_1.fanSpeedHomeKitToDevice)(hkSpeed);
552
+ this.fanSpeedDebouncer.call({ ...fanState, hkSpeed });
553
+ }
554
+ /** Debounced execution for fan speed changes */
555
+ async executeFanSpeedChange(params) {
556
+ const { speed, isNightMode, isStandby, hkSpeed } = params;
557
+ try {
558
+ if (isStandby) {
559
+ // 0% = Turn device off
560
+ this.platform.log.info(`[${this.device.name}] HomeKit → setFanSpeed: ${hkSpeed}% → Standby`);
561
+ await this.device.setState("standby", true);
562
+ }
563
+ else if (isNightMode && this.capabilities.hasNightMode) {
564
+ // 25% = Sleep/Night mode
565
+ this.platform.log.info(`[${this.device.name}] HomeKit → setFanSpeed: ${hkSpeed}% → Night Mode`);
566
+ this.humidityAutoControlEnabled = false;
567
+ await this.device.setState("standby", false);
568
+ if (this.capabilities.hasAutoMode) {
569
+ await this.device.setState("automode", false);
570
+ }
571
+ await this.device.setState("nightmode", true);
572
+ // Set target humidity to current humidity when leaving auto mode
573
+ await this.syncTargetHumidityToCurrent();
574
+ }
575
+ else {
576
+ // 50%, 75%, 100% = Manual fan speeds
577
+ this.platform.log.info(`[${this.device.name}] HomeKit → setFanSpeed: ${hkSpeed}% → speed ${speed}`);
578
+ this.humidityAutoControlEnabled = false;
579
+ this.lastManualOverride = Date.now();
580
+ await this.device.setState("standby", false);
581
+ if (this.capabilities.hasNightMode) {
582
+ await this.device.setState("nightmode", false);
583
+ }
584
+ if (this.capabilities.hasAutoMode) {
585
+ await this.device.setState("automode", false);
586
+ }
587
+ await this.device.setState("fanspeed", speed);
588
+ // Set target humidity to current humidity when leaving auto mode
589
+ await this.syncTargetHumidityToCurrent();
590
+ }
591
+ }
592
+ catch (error) {
593
+ this.platform.log.error(`[${this.device.name}] Failed to set fan speed: ${error}`);
594
+ }
595
+ }
596
+ getFilterChangeIndication() {
597
+ return this.device.state.filterusage !== undefined &&
598
+ this.device.state.filterusage >= this.configDev.filterChangeLevel
599
+ ? this.platform.Characteristic.FilterChangeIndication.CHANGE_FILTER
600
+ : this.platform.Characteristic.FilterChangeIndication.FILTER_OK;
601
+ }
602
+ getFilterLifeLevel() {
603
+ return 100 - (this.device.state.filterusage || 0);
604
+ }
605
+ getPM2_5Density() {
606
+ return this.device.sensorData.pm2_5 || 0;
607
+ }
608
+ getPM10Density() {
609
+ return this.device.sensorData.pm10 || 0;
610
+ }
611
+ getVOCDensity() {
612
+ return this.device.sensorData.voc || 0;
613
+ }
614
+ getAirQuality() {
615
+ const aqi = this.device.sensorData.aqi;
616
+ if (aqi === undefined) {
617
+ return this.platform.Characteristic.AirQuality.UNKNOWN;
618
+ }
619
+ const C = this.platform.Characteristic.AirQuality;
620
+ if (aqi <= constants_1.AQI_THRESHOLDS.EXCELLENT) {
621
+ return C.EXCELLENT;
622
+ }
623
+ if (aqi <= constants_1.AQI_THRESHOLDS.GOOD) {
624
+ return C.GOOD;
625
+ }
626
+ if (aqi <= constants_1.AQI_THRESHOLDS.FAIR) {
627
+ return C.FAIR;
628
+ }
629
+ if (aqi <= constants_1.AQI_THRESHOLDS.INFERIOR) {
630
+ return C.INFERIOR;
631
+ }
632
+ return C.POOR;
633
+ }
634
+ getGermShield() {
635
+ return this.device.state.germshield === true;
636
+ }
637
+ async setGermShield(value) {
638
+ this.platform.log.info(`[${this.device.name}] HomeKit → setGermShield: ${value ? "ON" : "OFF"}`);
639
+ await this.device.setState("germshield", value);
640
+ }
641
+ // Humidifier specific
642
+ findHumidityTargetAttribute() {
643
+ // First, check for a manual override from config
644
+ if (this.configDev.targetHumidityAttribute) {
645
+ const key = this.configDev.targetHumidityAttribute;
646
+ const v = this.device.state[key];
647
+ if (typeof v === "number") {
648
+ return key;
649
+ }
650
+ }
651
+ const keys = Object.keys(this.device.state || {});
652
+ const patterns = [
653
+ /^autorh$/i, // BlueAir humidifier uses "autorh"
654
+ /target\s*hum/i,
655
+ /hum\s*target/i,
656
+ /humidity\s*target/i,
657
+ /target\s*humidity/i,
658
+ /hum(i)?d(ity)?\s*set(point)?/i,
659
+ /set\s*hum/i,
660
+ ];
661
+ for (const k of keys) {
662
+ for (const p of patterns) {
663
+ if (p.test(k)) {
664
+ const v = this.device.state[k];
665
+ if (typeof v === "number") {
666
+ return k;
667
+ }
668
+ }
669
+ }
670
+ }
671
+ return null;
672
+ }
673
+ getCurrentRelativeHumidity() {
674
+ const humidity = Math.max(0, Math.min(100, this.device.sensorData.humidity || 0));
675
+ return humidity;
676
+ }
677
+ /**
678
+ * Sync target humidity to current humidity when switching to manual mode.
679
+ * This provides a sensible starting point for manual control.
680
+ */
681
+ async syncTargetHumidityToCurrent() {
682
+ // Only applies to humidifiers
683
+ if (this.deviceType !== "humidifier") {
684
+ return;
685
+ }
686
+ const currentHumidity = this.device.sensorData.humidity;
687
+ if (currentHumidity === undefined) {
688
+ this.platform.log.debug(`[${this.device.name}] Cannot sync humidity: no current humidity data`);
689
+ return;
690
+ }
691
+ const clampedHumidity = Math.max(constants_1.HUMIDITY_MIN, Math.min(constants_1.HUMIDITY_MAX, Math.round(currentHumidity)));
692
+ const attr = this.findHumidityTargetAttribute();
693
+ if (attr) {
694
+ this.platform.log.info(`[${this.device.name}] Syncing target humidity to current: ${clampedHumidity}%`);
695
+ await this.device.setState(attr, clampedHumidity);
696
+ // Update HomeKit to reflect the new target immediately
697
+ this.service.updateCharacteristic(this.platform.Characteristic.RelativeHumidityHumidifierThreshold, clampedHumidity);
698
+ }
699
+ else {
700
+ // No device attribute, update local config and HomeKit
701
+ this.platform.log.debug(`[${this.device.name}] No humidity target attribute, updating locally to ${clampedHumidity}%`);
702
+ this.configDev.targetHumidity = clampedHumidity;
703
+ this.service.updateCharacteristic(this.platform.Characteristic.RelativeHumidityHumidifierThreshold, clampedHumidity);
704
+ }
705
+ }
706
+ getTargetRelativeHumidity() {
707
+ var _a, _b;
708
+ const attr = this.findHumidityTargetAttribute();
709
+ if (attr && typeof this.device.state[attr] === "number") {
710
+ const v = this.device.state[attr];
711
+ return Math.max(constants_1.HUMIDITY_MIN, Math.min(constants_1.HUMIDITY_MAX, v));
712
+ }
713
+ const fallback = (_b = (_a = this.configDev.targetHumidity) !== null && _a !== void 0 ? _a : this.configDev.defaultTargetHumidity) !== null && _b !== void 0 ? _b : 60;
714
+ return Math.max(constants_1.HUMIDITY_MIN, Math.min(constants_1.HUMIDITY_MAX, fallback));
715
+ }
716
+ async setTargetRelativeHumidity(value) {
717
+ const desired = Math.max(constants_1.HUMIDITY_MIN, Math.min(constants_1.HUMIDITY_MAX, value));
718
+ this.humidityDebouncer.call(desired);
719
+ }
720
+ /** Debounced execution for target humidity changes */
721
+ async executeHumidityChange(valueToSet) {
722
+ this.platform.log.info(`[${this.device.name}] HomeKit → setTargetHumidity: ${valueToSet}% - enabling auto mode`);
723
+ const attr = this.findHumidityTargetAttribute();
724
+ if (attr) {
725
+ await this.device.setState(attr, valueToSet);
726
+ await this.device.setState("automode", true);
727
+ this.humidityAutoControlEnabled = true;
728
+ return;
729
+ }
730
+ // Fallback: store locally if device does not expose a writable target
731
+ if (!this.loggedNoWritableTargetHumidity) {
732
+ this.platform.log.info(`[${this.device.name}] Device did not expose a writable target humidity attribute; storing locally.`);
733
+ this.loggedNoWritableTargetHumidity = true;
734
+ }
735
+ this.configDev.targetHumidity = valueToSet;
736
+ await this.device.setState("automode", true);
737
+ this.humidityAutoControlEnabled = true;
738
+ }
739
+ maybeAutoAdjustFanSpeed() {
740
+ var _a, _b;
741
+ if (this.deviceType !== "humidifier") {
742
+ return;
743
+ }
744
+ if (!this.humidityAutoControlEnabled) {
745
+ return;
746
+ }
747
+ if (this.device.state.standby) {
748
+ return;
749
+ }
750
+ if (this.device.state.nightmode) {
751
+ return;
752
+ }
753
+ if (this.device.state.automode === false) {
754
+ return;
755
+ }
756
+ const now = Date.now();
757
+ if (now - this.lastAutoAdjust < 60000) {
758
+ return;
759
+ } // adjust at most once per minute
760
+ if (now - this.lastManualOverride < 60000) {
761
+ return;
762
+ } // respect recent manual change
763
+ const current = ((_a = this.device.sensorData.humidity) !== null && _a !== void 0 ? _a : 0);
764
+ const target = this.getTargetRelativeHumidity();
765
+ if (current === 0) {
766
+ return;
767
+ } // no data
768
+ const diff = target - current; // positive -> need more humidity
769
+ const speed = ((_b = this.device.state.fanspeed) !== null && _b !== void 0 ? _b : 0);
770
+ // Find current speed index in valid levels
771
+ const speedIndex = constants_1.FAN_SPEED_LEVELS.findIndex((lvl, i, arr) => speed <= lvl || i === arr.length - 1);
772
+ let newSpeedIndex = speedIndex;
773
+ // Use hysteresis thresholds to avoid oscillation
774
+ // Step up/down one level at a time based on humidity difference
775
+ if (diff > 2) {
776
+ // Need more humidity - increase fan speed one level
777
+ newSpeedIndex = Math.min(constants_1.FAN_SPEED_LEVELS.length - 1, speedIndex + 1);
778
+ }
779
+ else if (diff < -4) {
780
+ // Too humid - decrease fan speed one level
781
+ newSpeedIndex = Math.max(0, speedIndex - 1);
782
+ }
783
+ const newSpeed = constants_1.FAN_SPEED_LEVELS[newSpeedIndex];
784
+ if (newSpeed !== speed) {
785
+ this.platform.log.debug(`[${this.device.name}] Auto-adjust fan: humidity ${current}% target ${target}% -> speed ${speed} -> ${newSpeed}`);
786
+ this.lastAutoAdjust = now;
787
+ void this.device.setState("fanspeed", newSpeed);
788
+ }
789
+ }
790
+ getCurrentHumidifierState() {
791
+ if (this.device.state.standby === false) {
792
+ return this.device.state.automode && this.device.state.fanspeed === 0
793
+ ? this.platform.Characteristic.CurrentHumidifierDehumidifierState.IDLE
794
+ : this.platform.Characteristic.CurrentHumidifierDehumidifierState
795
+ .HUMIDIFYING;
796
+ }
797
+ return this.platform.Characteristic.CurrentHumidifierDehumidifierState
798
+ .INACTIVE;
799
+ }
800
+ getTargetHumidifierState() {
801
+ // Always return HUMIDIFIER - dropdown is locked to single value
802
+ // Night mode is controlled via fan speed slider (low speed = night mode)
803
+ return this.platform.Characteristic.TargetHumidifierDehumidifierState
804
+ .HUMIDIFIER;
805
+ }
806
+ async setTargetHumidifierState(_value) {
807
+ // No-op - mode is locked to HUMIDIFIER
808
+ // Auto/manual/night mode is controlled automatically via humidity and fan speed sliders
809
+ }
810
+ getWaterLevel() {
811
+ // Use wlevel from device state if available
812
+ const wlevel = this.device.state.wlevel;
813
+ if (typeof wlevel === "number") {
814
+ return Math.max(0, Math.min(100, wlevel));
815
+ }
816
+ // Fallback if not available
817
+ return 100;
818
+ }
819
+ }
820
+ exports.BlueAirAccessory = BlueAirAccessory;
821
+ //# sourceMappingURL=accessory.js.map