From fca0446ff8cf83da9cb39cfba37b53a85bbb7d84 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 10 Jun 2021 14:18:09 +0200 Subject: [PATCH] Add pollen sensors to Ambee (#51702) --- homeassistant/components/ambee/__init__.py | 25 +- homeassistant/components/ambee/const.py | 231 ++++++++++++--- homeassistant/components/ambee/models.py | 15 + homeassistant/components/ambee/sensor.py | 86 ++++-- .../components/ambee/strings.sensor.json | 10 + .../ambee/translations/sensor.en.json | 10 + tests/components/ambee/conftest.py | 5 +- tests/components/ambee/test_init.py | 6 +- tests/components/ambee/test_sensor.py | 265 ++++++++++++++++-- tests/fixtures/ambee/pollen.json | 43 +++ 10 files changed, 585 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/ambee/models.py create mode 100644 homeassistant/components/ambee/strings.sensor.json create mode 100644 homeassistant/components/ambee/translations/sensor.en.json create mode 100644 tests/fixtures/ambee/pollen.json diff --git a/homeassistant/components/ambee/__init__.py b/homeassistant/components/ambee/__init__.py index cea586d1a67..4968420174e 100644 --- a/homeassistant/components/ambee/__init__.py +++ b/homeassistant/components/ambee/__init__.py @@ -9,30 +9,31 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, LOGGER, SCAN_INTERVAL, SERVICE_AIR_QUALITY, SERVICE_POLLEN PLATFORMS = (SENSOR_DOMAIN,) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ambee from a config entry.""" + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) + client = Ambee( api_key=entry.data[CONF_API_KEY], latitude=entry.data[CONF_LATITUDE], longitude=entry.data[CONF_LONGITUDE], ) - coordinator = DataUpdateCoordinator( - hass, - LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - update_method=client.air_quality, - ) - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + for service in {SERVICE_AIR_QUALITY, SERVICE_POLLEN}: + coordinator: DataUpdateCoordinator = DataUpdateCoordinator( + hass, + LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + update_method=getattr(client, service), + ) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id][service] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/ambee/const.py b/homeassistant/components/ambee/const.py index 56107131f34..c30d1f8eadc 100644 --- a/homeassistant/components/ambee/const.py +++ b/homeassistant/components/ambee/const.py @@ -3,67 +3,210 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any, Final +from typing import Final from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, ATTR_NAME, - ATTR_SERVICE, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO, ) +from .models import AmbeeSensor + DOMAIN: Final = "ambee" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(minutes=180) -SERVICE_AIR_QUALITY: Final = ("air_quality", "Air Quality") +ATTR_ENABLED_BY_DEFAULT: Final = "enabled_by_default" +ATTR_ENTRY_TYPE: Final = "entry_type" +ENTRY_TYPE_SERVICE: Final = "service" -SENSORS: dict[str, dict[str, Any]] = { - "particulate_matter_2_5": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Particulate Matter < 2.5 μm", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, +DEVICE_CLASS_AMBEE_RISK: Final = "ambee__risk" + +SERVICE_AIR_QUALITY: Final = "air_quality" +SERVICE_POLLEN: Final = "pollen" + +SERVICES: dict[str, str] = { + SERVICE_AIR_QUALITY: "Air Quality", + SERVICE_POLLEN: "Pollen", +} + +SENSORS: dict[str, dict[str, AmbeeSensor]] = { + SERVICE_AIR_QUALITY: { + "particulate_matter_2_5": { + ATTR_NAME: "Particulate Matter < 2.5 μm", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "particulate_matter_10": { + ATTR_NAME: "Particulate Matter < 10 μm", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "sulphur_dioxide": { + ATTR_NAME: "Sulphur Dioxide (SO2)", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "nitrogen_dioxide": { + ATTR_NAME: "Nitrogen Dioxide (NO2)", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "ozone": { + ATTR_NAME: "Ozone", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "carbon_monoxide": { + ATTR_NAME: "Carbon Monoxide (CO)", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CO, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + "air_quality_index": { + ATTR_NAME: "Air Quality Index (AQI)", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, }, - "particulate_matter_10": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Particulate Matter < 10 μm", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "sulphur_dioxide": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Sulphur Dioxide (SO2)", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "nitrogen_dioxide": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Nitrogen Dioxide (NO2)", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "ozone": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Ozone", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "carbon_monoxide": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Carbon Monoxide (CO)", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "air_quality_index": { - ATTR_SERVICE: SERVICE_AIR_QUALITY, - ATTR_NAME: "Air Quality Index (AQI)", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + SERVICE_POLLEN: { + "grass": { + ATTR_NAME: "Grass Pollen", + ATTR_ICON: "mdi:grass", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "tree": { + ATTR_NAME: "Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "weed": { + ATTR_NAME: "Weed Pollen", + ATTR_ICON: "mdi:sprout", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + }, + "grass_risk": { + ATTR_NAME: "Grass Pollen Risk", + ATTR_ICON: "mdi:grass", + ATTR_DEVICE_CLASS: DEVICE_CLASS_AMBEE_RISK, + }, + "tree_risk": { + ATTR_NAME: "Tree Pollen Risk", + ATTR_ICON: "mdi:tree", + ATTR_DEVICE_CLASS: DEVICE_CLASS_AMBEE_RISK, + }, + "weed_risk": { + ATTR_NAME: "Weed Pollen Risk", + ATTR_ICON: "mdi:sprout", + ATTR_DEVICE_CLASS: DEVICE_CLASS_AMBEE_RISK, + }, + "grass_poaceae": { + ATTR_NAME: "Poaceae Grass Pollen", + ATTR_ICON: "mdi:grass", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_alder": { + ATTR_NAME: "Alder Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_birch": { + ATTR_NAME: "Birch Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_cypress": { + ATTR_NAME: "Cypress Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_elm": { + ATTR_NAME: "Elm Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_hazel": { + ATTR_NAME: "Hazel Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_oak": { + ATTR_NAME: "Oak Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_pine": { + ATTR_NAME: "Pine Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_plane": { + ATTR_NAME: "Plane Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "tree_poplar": { + ATTR_NAME: "Poplar Tree Pollen", + ATTR_ICON: "mdi:tree", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "weed_chenopod": { + ATTR_NAME: "Chenopod Weed Pollen", + ATTR_ICON: "mdi:sprout", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "weed_mugwort": { + ATTR_NAME: "Mugwort Weed Pollen", + ATTR_ICON: "mdi:sprout", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "weed_nettle": { + ATTR_NAME: "Nettle Weed Pollen", + ATTR_ICON: "mdi:sprout", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, + "weed_ragweed": { + ATTR_NAME: "Ragweed Weed Pollen", + ATTR_ICON: "mdi:sprout", + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, + ATTR_ENABLED_BY_DEFAULT: False, + }, }, } diff --git a/homeassistant/components/ambee/models.py b/homeassistant/components/ambee/models.py new file mode 100644 index 00000000000..871aeed332b --- /dev/null +++ b/homeassistant/components/ambee/models.py @@ -0,0 +1,15 @@ +"""Models helper class for the Ambee integration.""" +from __future__ import annotations + +from typing import TypedDict + + +class AmbeeSensor(TypedDict, total=False): + """Represent an Ambee Sensor.""" + + device_class: str + enabled_by_default: bool + icon: str + name: str + state_class: str + unit_of_measurement: str diff --git a/homeassistant/components/ambee/sensor.py b/homeassistant/components/ambee/sensor.py index 0bb626afb62..54e67160822 100644 --- a/homeassistant/components/ambee/sensor.py +++ b/homeassistant/components/ambee/sensor.py @@ -1,18 +1,21 @@ """Support for Ambee sensors.""" from __future__ import annotations -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ICON, ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME, - ATTR_SERVICE, ATTR_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( @@ -20,7 +23,15 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN, SENSORS +from .const import ( + ATTR_ENABLED_BY_DEFAULT, + ATTR_ENTRY_TYPE, + DOMAIN, + ENTRY_TYPE_SERVICE, + SENSORS, + SERVICES, +) +from .models import AmbeeSensor async def async_setup_entry( @@ -28,42 +39,61 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Ambee sensor based on a config entry.""" - coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + """Set up Ambee sensors based on a config entry.""" async_add_entities( - AmbeeSensor(coordinator=coordinator, entry_id=entry.entry_id, key=sensor) - for sensor in SENSORS + AmbeeSensorEntity( + coordinator=hass.data[DOMAIN][entry.entry_id][service_key], + entry_id=entry.entry_id, + sensor_key=sensor_key, + sensor=sensor, + service_key=service_key, + service=SERVICES[service_key], + ) + for service_key, service_sensors in SENSORS.items() + for sensor_key, sensor in service_sensors.items() ) -class AmbeeSensor(CoordinatorEntity, SensorEntity): +class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): """Defines an Ambee sensor.""" def __init__( - self, *, coordinator: DataUpdateCoordinator, entry_id: str, key: str + self, + *, + coordinator: DataUpdateCoordinator, + entry_id: str, + sensor_key: str, + sensor: AmbeeSensor, + service_key: str, + service: str, ) -> None: """Initialize Ambee sensor.""" super().__init__(coordinator=coordinator) - self._key = key - self._entry_id = entry_id - self._service_key, self._service_name = SENSORS[key][ATTR_SERVICE] + self._sensor_key = sensor_key + self._service_key = service_key - self._attr_device_class = SENSORS[key].get(ATTR_DEVICE_CLASS) - self._attr_name = SENSORS[key][ATTR_NAME] - self._attr_state_class = SENSORS[key].get(ATTR_STATE_CLASS) - self._attr_unique_id = f"{entry_id}_{key}" - self._attr_unit_of_measurement = SENSORS[key].get(ATTR_UNIT_OF_MEASUREMENT) + self.entity_id = f"{SENSOR_DOMAIN}.{service_key}_{sensor_key}" + self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) + self._attr_entity_registry_enabled_default = sensor.get( + ATTR_ENABLED_BY_DEFAULT, True + ) + self._attr_icon = sensor.get(ATTR_ICON) + self._attr_name = sensor.get(ATTR_NAME) + self._attr_state_class = sensor.get(ATTR_STATE_CLASS) + self._attr_unique_id = f"{entry_id}_{service_key}_{sensor_key}" + self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) + + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, f"{entry_id}_{service_key}")}, + ATTR_NAME: service, + ATTR_MANUFACTURER: "Ambee", + ATTR_ENTRY_TYPE: ENTRY_TYPE_SERVICE, + } @property def state(self) -> StateType: """Return the state of the sensor.""" - return getattr(self.coordinator.data, self._key) # type: ignore[no-any-return] - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Ambee Service.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, f"{self._entry_id}_{self._service_key}")}, - ATTR_NAME: self._service_name, - ATTR_MANUFACTURER: "Ambee", - } + value = getattr(self.coordinator.data, self._sensor_key) + if isinstance(value, str): + return value.lower() + return value # type: ignore[no-any-return] diff --git a/homeassistant/components/ambee/strings.sensor.json b/homeassistant/components/ambee/strings.sensor.json new file mode 100644 index 00000000000..83eb3b3fd73 --- /dev/null +++ b/homeassistant/components/ambee/strings.sensor.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "low": "Low", + "moderate": "Moderate", + "high": "High", + "very high": "Very High" + } + } +} diff --git a/homeassistant/components/ambee/translations/sensor.en.json b/homeassistant/components/ambee/translations/sensor.en.json new file mode 100644 index 00000000000..a4b198eadf5 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.en.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "High", + "low": "Low", + "moderate": "Moderate", + "very high": "Very High" + } + } +} \ No newline at end of file diff --git a/tests/components/ambee/conftest.py b/tests/components/ambee/conftest.py index de88e28e1d1..d6dd53a9711 100644 --- a/tests/components/ambee/conftest.py +++ b/tests/components/ambee/conftest.py @@ -2,7 +2,7 @@ import json from unittest.mock import AsyncMock, MagicMock, patch -from ambee import AirQuality +from ambee import AirQuality, Pollen import pytest from homeassistant.components.ambee.const import DOMAIN @@ -34,6 +34,9 @@ def mock_ambee(aioclient_mock: AiohttpClientMocker): json.loads(load_fixture("ambee/air_quality.json")) ) ) + client.pollen = AsyncMock( + return_value=Pollen.from_dict(json.loads(load_fixture("ambee/pollen.json"))) + ) yield ambee_mock diff --git a/tests/components/ambee/test_init.py b/tests/components/ambee/test_init.py index c58e7cfef0d..5db3255dc53 100644 --- a/tests/components/ambee/test_init.py +++ b/tests/components/ambee/test_init.py @@ -29,11 +29,11 @@ async def test_load_unload_config_entry( @patch( - "homeassistant.components.ambee.Ambee.air_quality", + "homeassistant.components.ambee.Ambee.request", side_effect=AmbeeConnectionError, ) async def test_config_entry_not_ready( - mock_air_quality: MagicMock, + mock_request: MagicMock, hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: @@ -42,5 +42,5 @@ async def test_config_entry_not_ready( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_air_quality.call_count == 1 + assert mock_request.call_count == 1 assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/ambee/test_sensor.py b/tests/components/ambee/test_sensor.py index a754256ff0a..34eaa273901 100644 --- a/tests/components/ambee/test_sensor.py +++ b/tests/components/ambee/test_sensor.py @@ -1,12 +1,26 @@ """Tests for the sensors provided by the Ambee integration.""" -from homeassistant.components.ambee.const import DOMAIN -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.ambee.const import ( + DEVICE_CLASS_AMBEE_RISK, + DOMAIN, + ENTRY_TYPE_SERVICE, +) +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, + ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO, ) @@ -25,11 +39,11 @@ async def test_air_quality( entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) - state = hass.states.get("sensor.particulate_matter_2_5_mm") - entry = entity_registry.async_get("sensor.particulate_matter_2_5_mm") + state = hass.states.get("sensor.air_quality_particulate_matter_2_5") + entry = entity_registry.async_get("sensor.air_quality_particulate_matter_2_5") assert entry assert state - assert entry.unique_id == f"{entry_id}_particulate_matter_2_5" + assert entry.unique_id == f"{entry_id}_air_quality_particulate_matter_2_5" assert state.state == "3.14" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Particulate Matter < 2.5 μm" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -38,12 +52,13 @@ async def test_air_quality( == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.particulate_matter_10_mm") - entry = entity_registry.async_get("sensor.particulate_matter_10_mm") + state = hass.states.get("sensor.air_quality_particulate_matter_10") + entry = entity_registry.async_get("sensor.air_quality_particulate_matter_10") assert entry assert state - assert entry.unique_id == f"{entry_id}_particulate_matter_10" + assert entry.unique_id == f"{entry_id}_air_quality_particulate_matter_10" assert state.state == "5.24" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Particulate Matter < 10 μm" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -52,12 +67,13 @@ async def test_air_quality( == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER ) assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.sulphur_dioxide_so2") - entry = entity_registry.async_get("sensor.sulphur_dioxide_so2") + state = hass.states.get("sensor.air_quality_sulphur_dioxide") + entry = entity_registry.async_get("sensor.air_quality_sulphur_dioxide") assert entry assert state - assert entry.unique_id == f"{entry_id}_sulphur_dioxide" + assert entry.unique_id == f"{entry_id}_air_quality_sulphur_dioxide" assert state.state == "0.031" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Sulphur Dioxide (SO2)" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -66,12 +82,13 @@ async def test_air_quality( == CONCENTRATION_PARTS_PER_BILLION ) assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.nitrogen_dioxide_no2") - entry = entity_registry.async_get("sensor.nitrogen_dioxide_no2") + state = hass.states.get("sensor.air_quality_nitrogen_dioxide") + entry = entity_registry.async_get("sensor.air_quality_nitrogen_dioxide") assert entry assert state - assert entry.unique_id == f"{entry_id}_nitrogen_dioxide" + assert entry.unique_id == f"{entry_id}_air_quality_nitrogen_dioxide" assert state.state == "0.66" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Nitrogen Dioxide (NO2)" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -80,12 +97,13 @@ async def test_air_quality( == CONCENTRATION_PARTS_PER_BILLION ) assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.ozone") - entry = entity_registry.async_get("sensor.ozone") + state = hass.states.get("sensor.air_quality_ozone") + entry = entity_registry.async_get("sensor.air_quality_ozone") assert entry assert state - assert entry.unique_id == f"{entry_id}_ozone" + assert entry.unique_id == f"{entry_id}_air_quality_ozone" assert state.state == "17.067" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Ozone" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT @@ -94,12 +112,13 @@ async def test_air_quality( == CONCENTRATION_PARTS_PER_BILLION ) assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.carbon_monoxide_co") - entry = entity_registry.async_get("sensor.carbon_monoxide_co") + state = hass.states.get("sensor.air_quality_carbon_monoxide") + entry = entity_registry.async_get("sensor.air_quality_carbon_monoxide") assert entry assert state - assert entry.unique_id == f"{entry_id}_carbon_monoxide" + assert entry.unique_id == f"{entry_id}_air_quality_carbon_monoxide" assert state.state == "0.105" assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CO assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Carbon Monoxide (CO)" @@ -108,17 +127,19 @@ async def test_air_quality( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CONCENTRATION_PARTS_PER_MILLION ) + assert ATTR_ICON not in state.attributes - state = hass.states.get("sensor.air_quality_index_aqi") - entry = entity_registry.async_get("sensor.air_quality_index_aqi") + state = hass.states.get("sensor.air_quality_air_quality_index") + entry = entity_registry.async_get("sensor.air_quality_air_quality_index") assert entry assert state - assert entry.unique_id == f"{entry_id}_air_quality_index" + assert entry.unique_id == f"{entry_id}_air_quality_air_quality_index" assert state.state == "13" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Air Quality Index (AQI)" assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT assert ATTR_DEVICE_CLASS not in state.attributes assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_ICON not in state.attributes assert entry.device_id device_entry = device_registry.async_get(entry.device_id) @@ -126,5 +147,203 @@ async def test_air_quality( assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_air_quality")} assert device_entry.manufacturer == "Ambee" assert device_entry.name == "Air Quality" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE assert not device_entry.model assert not device_entry.sw_version + + +async def test_pollen( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test the Ambee Pollen sensors.""" + entry_id = init_integration.entry_id + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + state = hass.states.get("sensor.pollen_grass") + entry = entity_registry.async_get("sensor.pollen_grass") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_grass" + assert state.state == "190" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Grass Pollen" + assert state.attributes.get(ATTR_ICON) == "mdi:grass" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_PARTS_PER_CUBIC_METER + ) + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.pollen_tree") + entry = entity_registry.async_get("sensor.pollen_tree") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_tree" + assert state.state == "127" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Tree Pollen" + assert state.attributes.get(ATTR_ICON) == "mdi:tree" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_PARTS_PER_CUBIC_METER + ) + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.pollen_weed") + entry = entity_registry.async_get("sensor.pollen_weed") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_weed" + assert state.state == "95" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Weed Pollen" + assert state.attributes.get(ATTR_ICON) == "mdi:sprout" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_PARTS_PER_CUBIC_METER + ) + assert ATTR_DEVICE_CLASS not in state.attributes + + state = hass.states.get("sensor.pollen_grass_risk") + entry = entity_registry.async_get("sensor.pollen_grass_risk") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_grass_risk" + assert state.state == "high" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AMBEE_RISK + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Grass Pollen Risk" + assert state.attributes.get(ATTR_ICON) == "mdi:grass" + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.pollen_tree_risk") + entry = entity_registry.async_get("sensor.pollen_tree_risk") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_tree_risk" + assert state.state == "moderate" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AMBEE_RISK + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Tree Pollen Risk" + assert state.attributes.get(ATTR_ICON) == "mdi:tree" + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + state = hass.states.get("sensor.pollen_weed_risk") + entry = entity_registry.async_get("sensor.pollen_weed_risk") + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_weed_risk" + assert state.state == "high" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_AMBEE_RISK + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Weed Pollen Risk" + assert state.attributes.get(ATTR_ICON) == "mdi:sprout" + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + assert entry.device_id + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.identifiers == {(DOMAIN, f"{entry_id}_pollen")} + assert device_entry.manufacturer == "Ambee" + assert device_entry.name == "Pollen" + assert device_entry.entry_type == ENTRY_TYPE_SERVICE + assert not device_entry.model + assert not device_entry.sw_version + + +@pytest.mark.parametrize( + "entity_id", + ( + "sensor.pollen_grass_poaceae", + "sensor.pollen_tree_alder", + "sensor.pollen_tree_birch", + "sensor.pollen_tree_cypress", + "sensor.pollen_tree_elm", + "sensor.pollen_tree_hazel", + "sensor.pollen_tree_oak", + "sensor.pollen_tree_pine", + "sensor.pollen_tree_plane", + "sensor.pollen_tree_poplar", + "sensor.pollen_weed_chenopod", + "sensor.pollen_weed_mugwort", + "sensor.pollen_weed_nettle", + "sensor.pollen_weed_ragweed", + ), +) +async def test_pollen_disabled_by_default( + hass: HomeAssistant, init_integration: MockConfigEntry, entity_id: str +) -> None: + """Test the Ambee Pollen sensors that are disabled by default.""" + entity_registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state is None + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION + + +@pytest.mark.parametrize( + "key,icon,name,value", + [ + ("grass_poaceae", "mdi:grass", "Poaceae Grass Pollen", "190"), + ("tree_alder", "mdi:tree", "Alder Tree Pollen", "0"), + ("tree_birch", "mdi:tree", "Birch Tree Pollen", "35"), + ("tree_cypress", "mdi:tree", "Cypress Tree Pollen", "0"), + ("tree_elm", "mdi:tree", "Elm Tree Pollen", "0"), + ("tree_hazel", "mdi:tree", "Hazel Tree Pollen", "0"), + ("tree_oak", "mdi:tree", "Oak Tree Pollen", "55"), + ("tree_pine", "mdi:tree", "Pine Tree Pollen", "30"), + ("tree_plane", "mdi:tree", "Plane Tree Pollen", "5"), + ("tree_poplar", "mdi:tree", "Poplar Tree Pollen", "0"), + ("weed_chenopod", "mdi:sprout", "Chenopod Weed Pollen", "0"), + ("weed_mugwort", "mdi:sprout", "Mugwort Weed Pollen", "1"), + ("weed_nettle", "mdi:sprout", "Nettle Weed Pollen", "88"), + ("weed_ragweed", "mdi:sprout", "Ragweed Weed Pollen", "3"), + ], +) +async def test_pollen_enable_disable_by_defaults( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ambee: AsyncMock, + key: str, + icon: str, + name: str, + value: str, +) -> None: + """Test the Ambee Pollen sensors that are disabled by default.""" + entry_id = mock_config_entry.entry_id + entity_id = f"{SENSOR_DOMAIN}.pollen_{key}" + entity_registry = er.async_get(hass) + + # Pre-create registry entry for disabled by default sensor + entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"{entry_id}_pollen_{key}", + suggested_object_id=f"pollen_{key}", + disabled_by=None, + ) + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + entry = entity_registry.async_get(entity_id) + assert entry + assert state + assert entry.unique_id == f"{entry_id}_pollen_{key}" + assert state.state == value + assert state.attributes.get(ATTR_FRIENDLY_NAME) == name + assert state.attributes.get(ATTR_ICON) == icon + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_PARTS_PER_CUBIC_METER + ) + assert ATTR_DEVICE_CLASS not in state.attributes diff --git a/tests/fixtures/ambee/pollen.json b/tests/fixtures/ambee/pollen.json new file mode 100644 index 00000000000..95f8a96c3c8 --- /dev/null +++ b/tests/fixtures/ambee/pollen.json @@ -0,0 +1,43 @@ +{ + "message": "Success", + "lat": 52.42, + "lng": 6.42, + "data": [ + { + "Count": { + "grass_pollen": 190, + "tree_pollen": 127, + "weed_pollen": 95 + }, + "Risk": { + "grass_pollen": "High", + "tree_pollen": "Moderate", + "weed_pollen": "High" + }, + "Species": { + "Grass": { + "Grass / Poaceae": 190 + }, + "Others": 5, + "Tree": { + "Alder": 0, + "Birch": 35, + "Cypress": 0, + "Elm": 0, + "Hazel": 0, + "Oak": 55, + "Pine": 30, + "Plane": 5, + "Poplar / Cottonwood": 0 + }, + "Weed": { + "Chenopod": 0, + "Mugwort": 1, + "Nettle": 88, + "Ragweed": 3 + } + }, + "updatedAt": "2021-06-09T16:24:27.000Z" + } + ] +} \ No newline at end of file