From 84794e649f4967f372a511750d132df748b46bb4 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Oct 2021 11:56:34 -0400 Subject: [PATCH 01/18] Add new tomorrow.io integration to replace Climacell - Part 1/3 --- CODEOWNERS | 1 + .../components/tomorrowio/__init__.py | 304 +++ .../components/tomorrowio/config_flow.py | 175 ++ homeassistant/components/tomorrowio/const.py | 366 +++ .../components/tomorrowio/manifest.json | 9 + homeassistant/components/tomorrowio/sensor.py | 98 + .../components/tomorrowio/strings.json | 32 + .../tomorrowio/translations/en.json | 32 + .../components/tomorrowio/weather.py | 383 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tomorrowio/__init__.py | 1 + tests/components/tomorrowio/conftest.py | 36 + tests/components/tomorrowio/const.py | 23 + .../components/tomorrowio/test_config_flow.py | 169 ++ tests/components/tomorrowio/test_const.py | 14 + tests/components/tomorrowio/test_init.py | 157 ++ tests/components/tomorrowio/test_sensor.py | 165 ++ tests/components/tomorrowio/test_weather.py | 251 ++ tests/fixtures/tomorrowio/v4.json | 2384 +++++++++++++++++ 21 files changed, 4607 insertions(+) create mode 100644 homeassistant/components/tomorrowio/__init__.py create mode 100644 homeassistant/components/tomorrowio/config_flow.py create mode 100644 homeassistant/components/tomorrowio/const.py create mode 100644 homeassistant/components/tomorrowio/manifest.json create mode 100644 homeassistant/components/tomorrowio/sensor.py create mode 100644 homeassistant/components/tomorrowio/strings.json create mode 100644 homeassistant/components/tomorrowio/translations/en.json create mode 100644 homeassistant/components/tomorrowio/weather.py create mode 100644 tests/components/tomorrowio/__init__.py create mode 100644 tests/components/tomorrowio/conftest.py create mode 100644 tests/components/tomorrowio/const.py create mode 100644 tests/components/tomorrowio/test_config_flow.py create mode 100644 tests/components/tomorrowio/test_const.py create mode 100644 tests/components/tomorrowio/test_init.py create mode 100644 tests/components/tomorrowio/test_sensor.py create mode 100644 tests/components/tomorrowio/test_weather.py create mode 100644 tests/fixtures/tomorrowio/v4.json diff --git a/CODEOWNERS b/CODEOWNERS index 606c71f0a1c7c6..ac160736202c55 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -533,6 +533,7 @@ homeassistant/components/tile/* @bachya homeassistant/components/time_date/* @fabaff homeassistant/components/tmb/* @alemuro homeassistant/components/todoist/* @boralyl +homeassistant/components/tomorrowio/* @raman325 homeassistant/components/toon/* @frenck homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/tplink/* @rytilahti @thegardenmonkey diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py new file mode 100644 index 00000000000000..25db24f068a017 --- /dev/null +++ b/homeassistant/components/tomorrowio/__init__.py @@ -0,0 +1,304 @@ +"""The Tomorrow.io integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from math import ceil +from typing import Any + +from pytomorrowio import TomorrowioV4 +from pytomorrowio.const import CURRENT, FORECASTS +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ATTRIBUTION, + CC_ATTR_CLOUD_COVER, + CC_ATTR_CONDITION, + CC_ATTR_HUMIDITY, + CC_ATTR_OZONE, + CC_ATTR_PRECIPITATION, + CC_ATTR_PRECIPITATION_PROBABILITY, + CC_ATTR_PRECIPITATION_TYPE, + CC_ATTR_PRESSURE, + CC_ATTR_TEMPERATURE, + CC_ATTR_TEMPERATURE_HIGH, + CC_ATTR_TEMPERATURE_LOW, + CC_ATTR_VISIBILITY, + CC_ATTR_WIND_DIRECTION, + CC_ATTR_WIND_GUST, + CC_ATTR_WIND_SPEED, + CC_SENSOR_TYPES, + CONF_TIMESTEP, + DEFAULT_TIMESTEP, + DOMAIN, + INTEGRATION_NAME, + MAX_REQUESTS_PER_DAY, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] + + +def _set_update_interval(hass: HomeAssistant, current_entry: ConfigEntry) -> timedelta: + """Recalculate update_interval based on existing Tomorrow.io instances and update them.""" + api_calls = 2 + # We check how many Tomorrow.io configured instances are using the same API key and + # calculate interval to not exceed allowed numbers of requests. Divide 90% of + # MAX_REQUESTS_PER_DAY by the number of API calls because we want a buffer in the + # number of API calls left at the end of the day. + other_instance_entry_ids = [ + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id != current_entry.entry_id + and entry.data[CONF_API_KEY] == current_entry.data[CONF_API_KEY] + ] + + interval = timedelta( + minutes=( + ceil( + (24 * 60 * (len(other_instance_entry_ids) + 1) * api_calls) + / (MAX_REQUESTS_PER_DAY * 0.9) + ) + ) + ) + + for entry_id in other_instance_entry_ids: + if entry_id in hass.data[DOMAIN]: + hass.data[DOMAIN][entry_id].update_interval = interval + + return interval + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Tomorrow.io API from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # Let's precreate the device so that if this is a first time setup for a config + # entry imported from a ClimaCell entry, we can apply customizations from the old + # device. + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.data[CONF_API_KEY])}, + name=INTEGRATION_NAME, + manufacturer=INTEGRATION_NAME, + sw_version="v4", + entry_type="service", + ) + + # If this is an import and we still have the old config entry ID in the entry data, + # it means we are setting this entry up for the first time after a migration from + # ClimaCell to Tomorrow.io. In order to preserve any customizations on the ClimaCell + # entities, we need to remove each old entity, creating a new entity in its place + # but attached to this entry. + if entry.source == SOURCE_IMPORT and "old_config_entry_id" in entry.data: + # Remove the old config entry ID from the entry data so we don't try this again + # on the next setup + data = entry.data.copy() + old_config_entry_id = data.pop("old_config_entry_id") + hass.config_entries.async_update_entry(entry, data=data) + _LOGGER.debug( + ( + "Setting up imported climacell entry %s for the first time as " + "tomorrowio entry %s" + ), + old_config_entry_id, + entry.entry_id, + ) + + ent_reg = er.async_get(hass) + for entity in er.async_entries_for_config_entry(ent_reg, old_config_entry_id): + _LOGGER.debug("Removing %s", entity.entity_id) + ent_reg.async_remove(entity.entity_id) + # In case the API key has changed due to a V3 -> V4 change, we need to + # generate the new entity's unique ID + new_unique_id = f"{entry.data[CONF_API_KEY]}_{'_'.join(entity.unique_id.split('_')[1:])}" + _LOGGER.debug("Re-creating %s for the new config entry", entity.entity_id) + # We will precreate the entity so that any customizations can be preserved + new_entity = ent_reg.async_get_or_create( + entity.domain, + DOMAIN, + new_unique_id, + suggested_object_id=entity.entity_id.split(".")[1], + disabled_by=entity.disabled_by, + config_entry=entry, + original_name=entity.original_name, + original_icon=entity.original_icon, + ) + _LOGGER.debug("Re-created %s", new_entity.entity_id) + # If there are customizations on the old entity, apply them to the new one + if entity.name or entity.icon: + ent_reg.async_update_entity( + new_entity.entity_id, name=entity.name, icon=entity.icon + ) + + # We only have one device in the registry but we will do a loop just in case + for old_device in dr.async_entries_for_config_entry( + dev_reg, old_config_entry_id + ): + if old_device.name_by_user: + dev_reg.async_update_device( + device.id, name_by_user=old_device.name_by_user + ) + + # Remove the old config entry and now the entry is fully migrated + hass.async_create_task(hass.config_entries.async_remove(old_config_entry_id)) + + # If config entry options not set up, set them up + if not entry.options: + hass.config_entries.async_update_entry( + entry, + options={ + CONF_TIMESTEP: DEFAULT_TIMESTEP, + }, + ) + + api = TomorrowioV4( + entry.data[CONF_API_KEY], + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], + session=async_get_clientsession(hass), + ) + + coordinator = TomorrowioDataUpdateCoordinator( + hass, + entry, + api, + _set_update_interval(hass, entry), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + hass.data[DOMAIN].pop(config_entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + +class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Tomorrow.io data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + api: TomorrowioV4, + update_interval: timedelta, + ) -> None: + """Initialize.""" + + self._config_entry = config_entry + self._api = api + self.name = config_entry.data[CONF_NAME] + self.data = {CURRENT: {}, FORECASTS: {}} + + super().__init__( + hass, + _LOGGER, + name=config_entry.data[CONF_NAME], + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + try: + return await self._api.realtime_and_all_forecasts( + [ + CC_ATTR_TEMPERATURE, + CC_ATTR_HUMIDITY, + CC_ATTR_PRESSURE, + CC_ATTR_WIND_SPEED, + CC_ATTR_WIND_DIRECTION, + CC_ATTR_CONDITION, + CC_ATTR_VISIBILITY, + CC_ATTR_OZONE, + CC_ATTR_WIND_GUST, + CC_ATTR_CLOUD_COVER, + CC_ATTR_PRECIPITATION_TYPE, + *(sensor_type.key for sensor_type in CC_SENSOR_TYPES), + ], + [ + CC_ATTR_TEMPERATURE_LOW, + CC_ATTR_TEMPERATURE_HIGH, + CC_ATTR_WIND_SPEED, + CC_ATTR_WIND_DIRECTION, + CC_ATTR_CONDITION, + CC_ATTR_PRECIPITATION, + CC_ATTR_PRECIPITATION_PROBABILITY, + ], + ) + except ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, + ) as error: + raise UpdateFailed from error + + +class TomorrowioEntity(CoordinatorEntity): + """Base Tomorrow.io Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + ) -> None: + """Initialize Tomorrow.io Entity.""" + super().__init__(coordinator) + self.api_version = api_version + self._config_entry = config_entry + self._attr_device_info = { + "identifiers": {(DOMAIN, self._config_entry.data[CONF_API_KEY])}, + "name": "Tomorrow.io", + "manufacturer": "Tomorrow.io", + "sw_version": f"v{self.api_version}", + "entry_type": "service", + } + + def _get_current_property(self, property_name: str) -> int | str | float | None: + """ + Get property from current conditions. + + Used for V4 API. + """ + return self.coordinator.data.get(CURRENT, {}).get(property_name) + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py new file mode 100644 index 00000000000000..2c55644b834af8 --- /dev/null +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -0,0 +1,175 @@ +"""Config flow for Tomorrow.io integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, +) +from pytomorrowio.pytomorrowio import TomorrowioV4 +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +from .const import ( + CC_ATTR_TEMPERATURE, + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def _get_config_schema( + hass: core.HomeAssistant, source: str | None, input_dict: dict[str, Any] = None +) -> vol.Schema: + """ + Return schema defaults for init step based on user input/config dict. + + Retain info already provided for future form views by setting them as + defaults in schema. + """ + if input_dict is None: + input_dict = {} + + api_key_schema = { + vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, + } + + if source == config_entries.SOURCE_USER: + return vol.Schema( + { + vol.Required( + CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) + ): str, + **api_key_schema, + vol.Required( + CONF_LATITUDE, + "location", + default=input_dict.get(CONF_LATITUDE, hass.config.latitude), + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + "location", + default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), + ): cv.longitude, + }, + extra=vol.REMOVE_EXTRA, + ) + + # For imports we just need to ask for the API key + return vol.Schema(api_key_schema, extra=vol.REMOVE_EXTRA) + + +def _get_unique_id(hass: HomeAssistant, input_dict: dict[str, Any]): + """Return unique ID from config data.""" + return ( + f"{input_dict[CONF_API_KEY]}" + f"_{input_dict.get(CONF_LATITUDE, hass.config.latitude)}" + f"_{input_dict.get(CONF_LONGITUDE, hass.config.longitude)}" + ) + + +class TomorrowioOptionsConfigFlow(config_entries.OptionsFlow): + """Handle Tomorrow.io options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize Tomorrow.io options flow.""" + self._config_entry = config_entry + + async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: + """Manage the Tomorrow.io options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options_schema = { + vol.Required( + CONF_TIMESTEP, + default=self._config_entry.options.get(CONF_TIMESTEP, DEFAULT_TIMESTEP), + ): vol.In([1, 5, 15, 30]), + } + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(options_schema) + ) + + +class TomorrowioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tomorrow.io Weather API.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow.""" + self._showed_import_message = 0 + self._import_config: dict[str, Any] | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TomorrowioOptionsConfigFlow: + """Get the options flow for this handler.""" + return TomorrowioOptionsConfigFlow(config_entry) + + async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + # Grab the API key and add it to the rest of the config before continuing + if self._import_config: + self._import_config[CONF_API_KEY] = user_input[CONF_API_KEY] + user_input = self._import_config.copy() + await self.async_set_unique_id( + unique_id=_get_unique_id(self.hass, user_input) + ) + self._abort_if_unique_id_configured() + + try: + await TomorrowioV4( + user_input[CONF_API_KEY], + str(user_input.get(CONF_LATITUDE, self.hass.config.latitude)), + str(user_input.get(CONF_LONGITUDE, self.hass.config.longitude)), + session=async_get_clientsession(self.hass), + ).realtime([CC_ATTR_TEMPERATURE]) + + options: Mapping[str, Any] = {} + # Store the old config entry ID and retrieve options to recreate the entry + if self.source == config_entries.SOURCE_IMPORT: + old_config_entry_id = self.context["old_config_entry_id"] + old_config_entry = self.hass.config_entries.async_get_entry( + old_config_entry_id + ) + assert old_config_entry + options = dict(old_config_entry.options) + user_input["old_config_entry_id"] = old_config_entry_id + + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input, options=options + ) + except CantConnectException: + errors["base"] = "cannot_connect" + except InvalidAPIKeyException: + errors[CONF_API_KEY] = "invalid_api_key" + except RateLimitedException: + errors[CONF_API_KEY] = "rate_limited" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", + data_schema=_get_config_schema(self.hass, self.source, user_input), + errors=errors, + ) diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py new file mode 100644 index 00000000000000..b06449881b68a3 --- /dev/null +++ b/homeassistant/components/tomorrowio/const.py @@ -0,0 +1,366 @@ +"""Constants for the Tomorrow.io integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from pytomorrowio.const import ( + DAILY, + HOURLY, + NOWCAST, + HealthConcernType, + PollenIndex, + PrecipitationType, + PrimaryPollutantType, + WeatherCode, +) + +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_CO, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + IRRADIATION_WATTS_PER_SQUARE_METER, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util.distance import convert as distance_convert +from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.temperature import convert as temp_convert + +CONF_TIMESTEP = "timestep" +FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] + +DEFAULT_TIMESTEP = 15 +DEFAULT_FORECAST_TYPE = DAILY +DOMAIN = "tomorrowio" +INTEGRATION_NAME = "Tomorrow.io" +DEFAULT_NAME = INTEGRATION_NAME +ATTRIBUTION = "Powered by Tomorrow.io" + +MAX_REQUESTS_PER_DAY = 100 + +CLEAR_CONDITIONS = {"night": ATTR_CONDITION_CLEAR_NIGHT, "day": ATTR_CONDITION_SUNNY} + +MAX_FORECASTS = { + DAILY: 14, + HOURLY: 24, + NOWCAST: 30, +} + +# Additional attributes +ATTR_WIND_GUST = "wind_gust" +ATTR_CLOUD_COVER = "cloud_cover" +ATTR_PRECIPITATION_TYPE = "precipitation_type" + +# V4 constants +CONDITIONS = { + WeatherCode.WIND: ATTR_CONDITION_WINDY, + WeatherCode.LIGHT_WIND: ATTR_CONDITION_WINDY, + WeatherCode.STRONG_WIND: ATTR_CONDITION_WINDY, + WeatherCode.FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.HEAVY_FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.LIGHT_FREEZING_RAIN: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.FREEZING_DRIZZLE: ATTR_CONDITION_SNOWY_RAINY, + WeatherCode.ICE_PELLETS: ATTR_CONDITION_HAIL, + WeatherCode.HEAVY_ICE_PELLETS: ATTR_CONDITION_HAIL, + WeatherCode.LIGHT_ICE_PELLETS: ATTR_CONDITION_HAIL, + WeatherCode.SNOW: ATTR_CONDITION_SNOWY, + WeatherCode.HEAVY_SNOW: ATTR_CONDITION_SNOWY, + WeatherCode.LIGHT_SNOW: ATTR_CONDITION_SNOWY, + WeatherCode.FLURRIES: ATTR_CONDITION_SNOWY, + WeatherCode.THUNDERSTORM: ATTR_CONDITION_LIGHTNING, + WeatherCode.RAIN: ATTR_CONDITION_POURING, + WeatherCode.HEAVY_RAIN: ATTR_CONDITION_RAINY, + WeatherCode.LIGHT_RAIN: ATTR_CONDITION_RAINY, + WeatherCode.DRIZZLE: ATTR_CONDITION_RAINY, + WeatherCode.FOG: ATTR_CONDITION_FOG, + WeatherCode.LIGHT_FOG: ATTR_CONDITION_FOG, + WeatherCode.CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCode.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY, + WeatherCode.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY, +} + +# Weather constants +CC_ATTR_TIMESTAMP = "startTime" +CC_ATTR_TEMPERATURE = "temperature" +CC_ATTR_TEMPERATURE_HIGH = "temperatureMax" +CC_ATTR_TEMPERATURE_LOW = "temperatureMin" +CC_ATTR_PRESSURE = "pressureSeaLevel" +CC_ATTR_HUMIDITY = "humidity" +CC_ATTR_WIND_SPEED = "windSpeed" +CC_ATTR_WIND_DIRECTION = "windDirection" +CC_ATTR_OZONE = "pollutantO3" +CC_ATTR_CONDITION = "weatherCode" +CC_ATTR_VISIBILITY = "visibility" +CC_ATTR_PRECIPITATION = "precipitationIntensityAvg" +CC_ATTR_PRECIPITATION_PROBABILITY = "precipitationProbability" +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_ATTR_FEELS_LIKE = "temperatureApparent" +CC_ATTR_DEW_POINT = "dewPoint" +CC_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" +CC_ATTR_SOLAR_GHI = "solarGHI" +CC_ATTR_CLOUD_BASE = "cloudBase" +CC_ATTR_CLOUD_CEILING = "cloudCeiling" + + +@dataclass +class TomorrowioSensorEntityDescription(SensorEntityDescription): + """Describes a Tomorrow.io sensor entity.""" + + unit_imperial: str | None = None + unit_metric: str | None = None + metric_conversion: Callable[[float], float] | float = 1.0 + is_metric_check: bool | None = None + device_class: str | None = None + value_map: Any | None = None + + def __post_init__(self) -> None: + """Post initialization.""" + units = (self.unit_imperial, self.unit_metric) + if any(u is not None for u in units) and any(u is None for u in units): + raise RuntimeError( + "`unit_imperial` and `unit_metric` both need to be None or both need " + "to be defined." + ) + + +CC_SENSOR_TYPES = ( + TomorrowioSensorEntityDescription( + key=CC_ATTR_FEELS_LIKE, + name="Feels Like", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_DEW_POINT, + name="Dew Point", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_PRESSURE_SURFACE_LEVEL, + name="Pressure (Surface Level)", + unit_imperial=PRESSURE_INHG, + unit_metric=PRESSURE_HPA, + metric_conversion=lambda val: pressure_convert( + val, PRESSURE_INHG, PRESSURE_HPA + ), + is_metric_check=True, + device_class=DEVICE_CLASS_PRESSURE, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_SOLAR_GHI, + name="Global Horizontal Irradiance", + unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, + metric_conversion=3.15459, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CLOUD_BASE, + name="Cloud Base", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CLOUD_CEILING, + name="Cloud Ceiling", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CLOUD_COVER, + name="Cloud Cover", + unit_imperial=PERCENTAGE, + unit_metric=PERCENTAGE, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_WIND_GUST, + name="Wind Gust", + unit_imperial=SPEED_MILES_PER_HOUR, + unit_metric=SPEED_METERS_PER_SECOND, + metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) + / 3600, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_PRECIPITATION_TYPE, + name="Precipitation Type", + value_map=PrecipitationType, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_OZONE, + name="Ozone", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_SULFUR_DIOXIDE, + name="Sulfur Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + value_map=PrimaryPollutantType, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + value_map=HealthConcernType, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + value_map=PrimaryPollutantType, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + value_map=HealthConcernType, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=PollenIndex, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=PollenIndex, + ), + TomorrowioSensorEntityDescription( + key=CC_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=PollenIndex, + ), + TomorrowioSensorEntityDescription( + CC_ATTR_FIRE_INDEX, + name="Fire Index", + ), +) + +# V3 constants +CONDITIONS_V3 = { + "breezy": ATTR_CONDITION_WINDY, + "freezing_rain_heavy": ATTR_CONDITION_SNOWY_RAINY, + "freezing_rain": ATTR_CONDITION_SNOWY_RAINY, + "freezing_rain_light": ATTR_CONDITION_SNOWY_RAINY, + "freezing_drizzle": ATTR_CONDITION_SNOWY_RAINY, + "ice_pellets_heavy": ATTR_CONDITION_HAIL, + "ice_pellets": ATTR_CONDITION_HAIL, + "ice_pellets_light": ATTR_CONDITION_HAIL, + "snow_heavy": ATTR_CONDITION_SNOWY, + "snow": ATTR_CONDITION_SNOWY, + "snow_light": ATTR_CONDITION_SNOWY, + "flurries": ATTR_CONDITION_SNOWY, + "tstorm": ATTR_CONDITION_LIGHTNING, + "rain_heavy": ATTR_CONDITION_POURING, + "rain": ATTR_CONDITION_RAINY, + "rain_light": ATTR_CONDITION_RAINY, + "drizzle": ATTR_CONDITION_RAINY, + "fog_light": ATTR_CONDITION_FOG, + "fog": ATTR_CONDITION_FOG, + "cloudy": ATTR_CONDITION_CLOUDY, + "mostly_cloudy": ATTR_CONDITION_CLOUDY, + "partly_cloudy": ATTR_CONDITION_PARTLYCLOUDY, +} diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json new file mode 100644 index 00000000000000..6fc8a2f12ce5b3 --- /dev/null +++ b/homeassistant/components/tomorrowio/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "tomorrowio", + "name": "Tomorrow.io", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tomorrowio", + "requirements": ["pytomorrowio==0.1.0"], + "codeowners": ["@raman325"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py new file mode 100644 index 00000000000000..b8fdf14e57b773 --- /dev/null +++ b/homeassistant/components/tomorrowio/sensor.py @@ -0,0 +1,98 @@ +"""Sensor component that handles additional Tomorrowio data for your location.""" +from __future__ import annotations + +from abc import abstractmethod +import logging + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity +from .const import CC_SENSOR_TYPES, DOMAIN, TomorrowioSensorEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [ + TomorrowioSensorEntity(hass, config_entry, coordinator, 4, description) + for description in CC_SENSOR_TYPES + ] + async_add_entities(entities) + + +class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): + """Base Tomorrow.io sensor entity.""" + + entity_description: TomorrowioSensorEntityDescription + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + description: TomorrowioSensorEntityDescription, + ) -> None: + """Initialize Tomorrow.io Sensor Entity.""" + super().__init__(config_entry, coordinator, api_version) + self.entity_description = description + self._attr_entity_registry_enabled_default = False + self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" + self._attr_unique_id = ( + f"{self._config_entry.unique_id}_{slugify(description.name)}" + ) + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} + self._attr_native_unit_of_measurement = ( + description.unit_metric + if hass.config.units.is_metric + else description.unit_imperial + ) + + @property + @abstractmethod + def _state(self) -> str | int | float | None: + """Return the raw state.""" + + @property + def native_value(self) -> str | int | float | None: + """Return the state.""" + state = self._state + if ( + state is not None + and self.entity_description.unit_imperial is not None + and self.entity_description.metric_conversion != 1.0 + and self.entity_description.is_metric_check is not None + and self.hass.config.units.is_metric + == self.entity_description.is_metric_check + ): + conversion = self.entity_description.metric_conversion + # When conversion is a callable, we assume it's a single input function + if callable(conversion): + return round(conversion(float(state)), 4) + + return round(float(state) * conversion, 4) + + if self.entity_description.value_map is not None and state is not None: + return self.entity_description.value_map(state).name.lower() + + return state + + +class TomorrowioSensorEntity(BaseTomorrowioSensorEntity): + """Sensor entity that talks to Tomorrow.io 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.entity_description.key) diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json new file mode 100644 index 00000000000000..b681dc4d043f44 --- /dev/null +++ b/homeassistant/components/tomorrowio/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "description": "To get an API key, sign up at [Tomorrow.io](https://app.tomorrow.io/signup).", + "data": { + "name": "[%key:common::config_flow::data::name%]", + "api_key": "[%key:common::config_flow::data::api_key%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "rate_limited": "Currently rate limited, please try again later." + } + }, + "options": { + "step": { + "init": { + "title": "Update Tomorrow.io Options", + "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", + "data": { + "timestep": "Min. Between NowCast Forecasts" + } + } + } + } +} diff --git a/homeassistant/components/tomorrowio/translations/en.json b/homeassistant/components/tomorrowio/translations/en.json new file mode 100644 index 00000000000000..7c653b005747f2 --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect", + "invalid_api_key": "Invalid API key", + "rate_limited": "Currently rate limited, please try again later.", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "description": "To get an API key, sign up at [Tomorrow.io](https://app.tomorrow.io/signup)." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "timestep": "Min. Between NowCast Forecasts" + }, + "description": "If you choose to enable the `nowcast` forecast entity, you can configure the number of minutes between each forecast. The number of forecasts provided depends on the number of minutes chosen between forecasts.", + "title": "Update Tomorrow.io Options" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py new file mode 100644 index 00000000000000..9e3d830115b182 --- /dev/null +++ b/homeassistant/components/tomorrowio/weather.py @@ -0,0 +1,383 @@ +"""Weather component that handles meteorological data for your location.""" +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Mapping +from datetime import datetime +import logging +from typing import Any + +from pytomorrowio.const import ( + DAILY, + FORECASTS, + HOURLY, + NOWCAST, + PrecipitationType, + WeatherCode, +) + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + WeatherEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_NAME, + LENGTH_FEET, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PRESSURE_HPA, + PRESSURE_INHG, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sun import is_up +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 TomorrowioDataUpdateCoordinator, TomorrowioEntity +from .const import ( + ATTR_CLOUD_COVER, + ATTR_PRECIPITATION_TYPE, + ATTR_WIND_GUST, + CC_ATTR_CLOUD_COVER, + CC_ATTR_CONDITION, + CC_ATTR_HUMIDITY, + CC_ATTR_OZONE, + CC_ATTR_PRECIPITATION, + CC_ATTR_PRECIPITATION_PROBABILITY, + CC_ATTR_PRECIPITATION_TYPE, + CC_ATTR_PRESSURE, + CC_ATTR_TEMPERATURE, + CC_ATTR_TEMPERATURE_HIGH, + CC_ATTR_TEMPERATURE_LOW, + CC_ATTR_TIMESTAMP, + CC_ATTR_VISIBILITY, + CC_ATTR_WIND_DIRECTION, + CC_ATTR_WIND_GUST, + CC_ATTR_WIND_SPEED, + CLEAR_CONDITIONS, + CONDITIONS, + CONF_TIMESTEP, + DEFAULT_FORECAST_TYPE, + DOMAIN, + MAX_FORECASTS, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) + for forecast_type in (DAILY, HOURLY, NOWCAST) + ] + async_add_entities(entities) + + +class BaseTomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): + """Base Tomorrow.io weather entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: TomorrowioDataUpdateCoordinator, + api_version: int, + forecast_type: str, + ) -> None: + """Initialize Tomorrow.io Weather Entity.""" + super().__init__(config_entry, coordinator, api_version) + self.forecast_type = forecast_type + self._attr_entity_registry_enabled_default = ( + forecast_type == DEFAULT_FORECAST_TYPE + ) + self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_unique_id = f"{config_entry.unique_id}_{forecast_type}" + + @staticmethod + @abstractmethod + def _translate_condition( + condition: int | None, sun_is_up: bool = True + ) -> str | None: + """Translate Tomorrow.io condition into an HA condition.""" + + def _forecast_dict( + self, + forecast_dt: datetime, + use_datetime: bool, + condition: int, + precipitation: float | None, + precipitation_probability: float | None, + temp: float | None, + temp_low: float | None, + wind_direction: float | None, + wind_speed: float | None, + ) -> dict[str, Any]: + """Return formatted Forecast dict from Tomorrow.io forecast data.""" + if use_datetime: + translated_condition = self._translate_condition( + condition, is_up(self.hass, forecast_dt) + ) + else: + translated_condition = self._translate_condition(condition, True) + + if self.hass.config.units.is_metric: + if precipitation: + precipitation = round( + distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) + * 1000, + 4, + ) + if wind_speed: + wind_speed = round( + distance_convert(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS), 4 + ) + + data = { + ATTR_FORECAST_TIME: forecast_dt.isoformat(), + ATTR_FORECAST_CONDITION: translated_condition, + ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, + ATTR_FORECAST_TEMP: temp, + ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_WIND_BEARING: wind_direction, + ATTR_FORECAST_WIND_SPEED: wind_speed, + } + + return {k: v for k, v in data.items() if v is not None} + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional state attributes.""" + wind_gust = self.wind_gust + if wind_gust and self.hass.config.units.is_metric: + wind_gust = round( + distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 + ) + cloud_cover = self.cloud_cover + return { + ATTR_CLOUD_COVER: cloud_cover, + ATTR_WIND_GUST: wind_gust, + ATTR_PRECIPITATION_TYPE: self.precipitation_type, + } + + @property + @abstractmethod + def cloud_cover(self): + """Return cloud cover.""" + + @property + @abstractmethod + def wind_gust(self): + """Return wind gust speed.""" + + @property + @abstractmethod + def precipitation_type(self): + """Return precipitation type.""" + + @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 TomorrowioWeatherEntity(BaseTomorrowioWeatherEntity): + """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" + + _attr_temperature_unit = TEMP_FAHRENHEIT + + @staticmethod + def _translate_condition( + condition: int | None, sun_is_up: bool = True + ) -> str | None: + """Translate Tomorrow.io condition into an HA condition.""" + if condition is None: + return None + # We won't guard here, instead we will fail hard + condition = WeatherCode(condition) + if condition in (WeatherCode.CLEAR, WeatherCode.MOSTLY_CLEAR): + if sun_is_up: + return CLEAR_CONDITIONS["day"] + return CLEAR_CONDITIONS["night"] + return CONDITIONS[condition] + + @property + def temperature(self): + """Return the platform temperature.""" + return self._get_current_property(CC_ATTR_TEMPERATURE) + + @property + def _pressure(self): + """Return the raw pressure.""" + return self._get_current_property(CC_ATTR_PRESSURE) + + @property + def humidity(self): + """Return the humidity.""" + return self._get_current_property(CC_ATTR_HUMIDITY) + + @property + def wind_gust(self): + """Return the wind gust speed.""" + return self._get_current_property(CC_ATTR_WIND_GUST) + + @property + def cloud_cover(self): + """Reteurn the cloud cover.""" + return self._get_current_property(CC_ATTR_CLOUD_COVER) + + @property + def precipitation_type(self): + """Return precipitation type.""" + precipitation_type = self._get_current_property(CC_ATTR_PRECIPITATION_TYPE) + if precipitation_type is None: + return None + return PrecipitationType(precipitation_type).name.lower() + + @property + def _wind_speed(self): + """Return the raw wind speed.""" + return self._get_current_property(CC_ATTR_WIND_SPEED) + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self._get_current_property(CC_ATTR_WIND_DIRECTION) + + @property + def ozone(self): + """Return the O3 (ozone) level.""" + return self._get_current_property(CC_ATTR_OZONE) + + @property + def condition(self): + """Return the condition.""" + return self._translate_condition( + self._get_current_property(CC_ATTR_CONDITION), + is_up(self.hass), + ) + + @property + def _visibility(self): + """Return the raw visibility.""" + return self._get_current_property(CC_ATTR_VISIBILITY) + + @property + def forecast(self): + """Return the forecast.""" + # Check if forecasts are available + raw_forecasts = self.coordinator.data.get(FORECASTS, {}).get(self.forecast_type) + if not raw_forecasts: + return None + + forecasts = [] + max_forecasts = MAX_FORECASTS[self.forecast_type] + forecast_count = 0 + + # Set default values (in cases where keys don't exist), None will be + # returned. Override properties per forecast type as needed + for forecast in raw_forecasts: + forecast_dt = dt_util.parse_datetime(forecast[CC_ATTR_TIMESTAMP]) + + # Throw out past data + if forecast_dt.date() < dt_util.utcnow().date(): + continue + + values = forecast["values"] + use_datetime = True + + condition = values.get(CC_ATTR_CONDITION) + precipitation = values.get(CC_ATTR_PRECIPITATION) + precipitation_probability = values.get(CC_ATTR_PRECIPITATION_PROBABILITY) + + temp = values.get(CC_ATTR_TEMPERATURE_HIGH) + temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) + wind_direction = values.get(CC_ATTR_WIND_DIRECTION) + wind_speed = values.get(CC_ATTR_WIND_SPEED) + + if self.forecast_type == DAILY: + use_datetime = False + if precipitation: + precipitation = precipitation * 24 + elif self.forecast_type == NOWCAST: + # Precipitation is forecasted in CONF_TIMESTEP increments but in a + # per hour rate, so value needs to be converted to an amount. + if precipitation: + precipitation = ( + precipitation / 60 * self._config_entry.options[CONF_TIMESTEP] + ) + + forecasts.append( + self._forecast_dict( + forecast_dt, + use_datetime, + condition, + precipitation, + precipitation_probability, + temp, + temp_low, + wind_direction, + wind_speed, + ) + ) + + forecast_count += 1 + if forecast_count == max_forecasts: + break + + return forecasts diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2a04ec394780c6..8f7497c652e0f8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -284,6 +284,7 @@ "tellduslive", "tibber", "tile", + "tomorrowio", "toon", "totalconnect", "tplink", diff --git a/requirements_all.txt b/requirements_all.txt index 363618c3e99fc5..6f526df24a6580 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,6 +1963,9 @@ pythonegardia==1.0.40 # homeassistant.components.tile pytile==5.2.3 +# homeassistant.components.tomorrowio +pytomorrowio==0.1.0 + # homeassistant.components.touchline pytouchline==0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab801c5f083c68..c4b0f984ba038d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1132,6 +1132,9 @@ python_awair==0.2.1 # homeassistant.components.tile pytile==5.2.3 +# homeassistant.components.tomorrowio +pytomorrowio==0.1.0 + # homeassistant.components.traccar pytraccar==0.9.0 diff --git a/tests/components/tomorrowio/__init__.py b/tests/components/tomorrowio/__init__.py new file mode 100644 index 00000000000000..de0ec5d135a037 --- /dev/null +++ b/tests/components/tomorrowio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tomorrow.io Weather API integration.""" diff --git a/tests/components/tomorrowio/conftest.py b/tests/components/tomorrowio/conftest.py new file mode 100644 index 00000000000000..338261abaee9c6 --- /dev/null +++ b/tests/components/tomorrowio/conftest.py @@ -0,0 +1,36 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from tests.common import load_fixture + + +@pytest.fixture(name="skip_notifications", autouse=True) +def skip_notifications_fixture(): + """Skip notification calls.""" + with patch("homeassistant.components.persistent_notification.async_create"), patch( + "homeassistant.components.persistent_notification.async_dismiss" + ): + yield + + +@pytest.fixture(name="tomorrowio_config_flow_connect", autouse=True) +def tomorrowio_config_flow_connect(): + """Mock valid tomorrowio config flow setup.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + return_value={}, + ): + yield + + +@pytest.fixture(name="tomorrowio_config_entry_update") +def tomorrowio_config_entry_update_fixture(): + """Mock valid tomorrowio config entry setup.""" + with patch( + "homeassistant.components.tomorrowio.TomorrowioV4.realtime_and_all_forecasts", + return_value=json.loads(load_fixture("tomorrowio/v4.json")), + ): + yield diff --git a/tests/components/tomorrowio/const.py b/tests/components/tomorrowio/const.py new file mode 100644 index 00000000000000..87e1aa633acd97 --- /dev/null +++ b/tests/components/tomorrowio/const.py @@ -0,0 +1,23 @@ +"""Constants for tomorrowio tests.""" + +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +API_KEY = "aa" + +MIN_CONFIG = { + CONF_API_KEY: API_KEY, +} + +V1_ENTRY_DATA = { + CONF_NAME: "Tomorrow.io", + CONF_API_KEY: API_KEY, + CONF_LATITUDE: 80, + CONF_LONGITUDE: 80, +} + +API_V4_ENTRY_DATA = { + CONF_NAME: "Tomorrow.io", + CONF_API_KEY: API_KEY, + CONF_LATITUDE: 80, + CONF_LONGITUDE: 80, +} diff --git a/tests/components/tomorrowio/test_config_flow.py b/tests/components/tomorrowio/test_config_flow.py new file mode 100644 index 00000000000000..1d445ffbb9bb33 --- /dev/null +++ b/tests/components/tomorrowio/test_config_flow.py @@ -0,0 +1,169 @@ +"""Test the Tomorrow.io config flow.""" +import logging +from unittest.mock import patch + +from pytomorrowio.exceptions import ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, +) + +from homeassistant import data_entry_flow +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import ( + CONF_TIMESTEP, + DEFAULT_NAME, + DEFAULT_TIMESTEP, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import API_KEY, MIN_CONFIG + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: + """Test user config flow with minimum fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_NAME] == DEFAULT_NAME + assert result["data"][CONF_API_KEY] == API_KEY + assert result["data"][CONF_LATITUDE] == hass.config.latitude + assert result["data"][CONF_LONGITUDE] == hass.config.longitude + + +async def test_user_flow_same_unique_ids(hass: HomeAssistant) -> None: + """Test user config flow with the same unique ID as an existing entry.""" + user_input = _get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG) + MockConfigEntry( + domain=DOMAIN, + data=user_input, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_input), + version=2, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_flow_cannot_connect(hass: HomeAssistant) -> None: + """Test user config flow when Tomorrow.io can't connect.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=CantConnectException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_user_flow_invalid_api(hass: HomeAssistant) -> None: + """Test user config flow when API key is invalid.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=InvalidAPIKeyException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_user_flow_rate_limited(hass: HomeAssistant) -> None: + """Test user config flow when API key is rate limited.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=RateLimitedException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_API_KEY: "rate_limited"} + + +async def test_user_flow_unknown_exception(hass: HomeAssistant) -> None: + """Test user config flow when unknown error occurs.""" + with patch( + "homeassistant.components.tomorrowio.config_flow.TomorrowioV4.realtime", + side_effect=UnknownException, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=_get_config_schema(hass, SOURCE_USER, MIN_CONFIG)(MIN_CONFIG), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test options config flow for tomorrowio.""" + user_config = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) + entry = MockConfigEntry( + domain=DOMAIN, + data=user_config, + source=SOURCE_USER, + unique_id=_get_unique_id(hass, user_config), + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + assert entry.options[CONF_TIMESTEP] == DEFAULT_TIMESTEP + assert CONF_TIMESTEP not in entry.data + + result = await hass.config_entries.options.async_init(entry.entry_id, data=None) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_TIMESTEP: 1} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "" + assert result["data"][CONF_TIMESTEP] == 1 + assert entry.options[CONF_TIMESTEP] == 1 diff --git a/tests/components/tomorrowio/test_const.py b/tests/components/tomorrowio/test_const.py new file mode 100644 index 00000000000000..26108924729dda --- /dev/null +++ b/tests/components/tomorrowio/test_const.py @@ -0,0 +1,14 @@ +"""Tests for Tomorrow.io const.""" +import pytest + +from homeassistant.components.tomorrowio.const import TomorrowioSensorEntityDescription +from homeassistant.const import TEMP_FAHRENHEIT + + +async def test_post_init(): + """Test post initiailization check for TomorrowioSensorEntityDescription.""" + + with pytest.raises(RuntimeError): + TomorrowioSensorEntityDescription( + key="a", name="b", unit_imperial=TEMP_FAHRENHEIT + ) diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py new file mode 100644 index 00000000000000..9dab388704bdc5 --- /dev/null +++ b/tests/components/tomorrowio/test_init.py @@ -0,0 +1,157 @@ +"""Tests for Tomorrow.io init.""" +import logging + +import pytest + +from homeassistant.components.climacell.const import DOMAIN as CC_DOMAIN +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import DOMAIN +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from .const import MIN_CONFIG + +from tests.common import MockConfigEntry +from tests.components.climacell.const import API_V3_ENTRY_DATA + +_LOGGER = logging.getLogger(__name__) + +NEW_NAME = "New Name" + + +async def test_load_and_unload( + hass: HomeAssistant, + tomorrowio_config_entry_update: pytest.fixture, +) -> None: + """Test loading and unloading entry.""" + data = _get_config_schema(hass, SOURCE_USER)(MIN_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() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 + + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 + + +async def test_climacell_migration_logic( + hass: HomeAssistant, + tomorrowio_config_entry_update: pytest.fixture, + climacell_config_entry_update: pytest.fixture, +) -> None: + """Test that climacell config entry is properly migrated.""" + old_data = API_V3_ENTRY_DATA.copy() + old_data[CONF_API_KEY] = "v3apikey" + old_config_entry = MockConfigEntry( + domain=CC_DOMAIN, + data=old_data, + unique_id=_get_unique_id(hass, old_data), + version=1, + ) + old_config_entry.add_to_hass(hass) + # Let's create a device and update its name + dev_reg = dr.async_get(hass) + old_device = dev_reg.async_get_or_create( + config_entry_id=old_config_entry.entry_id, + identifiers={(CC_DOMAIN, old_data[CONF_API_KEY])}, + manufacturer="ClimaCell", + sw_version="v4", + entry_type="service", + name="ClimaCell", + ) + dev_reg.async_update_device(old_device.id, name_by_user=NEW_NAME) + # Now let's create some entity and update some things to see if everything migrates + # over + ent_reg = er.async_get(hass) + old_entity_daily = ent_reg.async_get_or_create( + "weather", + CC_DOMAIN, + f"{_get_unique_id(hass, old_data)}_daily", + config_entry=old_config_entry, + original_name="ClimaCell - Daily", + suggested_object_id="climacell_daily", + device_id=old_device.id, + ) + old_entity_hourly = ent_reg.async_get_or_create( + "weather", + CC_DOMAIN, + f"{_get_unique_id(hass, old_data)}_hourly", + config_entry=old_config_entry, + original_name="ClimaCell - Hourly", + suggested_object_id="climacell_hourly", + device_id=old_device.id, + disabled_by=er.DISABLED_USER, + ) + old_entity_nowcast = ent_reg.async_get_or_create( + "weather", + CC_DOMAIN, + f"{_get_unique_id(hass, old_data)}_nowcast", + config_entry=old_config_entry, + original_name="ClimaCell - Nowcast", + suggested_object_id="climacell_nowcast", + device_id=old_device.id, + ) + ent_reg.async_update_entity(old_entity_daily.entity_id, name=NEW_NAME) + + # Now let's create a new tomorrowio config entry that is supposedly created from a + # climacell import and see what happens - we are also changing the API key to ensure + # that things work as expected + new_data = API_V3_ENTRY_DATA.copy() + new_data[CONF_API_VERSION] = 4 + new_data["old_config_entry_id"] = old_config_entry.entry_id + config_entry = MockConfigEntry( + domain=DOMAIN, + data=new_data, + unique_id=_get_unique_id(hass, new_data), + version=1, + source=SOURCE_IMPORT, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check that the old device no longer exists + assert dev_reg.async_get(old_device.id) is None + + # Check that the new device was created and that it has the correct name + assert ( + dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)[ + 0 + ].name_by_user + == NEW_NAME + ) + + # Check that the new entities match the old ones (minus the default name) + new_entity_daily = ent_reg.async_get(old_entity_daily.entity_id) + assert new_entity_daily.platform == DOMAIN + assert new_entity_daily.name == NEW_NAME + assert new_entity_daily.original_name == "ClimaCell - Daily" + assert new_entity_daily.device_id != old_device.id + assert new_entity_daily.unique_id == f"{_get_unique_id(hass, new_data)}_daily" + assert new_entity_daily.disabled_by is None + + new_entity_hourly = ent_reg.async_get(old_entity_hourly.entity_id) + assert new_entity_hourly.platform == DOMAIN + assert new_entity_hourly.name is None + assert new_entity_hourly.original_name == "ClimaCell - Hourly" + assert new_entity_hourly.device_id != old_device.id + assert new_entity_hourly.unique_id == f"{_get_unique_id(hass, new_data)}_hourly" + assert new_entity_hourly.disabled_by == er.DISABLED_USER + + new_entity_nowcast = ent_reg.async_get(old_entity_nowcast.entity_id) + assert new_entity_nowcast.platform == DOMAIN + assert new_entity_nowcast.name is None + assert new_entity_nowcast.original_name == "ClimaCell - Nowcast" + assert new_entity_nowcast.device_id != old_device.id + assert new_entity_nowcast.unique_id == f"{_get_unique_id(hass, new_data)}_nowcast" + assert new_entity_nowcast.disabled_by is None diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py new file mode 100644 index 00000000000000..92d28dc7656cc2 --- /dev/null +++ b/tests/components/tomorrowio/test_sensor.py @@ -0,0 +1,165 @@ +"""Tests for Tomorrow.io sensor entities.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import ATTRIBUTION, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt as dt_util + +from .const import API_V4_ENTRY_DATA + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) +CC_SENSOR_ENTITY_ID = "sensor.tomorrow_io_{}" + +O3 = "ozone" +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" +FEELS_LIKE = "feels_like" +DEW_POINT = "dew_point" +PRESSURE_SURFACE_LEVEL = "pressure_surface_level" +SNOW_ACCUMULATION = "snow_accumulation" +ICE_ACCUMULATION = "ice_accumulation" +GHI = "global_horizontal_irradiance" +CLOUD_BASE = "cloud_base" +CLOUD_COVER = "cloud_cover" +CLOUD_CEILING = "cloud_ceiling" +WIND_GUST = "wind_gust" +PRECIPITATION_TYPE = "precipitation_type" + +V3_FIELDS = [ + O3, + 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, +] + +V4_FIELDS = [ + *V3_FIELDS, + FEELS_LIKE, + DEW_POINT, + PRESSURE_SURFACE_LEVEL, + GHI, + CLOUD_BASE, + CLOUD_COVER, + CLOUD_CEILING, + WIND_GUST, + PRECIPITATION_TYPE, +] + + +@callback +def _enable_entity(hass: HomeAssistant, 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: HomeAssistant, sensors: list[str], 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=dt_util.UTC), + ): + data = _get_config_schema(hass, SOURCE_USER)(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 sensors: + _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)) == len(sensors) + + +def check_sensor_state(hass: HomeAssistant, entity_name: str, value: str): + """Check the state of a Tomorrow.io 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_v4_sensor( + hass: HomeAssistant, + tomorrowio_config_entry_update: pytest.fixture, +) -> None: + """Test v4 sensor data.""" + await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) + check_sensor_state(hass, O3, "46.53") + 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") + check_sensor_state(hass, FEELS_LIKE, "38.5") + check_sensor_state(hass, DEW_POINT, "22.6778") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.9688") + check_sensor_state(hass, GHI, "0.0") + check_sensor_state(hass, CLOUD_BASE, "1.1909") + check_sensor_state(hass, CLOUD_COVER, "100") + check_sensor_state(hass, CLOUD_CEILING, "1.1909") + check_sensor_state(hass, WIND_GUST, "5.6506") + check_sensor_state(hass, PRECIPITATION_TYPE, "rain") diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py new file mode 100644 index 00000000000000..db7cdc136a1214 --- /dev/null +++ b/tests/components/tomorrowio/test_weather.py @@ -0,0 +1,251 @@ +"""Tests for Tomorrow.io weather entity.""" +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.tomorrowio.config_flow import ( + _get_config_schema, + _get_unique_id, +) +from homeassistant.components.tomorrowio.const import ( + ATTR_CLOUD_COVER, + ATTR_PRECIPITATION_TYPE, + ATTR_WIND_GUST, + ATTRIBUTION, + DOMAIN, +) +from homeassistant.components.weather import ( + ATTR_CONDITION_SUNNY, + ATTR_FORECAST, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME +from homeassistant.core import HomeAssistant, State, callback +from homeassistant.helpers.entity_registry import async_get +from homeassistant.util import dt as dt_util + +from .const import API_V4_ENTRY_DATA + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + + +@callback +def _enable_entity(hass: HomeAssistant, 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: HomeAssistant, 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=dt_util.UTC), + ): + data = _get_config_schema(hass, SOURCE_USER)(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 ("hourly", "nowcast"): + _enable_entity(hass, f"weather.tomorrow_io_{entity_name}") + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 3 + + return hass.states.get("weather.tomorrow_io_daily") + + +async def test_v4_weather( + hass: HomeAssistant, + tomorrowio_config_entry_update: pytest.fixture, +) -> None: + """Test v4 weather data.""" + weather_state = await _setup(hass, API_V4_ENTRY_DATA) + assert weather_state.state == ATTR_CONDITION_SUNNY + assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert weather_state.attributes[ATTR_FORECAST] == [ + { + ATTR_FORECAST_CONDITION: ATTR_CONDITION_SUNNY, + ATTR_FORECAST_TIME: "2021-03-07T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 8, + ATTR_FORECAST_TEMP_LOW: -3, + ATTR_FORECAST_WIND_BEARING: 239.6, + ATTR_FORECAST_WIND_SPEED: 15.2727, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-08T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 10, + ATTR_FORECAST_TEMP_LOW: -3, + ATTR_FORECAST_WIND_BEARING: 262.82, + ATTR_FORECAST_WIND_SPEED: 11.6517, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-09T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 19, + ATTR_FORECAST_TEMP_LOW: 0, + ATTR_FORECAST_WIND_BEARING: 229.3, + ATTR_FORECAST_WIND_SPEED: 11.3459, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-10T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 18, + ATTR_FORECAST_TEMP_LOW: 3, + ATTR_FORECAST_WIND_BEARING: 149.91, + ATTR_FORECAST_WIND_SPEED: 17.1234, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-11T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 19, + ATTR_FORECAST_TEMP_LOW: 9, + ATTR_FORECAST_WIND_BEARING: 210.45, + ATTR_FORECAST_WIND_SPEED: 25.2506, + }, + { + ATTR_FORECAST_CONDITION: "rainy", + ATTR_FORECAST_TIME: "2021-03-12T11:00:00+00:00", + 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.7949, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-13T11:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, + ATTR_FORECAST_TEMP: 12, + ATTR_FORECAST_TEMP_LOW: 6, + ATTR_FORECAST_WIND_BEARING: 58.79, + ATTR_FORECAST_WIND_SPEED: 15.6428, + }, + { + ATTR_FORECAST_CONDITION: "snowy", + ATTR_FORECAST_TIME: "2021-03-14T10:00:00+00:00", + 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.1518, + }, + { + ATTR_FORECAST_CONDITION: "snowy", + ATTR_FORECAST_TIME: "2021-03-15T10:00:00+00:00", + 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.5725, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-16T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 6, + ATTR_FORECAST_TEMP_LOW: -2, + ATTR_FORECAST_WIND_BEARING: 103.85, + ATTR_FORECAST_WIND_SPEED: 10.7987, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-17T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 0, + ATTR_FORECAST_TEMP: 11, + ATTR_FORECAST_TEMP_LOW: 1, + ATTR_FORECAST_WIND_BEARING: 145.41, + ATTR_FORECAST_WIND_SPEED: 11.6999, + }, + { + ATTR_FORECAST_CONDITION: "cloudy", + ATTR_FORECAST_TIME: "2021-03-18T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 0, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 10, + ATTR_FORECAST_TEMP: 12, + ATTR_FORECAST_TEMP_LOW: 5, + ATTR_FORECAST_WIND_BEARING: 62.99, + ATTR_FORECAST_WIND_SPEED: 10.5895, + }, + { + ATTR_FORECAST_CONDITION: "rainy", + ATTR_FORECAST_TIME: "2021-03-19T10:00:00+00:00", + 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.3860, + }, + { + ATTR_FORECAST_CONDITION: "snowy", + ATTR_FORECAST_TIME: "2021-03-20T10:00:00+00:00", + ATTR_FORECAST_PRECIPITATION: 1.2192, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: 33.3, + ATTR_FORECAST_TEMP: 5, + ATTR_FORECAST_TEMP_LOW: 2, + ATTR_FORECAST_WIND_BEARING: 56.98, + ATTR_FORECAST_WIND_SPEED: 27.9221, + }, + ] + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - 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.7691 + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 7 + 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.0152 + assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 + assert weather_state.attributes[ATTR_WIND_GUST] == 20.3421 + assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" diff --git a/tests/fixtures/tomorrowio/v4.json b/tests/fixtures/tomorrowio/v4.json new file mode 100644 index 00000000000000..5cd86b5b60ebf8 --- /dev/null +++ b/tests/fixtures/tomorrowio/v4.json @@ -0,0 +1,2384 @@ +{ + "current": { + "temperature": 44.13, + "humidity": 22.71, + "pressureSeaLevel": 30.35, + "windSpeed": 9.33, + "windDirection": 315.14, + "weatherCode": 1000, + "visibility": 8.15, + "pollutantO3": 46.53, + "windGust": 12.64, + "cloudCover": 100, + "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, + "temperatureApparent": 101.3, + "dewPoint": 72.82, + "pressureSurfaceLevel": 29.47, + "solarGHI": 0, + "cloudBase": 0.74, + "cloudCeiling": 0.74 + }, + "forecasts": { + "nowcast": [ + { + "startTime": "2021-03-07T17:48:00Z", + "values": { + "temperatureMin": 44.13, + "temperatureMax": 44.13, + "windSpeed": 9.33, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T17:53:00Z", + "values": { + "temperatureMin": 43.9, + "temperatureMax": 43.9, + "windSpeed": 9.31, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T17:58:00Z", + "values": { + "temperatureMin": 43.68, + "temperatureMax": 43.68, + "windSpeed": 9.28, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:03:00Z", + "values": { + "temperatureMin": 43.66, + "temperatureMax": 43.66, + "windSpeed": 9.26, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:08:00Z", + "values": { + "temperatureMin": 43.79, + "temperatureMax": 43.79, + "windSpeed": 9.22, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:13:00Z", + "values": { + "temperatureMin": 43.92, + "temperatureMax": 43.92, + "windSpeed": 9.17, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:18:00Z", + "values": { + "temperatureMin": 44.04, + "temperatureMax": 44.04, + "windSpeed": 9.13, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:23:00Z", + "values": { + "temperatureMin": 44.17, + "temperatureMax": 44.17, + "windSpeed": 9.06, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:28:00Z", + "values": { + "temperatureMin": 44.31, + "temperatureMax": 44.31, + "windSpeed": 9.02, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:33:00Z", + "values": { + "temperatureMin": 44.44, + "temperatureMax": 44.44, + "windSpeed": 8.97, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:38:00Z", + "values": { + "temperatureMin": 44.56, + "temperatureMax": 44.56, + "windSpeed": 8.93, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:43:00Z", + "values": { + "temperatureMin": 44.69, + "temperatureMax": 44.69, + "windSpeed": 8.88, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:48:00Z", + "values": { + "temperatureMin": 44.82, + "temperatureMax": 44.82, + "windSpeed": 8.84, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:53:00Z", + "values": { + "temperatureMin": 44.94, + "temperatureMax": 44.94, + "windSpeed": 8.79, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:58:00Z", + "values": { + "temperatureMin": 45.07, + "temperatureMax": 45.07, + "windSpeed": 8.75, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:03:00Z", + "values": { + "temperatureMin": 45.16, + "temperatureMax": 45.16, + "windSpeed": 8.75, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:08:00Z", + "values": { + "temperatureMin": 45.23, + "temperatureMax": 45.23, + "windSpeed": 8.75, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:13:00Z", + "values": { + "temperatureMin": 45.28, + "temperatureMax": 45.28, + "windSpeed": 8.77, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:18:00Z", + "values": { + "temperatureMin": 45.36, + "temperatureMax": 45.36, + "windSpeed": 8.79, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:23:00Z", + "values": { + "temperatureMin": 45.43, + "temperatureMax": 45.43, + "windSpeed": 8.81, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:28:00Z", + "values": { + "temperatureMin": 45.5, + "temperatureMax": 45.5, + "windSpeed": 8.81, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:33:00Z", + "values": { + "temperatureMin": 45.55, + "temperatureMax": 45.55, + "windSpeed": 8.84, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:38:00Z", + "values": { + "temperatureMin": 45.63, + "temperatureMax": 45.63, + "windSpeed": 8.86, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:43:00Z", + "values": { + "temperatureMin": 45.7, + "temperatureMax": 45.7, + "windSpeed": 8.88, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:48:00Z", + "values": { + "temperatureMin": 45.75, + "temperatureMax": 45.75, + "windSpeed": 8.9, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:53:00Z", + "values": { + "temperatureMin": 45.82, + "temperatureMax": 45.82, + "windSpeed": 8.9, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:58:00Z", + "values": { + "temperatureMin": 45.9, + "temperatureMax": 45.9, + "windSpeed": 8.93, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:03:00Z", + "values": { + "temperatureMin": 45.88, + "temperatureMax": 45.88, + "windSpeed": 8.97, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:08:00Z", + "values": { + "temperatureMin": 45.82, + "temperatureMax": 45.82, + "windSpeed": 9.02, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:13:00Z", + "values": { + "temperatureMin": 45.75, + "temperatureMax": 45.75, + "windSpeed": 9.06, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:18:00Z", + "values": { + "temperatureMin": 45.7, + "temperatureMax": 45.7, + "windSpeed": 9.1, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:23:00Z", + "values": { + "temperatureMin": 45.63, + "temperatureMax": 45.63, + "windSpeed": 9.15, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:28:00Z", + "values": { + "temperatureMin": 45.57, + "temperatureMax": 45.57, + "windSpeed": 9.19, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:33:00Z", + "values": { + "temperatureMin": 45.5, + "temperatureMax": 45.5, + "windSpeed": 9.24, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:38:00Z", + "values": { + "temperatureMin": 45.45, + "temperatureMax": 45.45, + "windSpeed": 9.28, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:43:00Z", + "values": { + "temperatureMin": 45.39, + "temperatureMax": 45.39, + "windSpeed": 9.33, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:48:00Z", + "values": { + "temperatureMin": 45.32, + "temperatureMax": 45.32, + "windSpeed": 9.37, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:53:00Z", + "values": { + "temperatureMin": 45.27, + "temperatureMax": 45.27, + "windSpeed": 9.42, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:58:00Z", + "values": { + "temperatureMin": 45.19, + "temperatureMax": 45.19, + "windSpeed": 9.46, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:03:00Z", + "values": { + "temperatureMin": 45.14, + "temperatureMax": 45.14, + "windSpeed": 9.4, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:08:00Z", + "values": { + "temperatureMin": 45.07, + "temperatureMax": 45.07, + "windSpeed": 9.24, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:13:00Z", + "values": { + "temperatureMin": 45.01, + "temperatureMax": 45.01, + "windSpeed": 9.08, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:18:00Z", + "values": { + "temperatureMin": 44.94, + "temperatureMax": 44.94, + "windSpeed": 8.95, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:23:00Z", + "values": { + "temperatureMin": 44.89, + "temperatureMax": 44.89, + "windSpeed": 8.79, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:28:00Z", + "values": { + "temperatureMin": 44.82, + "temperatureMax": 44.82, + "windSpeed": 8.63, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:33:00Z", + "values": { + "temperatureMin": 44.76, + "temperatureMax": 44.76, + "windSpeed": 8.5, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:38:00Z", + "values": { + "temperatureMin": 44.69, + "temperatureMax": 44.69, + "windSpeed": 8.34, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:43:00Z", + "values": { + "temperatureMin": 44.64, + "temperatureMax": 44.64, + "windSpeed": 8.19, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:48:00Z", + "values": { + "temperatureMin": 44.56, + "temperatureMax": 44.56, + "windSpeed": 8.05, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:53:00Z", + "values": { + "temperatureMin": 44.51, + "temperatureMax": 44.51, + "windSpeed": 7.9, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:58:00Z", + "values": { + "temperatureMin": 44.44, + "temperatureMax": 44.44, + "windSpeed": 7.74, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:03:00Z", + "values": { + "temperatureMin": 44.26, + "temperatureMax": 44.26, + "windSpeed": 7.47, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:08:00Z", + "values": { + "temperatureMin": 44.01, + "temperatureMax": 44.01, + "windSpeed": 7.14, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:13:00Z", + "values": { + "temperatureMin": 43.74, + "temperatureMax": 43.74, + "windSpeed": 6.78, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:18:00Z", + "values": { + "temperatureMin": 43.48, + "temperatureMax": 43.48, + "windSpeed": 6.44, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:23:00Z", + "values": { + "temperatureMin": 43.23, + "temperatureMax": 43.23, + "windSpeed": 6.08, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:28:00Z", + "values": { + "temperatureMin": 42.98, + "temperatureMax": 42.98, + "windSpeed": 5.75, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:33:00Z", + "values": { + "temperatureMin": 42.71, + "temperatureMax": 42.71, + "windSpeed": 5.39, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:38:00Z", + "values": { + "temperatureMin": 42.46, + "temperatureMax": 42.46, + "windSpeed": 5.06, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:43:00Z", + "values": { + "temperatureMin": 42.21, + "temperatureMax": 42.21, + "windSpeed": 4.7, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:48:00Z", + "values": { + "temperatureMin": 41.94, + "temperatureMax": 41.94, + "windSpeed": 4.36, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:53:00Z", + "values": { + "temperatureMin": 41.68, + "temperatureMax": 41.68, + "windSpeed": 4, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:58:00Z", + "values": { + "temperatureMin": 41.43, + "temperatureMax": 41.43, + "windSpeed": 3.67, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:03:00Z", + "values": { + "temperatureMin": 41.16, + "temperatureMax": 41.16, + "windSpeed": 3.6, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:08:00Z", + "values": { + "temperatureMin": 40.91, + "temperatureMax": 40.91, + "windSpeed": 3.76, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:13:00Z", + "values": { + "temperatureMin": 40.66, + "temperatureMax": 40.66, + "windSpeed": 3.91, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:18:00Z", + "values": { + "temperatureMin": 40.41, + "temperatureMax": 40.41, + "windSpeed": 4.05, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:23:00Z", + "values": { + "temperatureMin": 40.14, + "temperatureMax": 40.14, + "windSpeed": 4.21, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:28:00Z", + "values": { + "temperatureMin": 39.88, + "temperatureMax": 39.88, + "windSpeed": 4.36, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:33:00Z", + "values": { + "temperatureMin": 39.63, + "temperatureMax": 39.63, + "windSpeed": 4.5, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:38:00Z", + "values": { + "temperatureMin": 39.38, + "temperatureMax": 39.38, + "windSpeed": 4.65, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:43:00Z", + "values": { + "temperatureMin": 39.11, + "temperatureMax": 39.11, + "windSpeed": 4.79, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + } + ], + "hourly": [ + { + "startTime": "2021-03-07T17:48:00Z", + "values": { + "temperatureMin": 44.13, + "temperatureMax": 44.13, + "windSpeed": 9.33, + "windDirection": 315.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T18:48:00Z", + "values": { + "temperatureMin": 44.82, + "temperatureMax": 44.82, + "windSpeed": 8.84, + "windDirection": 321.71, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T19:48:00Z", + "values": { + "temperatureMin": 45.75, + "temperatureMax": 45.75, + "windSpeed": 8.9, + "windDirection": 323.38, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T20:48:00Z", + "values": { + "temperatureMin": 45.32, + "temperatureMax": 45.32, + "windSpeed": 9.37, + "windDirection": 318.43, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T21:48:00Z", + "values": { + "temperatureMin": 44.56, + "temperatureMax": 44.56, + "windSpeed": 8.05, + "windDirection": 320.9, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T22:48:00Z", + "values": { + "temperatureMin": 41.94, + "temperatureMax": 41.94, + "windSpeed": 4.36, + "windDirection": 322.11, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-07T23:48:00Z", + "values": { + "temperatureMin": 38.86, + "temperatureMax": 38.86, + "windSpeed": 4.94, + "windDirection": 295.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T00:48:00Z", + "values": { + "temperatureMin": 36.18, + "temperatureMax": 36.18, + "windSpeed": 5.59, + "windDirection": 11.94, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T01:48:00Z", + "values": { + "temperatureMin": 34.3, + "temperatureMax": 34.3, + "windSpeed": 5.57, + "windDirection": 13.68, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T02:48:00Z", + "values": { + "temperatureMin": 32.88, + "temperatureMax": 32.88, + "windSpeed": 5.41, + "windDirection": 14.93, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T03:48:00Z", + "values": { + "temperatureMin": 31.91, + "temperatureMax": 31.91, + "windSpeed": 4.61, + "windDirection": 26.07, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T04:48:00Z", + "values": { + "temperatureMin": 29.17, + "temperatureMax": 29.17, + "windSpeed": 2.59, + "windDirection": 51.27, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T05:48:00Z", + "values": { + "temperatureMin": 27.37, + "temperatureMax": 27.37, + "windSpeed": 3.31, + "windDirection": 343.25, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T06:48:00Z", + "values": { + "temperatureMin": 26.73, + "temperatureMax": 26.73, + "windSpeed": 4.27, + "windDirection": 341.46, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T07:48:00Z", + "values": { + "temperatureMin": 26.38, + "temperatureMax": 26.38, + "windSpeed": 3.53, + "windDirection": 322.34, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T08:48:00Z", + "values": { + "temperatureMin": 26.15, + "temperatureMax": 26.15, + "windSpeed": 3.65, + "windDirection": 294.69, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T09:48:00Z", + "values": { + "temperatureMin": 30.07, + "temperatureMax": 30.07, + "windSpeed": 3.2, + "windDirection": 325.32, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T10:48:00Z", + "values": { + "temperatureMin": 31.03, + "temperatureMax": 31.03, + "windSpeed": 2.84, + "windDirection": 322.27, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T11:48:00Z", + "values": { + "temperatureMin": 27.23, + "temperatureMax": 27.23, + "windSpeed": 5.59, + "windDirection": 310.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T12:48:00Z", + "values": { + "temperatureMin": 29.21, + "temperatureMax": 29.21, + "windSpeed": 7.05, + "windDirection": 324.8, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T13:48:00Z", + "values": { + "temperatureMin": 33.19, + "temperatureMax": 33.19, + "windSpeed": 6.46, + "windDirection": 335.16, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T14:48:00Z", + "values": { + "temperatureMin": 37.02, + "temperatureMax": 37.02, + "windSpeed": 5.88, + "windDirection": 324.49, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T15:48:00Z", + "values": { + "temperatureMin": 40.01, + "temperatureMax": 40.01, + "windSpeed": 5.55, + "windDirection": 310.68, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T16:48:00Z", + "values": { + "temperatureMin": 42.37, + "temperatureMax": 42.37, + "windSpeed": 5.46, + "windDirection": 304.18, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T17:48:00Z", + "values": { + "temperatureMin": 44.62, + "temperatureMax": 44.62, + "windSpeed": 4.99, + "windDirection": 301.19, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T18:48:00Z", + "values": { + "temperatureMin": 46.78, + "temperatureMax": 46.78, + "windSpeed": 4.72, + "windDirection": 295.05, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T19:48:00Z", + "values": { + "temperatureMin": 48.42, + "temperatureMax": 48.42, + "windSpeed": 4.81, + "windDirection": 287.4, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T20:48:00Z", + "values": { + "temperatureMin": 49.28, + "temperatureMax": 49.28, + "windSpeed": 4.74, + "windDirection": 282.48, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T21:48:00Z", + "values": { + "temperatureMin": 48.72, + "temperatureMax": 48.72, + "windSpeed": 2.51, + "windDirection": 268.74, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T22:48:00Z", + "values": { + "temperatureMin": 44.37, + "temperatureMax": 44.37, + "windSpeed": 3.56, + "windDirection": 180.04, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T23:48:00Z", + "values": { + "temperatureMin": 39.9, + "temperatureMax": 39.9, + "windSpeed": 4.68, + "windDirection": 177.89, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T00:48:00Z", + "values": { + "temperatureMin": 37.87, + "temperatureMax": 37.87, + "windSpeed": 5.21, + "windDirection": 197.47, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T01:48:00Z", + "values": { + "temperatureMin": 36.91, + "temperatureMax": 36.91, + "windSpeed": 5.46, + "windDirection": 209.77, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T02:48:00Z", + "values": { + "temperatureMin": 36.64, + "temperatureMax": 36.64, + "windSpeed": 6.11, + "windDirection": 210.14, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T03:48:00Z", + "values": { + "temperatureMin": 36.63, + "temperatureMax": 36.63, + "windSpeed": 6.4, + "windDirection": 216, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T04:48:00Z", + "values": { + "temperatureMin": 36.23, + "temperatureMax": 36.23, + "windSpeed": 6.22, + "windDirection": 223.92, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T05:48:00Z", + "values": { + "temperatureMin": 35.58, + "temperatureMax": 35.58, + "windSpeed": 5.75, + "windDirection": 229.68, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T06:48:00Z", + "values": { + "temperatureMin": 34.68, + "temperatureMax": 34.68, + "windSpeed": 5.21, + "windDirection": 235.24, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T07:48:00Z", + "values": { + "temperatureMin": 33.69, + "temperatureMax": 33.69, + "windSpeed": 4.81, + "windDirection": 237.24, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T08:48:00Z", + "values": { + "temperatureMin": 32.74, + "temperatureMax": 32.74, + "windSpeed": 4.52, + "windDirection": 239.35, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T09:48:00Z", + "values": { + "temperatureMin": 32.05, + "temperatureMax": 32.05, + "windSpeed": 4.32, + "windDirection": 245.68, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T10:48:00Z", + "values": { + "temperatureMin": 31.57, + "temperatureMax": 31.57, + "windSpeed": 4.14, + "windDirection": 248.11, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T11:48:00Z", + "values": { + "temperatureMin": 32.92, + "temperatureMax": 32.92, + "windSpeed": 4.32, + "windDirection": 249.54, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T12:48:00Z", + "values": { + "temperatureMin": 38.5, + "temperatureMax": 38.5, + "windSpeed": 4.7, + "windDirection": 253.3, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T13:48:00Z", + "values": { + "temperatureMin": 46.08, + "temperatureMax": 46.08, + "windSpeed": 4.41, + "windDirection": 258.49, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T14:48:00Z", + "values": { + "temperatureMin": 53.26, + "temperatureMax": 53.26, + "windSpeed": 4.9, + "windDirection": 260.49, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T15:48:00Z", + "values": { + "temperatureMin": 58.15, + "temperatureMax": 58.15, + "windSpeed": 5.55, + "windDirection": 261.29, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T16:48:00Z", + "values": { + "temperatureMin": 61.56, + "temperatureMax": 61.56, + "windSpeed": 6.35, + "windDirection": 264.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T17:48:00Z", + "values": { + "temperatureMin": 64, + "temperatureMax": 64, + "windSpeed": 6.6, + "windDirection": 257.54, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T18:48:00Z", + "values": { + "temperatureMin": 65.79, + "temperatureMax": 65.79, + "windSpeed": 6.96, + "windDirection": 253.12, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T19:48:00Z", + "values": { + "temperatureMin": 66.74, + "temperatureMax": 66.74, + "windSpeed": 6.8, + "windDirection": 259.46, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T20:48:00Z", + "values": { + "temperatureMin": 66.96, + "temperatureMax": 66.96, + "windSpeed": 6.33, + "windDirection": 294.25, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T21:48:00Z", + "values": { + "temperatureMin": 64.35, + "temperatureMax": 64.35, + "windSpeed": 3.91, + "windDirection": 279.37, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T22:48:00Z", + "values": { + "temperatureMin": 61.07, + "temperatureMax": 61.07, + "windSpeed": 3.65, + "windDirection": 218.19, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T23:48:00Z", + "values": { + "temperatureMin": 56.3, + "temperatureMax": 56.3, + "windSpeed": 4.09, + "windDirection": 208.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T00:48:00Z", + "values": { + "temperatureMin": 53.19, + "temperatureMax": 53.19, + "windSpeed": 4.21, + "windDirection": 216.42, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T01:48:00Z", + "values": { + "temperatureMin": 51.94, + "temperatureMax": 51.94, + "windSpeed": 3.38, + "windDirection": 257.19, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T02:48:00Z", + "values": { + "temperatureMin": 49.82, + "temperatureMax": 49.82, + "windSpeed": 2.71, + "windDirection": 288.85, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T03:48:00Z", + "values": { + "temperatureMin": 48.24, + "temperatureMax": 48.24, + "windSpeed": 2.8, + "windDirection": 334.41, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T04:48:00Z", + "values": { + "temperatureMin": 47.44, + "temperatureMax": 47.44, + "windSpeed": 2.26, + "windDirection": 342.01, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T05:48:00Z", + "values": { + "temperatureMin": 45.59, + "temperatureMax": 45.59, + "windSpeed": 2.35, + "windDirection": 2.43, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T06:48:00Z", + "values": { + "temperatureMin": 43.43, + "temperatureMax": 43.43, + "windSpeed": 2.3, + "windDirection": 336.56, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T07:48:00Z", + "values": { + "temperatureMin": 41.11, + "temperatureMax": 41.11, + "windSpeed": 2.71, + "windDirection": 4.41, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T08:48:00Z", + "values": { + "temperatureMin": 39.58, + "temperatureMax": 39.58, + "windSpeed": 3.4, + "windDirection": 21.26, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T09:48:00Z", + "values": { + "temperatureMin": 39.85, + "temperatureMax": 39.85, + "windSpeed": 3.31, + "windDirection": 22.76, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T10:48:00Z", + "values": { + "temperatureMin": 37.85, + "temperatureMax": 37.85, + "windSpeed": 4.03, + "windDirection": 29.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T11:48:00Z", + "values": { + "temperatureMin": 38.97, + "temperatureMax": 38.97, + "windSpeed": 3.15, + "windDirection": 21.82, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T12:48:00Z", + "values": { + "temperatureMin": 44.31, + "temperatureMax": 44.31, + "windSpeed": 3.53, + "windDirection": 14.25, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T13:48:00Z", + "values": { + "temperatureMin": 50.25, + "temperatureMax": 50.25, + "windSpeed": 2.82, + "windDirection": 42.41, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T14:48:00Z", + "values": { + "temperatureMin": 54.97, + "temperatureMax": 54.97, + "windSpeed": 2.53, + "windDirection": 87.81, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T15:48:00Z", + "values": { + "temperatureMin": 58.46, + "temperatureMax": 58.46, + "windSpeed": 3.09, + "windDirection": 125.82, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T16:48:00Z", + "values": { + "temperatureMin": 61.21, + "temperatureMax": 61.21, + "windSpeed": 4.03, + "windDirection": 157.54, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T17:48:00Z", + "values": { + "temperatureMin": 63.36, + "temperatureMax": 63.36, + "windSpeed": 5.21, + "windDirection": 166.66, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T18:48:00Z", + "values": { + "temperatureMin": 64.83, + "temperatureMax": 64.83, + "windSpeed": 6.93, + "windDirection": 189.24, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T19:48:00Z", + "values": { + "temperatureMin": 65.23, + "temperatureMax": 65.23, + "windSpeed": 8.95, + "windDirection": 194.58, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T20:48:00Z", + "values": { + "temperatureMin": 64.98, + "temperatureMax": 64.98, + "windSpeed": 9.4, + "windDirection": 193.22, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T21:48:00Z", + "values": { + "temperatureMin": 64.06, + "temperatureMax": 64.06, + "windSpeed": 8.55, + "windDirection": 186.39, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T22:48:00Z", + "values": { + "temperatureMin": 61.9, + "temperatureMax": 61.9, + "windSpeed": 7.49, + "windDirection": 171.81, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T23:48:00Z", + "values": { + "temperatureMin": 59.4, + "temperatureMax": 59.4, + "windSpeed": 7.54, + "windDirection": 165.51, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T00:48:00Z", + "values": { + "temperatureMin": 57.63, + "temperatureMax": 57.63, + "windSpeed": 8.12, + "windDirection": 171.94, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T01:48:00Z", + "values": { + "temperatureMin": 56.17, + "temperatureMax": 56.17, + "windSpeed": 8.7, + "windDirection": 176.84, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T02:48:00Z", + "values": { + "temperatureMin": 55.36, + "temperatureMax": 55.36, + "windSpeed": 9.42, + "windDirection": 184.14, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T03:48:00Z", + "values": { + "temperatureMin": 54.88, + "temperatureMax": 54.88, + "windSpeed": 10, + "windDirection": 195.54, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T04:48:00Z", + "values": { + "temperatureMin": 54.14, + "temperatureMax": 54.14, + "windSpeed": 10.4, + "windDirection": 200.56, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T05:48:00Z", + "values": { + "temperatureMin": 53.46, + "temperatureMax": 53.46, + "windSpeed": 10.04, + "windDirection": 198.08, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T06:48:00Z", + "values": { + "temperatureMin": 52.11, + "temperatureMax": 52.11, + "windSpeed": 10.02, + "windDirection": 199.54, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T07:48:00Z", + "values": { + "temperatureMin": 51.64, + "temperatureMax": 51.64, + "windSpeed": 10.51, + "windDirection": 202.73, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T08:48:00Z", + "values": { + "temperatureMin": 50.79, + "temperatureMax": 50.79, + "windSpeed": 10.38, + "windDirection": 203.35, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T09:48:00Z", + "values": { + "temperatureMin": 49.93, + "temperatureMax": 49.93, + "windSpeed": 9.51, + "windDirection": 210.36, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T10:48:00Z", + "values": { + "temperatureMin": 49.1, + "temperatureMax": 49.1, + "windSpeed": 8.61, + "windDirection": 210.6, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T11:48:00Z", + "values": { + "temperatureMin": 48.42, + "temperatureMax": 48.42, + "windSpeed": 9.15, + "windDirection": 211.29, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T12:48:00Z", + "values": { + "temperatureMin": 48.9, + "temperatureMax": 48.9, + "windSpeed": 10.25, + "windDirection": 215.59, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T13:48:00Z", + "values": { + "temperatureMin": 50.54, + "temperatureMax": 50.54, + "windSpeed": 10.18, + "windDirection": 215.48, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T14:48:00Z", + "values": { + "temperatureMin": 53.19, + "temperatureMax": 53.19, + "windSpeed": 9.4, + "windDirection": 208.76, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T15:48:00Z", + "values": { + "temperatureMin": 56.19, + "temperatureMax": 56.19, + "windSpeed": 9.73, + "windDirection": 197.59, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T16:48:00Z", + "values": { + "temperatureMin": 59.34, + "temperatureMax": 59.34, + "windSpeed": 10.69, + "windDirection": 204.29, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T17:48:00Z", + "values": { + "temperatureMin": 62.35, + "temperatureMax": 62.35, + "windSpeed": 11.81, + "windDirection": 204.56, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T18:48:00Z", + "values": { + "temperatureMin": 64.6, + "temperatureMax": 64.6, + "windSpeed": 13.09, + "windDirection": 206.85, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T19:48:00Z", + "values": { + "temperatureMin": 65.91, + "temperatureMax": 65.91, + "windSpeed": 13.82, + "windDirection": 204.82, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T20:48:00Z", + "values": { + "temperatureMin": 66.22, + "temperatureMax": 66.22, + "windSpeed": 14.54, + "windDirection": 208.43, + "weatherCode": 1100, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T21:48:00Z", + "values": { + "temperatureMin": 65.46, + "temperatureMax": 65.46, + "windSpeed": 13.2, + "windDirection": 208.3, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T22:48:00Z", + "values": { + "temperatureMin": 64.35, + "temperatureMax": 64.35, + "windSpeed": 12.35, + "windDirection": 208.58, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T23:48:00Z", + "values": { + "temperatureMin": 62.85, + "temperatureMax": 62.85, + "windSpeed": 12.86, + "windDirection": 205.39, + "weatherCode": 1101, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T00:48:00Z", + "values": { + "temperatureMin": 61.75, + "temperatureMax": 61.75, + "windSpeed": 14.7, + "windDirection": 209.51, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T01:48:00Z", + "values": { + "temperatureMin": 61.2, + "temperatureMax": 61.2, + "windSpeed": 15.57, + "windDirection": 211.47, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T02:48:00Z", + "values": { + "temperatureMin": 60.46, + "temperatureMax": 60.46, + "windSpeed": 14.94, + "windDirection": 211.57, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T03:48:00Z", + "values": { + "temperatureMin": 59.94, + "temperatureMax": 59.94, + "windSpeed": 14.29, + "windDirection": 208.93, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T04:48:00Z", + "values": { + "temperatureMin": 59.52, + "temperatureMax": 59.52, + "windSpeed": 14.36, + "windDirection": 217.91, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + } + ], + "daily": [ + { + "startTime": "2021-03-07T11:00:00Z", + "values": { + "temperatureMin": 26.11, + "temperatureMax": 45.93, + "windSpeed": 9.49, + "windDirection": 239.6, + "weatherCode": 1000, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-08T11:00:00Z", + "values": { + "temperatureMin": 26.28, + "temperatureMax": 49.42, + "windSpeed": 7.24, + "windDirection": 262.82, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-09T11:00:00Z", + "values": { + "temperatureMin": 31.48, + "temperatureMax": 66.98, + "windSpeed": 7.05, + "windDirection": 229.3, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-10T11:00:00Z", + "values": { + "temperatureMin": 37.32, + "temperatureMax": 65.28, + "windSpeed": 10.64, + "windDirection": 149.91, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-11T11:00:00Z", + "values": { + "temperatureMin": 48.29, + "temperatureMax": 66.25, + "windSpeed": 15.69, + "windDirection": 210.45, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-12T11:00:00Z", + "values": { + "temperatureMin": 53.83, + "temperatureMax": 67.91, + "windSpeed": 12.3, + "windDirection": 217.98, + "weatherCode": 4000, + "precipitationIntensityAvg": 0.0002, + "precipitationProbability": 25 + } + }, + { + "startTime": "2021-03-13T11:00:00Z", + "values": { + "temperatureMin": 42.91, + "temperatureMax": 54.48, + "windSpeed": 9.72, + "windDirection": 58.79, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 25 + } + }, + { + "startTime": "2021-03-14T10:00:00Z", + "values": { + "temperatureMin": 33.35, + "temperatureMax": 42.91, + "windSpeed": 16.25, + "windDirection": 70.25, + "weatherCode": 5101, + "precipitationIntensityAvg": 0.0393, + "precipitationProbability": 95 + } + }, + { + "startTime": "2021-03-15T10:00:00Z", + "values": { + "temperatureMin": 29.35, + "temperatureMax": 43.67, + "windSpeed": 15.89, + "windDirection": 84.47, + "weatherCode": 5001, + "precipitationIntensityAvg": 0.0024, + "precipitationProbability": 55 + } + }, + { + "startTime": "2021-03-16T10:00:00Z", + "values": { + "temperatureMin": 29.1, + "temperatureMax": 43, + "windSpeed": 6.71, + "windDirection": 103.85, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-17T10:00:00Z", + "values": { + "temperatureMin": 34.32, + "temperatureMax": 52.4, + "windSpeed": 7.27, + "windDirection": 145.41, + "weatherCode": 1102, + "precipitationIntensityAvg": 0, + "precipitationProbability": 0 + } + }, + { + "startTime": "2021-03-18T10:00:00Z", + "values": { + "temperatureMin": 41.32, + "temperatureMax": 54.07, + "windSpeed": 6.58, + "windDirection": 62.99, + "weatherCode": 1001, + "precipitationIntensityAvg": 0, + "precipitationProbability": 10 + } + }, + { + "startTime": "2021-03-19T10:00:00Z", + "values": { + "temperatureMin": 39.4, + "temperatureMax": 48.94, + "windSpeed": 13.91, + "windDirection": 68.54, + "weatherCode": 4000, + "precipitationIntensityAvg": 0.0048, + "precipitationProbability": 55 + } + }, + { + "startTime": "2021-03-20T10:00:00Z", + "values": { + "temperatureMin": 35.06, + "temperatureMax": 40.12, + "windSpeed": 17.35, + "windDirection": 56.98, + "weatherCode": 5001, + "precipitationIntensityAvg": 0.002, + "precipitationProbability": 33.3 + } + }, + { + "startTime": "2021-03-21T10:00:00Z", + "values": { + "temperatureMin": 33.66, + "temperatureMax": 66.54, + "windSpeed": 15.93, + "windDirection": 82.57, + "weatherCode": 5001, + "precipitationIntensityAvg": 0.0004, + "precipitationProbability": 45 + } + } + ] + } + } From f23c234cf2e0ca22571ee8dec17140903466bc53 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Oct 2021 12:04:27 -0400 Subject: [PATCH 02/18] remove unused code --- .../components/tomorrowio/__init__.py | 75 +------------------ 1 file changed, 1 insertion(+), 74 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 25db24f068a017..22862ec4102379 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -17,10 +17,9 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -49,7 +48,6 @@ CONF_TIMESTEP, DEFAULT_TIMESTEP, DOMAIN, - INTEGRATION_NAME, MAX_REQUESTS_PER_DAY, ) @@ -92,77 +90,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tomorrow.io API from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # Let's precreate the device so that if this is a first time setup for a config - # entry imported from a ClimaCell entry, we can apply customizations from the old - # device. - dev_reg = dr.async_get(hass) - device = dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.data[CONF_API_KEY])}, - name=INTEGRATION_NAME, - manufacturer=INTEGRATION_NAME, - sw_version="v4", - entry_type="service", - ) - - # If this is an import and we still have the old config entry ID in the entry data, - # it means we are setting this entry up for the first time after a migration from - # ClimaCell to Tomorrow.io. In order to preserve any customizations on the ClimaCell - # entities, we need to remove each old entity, creating a new entity in its place - # but attached to this entry. - if entry.source == SOURCE_IMPORT and "old_config_entry_id" in entry.data: - # Remove the old config entry ID from the entry data so we don't try this again - # on the next setup - data = entry.data.copy() - old_config_entry_id = data.pop("old_config_entry_id") - hass.config_entries.async_update_entry(entry, data=data) - _LOGGER.debug( - ( - "Setting up imported climacell entry %s for the first time as " - "tomorrowio entry %s" - ), - old_config_entry_id, - entry.entry_id, - ) - - ent_reg = er.async_get(hass) - for entity in er.async_entries_for_config_entry(ent_reg, old_config_entry_id): - _LOGGER.debug("Removing %s", entity.entity_id) - ent_reg.async_remove(entity.entity_id) - # In case the API key has changed due to a V3 -> V4 change, we need to - # generate the new entity's unique ID - new_unique_id = f"{entry.data[CONF_API_KEY]}_{'_'.join(entity.unique_id.split('_')[1:])}" - _LOGGER.debug("Re-creating %s for the new config entry", entity.entity_id) - # We will precreate the entity so that any customizations can be preserved - new_entity = ent_reg.async_get_or_create( - entity.domain, - DOMAIN, - new_unique_id, - suggested_object_id=entity.entity_id.split(".")[1], - disabled_by=entity.disabled_by, - config_entry=entry, - original_name=entity.original_name, - original_icon=entity.original_icon, - ) - _LOGGER.debug("Re-created %s", new_entity.entity_id) - # If there are customizations on the old entity, apply them to the new one - if entity.name or entity.icon: - ent_reg.async_update_entity( - new_entity.entity_id, name=entity.name, icon=entity.icon - ) - - # We only have one device in the registry but we will do a loop just in case - for old_device in dr.async_entries_for_config_entry( - dev_reg, old_config_entry_id - ): - if old_device.name_by_user: - dev_reg.async_update_device( - device.id, name_by_user=old_device.name_by_user - ) - - # Remove the old config entry and now the entry is fully migrated - hass.async_create_task(hass.config_entries.async_remove(old_config_entry_id)) - # If config entry options not set up, set them up if not entry.options: hass.config_entries.async_update_entry( From 1abeeb558953deed156031abbca083f389408ccb Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Oct 2021 12:06:14 -0400 Subject: [PATCH 03/18] remove extra test --- tests/components/tomorrowio/test_init.py | 119 +---------------------- 1 file changed, 1 insertion(+), 118 deletions(-) diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index 9dab388704bdc5..2f16f7bff0f317 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -3,22 +3,18 @@ import pytest -from homeassistant.components.climacell.const import DOMAIN as CC_DOMAIN from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, _get_unique_id, ) from homeassistant.components.tomorrowio.const import DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_API_VERSION +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import MIN_CONFIG from tests.common import MockConfigEntry -from tests.components.climacell.const import API_V3_ENTRY_DATA _LOGGER = logging.getLogger(__name__) @@ -42,116 +38,3 @@ async def test_load_and_unload( assert await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 - - -async def test_climacell_migration_logic( - hass: HomeAssistant, - tomorrowio_config_entry_update: pytest.fixture, - climacell_config_entry_update: pytest.fixture, -) -> None: - """Test that climacell config entry is properly migrated.""" - old_data = API_V3_ENTRY_DATA.copy() - old_data[CONF_API_KEY] = "v3apikey" - old_config_entry = MockConfigEntry( - domain=CC_DOMAIN, - data=old_data, - unique_id=_get_unique_id(hass, old_data), - version=1, - ) - old_config_entry.add_to_hass(hass) - # Let's create a device and update its name - dev_reg = dr.async_get(hass) - old_device = dev_reg.async_get_or_create( - config_entry_id=old_config_entry.entry_id, - identifiers={(CC_DOMAIN, old_data[CONF_API_KEY])}, - manufacturer="ClimaCell", - sw_version="v4", - entry_type="service", - name="ClimaCell", - ) - dev_reg.async_update_device(old_device.id, name_by_user=NEW_NAME) - # Now let's create some entity and update some things to see if everything migrates - # over - ent_reg = er.async_get(hass) - old_entity_daily = ent_reg.async_get_or_create( - "weather", - CC_DOMAIN, - f"{_get_unique_id(hass, old_data)}_daily", - config_entry=old_config_entry, - original_name="ClimaCell - Daily", - suggested_object_id="climacell_daily", - device_id=old_device.id, - ) - old_entity_hourly = ent_reg.async_get_or_create( - "weather", - CC_DOMAIN, - f"{_get_unique_id(hass, old_data)}_hourly", - config_entry=old_config_entry, - original_name="ClimaCell - Hourly", - suggested_object_id="climacell_hourly", - device_id=old_device.id, - disabled_by=er.DISABLED_USER, - ) - old_entity_nowcast = ent_reg.async_get_or_create( - "weather", - CC_DOMAIN, - f"{_get_unique_id(hass, old_data)}_nowcast", - config_entry=old_config_entry, - original_name="ClimaCell - Nowcast", - suggested_object_id="climacell_nowcast", - device_id=old_device.id, - ) - ent_reg.async_update_entity(old_entity_daily.entity_id, name=NEW_NAME) - - # Now let's create a new tomorrowio config entry that is supposedly created from a - # climacell import and see what happens - we are also changing the API key to ensure - # that things work as expected - new_data = API_V3_ENTRY_DATA.copy() - new_data[CONF_API_VERSION] = 4 - new_data["old_config_entry_id"] = old_config_entry.entry_id - config_entry = MockConfigEntry( - domain=DOMAIN, - data=new_data, - unique_id=_get_unique_id(hass, new_data), - version=1, - source=SOURCE_IMPORT, - ) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - # Check that the old device no longer exists - assert dev_reg.async_get(old_device.id) is None - - # Check that the new device was created and that it has the correct name - assert ( - dr.async_entries_for_config_entry(dev_reg, config_entry.entry_id)[ - 0 - ].name_by_user - == NEW_NAME - ) - - # Check that the new entities match the old ones (minus the default name) - new_entity_daily = ent_reg.async_get(old_entity_daily.entity_id) - assert new_entity_daily.platform == DOMAIN - assert new_entity_daily.name == NEW_NAME - assert new_entity_daily.original_name == "ClimaCell - Daily" - assert new_entity_daily.device_id != old_device.id - assert new_entity_daily.unique_id == f"{_get_unique_id(hass, new_data)}_daily" - assert new_entity_daily.disabled_by is None - - new_entity_hourly = ent_reg.async_get(old_entity_hourly.entity_id) - assert new_entity_hourly.platform == DOMAIN - assert new_entity_hourly.name is None - assert new_entity_hourly.original_name == "ClimaCell - Hourly" - assert new_entity_hourly.device_id != old_device.id - assert new_entity_hourly.unique_id == f"{_get_unique_id(hass, new_data)}_hourly" - assert new_entity_hourly.disabled_by == er.DISABLED_USER - - new_entity_nowcast = ent_reg.async_get(old_entity_nowcast.entity_id) - assert new_entity_nowcast.platform == DOMAIN - assert new_entity_nowcast.name is None - assert new_entity_nowcast.original_name == "ClimaCell - Nowcast" - assert new_entity_nowcast.device_id != old_device.id - assert new_entity_nowcast.unique_id == f"{_get_unique_id(hass, new_data)}_nowcast" - assert new_entity_nowcast.disabled_by is None From de93169d99e782beb4e559ad78f0651b2d0842e5 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Oct 2021 12:22:37 -0400 Subject: [PATCH 04/18] remove more unused code --- homeassistant/components/tomorrowio/config_flow.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 2c55644b834af8..7cb8e24fcb0205 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -110,11 +110,6 @@ class TomorrowioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self) -> None: - """Initialize config flow.""" - self._showed_import_message = 0 - self._import_config: dict[str, Any] | None = None - @staticmethod @callback def async_get_options_flow( @@ -127,10 +122,6 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult """Handle the initial step.""" errors = {} if user_input is not None: - # Grab the API key and add it to the rest of the config before continuing - if self._import_config: - self._import_config[CONF_API_KEY] = user_input[CONF_API_KEY] - user_input = self._import_config.copy() await self.async_set_unique_id( unique_id=_get_unique_id(self.hass, user_input) ) From 5ff7657a4b6a79974a76da2142fea0c8f1e27f01 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Oct 2021 15:44:42 -0400 Subject: [PATCH 05/18] Remove even more unused code --- homeassistant/components/tomorrowio/config_flow.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 7cb8e24fcb0205..99f79c09099465 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -1,7 +1,6 @@ """Config flow for Tomorrow.io integration.""" from __future__ import annotations -from collections.abc import Mapping import logging from typing import Any @@ -135,19 +134,8 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult session=async_get_clientsession(self.hass), ).realtime([CC_ATTR_TEMPERATURE]) - options: Mapping[str, Any] = {} - # Store the old config entry ID and retrieve options to recreate the entry - if self.source == config_entries.SOURCE_IMPORT: - old_config_entry_id = self.context["old_config_entry_id"] - old_config_entry = self.hass.config_entries.async_get_entry( - old_config_entry_id - ) - assert old_config_entry - options = dict(old_config_entry.options) - user_input["old_config_entry_id"] = old_config_entry_id - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input, options=options + title=user_input[CONF_NAME], data=user_input ) except CantConnectException: errors["base"] = "cannot_connect" From 7ce4b77093da32f477d6b2d8222c97e6fe22822c Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Thu, 7 Oct 2021 13:06:42 -0400 Subject: [PATCH 06/18] Feedback --- .../components/tomorrowio/__init__.py | 81 ++--- .../components/tomorrowio/config_flow.py | 8 +- homeassistant/components/tomorrowio/const.py | 329 ++---------------- homeassistant/components/tomorrowio/sensor.py | 261 +++++++++++++- .../components/tomorrowio/weather.py | 122 ++----- tests/components/tomorrowio/conftest.py | 9 - tests/components/tomorrowio/test_const.py | 14 - tests/components/tomorrowio/test_sensor.py | 12 +- tests/components/tomorrowio/test_weather.py | 11 +- 9 files changed, 376 insertions(+), 471 deletions(-) delete mode 100644 tests/components/tomorrowio/test_const.py diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 22862ec4102379..b2001e6370bf54 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -29,27 +29,25 @@ from .const import ( ATTRIBUTION, - CC_ATTR_CLOUD_COVER, - CC_ATTR_CONDITION, - CC_ATTR_HUMIDITY, - CC_ATTR_OZONE, - CC_ATTR_PRECIPITATION, - CC_ATTR_PRECIPITATION_PROBABILITY, - CC_ATTR_PRECIPITATION_TYPE, - CC_ATTR_PRESSURE, - CC_ATTR_TEMPERATURE, - CC_ATTR_TEMPERATURE_HIGH, - CC_ATTR_TEMPERATURE_LOW, - CC_ATTR_VISIBILITY, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_WIND_GUST, - CC_ATTR_WIND_SPEED, - CC_SENSOR_TYPES, - CONF_TIMESTEP, - DEFAULT_TIMESTEP, DOMAIN, MAX_REQUESTS_PER_DAY, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_CONDITION, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_WIND_SPEED, ) +from .sensor import SENSOR_TYPES _LOGGER = logging.getLogger(__name__) @@ -90,15 +88,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tomorrow.io API from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # If config entry options not set up, set them up - if not entry.options: - hass.config_entries.async_update_entry( - entry, - options={ - CONF_TIMESTEP: DEFAULT_TIMESTEP, - }, - ) - api = TomorrowioV4( entry.data[CONF_API_KEY], entry.data[CONF_LATITUDE], @@ -164,27 +153,27 @@ async def _async_update_data(self) -> dict[str, Any]: try: return await self._api.realtime_and_all_forecasts( [ - CC_ATTR_TEMPERATURE, - CC_ATTR_HUMIDITY, - CC_ATTR_PRESSURE, - CC_ATTR_WIND_SPEED, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_CONDITION, - CC_ATTR_VISIBILITY, - CC_ATTR_OZONE, - CC_ATTR_WIND_GUST, - CC_ATTR_CLOUD_COVER, - CC_ATTR_PRECIPITATION_TYPE, - *(sensor_type.key for sensor_type in CC_SENSOR_TYPES), + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_PRECIPITATION_TYPE, + *(sensor_type.key for sensor_type in SENSOR_TYPES), ], [ - CC_ATTR_TEMPERATURE_LOW, - CC_ATTR_TEMPERATURE_HIGH, - CC_ATTR_WIND_SPEED, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_CONDITION, - CC_ATTR_PRECIPITATION, - CC_ATTR_PRECIPITATION_PROBABILITY, + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, ], ) except ( diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 99f79c09099465..9a76beca93d432 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -20,11 +20,11 @@ import homeassistant.helpers.config_validation as cv from .const import ( - CC_ATTR_TEMPERATURE, CONF_TIMESTEP, DEFAULT_NAME, DEFAULT_TIMESTEP, DOMAIN, + TMRW_ATTR_TEMPERATURE, ) _LOGGER = logging.getLogger(__name__) @@ -132,10 +132,12 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult str(user_input.get(CONF_LATITUDE, self.hass.config.latitude)), str(user_input.get(CONF_LONGITUDE, self.hass.config.longitude)), session=async_get_clientsession(self.hass), - ).realtime([CC_ATTR_TEMPERATURE]) + ).realtime([TMRW_ATTR_TEMPERATURE]) return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=user_input[CONF_NAME], + data=user_input, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, ) except CantConnectException: errors["base"] = "cannot_connect" diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py index b06449881b68a3..b1627de1f6d26c 100644 --- a/homeassistant/components/tomorrowio/const.py +++ b/homeassistant/components/tomorrowio/const.py @@ -1,21 +1,8 @@ """Constants for the Tomorrow.io integration.""" from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Callable +from pytomorrowio.const import DAILY, HOURLY, NOWCAST, WeatherCode -from pytomorrowio.const import ( - DAILY, - HOURLY, - NOWCAST, - HealthConcernType, - PollenIndex, - PrecipitationType, - PrimaryPollutantType, - WeatherCode, -) - -from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -30,30 +17,6 @@ ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ) -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_BILLION, - CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_CO, - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, - IRRADIATION_WATTS_PER_SQUARE_METER, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - PERCENTAGE, - PRESSURE_HPA, - PRESSURE_INHG, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.util.distance import convert as distance_convert -from homeassistant.util.pressure import convert as pressure_convert -from homeassistant.util.temperature import convert as temp_convert CONF_TIMESTEP = "timestep" FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] @@ -109,258 +72,42 @@ } # Weather constants -CC_ATTR_TIMESTAMP = "startTime" -CC_ATTR_TEMPERATURE = "temperature" -CC_ATTR_TEMPERATURE_HIGH = "temperatureMax" -CC_ATTR_TEMPERATURE_LOW = "temperatureMin" -CC_ATTR_PRESSURE = "pressureSeaLevel" -CC_ATTR_HUMIDITY = "humidity" -CC_ATTR_WIND_SPEED = "windSpeed" -CC_ATTR_WIND_DIRECTION = "windDirection" -CC_ATTR_OZONE = "pollutantO3" -CC_ATTR_CONDITION = "weatherCode" -CC_ATTR_VISIBILITY = "visibility" -CC_ATTR_PRECIPITATION = "precipitationIntensityAvg" -CC_ATTR_PRECIPITATION_PROBABILITY = "precipitationProbability" -CC_ATTR_WIND_GUST = "windGust" -CC_ATTR_CLOUD_COVER = "cloudCover" -CC_ATTR_PRECIPITATION_TYPE = "precipitationType" +TMRW_ATTR_TIMESTAMP = "startTime" +TMRW_ATTR_TEMPERATURE = "temperature" +TMRW_ATTR_TEMPERATURE_HIGH = "temperatureMax" +TMRW_ATTR_TEMPERATURE_LOW = "temperatureMin" +TMRW_ATTR_PRESSURE = "pressureSeaLevel" +TMRW_ATTR_HUMIDITY = "humidity" +TMRW_ATTR_WIND_SPEED = "windSpeed" +TMRW_ATTR_WIND_DIRECTION = "windDirection" +TMRW_ATTR_OZONE = "pollutantO3" +TMRW_ATTR_CONDITION = "weatherCode" +TMRW_ATTR_VISIBILITY = "visibility" +TMRW_ATTR_PRECIPITATION = "precipitationIntensityAvg" +TMRW_ATTR_PRECIPITATION_PROBABILITY = "precipitationProbability" +TMRW_ATTR_WIND_GUST = "windGust" +TMRW_ATTR_CLOUD_COVER = "cloudCover" +TMRW_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_ATTR_FEELS_LIKE = "temperatureApparent" -CC_ATTR_DEW_POINT = "dewPoint" -CC_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" -CC_ATTR_SOLAR_GHI = "solarGHI" -CC_ATTR_CLOUD_BASE = "cloudBase" -CC_ATTR_CLOUD_CEILING = "cloudCeiling" - - -@dataclass -class TomorrowioSensorEntityDescription(SensorEntityDescription): - """Describes a Tomorrow.io sensor entity.""" - - unit_imperial: str | None = None - unit_metric: str | None = None - metric_conversion: Callable[[float], float] | float = 1.0 - is_metric_check: bool | None = None - device_class: str | None = None - value_map: Any | None = None - - def __post_init__(self) -> None: - """Post initialization.""" - units = (self.unit_imperial, self.unit_metric) - if any(u is not None for u in units) and any(u is None for u in units): - raise RuntimeError( - "`unit_imperial` and `unit_metric` both need to be None or both need " - "to be defined." - ) - - -CC_SENSOR_TYPES = ( - TomorrowioSensorEntityDescription( - key=CC_ATTR_FEELS_LIKE, - name="Feels Like", - unit_imperial=TEMP_FAHRENHEIT, - unit_metric=TEMP_CELSIUS, - metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), - is_metric_check=True, - device_class=DEVICE_CLASS_TEMPERATURE, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_DEW_POINT, - name="Dew Point", - unit_imperial=TEMP_FAHRENHEIT, - unit_metric=TEMP_CELSIUS, - metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), - is_metric_check=True, - device_class=DEVICE_CLASS_TEMPERATURE, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_PRESSURE_SURFACE_LEVEL, - name="Pressure (Surface Level)", - unit_imperial=PRESSURE_INHG, - unit_metric=PRESSURE_HPA, - metric_conversion=lambda val: pressure_convert( - val, PRESSURE_INHG, PRESSURE_HPA - ), - is_metric_check=True, - device_class=DEVICE_CLASS_PRESSURE, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_SOLAR_GHI, - name="Global Horizontal Irradiance", - unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, - unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, - metric_conversion=3.15459, - is_metric_check=True, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CLOUD_BASE, - name="Cloud Base", - unit_imperial=LENGTH_MILES, - unit_metric=LENGTH_KILOMETERS, - metric_conversion=lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_KILOMETERS - ), - is_metric_check=True, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CLOUD_CEILING, - name="Cloud Ceiling", - unit_imperial=LENGTH_MILES, - unit_metric=LENGTH_KILOMETERS, - metric_conversion=lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_KILOMETERS - ), - is_metric_check=True, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CLOUD_COVER, - name="Cloud Cover", - unit_imperial=PERCENTAGE, - unit_metric=PERCENTAGE, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_WIND_GUST, - name="Wind Gust", - unit_imperial=SPEED_MILES_PER_HOUR, - unit_metric=SPEED_METERS_PER_SECOND, - metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) - / 3600, - is_metric_check=True, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_PRECIPITATION_TYPE, - name="Precipitation Type", - value_map=PrecipitationType, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_OZONE, - name="Ozone", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_PARTICULATE_MATTER_25, - name="Particulate Matter < 2.5 μm", - unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=3.2808399 ** 3, - is_metric_check=True, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_PARTICULATE_MATTER_10, - name="Particulate Matter < 10 μm", - unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - metric_conversion=3.2808399 ** 3, - is_metric_check=True, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_NITROGEN_DIOXIDE, - name="Nitrogen Dioxide", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CARBON_MONOXIDE, - name="Carbon Monoxide", - unit_imperial=CONCENTRATION_PARTS_PER_MILLION, - unit_metric=CONCENTRATION_PARTS_PER_MILLION, - device_class=DEVICE_CLASS_CO, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_SULFUR_DIOXIDE, - name="Sulfur Dioxide", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_EPA_AQI, - name="US EPA Air Quality Index", - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_EPA_PRIMARY_POLLUTANT, - name="US EPA Primary Pollutant", - value_map=PrimaryPollutantType, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_EPA_HEALTH_CONCERN, - name="US EPA Health Concern", - value_map=HealthConcernType, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CHINA_AQI, - name="China MEP Air Quality Index", - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CHINA_PRIMARY_POLLUTANT, - name="China MEP Primary Pollutant", - value_map=PrimaryPollutantType, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_CHINA_HEALTH_CONCERN, - name="China MEP Health Concern", - value_map=HealthConcernType, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_POLLEN_TREE, - name="Tree Pollen Index", - value_map=PollenIndex, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_POLLEN_WEED, - name="Weed Pollen Index", - value_map=PollenIndex, - ), - TomorrowioSensorEntityDescription( - key=CC_ATTR_POLLEN_GRASS, - name="Grass Pollen Index", - value_map=PollenIndex, - ), - TomorrowioSensorEntityDescription( - CC_ATTR_FIRE_INDEX, - name="Fire Index", - ), -) - -# V3 constants -CONDITIONS_V3 = { - "breezy": ATTR_CONDITION_WINDY, - "freezing_rain_heavy": ATTR_CONDITION_SNOWY_RAINY, - "freezing_rain": ATTR_CONDITION_SNOWY_RAINY, - "freezing_rain_light": ATTR_CONDITION_SNOWY_RAINY, - "freezing_drizzle": ATTR_CONDITION_SNOWY_RAINY, - "ice_pellets_heavy": ATTR_CONDITION_HAIL, - "ice_pellets": ATTR_CONDITION_HAIL, - "ice_pellets_light": ATTR_CONDITION_HAIL, - "snow_heavy": ATTR_CONDITION_SNOWY, - "snow": ATTR_CONDITION_SNOWY, - "snow_light": ATTR_CONDITION_SNOWY, - "flurries": ATTR_CONDITION_SNOWY, - "tstorm": ATTR_CONDITION_LIGHTNING, - "rain_heavy": ATTR_CONDITION_POURING, - "rain": ATTR_CONDITION_RAINY, - "rain_light": ATTR_CONDITION_RAINY, - "drizzle": ATTR_CONDITION_RAINY, - "fog_light": ATTR_CONDITION_FOG, - "fog": ATTR_CONDITION_FOG, - "cloudy": ATTR_CONDITION_CLOUDY, - "mostly_cloudy": ATTR_CONDITION_CLOUDY, - "partly_cloudy": ATTR_CONDITION_PARTLYCLOUDY, -} +TMRW_ATTR_PARTICULATE_MATTER_25 = "particulateMatter25" +TMRW_ATTR_PARTICULATE_MATTER_10 = "particulateMatter10" +TMRW_ATTR_NITROGEN_DIOXIDE = "pollutantNO2" +TMRW_ATTR_CARBON_MONOXIDE = "pollutantCO" +TMRW_ATTR_SULFUR_DIOXIDE = "pollutantSO2" +TMRW_ATTR_EPA_AQI = "epaIndex" +TMRW_ATTR_EPA_PRIMARY_POLLUTANT = "epaPrimaryPollutant" +TMRW_ATTR_EPA_HEALTH_CONCERN = "epaHealthConcern" +TMRW_ATTR_CHINA_AQI = "mepIndex" +TMRW_ATTR_CHINA_PRIMARY_POLLUTANT = "mepPrimaryPollutant" +TMRW_ATTR_CHINA_HEALTH_CONCERN = "mepHealthConcern" +TMRW_ATTR_POLLEN_TREE = "treeIndex" +TMRW_ATTR_POLLEN_WEED = "weedIndex" +TMRW_ATTR_POLLEN_GRASS = "grassIndex" +TMRW_ATTR_FIRE_INDEX = "fireIndex" +TMRW_ATTR_FEELS_LIKE = "temperatureApparent" +TMRW_ATTR_DEW_POINT = "dewPoint" +TMRW_ATTR_PRESSURE_SURFACE_LEVEL = "pressureSurfaceLevel" +TMRW_ATTR_SOLAR_GHI = "solarGHI" +TMRW_ATTR_CLOUD_BASE = "cloudBase" +TMRW_ATTR_CLOUD_CEILING = "cloudCeiling" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index b8fdf14e57b773..2a7554a6b92c78 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -2,21 +2,272 @@ from __future__ import annotations from abc import abstractmethod +from dataclasses import dataclass import logging +from typing import Any, Callable -from homeassistant.components.sensor import SensorEntity +from pytomorrowio.const import ( + HealthConcernType, + PollenIndex, + PrecipitationType, + PrimaryPollutantType, +) + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + CONF_NAME, + DEVICE_CLASS_CO, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + IRRADIATION_WATTS_PER_SQUARE_METER, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from homeassistant.util.distance import convert as distance_convert +from homeassistant.util.pressure import convert as pressure_convert +from homeassistant.util.temperature import convert as temp_convert from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity -from .const import CC_SENSOR_TYPES, DOMAIN, TomorrowioSensorEntityDescription +from .const import ( + DOMAIN, + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULFUR_DIOXIDE, + TMRW_ATTR_WIND_GUST, +) _LOGGER = logging.getLogger(__name__) +@dataclass +class TomorrowioSensorEntityDescription(SensorEntityDescription): + """Describes a Tomorrow.io sensor entity.""" + + unit_imperial: str | None = None + unit_metric: str | None = None + metric_conversion: Callable[[float], float] | float = 1.0 + is_metric_check: bool | None = None + device_class: str | None = None + value_map: Any | None = None + + def __post_init__(self) -> None: + """Post initialization.""" + units = (self.unit_imperial, self.unit_metric) + if any(u is not None for u in units) and any(u is None for u in units): + raise RuntimeError( + "`unit_imperial` and `unit_metric` both need to be None or both need " + "to be defined." + ) + + +SENSOR_TYPES = ( + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_FEELS_LIKE, + name="Feels Like", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_DEW_POINT, + name="Dew Point", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + name="Pressure (Surface Level)", + unit_imperial=PRESSURE_INHG, + unit_metric=PRESSURE_HPA, + metric_conversion=lambda val: pressure_convert( + val, PRESSURE_INHG, PRESSURE_HPA + ), + is_metric_check=True, + device_class=DEVICE_CLASS_PRESSURE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_SOLAR_GHI, + name="Global Horizontal Irradiance", + unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, + metric_conversion=3.15459, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CLOUD_BASE, + name="Cloud Base", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CLOUD_CEILING, + name="Cloud Ceiling", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( + val, LENGTH_MILES, LENGTH_KILOMETERS + ), + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CLOUD_COVER, + name="Cloud Cover", + unit_imperial=PERCENTAGE, + unit_metric=PERCENTAGE, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_WIND_GUST, + name="Wind Gust", + unit_imperial=SPEED_MILES_PER_HOUR, + unit_metric=SPEED_METERS_PER_SECOND, + metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) + / 3600, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PRECIPITATION_TYPE, + name="Precipitation Type", + value_map=PrecipitationType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_OZONE, + name="Ozone", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_SULFUR_DIOXIDE, + name="Sulfur Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + value_map=PrimaryPollutantType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + value_map=HealthConcernType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + value_map=PrimaryPollutantType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + value_map=HealthConcernType, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=PollenIndex, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=PollenIndex, + ), + TomorrowioSensorEntityDescription( + key=TMRW_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=PollenIndex, + ), + TomorrowioSensorEntityDescription( + TMRW_ATTR_FIRE_INDEX, + name="Fire Index", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -26,7 +277,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [ TomorrowioSensorEntity(hass, config_entry, coordinator, 4, description) - for description in CC_SENSOR_TYPES + for description in SENSOR_TYPES ] async_add_entities(entities) @@ -35,6 +286,7 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Base Tomorrow.io sensor entity.""" entity_description: TomorrowioSensorEntityDescription + _attr_entity_registry_enabled_default = False def __init__( self, @@ -47,7 +299,6 @@ def __init__( """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description - self._attr_entity_registry_enabled_default = False self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" self._attr_unique_id = ( f"{self._config_entry.unique_id}_{slugify(description.name)}" diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 9e3d830115b182..5d95b98e1096e9 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -2,19 +2,11 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Mapping from datetime import datetime import logging from typing import Any -from pytomorrowio.const import ( - DAILY, - FORECASTS, - HOURLY, - NOWCAST, - PrecipitationType, - WeatherCode, -) +from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -47,31 +39,25 @@ from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity from .const import ( - ATTR_CLOUD_COVER, - ATTR_PRECIPITATION_TYPE, - ATTR_WIND_GUST, - CC_ATTR_CLOUD_COVER, - CC_ATTR_CONDITION, - CC_ATTR_HUMIDITY, - CC_ATTR_OZONE, - CC_ATTR_PRECIPITATION, - CC_ATTR_PRECIPITATION_PROBABILITY, - CC_ATTR_PRECIPITATION_TYPE, - CC_ATTR_PRESSURE, - CC_ATTR_TEMPERATURE, - CC_ATTR_TEMPERATURE_HIGH, - CC_ATTR_TEMPERATURE_LOW, - CC_ATTR_TIMESTAMP, - CC_ATTR_VISIBILITY, - CC_ATTR_WIND_DIRECTION, - CC_ATTR_WIND_GUST, - CC_ATTR_WIND_SPEED, CLEAR_CONDITIONS, CONDITIONS, CONF_TIMESTEP, DEFAULT_FORECAST_TYPE, DOMAIN, MAX_FORECASTS, + TMRW_ATTR_CONDITION, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TIMESTAMP, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_WIND_SPEED, ) _LOGGER = logging.getLogger(__name__) @@ -163,36 +149,6 @@ def _forecast_dict( return {k: v for k, v in data.items() if v is not None} - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return additional state attributes.""" - wind_gust = self.wind_gust - if wind_gust and self.hass.config.units.is_metric: - wind_gust = round( - distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 - ) - cloud_cover = self.cloud_cover - return { - ATTR_CLOUD_COVER: cloud_cover, - ATTR_WIND_GUST: wind_gust, - ATTR_PRECIPITATION_TYPE: self.precipitation_type, - } - - @property - @abstractmethod - def cloud_cover(self): - """Return cloud cover.""" - - @property - @abstractmethod - def wind_gust(self): - """Return wind gust speed.""" - - @property - @abstractmethod - def precipitation_type(self): - """Return precipitation type.""" - @property @abstractmethod def _pressure(self): @@ -259,63 +215,45 @@ def _translate_condition( @property def temperature(self): """Return the platform temperature.""" - return self._get_current_property(CC_ATTR_TEMPERATURE) + return self._get_current_property(TMRW_ATTR_TEMPERATURE) @property def _pressure(self): """Return the raw pressure.""" - return self._get_current_property(CC_ATTR_PRESSURE) + return self._get_current_property(TMRW_ATTR_PRESSURE) @property def humidity(self): """Return the humidity.""" - return self._get_current_property(CC_ATTR_HUMIDITY) - - @property - def wind_gust(self): - """Return the wind gust speed.""" - return self._get_current_property(CC_ATTR_WIND_GUST) - - @property - def cloud_cover(self): - """Reteurn the cloud cover.""" - return self._get_current_property(CC_ATTR_CLOUD_COVER) - - @property - def precipitation_type(self): - """Return precipitation type.""" - precipitation_type = self._get_current_property(CC_ATTR_PRECIPITATION_TYPE) - if precipitation_type is None: - return None - return PrecipitationType(precipitation_type).name.lower() + return self._get_current_property(TMRW_ATTR_HUMIDITY) @property def _wind_speed(self): """Return the raw wind speed.""" - return self._get_current_property(CC_ATTR_WIND_SPEED) + return self._get_current_property(TMRW_ATTR_WIND_SPEED) @property def wind_bearing(self): """Return the wind bearing.""" - return self._get_current_property(CC_ATTR_WIND_DIRECTION) + return self._get_current_property(TMRW_ATTR_WIND_DIRECTION) @property def ozone(self): """Return the O3 (ozone) level.""" - return self._get_current_property(CC_ATTR_OZONE) + return self._get_current_property(TMRW_ATTR_OZONE) @property def condition(self): """Return the condition.""" return self._translate_condition( - self._get_current_property(CC_ATTR_CONDITION), + self._get_current_property(TMRW_ATTR_CONDITION), is_up(self.hass), ) @property def _visibility(self): """Return the raw visibility.""" - return self._get_current_property(CC_ATTR_VISIBILITY) + return self._get_current_property(TMRW_ATTR_VISIBILITY) @property def forecast(self): @@ -332,7 +270,7 @@ def forecast(self): # Set default values (in cases where keys don't exist), None will be # returned. Override properties per forecast type as needed for forecast in raw_forecasts: - forecast_dt = dt_util.parse_datetime(forecast[CC_ATTR_TIMESTAMP]) + forecast_dt = dt_util.parse_datetime(forecast[TMRW_ATTR_TIMESTAMP]) # Throw out past data if forecast_dt.date() < dt_util.utcnow().date(): @@ -341,14 +279,14 @@ def forecast(self): values = forecast["values"] use_datetime = True - condition = values.get(CC_ATTR_CONDITION) - precipitation = values.get(CC_ATTR_PRECIPITATION) - precipitation_probability = values.get(CC_ATTR_PRECIPITATION_PROBABILITY) + condition = values.get(TMRW_ATTR_CONDITION) + precipitation = values.get(TMRW_ATTR_PRECIPITATION) + precipitation_probability = values.get(TMRW_ATTR_PRECIPITATION_PROBABILITY) - temp = values.get(CC_ATTR_TEMPERATURE_HIGH) - temp_low = values.get(CC_ATTR_TEMPERATURE_LOW) - wind_direction = values.get(CC_ATTR_WIND_DIRECTION) - wind_speed = values.get(CC_ATTR_WIND_SPEED) + temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH) + temp_low = values.get(TMRW_ATTR_TEMPERATURE_LOW) + wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION) + wind_speed = values.get(TMRW_ATTR_WIND_SPEED) if self.forecast_type == DAILY: use_datetime = False diff --git a/tests/components/tomorrowio/conftest.py b/tests/components/tomorrowio/conftest.py index 338261abaee9c6..433a532ee9a501 100644 --- a/tests/components/tomorrowio/conftest.py +++ b/tests/components/tomorrowio/conftest.py @@ -7,15 +7,6 @@ from tests.common import load_fixture -@pytest.fixture(name="skip_notifications", autouse=True) -def skip_notifications_fixture(): - """Skip notification calls.""" - with patch("homeassistant.components.persistent_notification.async_create"), patch( - "homeassistant.components.persistent_notification.async_dismiss" - ): - yield - - @pytest.fixture(name="tomorrowio_config_flow_connect", autouse=True) def tomorrowio_config_flow_connect(): """Mock valid tomorrowio config flow setup.""" diff --git a/tests/components/tomorrowio/test_const.py b/tests/components/tomorrowio/test_const.py deleted file mode 100644 index 26108924729dda..00000000000000 --- a/tests/components/tomorrowio/test_const.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Tests for Tomorrow.io const.""" -import pytest - -from homeassistant.components.tomorrowio.const import TomorrowioSensorEntityDescription -from homeassistant.const import TEMP_FAHRENHEIT - - -async def test_post_init(): - """Test post initiailization check for TomorrowioSensorEntityDescription.""" - - with pytest.raises(RuntimeError): - TomorrowioSensorEntityDescription( - key="a", name="b", unit_imperial=TEMP_FAHRENHEIT - ) diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 92d28dc7656cc2..8593a8ececd620 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -14,8 +14,9 @@ _get_unique_id, ) from homeassistant.components.tomorrowio.const import ATTRIBUTION, DOMAIN +from homeassistant.components.tomorrowio.sensor import TomorrowioSensorEntityDescription from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -132,6 +133,15 @@ def check_sensor_state(hass: HomeAssistant, entity_name: str, value: str): assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION +async def test_entity_description_post_init(): + """Test post initiailization check for TomorrowioSensorEntityDescription.""" + + with pytest.raises(RuntimeError): + TomorrowioSensorEntityDescription( + key="a", name="b", unit_imperial=TEMP_FAHRENHEIT + ) + + async def test_v4_sensor( hass: HomeAssistant, tomorrowio_config_entry_update: pytest.fixture, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index db7cdc136a1214..1d23d1ee5a7260 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -12,13 +12,7 @@ _get_config_schema, _get_unique_id, ) -from homeassistant.components.tomorrowio.const import ( - ATTR_CLOUD_COVER, - ATTR_PRECIPITATION_TYPE, - ATTR_WIND_GUST, - ATTRIBUTION, - DOMAIN, -) +from homeassistant.components.tomorrowio.const import ATTRIBUTION, DOMAIN from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, @@ -246,6 +240,3 @@ async def test_v4_weather( 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.0152 - assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 - assert weather_state.attributes[ATTR_WIND_GUST] == 20.3421 - assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" From fe19a6957100e37a1cc3ecf59bf7e66c6407608e Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Thu, 7 Oct 2021 13:09:59 -0400 Subject: [PATCH 07/18] clean up options flow --- homeassistant/components/tomorrowio/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 9a76beca93d432..1f8f77b039b2d2 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -95,7 +95,7 @@ async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult options_schema = { vol.Required( CONF_TIMESTEP, - default=self._config_entry.options.get(CONF_TIMESTEP, DEFAULT_TIMESTEP), + default=self._config_entry.options[CONF_TIMESTEP], ): vol.In([1, 5, 15, 30]), } From aa48815047768159b6bd37017442e41b85c63db0 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Thu, 7 Oct 2021 13:31:53 -0400 Subject: [PATCH 08/18] clean up options flow --- .../components/tomorrowio/__init__.py | 50 ++++++++++++++- homeassistant/components/tomorrowio/const.py | 2 +- homeassistant/components/tomorrowio/sensor.py | 61 ++++++++++++------- tests/components/tomorrowio/test_sensor.py | 2 +- 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index b2001e6370bf54..011efe93673c6d 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -31,14 +31,35 @@ ATTRIBUTION, DOMAIN, MAX_REQUESTS_PER_DAY, + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, TMRW_ATTR_CLOUD_COVER, TMRW_ATTR_CONDITION, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, TMRW_ATTR_HUMIDITY, + TMRW_ATTR_NITROGEN_DIOXIDE, TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, TMRW_ATTR_PRECIPITATION, TMRW_ATTR_PRECIPITATION_PROBABILITY, TMRW_ATTR_PRECIPITATION_TYPE, TMRW_ATTR_PRESSURE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, TMRW_ATTR_TEMPERATURE, TMRW_ATTR_TEMPERATURE_HIGH, TMRW_ATTR_TEMPERATURE_LOW, @@ -47,7 +68,6 @@ TMRW_ATTR_WIND_GUST, TMRW_ATTR_WIND_SPEED, ) -from .sensor import SENSOR_TYPES _LOGGER = logging.getLogger(__name__) @@ -164,7 +184,33 @@ async def _async_update_data(self) -> dict[str, Any]: TMRW_ATTR_WIND_GUST, TMRW_ATTR_CLOUD_COVER, TMRW_ATTR_PRECIPITATION_TYPE, - *(sensor_type.key for sensor_type in SENSOR_TYPES), + *( + TMRW_ATTR_CARBON_MONOXIDE, + TMRW_ATTR_CHINA_AQI, + TMRW_ATTR_CHINA_HEALTH_CONCERN, + TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + TMRW_ATTR_CLOUD_BASE, + TMRW_ATTR_CLOUD_CEILING, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_EPA_AQI, + TMRW_ATTR_EPA_HEALTH_CONCERN, + TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + TMRW_ATTR_FEELS_LIKE, + TMRW_ATTR_FIRE_INDEX, + TMRW_ATTR_NITROGEN_DIOXIDE, + TMRW_ATTR_OZONE, + TMRW_ATTR_PARTICULATE_MATTER_10, + TMRW_ATTR_PARTICULATE_MATTER_25, + TMRW_ATTR_POLLEN_GRASS, + TMRW_ATTR_POLLEN_TREE, + TMRW_ATTR_POLLEN_WEED, + TMRW_ATTR_PRECIPITATION_TYPE, + TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + TMRW_ATTR_SOLAR_GHI, + TMRW_ATTR_SULPHUR_DIOXIDE, + TMRW_ATTR_WIND_GUST, + ), ], [ TMRW_ATTR_TEMPERATURE_LOW, diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py index b1627de1f6d26c..53882a8db20cab 100644 --- a/homeassistant/components/tomorrowio/const.py +++ b/homeassistant/components/tomorrowio/const.py @@ -94,7 +94,7 @@ TMRW_ATTR_PARTICULATE_MATTER_10 = "particulateMatter10" TMRW_ATTR_NITROGEN_DIOXIDE = "pollutantNO2" TMRW_ATTR_CARBON_MONOXIDE = "pollutantCO" -TMRW_ATTR_SULFUR_DIOXIDE = "pollutantSO2" +TMRW_ATTR_SULPHUR_DIOXIDE = "pollutantSO2" TMRW_ATTR_EPA_AQI = "epaIndex" TMRW_ATTR_EPA_PRIMARY_POLLUTANT = "epaPrimaryPollutant" TMRW_ATTR_EPA_HEALTH_CONCERN = "epaHealthConcern" diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 2a7554a6b92c78..fae7ef2c09d630 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -17,13 +17,17 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, CONF_NAME, + DEVICE_CLASS_AQI, DEVICE_CLASS_CO, + DEVICE_CLASS_NITROGEN_DIOXIDE, + DEVICE_CLASS_OZONE, + DEVICE_CLASS_PM10, + DEVICE_CLASS_PM25, DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_SULPHUR_DIOXIDE, DEVICE_CLASS_TEMPERATURE, IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, IRRADIATION_WATTS_PER_SQUARE_METER, @@ -71,7 +75,7 @@ TMRW_ATTR_PRECIPITATION_TYPE, TMRW_ATTR_PRESSURE_SURFACE_LEVEL, TMRW_ATTR_SOLAR_GHI, - TMRW_ATTR_SULFUR_DIOXIDE, + TMRW_ATTR_SULPHUR_DIOXIDE, TMRW_ATTR_WIND_GUST, ) @@ -121,7 +125,6 @@ def __post_init__(self) -> None: TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", - unit_imperial=PRESSURE_INHG, unit_metric=PRESSURE_HPA, metric_conversion=lambda val: pressure_convert( val, PRESSURE_INHG, PRESSURE_HPA @@ -180,30 +183,34 @@ def __post_init__(self) -> None: TomorrowioSensorEntityDescription( key=TMRW_ATTR_OZONE, name="Ozone", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=2.03, + is_metric_check=True, + device_class=DEVICE_CLASS_OZONE, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_PARTICULATE_MATTER_25, name="Particulate Matter < 2.5 μm", - unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, metric_conversion=3.2808399 ** 3, is_metric_check=True, + device_class=DEVICE_CLASS_PM25, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_PARTICULATE_MATTER_10, name="Particulate Matter < 10 μm", - unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, metric_conversion=3.2808399 ** 3, is_metric_check=True, + device_class=DEVICE_CLASS_PM10, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_NITROGEN_DIOXIDE, name="Nitrogen Dioxide", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=1.95, + is_metric_check=True, + device_class=DEVICE_CLASS_NITROGEN_DIOXIDE, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_CARBON_MONOXIDE, @@ -213,14 +220,17 @@ def __post_init__(self) -> None: device_class=DEVICE_CLASS_CO, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SULFUR_DIOXIDE, - name="Sulfur Dioxide", - unit_imperial=CONCENTRATION_PARTS_PER_BILLION, - unit_metric=CONCENTRATION_PARTS_PER_BILLION, + key=TMRW_ATTR_SULPHUR_DIOXIDE, + name="Sulphur Dioxide", + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=2.71, + is_metric_check=True, + device_class=DEVICE_CLASS_SULPHUR_DIOXIDE, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_EPA_AQI, name="US EPA Air Quality Index", + device_class=DEVICE_CLASS_AQI, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, @@ -235,6 +245,7 @@ def __post_init__(self) -> None: TomorrowioSensorEntityDescription( key=TMRW_ATTR_CHINA_AQI, name="China MEP Air Quality Index", + device_class=DEVICE_CLASS_AQI, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, @@ -304,11 +315,13 @@ def __init__( f"{self._config_entry.unique_id}_{slugify(description.name)}" ) self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} + # Fallback to metric always in case imperial isn't defined (for metric only + # sensors) self._attr_native_unit_of_measurement = ( description.unit_metric if hass.config.units.is_metric else description.unit_imperial - ) + ) or description.unit_metric @property @abstractmethod @@ -319,13 +332,17 @@ def _state(self) -> str | int | float | None: def native_value(self) -> str | int | float | None: """Return the state.""" state = self._state - if ( - state is not None - and self.entity_description.unit_imperial is not None - and self.entity_description.metric_conversion != 1.0 - and self.entity_description.is_metric_check is not None - and self.hass.config.units.is_metric - == self.entity_description.is_metric_check + + # If an imperial unit isn't provided, we always want to convert to metric since + # that is what the UI expects + if state is not None and ( + ( + self.entity_description.metric_conversion != 1.0 + and self.entity_description.is_metric_check is not None + and self.hass.config.units.is_metric + == self.entity_description.is_metric_check + ) + or self.entity_description.unit_imperial is None ): conversion = self.entity_description.metric_conversion # When conversion is a callable, we assume it's a single input function diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 8593a8ececd620..9a5f7de0963c09 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -31,7 +31,7 @@ O3 = "ozone" CO = "carbon_monoxide" NO2 = "nitrogen_dioxide" -SO2 = "sulfur_dioxide" +SO2 = "sulphur_dioxide" PM25 = "particulate_matter_2_5_mm" PM10 = "particulate_matter_10_mm" MEP_AQI = "china_mep_air_quality_index" From 3cd76117e2b72ac6d1c94fcab8c7e51dd9cecb4a Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Thu, 7 Oct 2021 13:40:50 -0400 Subject: [PATCH 09/18] tweaks and fix tests --- homeassistant/components/tomorrowio/sensor.py | 18 ++++------- .../components/tomorrowio/test_config_flow.py | 1 + tests/components/tomorrowio/test_sensor.py | 32 +++++++------------ 3 files changed, 18 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index fae7ef2c09d630..530c3db5436983 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -93,15 +93,6 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): device_class: str | None = None value_map: Any | None = None - def __post_init__(self) -> None: - """Post initialization.""" - units = (self.unit_imperial, self.unit_metric) - if any(u is not None for u in units) and any(u is None for u in units): - raise RuntimeError( - "`unit_imperial` and `unit_metric` both need to be None or both need " - "to be defined." - ) - SENSOR_TYPES = ( TomorrowioSensorEntityDescription( @@ -342,14 +333,17 @@ def native_value(self) -> str | int | float | None: and self.hass.config.units.is_metric == self.entity_description.is_metric_check ) - or self.entity_description.unit_imperial is None + or ( + self.entity_description.unit_imperial is None + and self.entity_description.unit_metric is not None + ) ): conversion = self.entity_description.metric_conversion # When conversion is a callable, we assume it's a single input function if callable(conversion): - return round(conversion(float(state)), 4) + return round(conversion(float(state)), 2) - return round(float(state) * conversion, 4) + return round(float(state) * conversion, 2) if self.entity_description.value_map is not None and state is not None: return self.entity_description.value_map(state).name.lower() diff --git a/tests/components/tomorrowio/test_config_flow.py b/tests/components/tomorrowio/test_config_flow.py index 1d445ffbb9bb33..f7618baa51e4a2 100644 --- a/tests/components/tomorrowio/test_config_flow.py +++ b/tests/components/tomorrowio/test_config_flow.py @@ -143,6 +143,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data=user_config, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, source=SOURCE_USER, unique_id=_get_unique_id(hass, user_config), version=1, diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 9a5f7de0963c09..0f657b572c7294 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -14,9 +14,8 @@ _get_unique_id, ) from homeassistant.components.tomorrowio.const import ATTRIBUTION, DOMAIN -from homeassistant.components.tomorrowio.sensor import TomorrowioSensorEntityDescription from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ATTR_ATTRIBUTION, TEMP_FAHRENHEIT +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -133,27 +132,18 @@ def check_sensor_state(hass: HomeAssistant, entity_name: str, value: str): assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION -async def test_entity_description_post_init(): - """Test post initiailization check for TomorrowioSensorEntityDescription.""" - - with pytest.raises(RuntimeError): - TomorrowioSensorEntityDescription( - key="a", name="b", unit_imperial=TEMP_FAHRENHEIT - ) - - async def test_v4_sensor( hass: HomeAssistant, tomorrowio_config_entry_update: pytest.fixture, ) -> None: """Test v4 sensor data.""" await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) - check_sensor_state(hass, O3, "46.53") + check_sensor_state(hass, O3, "94.46") 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, NO2, "20.81") + check_sensor_state(hass, SO2, "4.47") + check_sensor_state(hass, PM25, "5.3") + check_sensor_state(hass, PM10, "20.13") check_sensor_state(hass, MEP_AQI, "23") check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") @@ -165,11 +155,11 @@ async def test_v4_sensor( check_sensor_state(hass, WEED_POLLEN, "none") check_sensor_state(hass, TREE_POLLEN, "none") check_sensor_state(hass, FEELS_LIKE, "38.5") - check_sensor_state(hass, DEW_POINT, "22.6778") - check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.9688") + check_sensor_state(hass, DEW_POINT, "22.68") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.97") check_sensor_state(hass, GHI, "0.0") - check_sensor_state(hass, CLOUD_BASE, "1.1909") + check_sensor_state(hass, CLOUD_BASE, "1.19") check_sensor_state(hass, CLOUD_COVER, "100") - check_sensor_state(hass, CLOUD_CEILING, "1.1909") - check_sensor_state(hass, WIND_GUST, "5.6506") + check_sensor_state(hass, CLOUD_CEILING, "1.19") + check_sensor_state(hass, WIND_GUST, "5.65") check_sensor_state(hass, PRECIPITATION_TYPE, "rain") From 46c32a73f2589a8f9380209a878559ffe0c5343d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 16 Oct 2021 00:05:05 -0400 Subject: [PATCH 10/18] remove device_class from tomorrowio entity description class --- homeassistant/components/tomorrowio/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 530c3db5436983..42808674f148f5 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -90,7 +90,6 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): unit_metric: str | None = None metric_conversion: Callable[[float], float] | float = 1.0 is_metric_check: bool | None = None - device_class: str | None = None value_map: Any | None = None From ce13d0b92c812b7b8436fbbd3c73e6c48f40f975 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 16 Oct 2021 00:23:53 -0400 Subject: [PATCH 11/18] use timestep --- homeassistant/components/tomorrowio/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 011efe93673c6d..3177a750a1d87d 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -29,6 +29,7 @@ from .const import ( ATTRIBUTION, + CONF_TIMESTEP, DOMAIN, MAX_REQUESTS_PER_DAY, TMRW_ATTR_CARBON_MONOXIDE, @@ -221,6 +222,7 @@ async def _async_update_data(self) -> dict[str, Any]: TMRW_ATTR_PRECIPITATION, TMRW_ATTR_PRECIPITATION_PROBABILITY, ], + nowcast_timestep=self._config_entry.options[CONF_TIMESTEP], ) except ( CantConnectException, From eca501341e7b2096292108b25de7c11d60ae4cb8 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 16 Oct 2021 01:49:54 -0400 Subject: [PATCH 12/18] fix tests --- tests/components/tomorrowio/test_config_flow.py | 1 + tests/components/tomorrowio/test_init.py | 16 ++++++++++------ tests/components/tomorrowio/test_sensor.py | 10 +++++++--- tests/components/tomorrowio/test_weather.py | 8 +++++++- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/tests/components/tomorrowio/test_config_flow.py b/tests/components/tomorrowio/test_config_flow.py index f7618baa51e4a2..51251532e84d0a 100644 --- a/tests/components/tomorrowio/test_config_flow.py +++ b/tests/components/tomorrowio/test_config_flow.py @@ -58,6 +58,7 @@ async def test_user_flow_same_unique_ids(hass: HomeAssistant) -> None: MockConfigEntry( domain=DOMAIN, data=user_input, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, source=SOURCE_USER, unique_id=_get_unique_id(hass, user_input), version=2, diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index 2f16f7bff0f317..6bbfebe28d0b90 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -1,13 +1,15 @@ """Tests for Tomorrow.io init.""" -import logging - import pytest from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, _get_unique_id, ) -from homeassistant.components.tomorrowio.const import DOMAIN +from homeassistant.components.tomorrowio.const import ( + CONF_TIMESTEP, + DEFAULT_TIMESTEP, + DOMAIN, +) from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -16,8 +18,6 @@ from tests.common import MockConfigEntry -_LOGGER = logging.getLogger(__name__) - NEW_NAME = "New Name" @@ -28,7 +28,11 @@ async def test_load_and_unload( """Test loading and unloading entry.""" data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) config_entry = MockConfigEntry( - domain=DOMAIN, data=data, unique_id=_get_unique_id(hass, data), version=1 + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + 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) diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 0f657b572c7294..f50020113df1b0 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime -import logging from typing import Any from unittest.mock import patch @@ -13,7 +12,12 @@ _get_config_schema, _get_unique_id, ) -from homeassistant.components.tomorrowio.const import ATTRIBUTION, DOMAIN +from homeassistant.components.tomorrowio.const import ( + ATTRIBUTION, + CONF_TIMESTEP, + DEFAULT_TIMESTEP, + DOMAIN, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, State, callback @@ -24,7 +28,6 @@ from tests.common import MockConfigEntry -_LOGGER = logging.getLogger(__name__) CC_SENSOR_ENTITY_ID = "sensor.tomorrow_io_{}" O3 = "ozone" @@ -112,6 +115,7 @@ async def _setup( config_entry = MockConfigEntry( domain=DOMAIN, data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, unique_id=_get_unique_id(hass, data), version=1, ) diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 1d23d1ee5a7260..e165a120c0e8cf 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -12,7 +12,12 @@ _get_config_schema, _get_unique_id, ) -from homeassistant.components.tomorrowio.const import ATTRIBUTION, DOMAIN +from homeassistant.components.tomorrowio.const import ( + ATTRIBUTION, + CONF_TIMESTEP, + DEFAULT_TIMESTEP, + DOMAIN, +) from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, @@ -68,6 +73,7 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: config_entry = MockConfigEntry( domain=DOMAIN, data=data, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, unique_id=_get_unique_id(hass, data), version=1, ) From 0073fdbbd6f8ebefe59b5026295aac918a8fd794 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 16 Oct 2021 12:33:33 -0400 Subject: [PATCH 13/18] always use default name but add zone name if location is in a zone --- .../components/tomorrowio/config_flow.py | 24 ++++++++++++++----- tests/components/tomorrowio/test_init.py | 5 ++-- tests/components/tomorrowio/test_sensor.py | 4 +++- tests/components/tomorrowio/test_weather.py | 4 +++- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 1f8f77b039b2d2..2cd0247057ca2f 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -13,7 +13,14 @@ import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.components.zone import async_active_zone +from homeassistant.const import ( + CONF_API_KEY, + CONF_FRIENDLY_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, +) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -49,9 +56,6 @@ def _get_config_schema( if source == config_entries.SOURCE_USER: return vol.Schema( { - vol.Required( - CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) - ): str, **api_key_schema, vol.Required( CONF_LATITUDE, @@ -127,13 +131,21 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult self._abort_if_unique_id_configured() try: + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) await TomorrowioV4( user_input[CONF_API_KEY], - str(user_input.get(CONF_LATITUDE, self.hass.config.latitude)), - str(user_input.get(CONF_LONGITUDE, self.hass.config.longitude)), + str(latitude), + str(longitude), session=async_get_clientsession(self.hass), ).realtime([TMRW_ATTR_TEMPERATURE]) + user_input[CONF_NAME] = DEFAULT_NAME + if zone_state := async_active_zone(self.hass, latitude, longitude): + user_input[ + CONF_NAME + ] += f" - {zone_state.attributes[CONF_FRIENDLY_NAME]}" + return self.async_create_entry( title=user_input[CONF_NAME], data=user_input, diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index 6bbfebe28d0b90..95b09eaaf7d02e 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -7,19 +7,19 @@ ) from homeassistant.components.tomorrowio.const import ( CONF_TIMESTEP, + DEFAULT_NAME, DEFAULT_TIMESTEP, DOMAIN, ) from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from .const import MIN_CONFIG from tests.common import MockConfigEntry -NEW_NAME = "New Name" - async def test_load_and_unload( hass: HomeAssistant, @@ -27,6 +27,7 @@ async def test_load_and_unload( ) -> None: """Test loading and unloading entry.""" data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) + data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( domain=DOMAIN, data=data, diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index f50020113df1b0..6f74f001b9e8a0 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -15,11 +15,12 @@ from homeassistant.components.tomorrowio.const import ( ATTRIBUTION, CONF_TIMESTEP, + DEFAULT_NAME, DEFAULT_TIMESTEP, DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -112,6 +113,7 @@ async def _setup( return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), ): data = _get_config_schema(hass, SOURCE_USER)(config) + data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( domain=DOMAIN, data=data, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index e165a120c0e8cf..19031a7dc49894 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -15,6 +15,7 @@ from homeassistant.components.tomorrowio.const import ( ATTRIBUTION, CONF_TIMESTEP, + DEFAULT_NAME, DEFAULT_TIMESTEP, DOMAIN, ) @@ -39,7 +40,7 @@ DOMAIN as WEATHER_DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, CONF_NAME from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util @@ -70,6 +71,7 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State: return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), ): data = _get_config_schema(hass, SOURCE_USER)(config) + data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( domain=DOMAIN, data=data, From aa06462546f176644bca663a2aeffb1c543ce68d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 16 Oct 2021 13:14:39 -0400 Subject: [PATCH 14/18] revert change that will go into future PR --- .../components/tomorrowio/config_flow.py | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index 2cd0247057ca2f..ebb68640924345 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -53,26 +53,22 @@ def _get_config_schema( vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str, } - if source == config_entries.SOURCE_USER: - return vol.Schema( - { - **api_key_schema, - vol.Required( - CONF_LATITUDE, - "location", - default=input_dict.get(CONF_LATITUDE, hass.config.latitude), - ): cv.latitude, - vol.Required( - CONF_LONGITUDE, - "location", - default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), - ): cv.longitude, - }, - extra=vol.REMOVE_EXTRA, - ) - - # For imports we just need to ask for the API key - return vol.Schema(api_key_schema, extra=vol.REMOVE_EXTRA) + return vol.Schema( + { + **api_key_schema, + vol.Required( + CONF_LATITUDE, + "location", + default=input_dict.get(CONF_LATITUDE, hass.config.latitude), + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + "location", + default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), + ): cv.longitude, + }, + extra=vol.REMOVE_EXTRA, + ) def _get_unique_id(hass: HomeAssistant, input_dict: dict[str, Any]): From c6428f3af0097c3070da7cf404516f3bf7521559 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 17 Oct 2021 01:42:08 -0400 Subject: [PATCH 15/18] review comments --- .../components/tomorrowio/config_flow.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index ebb68640924345..ad8c05393a3541 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -67,7 +67,6 @@ def _get_config_schema( default=input_dict.get(CONF_LONGITUDE, hass.config.longitude), ): cv.longitude, }, - extra=vol.REMOVE_EXTRA, ) @@ -126,9 +125,14 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult ) self._abort_if_unique_id_configured() + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + user_input[CONF_NAME] = DEFAULT_NAME + if zone_state := async_active_zone(self.hass, latitude, longitude): + user_input[ + CONF_NAME + ] += f" - {zone_state.attributes[CONF_FRIENDLY_NAME]}" try: - latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) - longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) await TomorrowioV4( user_input[CONF_API_KEY], str(latitude), @@ -136,12 +140,6 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult session=async_get_clientsession(self.hass), ).realtime([TMRW_ATTR_TEMPERATURE]) - user_input[CONF_NAME] = DEFAULT_NAME - if zone_state := async_active_zone(self.hass, latitude, longitude): - user_input[ - CONF_NAME - ] += f" - {zone_state.attributes[CONF_FRIENDLY_NAME]}" - return self.async_create_entry( title=user_input[CONF_NAME], data=user_input, From 899060aeb3616cf5f00f241466b0941ad1a2444d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 17 Oct 2021 01:48:48 -0400 Subject: [PATCH 16/18] move code out of try block --- homeassistant/components/tomorrowio/config_flow.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tomorrowio/config_flow.py b/homeassistant/components/tomorrowio/config_flow.py index ad8c05393a3541..b2d42ac27fba65 100644 --- a/homeassistant/components/tomorrowio/config_flow.py +++ b/homeassistant/components/tomorrowio/config_flow.py @@ -139,12 +139,6 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult str(longitude), session=async_get_clientsession(self.hass), ).realtime([TMRW_ATTR_TEMPERATURE]) - - return self.async_create_entry( - title=user_input[CONF_NAME], - data=user_input, - options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, - ) except CantConnectException: errors["base"] = "cannot_connect" except InvalidAPIKeyException: @@ -155,6 +149,13 @@ async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + if not errors: + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, + ) + return self.async_show_form( step_id="user", data_schema=_get_config_schema(self.hass, self.source, user_input), From 271b977aa89c85a8837a54b44ccdbd5b50527d9d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 17 Oct 2021 01:56:13 -0400 Subject: [PATCH 17/18] bump max requests to 500 as per docs --- homeassistant/components/tomorrowio/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tomorrowio/const.py b/homeassistant/components/tomorrowio/const.py index 53882a8db20cab..c94e56707a2b61 100644 --- a/homeassistant/components/tomorrowio/const.py +++ b/homeassistant/components/tomorrowio/const.py @@ -28,7 +28,7 @@ DEFAULT_NAME = INTEGRATION_NAME ATTRIBUTION = "Powered by Tomorrow.io" -MAX_REQUESTS_PER_DAY = 100 +MAX_REQUESTS_PER_DAY = 500 CLEAR_CONDITIONS = {"night": ATTR_CONDITION_CLEAR_NIGHT, "day": ATTR_CONDITION_SUNNY} From 41f1d0a5b257564da8eb81fd56a699de21d3f034 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 17 Oct 2021 02:34:36 -0400 Subject: [PATCH 18/18] fix tests --- tests/components/tomorrowio/const.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/components/tomorrowio/const.py b/tests/components/tomorrowio/const.py index 87e1aa633acd97..8cb00c6707ee15 100644 --- a/tests/components/tomorrowio/const.py +++ b/tests/components/tomorrowio/const.py @@ -1,6 +1,6 @@ """Constants for tomorrowio tests.""" -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE API_KEY = "aa" @@ -9,14 +9,12 @@ } V1_ENTRY_DATA = { - CONF_NAME: "Tomorrow.io", CONF_API_KEY: API_KEY, CONF_LATITUDE: 80, CONF_LONGITUDE: 80, } API_V4_ENTRY_DATA = { - CONF_NAME: "Tomorrow.io", CONF_API_KEY: API_KEY, CONF_LATITUDE: 80, CONF_LONGITUDE: 80,