From d5b966d942a07036451505a4236d61d2884c7b68 Mon Sep 17 00:00:00 2001 From: Vincent Giorgi Date: Thu, 29 Sep 2022 21:55:45 +0200 Subject: [PATCH] Add Airthings BLE component (#77284) Co-authored-by: Paulus Schoutsen Co-authored-by: J. Nick Koston --- .coveragerc | 2 + CODEOWNERS | 2 + homeassistant/brands/airthings.json | 5 + .../components/airthings_ble/__init__.py | 73 +++++++ .../components/airthings_ble/config_flow.py | 169 +++++++++++++++ .../components/airthings_ble/const.py | 9 + .../components/airthings_ble/manifest.json | 15 ++ .../components/airthings_ble/sensor.py | 185 +++++++++++++++++ .../components/airthings_ble/strings.json | 23 +++ .../airthings_ble/translations/en.json | 21 ++ homeassistant/generated/bluetooth.py | 4 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 16 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airthings_ble/__init__.py | 99 +++++++++ tests/components/airthings_ble/conftest.py | 8 + .../airthings_ble/test_config_flow.py | 194 ++++++++++++++++++ 18 files changed, 829 insertions(+), 3 deletions(-) create mode 100644 homeassistant/brands/airthings.json create mode 100644 homeassistant/components/airthings_ble/__init__.py create mode 100644 homeassistant/components/airthings_ble/config_flow.py create mode 100644 homeassistant/components/airthings_ble/const.py create mode 100644 homeassistant/components/airthings_ble/manifest.json create mode 100644 homeassistant/components/airthings_ble/sensor.py create mode 100644 homeassistant/components/airthings_ble/strings.json create mode 100644 homeassistant/components/airthings_ble/translations/en.json create mode 100644 tests/components/airthings_ble/__init__.py create mode 100644 tests/components/airthings_ble/conftest.py create mode 100644 tests/components/airthings_ble/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ba07953cca3..45b9c8d1086 100644 --- a/.coveragerc +++ b/.coveragerc @@ -37,6 +37,8 @@ omit = homeassistant/components/airnow/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py + homeassistant/components/airthings_ble/__init__.py + homeassistant/components/airthings_ble/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 5c39337af74..d6d4ca61613 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -45,6 +45,8 @@ build.json @home-assistant/supervisor /tests/components/airnow/ @asymworks /homeassistant/components/airthings/ @danielhiversen /tests/components/airthings/ @danielhiversen +/homeassistant/components/airthings_ble/ @vincegio +/tests/components/airthings_ble/ @vincegio /homeassistant/components/airtouch4/ @LonePurpleWolf /tests/components/airtouch4/ @LonePurpleWolf /homeassistant/components/airvisual/ @bachya diff --git a/homeassistant/brands/airthings.json b/homeassistant/brands/airthings.json new file mode 100644 index 00000000000..e83546f9d61 --- /dev/null +++ b/homeassistant/brands/airthings.json @@ -0,0 +1,5 @@ +{ + "domain": "airthings", + "name": "Airthings", + "integrations": ["airthings", "airthings_ble"] +} diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py new file mode 100644 index 00000000000..4e066ea8447 --- /dev/null +++ b/homeassistant/components/airthings_ble/__init__.py @@ -0,0 +1,73 @@ +"""The Airthings BLE integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from airthings_ble import AirthingsBluetoothDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airthings BLE device from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + address = entry.unique_id + + elevation = hass.config.elevation + is_metric = hass.config.units.is_metric + assert address is not None + + ble_device = bluetooth.async_ble_device_from_address(hass, address) + + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find Airthings device with address {address}" + ) + + async def _async_update_method(): + """Get data from Airthings BLE.""" + ble_device = bluetooth.async_ble_device_from_address(hass, address) + airthings = AirthingsBluetoothDeviceData(_LOGGER, elevation, is_metric) + + try: + data = await airthings.update_device(ble_device) + except Exception as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_async_update_method, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py new file mode 100644 index 00000000000..6d5df7ddd56 --- /dev/null +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for Airthings BlE integration.""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any + +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak import BleakError +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothServiceInfo, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, MFCT_ID + +_LOGGER = logging.getLogger(__name__) + + +@dataclasses.dataclass +class Discovery: + """A discovered bluetooth device.""" + + name: str + discovery_info: BluetoothServiceInfo + device: AirthingsDevice + + +def get_name(device: AirthingsDevice) -> str: + """Generate name with identifier for device.""" + return f"{device.name} ({device.identifier})" + + +class AirthingsDeviceUpdateError(Exception): + """Custom error class for device updates.""" + + +class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airthings BLE.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_device: Discovery | None = None + self._discovered_devices: dict[str, Discovery] = {} + + async def _get_device_data( + self, discovery_info: BluetoothServiceInfo + ) -> AirthingsDevice: + ble_device = bluetooth.async_ble_device_from_address( + self.hass, discovery_info.address + ) + if ble_device is None: + _LOGGER.debug("no ble_device in _get_device_data") + raise AirthingsDeviceUpdateError("No ble_device") + + airthings = AirthingsBluetoothDeviceData(_LOGGER) + + try: + data = await airthings.update_device(ble_device) + except BleakError as err: + _LOGGER.error( + "Error connecting to and getting data from %s: %s", + discovery_info.address, + err, + ) + raise AirthingsDeviceUpdateError("Failed getting device data") from err + except Exception as err: + _LOGGER.error( + "Unknown error occurred from %s: %s", discovery_info.address, err + ) + raise err + return data + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfo + ) -> FlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("Discovered BT device: %s", discovery_info) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + try: + device = await self._get_device_data(discovery_info) + except AirthingsDeviceUpdateError: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="unknown") + + name = get_name(device) + self.context["title_placeholders"] = {"name": name} + self._discovered_device = Discovery(name, discovery_info, device) + + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.context["title_placeholders"]["name"], data={} + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders=self.context["title_placeholders"], + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + discovery = self._discovered_devices[address] + + self.context["title_placeholders"] = { + "name": discovery.name, + } + + self._discovered_device = discovery + + return self.async_create_entry(title=discovery.name, data={}) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + + if MFCT_ID not in discovery_info.manufacturer_data: + continue + + try: + device = await self._get_device_data(discovery_info) + except AirthingsDeviceUpdateError: + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + return self.async_abort(reason="unknown") + name = get_name(device) + self._discovered_devices[address] = Discovery(name, discovery_info, device) + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + titles = { + address: get_name(discovery.device) + for (address, discovery) in self._discovered_devices.items() + } + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In(titles), + }, + ), + ) diff --git a/homeassistant/components/airthings_ble/const.py b/homeassistant/components/airthings_ble/const.py new file mode 100644 index 00000000000..96372919e70 --- /dev/null +++ b/homeassistant/components/airthings_ble/const.py @@ -0,0 +1,9 @@ +"""Constants for Airthings BLE.""" + +DOMAIN = "airthings_ble" +MFCT_ID = 820 + +VOLUME_BECQUEREL = "Bq/m³" +VOLUME_PICOCURIE = "pCi/L" + +DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json new file mode 100644 index 00000000000..dca2dbbb562 --- /dev/null +++ b/homeassistant/components/airthings_ble/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "airthings_ble", + "name": "Airthings BLE", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airthings_ble", + "requirements": ["airthings-ble==0.5.2"], + "dependencies": ["bluetooth"], + "codeowners": ["@vincegio"], + "iot_class": "local_polling", + "bluetooth": [ + { + "manufacturer_id": 820 + } + ] +} diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py new file mode 100644 index 00000000000..0f0ca2e4af5 --- /dev/null +++ b/homeassistant/components/airthings_ble/sensor.py @@ -0,0 +1,185 @@ +"""Support for airthings ble sensors.""" +from __future__ import annotations + +import logging + +from airthings_ble import AirthingsDevice + +from homeassistant import config_entries +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + LIGHT_LUX, + PERCENTAGE, + PRESSURE_MBAR, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, VOLUME_BECQUEREL, VOLUME_PICOCURIE + +_LOGGER = logging.getLogger(__name__) + +SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { + "radon_1day_avg": SensorEntityDescription( + key="radon_1day_avg", + native_unit_of_measurement=VOLUME_BECQUEREL, + name="Radon 1-day average", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), + "radon_longterm_avg": SensorEntityDescription( + key="radon_longterm_avg", + native_unit_of_measurement=VOLUME_BECQUEREL, + name="Radon longterm average", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:radioactive", + ), + "radon_1day_level": SensorEntityDescription( + key="radon_1day_level", + name="Radon 1-day level", + icon="mdi:radioactive", + ), + "radon_longterm_level": SensorEntityDescription( + key="radon_longterm_level", + name="Radon longterm level", + icon="mdi:radioactive", + ), + "temperature": SensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + ), + "humidity": SensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + ), + "pressure": SensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=PRESSURE_MBAR, + name="Pressure", + ), + "battery": SensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + name="Battery", + ), + "co2": SensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="co2", + ), + "voc": SensorEntityDescription( + key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="VOC", + icon="mdi:cloud", + ), + "illuminance": SensorEntityDescription( + key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + name="Illuminance", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airthings BLE sensors.""" + is_metric = hass.config.units.is_metric + + coordinator: DataUpdateCoordinator[AirthingsDevice] = hass.data[DOMAIN][ + entry.entry_id + ] + + # we need to change some units + sensors_mapping = SENSORS_MAPPING_TEMPLATE.copy() + if not is_metric: + for val in sensors_mapping.values(): + if val.native_unit_of_measurement is not VOLUME_BECQUEREL: + continue + val.native_unit_of_measurement = VOLUME_PICOCURIE + + entities = [] + _LOGGER.debug("got sensors: %s", coordinator.data.sensors) + for sensor_type, sensor_value in coordinator.data.sensors.items(): + if sensor_type not in sensors_mapping: + _LOGGER.debug( + "Unknown sensor type detected: %s, %s", + sensor_type, + sensor_value, + ) + continue + entities.append( + AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) + ) + + async_add_entities(entities) + + +class AirthingsSensor( + CoordinatorEntity[DataUpdateCoordinator[AirthingsDevice]], SensorEntity +): + """Airthings BLE sensors for the device.""" + + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + airthings_device: AirthingsDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Populate the airthings entity with relevant data.""" + super().__init__(coordinator) + self.entity_description = entity_description + + name = f"{airthings_device.name} {airthings_device.identifier}" + + self._attr_unique_id = f"{name}_{entity_description.key}" + + self._id = airthings_device.address + self._attr_device_info = DeviceInfo( + connections={ + ( + CONNECTION_BLUETOOTH, + airthings_device.address, + ) + }, + name=name, + manufacturer="Airthings", + hw_version=airthings_device.hw_version, + sw_version=airthings_device.sw_version, + ) + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data.sensors[self.entity_description.key] diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json new file mode 100644 index 00000000000..1cfc4ccd592 --- /dev/null +++ b/homeassistant/components/airthings_ble/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:component::bluetooth::config::step::user::data::address%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/airthings_ble/translations/en.json b/homeassistant/components/airthings_ble/translations/en.json new file mode 100644 index 00000000000..d24df64f135 --- /dev/null +++ b/homeassistant/components/airthings_ble/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Configuration flow is already in progress", + "no_devices_found": "No devices found on the network" + }, + "flow_title": "{name}", + "step": { + "bluetooth_confirm": { + "description": "Do you want to setup {name}?" + }, + "user": { + "data": { + "address": "Device" + }, + "description": "Choose a device to setup" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 3036f691f00..cffcac7558c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -5,6 +5,10 @@ To update, run python3 -m script.hassfest from __future__ import annotations BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ + { + "domain": "airthings_ble", + "manufacturer_id": 820, + }, { "domain": "bluemaestro", "manufacturer_id": 307, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ba6c76d329a..98f1d3ab7f8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ FLOWS = { "airly", "airnow", "airthings", + "airthings_ble", "airtouch4", "airvisual", "airzone", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 75e0296f3c8..5a97ffa2dd0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -71,9 +71,19 @@ "name": "AirNow" }, "airthings": { - "config_flow": true, - "iot_class": "cloud_polling", - "name": "Airthings" + "name": "Airthings", + "integrations": { + "airthings": { + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Airthings" + }, + "airthings_ble": { + "config_flow": true, + "iot_class": "local_polling", + "name": "Airthings BLE" + } + } }, "airtouch4": { "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index e0e7f4261ea..db76ee15df4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -293,6 +293,9 @@ aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings_ble +airthings-ble==0.5.2 + # homeassistant.components.airthings airthings_cloud==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf7b95efd29..d2c3c22a590 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -268,6 +268,9 @@ aioymaps==1.2.2 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings_ble +airthings-ble==0.5.2 + # homeassistant.components.airthings airthings_cloud==0.1.0 diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py new file mode 100644 index 00000000000..c6b59e02c15 --- /dev/null +++ b/tests/components/airthings_ble/__init__.py @@ -0,0 +1,99 @@ +"""Tests for the Airthings BLE integration.""" +from typing import Union +from unittest.mock import patch + +from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData + +from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak + + +def patch_async_setup_entry(return_value=True): + """Patch async setup entry to return True.""" + return patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=return_value, + ) + + +def patch_async_ble_device_from_address( + return_value: Union[BluetoothServiceInfoBleak, None] +): + """Patch async ble device from address to return a given value.""" + return patch( + "homeassistant.components.bluetooth.async_ble_device_from_address", + return_value=return_value, + ) + + +def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): + """Patch airthings-ble device fetcher with given values and effects.""" + return patch.object( + AirthingsBluetoothDeviceData, + "update_device", + return_value=return_value, + side_effect=side_effect, + ) + + +WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( + name="cc-cc-cc-cc-cc-cc", + address="cc:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_data={}, + service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + source="local", + device=BLEDevice( + "cc:cc:cc:cc:cc:cc", + "cc-cc-cc-cc-cc-cc", + ), + advertisement=AdvertisementData( + manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, + service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + ), + connectable=True, + time=0, +) + +UNKNOWN_SERVICE_INFO = BluetoothServiceInfoBleak( + name="unknown", + address="00:cc:cc:cc:cc:cc", + rssi=-61, + manufacturer_data={}, + service_data={}, + service_uuids=[], + source="local", + device=BLEDevice( + "cc:cc:cc:cc:cc:cc", + "unknown", + ), + advertisement=AdvertisementData( + manufacturer_data={}, + service_uuids=[], + ), + connectable=True, + time=0, +) + +WAVE_DEVICE_INFO = AirthingsDevice( + hw_version="REV A", + sw_version="G-BLE-1.5.3-master+0", + name="Airthings Wave+", + identifier="123456", + sensors={ + "illuminance": 25, + "battery": 85, + "humidity": 60.0, + "radon_1day_avg": 30, + "radon_longterm_avg": 30, + "temperature": 21.0, + "co2": 500.0, + "voc": 155.0, + "radon_1day_level": "very low", + "radon_longterm_level": "very low", + "pressure": 1020, + }, + address="cc:cc:cc:cc:cc:cc", +) diff --git a/tests/components/airthings_ble/conftest.py b/tests/components/airthings_ble/conftest.py new file mode 100644 index 00000000000..3df082c4361 --- /dev/null +++ b/tests/components/airthings_ble/conftest.py @@ -0,0 +1,8 @@ +"""Define fixtures available for all tests.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/airthings_ble/test_config_flow.py b/tests/components/airthings_ble/test_config_flow.py new file mode 100644 index 00000000000..ddddcdbc94a --- /dev/null +++ b/tests/components/airthings_ble/test_config_flow.py @@ -0,0 +1,194 @@ +"""Test the Airthings BLE config flow.""" +from unittest.mock import patch + +from airthings_ble import AirthingsDevice +from bleak import BleakError + +from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + UNKNOWN_SERVICE_INFO, + WAVE_DEVICE_INFO, + WAVE_SERVICE_INFO, + patch_airthings_ble, + patch_async_ble_device_from_address, + patch_async_setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_bluetooth_discovery(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device.""" + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble( + AirthingsDevice(name="Airthings Wave+", identifier="123456") + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["description_placeholders"] == {"name": "Airthings Wave+ (123456)"} + + with patch_async_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"not": "empty"} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave+ (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + +async def test_bluetooth_discovery_no_BLEDevice(hass: HomeAssistant): + """Test discovery via bluetooth but there's no BLEDevice.""" + with patch_async_ble_device_from_address(None): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_bluetooth_discovery_airthings_ble_update_failed( + hass: HomeAssistant, +): + """Test discovery via bluetooth but there's an exception from airthings-ble.""" + for loop in [(Exception(), "unknown"), (BleakError(), "cannot_connect")]: + exc, reason = loop + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(side_effect=exc): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_SERVICE_INFO, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_bluetooth_discovery_already_setup(hass: HomeAssistant): + """Test discovery via bluetooth with a valid device when already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=WAVE_DEVICE_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_user_setup(hass: HomeAssistant): + """Test the user initiated form.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble( + AirthingsDevice(name="Airthings Wave+", identifier="123456") + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["data_schema"] is not None + schema = result["data_schema"].schema + + assert schema.get(CONF_ADDRESS).container == { + "cc:cc:cc:cc:cc:cc": "Airthings Wave+ (123456)" + } + + with patch( + "homeassistant.components.airthings_ble.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ADDRESS: "cc:cc:cc:cc:cc:cc"} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Airthings Wave+ (123456)" + assert result["result"].unique_id == "cc:cc:cc:cc:cc:cc" + + +async def test_user_setup_no_device(hass: HomeAssistant): + """Test the user initiated form without any device detected.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_existing_and_unknown_device(hass: HomeAssistant): + """Test the user initiated form with existing devices and unknown ones.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cc:cc:cc:cc:cc:cc", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[UNKNOWN_SERVICE_INFO, WAVE_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_user_setup_unknown_error(hass: HomeAssistant): + """Test the user initiated form with an unknown error.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(None, Exception()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_user_setup_unable_to_connect(hass: HomeAssistant): + """Test the user initiated form with a device that's failing connection.""" + with patch( + "homeassistant.components.airthings_ble.config_flow.async_discovered_service_info", + return_value=[WAVE_SERVICE_INFO], + ): + with patch_async_ble_device_from_address(WAVE_SERVICE_INFO): + with patch_airthings_ble(side_effect=BleakError("An error")): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect"