diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 58899d76ef8..0304945e6d2 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -11,9 +11,11 @@ from airly import Airly from airly.exceptions import AirlyError import async_timeout +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,7 +33,7 @@ from .const import ( NO_AIRLY_SENSORS, ) -PLATFORMS = ["air_quality", "sensor"] +PLATFORMS = ["sensor"] _LOGGER = logging.getLogger(__name__) @@ -111,6 +113,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # Remove air_quality entities from registry if they exist + ent_reg = entity_registry.async_get(hass) + unique_id = f"{coordinator.latitude}-{coordinator.longitude}" + if entity_id := ent_reg.async_get_entity_id( + AIR_QUALITY_PLATFORM, DOMAIN, unique_id + ): + _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) + ent_reg.async_remove(entity_id) + return True diff --git a/homeassistant/components/airly/air_quality.py b/homeassistant/components/airly/air_quality.py deleted file mode 100644 index 03c1084720d..00000000000 --- a/homeassistant/components/airly/air_quality.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Support for the Airly air_quality service.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.components.air_quality import ( - ATTR_AQI, - ATTR_PM_2_5, - ATTR_PM_10, - AirQualityEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import AirlyDataUpdateCoordinator -from .const import ( - ATTR_API_ADVICE, - ATTR_API_CAQI, - ATTR_API_CAQI_DESCRIPTION, - ATTR_API_CAQI_LEVEL, - ATTR_API_PM10, - ATTR_API_PM10_LIMIT, - ATTR_API_PM10_PERCENT, - ATTR_API_PM25, - ATTR_API_PM25_LIMIT, - ATTR_API_PM25_PERCENT, - ATTRIBUTION, - DEFAULT_NAME, - DOMAIN, - LABEL_ADVICE, - MANUFACTURER, -) - -LABEL_AQI_DESCRIPTION = f"{ATTR_AQI}_description" -LABEL_AQI_LEVEL = f"{ATTR_AQI}_level" -LABEL_PM_2_5_LIMIT = f"{ATTR_PM_2_5}_limit" -LABEL_PM_2_5_PERCENT = f"{ATTR_PM_2_5}_percent_of_limit" -LABEL_PM_10_LIMIT = f"{ATTR_PM_10}_limit" -LABEL_PM_10_PERCENT = f"{ATTR_PM_10}_percent_of_limit" - -PARALLEL_UPDATES = 1 - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up Airly air_quality entity based on a config entry.""" - name = entry.data[CONF_NAME] - - coordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([AirlyAirQuality(coordinator, name)], False) - - -class AirlyAirQuality(CoordinatorEntity, AirQualityEntity): - """Define an Airly air quality.""" - - coordinator: AirlyDataUpdateCoordinator - - def __init__(self, coordinator: AirlyDataUpdateCoordinator, name: str) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_icon = "mdi:blur" - self._attr_name = name - self._attr_unique_id = f"{coordinator.latitude}-{coordinator.longitude}" - - @property - def air_quality_index(self) -> float | None: - """Return the air quality index.""" - return round_state(self.coordinator.data[ATTR_API_CAQI]) - - @property - def particulate_matter_2_5(self) -> float | None: - """Return the particulate matter 2.5 level.""" - return round_state(self.coordinator.data.get(ATTR_API_PM25)) - - @property - def particulate_matter_10(self) -> float | None: - """Return the particulate matter 10 level.""" - return round_state(self.coordinator.data.get(ATTR_API_PM10)) - - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": { - ( - DOMAIN, - f"{self.coordinator.latitude}-{self.coordinator.longitude}", - ) - }, - "name": DEFAULT_NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the state attributes.""" - attrs = { - LABEL_AQI_DESCRIPTION: self.coordinator.data[ATTR_API_CAQI_DESCRIPTION], - LABEL_ADVICE: self.coordinator.data[ATTR_API_ADVICE], - LABEL_AQI_LEVEL: self.coordinator.data[ATTR_API_CAQI_LEVEL], - } - if ATTR_API_PM25 in self.coordinator.data: - attrs[LABEL_PM_2_5_LIMIT] = self.coordinator.data[ATTR_API_PM25_LIMIT] - attrs[LABEL_PM_2_5_PERCENT] = round( - self.coordinator.data[ATTR_API_PM25_PERCENT] - ) - if ATTR_API_PM10 in self.coordinator.data: - attrs[LABEL_PM_10_LIMIT] = self.coordinator.data[ATTR_API_PM10_LIMIT] - attrs[LABEL_PM_10_PERCENT] = round( - self.coordinator.data[ATTR_API_PM10_PERCENT] - ) - return attrs - - -def round_state(state: float | None) -> float | None: - """Round state.""" - return round(state) if state else state diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index a860a7a1b5a..d79a33a66ab 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -24,16 +24,22 @@ ATTR_API_CAQI_DESCRIPTION: Final = "DESCRIPTION" ATTR_API_CAQI_LEVEL: Final = "LEVEL" ATTR_API_HUMIDITY: Final = "HUMIDITY" ATTR_API_PM10: Final = "PM10" -ATTR_API_PM10_LIMIT: Final = "PM10_LIMIT" -ATTR_API_PM10_PERCENT: Final = "PM10_PERCENT" ATTR_API_PM1: Final = "PM1" ATTR_API_PM25: Final = "PM25" -ATTR_API_PM25_LIMIT: Final = "PM25_LIMIT" -ATTR_API_PM25_PERCENT: Final = "PM25_PERCENT" ATTR_API_PRESSURE: Final = "PRESSURE" ATTR_API_TEMPERATURE: Final = "TEMPERATURE" + +ATTR_ADVICE: Final = "advice" +ATTR_DESCRIPTION: Final = "description" ATTR_LABEL: Final = "label" +ATTR_LEVEL: Final = "level" +ATTR_LIMIT: Final = "limit" +ATTR_PERCENT: Final = "percent" ATTR_UNIT: Final = "unit" +ATTR_VALUE: Final = "value" + +SUFFIX_PERCENT: Final = "PERCENT" +SUFFIX_LIMIT: Final = "LIMIT" ATTRIBUTION: Final = "Data provided by Airly" CONF_USE_NEAREST: Final = "use_nearest" @@ -46,32 +52,51 @@ MIN_UPDATE_INTERVAL: Final = 5 NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." SENSOR_TYPES: dict[str, SensorDescription] = { + ATTR_API_CAQI: { + ATTR_LABEL: ATTR_API_CAQI, + ATTR_UNIT: "CAQI", + ATTR_VALUE: round, + }, ATTR_API_PM1: { - ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:blur", ATTR_LABEL: ATTR_API_PM1, ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_VALUE: round, + }, + ATTR_API_PM25: { + ATTR_ICON: "mdi:blur", + ATTR_LABEL: "PM2.5", + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_VALUE: round, + }, + ATTR_API_PM10: { + ATTR_ICON: "mdi:blur", + ATTR_LABEL: ATTR_API_PM10, + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_VALUE: round, }, ATTR_API_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, ATTR_LABEL: ATTR_API_HUMIDITY.capitalize(), ATTR_UNIT: PERCENTAGE, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_VALUE: lambda value: round(value, 1), }, ATTR_API_PRESSURE: { ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, ATTR_LABEL: ATTR_API_PRESSURE.capitalize(), ATTR_UNIT: PRESSURE_HPA, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_VALUE: round, }, ATTR_API_TEMPERATURE: { ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, ATTR_LABEL: ATTR_API_TEMPERATURE.capitalize(), ATTR_UNIT: TEMP_CELSIUS, ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_VALUE: lambda value: round(value, 1), }, } diff --git a/homeassistant/components/airly/model.py b/homeassistant/components/airly/model.py index 6109b6e71d9..fe8ad6c929b 100644 --- a/homeassistant/components/airly/model.py +++ b/homeassistant/components/airly/model.py @@ -1,14 +1,15 @@ """Type definitions for Airly integration.""" from __future__ import annotations -from typing import TypedDict +from typing import Callable, TypedDict -class SensorDescription(TypedDict): +class SensorDescription(TypedDict, total=False): """Sensor description class.""" device_class: str | None icon: str | None label: str unit: str - state_class: str + state_class: str | None + value: Callable diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 9c820a02d1b..3f9048dd03e 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -1,7 +1,7 @@ """Support for the Airly sensor service.""" from __future__ import annotations -from typing import cast +from typing import Any, cast from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -19,15 +19,27 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirlyDataUpdateCoordinator from .const import ( - ATTR_API_PM1, - ATTR_API_PRESSURE, + ATTR_ADVICE, + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + ATTR_API_PM10, + ATTR_API_PM25, + ATTR_DESCRIPTION, ATTR_LABEL, + ATTR_LEVEL, + ATTR_LIMIT, + ATTR_PERCENT, ATTR_UNIT, + ATTR_VALUE, ATTRIBUTION, DEFAULT_NAME, DOMAIN, MANUFACTURER, SENSOR_TYPES, + SUFFIX_LIMIT, + SUFFIX_PERCENT, ) PARALLEL_UPDATES = 1 @@ -60,26 +72,48 @@ class AirlySensor(CoordinatorEntity, SensorEntity): ) -> None: """Initialize.""" super().__init__(coordinator) - description = SENSOR_TYPES[kind] - self._attr_device_class = description[ATTR_DEVICE_CLASS] - self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - self._attr_icon = description[ATTR_ICON] + self._description = description = SENSOR_TYPES[kind] + self._attr_device_class = description.get(ATTR_DEVICE_CLASS) + self._attr_icon = description.get(ATTR_ICON) self._attr_name = f"{name} {description[ATTR_LABEL]}" - self._attr_state_class = description[ATTR_STATE_CLASS] + self._attr_state_class = description.get(ATTR_STATE_CLASS) self._attr_unique_id = ( f"{coordinator.latitude}-{coordinator.longitude}-{kind.lower()}" ) - self._attr_unit_of_measurement = description[ATTR_UNIT] + self._attr_unit_of_measurement = description.get(ATTR_UNIT) + self._attrs: dict[str, Any] = {ATTR_ATTRIBUTION: ATTRIBUTION} self.kind = kind - self._state = None @property def state(self) -> StateType: """Return the state.""" - self._state = self.coordinator.data[self.kind] - if self.kind in [ATTR_API_PM1, ATTR_API_PRESSURE]: - return round(cast(float, self._state)) - return round(cast(float, self._state), 1) + state = self.coordinator.data[self.kind] + return cast(StateType, self._description[ATTR_VALUE](state)) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + if self.kind == ATTR_API_CAQI: + self._attrs[ATTR_LEVEL] = self.coordinator.data[ATTR_API_CAQI_LEVEL] + self._attrs[ATTR_ADVICE] = self.coordinator.data[ATTR_API_ADVICE] + self._attrs[ATTR_DESCRIPTION] = self.coordinator.data[ + ATTR_API_CAQI_DESCRIPTION + ] + if self.kind == ATTR_API_PM25: + self._attrs[ATTR_LIMIT] = self.coordinator.data[ + f"{ATTR_API_PM25}_{SUFFIX_LIMIT}" + ] + self._attrs[ATTR_PERCENT] = round( + self.coordinator.data[f"{ATTR_API_PM25}_{SUFFIX_PERCENT}"] + ) + if self.kind == ATTR_API_PM10: + self._attrs[ATTR_LIMIT] = self.coordinator.data[ + f"{ATTR_API_PM10}_{SUFFIX_LIMIT}" + ] + self._attrs[ATTR_PERCENT] = round( + self.coordinator.data[f"{ATTR_API_PM10}_{SUFFIX_PERCENT}"] + ) + return self._attrs @property def device_info(self) -> DeviceInfo: diff --git a/tests/components/airly/test_air_quality.py b/tests/components/airly/test_air_quality.py deleted file mode 100644 index de059e84aa4..00000000000 --- a/tests/components/airly/test_air_quality.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Test air_quality of Airly integration.""" -from datetime import timedelta - -from airly.exceptions import AirlyError - -from homeassistant.components.air_quality import ATTR_AQI, ATTR_PM_2_5, ATTR_PM_10 -from homeassistant.components.airly.air_quality import ( - ATTRIBUTION, - LABEL_ADVICE, - LABEL_AQI_DESCRIPTION, - LABEL_AQI_LEVEL, - LABEL_PM_2_5_LIMIT, - LABEL_PM_2_5_PERCENT, - LABEL_PM_10_LIMIT, - LABEL_PM_10_PERCENT, -) -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_ENTITY_ID, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - HTTP_INTERNAL_SERVER_ERROR, - STATE_UNAVAILABLE, -) -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow - -from . import API_POINT_URL - -from tests.common import async_fire_time_changed, load_fixture -from tests.components.airly import init_integration - - -async def test_air_quality(hass, aioclient_mock): - """Test states of the air_quality.""" - await init_integration(hass, aioclient_mock) - registry = er.async_get(hass) - - state = hass.states.get("air_quality.home") - assert state - assert state.state == "14" - assert state.attributes.get(ATTR_AQI) == 23 - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(LABEL_ADVICE) == "Great air!" - assert state.attributes.get(ATTR_PM_10) == 19 - assert state.attributes.get(ATTR_PM_2_5) == 14 - assert state.attributes.get(LABEL_AQI_DESCRIPTION) == "Great air here today!" - assert state.attributes.get(LABEL_AQI_LEVEL) == "very low" - assert state.attributes.get(LABEL_PM_2_5_LIMIT) == 25.0 - assert state.attributes.get(LABEL_PM_2_5_PERCENT) == 55 - assert state.attributes.get(LABEL_PM_10_LIMIT) == 50.0 - assert state.attributes.get(LABEL_PM_10_PERCENT) == 37 - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" - - entry = registry.async_get("air_quality.home") - assert entry - assert entry.unique_id == "123-456" - - -async def test_availability(hass, aioclient_mock): - """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass, aioclient_mock) - - state = hass.states.get("air_quality.home") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "14" - - aioclient_mock.clear_requests() - aioclient_mock.get( - API_POINT_URL, exc=AirlyError(HTTP_INTERNAL_SERVER_ERROR, "Unexpected error") - ) - future = utcnow() + timedelta(minutes=60) - - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("air_quality.home") - assert state - assert state.state == STATE_UNAVAILABLE - - aioclient_mock.clear_requests() - aioclient_mock.get(API_POINT_URL, text=load_fixture("airly_valid_station.json")) - future = utcnow() + timedelta(minutes=120) - - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("air_quality.home") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "14" - - -async def test_manual_update_entity(hass, aioclient_mock): - """Test manual update entity via service homeasasistant/update_entity.""" - await init_integration(hass, aioclient_mock) - - call_count = aioclient_mock.call_count - await async_setup_component(hass, "homeassistant", {}) - await hass.services.async_call( - "homeassistant", - "update_entity", - {ATTR_ENTITY_ID: ["air_quality.home"]}, - blocking=True, - ) - - assert aioclient_mock.call_count == call_count + 1 diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index a20ae6ddd1a..252c01c124a 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -3,10 +3,12 @@ from unittest.mock import patch import pytest +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.airly import set_update_interval from homeassistant.components.airly.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from . import API_POINT_URL @@ -24,7 +26,7 @@ async def test_async_setup_entry(hass, aioclient_mock): """Test a successful setup entry.""" await init_integration(hass, aioclient_mock) - state = hass.states.get("air_quality.home") + state = hass.states.get("sensor.home_pm2_5") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == "14" @@ -216,3 +218,21 @@ async def test_migrate_device_entry(hass, aioclient_mock, old_identifier): config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "123-456")} ) assert device_entry.id == migrated_device_entry.id + + +async def test_remove_air_quality_entities(hass, aioclient_mock): + """Test remove air_quality entities from registry.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + AIR_QUALITY_PLATFORM, + DOMAIN, + "123-456", + suggested_object_id="home", + disabled_by=None, + ) + + await init_integration(hass, aioclient_mock) + + entry = registry.async_get("air_quality.home") + assert entry is None diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py index 7db3c81f44d..c566702a5b4 100644 --- a/tests/components/airly/test_sensor.py +++ b/tests/components/airly/test_sensor.py @@ -33,6 +33,16 @@ async def test_sensor(hass, aioclient_mock): await init_integration(hass, aioclient_mock) registry = er.async_get(hass) + state = hass.states.get("sensor.home_caqi") + assert state + assert state.state == "23" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "CAQI" + + entry = registry.async_get("sensor.home_caqi") + assert entry + assert entry.unique_id == "123-456-caqi" + state = hass.states.get("sensor.home_humidity") assert state assert state.state == "92.8" @@ -60,6 +70,36 @@ async def test_sensor(hass, aioclient_mock): assert entry assert entry.unique_id == "123-456-pm1" + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "14" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + + entry = registry.async_get("sensor.home_pm2_5") + assert entry + assert entry.unique_id == "123-456-pm25" + + state = hass.states.get("sensor.home_pm10") + assert state + assert state.state == "19" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + + entry = registry.async_get("sensor.home_pm10") + assert entry + assert entry.unique_id == "123-456-pm10" + state = hass.states.get("sensor.home_pressure") assert state assert state.state == "1001"