Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add select entities to AirGradient #117136

Merged
merged 8 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 30 additions & 7 deletions homeassistant/components/airgradient/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

await coordinator.async_config_entry_first_refresh()

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
client = AirGradientClient(
entry.data[CONF_HOST], session=async_get_clientsession(hass)
)

measurement_coordinator = AirGradientMeasurementCoordinator(hass, client)
config_coordinator = AirGradientConfigCoordinator(hass, client)

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)

Expand Down
45 changes: 35 additions & 10 deletions homeassistant/components/airgradient/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
12 changes: 4 additions & 8 deletions homeassistant/components/airgradient/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,17 @@
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)},
)
119 changes: 119 additions & 0 deletions homeassistant/components/airgradient/select.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 7 additions & 4 deletions homeassistant/components/airgradient/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
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


Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
22 changes: 22 additions & 0 deletions homeassistant/components/airgradient/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."
}
}
}
8 changes: 6 additions & 2 deletions tests/components/airgradient/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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


Expand Down
Loading