diff --git a/custom_components/blueair/__init__.py b/custom_components/blueair/__init__.py index ebd5f5e..7c09f6e 100644 --- a/custom_components/blueair/__init__.py +++ b/custom_components/blueair/__init__.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "fan"] +PLATFORMS = ["binary_sensor", "fan", "sensor", "light"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -36,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: devices = await hass.async_add_executor_job(lambda: client.get_devices()) hass.data[DOMAIN][entry.entry_id]["devices"] = [ - BlueairDataUpdateCoordinator(hass, client, device["uuid"], device["name"]) + BlueairDataUpdateCoordinator(hass, client, device["uuid"], device["name"], device["mac"]) for device in devices ] _LOGGER.debug(f"BlueAir Devices {devices}") @@ -47,7 +47,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*tasks) - hass.config_entries.async_setup_platforms(entry, PLATFORMS) + try: + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + except AttributeError: + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/custom_components/blueair/binary_sensor.py b/custom_components/blueair/binary_sensor.py new file mode 100644 index 0000000..c37cf05 --- /dev/null +++ b/custom_components/blueair/binary_sensor.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from .const import DOMAIN +from .device import BlueairDataUpdateCoordinator +from .entity import BlueairEntity + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity +) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Blueair sensors from config entry.""" + devices: list[BlueairDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ]["devices"] + entities = [] + for device in devices: + # Don't add sensors to classic models + if ( + device.model.startswith("classic") and not device.model.endswith("i") + ) or device.model == "foobot": + pass + else: + entities.extend( + [ + BlueairFilterExpiredSensor(f"{device.device_name}_filter_expired", device), + BlueairChildLockSensor(f"{device.device_name}_child_lock", device), + BlueairOnlineSensor(f"{device.device_name}_online", device), + ] + ) + async_add_entities(entities) + + +class BlueairFilterExpiredSensor(BlueairEntity, BinarySensorEntity): + """Monitors the status of the Filter""" + + def __init__(self, name, device): + """Initialize the filter_status sensor.""" + super().__init__("filter_expired", name, device) + self._state: bool = None + self._attr_icon = "mdi:air-filter" + self._attr_device_class = BinarySensorDeviceClass.PROBLEM + + @property + def is_on(self) -> bool | None: + """Return the current filter_status.""" + return self._device.filter_expired + + +class BlueairChildLockSensor(BlueairEntity, BinarySensorEntity): + + def __init__(self, name, device): + super().__init__("child_Lock", name, device) + self._state: bool = None + self._attr_icon = "mdi:account-child-outline" + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._device.child_lock + + +class BlueairOnlineSensor(BlueairEntity, BinarySensorEntity): + def __init__(self, name, device): + """Initialize the online sensor.""" + super().__init__("online", name, device) + self._state: bool = None + self._attr_icon = "mdi:wifi-check" + self._attr_device_class = BinarySensorDeviceClass.CONNECTIVITY, + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self._device.wifi_working + + @property + def icon(self) -> str | None: + if self.is_on: + return self._attr_icon + else: + return "mdi:wifi-strength-outline" diff --git a/custom_components/blueair/blueair/blueair.py b/custom_components/blueair/blueair/blueair.py index d56cce1..7f460d9 100644 --- a/custom_components/blueair/blueair/blueair.py +++ b/custom_components/blueair/blueair/blueair.py @@ -185,6 +185,26 @@ def set_fan_speed(self, device_uuid, new_speed): }, ) + def set_brightness(self, device_uuid, brightness): + """ + Set the brightness per @spikeyGG comment at https://community.home-assistant.io/t/blueair-purifier-addon/154456/14 + """ + res = requests.post( + f"https://{self.home_host}/v2/device/{device_uuid}/attribute/brightness/", + headers={ + "Content-Type": "application/json", + "X-API-KEY-TOKEN": API_KEY, + "X-AUTH-TOKEN": self.auth_token, + }, + json={ + "currentValue": brightness, + "scope": "device", + "defaultValue": brightness, + "name": "brightness", + "uuid": device_uuid, + }, + ) + def set_fan_mode(self, device_uuid, new_mode): """ Set the fan mode to automatic diff --git a/custom_components/blueair/device.py b/custom_components/blueair/device.py index 71fe074..7757ff8 100644 --- a/custom_components/blueair/device.py +++ b/custom_components/blueair/device.py @@ -4,7 +4,6 @@ from typing import Any from async_timeout import timeout - from . import blueair API = blueair.BlueAir @@ -20,13 +19,15 @@ class BlueairDataUpdateCoordinator(DataUpdateCoordinator): """Blueair device object.""" def __init__( - self, hass: HomeAssistant, api_client: API, uuid: str, device_name: str + self, hass: HomeAssistant, api_client: API, uuid: str, device_name: str, + mac: str = None, ) -> None: """Initialize the device.""" self.hass: HomeAssistant = hass self.api_client: API = api_client self._uuid: str = uuid self._name: str = device_name + self.mac = mac self._manufacturer: str = "BlueAir" self._device_information: dict[str, Any] = {} self._datapoint: dict[str, Any] = {} @@ -68,63 +69,63 @@ def model(self) -> str: return self._device_information.get("compatibility", self.id) @property - def temperature(self) -> float: + def temperature(self) -> float | None: """Return the current temperature in degrees C.""" if "temperature" not in self._datapoint: return None return self._datapoint["temperature"] @property - def humidity(self) -> float: + def humidity(self) -> float | None: """Return the current relative humidity percentage.""" if "humidity" not in self._datapoint: return None return self._datapoint["humidity"] @property - def co2(self) -> float: + def co2(self) -> float | None: """Return the current co2.""" if "co2" not in self._datapoint: return None return self._datapoint["co2"] @property - def voc(self) -> float: + def voc(self) -> float | None: """Return the current voc.""" if "voc" not in self._datapoint: return None return self._datapoint["voc"] @property - def pm1(self) -> float: + def pm1(self) -> float | None: """Return the current pm1.""" if "pm1" not in self._datapoint: return None return self._datapoint["pm1"] @property - def pm10(self) -> float: + def pm10(self) -> float | None: """Return the current pm10.""" if "pm10" not in self._datapoint: return None return self._datapoint["pm10"] @property - def pm25(self) -> float: + def pm25(self) -> float | None: """Return the current pm25.""" if "pm25" not in self._datapoint: return None return self._datapoint["pm25"] @property - def all_pollution(self) -> float: + def all_pollution(self) -> float | None: """Return all pollution""" if "all_pollution" not in self._datapoint: return None return self._datapoint["all_pollution"] @property - def fan_speed(self) -> int: + def fan_speed(self) -> int | None: """Return the current fan speed.""" if "fan_speed" not in self._attribute: return None @@ -140,7 +141,7 @@ def is_on(self) -> bool(): return True @property - def fan_mode(self) -> str: + def fan_mode(self) -> str | None: """Return the current fan mode""" if self._attribute["mode"] == "manual": return None @@ -155,11 +156,39 @@ def fan_mode_supported(self) -> bool(): return False @property - def filter_status(self) -> str: + def filter_expired(self) -> bool | None: """Return the current filter status.""" if "filter_status" not in self._attribute: return None - return self._attribute["filter_status"] + return self._attribute["filter_status"] != "OK" + + @property + def child_lock(self) -> bool | None: + """Return the current filter status.""" + if "child_lock" not in self._attribute: + return None + return bool(self._attribute["child_lock"]) + + @property + def wifi_working(self) -> bool | None: + """Return the current filter status.""" + if "wifi_status" not in self._attribute: + return None + return self._attribute["wifi_status"] == "1" + + @property + def brightness(self) -> int | None: + """Return the current filter status.""" + if "brightness" not in self._attribute: + return None + return int(self._attribute["brightness"]) + + async def set_brightness(self, brightness) -> None: + await self.hass.async_add_executor_job( + lambda: self.api_client.set_brightness(self.id, brightness) + ) + self._attribute["brightness"] = brightness + await self.async_refresh() async def set_fan_speed(self, new_speed) -> None: await self.hass.async_add_executor_job( diff --git a/custom_components/blueair/entity.py b/custom_components/blueair/entity.py index db0149d..fc9636e 100644 --- a/custom_components/blueair/entity.py +++ b/custom_components/blueair/entity.py @@ -31,7 +31,9 @@ def __init__( @property def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" + connections = {(CONNECTION_NETWORK_MAC, self._device.mac)} return { + "connections": connections, "identifiers": {(DOMAIN, self._device.id)}, "manufacturer": self._device.manufacturer, "model": self._device.model, @@ -41,6 +43,7 @@ def device_info(self) -> DeviceInfo: async def async_update(self): """Update Blueair entity.""" await self._device.async_request_refresh() + self._attr_available = self._device.wifi_working async def async_added_to_hass(self): """When entity is added to hass.""" diff --git a/custom_components/blueair/light.py b/custom_components/blueair/light.py new file mode 100644 index 0000000..5a74f6a --- /dev/null +++ b/custom_components/blueair/light.py @@ -0,0 +1,60 @@ +from .const import DOMAIN +from .device import BlueairDataUpdateCoordinator +from .entity import BlueairEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ColorMode, + LightEntity, +) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Blueair sensors from config entry.""" + devices: list[BlueairDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ]["devices"] + entities = [] + for device in devices: + # Don't add sensors to classic models + if ( + device.model.startswith("classic") and not device.model.endswith("i") + ) or device.model == "foobot": + pass + else: + entities.extend( + [ + BlueairLightEntity(f"{device.device_name}_light", device), + ] + ) + async_add_entities(entities) + + +class BlueairLightEntity(BlueairEntity, LightEntity): + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__(self, name, device): + super().__init__("LED Light", name, device) + + @property + def brightness(self) -> int | None: + """Return the brightness of this light between 0..255.""" + return round(self._device.brightness / 100 * 255.0, 0) + + @property + def is_on(self) -> bool: + """Return True if the entity is on.""" + return self._device.brightness != 0 + + async def async_turn_on(self, **kwargs): + if ATTR_BRIGHTNESS in kwargs: + # Convert Home Assistant brightness (0-255) to Abode brightness (0-99) + # If 100 is sent to Abode, response is 99 causing an error + await self._device.set_brightness( + round(kwargs[ATTR_BRIGHTNESS] * 100 / 255.0) + ) + else: + await self._device.set_brightness(100) + + async def async_turn_off(self, **kwargs): + await self._device.set_brightness(0) \ No newline at end of file diff --git a/custom_components/blueair/sensor.py b/custom_components/blueair/sensor.py index 6e11948..6876d70 100644 --- a/custom_components/blueair/sensor.py +++ b/custom_components/blueair/sensor.py @@ -1,5 +1,7 @@ """Support for Blueair sensors.""" -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorEntity +) from homeassistant.const import ( DEVICE_CLASS_CO2, DEVICE_CLASS_TEMPERATURE, @@ -11,7 +13,6 @@ TEMP_CELSIUS, PERCENTAGE, ) - from .const import DOMAIN from .device import BlueairDataUpdateCoordinator from .entity import BlueairEntity @@ -29,10 +30,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in devices: # Don't add sensors to classic models if ( - device.model.startswith("classic") and not device.model.endswith("i") + device.model.startswith("classic") and not device.model.endswith("i") ) or device.model == "foobot": pass - else: + else: entities.extend( [ BlueairTemperatureSensor(f"{device.device_name}_temperature", device), @@ -200,19 +201,3 @@ def native_value(self) -> float: if self._device.pm25 is None: return None return round(self._device.pm25, 0) - -class BlueairFilterStatusSensor(BlueairEntity, SensorEntity): - """Monitors the status of the Filter""" - - def __init__(self, name, device): - """Initialize the filter_status sensor.""" - super().__init__("filter_status", name, device) - self._state: str = None - self._attr_icon = "mdi:air-filter" - - @property - def native_value(self) -> float: - """Return the current filter_status.""" - if self._device.filter_status is None: - return None - return str(self._device.filter_status)