From 898a1a17be0e8519c5d3a4c34635c63daa5b96a4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 15 Apr 2021 16:31:59 -0400 Subject: [PATCH] Add sensors for other ClimaCell data (#49259) * Add sensors for other ClimaCell data * add tests and add rounding * docstrings * fix pressure * Update homeassistant/components/climacell/sensor.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/climacell/sensor.py Co-authored-by: Martin Hjelmare * review comments * add another abstractmethod * use superscript * remove mypy ignore Co-authored-by: Martin Hjelmare --- .../components/climacell/__init__.py | 44 ++--- homeassistant/components/climacell/const.py | 186 +++++++++++++++++- homeassistant/components/climacell/sensor.py | 152 ++++++++++++++ homeassistant/components/climacell/weather.py | 165 ++++++++++------ tests/components/climacell/test_sensor.py | 148 ++++++++++++++ tests/components/climacell/test_weather.py | 71 +++---- tests/fixtures/climacell/v3_realtime.json | 53 +++++ tests/fixtures/climacell/v4.json | 17 +- 8 files changed, 717 insertions(+), 119 deletions(-) create mode 100644 homeassistant/components/climacell/sensor.py create mode 100644 tests/components/climacell/test_sensor.py diff --git a/homeassistant/components/climacell/__init__.py b/homeassistant/components/climacell/__init__.py index 39412520653..20a8dd4483e 100644 --- a/homeassistant/components/climacell/__init__.py +++ b/homeassistant/components/climacell/__init__.py @@ -16,6 +16,7 @@ from pyclimacell.exceptions import ( UnknownException, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -34,6 +35,7 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( + ATTR_FIELD, ATTRIBUTION, CC_ATTR_CLOUD_COVER, CC_ATTR_CONDITION, @@ -50,6 +52,7 @@ from .const import ( CC_ATTR_WIND_DIRECTION, CC_ATTR_WIND_GUST, CC_ATTR_WIND_SPEED, + CC_SENSOR_TYPES, CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_CONDITION, CC_V3_ATTR_HUMIDITY, @@ -64,8 +67,8 @@ from .const import ( CC_V3_ATTR_WIND_DIRECTION, CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_WIND_SPEED, + CC_V3_SENSOR_TYPES, CONF_TIMESTEP, - DEFAULT_FORECAST_TYPE, DEFAULT_TIMESTEP, DOMAIN, MAX_REQUESTS_PER_DAY, @@ -73,7 +76,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [WEATHER_DOMAIN] +PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] def _set_update_interval( @@ -232,6 +235,10 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator): CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_PRECIPITATION_TYPE, + *[ + sensor_type[ATTR_FIELD] + for sensor_type in CC_V3_SENSOR_TYPES + ], ] ) data[FORECASTS][HOURLY] = await self._api.forecast_hourly( @@ -288,6 +295,7 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator): CC_ATTR_WIND_GUST, CC_ATTR_CLOUD_COVER, CC_ATTR_PRECIPITATION_TYPE, + *[sensor_type[ATTR_FIELD] for sensor_type in CC_SENSOR_TYPES], ], [ CC_ATTR_TEMPERATURE_LOW, @@ -317,20 +325,22 @@ class ClimaCellEntity(CoordinatorEntity): self, config_entry: ConfigEntry, coordinator: ClimaCellDataUpdateCoordinator, - forecast_type: str, api_version: int, ) -> None: """Initialize ClimaCell Entity.""" super().__init__(coordinator) self.api_version = api_version - self.forecast_type = forecast_type self._config_entry = config_entry @staticmethod def _get_cc_value( weather_dict: dict[str, Any], key: str ) -> int | float | str | None: - """Return property from weather_dict.""" + """ + Return property from weather_dict. + + Used for V3 API. + """ items = weather_dict.get(key, {}) # Handle cases where value returned is a list. # Optimistically find the best value to return. @@ -347,23 +357,13 @@ class ClimaCellEntity(CoordinatorEntity): return items.get("value") - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - if self.forecast_type == DEFAULT_FORECAST_TYPE: - return True + def _get_current_property(self, property_name: str) -> int | str | float | None: + """ + Get property from current conditions. - return False - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"{self._config_entry.data[CONF_NAME]} - {self.forecast_type.title()}" - - @property - def unique_id(self) -> str: - """Return the unique id of the entity.""" - return f"{self._config_entry.unique_id}_{self.forecast_type}" + Used for V4 API. + """ + return self.coordinator.data.get(CURRENT, {}).get(property_name) @property def attribution(self): @@ -377,6 +377,6 @@ class ClimaCellEntity(CoordinatorEntity): "identifiers": {(DOMAIN, self._config_entry.data[CONF_API_KEY])}, "name": "ClimaCell", "manufacturer": "ClimaCell", - "sw_version": "v3", + "sw_version": f"v{self.api_version}", "entry_type": "service", } diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 6d451fa6f06..2c1646afc70 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -1,5 +1,13 @@ """Constants for the ClimaCell integration.""" -from pyclimacell.const import DAILY, HOURLY, NOWCAST, WeatherCode +from pyclimacell.const import ( + DAILY, + HOURLY, + NOWCAST, + HealthConcernType, + PollenIndex, + PrimaryPollutantType, + WeatherCode, +) from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, @@ -15,6 +23,15 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ) +from homeassistant.const import ( + ATTR_NAME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_UNIT_OF_MEASUREMENT, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, +) CONF_TIMESTEP = "timestep" FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] @@ -35,6 +52,12 @@ MAX_FORECASTS = { NOWCAST: 30, } +# Sensor type keys +ATTR_FIELD = "field" +ATTR_METRIC_CONVERSION = "metric_conversion" +ATTR_VALUE_MAP = "value_map" +ATTR_IS_METRIC_CHECK = "is_metric_check" + # Additional attributes ATTR_WIND_GUST = "wind_gust" ATTR_CLOUD_COVER = "cloud_cover" @@ -68,6 +91,7 @@ CONDITIONS = { WeatherCode.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY, } +# Weather constants CC_ATTR_TIMESTAMP = "startTime" CC_ATTR_TEMPERATURE = "temperature" CC_ATTR_TEMPERATURE_HIGH = "temperatureMax" @@ -85,6 +109,95 @@ CC_ATTR_WIND_GUST = "windGust" CC_ATTR_CLOUD_COVER = "cloudCover" CC_ATTR_PRECIPITATION_TYPE = "precipitationType" +# Sensor attributes +CC_ATTR_PARTICULATE_MATTER_25 = "particulateMatter25" +CC_ATTR_PARTICULATE_MATTER_10 = "particulateMatter10" +CC_ATTR_NITROGEN_DIOXIDE = "pollutantNO2" +CC_ATTR_CARBON_MONOXIDE = "pollutantCO" +CC_ATTR_SULFUR_DIOXIDE = "pollutantSO2" +CC_ATTR_EPA_AQI = "epaIndex" +CC_ATTR_EPA_PRIMARY_POLLUTANT = "epaPrimaryPollutant" +CC_ATTR_EPA_HEALTH_CONCERN = "epaHealthConcern" +CC_ATTR_CHINA_AQI = "mepIndex" +CC_ATTR_CHINA_PRIMARY_POLLUTANT = "mepPrimaryPollutant" +CC_ATTR_CHINA_HEALTH_CONCERN = "mepHealthConcern" +CC_ATTR_POLLEN_TREE = "treeIndex" +CC_ATTR_POLLEN_WEED = "weedIndex" +CC_ATTR_POLLEN_GRASS = "grassIndex" +CC_ATTR_FIRE_INDEX = "fireIndex" + +CC_SENSOR_TYPES = [ + { + ATTR_FIELD: CC_ATTR_PARTICULATE_MATTER_25, + ATTR_NAME: "Particulate Matter < 2.5 μm", + CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", + CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_METRIC_CONVERSION: 3.2808399 ** 3, + ATTR_IS_METRIC_CHECK: True, + }, + { + ATTR_FIELD: CC_ATTR_PARTICULATE_MATTER_10, + ATTR_NAME: "Particulate Matter < 10 μm", + CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", + CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_METRIC_CONVERSION: 3.2808399 ** 3, + ATTR_IS_METRIC_CHECK: True, + }, + { + ATTR_FIELD: CC_ATTR_NITROGEN_DIOXIDE, + ATTR_NAME: "Nitrogen Dioxide", + CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + }, + { + ATTR_FIELD: CC_ATTR_CARBON_MONOXIDE, + ATTR_NAME: "Carbon Monoxide", + CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + }, + { + ATTR_FIELD: CC_ATTR_SULFUR_DIOXIDE, + ATTR_NAME: "Sulfur Dioxide", + CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + }, + {ATTR_FIELD: CC_ATTR_EPA_AQI, ATTR_NAME: "US EPA Air Quality Index"}, + { + ATTR_FIELD: CC_ATTR_EPA_PRIMARY_POLLUTANT, + ATTR_NAME: "US EPA Primary Pollutant", + ATTR_VALUE_MAP: PrimaryPollutantType, + }, + { + ATTR_FIELD: CC_ATTR_EPA_HEALTH_CONCERN, + ATTR_NAME: "US EPA Health Concern", + ATTR_VALUE_MAP: HealthConcernType, + }, + {ATTR_FIELD: CC_ATTR_CHINA_AQI, ATTR_NAME: "China MEP Air Quality Index"}, + { + ATTR_FIELD: CC_ATTR_CHINA_PRIMARY_POLLUTANT, + ATTR_NAME: "China MEP Primary Pollutant", + ATTR_VALUE_MAP: PrimaryPollutantType, + }, + { + ATTR_FIELD: CC_ATTR_CHINA_HEALTH_CONCERN, + ATTR_NAME: "China MEP Health Concern", + ATTR_VALUE_MAP: HealthConcernType, + }, + { + ATTR_FIELD: CC_ATTR_POLLEN_TREE, + ATTR_NAME: "Tree Pollen Index", + ATTR_VALUE_MAP: PollenIndex, + }, + { + ATTR_FIELD: CC_ATTR_POLLEN_WEED, + ATTR_NAME: "Weed Pollen Index", + ATTR_VALUE_MAP: PollenIndex, + }, + { + ATTR_FIELD: CC_ATTR_POLLEN_GRASS, + ATTR_NAME: "Grass Pollen Index", + ATTR_VALUE_MAP: PollenIndex, + }, + {ATTR_FIELD: CC_ATTR_FIRE_INDEX, ATTR_NAME: "Fire Index"}, +] + # V3 constants CONDITIONS_V3 = { "breezy": ATTR_CONDITION_WINDY, @@ -111,6 +224,7 @@ CONDITIONS_V3 = { "partly_cloudy": ATTR_CONDITION_PARTLYCLOUDY, } +# Weather attributes CC_V3_ATTR_TIMESTAMP = "observation_time" CC_V3_ATTR_TEMPERATURE = "temp" CC_V3_ATTR_TEMPERATURE_HIGH = "max" @@ -128,3 +242,73 @@ CC_V3_ATTR_PRECIPITATION_PROBABILITY = "precipitation_probability" CC_V3_ATTR_WIND_GUST = "wind_gust" CC_V3_ATTR_CLOUD_COVER = "cloud_cover" CC_V3_ATTR_PRECIPITATION_TYPE = "precipitation_type" + +# Sensor attributes +CC_V3_ATTR_PARTICULATE_MATTER_25 = "pm25" +CC_V3_ATTR_PARTICULATE_MATTER_10 = "pm10" +CC_V3_ATTR_NITROGEN_DIOXIDE = "no2" +CC_V3_ATTR_CARBON_MONOXIDE = "co" +CC_V3_ATTR_SULFUR_DIOXIDE = "so2" +CC_V3_ATTR_EPA_AQI = "epa_aqi" +CC_V3_ATTR_EPA_PRIMARY_POLLUTANT = "epa_primary_pollutant" +CC_V3_ATTR_EPA_HEALTH_CONCERN = "epa_health_concern" +CC_V3_ATTR_CHINA_AQI = "china_aqi" +CC_V3_ATTR_CHINA_PRIMARY_POLLUTANT = "china_primary_pollutant" +CC_V3_ATTR_CHINA_HEALTH_CONCERN = "china_health_concern" +CC_V3_ATTR_POLLEN_TREE = "pollen_tree" +CC_V3_ATTR_POLLEN_WEED = "pollen_weed" +CC_V3_ATTR_POLLEN_GRASS = "pollen_grass" +CC_V3_ATTR_FIRE_INDEX = "fire_index" + +CC_V3_SENSOR_TYPES = [ + { + ATTR_FIELD: CC_V3_ATTR_PARTICULATE_MATTER_25, + ATTR_NAME: "Particulate Matter < 2.5 μm", + CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", + CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_METRIC_CONVERSION: 1 / (3.2808399 ** 3), + ATTR_IS_METRIC_CHECK: False, + }, + { + ATTR_FIELD: CC_V3_ATTR_PARTICULATE_MATTER_10, + ATTR_NAME: "Particulate Matter < 10 μm", + CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", + CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_METRIC_CONVERSION: 1 / (3.2808399 ** 3), + ATTR_IS_METRIC_CHECK: False, + }, + { + ATTR_FIELD: CC_V3_ATTR_NITROGEN_DIOXIDE, + ATTR_NAME: "Nitrogen Dioxide", + CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + }, + { + ATTR_FIELD: CC_V3_ATTR_CARBON_MONOXIDE, + ATTR_NAME: "Carbon Monoxide", + CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, + }, + { + ATTR_FIELD: CC_V3_ATTR_SULFUR_DIOXIDE, + ATTR_NAME: "Sulfur Dioxide", + CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + }, + {ATTR_FIELD: CC_V3_ATTR_EPA_AQI, ATTR_NAME: "US EPA Air Quality Index"}, + { + ATTR_FIELD: CC_V3_ATTR_EPA_PRIMARY_POLLUTANT, + ATTR_NAME: "US EPA Primary Pollutant", + }, + {ATTR_FIELD: CC_V3_ATTR_EPA_HEALTH_CONCERN, ATTR_NAME: "US EPA Health Concern"}, + {ATTR_FIELD: CC_V3_ATTR_CHINA_AQI, ATTR_NAME: "China MEP Air Quality Index"}, + { + ATTR_FIELD: CC_V3_ATTR_CHINA_PRIMARY_POLLUTANT, + ATTR_NAME: "China MEP Primary Pollutant", + }, + { + ATTR_FIELD: CC_V3_ATTR_CHINA_HEALTH_CONCERN, + ATTR_NAME: "China MEP Health Concern", + }, + {ATTR_FIELD: CC_V3_ATTR_POLLEN_TREE, ATTR_NAME: "Tree Pollen Index"}, + {ATTR_FIELD: CC_V3_ATTR_POLLEN_WEED, ATTR_NAME: "Weed Pollen Index"}, + {ATTR_FIELD: CC_V3_ATTR_POLLEN_GRASS, ATTR_NAME: "Grass Pollen Index"}, + {ATTR_FIELD: CC_V3_ATTR_FIRE_INDEX, ATTR_NAME: "Fire Index"}, +] diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py new file mode 100644 index 00000000000..8a6fb39a381 --- /dev/null +++ b/homeassistant/components/climacell/sensor.py @@ -0,0 +1,152 @@ +"""Sensor component that handles additional ClimaCell data for your location.""" +from __future__ import annotations + +from abc import abstractmethod +import logging +from typing import Any, Callable, Mapping + +from pyclimacell.const import CURRENT + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_NAME, + CONF_API_VERSION, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify + +from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity +from .const import ( + ATTR_FIELD, + ATTR_IS_METRIC_CHECK, + ATTR_METRIC_CONVERSION, + ATTR_VALUE_MAP, + CC_SENSOR_TYPES, + CC_V3_SENSOR_TYPES, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: ConfigEntry, + async_add_entities: Callable[[list[Entity], bool], None], +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + api_version = config_entry.data[CONF_API_VERSION] + + if api_version == 3: + api_class = ClimaCellV3SensorEntity + sensor_types = CC_V3_SENSOR_TYPES + else: + api_class = ClimaCellSensorEntity + sensor_types = CC_SENSOR_TYPES + entities = [ + api_class(config_entry, coordinator, api_version, sensor_type) + for sensor_type in sensor_types + ] + async_add_entities(entities) + + +class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): + """Base ClimaCell sensor entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: ClimaCellDataUpdateCoordinator, + api_version: int, + sensor_type: dict[str, str | float], + ) -> None: + """Initialize ClimaCell Sensor Entity.""" + super().__init__(config_entry, coordinator, api_version) + self.sensor_type = sensor_type + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def name(self) -> str: + """Return the name of the entity.""" + return f"{self._config_entry.data[CONF_NAME]} - {self.sensor_type[ATTR_NAME]}" + + @property + def unique_id(self) -> str: + """Return the unique id of the entity.""" + return f"{self._config_entry.unique_id}_{slugify(self.sensor_type[ATTR_NAME])}" + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + return {ATTR_ATTRIBUTION: self.attribution} + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if CONF_UNIT_OF_MEASUREMENT in self.sensor_type: + return self.sensor_type[CONF_UNIT_OF_MEASUREMENT] + + if ( + CONF_UNIT_SYSTEM_IMPERIAL in self.sensor_type + and CONF_UNIT_SYSTEM_METRIC in self.sensor_type + ): + if self.hass.config.units.is_metric: + return self.sensor_type[CONF_UNIT_SYSTEM_METRIC] + return self.sensor_type[CONF_UNIT_SYSTEM_IMPERIAL] + + return None + + @property + @abstractmethod + def _state(self) -> str | int | float | None: + """Return the raw state.""" + + @property + def state(self) -> str | int | float | None: + """Return the state.""" + if ( + self._state is not None + and CONF_UNIT_SYSTEM_IMPERIAL in self.sensor_type + and CONF_UNIT_SYSTEM_METRIC in self.sensor_type + and ATTR_METRIC_CONVERSION in self.sensor_type + and ATTR_IS_METRIC_CHECK in self.sensor_type + and self.hass.config.units.is_metric + == self.sensor_type[ATTR_IS_METRIC_CHECK] + ): + return round(self._state * self.sensor_type[ATTR_METRIC_CONVERSION], 4) + + if ATTR_VALUE_MAP in self.sensor_type: + return self.sensor_type[ATTR_VALUE_MAP](self._state).name.lower() + return self._state + + +class ClimaCellSensorEntity(BaseClimaCellSensorEntity): + """Sensor entity that talks to ClimaCell v4 API to retrieve non-weather data.""" + + @property + def _state(self) -> str | int | float | None: + """Return the raw state.""" + return self._get_current_property(self.sensor_type[ATTR_FIELD]) + + +class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity): + """Sensor entity that talks to ClimaCell v3 API to retrieve non-weather data.""" + + @property + def _state(self) -> str | int | float | None: + """Return the raw state.""" + return self._get_cc_value( + self.coordinator.data[CURRENT], self.sensor_type[ATTR_FIELD] + ) diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index 0808a4bd734..2c31d4df4fa 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -1,6 +1,7 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +from abc import abstractmethod from datetime import datetime import logging from typing import Any, Callable, Mapping @@ -29,6 +30,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, + CONF_NAME, LENGTH_FEET, LENGTH_KILOMETERS, LENGTH_METERS, @@ -44,7 +46,7 @@ from homeassistant.util import dt as dt_util from homeassistant.util.distance import convert as distance_convert from homeassistant.util.pressure import convert as pressure_convert -from . import ClimaCellEntity +from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( ATTR_CLOUD_COVER, ATTR_PRECIPITATION_TYPE, @@ -86,12 +88,11 @@ from .const import ( CONDITIONS, CONDITIONS_V3, CONF_TIMESTEP, + DEFAULT_FORECAST_TYPE, DOMAIN, MAX_FORECASTS, ) -# mypy: allow-untyped-defs, no-check-untyped-defs - _LOGGER = logging.getLogger(__name__) @@ -106,7 +107,7 @@ async def async_setup_entry( api_class = ClimaCellV3WeatherEntity if api_version == 3 else ClimaCellWeatherEntity entities = [ - api_class(config_entry, coordinator, forecast_type, api_version) + api_class(config_entry, coordinator, api_version, forecast_type) for forecast_type in [DAILY, HOURLY, NOWCAST] ] async_add_entities(entities) @@ -115,12 +116,41 @@ async def async_setup_entry( class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Base ClimaCell weather entity.""" + def __init__( + self, + config_entry: ConfigEntry, + coordinator: ClimaCellDataUpdateCoordinator, + api_version: int, + forecast_type: str, + ) -> None: + """Initialize ClimaCell Weather Entity.""" + super().__init__(config_entry, coordinator, api_version) + self.forecast_type = forecast_type + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + if self.forecast_type == DEFAULT_FORECAST_TYPE: + return True + + return False + + @property + def name(self) -> str: + """Return the name of the entity.""" + return f"{self._config_entry.data[CONF_NAME]} - {self.forecast_type.title()}" + + @property + def unique_id(self) -> str: + """Return the unique id of the entity.""" + return f"{self._config_entry.unique_id}_{self.forecast_type}" + @staticmethod + @abstractmethod def _translate_condition( condition: int | None, sun_is_up: bool = True ) -> str | None: """Translate ClimaCell condition into an HA condition.""" - raise NotImplementedError() def _forecast_dict( self, @@ -144,13 +174,14 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): if self.hass.config.units.is_metric: if precipitation: - precipitation = ( + precipitation = round( distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) - * 1000 + * 1000, + 4, ) if wind_speed: - wind_speed = distance_convert( - wind_speed, LENGTH_MILES, LENGTH_KILOMETERS + wind_speed = round( + distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 ) data = { @@ -171,8 +202,8 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return additional state attributes.""" wind_gust = self.wind_gust if wind_gust and self.hass.config.units.is_metric: - wind_gust = distance_convert( - self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS + wind_gust = round( + distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 ) cloud_cover = self.cloud_cover if cloud_cover is not None: @@ -184,19 +215,61 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): } @property + @abstractmethod def cloud_cover(self): """Return cloud cover.""" - raise NotImplementedError @property + @abstractmethod def wind_gust(self): """Return wind gust speed.""" - raise NotImplementedError @property + @abstractmethod def precipitation_type(self): """Return precipitation type.""" - raise NotImplementedError + + @property + @abstractmethod + def _pressure(self): + """Return the raw pressure.""" + + @property + def pressure(self): + """Return the pressure.""" + if self.hass.config.units.is_metric and self._pressure: + return round( + pressure_convert(self._pressure, PRESSURE_INHG, PRESSURE_HPA), 4 + ) + return self._pressure + + @property + @abstractmethod + def _wind_speed(self): + """Return the raw wind speed.""" + + @property + def wind_speed(self): + """Return the wind speed.""" + if self.hass.config.units.is_metric and self._wind_speed: + return round( + distance_convert(self._wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + ) + return self._wind_speed + + @property + @abstractmethod + def _visibility(self): + """Return the raw visibility.""" + + @property + def visibility(self): + """Return the visibility.""" + if self.hass.config.units.is_metric and self._visibility: + return round( + distance_convert(self._visibility, LENGTH_MILES, LENGTH_KILOMETERS), 4 + ) + return self._visibility class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): @@ -217,10 +290,6 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): return CLEAR_CONDITIONS["night"] return CONDITIONS[condition] - def _get_current_property(self, property_name: str) -> int | str | float | None: - """Get property from current conditions.""" - return self.coordinator.data.get(CURRENT, {}).get(property_name) - @property def temperature(self): """Return the platform temperature.""" @@ -232,12 +301,9 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): return TEMP_FAHRENHEIT @property - def pressure(self): - """Return the pressure.""" - pressure = self._get_current_property(CC_ATTR_PRESSURE) - if self.hass.config.units.is_metric and pressure: - return pressure_convert(pressure, PRESSURE_INHG, PRESSURE_HPA) - return pressure + def _pressure(self): + """Return the raw pressure.""" + return self._get_current_property(CC_ATTR_PRESSURE) @property def humidity(self): @@ -263,12 +329,9 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): return PrecipitationType(precipitation_type).name.lower() @property - def wind_speed(self): - """Return the wind speed.""" - wind_speed = self._get_current_property(CC_ATTR_WIND_SPEED) - if self.hass.config.units.is_metric and wind_speed: - return distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) - return wind_speed + def _wind_speed(self): + """Return the raw wind speed.""" + return self._get_current_property(CC_ATTR_WIND_SPEED) @property def wind_bearing(self): @@ -289,12 +352,9 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): ) @property - def visibility(self): - """Return the visibility.""" - visibility = self._get_current_property(CC_ATTR_VISIBILITY) - if self.hass.config.units.is_metric and visibility: - return distance_convert(visibility, LENGTH_MILES, LENGTH_KILOMETERS) - return visibility + def _visibility(self): + """Return the raw visibility.""" + return self._get_current_property(CC_ATTR_VISIBILITY) @property def forecast(self): @@ -391,14 +451,9 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): return TEMP_FAHRENHEIT @property - def pressure(self): - """Return the pressure.""" - pressure = self._get_cc_value( - self.coordinator.data[CURRENT], CC_V3_ATTR_PRESSURE - ) - if self.hass.config.units.is_metric and pressure: - return pressure_convert(pressure, PRESSURE_INHG, PRESSURE_HPA) - return pressure + def _pressure(self): + """Return the raw pressure.""" + return self._get_cc_value(self.coordinator.data[CURRENT], CC_V3_ATTR_PRESSURE) @property def humidity(self): @@ -425,14 +480,9 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): ) @property - def wind_speed(self): - """Return the wind speed.""" - wind_speed = self._get_cc_value( - self.coordinator.data[CURRENT], CC_V3_ATTR_WIND_SPEED - ) - if self.hass.config.units.is_metric and wind_speed: - return distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) - return wind_speed + def _wind_speed(self): + """Return the raw wind speed.""" + return self._get_cc_value(self.coordinator.data[CURRENT], CC_V3_ATTR_WIND_SPEED) @property def wind_bearing(self): @@ -455,14 +505,9 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): ) @property - def visibility(self): - """Return the visibility.""" - visibility = self._get_cc_value( - self.coordinator.data[CURRENT], CC_V3_ATTR_VISIBILITY - ) - if self.hass.config.units.is_metric and visibility: - return distance_convert(visibility, LENGTH_MILES, LENGTH_KILOMETERS) - return visibility + def _visibility(self): + """Return the raw visibility.""" + return self._get_cc_value(self.coordinator.data[CURRENT], CC_V3_ATTR_VISIBILITY) @property def forecast(self): diff --git a/tests/components/climacell/test_sensor.py b/tests/components/climacell/test_sensor.py new file mode 100644 index 00000000000..d82a70964cf --- /dev/null +++ b/tests/components/climacell/test_sensor.py @@ -0,0 +1,148 @@ +"""Tests for Climacell sensor entities.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any +from unittest.mock import patch + +import pytest +import pytz + +from homeassistant.components.climacell.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.climacell.const import ATTRIBUTION, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import State, callback +from homeassistant.helpers.entity_registry import async_get +from homeassistant.helpers.typing import HomeAssistantType + +from .const import API_V3_ENTRY_DATA, API_V4_ENTRY_DATA + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) +CC_SENSOR_ENTITY_ID = "sensor.climacell_{}" + +CO = "carbon_monoxide" +NO2 = "nitrogen_dioxide" +SO2 = "sulfur_dioxide" +PM25 = "particulate_matter_2_5_mm" +PM10 = "particulate_matter_10_mm" +MEP_AQI = "china_mep_air_quality_index" +MEP_HEALTH_CONCERN = "china_mep_health_concern" +MEP_PRIMARY_POLLUTANT = "china_mep_primary_pollutant" +EPA_AQI = "us_epa_air_quality_index" +EPA_HEALTH_CONCERN = "us_epa_health_concern" +EPA_PRIMARY_POLLUTANT = "us_epa_primary_pollutant" +FIRE_INDEX = "fire_index" +GRASS_POLLEN = "grass_pollen_index" +WEED_POLLEN = "weed_pollen_index" +TREE_POLLEN = "tree_pollen_index" + + +@callback +def _enable_entity(hass: HomeAssistantType, entity_name: str) -> None: + """Enable disabled entity.""" + ent_reg = async_get(hass) + entry = ent_reg.async_get(entity_name) + updated_entry = ent_reg.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entry + assert updated_entry.disabled is False + + +async def _setup(hass: HomeAssistantType, config: dict[str, Any]) -> State: + """Set up entry and return entity state.""" + with patch( + "homeassistant.util.dt.utcnow", + return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=pytz.UTC), + ): + data = _get_config_schema(hass)(config) + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + for entity_name in ( + CO, + NO2, + SO2, + PM25, + PM10, + MEP_AQI, + MEP_HEALTH_CONCERN, + MEP_PRIMARY_POLLUTANT, + EPA_AQI, + EPA_HEALTH_CONCERN, + EPA_PRIMARY_POLLUTANT, + FIRE_INDEX, + GRASS_POLLEN, + WEED_POLLEN, + TREE_POLLEN, + ): + _enable_entity(hass, CC_SENSOR_ENTITY_ID.format(entity_name)) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 15 + + +def check_sensor_state(hass: HomeAssistantType, entity_name: str, value: str): + """Check the state of a ClimaCell sensor.""" + state = hass.states.get(CC_SENSOR_ENTITY_ID.format(entity_name)) + assert state + assert state.state == value + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + + +async def test_v3_sensor( + hass: HomeAssistantType, + climacell_config_entry_update: pytest.fixture, +) -> None: + """Test v3 sensor data.""" + await _setup(hass, API_V3_ENTRY_DATA) + check_sensor_state(hass, CO, "0.875") + check_sensor_state(hass, NO2, "14.1875") + check_sensor_state(hass, SO2, "2") + check_sensor_state(hass, PM25, "5.3125") + check_sensor_state(hass, PM10, "27") + check_sensor_state(hass, MEP_AQI, "27") + check_sensor_state(hass, MEP_HEALTH_CONCERN, "Good") + check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") + check_sensor_state(hass, EPA_AQI, "22.3125") + check_sensor_state(hass, EPA_HEALTH_CONCERN, "Good") + check_sensor_state(hass, EPA_PRIMARY_POLLUTANT, "pm25") + check_sensor_state(hass, FIRE_INDEX, "9") + check_sensor_state(hass, GRASS_POLLEN, "0") + check_sensor_state(hass, WEED_POLLEN, "0") + check_sensor_state(hass, TREE_POLLEN, "0") + + +async def test_v4_sensor( + hass: HomeAssistantType, + climacell_config_entry_update: pytest.fixture, +) -> None: + """Test v4 sensor data.""" + await _setup(hass, API_V4_ENTRY_DATA) + check_sensor_state(hass, CO, "0.63") + check_sensor_state(hass, NO2, "10.67") + check_sensor_state(hass, SO2, "1.65") + check_sensor_state(hass, PM25, "5.2972") + check_sensor_state(hass, PM10, "20.1294") + check_sensor_state(hass, MEP_AQI, "23") + check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") + check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") + check_sensor_state(hass, EPA_AQI, "24") + check_sensor_state(hass, EPA_HEALTH_CONCERN, "good") + check_sensor_state(hass, EPA_PRIMARY_POLLUTANT, "pm25") + check_sensor_state(hass, FIRE_INDEX, "10") + check_sensor_state(hass, GRASS_POLLEN, "none") + check_sensor_state(hass, WEED_POLLEN, "none") + check_sensor_state(hass, TREE_POLLEN, "none") diff --git a/tests/components/climacell/test_weather.py b/tests/components/climacell/test_weather.py index 779b0afa2c0..43515d6aa66 100644 --- a/tests/components/climacell/test_weather.py +++ b/tests/components/climacell/test_weather.py @@ -44,7 +44,7 @@ from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, ) from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME -from homeassistant.core import State +from homeassistant.core import State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.helpers.typing import HomeAssistantType @@ -55,7 +55,8 @@ from tests.common import MockConfigEntry _LOGGER = logging.getLogger(__name__) -async def _enable_entity(hass: HomeAssistantType, entity_name: str) -> None: +@callback +def _enable_entity(hass: HomeAssistantType, entity_name: str) -> None: """Enable disabled entity.""" ent_reg = async_get(hass) entry = ent_reg.async_get(entity_name) @@ -82,8 +83,8 @@ async def _setup(hass: HomeAssistantType, config: dict[str, Any]) -> State: config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - await _enable_entity(hass, "weather.climacell_hourly") - await _enable_entity(hass, "weather.climacell_nowcast") + for entity_name in ("hourly", "nowcast"): + _enable_entity(hass, f"weather.climacell_{entity_name}") await hass.async_block_till_done() assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 3 @@ -142,7 +143,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-12T00:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0.04572, + ATTR_FORECAST_PRECIPITATION: 0.0457, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, ATTR_FORECAST_TEMP: 20, ATTR_FORECAST_TEMP_LOW: 12, @@ -158,7 +159,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, ATTR_FORECAST_TIME: "2021-03-14T00:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 1.07442, + ATTR_FORECAST_PRECIPITATION: 1.0744, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 75, ATTR_FORECAST_TEMP: 6, ATTR_FORECAST_TEMP_LOW: 3, @@ -166,7 +167,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, ATTR_FORECAST_TIME: "2021-03-15T00:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 7.305040000000001, + ATTR_FORECAST_PRECIPITATION: 7.3050, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, ATTR_FORECAST_TEMP: 1, ATTR_FORECAST_TEMP_LOW: 0, @@ -174,7 +175,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-16T00:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0.00508, + ATTR_FORECAST_PRECIPITATION: 0.0051, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 5, ATTR_FORECAST_TEMP: 6, ATTR_FORECAST_TEMP_LOW: -2, @@ -214,7 +215,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-21T00:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0.043179999999999996, + ATTR_FORECAST_PRECIPITATION: 0.0432, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 20, ATTR_FORECAST_TEMP: 7, ATTR_FORECAST_TEMP_LOW: 1, @@ -223,13 +224,13 @@ async def test_v3_weather( assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "ClimaCell - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 24 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 52.625 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1028.124632345 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1028.1246 assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 7 - assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.994026240000002 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.9940 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 320.31 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.62893696 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.6289 assert weather_state.attributes[ATTR_CLOUD_COVER] == 1 - assert weather_state.attributes[ATTR_WIND_GUST] == 24.075786240000003 + assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" @@ -250,7 +251,7 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 8, ATTR_FORECAST_TEMP_LOW: -3, ATTR_FORECAST_WIND_BEARING: 239.6, - ATTR_FORECAST_WIND_SPEED: 15.272674560000002, + ATTR_FORECAST_WIND_SPEED: 15.2727, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -260,7 +261,7 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 10, ATTR_FORECAST_TEMP_LOW: -3, ATTR_FORECAST_WIND_BEARING: 262.82, - ATTR_FORECAST_WIND_SPEED: 11.65165056, + ATTR_FORECAST_WIND_SPEED: 11.6517, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -270,7 +271,7 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 19, ATTR_FORECAST_TEMP_LOW: 0, ATTR_FORECAST_WIND_BEARING: 229.3, - ATTR_FORECAST_WIND_SPEED: 11.3458752, + ATTR_FORECAST_WIND_SPEED: 11.3459, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -280,7 +281,7 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 18, ATTR_FORECAST_TEMP_LOW: 3, ATTR_FORECAST_WIND_BEARING: 149.91, - ATTR_FORECAST_WIND_SPEED: 17.123420160000002, + ATTR_FORECAST_WIND_SPEED: 17.1234, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -290,17 +291,17 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 19, ATTR_FORECAST_TEMP_LOW: 9, ATTR_FORECAST_WIND_BEARING: 210.45, - ATTR_FORECAST_WIND_SPEED: 25.250607360000004, + ATTR_FORECAST_WIND_SPEED: 25.2506, }, { ATTR_FORECAST_CONDITION: "rainy", ATTR_FORECAST_TIME: "2021-03-12T11:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 0.12192000000000001, + ATTR_FORECAST_PRECIPITATION: 0.1219, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, ATTR_FORECAST_TEMP: 20, ATTR_FORECAST_TEMP_LOW: 12, ATTR_FORECAST_WIND_BEARING: 217.98, - ATTR_FORECAST_WIND_SPEED: 19.794931200000004, + ATTR_FORECAST_WIND_SPEED: 19.7949, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -310,27 +311,27 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 12, ATTR_FORECAST_TEMP_LOW: 6, ATTR_FORECAST_WIND_BEARING: 58.79, - ATTR_FORECAST_WIND_SPEED: 15.642823680000001, + ATTR_FORECAST_WIND_SPEED: 15.6428, }, { ATTR_FORECAST_CONDITION: "snowy", ATTR_FORECAST_TIME: "2021-03-14T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 23.95728, + ATTR_FORECAST_PRECIPITATION: 23.9573, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, ATTR_FORECAST_TEMP: 6, ATTR_FORECAST_TEMP_LOW: 1, ATTR_FORECAST_WIND_BEARING: 70.25, - ATTR_FORECAST_WIND_SPEED: 26.15184, + ATTR_FORECAST_WIND_SPEED: 26.1518, }, { ATTR_FORECAST_CONDITION: "snowy", ATTR_FORECAST_TIME: "2021-03-15T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 1.46304, + ATTR_FORECAST_PRECIPITATION: 1.4630, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, ATTR_FORECAST_TEMP: 6, ATTR_FORECAST_TEMP_LOW: -1, ATTR_FORECAST_WIND_BEARING: 84.47, - ATTR_FORECAST_WIND_SPEED: 25.57247616, + ATTR_FORECAST_WIND_SPEED: 25.5725, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -340,7 +341,7 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 6, ATTR_FORECAST_TEMP_LOW: -2, ATTR_FORECAST_WIND_BEARING: 103.85, - ATTR_FORECAST_WIND_SPEED: 10.79869824, + ATTR_FORECAST_WIND_SPEED: 10.7987, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -350,7 +351,7 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 11, ATTR_FORECAST_TEMP_LOW: 1, ATTR_FORECAST_WIND_BEARING: 145.41, - ATTR_FORECAST_WIND_SPEED: 11.69993088, + ATTR_FORECAST_WIND_SPEED: 11.6999, }, { ATTR_FORECAST_CONDITION: "cloudy", @@ -360,17 +361,17 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 12, ATTR_FORECAST_TEMP_LOW: 5, ATTR_FORECAST_WIND_BEARING: 62.99, - ATTR_FORECAST_WIND_SPEED: 10.58948352, + ATTR_FORECAST_WIND_SPEED: 10.5895, }, { ATTR_FORECAST_CONDITION: "rainy", ATTR_FORECAST_TIME: "2021-03-19T10:00:00+00:00", - ATTR_FORECAST_PRECIPITATION: 2.92608, + ATTR_FORECAST_PRECIPITATION: 2.9261, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, ATTR_FORECAST_TEMP: 9, ATTR_FORECAST_TEMP_LOW: 4, ATTR_FORECAST_WIND_BEARING: 68.54, - ATTR_FORECAST_WIND_SPEED: 22.38597504, + ATTR_FORECAST_WIND_SPEED: 22.3860, }, { ATTR_FORECAST_CONDITION: "snowy", @@ -380,17 +381,17 @@ async def test_v4_weather( ATTR_FORECAST_TEMP: 5, ATTR_FORECAST_TEMP_LOW: 2, ATTR_FORECAST_WIND_BEARING: 56.98, - ATTR_FORECAST_WIND_SPEED: 27.922118400000002, + ATTR_FORECAST_WIND_SPEED: 27.9221, }, ] assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "ClimaCell - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1027.7690615000001 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1027.7691 assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 7 - assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.116153600000002 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.1162 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 15.01517952 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 15.0152 assert weather_state.attributes[ATTR_CLOUD_COVER] == 1 - assert weather_state.attributes[ATTR_WIND_GUST] == 20.34210816 + assert weather_state.attributes[ATTR_WIND_GUST] == 20.3421 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" diff --git a/tests/fixtures/climacell/v3_realtime.json b/tests/fixtures/climacell/v3_realtime.json index c4226ab5ad9..b7801d78160 100644 --- a/tests/fixtures/climacell/v3_realtime.json +++ b/tests/fixtures/climacell/v3_realtime.json @@ -43,6 +43,59 @@ "value": 100, "units": "%" }, + "fire_index": { + "value": 9 + }, + "epa_aqi": { + "value": 22.3125 + }, + "epa_primary_pollutant": { + "value": "pm25" + }, + "china_aqi": { + "value": 27 + }, + "china_primary_pollutant": { + "value": "pm10" + }, + "pm25": { + "value": 5.3125, + "units": "\u00b5g/m3" + }, + "pm10": { + "value": 27, + "units": "\u00b5g/m3" + }, + "no2": { + "value": 14.1875, + "units": "ppb" + }, + "co": { + "value": 0.875, + "units": "ppm" + }, + "so2": { + "value": 2, + "units": "ppb" + }, + "epa_health_concern": { + "value": "Good" + }, + "china_health_concern": { + "value": "Good" + }, + "pollen_tree": { + "value": 0, + "units": "Climacell Pollen Index" + }, + "pollen_weed": { + "value": 0, + "units": "Climacell Pollen Index" + }, + "pollen_grass": { + "value": 0, + "units": "Climacell Pollen Index" + }, "observation_time": { "value": "2021-03-07T18:54:06.055Z" } diff --git a/tests/fixtures/climacell/v4.json b/tests/fixtures/climacell/v4.json index 7d778ba9f51..f2f10b0360e 100644 --- a/tests/fixtures/climacell/v4.json +++ b/tests/fixtures/climacell/v4.json @@ -10,7 +10,22 @@ "pollutantO3": 46.53, "windGust": 12.64, "cloudCover": 100, - "precipitationType": 1 + "precipitationType": 1, + "particulateMatter25": 0.15, + "particulateMatter10": 0.57, + "pollutantNO2": 10.67, + "pollutantCO": 0.63, + "pollutantSO2": 1.65, + "epaIndex": 24, + "epaPrimaryPollutant": 0, + "epaHealthConcern": 0, + "mepIndex": 23, + "mepPrimaryPollutant": 1, + "mepHealthConcern": 0, + "treeIndex": 0, + "weedIndex": 0, + "grassIndex": 0, + "fireIndex": 10 }, "forecasts": { "nowcast": [