diff --git a/.coveragerc b/.coveragerc index 78168feced0..eb60f320a74 100644 --- a/.coveragerc +++ b/.coveragerc @@ -35,6 +35,8 @@ omit = homeassistant/components/agent_dvr/helpers.py homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py + homeassistant/components/airq/__init__.py + homeassistant/components/airq/sensor.py homeassistant/components/airthings/__init__.py homeassistant/components/airthings/sensor.py homeassistant/components/airthings_ble/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index cb4e68f1535..cbff4badc9f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -45,6 +45,8 @@ build.json @home-assistant/supervisor /tests/components/airly/ @bieniu /homeassistant/components/airnow/ @asymworks /tests/components/airnow/ @asymworks +/homeassistant/components/airq/ @Sibgatulin @dl2080 +/tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen /tests/components/airthings/ @danielhiversen /homeassistant/components/airthings_ble/ @vincegio diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py new file mode 100644 index 00000000000..4bc64e1e825 --- /dev/null +++ b/homeassistant/components/airq/__init__.py @@ -0,0 +1,78 @@ +"""The air-Q integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aioairq import AirQ + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, MANUFACTURER, TARGET_ROUTE, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +class AirQCoordinator(DataUpdateCoordinator): + """Coordinator is responsible for querying the device at a specified route.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialise a custom coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + session = async_get_clientsession(hass) + self.airq = AirQ( + entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session + ) + self.device_id = entry.unique_id + assert self.device_id is not None + self.device_info = DeviceInfo( + manufacturer=MANUFACTURER, + identifiers={(DOMAIN, self.device_id)}, + ) + self.device_info.update(entry.data["device_info"]) + + async def _async_update_data(self) -> dict: + """Fetch the data from the device.""" + data = await self.airq.get(TARGET_ROUTE) + return self.airq.drop_uncertainties_from_data(data) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up air-Q from a config entry.""" + + coordinator = AirQCoordinator(hass, entry) + + # Query the device for the first time and initialise coordinator.data + await coordinator.async_config_entry_first_refresh() + + # Record the coordinator in a global store + hass.data.setdefault(DOMAIN, {}) + 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/airq/config_flow.py b/homeassistant/components/airq/config_flow.py new file mode 100644 index 00000000000..05af6825233 --- /dev/null +++ b/homeassistant/components/airq/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for air-Q integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aioairq import AirQ, InvalidAuth, InvalidInput +from aiohttp.client_exceptions import ClientConnectionError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for air-Q.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial (authentication) configuration step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors: dict[str, str] = {} + + session = async_get_clientsession(self.hass) + try: + airq = AirQ(user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD], session) + except InvalidInput: + _LOGGER.debug( + "%s does not appear to be a valid IP address or mDNS name", + user_input[CONF_IP_ADDRESS], + ) + errors["base"] = "invalid_input" + else: + try: + await airq.validate() + except ClientConnectionError: + _LOGGER.debug( + "Failed to connect to device %s. Check the IP address / device ID " + "as well as whether the device is connected to power and the WiFi", + user_input[CONF_IP_ADDRESS], + ) + errors["base"] = "cannot_connect" + except InvalidAuth: + _LOGGER.debug( + "Incorrect password for device %s", user_input[CONF_IP_ADDRESS] + ) + errors["base"] = "invalid_auth" + else: + _LOGGER.debug( + "Successfully connected to %s", user_input[CONF_IP_ADDRESS] + ) + + device_info = await airq.fetch_device_info() + await self.async_set_unique_id(device_info.pop("id")) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=device_info["name"], + data=user_input | {"device_info": device_info}, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py new file mode 100644 index 00000000000..82719515cbf --- /dev/null +++ b/homeassistant/components/airq/const.py @@ -0,0 +1,9 @@ +"""Constants for the air-Q integration.""" +from typing import Final + +DOMAIN: Final = "airq" +MANUFACTURER: Final = "CorantGmbH" +TARGET_ROUTE: Final = "average" +CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" +ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" +UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/manifest.json b/homeassistant/components/airq/manifest.json new file mode 100644 index 00000000000..932b404278d --- /dev/null +++ b/homeassistant/components/airq/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airq", + "name": "air-Q", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airq", + "requirements": ["aioairq==0.2.4"], + "codeowners": ["@Sibgatulin", "@dl2080"], + "iot_class": "local_polling", + "loggers": ["aioairq"], + "integration_type": "hub" +} diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py new file mode 100644 index 00000000000..c524050ea66 --- /dev/null +++ b/homeassistant/components/airq/sensor.py @@ -0,0 +1,361 @@ +"""Definition of air-Q sensor platform.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import Literal + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + PRESSURE_HPA, + SOUND_PRESSURE_WEIGHTED_DBA, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AirQCoordinator +from .const import ( + ACTIVITY_BECQUEREL_PER_CUBIC_METER, + CONCENTRATION_GRAMS_PER_CUBIC_METER, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class AirQEntityDescriptionMixin: + """Class for keys required by AirQ entity.""" + + value: Callable[[dict], float | int | None] + + +@dataclass +class AirQEntityDescription(SensorEntityDescription, AirQEntityDescriptionMixin): + """Describes AirQ sensor entity.""" + + +# Keys must match those in the data dictionary +SENSOR_TYPES: list[AirQEntityDescription] = [ + AirQEntityDescription( + key="nh3_MR100", + name="Ammonia", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("nh3_MR100"), + ), + AirQEntityDescription( + key="cl2_M20", + name="Chlorine", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("cl2_M20"), + ), + AirQEntityDescription( + key="co", + name="CO", + device_class=SensorDeviceClass.CO, + native_unit_of_measurement=CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("co"), + ), + AirQEntityDescription( + key="co2", + name="CO2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("co2"), + ), + AirQEntityDescription( + key="dewpt", + name="Dew point", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("dewpt"), + icon="mdi:water-thermometer", + ), + AirQEntityDescription( + key="ethanol", + name="Ethanol", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ethanol"), + ), + AirQEntityDescription( + key="ch2o_M10", + name="Formaldehyde", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ch2o_M10"), + ), + AirQEntityDescription( + key="h2s", + name="H2S", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("h2s"), + ), + AirQEntityDescription( + key="health", + name="Health Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:heart-pulse", + value=lambda data: data.get("health", 0.0) / 10.0, + ), + AirQEntityDescription( + key="humidity", + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("humidity"), + ), + AirQEntityDescription( + key="humidity_abs", + name="Absolute humidity", + native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("humidity_abs"), + icon="mdi:water", + ), + AirQEntityDescription( + key="h2_M1000", + name="Hydrogen", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("h2_M1000"), + ), + AirQEntityDescription( + key="ch4_MIPEX", + name="Methane", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("ch4_MIPEX"), + ), + AirQEntityDescription( + key="n2o", + name="N2O", + device_class=SensorDeviceClass.NITROUS_OXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("n2o"), + ), + AirQEntityDescription( + key="no_M250", + name="NO", + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("no_M250"), + ), + AirQEntityDescription( + key="no2", + name="NO2", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("no2"), + ), + AirQEntityDescription( + key="o3", + name="Ozone", + device_class=SensorDeviceClass.OZONE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("o3"), + ), + AirQEntityDescription( + key="oxygen", + name="Oxygen", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("oxygen"), + icon="mdi:leaf", + ), + AirQEntityDescription( + key="performance", + name="Performance Index", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:head-check", + value=lambda data: data.get("performance", 0.0) / 10.0, + ), + AirQEntityDescription( + key="pm1", + name="PM1", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("pm1"), + icon="mdi:dots-hexagon", + ), + AirQEntityDescription( + key="pm2_5", + name="PM2.5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("pm2_5"), + icon="mdi:dots-hexagon", + ), + AirQEntityDescription( + key="pm10", + name="PM10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("pm10"), + icon="mdi:dots-hexagon", + ), + AirQEntityDescription( + key="pressure", + name="Pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("pressure"), + ), + AirQEntityDescription( + key="pressure_rel", + name="Relative pressure", + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("pressure_rel"), + icon="mdi:gauge", + ), + AirQEntityDescription( + key="c3h8_MIPEX", + name="Propane", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("c3h8_MIPEX"), + ), + AirQEntityDescription( + key="so2", + name="SO2", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("so2"), + ), + AirQEntityDescription( + key="sound", + name="Noise", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sound"), + icon="mdi:ear-hearing", + ), + AirQEntityDescription( + key="sound_max", + name="Noise (Maximum)", + native_unit_of_measurement=SOUND_PRESSURE_WEIGHTED_DBA, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("sound_max"), + icon="mdi:ear-hearing", + ), + AirQEntityDescription( + key="radon", + name="Radon", + native_unit_of_measurement=ACTIVITY_BECQUEREL_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("radon"), + icon="mdi:radioactive", + ), + AirQEntityDescription( + key="temperature", + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("temperature"), + ), + AirQEntityDescription( + key="tvoc", + name="VOC", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("tvoc"), + ), + AirQEntityDescription( + key="tvoc_ionsc", + name="VOC (Industrial)", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, + value=lambda data: data.get("tvoc_ionsc"), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensor entities based on a config entry.""" + + coordinator = hass.data[DOMAIN][config.entry_id] + + entities: list[AirQSensor] = [] + + device_status: dict[str, str] | Literal["OK"] = coordinator.data["Status"] + + for description in SENSOR_TYPES: + if description.key not in coordinator.data: + if isinstance( + device_status, dict + ) and "sensor still in warm up phase" in device_status.get( + description.key, "OK" + ): + # warming up sensors do not contribute keys to coordinator.data + # but still must be added + _LOGGER.debug("Following sensor is warming up: %s", description.key) + else: + continue + entities.append(AirQSensor(coordinator, description)) + + async_add_entities(entities) + + +class AirQSensor(CoordinatorEntity, SensorEntity): + """Representation of a Sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AirQCoordinator, + description: AirQEntityDescription, + ) -> None: + """Initialize a single sensor.""" + super().__init__(coordinator) + self.entity_description: AirQEntityDescription = description + + self._attr_device_info = coordinator.device_info + self._attr_name = description.name + self._attr_unique_id = f"{coordinator.device_id}_{description.key}" + self._attr_native_value = description.value(coordinator.data) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.entity_description.value(self.coordinator.data) + self.async_write_ha_state() diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json new file mode 100644 index 00000000000..3618d9d517e --- /dev/null +++ b/homeassistant/components/airq/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Identify the device", + "description": "Provide the IP address or mDNS of the device and its password", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_input": "[%key:common::config_flow::error::invalid_host%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/airq/translations/en.json b/homeassistant/components/airq/translations/en.json new file mode 100644 index 00000000000..81b8c1ff83e --- /dev/null +++ b/homeassistant/components/airq/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Identify the device", + "description": "Provide the IP address or mDNS of the device and its password", + "data": { + "ip_address": "Device IP or mDNS (e.g. '123ab_air-q.local')", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please check the IP or mDNS", + "invalid_auth": "Wrong password", + "invalid_input": "Invalid IP address or mDNS " + }, + "abort": { + "already_configured": "This device is already configured and available to Home Assistant" + } + } +} + diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5214436be42..b0cd687b54e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -15,6 +15,7 @@ FLOWS = { "agent_dvr", "airly", "airnow", + "airq", "airthings", "airthings_ble", "airtouch4", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d04bde132a..4d1b9da50af 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -89,6 +89,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "airq": { + "name": "air-Q", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "airthings": { "name": "Airthings", "integrations": { diff --git a/requirements_all.txt b/requirements_all.txt index 13087d43f40..225d0096684 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -112,6 +112,9 @@ aio_geojson_usgs_earthquakes==0.1 # homeassistant.components.gdacs aio_georss_gdacs==0.7 +# homeassistant.components.airq +aioairq==0.2.4 + # homeassistant.components.airzone aioairzone==0.4.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98ee447b345..5a7f2351050 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -99,6 +99,9 @@ aio_geojson_usgs_earthquakes==0.1 # homeassistant.components.gdacs aio_georss_gdacs==0.7 +# homeassistant.components.airq +aioairq==0.2.4 + # homeassistant.components.airzone aioairzone==0.4.8 diff --git a/tests/components/airq/__init__.py b/tests/components/airq/__init__.py new file mode 100644 index 00000000000..612761c0653 --- /dev/null +++ b/tests/components/airq/__init__.py @@ -0,0 +1 @@ +"""Tests for the air-Q integration.""" diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py new file mode 100644 index 00000000000..38fc15fdae3 --- /dev/null +++ b/tests/components/airq/test_config_flow.py @@ -0,0 +1,93 @@ +"""Test the air-Q config flow.""" +from unittest.mock import patch + +from aioairq.core import DeviceInfo, InvalidAuth, InvalidInput +from aiohttp.client_exceptions import ClientConnectionError + +from homeassistant import config_entries +from homeassistant.components.airq.const import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +TEST_USER_DATA = { + CONF_IP_ADDRESS: "192.168.0.0", + CONF_PASSWORD: "password", +} +TEST_DEVICE_INFO = DeviceInfo( + id="id", + name="name", + model="model", + sw_version="sw", + hw_version="hw", +) +TEST_DATA_OUT = TEST_USER_DATA | { + "device_info": {k: v for k, v in TEST_DEVICE_INFO.items() if k != "id"} +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch("aioairq.AirQ.validate"), patch( + "aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TEST_DEVICE_INFO["name"] + assert result2["data"] == TEST_DATA_OUT + + +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("aioairq.AirQ.validate", side_effect=InvalidAuth): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} + ) + + assert result2["type"] == FlowResultType.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("aioairq.AirQ.validate", side_effect=ClientConnectionError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_invalid_input(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("aioairq.AirQ.validate", side_effect=InvalidInput): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA | {CONF_IP_ADDRESS: "invalid_ip"} + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_input"}