Add new tomorrow.io integration to replace Climacell (#68156)
* Add new tomorrow.io integration to replace Climacell - Part 1/3 (#57121) * Add new tomorrow.io integration to replace Climacell - Part 1/3 * remove unused code * remove extra test * remove more unused code * Remove even more unused code * Feedback * clean up options flow * clean up options flow * tweaks and fix tests * remove device_class from tomorrowio entity description class * use timestep * fix tests * always use default name but add zone name if location is in a zone * revert change that will go into future PR * review comments * move code out of try block * bump max requests to 500 as per docs * fix tests * Add new tomorrow.io integration to replace Climacell - Part 2/3 (#57124) * Add new tomorrow.io integration to replace Climacell - Part 2/3 * translations * set config flow to false in manifest * Cleanup more code and re-add options flow test * fixes * patch I/O calls * Update tests/components/climacell/test_config_flow.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * remove unused import Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Fix codeowners * fix mypy and pylint * Switch to DeviceInfo * Fix fixture location and improve sensor entities in tomorrowio integration (#63527) * Add new tomorrow.io integration to replace Climacell - Part 3/3 (#59698) * Switch to DeviceInfo * Add new tomorrow.io integration to replace Climacell - Part 1/3 (#57121) * Add new tomorrow.io integration to replace Climacell - Part 1/3 * remove unused code * remove extra test * remove more unused code * Remove even more unused code * Feedback * clean up options flow * clean up options flow * tweaks and fix tests * remove device_class from tomorrowio entity description class * use timestep * fix tests * always use default name but add zone name if location is in a zone * revert change that will go into future PR * review comments * move code out of try block * bump max requests to 500 as per docs * fix tests * Migrate ClimaCell entries to Tomorrow.io * tweaks * pylint * Apply fix from #60454 to tomorrowio integration * lint and mypy * use speed conversion instead of distance conversion * Use SensorDeviceClass enum * Use built in conversions and remove unused loggers * fix requirements * Update homeassistant/components/tomorrowio/__init__.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Use constants Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Black * Update logic and add coverage * remove extra line * Do patching correctly Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
4578de68e7
commit
4cd4fbefbf
37 changed files with 4895 additions and 3576 deletions
|
@ -1026,6 +1026,8 @@ homeassistant/components/todoist/* @boralyl
|
|||
tests/components/todoist/* @boralyl
|
||||
homeassistant/components/tolo/* @MatthiasLohr
|
||||
tests/components/tolo/* @MatthiasLohr
|
||||
homeassistant/components/tomorrowio/* @raman325
|
||||
tests/components/tomorrowio/* @raman325
|
||||
homeassistant/components/totalconnect/* @austinmroczek
|
||||
tests/components/totalconnect/* @austinmroczek
|
||||
homeassistant/components/tplink/* @rytilahti @thegardenmonkey
|
||||
|
|
|
@ -15,7 +15,8 @@ from pyclimacell.exceptions import (
|
|||
UnknownException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.components.tomorrowio import DOMAIN as TOMORROW_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_API_VERSION,
|
||||
|
@ -36,22 +37,6 @@ from homeassistant.helpers.update_coordinator import (
|
|||
|
||||
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,
|
||||
CC_V3_ATTR_CLOUD_COVER,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_HUMIDITY,
|
||||
|
@ -142,8 +127,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||
if params:
|
||||
hass.config_entries.async_update_entry(entry, **params)
|
||||
|
||||
api_class = ClimaCellV3 if entry.data[CONF_API_VERSION] == 3 else ClimaCellV4
|
||||
api = api_class(
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
TOMORROW_DOMAIN,
|
||||
context={"source": SOURCE_IMPORT, "old_config_entry_id": entry.entry_id},
|
||||
data=entry.data,
|
||||
)
|
||||
)
|
||||
|
||||
# Eventually we will remove the code that sets up the platforms and force users to
|
||||
# migrate. This will only impact users still on the V3 API because we can't
|
||||
# automatically migrate them, but for V4 users, we can skip the platform setup.
|
||||
if entry.data[CONF_API_VERSION] == 4:
|
||||
return True
|
||||
|
||||
api = ClimaCellV3(
|
||||
entry.data[CONF_API_KEY],
|
||||
entry.data.get(CONF_LATITUDE, hass.config.latitude),
|
||||
entry.data.get(CONF_LONGITUDE, hass.config.longitude),
|
||||
|
@ -172,7 +170,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
|||
config_entry, PLATFORMS
|
||||
)
|
||||
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id, None)
|
||||
if not hass.data[DOMAIN]:
|
||||
hass.data.pop(DOMAIN)
|
||||
|
||||
|
@ -208,89 +206,62 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator):
|
|||
"""Update data via library."""
|
||||
data: dict[str, Any] = {FORECASTS: {}}
|
||||
try:
|
||||
if self._api_version == 3:
|
||||
data[CURRENT] = await self._api.realtime(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_HUMIDITY,
|
||||
CC_V3_ATTR_PRESSURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_VISIBILITY,
|
||||
CC_V3_ATTR_OZONE,
|
||||
CC_V3_ATTR_WIND_GUST,
|
||||
CC_V3_ATTR_CLOUD_COVER,
|
||||
CC_V3_ATTR_PRECIPITATION_TYPE,
|
||||
*(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES),
|
||||
]
|
||||
)
|
||||
data[FORECASTS][HOURLY] = await self._api.forecast_hourly(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_PRECIPITATION,
|
||||
CC_V3_ATTR_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
None,
|
||||
timedelta(hours=24),
|
||||
)
|
||||
data[CURRENT] = await self._api.realtime(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_HUMIDITY,
|
||||
CC_V3_ATTR_PRESSURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_VISIBILITY,
|
||||
CC_V3_ATTR_OZONE,
|
||||
CC_V3_ATTR_WIND_GUST,
|
||||
CC_V3_ATTR_CLOUD_COVER,
|
||||
CC_V3_ATTR_PRECIPITATION_TYPE,
|
||||
*(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES),
|
||||
]
|
||||
)
|
||||
data[FORECASTS][HOURLY] = await self._api.forecast_hourly(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_PRECIPITATION,
|
||||
CC_V3_ATTR_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
None,
|
||||
timedelta(hours=24),
|
||||
)
|
||||
|
||||
data[FORECASTS][DAILY] = await self._api.forecast_daily(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_PRECIPITATION_DAILY,
|
||||
CC_V3_ATTR_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
None,
|
||||
timedelta(days=14),
|
||||
)
|
||||
data[FORECASTS][DAILY] = await self._api.forecast_daily(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_PRECIPITATION_DAILY,
|
||||
CC_V3_ATTR_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
None,
|
||||
timedelta(days=14),
|
||||
)
|
||||
|
||||
data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_PRECIPITATION,
|
||||
],
|
||||
None,
|
||||
timedelta(
|
||||
minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30)
|
||||
),
|
||||
self._config_entry.options[CONF_TIMESTEP],
|
||||
)
|
||||
else:
|
||||
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,
|
||||
],
|
||||
)
|
||||
data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast(
|
||||
[
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CC_V3_ATTR_WIND_DIRECTION,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_PRECIPITATION,
|
||||
],
|
||||
None,
|
||||
timedelta(
|
||||
minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30)
|
||||
),
|
||||
self._config_entry.options[CONF_TIMESTEP],
|
||||
)
|
||||
except (
|
||||
CantConnectException,
|
||||
InvalidAPIKeyException,
|
||||
|
@ -341,14 +312,6 @@ class ClimaCellEntity(CoordinatorEntity):
|
|||
|
||||
return items.get("value")
|
||||
|
||||
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."""
|
||||
|
|
|
@ -1,84 +1,15 @@
|
|||
"""Config flow for ClimaCell integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyclimacell import ClimaCellV3
|
||||
from pyclimacell.exceptions import (
|
||||
CantConnectException,
|
||||
InvalidAPIKeyException,
|
||||
RateLimitedException,
|
||||
)
|
||||
from pyclimacell.pyclimacell import ClimaCellV4
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, core
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_API_VERSION,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import 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,
|
||||
CC_V3_ATTR_TEMPERATURE,
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMESTEP,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_config_schema(
|
||||
hass: core.HomeAssistant, 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 = {}
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME)
|
||||
): str,
|
||||
vol.Required(CONF_API_KEY, default=input_dict.get(CONF_API_KEY)): str,
|
||||
vol.Required(CONF_API_VERSION, default=4): vol.In([3, 4]),
|
||||
vol.Inclusive(
|
||||
CONF_LATITUDE,
|
||||
"location",
|
||||
default=input_dict.get(CONF_LATITUDE, hass.config.latitude),
|
||||
): cv.latitude,
|
||||
vol.Inclusive(
|
||||
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]):
|
||||
"""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)}"
|
||||
)
|
||||
from .const import CONF_TIMESTEP, DEFAULT_TIMESTEP, DOMAIN
|
||||
|
||||
|
||||
class ClimaCellOptionsConfigFlow(config_entries.OptionsFlow):
|
||||
|
@ -117,45 +48,3 @@ class ClimaCellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||
) -> ClimaCellOptionsConfigFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return ClimaCellOptionsConfigFlow(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:
|
||||
await self.async_set_unique_id(
|
||||
unique_id=_get_unique_id(self.hass, user_input)
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
if user_input[CONF_API_VERSION] == 3:
|
||||
api_class = ClimaCellV3
|
||||
field = CC_V3_ATTR_TEMPERATURE
|
||||
else:
|
||||
api_class = ClimaCellV4
|
||||
field = CC_ATTR_TEMPERATURE
|
||||
await api_class(
|
||||
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([field])
|
||||
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_NAME], data=user_input
|
||||
)
|
||||
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, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
|
|
@ -5,17 +5,7 @@ from collections.abc import Callable
|
|||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
from pyclimacell.const import (
|
||||
DAILY,
|
||||
HOURLY,
|
||||
NOWCAST,
|
||||
HealthConcernType,
|
||||
PollenIndex,
|
||||
PrecipitationType,
|
||||
PrimaryPollutantType,
|
||||
V3PollenIndex,
|
||||
WeatherCode,
|
||||
)
|
||||
from pyclimacell.const import DAILY, HOURLY, NOWCAST, V3PollenIndex
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
|
||||
from homeassistant.components.weather import (
|
||||
|
@ -37,22 +27,7 @@ from homeassistant.const import (
|
|||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_BILLION,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
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]
|
||||
|
@ -78,75 +53,6 @@ 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 ClimaCellSensorEntityDescription(SensorEntityDescription):
|
||||
|
@ -169,187 +75,6 @@ class ClimaCellSensorEntityDescription(SensorEntityDescription):
|
|||
)
|
||||
|
||||
|
||||
CC_SENSOR_TYPES = (
|
||||
ClimaCellSensorEntityDescription(
|
||||
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=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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=SensorDeviceClass.PRESSURE,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_CLOUD_COVER,
|
||||
name="Cloud Cover",
|
||||
unit_imperial=PERCENTAGE,
|
||||
unit_metric=PERCENTAGE,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_PRECIPITATION_TYPE,
|
||||
name="Precipitation Type",
|
||||
value_map=PrecipitationType,
|
||||
device_class="climacell__precipitation_type",
|
||||
icon="mdi:weather-snowy-rainy",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_OZONE,
|
||||
name="Ozone",
|
||||
unit_imperial=CONCENTRATION_PARTS_PER_BILLION,
|
||||
unit_metric=CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
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,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_NITROGEN_DIOXIDE,
|
||||
name="Nitrogen Dioxide",
|
||||
unit_imperial=CONCENTRATION_PARTS_PER_BILLION,
|
||||
unit_metric=CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_CARBON_MONOXIDE,
|
||||
name="Carbon Monoxide",
|
||||
unit_imperial=CONCENTRATION_PARTS_PER_MILLION,
|
||||
unit_metric=CONCENTRATION_PARTS_PER_MILLION,
|
||||
device_class=SensorDeviceClass.CO,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_SULFUR_DIOXIDE,
|
||||
name="Sulfur Dioxide",
|
||||
unit_imperial=CONCENTRATION_PARTS_PER_BILLION,
|
||||
unit_metric=CONCENTRATION_PARTS_PER_BILLION,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_EPA_AQI,
|
||||
name="US EPA Air Quality Index",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_EPA_PRIMARY_POLLUTANT,
|
||||
name="US EPA Primary Pollutant",
|
||||
value_map=PrimaryPollutantType,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_EPA_HEALTH_CONCERN,
|
||||
name="US EPA Health Concern",
|
||||
value_map=HealthConcernType,
|
||||
device_class="climacell__health_concern",
|
||||
icon="mdi:hospital",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_CHINA_AQI,
|
||||
name="China MEP Air Quality Index",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_CHINA_PRIMARY_POLLUTANT,
|
||||
name="China MEP Primary Pollutant",
|
||||
value_map=PrimaryPollutantType,
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_CHINA_HEALTH_CONCERN,
|
||||
name="China MEP Health Concern",
|
||||
value_map=HealthConcernType,
|
||||
device_class="climacell__health_concern",
|
||||
icon="mdi:hospital",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_POLLEN_TREE,
|
||||
name="Tree Pollen Index",
|
||||
value_map=PollenIndex,
|
||||
device_class="climacell__pollen_index",
|
||||
icon="mdi:flower-pollen",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_POLLEN_WEED,
|
||||
name="Weed Pollen Index",
|
||||
value_map=PollenIndex,
|
||||
device_class="climacell__pollen_index",
|
||||
icon="mdi:flower-pollen",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
key=CC_ATTR_POLLEN_GRASS,
|
||||
name="Grass Pollen Index",
|
||||
value_map=PollenIndex,
|
||||
device_class="climacell__pollen_index",
|
||||
icon="mdi:flower-pollen",
|
||||
),
|
||||
ClimaCellSensorEntityDescription(
|
||||
CC_ATTR_FIRE_INDEX,
|
||||
name="Fire Index",
|
||||
icon="mdi:fire",
|
||||
),
|
||||
)
|
||||
|
||||
# V3 constants
|
||||
CONDITIONS_V3 = {
|
||||
"breezy": ATTR_CONDITION_WINDY,
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
{
|
||||
"domain": "climacell",
|
||||
"name": "ClimaCell",
|
||||
"config_flow": true,
|
||||
"config_flow": false,
|
||||
"documentation": "https://www.home-assistant.io/integrations/climacell",
|
||||
"requirements": ["pyclimacell==0.18.2"],
|
||||
"after_dependencies": ["tomorrowio"],
|
||||
"codeowners": ["@raman325"],
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyclimacell"]
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
"""Sensor component that handles additional ClimaCell data for your location."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
|
||||
from pyclimacell.const import CURRENT
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
|
@ -13,12 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||
from homeassistant.util import slugify
|
||||
|
||||
from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity
|
||||
from .const import (
|
||||
CC_SENSOR_TYPES,
|
||||
CC_V3_SENSOR_TYPES,
|
||||
DOMAIN,
|
||||
ClimaCellSensorEntityDescription,
|
||||
)
|
||||
from .const import CC_V3_SENSOR_TYPES, DOMAIN, ClimaCellSensorEntityDescription
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@ -28,24 +21,18 @@ async def async_setup_entry(
|
|||
) -> None:
|
||||
"""Set up a config entry."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
api_class: type[BaseClimaCellSensorEntity]
|
||||
sensor_types: tuple[ClimaCellSensorEntityDescription, ...]
|
||||
|
||||
if (api_version := config_entry.data[CONF_API_VERSION]) == 3:
|
||||
api_class = ClimaCellV3SensorEntity
|
||||
sensor_types = CC_V3_SENSOR_TYPES
|
||||
else:
|
||||
api_class = ClimaCellSensorEntity
|
||||
sensor_types = CC_SENSOR_TYPES
|
||||
api_version = config_entry.data[CONF_API_VERSION]
|
||||
entities = [
|
||||
api_class(hass, config_entry, coordinator, api_version, description)
|
||||
for description in sensor_types
|
||||
ClimaCellV3SensorEntity(
|
||||
hass, config_entry, coordinator, api_version, description
|
||||
)
|
||||
for description in CC_V3_SENSOR_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity):
|
||||
"""Base ClimaCell sensor entity."""
|
||||
class ClimaCellV3SensorEntity(ClimaCellEntity, SensorEntity):
|
||||
"""Sensor entity that talks to ClimaCell v3 API to retrieve non-weather data."""
|
||||
|
||||
entity_description: ClimaCellSensorEntityDescription
|
||||
|
||||
|
@ -72,15 +59,12 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity):
|
|||
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
|
||||
state = self._get_cc_value(
|
||||
self.coordinator.data[CURRENT], self.entity_description.key
|
||||
)
|
||||
if (
|
||||
state is not None
|
||||
and not isinstance(state, str)
|
||||
|
@ -102,23 +86,3 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity):
|
|||
return self.entity_description.value_map(state).name.lower() # type: ignore[misc]
|
||||
|
||||
return state
|
||||
|
||||
|
||||
class ClimaCellSensorEntity(BaseClimaCellSensorEntity):
|
||||
"""Sensor entity that talks to ClimaCell v4 API to retrieve non-weather data."""
|
||||
|
||||
@property
|
||||
def _state(self) -> str | int | float | None:
|
||||
"""Return the raw state."""
|
||||
return self._get_current_property(self.entity_description.key)
|
||||
|
||||
|
||||
class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity):
|
||||
"""Sensor entity that talks to ClimaCell v3 API to retrieve non-weather data."""
|
||||
|
||||
@property
|
||||
def _state(self) -> str | int | float | None:
|
||||
"""Return the raw state."""
|
||||
return self._get_cc_value(
|
||||
self.coordinator.data[CURRENT], self.entity_description.key
|
||||
)
|
||||
|
|
|
@ -1,24 +1,4 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "If [%key:common::config_flow::data::latitude%] and [%key:common::config_flow::data::longitude%] are not provided, the default values in the Home Assistant configuration will be used. An entity will be created for each forecast type but only the ones you select will be enabled by default.",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"api_version": "API Version",
|
||||
"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": {
|
||||
|
|
|
@ -1,24 +1,4 @@
|
|||
{
|
||||
"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",
|
||||
"api_version": "API Version",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"name": "Name"
|
||||
},
|
||||
"description": "If Latitude and Longitude are not provided, the default values in the Home Assistant configuration will be used. An entity will be created for each forecast type but only the ones you select will be enabled by default."
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
@ -29,6 +9,5 @@
|
|||
"title": "Update ClimaCell Options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "ClimaCell"
|
||||
}
|
||||
}
|
|
@ -6,15 +6,7 @@ from collections.abc import Mapping
|
|||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
|
||||
from pyclimacell.const import (
|
||||
CURRENT,
|
||||
DAILY,
|
||||
FORECASTS,
|
||||
HOURLY,
|
||||
NOWCAST,
|
||||
PrecipitationType,
|
||||
WeatherCode,
|
||||
)
|
||||
from pyclimacell.const import CURRENT, DAILY, FORECASTS, HOURLY, NOWCAST
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_FORECAST_CONDITION,
|
||||
|
@ -54,22 +46,6 @@ 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,
|
||||
CC_V3_ATTR_CLOUD_COVER,
|
||||
CC_V3_ATTR_CONDITION,
|
||||
CC_V3_ATTR_HUMIDITY,
|
||||
|
@ -88,12 +64,10 @@ from .const import (
|
|||
CC_V3_ATTR_WIND_GUST,
|
||||
CC_V3_ATTR_WIND_SPEED,
|
||||
CLEAR_CONDITIONS,
|
||||
CONDITIONS,
|
||||
CONDITIONS_V3,
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_FORECAST_TYPE,
|
||||
DOMAIN,
|
||||
MAX_FORECASTS,
|
||||
)
|
||||
|
||||
|
||||
|
@ -105,10 +79,8 @@ async def async_setup_entry(
|
|||
"""Set up a config entry."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
api_version = config_entry.data[CONF_API_VERSION]
|
||||
|
||||
api_class = ClimaCellV3WeatherEntity if api_version == 3 else ClimaCellWeatherEntity
|
||||
entities = [
|
||||
api_class(config_entry, coordinator, api_version, forecast_type)
|
||||
ClimaCellV3WeatherEntity(config_entry, coordinator, api_version, forecast_type)
|
||||
for forecast_type in (DAILY, HOURLY, NOWCAST)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
@ -267,154 +239,6 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity):
|
|||
return self._visibility
|
||||
|
||||
|
||||
class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity):
|
||||
"""Entity that talks to ClimaCell v4 API to retrieve weather data."""
|
||||
|
||||
_attr_temperature_unit = TEMP_FAHRENHEIT
|
||||
|
||||
@staticmethod
|
||||
def _translate_condition(
|
||||
condition: int | str | None, sun_is_up: bool = True
|
||||
) -> str | None:
|
||||
"""Translate ClimaCell 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):
|
||||
"""Return 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 = None
|
||||
wind_direction = values.get(CC_ATTR_WIND_DIRECTION)
|
||||
wind_speed = values.get(CC_ATTR_WIND_SPEED)
|
||||
|
||||
if self.forecast_type == DAILY:
|
||||
use_datetime = False
|
||||
temp_low = values.get(CC_ATTR_TEMPERATURE_LOW)
|
||||
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
|
||||
|
||||
|
||||
class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity):
|
||||
"""Entity that talks to ClimaCell v3 API to retrieve weather data."""
|
||||
|
||||
|
|
351
homeassistant/components/tomorrowio/__init__.py
Normal file
351
homeassistant/components/tomorrowio/__init__.py
Normal file
|
@ -0,0 +1,351 @@
|
|||
"""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.entity import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTRIBUTION,
|
||||
CONF_TIMESTEP,
|
||||
DOMAIN,
|
||||
INTEGRATION_NAME,
|
||||
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,
|
||||
TMRW_ATTR_VISIBILITY,
|
||||
TMRW_ATTR_WIND_DIRECTION,
|
||||
TMRW_ATTR_WIND_GUST,
|
||||
TMRW_ATTR_WIND_SPEED,
|
||||
)
|
||||
|
||||
_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=dr.DeviceEntryType.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_entry in er.async_entries_for_config_entry(
|
||||
ent_reg, old_config_entry_id
|
||||
):
|
||||
_LOGGER.debug("Removing %s", entity_entry.entity_id)
|
||||
ent_reg.async_remove(entity_entry.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]}_"
|
||||
f"{'_'.join(entity_entry.unique_id.split('_')[1:])}"
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Re-creating %s for the new config entry", entity_entry.entity_id
|
||||
)
|
||||
# We will precreate the entity so that any customizations can be preserved
|
||||
new_entity_entry = ent_reg.async_get_or_create(
|
||||
entity_entry.domain,
|
||||
DOMAIN,
|
||||
new_unique_id,
|
||||
suggested_object_id=entity_entry.entity_id.split(".")[1],
|
||||
disabled_by=entity_entry.disabled_by,
|
||||
config_entry=entry,
|
||||
original_name=entity_entry.original_name,
|
||||
original_icon=entity_entry.original_icon,
|
||||
)
|
||||
_LOGGER.debug("Re-created %s", new_entity_entry.entity_id)
|
||||
# If there are customizations on the old entity, apply them to the new one
|
||||
if entity_entry.name or entity_entry.icon:
|
||||
ent_reg.async_update_entity(
|
||||
new_entity_entry.entity_id,
|
||||
name=entity_entry.name,
|
||||
icon=entity_entry.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))
|
||||
|
||||
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(
|
||||
[
|
||||
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,
|
||||
*(
|
||||
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,
|
||||
TMRW_ATTR_TEMPERATURE_HIGH,
|
||||
TMRW_ATTR_WIND_SPEED,
|
||||
TMRW_ATTR_WIND_DIRECTION,
|
||||
TMRW_ATTR_CONDITION,
|
||||
TMRW_ATTR_PRECIPITATION,
|
||||
TMRW_ATTR_PRECIPITATION_PROBABILITY,
|
||||
],
|
||||
nowcast_timestep=self._config_entry.options[CONF_TIMESTEP],
|
||||
)
|
||||
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 = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])},
|
||||
name="Tomorrow.io",
|
||||
manufacturer="Tomorrow.io",
|
||||
sw_version=f"v{self.api_version}",
|
||||
entry_type=dr.DeviceEntryType.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
|
214
homeassistant/components/tomorrowio/config_flow.py
Normal file
214
homeassistant/components/tomorrowio/config_flow.py
Normal file
|
@ -0,0 +1,214 @@
|
|||
"""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.components.zone import async_active_zone
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_API_VERSION,
|
||||
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
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
from .const import (
|
||||
AUTO_MIGRATION_MESSAGE,
|
||||
CC_DOMAIN,
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMESTEP,
|
||||
DOMAIN,
|
||||
INTEGRATION_NAME,
|
||||
MANUAL_MIGRATION_MESSAGE,
|
||||
TMRW_ATTR_TEMPERATURE,
|
||||
)
|
||||
|
||||
_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,
|
||||
}
|
||||
|
||||
# For imports we just need to ask for the API key
|
||||
if source == config_entries.SOURCE_IMPORT:
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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[CONF_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()
|
||||
|
||||
latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude)
|
||||
longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude)
|
||||
if CONF_NAME not in user_input:
|
||||
user_input[CONF_NAME] = DEFAULT_NAME
|
||||
# Append zone name if it exists and we are using the default name
|
||||
if zone_state := async_active_zone(self.hass, latitude, longitude):
|
||||
zone_name = zone_state.attributes[CONF_FRIENDLY_NAME]
|
||||
user_input[CONF_NAME] += f" - {zone_name}"
|
||||
try:
|
||||
await TomorrowioV4(
|
||||
user_input[CONF_API_KEY],
|
||||
str(latitude),
|
||||
str(longitude),
|
||||
session=async_get_clientsession(self.hass),
|
||||
).realtime([TMRW_ATTR_TEMPERATURE])
|
||||
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"
|
||||
|
||||
if not errors:
|
||||
options: Mapping[str, Any] = {CONF_TIMESTEP: DEFAULT_TIMESTEP}
|
||||
# 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,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=_get_config_schema(self.hass, self.source, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(self, import_config: dict) -> FlowResult:
|
||||
"""Import from config."""
|
||||
# Store import config for later
|
||||
self._import_config = dict(import_config)
|
||||
if self._import_config.pop(CONF_API_VERSION, 3) == 3:
|
||||
# Clear API key from import config
|
||||
self._import_config[CONF_API_KEY] = ""
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
MANUAL_MIGRATION_MESSAGE,
|
||||
INTEGRATION_NAME,
|
||||
f"{CC_DOMAIN}_to_{DOMAIN}_new_api_key_needed",
|
||||
)
|
||||
return await self.async_step_user()
|
||||
|
||||
self.hass.components.persistent_notification.async_create(
|
||||
AUTO_MIGRATION_MESSAGE,
|
||||
INTEGRATION_NAME,
|
||||
f"{CC_DOMAIN}_to_{DOMAIN}",
|
||||
)
|
||||
return await self.async_step_user(self._import_config)
|
143
homeassistant/components/tomorrowio/const.py
Normal file
143
homeassistant/components/tomorrowio/const.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
"""Constants for the Tomorrow.io integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pytomorrowio.const import DAILY, HOURLY, NOWCAST, WeatherCode
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
CONF_TIMESTEP = "timestep"
|
||||
FORECAST_TYPES = [DAILY, HOURLY, NOWCAST]
|
||||
|
||||
DEFAULT_TIMESTEP = 15
|
||||
DEFAULT_FORECAST_TYPE = DAILY
|
||||
CC_DOMAIN = "climacell"
|
||||
DOMAIN = "tomorrowio"
|
||||
INTEGRATION_NAME = "Tomorrow.io"
|
||||
DEFAULT_NAME = INTEGRATION_NAME
|
||||
ATTRIBUTION = "Powered by Tomorrow.io"
|
||||
|
||||
MAX_REQUESTS_PER_DAY = 500
|
||||
|
||||
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
|
||||
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
|
||||
TMRW_ATTR_PARTICULATE_MATTER_25 = "particulateMatter25"
|
||||
TMRW_ATTR_PARTICULATE_MATTER_10 = "particulateMatter10"
|
||||
TMRW_ATTR_NITROGEN_DIOXIDE = "pollutantNO2"
|
||||
TMRW_ATTR_CARBON_MONOXIDE = "pollutantCO"
|
||||
TMRW_ATTR_SULPHUR_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"
|
||||
|
||||
MANUAL_MIGRATION_MESSAGE = (
|
||||
"As part of [ClimaCell's rebranding to Tomorrow.io](https://www.tomorrow.io/blog/my-last-day-as-ceo-of-climacell/) "
|
||||
"we will migrate your existing ClimaCell config entry (or config "
|
||||
"entries) to the new Tomorrow.io integration, but because **the "
|
||||
" V3 API is now deprecated**, you will need to get a new V4 API "
|
||||
"key from [Tomorrow.io](https://app.tomorrow.io/development/keys)."
|
||||
" Once that is done, visit the "
|
||||
"[Integrations Configuration](/config/integrations) page and "
|
||||
"click Configure on the Tomorrow.io card(s) to submit the new "
|
||||
"key. Once your key has been validated, your config entry will "
|
||||
"automatically be migrated. The new integration is a drop in "
|
||||
"replacement and your existing entities will be migrated over, "
|
||||
"just note that the location of the integration card on the "
|
||||
"[Integrations Configuration](/config/integrations) page has changed "
|
||||
"since the integration name has changed."
|
||||
)
|
||||
|
||||
AUTO_MIGRATION_MESSAGE = (
|
||||
"As part of [ClimaCell's rebranding to Tomorrow.io](https://www.tomorrow.io/blog/my-last-day-as-ceo-of-climacell/) "
|
||||
"we have automatically migrated your existing ClimaCell config entry "
|
||||
"(or as many of your ClimaCell config entries as we could) to the new "
|
||||
"Tomorrow.io integration. There is nothing you need to do since the "
|
||||
"new integration is a drop in replacement and your existing entities "
|
||||
"have been migrated over, just note that the location of the "
|
||||
"integration card on the "
|
||||
"[Integrations Configuration](/config/integrations) page has changed "
|
||||
"since the integration name has changed."
|
||||
)
|
9
homeassistant/components/tomorrowio/manifest.json
Normal file
9
homeassistant/components/tomorrowio/manifest.json
Normal file
|
@ -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"
|
||||
}
|
365
homeassistant/components/tomorrowio/sensor.py
Normal file
365
homeassistant/components/tomorrowio/sensor.py
Normal file
|
@ -0,0 +1,365 @@
|
|||
"""Sensor component that handles additional Tomorrowio data for your location."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pytomorrowio.const import (
|
||||
HealthConcernType,
|
||||
PollenIndex,
|
||||
PrecipitationType,
|
||||
PrimaryPollutantType,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
CONF_NAME,
|
||||
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 (
|
||||
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_SULPHUR_DIOXIDE,
|
||||
TMRW_ATTR_WIND_GUST,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
value_map: Any | None = None
|
||||
|
||||
|
||||
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=SensorDeviceClass.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=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL,
|
||||
name="Pressure (Surface Level)",
|
||||
unit_metric=PRESSURE_HPA,
|
||||
metric_conversion=lambda val: pressure_convert(
|
||||
val, PRESSURE_INHG, PRESSURE_HPA
|
||||
),
|
||||
is_metric_check=True,
|
||||
device_class=SensorDeviceClass.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,
|
||||
device_class="tomorrowio__precipitation_type",
|
||||
icon="mdi:weather-snowy-rainy",
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_OZONE,
|
||||
name="Ozone",
|
||||
unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
metric_conversion=2.03,
|
||||
is_metric_check=True,
|
||||
device_class=SensorDeviceClass.OZONE,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_PARTICULATE_MATTER_25,
|
||||
name="Particulate Matter < 2.5 μm",
|
||||
unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
metric_conversion=3.2808399**3,
|
||||
is_metric_check=True,
|
||||
device_class=SensorDeviceClass.PM25,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_PARTICULATE_MATTER_10,
|
||||
name="Particulate Matter < 10 μm",
|
||||
unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
metric_conversion=3.2808399**3,
|
||||
is_metric_check=True,
|
||||
device_class=SensorDeviceClass.PM10,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_NITROGEN_DIOXIDE,
|
||||
name="Nitrogen Dioxide",
|
||||
unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||
metric_conversion=1.95,
|
||||
is_metric_check=True,
|
||||
device_class=SensorDeviceClass.NITROGEN_DIOXIDE,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_CARBON_MONOXIDE,
|
||||
name="Carbon Monoxide",
|
||||
unit_imperial=CONCENTRATION_PARTS_PER_MILLION,
|
||||
unit_metric=CONCENTRATION_PARTS_PER_MILLION,
|
||||
device_class=SensorDeviceClass.CO,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
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=SensorDeviceClass.SULPHUR_DIOXIDE,
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_EPA_AQI,
|
||||
name="US EPA Air Quality Index",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
),
|
||||
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,
|
||||
device_class="tomorrowio__health_concern",
|
||||
icon="mdi:hospital",
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_CHINA_AQI,
|
||||
name="China MEP Air Quality Index",
|
||||
device_class=SensorDeviceClass.AQI,
|
||||
),
|
||||
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,
|
||||
device_class="tomorrowio__health_concern",
|
||||
icon="mdi:hospital",
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_POLLEN_TREE,
|
||||
name="Tree Pollen Index",
|
||||
value_map=PollenIndex,
|
||||
device_class="tomorrowio__pollen_index",
|
||||
icon="mdi:flower-pollen",
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_POLLEN_WEED,
|
||||
name="Weed Pollen Index",
|
||||
value_map=PollenIndex,
|
||||
device_class="tomorrowio__pollen_index",
|
||||
icon="mdi:flower-pollen",
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
key=TMRW_ATTR_POLLEN_GRASS,
|
||||
name="Grass Pollen Index",
|
||||
value_map=PollenIndex,
|
||||
device_class="tomorrowio__pollen_index",
|
||||
icon="mdi:flower-pollen",
|
||||
),
|
||||
TomorrowioSensorEntityDescription(
|
||||
TMRW_ATTR_FIRE_INDEX,
|
||||
name="Fire Index",
|
||||
icon="mdi:fire",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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 SENSOR_TYPES
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity):
|
||||
"""Base Tomorrow.io sensor entity."""
|
||||
|
||||
entity_description: TomorrowioSensorEntityDescription
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
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_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}
|
||||
# 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
|
||||
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 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
|
||||
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)), 2)
|
||||
|
||||
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()
|
||||
|
||||
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)
|
32
homeassistant/components/tomorrowio/strings.json
Normal file
32
homeassistant/components/tomorrowio/strings.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
27
homeassistant/components/tomorrowio/strings.sensor.json
Normal file
27
homeassistant/components/tomorrowio/strings.sensor.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"state": {
|
||||
"tomorrowio__pollen_index": {
|
||||
"none": "None",
|
||||
"very_low": "Very Low",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"very_high": "Very High"
|
||||
},
|
||||
"tomorrowio__health_concern": {
|
||||
"good": "Good",
|
||||
"moderate": "Moderate",
|
||||
"unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups",
|
||||
"unhealthy": "Unhealthy",
|
||||
"very_unhealthy": "Very Unhealthy",
|
||||
"hazardous": "Hazardous"
|
||||
},
|
||||
"tomorrowio__precipitation_type": {
|
||||
"none": "None",
|
||||
"rain": "Rain",
|
||||
"snow": "Snow",
|
||||
"freezing_rain": "Freezing Rain",
|
||||
"ice_pellets": "Ice Pellets"
|
||||
}
|
||||
}
|
||||
}
|
32
homeassistant/components/tomorrowio/translations/en.json
Normal file
32
homeassistant/components/tomorrowio/translations/en.json
Normal file
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"state": {
|
||||
"tomorrowio__health_concern": {
|
||||
"good": "Good",
|
||||
"hazardous": "Hazardous",
|
||||
"moderate": "Moderate",
|
||||
"unhealthy": "Unhealthy",
|
||||
"unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups",
|
||||
"very_unhealthy": "Very Unhealthy"
|
||||
},
|
||||
"tomorrowio__pollen_index": {
|
||||
"high": "High",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"none": "None",
|
||||
"very_high": "Very High",
|
||||
"very_low": "Very Low"
|
||||
},
|
||||
"tomorrowio__precipitation_type": {
|
||||
"freezing_rain": "Freezing Rain",
|
||||
"ice_pellets": "Ice Pellets",
|
||||
"none": "None",
|
||||
"rain": "Rain",
|
||||
"snow": "Snow"
|
||||
}
|
||||
}
|
||||
}
|
253
homeassistant/components/tomorrowio/weather.py
Normal file
253
homeassistant/components/tomorrowio/weather.py
Normal file
|
@ -0,0 +1,253 @@
|
|||
"""Weather component that handles meteorological data for your location."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, 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_INCHES,
|
||||
LENGTH_MILES,
|
||||
PRESSURE_INHG,
|
||||
SPEED_MILES_PER_HOUR,
|
||||
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 . import TomorrowioDataUpdateCoordinator, TomorrowioEntity
|
||||
from .const import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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 TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity):
|
||||
"""Entity that talks to Tomorrow.io v4 API to retrieve weather data."""
|
||||
|
||||
_attr_temperature_unit = TEMP_FAHRENHEIT
|
||||
_attr_pressure_unit = PRESSURE_INHG
|
||||
_attr_wind_speed_unit = SPEED_MILES_PER_HOUR
|
||||
_attr_visibility_unit = LENGTH_MILES
|
||||
_attr_precipitation_unit = LENGTH_INCHES
|
||||
|
||||
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}"
|
||||
|
||||
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)
|
||||
|
||||
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}
|
||||
|
||||
@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(TMRW_ATTR_TEMPERATURE)
|
||||
|
||||
@property
|
||||
def pressure(self):
|
||||
"""Return the raw pressure."""
|
||||
return self._get_current_property(TMRW_ATTR_PRESSURE)
|
||||
|
||||
@property
|
||||
def humidity(self):
|
||||
"""Return the humidity."""
|
||||
return self._get_current_property(TMRW_ATTR_HUMIDITY)
|
||||
|
||||
@property
|
||||
def wind_speed(self):
|
||||
"""Return the raw 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(TMRW_ATTR_WIND_DIRECTION)
|
||||
|
||||
@property
|
||||
def ozone(self):
|
||||
"""Return the O3 (ozone) level."""
|
||||
return self._get_current_property(TMRW_ATTR_OZONE)
|
||||
|
||||
@property
|
||||
def condition(self):
|
||||
"""Return the condition."""
|
||||
return self._translate_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(TMRW_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[TMRW_ATTR_TIMESTAMP])
|
||||
|
||||
# Throw out past data
|
||||
if forecast_dt.date() < dt_util.utcnow().date():
|
||||
continue
|
||||
|
||||
values = forecast["values"]
|
||||
use_datetime = True
|
||||
|
||||
condition = values.get(TMRW_ATTR_CONDITION)
|
||||
precipitation = values.get(TMRW_ATTR_PRECIPITATION)
|
||||
precipitation_probability = values.get(TMRW_ATTR_PRECIPITATION_PROBABILITY)
|
||||
|
||||
temp = values.get(TMRW_ATTR_TEMPERATURE_HIGH)
|
||||
temp_low = None
|
||||
wind_direction = values.get(TMRW_ATTR_WIND_DIRECTION)
|
||||
wind_speed = values.get(TMRW_ATTR_WIND_SPEED)
|
||||
|
||||
if self.forecast_type == DAILY:
|
||||
use_datetime = False
|
||||
temp_low = values.get(TMRW_ATTR_TEMPERATURE_LOW)
|
||||
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
|
|
@ -55,7 +55,6 @@ FLOWS = [
|
|||
"canary",
|
||||
"cast",
|
||||
"cert_expiry",
|
||||
"climacell",
|
||||
"cloudflare",
|
||||
"co2signal",
|
||||
"coinbase",
|
||||
|
@ -341,6 +340,7 @@ FLOWS = [
|
|||
"tibber",
|
||||
"tile",
|
||||
"tolo",
|
||||
"tomorrowio",
|
||||
"toon",
|
||||
"totalconnect",
|
||||
"tplink",
|
||||
|
|
|
@ -1944,6 +1944,9 @@ pythonegardia==1.0.40
|
|||
# homeassistant.components.tile
|
||||
pytile==2022.02.0
|
||||
|
||||
# homeassistant.components.tomorrowio
|
||||
pytomorrowio==0.1.0
|
||||
|
||||
# homeassistant.components.touchline
|
||||
pytouchline==0.7
|
||||
|
||||
|
|
|
@ -1244,6 +1244,9 @@ python_awair==0.2.1
|
|||
# homeassistant.components.tile
|
||||
pytile==2022.02.0
|
||||
|
||||
# homeassistant.components.tomorrowio
|
||||
pytomorrowio==0.1.0
|
||||
|
||||
# homeassistant.components.traccar
|
||||
pytraccar==0.10.0
|
||||
|
||||
|
|
|
@ -7,36 +7,20 @@ import pytest
|
|||
from tests.common import load_fixture
|
||||
|
||||
|
||||
@pytest.fixture(name="climacell_config_flow_connect", autouse=True)
|
||||
def climacell_config_flow_connect():
|
||||
"""Mock valid climacell config flow setup."""
|
||||
with patch(
|
||||
"homeassistant.components.climacell.config_flow.ClimaCellV3.realtime",
|
||||
return_value={},
|
||||
), patch(
|
||||
"homeassistant.components.climacell.config_flow.ClimaCellV4.realtime",
|
||||
return_value={},
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="climacell_config_entry_update")
|
||||
def climacell_config_entry_update_fixture():
|
||||
"""Mock valid climacell config entry setup."""
|
||||
with patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.realtime",
|
||||
return_value=json.loads(load_fixture("climacell/v3_realtime.json")),
|
||||
return_value=json.loads(load_fixture("v3_realtime.json", "climacell")),
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.forecast_hourly",
|
||||
return_value=json.loads(load_fixture("climacell/v3_forecast_hourly.json")),
|
||||
return_value=json.loads(load_fixture("v3_forecast_hourly.json", "climacell")),
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.forecast_daily",
|
||||
return_value=json.loads(load_fixture("climacell/v3_forecast_daily.json")),
|
||||
return_value=json.loads(load_fixture("v3_forecast_daily.json", "climacell")),
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.forecast_nowcast",
|
||||
return_value=json.loads(load_fixture("climacell/v3_forecast_nowcast.json")),
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV4.realtime_and_all_forecasts",
|
||||
return_value=json.loads(load_fixture("climacell/v4.json")),
|
||||
return_value=json.loads(load_fixture("v3_forecast_nowcast.json", "climacell")),
|
||||
):
|
||||
yield
|
||||
|
|
|
@ -10,17 +10,6 @@ from homeassistant.const import (
|
|||
|
||||
API_KEY = "aa"
|
||||
|
||||
MIN_CONFIG = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
}
|
||||
|
||||
V1_ENTRY_DATA = {
|
||||
CONF_NAME: "ClimaCell",
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_LATITUDE: 80,
|
||||
CONF_LONGITUDE: 80,
|
||||
}
|
||||
|
||||
API_V3_ENTRY_DATA = {
|
||||
CONF_NAME: "ClimaCell",
|
||||
CONF_API_KEY: API_KEY,
|
||||
|
@ -28,11 +17,3 @@ API_V3_ENTRY_DATA = {
|
|||
CONF_LONGITUDE: 80,
|
||||
CONF_API_VERSION: 3,
|
||||
}
|
||||
|
||||
API_V4_ENTRY_DATA = {
|
||||
CONF_NAME: "ClimaCell",
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_LATITUDE: 80,
|
||||
CONF_LONGITUDE: 80,
|
||||
CONF_API_VERSION: 4,
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,179 +1,27 @@
|
|||
"""Test the ClimaCell config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyclimacell.exceptions import (
|
||||
CantConnectException,
|
||||
InvalidAPIKeyException,
|
||||
RateLimitedException,
|
||||
UnknownException,
|
||||
)
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.climacell.config_flow import (
|
||||
_get_config_schema,
|
||||
_get_unique_id,
|
||||
)
|
||||
from homeassistant.components.climacell.const import (
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMESTEP,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_API_VERSION,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import API_KEY, MIN_CONFIG
|
||||
from .const import API_V3_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
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, 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_API_VERSION] == 4
|
||||
assert result["data"][CONF_LATITUDE] == hass.config.latitude
|
||||
assert result["data"][CONF_LONGITUDE] == hass.config.longitude
|
||||
|
||||
|
||||
async def test_user_flow_v3(hass: HomeAssistant) -> None:
|
||||
"""Test user config flow with v3 API."""
|
||||
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"
|
||||
|
||||
data = _get_config_schema(hass, MIN_CONFIG)(MIN_CONFIG)
|
||||
data[CONF_API_VERSION] = 3
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=data,
|
||||
)
|
||||
|
||||
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_API_VERSION] == 3
|
||||
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, 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 ClimaCell can't connect."""
|
||||
with patch(
|
||||
"homeassistant.components.climacell.config_flow.ClimaCellV4.realtime",
|
||||
side_effect=CantConnectException,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=_get_config_schema(hass, 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.climacell.config_flow.ClimaCellV4.realtime",
|
||||
side_effect=InvalidAPIKeyException,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=_get_config_schema(hass, 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.climacell.config_flow.ClimaCellV4.realtime",
|
||||
side_effect=RateLimitedException,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=_get_config_schema(hass, 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.climacell.config_flow.ClimaCellV4.realtime",
|
||||
side_effect=UnknownException,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data=_get_config_schema(hass, 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:
|
||||
async def test_options_flow(
|
||||
hass: HomeAssistant, climacell_config_entry_update: None
|
||||
) -> None:
|
||||
"""Test options config flow for climacell."""
|
||||
user_config = _get_config_schema(hass)(MIN_CONFIG)
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=user_config,
|
||||
data=API_V3_ENTRY_DATA,
|
||||
source=SOURCE_USER,
|
||||
unique_id=_get_unique_id(hass, user_config),
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
|
|
@ -1,16 +1,14 @@
|
|||
"""Tests for Climacell init."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.climacell.config_flow import (
|
||||
_get_config_schema,
|
||||
_get_unique_id,
|
||||
)
|
||||
from homeassistant.components.climacell.const import CONF_TIMESTEP, DOMAIN
|
||||
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
|
||||
from homeassistant.const import CONF_API_VERSION
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import API_V3_ENTRY_DATA, MIN_CONFIG, V1_ENTRY_DATA
|
||||
from .const import API_V3_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
@ -20,11 +18,10 @@ async def test_load_and_unload(
|
|||
climacell_config_entry_update: pytest.fixture,
|
||||
) -> None:
|
||||
"""Test loading and unloading entry."""
|
||||
data = _get_config_schema(hass)(MIN_CONFIG)
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
unique_id=_get_unique_id(hass, data),
|
||||
data=API_V3_ENTRY_DATA,
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
@ -42,11 +39,10 @@ async def test_v3_load_and_unload(
|
|||
climacell_config_entry_update: pytest.fixture,
|
||||
) -> None:
|
||||
"""Test loading and unloading v3 entry."""
|
||||
data = _get_config_schema(hass)(API_V3_ENTRY_DATA)
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
unique_id=_get_unique_id(hass, data),
|
||||
data={k: v for k, v in API_V3_ENTRY_DATA.items() if k != CONF_API_VERSION},
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
@ -59,6 +55,29 @@ async def test_v3_load_and_unload(
|
|||
assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0
|
||||
|
||||
|
||||
async def test_v4_load_and_unload(
|
||||
hass: HomeAssistant,
|
||||
climacell_config_entry_update: pytest.fixture,
|
||||
) -> None:
|
||||
"""Test loading and unloading v3 entry."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_API_VERSION: 4,
|
||||
**{k: v for k, v in API_V3_ENTRY_DATA.items() if k != CONF_API_VERSION},
|
||||
},
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.tomorrowio.async_setup_entry", return_value=True
|
||||
):
|
||||
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)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"old_timestep, new_timestep", [(2, 1), (7, 5), (20, 15), (21, 30)]
|
||||
)
|
||||
|
@ -71,9 +90,9 @@ async def test_migrate_timestep(
|
|||
"""Test migration to standardized timestep."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=V1_ENTRY_DATA,
|
||||
data=API_V3_ENTRY_DATA,
|
||||
options={CONF_TIMESTEP: old_timestep},
|
||||
unique_id=_get_unique_id(hass, V1_ENTRY_DATA),
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
|
|
@ -7,10 +7,6 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.climacell.config_flow import (
|
||||
_get_config_schema,
|
||||
_get_unique_id,
|
||||
)
|
||||
from homeassistant.components.climacell.const import ATTRIBUTION, DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import ATTR_ATTRIBUTION
|
||||
|
@ -18,7 +14,7 @@ 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_V3_ENTRY_DATA, API_V4_ENTRY_DATA
|
||||
from .const import API_V3_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
@ -105,11 +101,10 @@ async def _setup(
|
|||
"homeassistant.util.dt.utcnow",
|
||||
return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC),
|
||||
):
|
||||
data = _get_config_schema(hass)(config)
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
unique_id=_get_unique_id(hass, data),
|
||||
data=config,
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
@ -151,36 +146,3 @@ async def test_v3_sensor(
|
|||
check_sensor_state(hass, GRASS_POLLEN, "minimal_to_none")
|
||||
check_sensor_state(hass, WEED_POLLEN, "minimal_to_none")
|
||||
check_sensor_state(hass, TREE_POLLEN, "minimal_to_none")
|
||||
|
||||
|
||||
async def test_v4_sensor(
|
||||
hass: HomeAssistant,
|
||||
climacell_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")
|
||||
|
|
|
@ -7,10 +7,6 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.climacell.config_flow import (
|
||||
_get_config_schema,
|
||||
_get_unique_id,
|
||||
)
|
||||
from homeassistant.components.climacell.const import (
|
||||
ATTR_CLOUD_COVER,
|
||||
ATTR_PRECIPITATION_TYPE,
|
||||
|
@ -30,8 +26,6 @@ from homeassistant.components.weather import (
|
|||
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,
|
||||
|
@ -46,7 +40,7 @@ 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_V3_ENTRY_DATA, API_V4_ENTRY_DATA
|
||||
from .const import API_V3_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
@ -69,11 +63,10 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State:
|
|||
"homeassistant.util.dt.utcnow",
|
||||
return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC),
|
||||
):
|
||||
data = _get_config_schema(hass)(config)
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
unique_id=_get_unique_id(hass, data),
|
||||
data=config,
|
||||
unique_id="test",
|
||||
version=1,
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
@ -228,166 +221,3 @@ async def test_v3_weather(
|
|||
assert weather_state.attributes[ATTR_CLOUD_COVER] == 100
|
||||
assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758
|
||||
assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain"
|
||||
|
||||
|
||||
async def test_v4_weather(
|
||||
hass: HomeAssistant,
|
||||
climacell_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] == "ClimaCell - Daily"
|
||||
assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23
|
||||
assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53
|
||||
assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1027.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"
|
||||
|
|
1
tests/components/tomorrowio/__init__.py
Normal file
1
tests/components/tomorrowio/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Tomorrow.io Weather API integration."""
|
46
tests/components/tomorrowio/conftest.py
Normal file
46
tests/components/tomorrowio/conftest.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
"""Configure py.test."""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.common import load_fixture
|
||||
|
||||
|
||||
@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", autouse=True)
|
||||
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("v4.json", "tomorrowio")),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="climacell_config_entry_update")
|
||||
def climacell_config_entry_update_fixture():
|
||||
"""Mock valid climacell config entry setup."""
|
||||
with patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.realtime",
|
||||
return_value={},
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.forecast_hourly",
|
||||
return_value={},
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.forecast_daily",
|
||||
return_value={},
|
||||
), patch(
|
||||
"homeassistant.components.climacell.ClimaCellV3.forecast_nowcast",
|
||||
return_value={},
|
||||
):
|
||||
yield
|
21
tests/components/tomorrowio/const.py
Normal file
21
tests/components/tomorrowio/const.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
"""Constants for tomorrowio tests."""
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
|
||||
API_KEY = "aa"
|
||||
|
||||
MIN_CONFIG = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
}
|
||||
|
||||
V1_ENTRY_DATA = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_LATITUDE: 80,
|
||||
CONF_LONGITUDE: 80,
|
||||
}
|
||||
|
||||
API_V4_ENTRY_DATA = {
|
||||
CONF_API_KEY: API_KEY,
|
||||
CONF_LATITUDE: 80,
|
||||
CONF_LONGITUDE: 80,
|
||||
}
|
2384
tests/components/tomorrowio/fixtures/v4.json
Normal file
2384
tests/components/tomorrowio/fixtures/v4.json
Normal file
File diff suppressed because it is too large
Load diff
279
tests/components/tomorrowio/test_config_flow.py
Normal file
279
tests/components/tomorrowio/test_config_flow.py
Normal file
|
@ -0,0 +1,279 @@
|
|||
"""Test the Tomorrow.io config flow."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from pytomorrowio.exceptions import (
|
||||
CantConnectException,
|
||||
InvalidAPIKeyException,
|
||||
RateLimitedException,
|
||||
UnknownException,
|
||||
)
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
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 (
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMESTEP,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_API_VERSION,
|
||||
CONF_LATITUDE,
|
||||
CONF_LONGITUDE,
|
||||
CONF_NAME,
|
||||
CONF_RADIUS,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .const import API_KEY, MIN_CONFIG
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.climacell.const import API_V3_ENTRY_DATA
|
||||
|
||||
|
||||
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_minimum_fields_in_zone(hass: HomeAssistant) -> None:
|
||||
"""Test user config flow with minimum fields."""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
"zone",
|
||||
{
|
||||
"zone": {
|
||||
CONF_NAME: "Home",
|
||||
CONF_LATITUDE: hass.config.latitude,
|
||||
CONF_LONGITUDE: hass.config.longitude,
|
||||
CONF_RADIUS: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
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"] == f"{DEFAULT_NAME} - Home"
|
||||
assert result["data"][CONF_NAME] == f"{DEFAULT_NAME} - Home"
|
||||
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,
|
||||
options={CONF_TIMESTEP: DEFAULT_TIMESTEP},
|
||||
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,
|
||||
options={CONF_TIMESTEP: DEFAULT_TIMESTEP},
|
||||
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
|
||||
|
||||
|
||||
async def test_import_flow_v4(hass: HomeAssistant) -> None:
|
||||
"""Test import flow for climacell v4 config entry."""
|
||||
user_config = API_V3_ENTRY_DATA.copy()
|
||||
user_config[CONF_API_VERSION] = 4
|
||||
old_entry = MockConfigEntry(
|
||||
domain=CC_DOMAIN,
|
||||
data=user_config,
|
||||
source=SOURCE_USER,
|
||||
unique_id=_get_unique_id(hass, user_config),
|
||||
version=1,
|
||||
)
|
||||
old_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(old_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert old_entry.state != ConfigEntryState.LOADED
|
||||
|
||||
assert len(hass.config_entries.async_entries(CC_DOMAIN)) == 0
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert "old_config_entry_id" not in entry.data
|
||||
assert CONF_API_VERSION not in entry.data
|
||||
|
||||
|
||||
async def test_import_flow_v3(
|
||||
hass: HomeAssistant, climacell_config_entry_update
|
||||
) -> None:
|
||||
"""Test import flow for climacell v3 config entry."""
|
||||
user_config = API_V3_ENTRY_DATA
|
||||
old_entry = MockConfigEntry(
|
||||
domain=CC_DOMAIN,
|
||||
data=user_config,
|
||||
source=SOURCE_USER,
|
||||
unique_id=_get_unique_id(hass, user_config),
|
||||
version=1,
|
||||
)
|
||||
old_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(old_entry.entry_id)
|
||||
assert old_entry.state == ConfigEntryState.LOADED
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT, "old_config_entry_id": old_entry.entry_id},
|
||||
data=old_entry.data,
|
||||
)
|
||||
|
||||
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"], {CONF_API_KEY: "this is a test"}
|
||||
)
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_API_KEY: "this is a test",
|
||||
CONF_LATITUDE: 80,
|
||||
CONF_LONGITUDE: 80,
|
||||
CONF_NAME: "ClimaCell",
|
||||
"old_config_entry_id": old_entry.entry_id,
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.config_entries.async_entries(CC_DOMAIN)) == 0
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
assert "old_config_entry_id" not in entry.data
|
||||
assert CONF_API_VERSION not in entry.data
|
151
tests/components/tomorrowio/test_init.py
Normal file
151
tests/components/tomorrowio/test_init.py
Normal file
|
@ -0,0 +1,151 @@
|
|||
"""Tests for Tomorrow.io init."""
|
||||
from homeassistant.components.climacell.const import CONF_TIMESTEP, 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, CONF_NAME
|
||||
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
|
||||
|
||||
NEW_NAME = "New Name"
|
||||
|
||||
|
||||
async def test_load_and_unload(hass: HomeAssistant) -> None:
|
||||
"""Test loading and unloading entry."""
|
||||
data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG)
|
||||
data[CONF_NAME] = "test"
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=data,
|
||||
options={CONF_TIMESTEP: 1},
|
||||
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, climacell_config_entry_update
|
||||
) -> 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
|
166
tests/components/tomorrowio/test_sensor.py
Normal file
166
tests/components/tomorrowio/test_sensor.py
Normal file
|
@ -0,0 +1,166 @@
|
|||
"""Tests for Tomorrow.io sensor entities."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
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,
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMESTEP,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
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
|
||||
|
||||
from .const import API_V4_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
CC_SENSOR_ENTITY_ID = "sensor.tomorrow_io_{}"
|
||||
|
||||
O3 = "ozone"
|
||||
CO = "carbon_monoxide"
|
||||
NO2 = "nitrogen_dioxide"
|
||||
SO2 = "sulphur_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)
|
||||
data[CONF_NAME] = DEFAULT_NAME
|
||||
config_entry = MockConfigEntry(
|
||||
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)
|
||||
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) -> None:
|
||||
"""Test v4 sensor data."""
|
||||
await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA)
|
||||
check_sensor_state(hass, O3, "94.46")
|
||||
check_sensor_state(hass, CO, "0.63")
|
||||
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")
|
||||
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.68")
|
||||
check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.97")
|
||||
check_sensor_state(hass, GHI, "0.0")
|
||||
check_sensor_state(hass, CLOUD_BASE, "1.19")
|
||||
check_sensor_state(hass, CLOUD_COVER, "100")
|
||||
check_sensor_state(hass, CLOUD_CEILING, "1.19")
|
||||
check_sensor_state(hass, WIND_GUST, "5.65")
|
||||
check_sensor_state(hass, PRECIPITATION_TYPE, "rain")
|
245
tests/components/tomorrowio/test_weather.py
Normal file
245
tests/components/tomorrowio/test_weather.py
Normal file
|
@ -0,0 +1,245 @@
|
|||
"""Tests for Tomorrow.io weather entity."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.tomorrowio.config_flow import (
|
||||
_get_config_schema,
|
||||
_get_unique_id,
|
||||
)
|
||||
from homeassistant.components.tomorrowio.const import (
|
||||
ATTRIBUTION,
|
||||
CONF_TIMESTEP,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMESTEP,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLOUDY,
|
||||
ATTR_CONDITION_RAINY,
|
||||
ATTR_CONDITION_SNOWY,
|
||||
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, 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
|
||||
|
||||
from .const import API_V4_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@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)
|
||||
data[CONF_NAME] = DEFAULT_NAME
|
||||
config_entry = MockConfigEntry(
|
||||
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)
|
||||
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) -> 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: 4.24,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 3.24,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 3.15,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 4.76,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 7.01,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY,
|
||||
ATTR_FORECAST_TIME: "2021-03-12T11:00:00+00:00",
|
||||
ATTR_FORECAST_PRECIPITATION: 0.12,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25,
|
||||
ATTR_FORECAST_TEMP: 20,
|
||||
ATTR_FORECAST_TEMP_LOW: 12,
|
||||
ATTR_FORECAST_WIND_BEARING: 217.98,
|
||||
ATTR_FORECAST_WIND_SPEED: 5.5,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 4.35,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY,
|
||||
ATTR_FORECAST_TIME: "2021-03-14T10:00:00+00:00",
|
||||
ATTR_FORECAST_PRECIPITATION: 23.96,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95,
|
||||
ATTR_FORECAST_TEMP: 6,
|
||||
ATTR_FORECAST_TEMP_LOW: 1,
|
||||
ATTR_FORECAST_WIND_BEARING: 70.25,
|
||||
ATTR_FORECAST_WIND_SPEED: 7.26,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY,
|
||||
ATTR_FORECAST_TIME: "2021-03-15T10:00:00+00:00",
|
||||
ATTR_FORECAST_PRECIPITATION: 1.46,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55,
|
||||
ATTR_FORECAST_TEMP: 6,
|
||||
ATTR_FORECAST_TEMP_LOW: -1,
|
||||
ATTR_FORECAST_WIND_BEARING: 84.47,
|
||||
ATTR_FORECAST_WIND_SPEED: 7.1,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 3.0,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 3.25,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_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: 2.94,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY,
|
||||
ATTR_FORECAST_TIME: "2021-03-19T10:00:00+00:00",
|
||||
ATTR_FORECAST_PRECIPITATION: 2.93,
|
||||
ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55,
|
||||
ATTR_FORECAST_TEMP: 9,
|
||||
ATTR_FORECAST_TEMP_LOW: 4,
|
||||
ATTR_FORECAST_WIND_BEARING: 68.54,
|
||||
ATTR_FORECAST_WIND_SPEED: 6.22,
|
||||
},
|
||||
{
|
||||
ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY,
|
||||
ATTR_FORECAST_TIME: "2021-03-20T10:00:00+00:00",
|
||||
ATTR_FORECAST_PRECIPITATION: 1.22,
|
||||
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: 7.76,
|
||||
},
|
||||
]
|
||||
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] == 102776.91
|
||||
assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 7
|
||||
assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.12
|
||||
assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14
|
||||
assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 4.17
|
Loading…
Add table
Add a link
Reference in a new issue