From cbaef096faddb5c924dcdf234fe4beb7e22ba57c Mon Sep 17 00:00:00 2001 From: Illia <146177275+ikalnyi@users.noreply.github.com> Date: Mon, 8 Apr 2024 17:24:32 +0200 Subject: [PATCH] Add Arve integration (#113156) * add Arve integration * Update homeassistant/components/arve/config_flow.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Various fixes, changed scan interval to one minute * coordinator implementation * Code cleanup * Moved device info to the entity.py, ArveDeviceEntityDescription to sensor.py * delete refresh before adding entities Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update tests/components/arve/test_config_flow.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Update tests/components/arve/conftest.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Changed value_fn in sensors.py, added typing to description * Code cleanups, platfrom test implementation * New code cleanups, first two working tests * Created platform test, generated snapshots * Reworked integration to get all of the customer devices * new fixes * Added customer id, small cleanups * Logic of setting unique_id to the config flow * Added test of abortion on duplicate config_flow id * Added "available" and "device" properties fro ArveDeviceEntity * small _attr_unique_id fix * Added new test, improved mocking, various fixes * Various cleanups and fixes * microfix * Update homeassistant/components/arve/strings.json * ruff fix --------- Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/arve/__init__.py | 34 + homeassistant/components/arve/config_flow.py | 53 ++ homeassistant/components/arve/const.py | 7 + homeassistant/components/arve/coordinator.py | 63 ++ homeassistant/components/arve/entity.py | 53 ++ homeassistant/components/arve/icons.json | 9 + homeassistant/components/arve/manifest.json | 9 + homeassistant/components/arve/sensor.py | 108 +++ homeassistant/components/arve/strings.json | 26 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/arve/__init__.py | 20 + tests/components/arve/conftest.py | 56 ++ .../arve/snapshots/test_sensor.ambr | 773 ++++++++++++++++++ tests/components/arve/test_config_flow.py | 79 ++ tests/components/arve/test_sensor.py | 43 + 19 files changed, 1348 insertions(+) create mode 100644 homeassistant/components/arve/__init__.py create mode 100644 homeassistant/components/arve/config_flow.py create mode 100644 homeassistant/components/arve/const.py create mode 100644 homeassistant/components/arve/coordinator.py create mode 100644 homeassistant/components/arve/entity.py create mode 100644 homeassistant/components/arve/icons.json create mode 100644 homeassistant/components/arve/manifest.json create mode 100644 homeassistant/components/arve/sensor.py create mode 100644 homeassistant/components/arve/strings.json create mode 100644 tests/components/arve/__init__.py create mode 100644 tests/components/arve/conftest.py create mode 100644 tests/components/arve/snapshots/test_sensor.ambr create mode 100644 tests/components/arve/test_config_flow.py create mode 100644 tests/components/arve/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 40d7c0f502a..a4e237e79f1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -130,6 +130,8 @@ build.json @home-assistant/supervisor /homeassistant/components/arcam_fmj/ @elupus /tests/components/arcam_fmj/ @elupus /homeassistant/components/arris_tg2492lg/ @vanbalken +/homeassistant/components/arve/ @ikalnyi +/tests/components/arve/ @ikalnyi /homeassistant/components/aseko_pool_live/ @milanmeu /tests/components/aseko_pool_live/ @milanmeu /homeassistant/components/assist_pipeline/ @balloob @synesthesiam diff --git a/homeassistant/components/arve/__init__.py b/homeassistant/components/arve/__init__.py new file mode 100644 index 00000000000..91e38da4c60 --- /dev/null +++ b/homeassistant/components/arve/__init__.py @@ -0,0 +1,34 @@ +"""The Arve integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import ArveCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Arve from a config entry.""" + + coordinator = ArveCoordinator(hass) + + 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/arve/config_flow.py b/homeassistant/components/arve/config_flow.py new file mode 100644 index 00000000000..23d344d2325 --- /dev/null +++ b/homeassistant/components/arve/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow for Arve integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from asyncarve import Arve, ArveConnectionError, ArveCustomer +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ArveConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Arve.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict[str, str] = {} + if user_input is not None: + arve = Arve( + user_input[CONF_ACCESS_TOKEN], + user_input[CONF_CLIENT_SECRET], + ) + try: + customer: ArveCustomer = await arve.get_customer_id() + except ArveConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(customer.customerId) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="Arve", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ACCESS_TOKEN): str, + vol.Required(CONF_CLIENT_SECRET): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/arve/const.py b/homeassistant/components/arve/const.py new file mode 100644 index 00000000000..1350640f887 --- /dev/null +++ b/homeassistant/components/arve/const.py @@ -0,0 +1,7 @@ +"""Constants for the Arve integration.""" + +import logging + +DOMAIN = "arve" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/arve/coordinator.py b/homeassistant/components/arve/coordinator.py new file mode 100644 index 00000000000..b053e30336b --- /dev/null +++ b/homeassistant/components/arve/coordinator.py @@ -0,0 +1,63 @@ +"""Coordinator for the Arve integration.""" + +from __future__ import annotations + +from datetime import timedelta + +from asyncarve import ( + Arve, + ArveConnectionError, + ArveDeviceInfo, + ArveDevices, + ArveError, + ArveSensProData, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET +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 DOMAIN, LOGGER + + +class ArveCoordinator(DataUpdateCoordinator[ArveSensProData]): + """Arve coordinator.""" + + config_entry: ConfigEntry + devices: ArveDevices + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize Arve coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + + self.arve = Arve( + self.config_entry.data[CONF_ACCESS_TOKEN], + self.config_entry.data[CONF_CLIENT_SECRET], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[str, ArveDeviceInfo]: + """Fetch data from API endpoint.""" + try: + self.devices = await self.arve.get_devices() + + response_data = { + sn: ArveDeviceInfo( + await self.arve.device_sensor_data(sn), + await self.arve.get_sensor_info(sn), + ) + for sn in self.devices.sn + } + except ArveConnectionError as err: + raise UpdateFailed("Unable to connect to the Arve device") from err + except ArveError as err: + raise UpdateFailed("Unknown error occurred") from err + + return response_data diff --git a/homeassistant/components/arve/entity.py b/homeassistant/components/arve/entity.py new file mode 100644 index 00000000000..46c6bfc75ec --- /dev/null +++ b/homeassistant/components/arve/entity.py @@ -0,0 +1,53 @@ +"""Arve base entity.""" + +from __future__ import annotations + +from asyncarve import ArveDeviceInfo + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ArveCoordinator + + +class ArveDeviceEntity(CoordinatorEntity[ArveCoordinator]): + """Defines a base Arve device entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: ArveCoordinator, + description: EntityDescription, + serial_number: str, + ) -> None: + """Initialize the Arve device entity.""" + super().__init__(coordinator) + + self.device_serial_number = serial_number + + self.entity_description = description + + self._attr_unique_id = f"{serial_number}_{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, serial_number)}, + manufacturer="Calanda Air AG", + model="Arve Sens Pro", + serial_number=serial_number, + name=self.device.info.name, + ) + + @property + def available(self) -> bool: + """Check if device is available.""" + return super()._attr_available and ( + self.device_serial_number in self.coordinator.data + ) + + @property + def device(self) -> ArveDeviceInfo: + """Returns device instance.""" + return self.coordinator.data[self.device_serial_number] diff --git a/homeassistant/components/arve/icons.json b/homeassistant/components/arve/icons.json new file mode 100644 index 00000000000..887a0694e5d --- /dev/null +++ b/homeassistant/components/arve/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "tvoc": { + "default": "mdi:flask" + } + } + } +} diff --git a/homeassistant/components/arve/manifest.json b/homeassistant/components/arve/manifest.json new file mode 100644 index 00000000000..fa33b3309ce --- /dev/null +++ b/homeassistant/components/arve/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "arve", + "name": "Arve", + "codeowners": ["@ikalnyi"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/arve", + "iot_class": "cloud_polling", + "requirements": ["asyncarve==0.0.9"] +} diff --git a/homeassistant/components/arve/sensor.py b/homeassistant/components/arve/sensor.py new file mode 100644 index 00000000000..f95b26b0451 --- /dev/null +++ b/homeassistant/components/arve/sensor.py @@ -0,0 +1,108 @@ +"""Sensor platform for Arve devices.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from asyncarve import ArveSensProData + +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, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import ArveCoordinator +from .entity import ArveDeviceEntity + + +@dataclass(frozen=True, kw_only=True) +class ArveDeviceEntityDescription(SensorEntityDescription): + """Describes Arve device entity.""" + + value_fn: Callable[[ArveSensProData], float | int] + + +SENSORS: tuple[ArveDeviceEntityDescription, ...] = ( + ArveDeviceEntityDescription( + key="CO2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + value_fn=lambda arve_data: arve_data.co2, + state_class=SensorStateClass.MEASUREMENT, + ), + ArveDeviceEntityDescription( + key="AQI", + device_class=SensorDeviceClass.AQI, + value_fn=lambda arve_data: arve_data.aqi, + state_class=SensorStateClass.MEASUREMENT, + ), + ArveDeviceEntityDescription( + key="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + value_fn=lambda arve_data: arve_data.humidity, + state_class=SensorStateClass.MEASUREMENT, + ), + ArveDeviceEntityDescription( + key="PM10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + value_fn=lambda arve_data: arve_data.pm10, + state_class=SensorStateClass.MEASUREMENT, + ), + ArveDeviceEntityDescription( + key="PM25", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + value_fn=lambda arve_data: arve_data.pm25, + state_class=SensorStateClass.MEASUREMENT, + ), + ArveDeviceEntityDescription( + key="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda arve_data: arve_data.temperature, + state_class=SensorStateClass.MEASUREMENT, + ), + ArveDeviceEntityDescription( + key="TVOC", + translation_key="tvoc", + value_fn=lambda arve_data: arve_data.tvoc, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Arve device based on a config entry.""" + coordinator: ArveCoordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + ArveDevice(coordinator, description, sn) + for description in SENSORS + for sn in coordinator.devices.sn + ) + + +class ArveDevice(ArveDeviceEntity, SensorEntity): + """Define an Arve device.""" + + entity_description: ArveDeviceEntityDescription + + @property + def native_value(self) -> int | float: + """State of the sensor.""" + return self.entity_description.value_fn(self.device.sensors) diff --git a/homeassistant/components/arve/strings.json b/homeassistant/components/arve/strings.json new file mode 100644 index 00000000000..cbfe3c6b065 --- /dev/null +++ b/homeassistant/components/arve/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up your Arve device", + "data": { + "access_token": "Arve token", + "client_secret": "Arve customer token" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "tvoc": { + "name": "Total volatile organic compounds" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index acac5f8df5d..125f02df3b5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -55,6 +55,7 @@ FLOWS = { "aprilaire", "aranet", "arcam_fmj", + "arve", "aseko_pool_live", "asuswrt", "atag", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e6c0588979c..f027db93fe0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -455,6 +455,12 @@ } } }, + "arve": { + "name": "Arve", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "arwn": { "name": "Ambient Radio Weather Network", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 7e2ddc78c5b..c0984c8b758 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -489,6 +489,9 @@ asterisk_mbox==0.5.0 # homeassistant.components.yeelight async-upnp-client==0.38.3 +# homeassistant.components.arve +asyncarve==0.0.9 + # homeassistant.components.keyboard_remote asyncinotify==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3eb48da244e..526466e04e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -444,6 +444,9 @@ asterisk_mbox==0.5.0 # homeassistant.components.yeelight async-upnp-client==0.38.3 +# homeassistant.components.arve +asyncarve==0.0.9 + # homeassistant.components.sleepiq asyncsleepiq==1.5.2 diff --git a/tests/components/arve/__init__.py b/tests/components/arve/__init__.py new file mode 100644 index 00000000000..24f970b55b6 --- /dev/null +++ b/tests/components/arve/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the Arve integration.""" + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +USER_INPUT = { + CONF_ACCESS_TOKEN: "test-access-token", + CONF_CLIENT_SECRET: "test-customer-token", +} + + +async def async_init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the Arve integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/arve/conftest.py b/tests/components/arve/conftest.py new file mode 100644 index 00000000000..f1dfee8ba41 --- /dev/null +++ b/tests/components/arve/conftest.py @@ -0,0 +1,56 @@ +"""Common fixtures for the Arve tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from asyncarve import ArveCustomer, ArveDevices, ArveSensPro, ArveSensProData +import pytest + +from homeassistant.components.arve.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.arve.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant, mock_arve: MagicMock) -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Arve", domain=DOMAIN, data=USER_INPUT, unique_id=mock_arve.customer_id + ) + + +@pytest.fixture +def mock_arve(): + """Return a mocked Arve client.""" + + with ( + patch( + "homeassistant.components.arve.coordinator.Arve", autospec=True + ) as arve_mock, + patch("homeassistant.components.arve.config_flow.Arve", new=arve_mock), + ): + arve = arve_mock.return_value + arve.customer_id = 12345 + + arve.get_customer_id.return_value = ArveCustomer(12345) + + arve.get_devices.return_value = ArveDevices(["test-serial-number"]) + arve.get_sensor_info.return_value = ArveSensPro("Test Sensor", "1.0", "prov1") + + arve.device_sensor_data.return_value = ArveSensProData( + 14, 595.75, 28.71, 0.16, 0.19, 26.02, 7 + ) + + yield arve diff --git a/tests/components/arve/snapshots/test_sensor.ambr b/tests/components/arve/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5c5c4c84d08 --- /dev/null +++ b/tests/components/arve/snapshots/test_sensor.ambr @@ -0,0 +1,773 @@ +# serializer version: 1 +# name: test_sensors[entry_air_quality_index] + 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.test_sensor_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_AQI', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[entry_carbon_dioxide] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_CO2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[entry_humidity] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_Humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[entry_pm10] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_PM10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[entry_pm2_5] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_PM25', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[entry_temperature] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_Temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[entry_test-serial-number_air_quality_index] + 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.test_sensor_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_AQI', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[entry_test-serial-number_carbon_dioxide] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_CO2', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_sensors[entry_test-serial-number_humidity] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_Humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[entry_test-serial-number_none] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_arve_none', + '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': 'TVOC', + 'platform': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tvoc', + 'unique_id': 'test-serial-number_tvoc', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[entry_test-serial-number_pm10] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_PM10', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[entry_test-serial-number_pm2_5] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_PM25', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensors[entry_test-serial-number_temperature] + 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.test_sensor_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': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'test-serial-number_Temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[entry_test-serial-number_total_volatile_organic_compounds] + 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.test_sensor_total_volatile_organic_compounds', + '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 volatile organic compounds', + 'platform': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tvoc', + 'unique_id': 'test-serial-number_TVOC', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[entry_test-serial-number_tvoc] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_arve_tvoc', + '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': 'TVOC', + 'platform': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tvoc', + 'unique_id': 'test-serial-number_tvoc', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[entry_total_volatile_organic_compounds] + 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.test_sensor_total_volatile_organic_compounds', + '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 volatile organic compounds', + 'platform': 'arve', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'tvoc', + 'unique_id': 'test-serial-number_TVOC', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[my_arve_air_quality_index] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'aqi', + 'friendly_name': 'My Arve AQI', + }), + 'context': , + 'entity_id': 'sensor.my_arve_air_quality_index', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_carbon_dioxide] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'My Arve CO2', + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.my_arve_carbon_dioxide', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'My Arve Humidity', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_arve_humidity', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_none] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Arve TVOC', + }), + 'context': , + 'entity_id': 'sensor.my_arve_none', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_pm10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'My Arve PM10', + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.my_arve_pm10', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_pm2_5] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'My Arve PM25', + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.my_arve_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'My Arve Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_arve_temperature', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[my_arve_tvoc] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Arve TVOC', + }), + 'context': , + 'entity_id': 'sensor.my_arve_tvoc', + 'last_changed': , + 'last_updated': , + 'state': "", + }) +# --- +# name: test_sensors[test_sensor_air_quality_index] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'aqi', + 'friendly_name': 'Test Sensor Air quality index', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_air_quality_index', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14', + }) +# --- +# name: test_sensors[test_sensor_carbon_dioxide] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'carbon_dioxide', + 'friendly_name': 'Test Sensor Carbon dioxide', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_carbon_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '595.75', + }) +# --- +# name: test_sensors[test_sensor_humidity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Test Sensor Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.71', + }) +# --- +# name: test_sensors[test_sensor_pm10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': 'Test Sensor PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.16', + }) +# --- +# name: test_sensors[test_sensor_pm2_5] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': 'Test Sensor PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.test_sensor_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.19', + }) +# --- +# name: test_sensors[test_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Sensor Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.02', + }) +# --- +# name: test_sensors[test_sensor_total_volatile_organic_compounds] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Sensor Total volatile organic compounds', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_sensor_total_volatile_organic_compounds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '7', + }) +# --- diff --git a/tests/components/arve/test_config_flow.py b/tests/components/arve/test_config_flow.py new file mode 100644 index 00000000000..efa36e37d44 --- /dev/null +++ b/tests/components/arve/test_config_flow.py @@ -0,0 +1,79 @@ +"""Test the Arve config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.arve.config_flow import ArveConnectionError +from homeassistant.components.arve.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_SECRET +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import USER_INPUT, async_init_integration + +from tests.common import MockConfigEntry + + +async def test_correct_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_arve: AsyncMock +) -> None: + """Test the whole flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == 12345 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_arve: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + mock_arve.get_customer_id.side_effect = ArveConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_abort_already_configured( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test form aborts if already configured.""" + await async_init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ACCESS_TOKEN: "test-access-token", + CONF_CLIENT_SECRET: "test-customer-token", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/arve/test_sensor.py b/tests/components/arve/test_sensor.py new file mode 100644 index 00000000000..541820fd7b6 --- /dev/null +++ b/tests/components/arve/test_sensor.py @@ -0,0 +1,43 @@ +"""Test for Arve sensors.""" + +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_init_integration + +from tests.common import MockConfigEntry + +SENSORS = ( + "air_quality_index", + "carbon_dioxide", + "humidity", + "pm10", + "pm2_5", + "temperature", + "total_volatile_organic_compounds", +) + + +async def test_sensors( + hass: HomeAssistant, + mock_arve: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Arve sensors.""" + await async_init_integration(hass, mock_config_entry) + + for sensor in SENSORS: + state = hass.states.get(f"sensor.test_sensor_{sensor}") + assert state + assert state == snapshot(name=f"test_sensor_{sensor}") + + entry = entity_registry.async_get(state.entity_id) + assert entry + assert entry.device_id + assert entry == snapshot(name=f"entry_{sensor}")