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 <joostlek@outlook.com>
This commit is contained in:
Illia 2024-04-08 17:24:32 +02:00 committed by GitHub
parent 376aafc83e
commit cbaef096fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1348 additions and 0 deletions

View file

@ -130,6 +130,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/arcam_fmj/ @elupus /homeassistant/components/arcam_fmj/ @elupus
/tests/components/arcam_fmj/ @elupus /tests/components/arcam_fmj/ @elupus
/homeassistant/components/arris_tg2492lg/ @vanbalken /homeassistant/components/arris_tg2492lg/ @vanbalken
/homeassistant/components/arve/ @ikalnyi
/tests/components/arve/ @ikalnyi
/homeassistant/components/aseko_pool_live/ @milanmeu /homeassistant/components/aseko_pool_live/ @milanmeu
/tests/components/aseko_pool_live/ @milanmeu /tests/components/aseko_pool_live/ @milanmeu
/homeassistant/components/assist_pipeline/ @balloob @synesthesiam /homeassistant/components/assist_pipeline/ @balloob @synesthesiam

View file

@ -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

View file

@ -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,
)

View file

@ -0,0 +1,7 @@
"""Constants for the Arve integration."""
import logging
DOMAIN = "arve"
LOGGER = logging.getLogger(__package__)

View file

@ -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

View file

@ -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]

View file

@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"tvoc": {
"default": "mdi:flask"
}
}
}
}

View file

@ -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"]
}

View file

@ -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)

View file

@ -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"
}
}
}
}

View file

@ -55,6 +55,7 @@ FLOWS = {
"aprilaire", "aprilaire",
"aranet", "aranet",
"arcam_fmj", "arcam_fmj",
"arve",
"aseko_pool_live", "aseko_pool_live",
"asuswrt", "asuswrt",
"atag", "atag",

View file

@ -455,6 +455,12 @@
} }
} }
}, },
"arve": {
"name": "Arve",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"arwn": { "arwn": {
"name": "Ambient Radio Weather Network", "name": "Ambient Radio Weather Network",
"integration_type": "hub", "integration_type": "hub",

View file

@ -489,6 +489,9 @@ asterisk_mbox==0.5.0
# homeassistant.components.yeelight # homeassistant.components.yeelight
async-upnp-client==0.38.3 async-upnp-client==0.38.3
# homeassistant.components.arve
asyncarve==0.0.9
# homeassistant.components.keyboard_remote # homeassistant.components.keyboard_remote
asyncinotify==4.0.2 asyncinotify==4.0.2

View file

@ -444,6 +444,9 @@ asterisk_mbox==0.5.0
# homeassistant.components.yeelight # homeassistant.components.yeelight
async-upnp-client==0.38.3 async-upnp-client==0.38.3
# homeassistant.components.arve
asyncarve==0.0.9
# homeassistant.components.sleepiq # homeassistant.components.sleepiq
asyncsleepiq==1.5.2 asyncsleepiq==1.5.2

View file

@ -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()

View file

@ -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

View file

@ -0,0 +1,773 @@
# serializer version: 1
# name: test_sensors[entry_air_quality_index]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.AQI: 'aqi'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CO2: 'carbon_dioxide'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_sensor_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_sensor_pm10',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM10: 'pm10'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM25: 'pm25'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_sensor_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[entry_test-serial-number_air_quality_index]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.AQI: 'aqi'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.CO2: 'carbon_dioxide'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_sensor_humidity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_arve_none',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_sensor_pm10',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM10: 'pm10'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.PM25: 'pm25'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_sensor_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[entry_test-serial-number_total_volatile_organic_compounds]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.my_arve_tvoc',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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': <ANY>,
'entity_id': 'sensor.my_arve_air_quality_index',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().aqi' id='4673889600'>",
})
# ---
# name: test_sensors[my_arve_carbon_dioxide]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'carbon_dioxide',
'friendly_name': 'My Arve CO2',
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_carbon_dioxide',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().co2' id='4683517632'>",
})
# ---
# name: test_sensors[my_arve_humidity]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'My Arve Humidity',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_humidity',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().humidity' id='4674090384'>",
})
# ---
# name: test_sensors[my_arve_none]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'My Arve TVOC',
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_none',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().tvoc' id='4430967152'>",
})
# ---
# name: test_sensors[my_arve_pm10]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm10',
'friendly_name': 'My Arve PM10',
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_pm10',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().pm10' id='4683800288'>",
})
# ---
# name: test_sensors[my_arve_pm2_5]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm25',
'friendly_name': 'My Arve PM25',
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_pm2_5',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().pm25' id='4683861792'>",
})
# ---
# name: test_sensors[my_arve_temperature]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'My Arve Temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_temperature',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().temperature' id='4683873744'>",
})
# ---
# name: test_sensors[my_arve_tvoc]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'My Arve TVOC',
}),
'context': <ANY>,
'entity_id': 'sensor.my_arve_tvoc',
'last_changed': <ANY>,
'last_updated': <ANY>,
'state': "<AsyncMock name='Arve().device_sensor_data().tvoc' id='4683902432'>",
})
# ---
# name: test_sensors[test_sensor_air_quality_index]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'aqi',
'friendly_name': 'Test Sensor Air quality index',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_air_quality_index',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '14',
})
# ---
# name: test_sensors[test_sensor_carbon_dioxide]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'carbon_dioxide',
'friendly_name': 'Test Sensor Carbon dioxide',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'ppm',
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_carbon_dioxide',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '595.75',
})
# ---
# name: test_sensors[test_sensor_humidity]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'humidity',
'friendly_name': 'Test Sensor Humidity',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_humidity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '28.71',
})
# ---
# name: test_sensors[test_sensor_pm10]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm10',
'friendly_name': 'Test Sensor PM10',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_pm10',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.16',
})
# ---
# name: test_sensors[test_sensor_pm2_5]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'pm25',
'friendly_name': 'Test Sensor PM2.5',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'µg/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_pm2_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.19',
})
# ---
# name: test_sensors[test_sensor_temperature]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Test Sensor Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_sensor_total_volatile_organic_compounds',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '7',
})
# ---

View file

@ -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"

View file

@ -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}")