Add select entities to AirGradient (#117136)

This commit is contained in:
Joost Lekkerkerker 2024-05-29 20:12:51 +02:00 committed by GitHub
parent 6382cb9134
commit c80718628e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 549 additions and 33 deletions

View File

@ -2,24 +2,47 @@
from __future__ import annotations
from airgradient import AirGradientClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import AirGradientDataUpdateCoordinator
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SELECT, 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])
client = AirGradientClient(
entry.data[CONF_HOST], session=async_get_clientsession(hass)
)
await coordinator.async_config_entry_first_refresh()
measurement_coordinator = AirGradientMeasurementCoordinator(hass, client)
config_coordinator = AirGradientConfigCoordinator(hass, client)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await measurement_coordinator.async_config_entry_first_refresh()
await config_coordinator.async_config_entry_first_refresh()
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, measurement_coordinator.serial_number)},
manufacturer="AirGradient",
model=measurement_coordinator.data.model,
serial_number=measurement_coordinator.data.serial_number,
sw_version=measurement_coordinator.data.firmware_version,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"measurement": measurement_coordinator,
"config": config_coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -2,31 +2,56 @@
from datetime import timedelta
from airgradient import AirGradientClient, AirGradientError, Measures
from airgradient import AirGradientClient, AirGradientError, Config, Measures
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
class AirGradientDataUpdateCoordinator(DataUpdateCoordinator[Measures]):
class AirGradientCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Class to manage fetching AirGradient data."""
def __init__(self, hass: HomeAssistant, host: str) -> None:
_update_interval: timedelta
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, client: AirGradientClient) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
logger=LOGGER,
name=f"AirGradient {host}",
update_interval=timedelta(minutes=1),
name=f"AirGradient {client.host}",
update_interval=self._update_interval,
)
session = async_get_clientsession(hass)
self.client = AirGradientClient(host, session=session)
self.client = client
assert self.config_entry.unique_id
self.serial_number = self.config_entry.unique_id
async def _async_update_data(self) -> Measures:
async def _async_update_data(self) -> _DataT:
try:
return await self.client.get_current_measures()
return await self._update_data()
except AirGradientError as error:
raise UpdateFailed(error) from error
async def _update_data(self) -> _DataT:
raise NotImplementedError
class AirGradientMeasurementCoordinator(AirGradientCoordinator[Measures]):
"""Class to manage fetching AirGradient data."""
_update_interval = timedelta(minutes=1)
async def _update_data(self) -> Measures:
return await self.client.get_current_measures()
class AirGradientConfigCoordinator(AirGradientCoordinator[Config]):
"""Class to manage fetching AirGradient data."""
_update_interval = timedelta(minutes=5)
async def _update_data(self) -> Config:
return await self.client.get_config()

View File

@ -4,21 +4,17 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AirGradientDataUpdateCoordinator
from .coordinator import AirGradientCoordinator
class AirGradientEntity(CoordinatorEntity[AirGradientDataUpdateCoordinator]):
class AirGradientEntity(CoordinatorEntity[AirGradientCoordinator]):
"""Defines a base AirGradient entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: AirGradientDataUpdateCoordinator) -> None:
def __init__(self, coordinator: AirGradientCoordinator) -> None:
"""Initialize airgradient entity."""
super().__init__(coordinator)
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,
identifiers={(DOMAIN, coordinator.serial_number)},
)

View File

@ -0,0 +1,119 @@
"""Support for AirGradient select entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from airgradient import AirGradientClient, Config
from airgradient.models import ConfigurationControl, TemperatureUnit
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN
from .coordinator import AirGradientConfigCoordinator, AirGradientMeasurementCoordinator
from .entity import AirGradientEntity
@dataclass(frozen=True, kw_only=True)
class AirGradientSelectEntityDescription(SelectEntityDescription):
"""Describes AirGradient select entity."""
value_fn: Callable[[Config], str]
set_value_fn: Callable[[AirGradientClient, str], Awaitable[None]]
requires_display: bool = False
CONFIG_CONTROL_ENTITY = AirGradientSelectEntityDescription(
key="configuration_control",
translation_key="configuration_control",
options=[x.value for x in ConfigurationControl],
value_fn=lambda config: config.configuration_control,
set_value_fn=lambda client, value: client.set_configuration_control(
ConfigurationControl(value)
),
)
PROTECTED_SELECT_TYPES: tuple[AirGradientSelectEntityDescription, ...] = (
AirGradientSelectEntityDescription(
key="display_temperature_unit",
translation_key="display_temperature_unit",
options=[x.value for x in TemperatureUnit],
value_fn=lambda config: config.temperature_unit,
set_value_fn=lambda client, value: client.set_temperature_unit(
TemperatureUnit(value)
),
requires_display=True,
),
)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up AirGradient select entities based on a config entry."""
config_coordinator: AirGradientConfigCoordinator = hass.data[DOMAIN][
entry.entry_id
]["config"]
measurement_coordinator: AirGradientMeasurementCoordinator = hass.data[DOMAIN][
entry.entry_id
]["measurement"]
entities = [AirGradientSelect(config_coordinator, CONFIG_CONTROL_ENTITY)]
entities.extend(
AirGradientProtectedSelect(config_coordinator, description)
for description in PROTECTED_SELECT_TYPES
if (
description.requires_display
and measurement_coordinator.data.model.startswith("I")
)
)
async_add_entities(entities)
class AirGradientSelect(AirGradientEntity, SelectEntity):
"""Defines an AirGradient select entity."""
entity_description: AirGradientSelectEntityDescription
coordinator: AirGradientConfigCoordinator
def __init__(
self,
coordinator: AirGradientConfigCoordinator,
description: AirGradientSelectEntityDescription,
) -> None:
"""Initialize AirGradient select."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}-{description.key}"
@property
def current_option(self) -> str:
"""Return the state of the select."""
return self.entity_description.value_fn(self.coordinator.data)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.set_value_fn(self.coordinator.client, option)
await self.coordinator.async_request_refresh()
class AirGradientProtectedSelect(AirGradientSelect):
"""Defines a protected AirGradient select entity."""
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
if (
self.coordinator.data.configuration_control
is not ConfigurationControl.LOCAL
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_local_configuration",
)
await super().async_select_option(option)

View File

@ -24,8 +24,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AirGradientDataUpdateCoordinator
from .const import DOMAIN
from .coordinator import AirGradientMeasurementCoordinator
from .entity import AirGradientEntity
@ -130,7 +130,9 @@ async def async_setup_entry(
) -> None:
"""Set up AirGradient sensor entities based on a config entry."""
coordinator: AirGradientDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator: AirGradientMeasurementCoordinator = hass.data[DOMAIN][entry.entry_id][
"measurement"
]
listener: Callable[[], None] | None = None
not_setup: set[AirGradientSensorEntityDescription] = set(SENSOR_TYPES)
@ -162,16 +164,17 @@ class AirGradientSensor(AirGradientEntity, SensorEntity):
"""Defines an AirGradient sensor."""
entity_description: AirGradientSensorEntityDescription
coordinator: AirGradientMeasurementCoordinator
def __init__(
self,
coordinator: AirGradientDataUpdateCoordinator,
coordinator: AirGradientMeasurementCoordinator,
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_unique_id = f"{coordinator.serial_number}-{description.key}"
@property
def native_value(self) -> StateType:

View File

@ -23,6 +23,23 @@
}
},
"entity": {
"select": {
"configuration_control": {
"name": "Configuration source",
"state": {
"cloud": "Cloud",
"local": "Local",
"both": "Both"
}
},
"display_temperature_unit": {
"name": "Display temperature unit",
"state": {
"c": "Celsius",
"f": "Fahrenheit"
}
}
},
"sensor": {
"total_volatile_organic_component_index": {
"name": "Total VOC index"
@ -40,5 +57,10 @@
"name": "Raw nitrogen"
}
}
},
"exceptions": {
"no_local_configuration": {
"message": "Device should be configured with local configuration to be able to change settings."
}
}
}

View File

@ -3,7 +3,7 @@
from collections.abc import Generator
from unittest.mock import patch
from airgradient import Measures
from airgradient import Config, Measures
import pytest
from homeassistant.components.airgradient.const import DOMAIN
@ -28,7 +28,7 @@ def mock_airgradient_client() -> Generator[AsyncMock, None, None]:
"""Mock an AirGradient client."""
with (
patch(
"homeassistant.components.airgradient.coordinator.AirGradientClient",
"homeassistant.components.airgradient.AirGradientClient",
autospec=True,
) as mock_client,
patch(
@ -37,9 +37,13 @@ def mock_airgradient_client() -> Generator[AsyncMock, None, None]:
),
):
client = mock_client.return_value
client.host = "10.0.0.131"
client.get_current_measures.return_value = Measures.from_json(
load_fixture("current_measures.json", DOMAIN)
)
client.get_config.return_value = Config.from_json(
load_fixture("get_config.json", DOMAIN)
)
yield client

View File

@ -0,0 +1,24 @@
{
"wifi": -64,
"serialno": "84fce60bec38",
"channels": {
"1": {
"pm01": 3,
"pm02": 5,
"pm10": 5,
"pm003Count": 753,
"atmp": 18.8,
"rhum": 68,
"atmpCompensated": 17.09,
"rhumCompensated": 92
}
},
"tvocIndex": 49,
"tvocRaw": 30802,
"noxIndex": 1,
"noxRaw": 16359,
"bootCount": 1,
"ledMode": "co2",
"firmware": "3.1.1",
"model": "O-1PPT"
}

View File

@ -0,0 +1,13 @@
{
"country": "DE",
"pmStandard": "ugm3",
"ledBarMode": "co2",
"displayMode": "on",
"abcDays": 8,
"tvocLearningOffset": 12,
"noxLearningOffset": 12,
"mqttBrokerUrl": "",
"temperatureUnit": "c",
"configurationControl": "both",
"postDataToAirGradient": true
}

View File

@ -0,0 +1,170 @@
# serializer version: 1
# name: test_all_entities[select.airgradient_configuration_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'cloud',
'local',
'both',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.airgradient_configuration_source',
'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': 'Configuration source',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'configuration_control',
'unique_id': '84fce612f5b8-configuration_control',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[select.airgradient_configuration_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Configuration source',
'options': list([
'cloud',
'local',
'both',
]),
}),
'context': <ANY>,
'entity_id': 'select.airgradient_configuration_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'both',
})
# ---
# name: test_all_entities[select.airgradient_display_temperature_unit-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'c',
'f',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.airgradient_display_temperature_unit',
'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': 'Display temperature unit',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'display_temperature_unit',
'unique_id': '84fce612f5b8-display_temperature_unit',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[select.airgradient_display_temperature_unit-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Display temperature unit',
'options': list([
'c',
'f',
]),
}),
'context': <ANY>,
'entity_id': 'select.airgradient_display_temperature_unit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'c',
})
# ---
# name: test_all_entities_outdoor[select.airgradient_configuration_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'cloud',
'local',
'both',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': None,
'entity_id': 'select.airgradient_configuration_source',
'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': 'Configuration source',
'platform': 'airgradient',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'configuration_control',
'unique_id': '84fce612f5b8-configuration_control',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities_outdoor[select.airgradient_configuration_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Airgradient Configuration source',
'options': list([
'cloud',
'local',
'both',
]),
}),
'context': <ANY>,
'entity_id': 'select.airgradient_configuration_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'both',
})
# ---

View File

@ -0,0 +1,115 @@
"""Tests for the AirGradient select platform."""
from unittest.mock import AsyncMock, patch
from airgradient import ConfigurationControl, Measures
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient import DOMAIN
from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, 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."""
with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SELECT]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities_outdoor(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
mock_airgradient_client.get_current_measures.return_value = Measures.from_json(
load_fixture("current_measures_outdoor.json", DOMAIN)
)
with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SELECT]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_setting_value(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting value."""
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.airgradient_configuration_source",
ATTR_OPTION: "local",
},
blocking=True,
)
mock_airgradient_client.set_configuration_control.assert_called_once_with("local")
assert mock_airgradient_client.get_config.call_count == 2
async def test_setting_protected_value(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test setting protected value."""
await setup_integration(hass, mock_config_entry)
mock_airgradient_client.get_config.return_value.configuration_control = (
ConfigurationControl.CLOUD
)
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.airgradient_display_temperature_unit",
ATTR_OPTION: "c",
},
blocking=True,
)
mock_airgradient_client.set_temperature_unit.assert_not_called()
mock_airgradient_client.get_config.return_value.configuration_control = (
ConfigurationControl.LOCAL
)
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: "select.airgradient_display_temperature_unit",
ATTR_OPTION: "c",
},
blocking=True,
)
mock_airgradient_client.set_temperature_unit.assert_called_once_with("c")

View File

@ -1,7 +1,7 @@
"""Tests for the AirGradient sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
from airgradient import AirGradientError, Measures
from freezegun.api import FrozenDateTimeFactory
@ -9,7 +9,7 @@ import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.airgradient import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -32,7 +32,8 @@ async def test_all_entities(
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await setup_integration(hass, mock_config_entry)
with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@ -47,7 +48,8 @@ async def test_create_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)
with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SENSOR]):
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(