From a814c508d9903082b20d9a9eac4fb33903a6053d Mon Sep 17 00:00:00 2001 From: Yu Feng Date: Thu, 28 Nov 2024 05:24:31 -0800 Subject: [PATCH] feat: Add more i35 sensors and switches. (#142) * add model name. * Better i35 support. * speed count = 64 * some of the sensors do not apply * set a default perentage to 50% when turning on. (because device does not remember last speed) * added a few sensors. * added model name look up for AWS devices i35 and 7470i * fixed a few mis-typed type annotations. * Remove unused property mutation. the setters will and shall internally update these 'local cached state' properties anyway. * comment about default fan speed. * bump version of blueair api. * fix type annotation error. * Use the debouncer, recommended by ha documentation * Set Deboncer to use the 5 second delay. Also avoid the first, immediate action because the cloud side state is not updated till after 5 seconds. * Move constant to const.py. * remove unused sleep import. * Move const and bump blueair_api version. --- custom_components/ha_blueair/binary_sensor.py | 23 +++++- .../blueair_aws_data_update_coordinator.py | 76 ++++++++++++------- custom_components/ha_blueair/const.py | 5 ++ custom_components/ha_blueair/entity.py | 6 +- custom_components/ha_blueair/fan.py | 15 +++- custom_components/ha_blueair/manifest.json | 2 +- custom_components/ha_blueair/sensor.py | 18 +++-- custom_components/ha_blueair/switch.py | 24 ++++++ 8 files changed, 128 insertions(+), 41 deletions(-) diff --git a/custom_components/ha_blueair/binary_sensor.py b/custom_components/ha_blueair/binary_sensor.py index 6e18380..8e10e8f 100644 --- a/custom_components/ha_blueair/binary_sensor.py +++ b/custom_components/ha_blueair/binary_sensor.py @@ -8,6 +8,7 @@ from .const import DOMAIN, DATA_DEVICES, DATA_AWS_DEVICES from .blueair_data_update_coordinator import BlueairDataUpdateCoordinator +from .blueair_aws_data_update_coordinator import BlueairAwsDataUpdateCoordinator from .entity import BlueairEntity @@ -25,7 +26,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) async_add_entities(entities) - aws_devices: list[BlueairDataUpdateCoordinator] = hass.data[DOMAIN][ + aws_devices: list[BlueairAwsDataUpdateCoordinator] = hass.data[DOMAIN][ DATA_AWS_DEVICES ] entities = [] @@ -34,6 +35,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): [ BlueairFilterExpiredSensor(device), BlueairOnlineSensor(device), + BlueairWaterShortageSensor(device), ] ) async_add_entities(entities) @@ -55,11 +57,11 @@ class BlueairFilterExpiredSensor(BlueairEntity, BinarySensorEntity): _attr_icon = "mdi:air-filter" def __init__(self, device): + """Initialize the temperature sensor.""" self.entity_description = EntityDescription( key=f"#{device.blueair_api_device.uuid}-filter-expired", device_class=BinarySensorDeviceClass.PROBLEM, ) - """Initialize the temperature sensor.""" super().__init__("Filter Expiration", device) @property @@ -72,11 +74,11 @@ class BlueairOnlineSensor(BlueairEntity, BinarySensorEntity): _attr_icon = "mdi:wifi-check" def __init__(self, device): + """Initialize the temperature sensor.""" self.entity_description = EntityDescription( key=f"#{device.blueair_api_device.uuid}-online", device_class=BinarySensorDeviceClass.CONNECTIVITY, ) - """Initialize the temperature sensor.""" super().__init__("Online", device) @property @@ -90,3 +92,18 @@ def icon(self) -> str | None: return self._attr_icon else: return "mdi:wifi-strength-outline" + +class BlueairWaterShortageSensor(BlueairEntity, BinarySensorEntity): + _attr_icon = "mdi:water-alert-outline" + + def __init__(self, device): + self.entity_description = EntityDescription( + key=f"#{device.blueair_api_device.uuid}-water-shortage", + device_class=BinarySensorDeviceClass.PROBLEM, + ) + super().__init__("Water Shortage", device) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._device.water_shortage diff --git a/custom_components/ha_blueair/blueair_aws_data_update_coordinator.py b/custom_components/ha_blueair/blueair_aws_data_update_coordinator.py index 5e22fe3..e2fba9b 100644 --- a/custom_components/ha_blueair/blueair_aws_data_update_coordinator.py +++ b/custom_components/ha_blueair/blueair_aws_data_update_coordinator.py @@ -1,18 +1,24 @@ """Blueair device object.""" import logging from datetime import timedelta - +import enum from blueair_api import DeviceAws as BlueAirApiDeviceAws -from asyncio import sleep from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.debounce import Debouncer -from .const import DOMAIN +from .const import DOMAIN, FILTER_EXPIRED_THRESHOLD _LOGGER = logging.getLogger(__name__) +class ModelEnum(enum.StrEnum): + UNKNOWN = "Unknown" + HUMIDIFIER_I35 = "Blueair Humidifier i35" + PROTECT_7470I = "Blueair Protect 7470i" + + class BlueairAwsDataUpdateCoordinator(DataUpdateCoordinator): """Blueair device object.""" @@ -23,12 +29,14 @@ def __init__( self.hass: HomeAssistant = hass self.blueair_api_device: BlueAirApiDeviceAws = blueair_api_device self._manufacturer: str = "BlueAir" - super().__init__( hass, _LOGGER, name=f"{DOMAIN}-{self.blueair_api_device.uuid}", update_interval=timedelta(minutes=10), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=5.0, immediate=False, + ), ) async def _async_update_data(self): @@ -56,14 +64,26 @@ def manufacturer(self) -> str: return self._manufacturer @property - def model(self) -> str: - return "protect?" + def model(self) -> ModelEnum: + if self.blueair_api_device.sku == "111633": + return ModelEnum.HUMIDIFIER_I35 + if self.blueair_api_device.sku == "105826": + return ModelEnum.PROTECT_7470I + return ModelEnum.UNKNOWN @property def fan_speed(self) -> int: """Return the current fan speed.""" return self.blueair_api_device.fan_speed + @property + def speed_count(self) -> int: + """Return the max fan speed.""" + if self.model == ModelEnum.HUMIDIFIER_I35: + return 64 + else: + return 100 + @property def is_on(self) -> False: """Return the current fan state.""" @@ -118,44 +138,48 @@ def online(self) -> bool: def fan_auto_mode(self) -> bool: return self.blueair_api_device.fan_auto_mode + @property + def wick_dry_mode(self) -> bool: + return self.blueair_api_device.wick_dry_mode + + @property + def water_shortage(self) -> bool: + return self.blueair_api_device.water_shortage + @property def filter_expired(self) -> bool: """Return the current filter status.""" - return (self.blueair_api_device.filter_usage is not None - and self.blueair_api_device.filter_usage >= 95) + if self.blueair_api_device.filter_usage is not None: + return (self.blueair_api_device.filter_usage >= + FILTER_EXPIRED_THRESHOLD) + if self.blueair_api_device.wick_usage is not None: + return (self.blueair_api_device.wick_usage >= + FILTER_EXPIRED_THRESHOLD) async def set_fan_speed(self, new_speed) -> None: - self.blueair_api_device.fan_speed = new_speed await self.blueair_api_device.set_fan_speed(new_speed) - await sleep(5) - await self.async_refresh() + await self.async_request_refresh() async def set_running(self, running) -> None: - self.blueair_api_device.running = running await self.blueair_api_device.set_running(running) - await sleep(5) - await self.async_refresh() + await self.async_request_refresh() async def set_brightness(self, brightness) -> None: - self.blueair_api_device.brightness = brightness await self.blueair_api_device.set_brightness(brightness) - await sleep(5) - await self.async_refresh() + await self.async_request_refresh() async def set_child_lock(self, locked) -> None: - self.blueair_api_device.child_lock = locked await self.blueair_api_device.set_child_lock(locked) - await sleep(5) - await self.async_refresh() + await self.async_request_refresh() async def set_night_mode(self, mode) -> None: - self.blueair_api_device.night_mode = mode await self.blueair_api_device.set_night_mode(mode) - await sleep(5) - await self.async_refresh() + await self.async_request_refresh() async def set_fan_auto_mode(self, value) -> None: - self.blueair_api_device.fan_auto_mode = value await self.blueair_api_device.set_fan_auto_mode(value) - await sleep(5) - await self.async_refresh() + await self.async_request_refresh() + + async def set_wick_dry_mode(self, value) -> None: + await self.blueair_api_device.set_wick_dry_mode(value) + await self.async_request_refresh() diff --git a/custom_components/ha_blueair/const.py b/custom_components/ha_blueair/const.py index 248c4f5..421a34e 100644 --- a/custom_components/ha_blueair/const.py +++ b/custom_components/ha_blueair/const.py @@ -13,3 +13,8 @@ REGION_EU = "eu" REGION_USA = "us" REGIONS = [REGION_USA, REGION_EU] + +DEFAULT_FAN_SPEED_PERCENTAGE = 50 +FILTER_EXPIRED_THRESHOLD = 95 + + diff --git a/custom_components/ha_blueair/entity.py b/custom_components/ha_blueair/entity.py index 581e365..49c12e8 100644 --- a/custom_components/ha_blueair/entity.py +++ b/custom_components/ha_blueair/entity.py @@ -6,6 +6,7 @@ ) from .const import DOMAIN +from .blueair_data_update_coordinator import BlueairDataUpdateCoordinator from .blueair_aws_data_update_coordinator import BlueairAwsDataUpdateCoordinator @@ -18,7 +19,7 @@ class BlueairEntity(CoordinatorEntity): def __init__( self, entity_type: str, - device: BlueairAwsDataUpdateCoordinator, + device: BlueairAwsDataUpdateCoordinator | BlueairDataUpdateCoordinator, **kwargs, ) -> None: super().__init__(device) @@ -40,6 +41,9 @@ def device_info(self) -> DeviceInfo: async def async_update(self): """Update Blueair entity.""" + if not self.enabled: + return + await self._device.async_request_refresh() self._attr_available = self._device.blueair_api_device.wifi_working diff --git a/custom_components/ha_blueair/fan.py b/custom_components/ha_blueair/fan.py index f97773a..7528b9c 100644 --- a/custom_components/ha_blueair/fan.py +++ b/custom_components/ha_blueair/fan.py @@ -6,7 +6,7 @@ FanEntityFeature, ) -from .const import DOMAIN, DATA_DEVICES, DATA_AWS_DEVICES +from .const import DOMAIN, DATA_DEVICES, DATA_AWS_DEVICES, DEFAULT_FAN_SPEED_PERCENTAGE from .blueair_data_update_coordinator import BlueairDataUpdateCoordinator from .blueair_aws_data_update_coordinator import BlueairAwsDataUpdateCoordinator from .entity import BlueairEntity @@ -107,10 +107,10 @@ def is_on(self) -> int: @property def percentage(self) -> int: """Return the current speed percentage.""" - return self._device.fan_speed + return int(self._device.fan_speed / self._device.speed_count * 100) async def async_set_percentage(self, percentage: int) -> None: - await self._device.set_fan_speed(percentage) + await self._device.set_fan_speed(int(percentage / 100 * self._device.speed_count)) self.async_write_ha_state() async def async_turn_off(self, **kwargs: any) -> None: @@ -125,10 +125,17 @@ async def async_turn_on( ) -> None: await self._device.set_running(True) self.async_write_ha_state() + if percentage is None: + # FIXME: i35 (and probably others) do not remember the + # last fan speed and always set the speed to 0. I don't know + # where to store the last fan speed such that it persists across + # HA reboots. Thus we set the default turn_on fan speed to 50% + # to make sure the fan actually spins at all. + percentage = DEFAULT_FAN_SPEED_PERCENTAGE if percentage is not None: await self.async_set_percentage(percentage=percentage) @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return 100 + return self._device.speed_count diff --git a/custom_components/ha_blueair/manifest.json b/custom_components/ha_blueair/manifest.json index dd1b434..fa74e6c 100644 --- a/custom_components/ha_blueair/manifest.json +++ b/custom_components/ha_blueair/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://github.com/dahlb/ha_blueair", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/dahlb/ha_blueair/issues", - "requirements": ["blueair-api==1.9.5"], + "requirements": ["blueair-api==1.11.0"], "version": "1.9.6" } diff --git a/custom_components/ha_blueair/sensor.py b/custom_components/ha_blueair/sensor.py index ba2de04..ec8cbb3 100644 --- a/custom_components/ha_blueair/sensor.py +++ b/custom_components/ha_blueair/sensor.py @@ -10,27 +10,33 @@ from .const import DOMAIN, DATA_AWS_DEVICES -from .blueair_data_update_coordinator import BlueairDataUpdateCoordinator +from .blueair_aws_data_update_coordinator import BlueairAwsDataUpdateCoordinator, ModelEnum from .entity import BlueairEntity async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Blueair sensors from config entry.""" - aws_devices: list[BlueairDataUpdateCoordinator] = hass.data[DOMAIN][ + aws_devices: list[BlueairAwsDataUpdateCoordinator] = hass.data[DOMAIN][ DATA_AWS_DEVICES ] entities = [] + for device in aws_devices: - entities.extend( - [ + if device.model in (ModelEnum.UNKNOWN, ModelEnum.PROTECT_7470I): + entities.extend([ BlueairTemperatureSensor(device), BlueairHumiditySensor(device), BlueairVOCSensor(device), BlueairPM1Sensor(device), BlueairPM10Sensor(device), BlueairPM25Sensor(device), - ] - ) + ]) + elif device.model == ModelEnum.HUMIDIFIER_I35: + entities.extend([ + BlueairTemperatureSensor(device), + BlueairHumiditySensor(device), + ]) + async_add_entities(entities) diff --git a/custom_components/ha_blueair/switch.py b/custom_components/ha_blueair/switch.py index 075058f..57c8b69 100644 --- a/custom_components/ha_blueair/switch.py +++ b/custom_components/ha_blueair/switch.py @@ -22,6 +22,7 @@ async def async_setup_entry(hass, _config_entry, async_add_entities): BlueairChildLockSwitchEntity(device), BlueairAutoFanModeSwitchEntity(device), BlueairNightModeSwitchEntity(device), + BlueairWickDryModeSwitchEntity(device), ] ) async_add_entities(entities) @@ -87,3 +88,26 @@ async def async_turn_on(self, **kwargs): async def async_turn_off(self, **kwargs): await self._device.set_night_mode(False) self.async_write_ha_state() + +class BlueairWickDryModeSwitchEntity(BlueairEntity, SwitchEntity): + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__(self, device): + super().__init__("Wick Dry Mode", device) + + @property + def is_on(self) -> int | None: + return self._device.wick_dry_mode + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._device.wick_dry_mode is not None + + async def async_turn_on(self, **kwargs): + await self._device.set_wick_dry_mode(True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + await self._device.set_wick_dry_mode(False) + self.async_write_ha_state()