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:
Raman Gupta 2022-03-19 03:42:22 -04:00 committed by GitHub
parent 4578de68e7
commit 4cd4fbefbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 4895 additions and 3576 deletions

View file

@ -1026,6 +1026,8 @@ homeassistant/components/todoist/* @boralyl
tests/components/todoist/* @boralyl tests/components/todoist/* @boralyl
homeassistant/components/tolo/* @MatthiasLohr homeassistant/components/tolo/* @MatthiasLohr
tests/components/tolo/* @MatthiasLohr tests/components/tolo/* @MatthiasLohr
homeassistant/components/tomorrowio/* @raman325
tests/components/tomorrowio/* @raman325
homeassistant/components/totalconnect/* @austinmroczek homeassistant/components/totalconnect/* @austinmroczek
tests/components/totalconnect/* @austinmroczek tests/components/totalconnect/* @austinmroczek
homeassistant/components/tplink/* @rytilahti @thegardenmonkey homeassistant/components/tplink/* @rytilahti @thegardenmonkey

View file

@ -15,7 +15,8 @@ from pyclimacell.exceptions import (
UnknownException, 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 ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_API_VERSION, CONF_API_VERSION,
@ -36,22 +37,6 @@ from homeassistant.helpers.update_coordinator import (
from .const import ( from .const import (
ATTRIBUTION, 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_CLOUD_COVER,
CC_V3_ATTR_CONDITION, CC_V3_ATTR_CONDITION,
CC_V3_ATTR_HUMIDITY, CC_V3_ATTR_HUMIDITY,
@ -142,8 +127,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if params: if params:
hass.config_entries.async_update_entry(entry, **params) hass.config_entries.async_update_entry(entry, **params)
api_class = ClimaCellV3 if entry.data[CONF_API_VERSION] == 3 else ClimaCellV4 hass.async_create_task(
api = api_class( 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[CONF_API_KEY],
entry.data.get(CONF_LATITUDE, hass.config.latitude), entry.data.get(CONF_LATITUDE, hass.config.latitude),
entry.data.get(CONF_LONGITUDE, hass.config.longitude), 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 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]: if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN) hass.data.pop(DOMAIN)
@ -208,89 +206,62 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator):
"""Update data via library.""" """Update data via library."""
data: dict[str, Any] = {FORECASTS: {}} data: dict[str, Any] = {FORECASTS: {}}
try: try:
if self._api_version == 3: data[CURRENT] = await self._api.realtime(
data[CURRENT] = await self._api.realtime( [
[ CC_V3_ATTR_TEMPERATURE,
CC_V3_ATTR_TEMPERATURE, CC_V3_ATTR_HUMIDITY,
CC_V3_ATTR_HUMIDITY, CC_V3_ATTR_PRESSURE,
CC_V3_ATTR_PRESSURE, CC_V3_ATTR_WIND_SPEED,
CC_V3_ATTR_WIND_SPEED, CC_V3_ATTR_WIND_DIRECTION,
CC_V3_ATTR_WIND_DIRECTION, CC_V3_ATTR_CONDITION,
CC_V3_ATTR_CONDITION, CC_V3_ATTR_VISIBILITY,
CC_V3_ATTR_VISIBILITY, CC_V3_ATTR_OZONE,
CC_V3_ATTR_OZONE, CC_V3_ATTR_WIND_GUST,
CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_CLOUD_COVER,
CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_PRECIPITATION_TYPE,
CC_V3_ATTR_PRECIPITATION_TYPE, *(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES),
*(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES), ]
] )
) data[FORECASTS][HOURLY] = await self._api.forecast_hourly(
data[FORECASTS][HOURLY] = await self._api.forecast_hourly( [
[ CC_V3_ATTR_TEMPERATURE,
CC_V3_ATTR_TEMPERATURE, CC_V3_ATTR_WIND_SPEED,
CC_V3_ATTR_WIND_SPEED, CC_V3_ATTR_WIND_DIRECTION,
CC_V3_ATTR_WIND_DIRECTION, CC_V3_ATTR_CONDITION,
CC_V3_ATTR_CONDITION, CC_V3_ATTR_PRECIPITATION,
CC_V3_ATTR_PRECIPITATION, CC_V3_ATTR_PRECIPITATION_PROBABILITY,
CC_V3_ATTR_PRECIPITATION_PROBABILITY, ],
], None,
None, timedelta(hours=24),
timedelta(hours=24), )
)
data[FORECASTS][DAILY] = await self._api.forecast_daily( data[FORECASTS][DAILY] = await self._api.forecast_daily(
[ [
CC_V3_ATTR_TEMPERATURE, CC_V3_ATTR_TEMPERATURE,
CC_V3_ATTR_WIND_SPEED, CC_V3_ATTR_WIND_SPEED,
CC_V3_ATTR_WIND_DIRECTION, CC_V3_ATTR_WIND_DIRECTION,
CC_V3_ATTR_CONDITION, CC_V3_ATTR_CONDITION,
CC_V3_ATTR_PRECIPITATION_DAILY, CC_V3_ATTR_PRECIPITATION_DAILY,
CC_V3_ATTR_PRECIPITATION_PROBABILITY, CC_V3_ATTR_PRECIPITATION_PROBABILITY,
], ],
None, None,
timedelta(days=14), timedelta(days=14),
) )
data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast( data[FORECASTS][NOWCAST] = await self._api.forecast_nowcast(
[ [
CC_V3_ATTR_TEMPERATURE, CC_V3_ATTR_TEMPERATURE,
CC_V3_ATTR_WIND_SPEED, CC_V3_ATTR_WIND_SPEED,
CC_V3_ATTR_WIND_DIRECTION, CC_V3_ATTR_WIND_DIRECTION,
CC_V3_ATTR_CONDITION, CC_V3_ATTR_CONDITION,
CC_V3_ATTR_PRECIPITATION, CC_V3_ATTR_PRECIPITATION,
], ],
None, None,
timedelta( timedelta(
minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30) minutes=min(300, self._config_entry.options[CONF_TIMESTEP] * 30)
), ),
self._config_entry.options[CONF_TIMESTEP], 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,
],
)
except ( except (
CantConnectException, CantConnectException,
InvalidAPIKeyException, InvalidAPIKeyException,
@ -341,14 +312,6 @@ class ClimaCellEntity(CoordinatorEntity):
return items.get("value") 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 @property
def attribution(self): def attribution(self):
"""Return the attribution.""" """Return the attribution."""

View file

@ -1,84 +1,15 @@
"""Config flow for ClimaCell integration.""" """Config flow for ClimaCell integration."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from pyclimacell import ClimaCellV3
from pyclimacell.exceptions import (
CantConnectException,
InvalidAPIKeyException,
RateLimitedException,
)
from pyclimacell.pyclimacell import ClimaCellV4
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries, core from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.core import callback
CONF_API_KEY,
CONF_API_VERSION,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResult 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 ( from .const import CONF_TIMESTEP, DEFAULT_TIMESTEP, DOMAIN
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)}"
)
class ClimaCellOptionsConfigFlow(config_entries.OptionsFlow): class ClimaCellOptionsConfigFlow(config_entries.OptionsFlow):
@ -117,45 +48,3 @@ class ClimaCellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> ClimaCellOptionsConfigFlow: ) -> ClimaCellOptionsConfigFlow:
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return ClimaCellOptionsConfigFlow(config_entry) 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,
)

View file

@ -5,17 +5,7 @@ from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
from pyclimacell.const import ( from pyclimacell.const import DAILY, HOURLY, NOWCAST, V3PollenIndex
DAILY,
HOURLY,
NOWCAST,
HealthConcernType,
PollenIndex,
PrecipitationType,
PrimaryPollutantType,
V3PollenIndex,
WeatherCode,
)
from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription
from homeassistant.components.weather import ( from homeassistant.components.weather import (
@ -37,22 +27,7 @@ from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION, 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" CONF_TIMESTEP = "timestep"
FORECAST_TYPES = [DAILY, HOURLY, NOWCAST] FORECAST_TYPES = [DAILY, HOURLY, NOWCAST]
@ -78,75 +53,6 @@ ATTR_WIND_GUST = "wind_gust"
ATTR_CLOUD_COVER = "cloud_cover" ATTR_CLOUD_COVER = "cloud_cover"
ATTR_PRECIPITATION_TYPE = "precipitation_type" 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 @dataclass
class ClimaCellSensorEntityDescription(SensorEntityDescription): 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 # V3 constants
CONDITIONS_V3 = { CONDITIONS_V3 = {
"breezy": ATTR_CONDITION_WINDY, "breezy": ATTR_CONDITION_WINDY,

View file

@ -1,9 +1,10 @@
{ {
"domain": "climacell", "domain": "climacell",
"name": "ClimaCell", "name": "ClimaCell",
"config_flow": true, "config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/climacell", "documentation": "https://www.home-assistant.io/integrations/climacell",
"requirements": ["pyclimacell==0.18.2"], "requirements": ["pyclimacell==0.18.2"],
"after_dependencies": ["tomorrowio"],
"codeowners": ["@raman325"], "codeowners": ["@raman325"],
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyclimacell"] "loggers": ["pyclimacell"]

View file

@ -1,8 +1,6 @@
"""Sensor component that handles additional ClimaCell data for your location.""" """Sensor component that handles additional ClimaCell data for your location."""
from __future__ import annotations from __future__ import annotations
from abc import abstractmethod
from pyclimacell.const import CURRENT from pyclimacell.const import CURRENT
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
@ -13,12 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity
from .const import ( from .const import CC_V3_SENSOR_TYPES, DOMAIN, ClimaCellSensorEntityDescription
CC_SENSOR_TYPES,
CC_V3_SENSOR_TYPES,
DOMAIN,
ClimaCellSensorEntityDescription,
)
async def async_setup_entry( async def async_setup_entry(
@ -28,24 +21,18 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up a config entry.""" """Set up a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator = hass.data[DOMAIN][config_entry.entry_id]
api_class: type[BaseClimaCellSensorEntity] api_version = config_entry.data[CONF_API_VERSION]
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
entities = [ entities = [
api_class(hass, config_entry, coordinator, api_version, description) ClimaCellV3SensorEntity(
for description in sensor_types hass, config_entry, coordinator, api_version, description
)
for description in CC_V3_SENSOR_TYPES
] ]
async_add_entities(entities) async_add_entities(entities)
class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): class ClimaCellV3SensorEntity(ClimaCellEntity, SensorEntity):
"""Base ClimaCell sensor entity.""" """Sensor entity that talks to ClimaCell v3 API to retrieve non-weather data."""
entity_description: ClimaCellSensorEntityDescription entity_description: ClimaCellSensorEntityDescription
@ -72,15 +59,12 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity):
else description.unit_imperial else description.unit_imperial
) )
@property
@abstractmethod
def _state(self) -> str | int | float | None:
"""Return the raw state."""
@property @property
def native_value(self) -> str | int | float | None: def native_value(self) -> str | int | float | None:
"""Return the state.""" """Return the state."""
state = self._state state = self._get_cc_value(
self.coordinator.data[CURRENT], self.entity_description.key
)
if ( if (
state is not None state is not None
and not isinstance(state, str) 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 self.entity_description.value_map(state).name.lower() # type: ignore[misc]
return state 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
)

View file

@ -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": { "options": {
"step": { "step": {
"init": { "init": {

View file

@ -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": { "options": {
"step": { "step": {
"init": { "init": {
@ -29,6 +9,5 @@
"title": "Update ClimaCell Options" "title": "Update ClimaCell Options"
} }
} }
}, }
"title": "ClimaCell"
} }

View file

@ -6,15 +6,7 @@ from collections.abc import Mapping
from datetime import datetime from datetime import datetime
from typing import Any, cast from typing import Any, cast
from pyclimacell.const import ( from pyclimacell.const import CURRENT, DAILY, FORECASTS, HOURLY, NOWCAST
CURRENT,
DAILY,
FORECASTS,
HOURLY,
NOWCAST,
PrecipitationType,
WeatherCode,
)
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION, ATTR_FORECAST_CONDITION,
@ -54,22 +46,6 @@ from .const import (
ATTR_CLOUD_COVER, ATTR_CLOUD_COVER,
ATTR_PRECIPITATION_TYPE, ATTR_PRECIPITATION_TYPE,
ATTR_WIND_GUST, 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_CLOUD_COVER,
CC_V3_ATTR_CONDITION, CC_V3_ATTR_CONDITION,
CC_V3_ATTR_HUMIDITY, CC_V3_ATTR_HUMIDITY,
@ -88,12 +64,10 @@ from .const import (
CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_WIND_GUST,
CC_V3_ATTR_WIND_SPEED, CC_V3_ATTR_WIND_SPEED,
CLEAR_CONDITIONS, CLEAR_CONDITIONS,
CONDITIONS,
CONDITIONS_V3, CONDITIONS_V3,
CONF_TIMESTEP, CONF_TIMESTEP,
DEFAULT_FORECAST_TYPE, DEFAULT_FORECAST_TYPE,
DOMAIN, DOMAIN,
MAX_FORECASTS,
) )
@ -105,10 +79,8 @@ async def async_setup_entry(
"""Set up a config entry.""" """Set up a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator = hass.data[DOMAIN][config_entry.entry_id]
api_version = config_entry.data[CONF_API_VERSION] api_version = config_entry.data[CONF_API_VERSION]
api_class = ClimaCellV3WeatherEntity if api_version == 3 else ClimaCellWeatherEntity
entities = [ 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) for forecast_type in (DAILY, HOURLY, NOWCAST)
] ]
async_add_entities(entities) async_add_entities(entities)
@ -267,154 +239,6 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity):
return self._visibility 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): class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity):
"""Entity that talks to ClimaCell v3 API to retrieve weather data.""" """Entity that talks to ClimaCell v3 API to retrieve weather data."""

View 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

View 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)

View 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."
)

View 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"
}

View 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)

View 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"
}
}
}
}
}

View 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"
}
}
}

View 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"
}
}
}
}

View file

@ -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"
}
}
}

View 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

View file

@ -55,7 +55,6 @@ FLOWS = [
"canary", "canary",
"cast", "cast",
"cert_expiry", "cert_expiry",
"climacell",
"cloudflare", "cloudflare",
"co2signal", "co2signal",
"coinbase", "coinbase",
@ -341,6 +340,7 @@ FLOWS = [
"tibber", "tibber",
"tile", "tile",
"tolo", "tolo",
"tomorrowio",
"toon", "toon",
"totalconnect", "totalconnect",
"tplink", "tplink",

View file

@ -1944,6 +1944,9 @@ pythonegardia==1.0.40
# homeassistant.components.tile # homeassistant.components.tile
pytile==2022.02.0 pytile==2022.02.0
# homeassistant.components.tomorrowio
pytomorrowio==0.1.0
# homeassistant.components.touchline # homeassistant.components.touchline
pytouchline==0.7 pytouchline==0.7

View file

@ -1244,6 +1244,9 @@ python_awair==0.2.1
# homeassistant.components.tile # homeassistant.components.tile
pytile==2022.02.0 pytile==2022.02.0
# homeassistant.components.tomorrowio
pytomorrowio==0.1.0
# homeassistant.components.traccar # homeassistant.components.traccar
pytraccar==0.10.0 pytraccar==0.10.0

View file

@ -7,36 +7,20 @@ import pytest
from tests.common import load_fixture 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") @pytest.fixture(name="climacell_config_entry_update")
def climacell_config_entry_update_fixture(): def climacell_config_entry_update_fixture():
"""Mock valid climacell config entry setup.""" """Mock valid climacell config entry setup."""
with patch( with patch(
"homeassistant.components.climacell.ClimaCellV3.realtime", "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( ), patch(
"homeassistant.components.climacell.ClimaCellV3.forecast_hourly", "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( ), patch(
"homeassistant.components.climacell.ClimaCellV3.forecast_daily", "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( ), patch(
"homeassistant.components.climacell.ClimaCellV3.forecast_nowcast", "homeassistant.components.climacell.ClimaCellV3.forecast_nowcast",
return_value=json.loads(load_fixture("climacell/v3_forecast_nowcast.json")), return_value=json.loads(load_fixture("v3_forecast_nowcast.json", "climacell")),
), patch(
"homeassistant.components.climacell.ClimaCellV4.realtime_and_all_forecasts",
return_value=json.loads(load_fixture("climacell/v4.json")),
): ):
yield yield

View file

@ -10,17 +10,6 @@ from homeassistant.const import (
API_KEY = "aa" 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 = { API_V3_ENTRY_DATA = {
CONF_NAME: "ClimaCell", CONF_NAME: "ClimaCell",
CONF_API_KEY: API_KEY, CONF_API_KEY: API_KEY,
@ -28,11 +17,3 @@ API_V3_ENTRY_DATA = {
CONF_LONGITUDE: 80, CONF_LONGITUDE: 80,
CONF_API_VERSION: 3, 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

View file

@ -1,179 +1,27 @@
"""Test the ClimaCell config flow.""" """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 import data_entry_flow
from homeassistant.components.climacell.config_flow import (
_get_config_schema,
_get_unique_id,
)
from homeassistant.components.climacell.const import ( from homeassistant.components.climacell.const import (
CONF_TIMESTEP, CONF_TIMESTEP,
DEFAULT_NAME,
DEFAULT_TIMESTEP, DEFAULT_TIMESTEP,
DOMAIN, DOMAIN,
) )
from homeassistant.config_entries import SOURCE_USER 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 homeassistant.core import HomeAssistant
from .const import API_KEY, MIN_CONFIG from .const import API_V3_ENTRY_DATA
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_user_flow_minimum_fields(hass: HomeAssistant) -> None: async def test_options_flow(
"""Test user config flow with minimum fields.""" hass: HomeAssistant, climacell_config_entry_update: None
result = await hass.config_entries.flow.async_init( ) -> None:
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:
"""Test options config flow for climacell.""" """Test options config flow for climacell."""
user_config = _get_config_schema(hass)(MIN_CONFIG)
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=user_config, data=API_V3_ENTRY_DATA,
source=SOURCE_USER, source=SOURCE_USER,
unique_id=_get_unique_id(hass, user_config), unique_id="test",
version=1, version=1,
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)

View file

@ -1,16 +1,14 @@
"""Tests for Climacell init.""" """Tests for Climacell init."""
from unittest.mock import patch
import pytest 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.climacell.const import CONF_TIMESTEP, DOMAIN
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.const import CONF_API_VERSION from homeassistant.const import CONF_API_VERSION
from homeassistant.core import HomeAssistant 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 from tests.common import MockConfigEntry
@ -20,11 +18,10 @@ async def test_load_and_unload(
climacell_config_entry_update: pytest.fixture, climacell_config_entry_update: pytest.fixture,
) -> None: ) -> None:
"""Test loading and unloading entry.""" """Test loading and unloading entry."""
data = _get_config_schema(hass)(MIN_CONFIG)
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=data, data=API_V3_ENTRY_DATA,
unique_id=_get_unique_id(hass, data), unique_id="test",
version=1, version=1,
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -42,11 +39,10 @@ async def test_v3_load_and_unload(
climacell_config_entry_update: pytest.fixture, climacell_config_entry_update: pytest.fixture,
) -> None: ) -> None:
"""Test loading and unloading v3 entry.""" """Test loading and unloading v3 entry."""
data = _get_config_schema(hass)(API_V3_ENTRY_DATA)
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=data, data={k: v for k, v in API_V3_ENTRY_DATA.items() if k != CONF_API_VERSION},
unique_id=_get_unique_id(hass, data), unique_id="test",
version=1, version=1,
) )
config_entry.add_to_hass(hass) 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 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( @pytest.mark.parametrize(
"old_timestep, new_timestep", [(2, 1), (7, 5), (20, 15), (21, 30)] "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.""" """Test migration to standardized timestep."""
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=V1_ENTRY_DATA, data=API_V3_ENTRY_DATA,
options={CONF_TIMESTEP: old_timestep}, options={CONF_TIMESTEP: old_timestep},
unique_id=_get_unique_id(hass, V1_ENTRY_DATA), unique_id="test",
version=1, version=1,
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)

View file

@ -7,10 +7,6 @@ from unittest.mock import patch
import pytest 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.climacell.const import ATTRIBUTION, DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import ATTR_ATTRIBUTION 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.helpers.entity_registry import async_get
from homeassistant.util import dt as dt_util 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 from tests.common import MockConfigEntry
@ -105,11 +101,10 @@ async def _setup(
"homeassistant.util.dt.utcnow", "homeassistant.util.dt.utcnow",
return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC),
): ):
data = _get_config_schema(hass)(config)
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=data, data=config,
unique_id=_get_unique_id(hass, data), unique_id="test",
version=1, version=1,
) )
config_entry.add_to_hass(hass) 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, GRASS_POLLEN, "minimal_to_none")
check_sensor_state(hass, WEED_POLLEN, "minimal_to_none") check_sensor_state(hass, WEED_POLLEN, "minimal_to_none")
check_sensor_state(hass, TREE_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")

View file

@ -7,10 +7,6 @@ from unittest.mock import patch
import pytest import pytest
from homeassistant.components.climacell.config_flow import (
_get_config_schema,
_get_unique_id,
)
from homeassistant.components.climacell.const import ( from homeassistant.components.climacell.const import (
ATTR_CLOUD_COVER, ATTR_CLOUD_COVER,
ATTR_PRECIPITATION_TYPE, ATTR_PRECIPITATION_TYPE,
@ -30,8 +26,6 @@ from homeassistant.components.weather import (
ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_HUMIDITY,
ATTR_WEATHER_OZONE, ATTR_WEATHER_OZONE,
ATTR_WEATHER_PRESSURE, ATTR_WEATHER_PRESSURE,
@ -46,7 +40,7 @@ from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.entity_registry import async_get from homeassistant.helpers.entity_registry import async_get
from homeassistant.util import dt as dt_util 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 from tests.common import MockConfigEntry
@ -69,11 +63,10 @@ async def _setup(hass: HomeAssistant, config: dict[str, Any]) -> State:
"homeassistant.util.dt.utcnow", "homeassistant.util.dt.utcnow",
return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC), return_value=datetime(2021, 3, 6, 23, 59, 59, tzinfo=dt_util.UTC),
): ):
data = _get_config_schema(hass)(config)
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data=data, data=config,
unique_id=_get_unique_id(hass, data), unique_id="test",
version=1, version=1,
) )
config_entry.add_to_hass(hass) 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_CLOUD_COVER] == 100
assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758 assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758
assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" 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"

View file

@ -0,0 +1 @@
"""Tests for the Tomorrow.io Weather API integration."""

View 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

View 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,
}

File diff suppressed because it is too large Load diff

View 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

View 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

View 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")

View 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