diff --git a/.coveragerc b/.coveragerc index 70e81b377ca..9cc1e4b9533 100644 --- a/.coveragerc +++ b/.coveragerc @@ -36,6 +36,8 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airthings/__init__.py + homeassistant/components/airthings/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py homeassistant/components/airtouch4/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 76748306cfe..375842e33cd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -29,6 +29,7 @@ homeassistant/components/aemet/* @noltari homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu homeassistant/components/airnow/* @asymworks +homeassistant/components/airthings/* @danielhiversen homeassistant/components/airtouch4/* @LonePurpleWolf homeassistant/components/airvisual/* @bachya homeassistant/components/alarmdecoder/* @ajschmidt8 diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py new file mode 100644 index 00000000000..601396d36da --- /dev/null +++ b/homeassistant/components/airthings/__init__.py @@ -0,0 +1,61 @@ +"""The Airthings integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from airthings import Airthings, AirthingsError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[str] = ["sensor"] +SCAN_INTERVAL = timedelta(minutes=6) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airthings from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + airthings = Airthings( + entry.data[CONF_ID], + entry.data[CONF_SECRET], + async_get_clientsession(hass), + ) + + async def _update_method(): + """Get the latest data from Airthings.""" + try: + return await airthings.update_devices() + except AirthingsError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=_update_method, + update_interval=SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py new file mode 100644 index 00000000000..842f05d76db --- /dev/null +++ b/homeassistant/components/airthings/config_flow.py @@ -0,0 +1,67 @@ +"""Config flow for Airthings integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import airthings +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_ID, CONF_SECRET, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ID): str, + vol.Required(CONF_SECRET): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Airthings.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders={ + "url": "https://dashboard.airthings.com/integrations/api-integration", + }, + ) + + errors = {} + + try: + await airthings.get_token( + async_get_clientsession(self.hass), + user_input[CONF_ID], + user_input[CONF_SECRET], + ) + except airthings.AirthingsConnectionError: + errors["base"] = "cannot_connect" + except airthings.AirthingsAuthError: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title="Airthings", data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airthings/const.py b/homeassistant/components/airthings/const.py new file mode 100644 index 00000000000..70de549141b --- /dev/null +++ b/homeassistant/components/airthings/const.py @@ -0,0 +1,6 @@ +"""Constants for the Airthings integration.""" + +DOMAIN = "airthings" + +CONF_ID = "id" +CONF_SECRET = "secret" diff --git a/homeassistant/components/airthings/manifest.json b/homeassistant/components/airthings/manifest.json new file mode 100644 index 00000000000..749a5e44992 --- /dev/null +++ b/homeassistant/components/airthings/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airthings", + "name": "Airthings", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airthings", + "requirements": ["airthings_cloud==0.0.1"], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} \ No newline at end of file diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py new file mode 100644 index 00000000000..b40e8b06400 --- /dev/null +++ b/homeassistant/components/airthings/sensor.py @@ -0,0 +1,127 @@ +"""Support for Airthings sensors.""" +from __future__ import annotations + +from airthings import AirthingsDevice + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO2, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + PRESSURE_MBAR, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN + +SENSORS: dict[str, SensorEntityDescription] = { + "radonShortTermAvg": SensorEntityDescription( + key="radonShortTermAvg", + native_unit_of_measurement="Bq/m³", + name="Radon", + ), + "temp": SensorEntityDescription( + key="temp", + device_class=DEVICE_CLASS_TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + name="Temperature", + ), + "humidity": SensorEntityDescription( + key="humidity", + device_class=DEVICE_CLASS_HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + name="Humidity", + ), + "pressure": SensorEntityDescription( + key="pressure", + device_class=DEVICE_CLASS_PRESSURE, + native_unit_of_measurement=PRESSURE_MBAR, + name="Pressure", + ), + "battery": SensorEntityDescription( + key="battery", + device_class=DEVICE_CLASS_BATTERY, + native_unit_of_measurement=PERCENTAGE, + name="Battery", + ), + "co2": SensorEntityDescription( + key="co2", + device_class=DEVICE_CLASS_CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + name="CO2", + ), + "voc": SensorEntityDescription( + key="voc", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + name="VOC", + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Airthings sensor.""" + + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + entities = [ + AirthingsHeaterEnergySensor( + coordinator, + airthings_device, + SENSORS[sensor_types], + ) + for airthings_device in coordinator.data.values() + for sensor_types in airthings_device.sensor_types + if sensor_types in SENSORS + ] + async_add_entities(entities) + + +class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): + """Representation of a Airthings Sensor device.""" + + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__( + self, + coordinator: DataUpdateCoordinator, + airthings_device: AirthingsDevice, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + + self.entity_description = entity_description + + self._attr_name = f"{airthings_device.name} {entity_description.name}" + self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" + self._id = airthings_device.device_id + self._attr_device_info = { + "identifiers": {(DOMAIN, airthings_device.device_id)}, + "name": self.name, + "manufacturer": "Airthings", + } + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.coordinator.data[self._id].sensors[self.entity_description.key] diff --git a/homeassistant/components/airthings/strings.json b/homeassistant/components/airthings/strings.json new file mode 100644 index 00000000000..32f3fbc6954 --- /dev/null +++ b/homeassistant/components/airthings/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "id": "ID", + "secret": "Secret", + "description": "Login at {url} to find your credentials" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0983da03f98..78dc71976e6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -16,6 +16,7 @@ FLOWS = [ "agent_dvr", "airly", "airnow", + "airthings", "airtouch4", "airvisual", "alarmdecoder", diff --git a/requirements_all.txt b/requirements_all.txt index 07507251f73..be626ba7ba0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -263,6 +263,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings +airthings_cloud==0.0.1 + # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b4a7bdb0ce..32badcb7b66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -187,6 +187,9 @@ aioymaps==1.1.0 # homeassistant.components.airly airly==1.1.0 +# homeassistant.components.airthings +airthings_cloud==0.0.1 + # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 diff --git a/tests/components/airthings/__init__.py b/tests/components/airthings/__init__.py new file mode 100644 index 00000000000..e331fb2f2c6 --- /dev/null +++ b/tests/components/airthings/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airthings integration.""" diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py new file mode 100644 index 00000000000..ad9d44a054a --- /dev/null +++ b/tests/components/airthings/test_config_flow.py @@ -0,0 +1,117 @@ +"""Test the Airthings config flow.""" +from unittest.mock import patch + +import airthings + +from homeassistant import config_entries, setup +from homeassistant.components.airthings.const import CONF_ID, CONF_SECRET, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + +TEST_DATA = { + CONF_ID: "client_id", + CONF_SECRET: "secret", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("airthings.get_token", return_value="test_token",), patch( + "homeassistant.components.airthings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Airthings" + assert result2["data"] == TEST_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=airthings.AirthingsAuthError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=airthings.AirthingsConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "airthings.get_token", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + + first_entry = MockConfigEntry( + domain="airthings", + data=TEST_DATA, + unique_id=TEST_DATA[CONF_ID], + ) + first_entry.add_to_hass(hass) + + with patch("airthings.get_token", return_value="token"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured"