From 6485973d9beb3173d01e50e5869927485137c694 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 9 May 2024 10:54:29 +0200 Subject: [PATCH] Add airgradient integration (#114113) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/airgradient/__init__.py | 34 + .../components/airgradient/config_flow.py | 83 +++ homeassistant/components/airgradient/const.py | 7 + .../components/airgradient/coordinator.py | 32 + .../components/airgradient/icons.json | 15 + .../components/airgradient/manifest.json | 11 + .../components/airgradient/sensor.py | 192 ++++++ .../components/airgradient/strings.json | 44 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/airgradient/__init__.py | 13 + tests/components/airgradient/conftest.py | 54 ++ .../fixtures/current_measures.json | 19 + .../fixtures/measures_after_boot.json | 8 + .../airgradient/snapshots/test_init.ambr | 31 + .../airgradient/snapshots/test_sensor.ambr | 605 ++++++++++++++++++ .../airgradient/test_config_flow.py | 149 +++++ tests/components/airgradient/test_init.py | 28 + tests/components/airgradient/test_sensor.py | 76 +++ 25 files changed, 1432 insertions(+) create mode 100644 homeassistant/components/airgradient/__init__.py create mode 100644 homeassistant/components/airgradient/config_flow.py create mode 100644 homeassistant/components/airgradient/const.py create mode 100644 homeassistant/components/airgradient/coordinator.py create mode 100644 homeassistant/components/airgradient/icons.json create mode 100644 homeassistant/components/airgradient/manifest.json create mode 100644 homeassistant/components/airgradient/sensor.py create mode 100644 homeassistant/components/airgradient/strings.json create mode 100644 tests/components/airgradient/__init__.py create mode 100644 tests/components/airgradient/conftest.py create mode 100644 tests/components/airgradient/fixtures/current_measures.json create mode 100644 tests/components/airgradient/fixtures/measures_after_boot.json create mode 100644 tests/components/airgradient/snapshots/test_init.ambr create mode 100644 tests/components/airgradient/snapshots/test_sensor.ambr create mode 100644 tests/components/airgradient/test_config_flow.py create mode 100644 tests/components/airgradient/test_init.py create mode 100644 tests/components/airgradient/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 36bfc6ffac9..1cc40b6e91a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -48,6 +48,7 @@ homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* homeassistant.components.air_quality.* +homeassistant.components.airgradient.* homeassistant.components.airly.* homeassistant.components.airnow.* homeassistant.components.airq.* diff --git a/CODEOWNERS b/CODEOWNERS index 4920aeaf075..a65ff6955f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -56,6 +56,8 @@ build.json @home-assistant/supervisor /tests/components/agent_dvr/ @ispysoftware /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core +/homeassistant/components/airgradient/ @airgradienthq @joostlek +/tests/components/airgradient/ @airgradienthq @joostlek /homeassistant/components/airly/ @bieniu /tests/components/airly/ @bieniu /homeassistant/components/airnow/ @asymworks diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py new file mode 100644 index 00000000000..b611bf0fb74 --- /dev/null +++ b/homeassistant/components/airgradient/__init__.py @@ -0,0 +1,34 @@ +"""The Airgradient integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AirGradientDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airgradient from a config entry.""" + + coordinator = AirGradientDataUpdateCoordinator(hass, entry.data[CONF_HOST]) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(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/airgradient/config_flow.py b/homeassistant/components/airgradient/config_flow.py new file mode 100644 index 00000000000..c02ec2a469f --- /dev/null +++ b/homeassistant/components/airgradient/config_flow.py @@ -0,0 +1,83 @@ +"""Config flow for Airgradient.""" + +from typing import Any + +from airgradient import AirGradientClient, AirGradientError +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_MODEL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class AirGradientConfigFlow(ConfigFlow, domain=DOMAIN): + """AirGradient config flow.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + self.data[CONF_MODEL] = discovery_info.properties["model"] + + await self.async_set_unique_id(discovery_info.properties["serialno"]) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + session = async_get_clientsession(self.hass) + air_gradient = AirGradientClient(host, session=session) + await air_gradient.get_current_measures() + + self.context["title_placeholders"] = { + "model": self.data[CONF_MODEL], + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_MODEL], + data={CONF_HOST: self.data[CONF_HOST]}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "model": self.data[CONF_MODEL], + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + session = async_get_clientsession(self.hass) + air_gradient = AirGradientClient(user_input[CONF_HOST], session=session) + try: + current_measures = await air_gradient.get_current_measures() + except AirGradientError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(current_measures.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=current_measures.model, + data={CONF_HOST: user_input[CONF_HOST]}, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) diff --git a/homeassistant/components/airgradient/const.py b/homeassistant/components/airgradient/const.py new file mode 100644 index 00000000000..bbb15a3741d --- /dev/null +++ b/homeassistant/components/airgradient/const.py @@ -0,0 +1,7 @@ +"""Constants for the Airgradient integration.""" + +import logging + +DOMAIN = "airgradient" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py new file mode 100644 index 00000000000..d54e1b46efd --- /dev/null +++ b/homeassistant/components/airgradient/coordinator.py @@ -0,0 +1,32 @@ +"""Define an object to manage fetching AirGradient data.""" + +from datetime import timedelta + +from airgradient import AirGradientClient, AirGradientError, Measures + +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 LOGGER + + +class AirGradientDataUpdateCoordinator(DataUpdateCoordinator[Measures]): + """Class to manage fetching AirGradient data.""" + + def __init__(self, hass: HomeAssistant, host: str) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=LOGGER, + name=f"AirGradient {host}", + update_interval=timedelta(minutes=1), + ) + session = async_get_clientsession(hass) + self.client = AirGradientClient(host, session=session) + + async def _async_update_data(self) -> Measures: + try: + return await self.client.get_current_measures() + except AirGradientError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/airgradient/icons.json b/homeassistant/components/airgradient/icons.json new file mode 100644 index 00000000000..cf0c80c873e --- /dev/null +++ b/homeassistant/components/airgradient/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "total_volatile_organic_component_index": { + "default": "mdi:molecule" + }, + "nitrogen_index": { + "default": "mdi:molecule" + }, + "pm003_count": { + "default": "mdi:blur" + } + } + } +} diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json new file mode 100644 index 00000000000..00de4342ada --- /dev/null +++ b/homeassistant/components/airgradient/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "airgradient", + "name": "Airgradient", + "codeowners": ["@airgradienthq", "@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airgradient", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["airgradient==0.4.0"], + "zeroconf": ["_airgradient._tcp.local."] +} diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py new file mode 100644 index 00000000000..5347e55cacd --- /dev/null +++ b/homeassistant/components/airgradient/sensor.py @@ -0,0 +1,192 @@ +"""Support for AirGradient sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from airgradient.models import Measures + +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_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import AirGradientDataUpdateCoordinator +from .const import DOMAIN + + +@dataclass(frozen=True, kw_only=True) +class AirGradientSensorEntityDescription(SensorEntityDescription): + """Describes AirGradient sensor entity.""" + + value_fn: Callable[[Measures], StateType] + + +SENSOR_TYPES: tuple[AirGradientSensorEntityDescription, ...] = ( + AirGradientSensorEntityDescription( + key="pm01", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm01, + ), + AirGradientSensorEntityDescription( + key="pm02", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm02, + ), + AirGradientSensorEntityDescription( + key="pm10", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm10, + ), + AirGradientSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.ambient_temperature, + ), + AirGradientSensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.relative_humidity, + ), + AirGradientSensorEntityDescription( + key="signal_strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.signal_strength, + ), + AirGradientSensorEntityDescription( + key="tvoc", + translation_key="total_volatile_organic_component_index", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.total_volatile_organic_component_index, + ), + AirGradientSensorEntityDescription( + key="nitrogen_index", + translation_key="nitrogen_index", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.nitrogen_index, + ), + AirGradientSensorEntityDescription( + key="co2", + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.rco2, + ), + AirGradientSensorEntityDescription( + key="pm003", + translation_key="pm003_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda status: status.pm003_count, + ), + AirGradientSensorEntityDescription( + key="nox_raw", + translation_key="raw_nitrogen", + native_unit_of_measurement="ticks", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_nitrogen, + ), + AirGradientSensorEntityDescription( + key="tvoc_raw", + translation_key="raw_total_volatile_organic_component", + native_unit_of_measurement="ticks", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda status: status.raw_total_volatile_organic_component, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up AirGradient sensor entities based on a config entry.""" + + coordinator: AirGradientDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + listener: Callable[[], None] | None = None + not_setup: set[AirGradientSensorEntityDescription] = set(SENSOR_TYPES) + + @callback + def add_entities() -> None: + """Add new entities based on the latest data.""" + nonlocal not_setup, listener + sensor_descriptions = not_setup + not_setup = set() + sensors = [] + for description in sensor_descriptions: + if description.value_fn(coordinator.data) is None: + not_setup.add(description) + else: + sensors.append(AirGradientSensor(coordinator, description)) + + if sensors: + async_add_entities(sensors) + if not_setup: + if not listener: + listener = coordinator.async_add_listener(add_entities) + elif listener: + listener() + + add_entities() + + +class AirGradientSensor( + CoordinatorEntity[AirGradientDataUpdateCoordinator], SensorEntity +): + """Defines an AirGradient sensor.""" + + _attr_has_entity_name = True + + entity_description: AirGradientSensorEntityDescription + + def __init__( + self, + coordinator: AirGradientDataUpdateCoordinator, + description: AirGradientSensorEntityDescription, + ) -> None: + """Initialize airgradient sensor.""" + super().__init__(coordinator) + + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + model=coordinator.data.model, + manufacturer="AirGradient", + serial_number=coordinator.data.serial_number, + sw_version=coordinator.data.firmware_version, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json new file mode 100644 index 00000000000..f4e0dabced2 --- /dev/null +++ b/homeassistant/components/airgradient/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "flow_title": "{model}", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Airgradient device." + } + }, + "discovery_confirm": { + "description": "Do you want to setup {model}?" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "entity": { + "sensor": { + "total_volatile_organic_component_index": { + "name": "Total VOC index" + }, + "nitrogen_index": { + "name": "Nitrogen index" + }, + "pm003_count": { + "name": "PM0.3 count" + }, + "raw_total_volatile_organic_component": { + "name": "Raw total VOC" + }, + "raw_nitrogen": { + "name": "Raw nitrogen" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a9a387de473..134b1e80d98 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -27,6 +27,7 @@ FLOWS = { "aemet", "aftership", "agent_dvr", + "airgradient", "airly", "airnow", "airq", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index baec734a058..e16f29a14e2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -93,6 +93,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "airgradient": { + "name": "Airgradient", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "airly": { "name": "Airly", "integration_type": "service", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 7b1bbff9de0..aea3fa341df 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -277,6 +277,11 @@ ZEROCONF = { "domain": "romy", }, ], + "_airgradient._tcp.local.": [ + { + "domain": "airgradient", + }, + ], "_airplay._tcp.local.": [ { "domain": "apple_tv", diff --git a/mypy.ini b/mypy.ini index 6da57f22252..42b5581d42c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -241,6 +241,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airgradient.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.airly.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1d5bd49e55d..4821ca831cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -406,6 +406,9 @@ aiowithings==2.1.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 +# homeassistant.components.airgradient +airgradient==0.4.0 + # homeassistant.components.airly airly==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bf400aed8e..99f90017aba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -379,6 +379,9 @@ aiowithings==2.1.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 +# homeassistant.components.airgradient +airgradient==0.4.0 + # homeassistant.components.airly airly==1.1.0 diff --git a/tests/components/airgradient/__init__.py b/tests/components/airgradient/__init__.py new file mode 100644 index 00000000000..9c57dbf8225 --- /dev/null +++ b/tests/components/airgradient/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Airgradient integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/airgradient/conftest.py b/tests/components/airgradient/conftest.py new file mode 100644 index 00000000000..ed1f8acb381 --- /dev/null +++ b/tests/components/airgradient/conftest.py @@ -0,0 +1,54 @@ +"""AirGradient tests configuration.""" + +from collections.abc import Generator +from unittest.mock import patch + +from airgradient import Measures +import pytest + +from homeassistant.components.airgradient.const import DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry, load_fixture +from tests.components.smhi.common import AsyncMock + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.airgradient.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_airgradient_client() -> Generator[AsyncMock, None, None]: + """Mock an AirGradient client.""" + with ( + patch( + "homeassistant.components.airgradient.coordinator.AirGradientClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.airgradient.config_flow.AirGradientClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_current_measures.return_value = Measures.from_json( + load_fixture("current_measures.json", DOMAIN) + ) + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Airgradient", + data={CONF_HOST: "10.0.0.131"}, + unique_id="84fce612f5b8", + ) diff --git a/tests/components/airgradient/fixtures/current_measures.json b/tests/components/airgradient/fixtures/current_measures.json new file mode 100644 index 00000000000..383a0631e94 --- /dev/null +++ b/tests/components/airgradient/fixtures/current_measures.json @@ -0,0 +1,19 @@ +{ + "wifi": -52, + "serialno": "84fce612f5b8", + "rco2": 778, + "pm01": 22, + "pm02": 34, + "pm10": 41, + "pm003Count": 270, + "tvocIndex": 99, + "tvoc_raw": 31792, + "noxIndex": 1, + "nox_raw": 16931, + "atmp": 27.96, + "rhum": 48, + "boot": 28, + "ledMode": "co2", + "firmwareVersion": "3.0.8", + "fwMode": "I-9PSL" +} diff --git a/tests/components/airgradient/fixtures/measures_after_boot.json b/tests/components/airgradient/fixtures/measures_after_boot.json new file mode 100644 index 00000000000..08ce0c11646 --- /dev/null +++ b/tests/components/airgradient/fixtures/measures_after_boot.json @@ -0,0 +1,8 @@ +{ + "wifi": -59, + "serialno": "84fce612f5b8", + "boot": 0, + "ledMode": "co2", + "firmwareVersion": "3.0.8", + "fwMode": "I-9PSL" +} diff --git a/tests/components/airgradient/snapshots/test_init.ambr b/tests/components/airgradient/snapshots/test_init.ambr new file mode 100644 index 00000000000..9b81cc949c5 --- /dev/null +++ b/tests/components/airgradient/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_device_info + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'airgradient', + '84fce612f5b8', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'AirGradient', + 'model': 'I-9PSL', + 'name': 'Airgradient', + 'name_by_user': None, + 'serial_number': '84fce612f5b8', + 'suggested_area': None, + 'sw_version': '3.0.8', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/airgradient/snapshots/test_sensor.ambr b/tests/components/airgradient/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..27d8043a395 --- /dev/null +++ b/tests/components/airgradient/snapshots/test_sensor.ambr @@ -0,0 +1,605 @@ +# serializer version: 1 +# name: test_all_entities[sensor.airgradient_carbon_dioxide-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_carbon_dioxide', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Carbon dioxide', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-co2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_entities[sensor.airgradient_carbon_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Airgradient Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.airgradient_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '778', + }) +# --- +# name: test_all_entities[sensor.airgradient_humidity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.airgradient_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Airgradient Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.airgradient_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_all_entities[sensor.airgradient_nitrogen_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_nitrogen_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Nitrogen index', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_index', + 'unique_id': '84fce612f5b8-nitrogen_index', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.airgradient_nitrogen_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Nitrogen index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_nitrogen_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm0_3_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm0_3_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'PM0.3 count', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pm003_count', + 'unique_id': '84fce612f5b8-pm003', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.airgradient_pm0_3_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient PM0.3 count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm0_3_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '270', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM1', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-pm01', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm1', + 'friendly_name': 'Airgradient PM1', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm10-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm10', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM10', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-pm10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Airgradient PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '41', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm2_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_pm2_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'PM2.5', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-pm02', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.airgradient_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Airgradient PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.airgradient_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '34', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_nitrogen-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_nitrogen', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raw nitrogen', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_nitrogen', + 'unique_id': '84fce612f5b8-nox_raw', + 'unit_of_measurement': 'ticks', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_nitrogen-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Raw nitrogen', + 'state_class': , + 'unit_of_measurement': 'ticks', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_nitrogen', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16931', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_total_voc-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_raw_total_voc', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Raw total VOC', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'raw_total_volatile_organic_component', + 'unique_id': '84fce612f5b8-tvoc_raw', + 'unit_of_measurement': 'ticks', + }) +# --- +# name: test_all_entities[sensor.airgradient_raw_total_voc-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Raw total VOC', + 'state_class': , + 'unit_of_measurement': 'ticks', + }), + 'context': , + 'entity_id': 'sensor.airgradient_raw_total_voc', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '31792', + }) +# --- +# name: test_all_entities[sensor.airgradient_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.airgradient_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.airgradient_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Airgradient Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.airgradient_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-52', + }) +# --- +# name: test_all_entities[sensor.airgradient_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '84fce612f5b8-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.airgradient_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Airgradient Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.96', + }) +# --- +# name: test_all_entities[sensor.airgradient_total_voc_index-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.airgradient_total_voc_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total VOC index', + 'platform': 'airgradient', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'total_volatile_organic_component_index', + 'unique_id': '84fce612f5b8-tvoc', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.airgradient_total_voc_index-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Airgradient Total VOC index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.airgradient_total_voc_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- diff --git a/tests/components/airgradient/test_config_flow.py b/tests/components/airgradient/test_config_flow.py new file mode 100644 index 00000000000..022a250ebef --- /dev/null +++ b/tests/components/airgradient/test_config_flow.py @@ -0,0 +1,149 @@ +"""Tests for the AirGradient config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from airgradient import AirGradientConnectionError + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("10.0.0.131"), + ip_addresses=[ip_address("10.0.0.131")], + hostname="airgradient_84fce612f5b8.local.", + name="airgradient_84fce612f5b8._airgradient._tcp.local.", + port=80, + type="_airgradient._tcp.local.", + properties={ + "vendor": "AirGradient", + "fw_ver": "3.0.8", + "serialno": "84fce612f5b8", + "model": "I-9PSL", + }, +) + + +async def test_full_flow( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "I-9PSL" + assert result["data"] == { + CONF_HOST: "10.0.0.131", + } + assert result["result"].unique_id == "84fce612f5b8" + + +async def test_flow_errors( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow errors.""" + mock_airgradient_client.get_current_measures.side_effect = ( + AirGradientConnectionError() + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + mock_airgradient_client.get_current_measures.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "10.0.0.131"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "I-9PSL" + assert result["data"] == { + CONF_HOST: "10.0.0.131", + } + assert result["result"].unique_id == "84fce612f5b8" diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py new file mode 100644 index 00000000000..463cb47f144 --- /dev/null +++ b/tests/components/airgradient/test_init.py @@ -0,0 +1,28 @@ +"""Tests for the AirGradient integration.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry +from tests.components.airgradient import setup_integration + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.unique_id)} + ) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/airgradient/test_sensor.py b/tests/components/airgradient/test_sensor.py new file mode 100644 index 00000000000..de8f8a6add9 --- /dev/null +++ b/tests/components/airgradient/test_sensor.py @@ -0,0 +1,76 @@ +"""Tests for the AirGradient sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from airgradient import AirGradientError, Measures +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.airgradient import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + snapshot_platform, +) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_create_entities( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test creating entities.""" + mock_airgradient_client.get_current_measures.return_value = Measures.from_json( + load_fixture("measures_after_boot.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + + assert len(hass.states.async_all()) == 0 + mock_airgradient_client.get_current_measures.return_value = Measures.from_json( + load_fixture("current_measures.json", DOMAIN) + ) + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 9 + + +async def test_connection_error( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test connection error.""" + await setup_integration(hass, mock_config_entry) + + mock_airgradient_client.get_current_measures.side_effect = AirGradientError() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.airgradient_humidity").state == STATE_UNAVAILABLE