Add Airthings BLE component (#77284)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
a01f18a3ac
commit
d5b966d942
18 changed files with 829 additions and 3 deletions
|
@ -37,6 +37,8 @@ omit =
|
||||||
homeassistant/components/airnow/sensor.py
|
homeassistant/components/airnow/sensor.py
|
||||||
homeassistant/components/airthings/__init__.py
|
homeassistant/components/airthings/__init__.py
|
||||||
homeassistant/components/airthings/sensor.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/__init__.py
|
||||||
homeassistant/components/airtouch4/climate.py
|
homeassistant/components/airtouch4/climate.py
|
||||||
homeassistant/components/airtouch4/const.py
|
homeassistant/components/airtouch4/const.py
|
||||||
|
|
|
@ -45,6 +45,8 @@ build.json @home-assistant/supervisor
|
||||||
/tests/components/airnow/ @asymworks
|
/tests/components/airnow/ @asymworks
|
||||||
/homeassistant/components/airthings/ @danielhiversen
|
/homeassistant/components/airthings/ @danielhiversen
|
||||||
/tests/components/airthings/ @danielhiversen
|
/tests/components/airthings/ @danielhiversen
|
||||||
|
/homeassistant/components/airthings_ble/ @vincegio
|
||||||
|
/tests/components/airthings_ble/ @vincegio
|
||||||
/homeassistant/components/airtouch4/ @LonePurpleWolf
|
/homeassistant/components/airtouch4/ @LonePurpleWolf
|
||||||
/tests/components/airtouch4/ @LonePurpleWolf
|
/tests/components/airtouch4/ @LonePurpleWolf
|
||||||
/homeassistant/components/airvisual/ @bachya
|
/homeassistant/components/airvisual/ @bachya
|
||||||
|
|
5
homeassistant/brands/airthings.json
Normal file
5
homeassistant/brands/airthings.json
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"domain": "airthings",
|
||||||
|
"name": "Airthings",
|
||||||
|
"integrations": ["airthings", "airthings_ble"]
|
||||||
|
}
|
73
homeassistant/components/airthings_ble/__init__.py
Normal file
73
homeassistant/components/airthings_ble/__init__.py
Normal file
|
@ -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
|
169
homeassistant/components/airthings_ble/config_flow.py
Normal file
169
homeassistant/components/airthings_ble/config_flow.py
Normal file
|
@ -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),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
9
homeassistant/components/airthings_ble/const.py
Normal file
9
homeassistant/components/airthings_ble/const.py
Normal file
|
@ -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
|
15
homeassistant/components/airthings_ble/manifest.json
Normal file
15
homeassistant/components/airthings_ble/manifest.json
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
185
homeassistant/components/airthings_ble/sensor.py
Normal file
185
homeassistant/components/airthings_ble/sensor.py
Normal file
|
@ -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]
|
23
homeassistant/components/airthings_ble/strings.json
Normal file
23
homeassistant/components/airthings_ble/strings.json
Normal file
|
@ -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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
homeassistant/components/airthings_ble/translations/en.json
Normal file
21
homeassistant/components/airthings_ble/translations/en.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,10 @@ To update, run python3 -m script.hassfest
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
|
BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [
|
||||||
|
{
|
||||||
|
"domain": "airthings_ble",
|
||||||
|
"manufacturer_id": 820,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "bluemaestro",
|
"domain": "bluemaestro",
|
||||||
"manufacturer_id": 307,
|
"manufacturer_id": 307,
|
||||||
|
|
|
@ -16,6 +16,7 @@ FLOWS = {
|
||||||
"airly",
|
"airly",
|
||||||
"airnow",
|
"airnow",
|
||||||
"airthings",
|
"airthings",
|
||||||
|
"airthings_ble",
|
||||||
"airtouch4",
|
"airtouch4",
|
||||||
"airvisual",
|
"airvisual",
|
||||||
"airzone",
|
"airzone",
|
||||||
|
|
|
@ -71,9 +71,19 @@
|
||||||
"name": "AirNow"
|
"name": "AirNow"
|
||||||
},
|
},
|
||||||
"airthings": {
|
"airthings": {
|
||||||
"config_flow": true,
|
"name": "Airthings",
|
||||||
"iot_class": "cloud_polling",
|
"integrations": {
|
||||||
"name": "Airthings"
|
"airthings": {
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"name": "Airthings"
|
||||||
|
},
|
||||||
|
"airthings_ble": {
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"name": "Airthings BLE"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"airtouch4": {
|
"airtouch4": {
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
|
|
@ -293,6 +293,9 @@ aioymaps==1.2.2
|
||||||
# homeassistant.components.airly
|
# homeassistant.components.airly
|
||||||
airly==1.1.0
|
airly==1.1.0
|
||||||
|
|
||||||
|
# homeassistant.components.airthings_ble
|
||||||
|
airthings-ble==0.5.2
|
||||||
|
|
||||||
# homeassistant.components.airthings
|
# homeassistant.components.airthings
|
||||||
airthings_cloud==0.1.0
|
airthings_cloud==0.1.0
|
||||||
|
|
||||||
|
|
|
@ -268,6 +268,9 @@ aioymaps==1.2.2
|
||||||
# homeassistant.components.airly
|
# homeassistant.components.airly
|
||||||
airly==1.1.0
|
airly==1.1.0
|
||||||
|
|
||||||
|
# homeassistant.components.airthings_ble
|
||||||
|
airthings-ble==0.5.2
|
||||||
|
|
||||||
# homeassistant.components.airthings
|
# homeassistant.components.airthings
|
||||||
airthings_cloud==0.1.0
|
airthings_cloud==0.1.0
|
||||||
|
|
||||||
|
|
99
tests/components/airthings_ble/__init__.py
Normal file
99
tests/components/airthings_ble/__init__.py
Normal file
|
@ -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",
|
||||||
|
)
|
8
tests/components/airthings_ble/conftest.py
Normal file
8
tests/components/airthings_ble/conftest.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
"""Define fixtures available for all tests."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_bluetooth(enable_bluetooth):
|
||||||
|
"""Auto mock bluetooth."""
|
194
tests/components/airthings_ble/test_config_flow.py
Normal file
194
tests/components/airthings_ble/test_config_flow.py
Normal file
|
@ -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"
|
Loading…
Add table
Add a link
Reference in a new issue