diff --git a/CODEOWNERS b/CODEOWNERS index 0e92885d247a3..4563d8f132de5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -205,6 +205,7 @@ homeassistant/components/hassio/* @home-assistant/supervisor homeassistant/components/heatmiser/* @andylockran homeassistant/components/heos/* @andrewsayre homeassistant/components/here_travel_time/* @eifinger +homeassistant/components/here_weather/* @eifinger homeassistant/components/hikvision/* @mezz64 homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index 9a3e8bd482717..3d29299a95bac 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -2,7 +2,7 @@ "domain": "here_travel_time", "name": "HERE Travel Time", "documentation": "https://www.home-assistant.io/integrations/here_travel_time", - "requirements": ["herepy==2.0.0"], + "requirements": ["herepy==3.5.3"], "codeowners": ["@eifinger"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/here_weather/__init__.py b/homeassistant/components/here_weather/__init__.py new file mode 100644 index 0000000000000..cc832f83006c2 --- /dev/null +++ b/homeassistant/components/here_weather/__init__.py @@ -0,0 +1,107 @@ +"""The here_weather component.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import async_timeout +import herepy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_MODES, DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor", "weather"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up here_weather from a config entry.""" + here_weather_coordinators = {} + for mode in CONF_MODES: + coordinator = HEREWeatherDataUpdateCoordinator(hass, entry, mode) + await coordinator.async_config_entry_first_refresh() + here_weather_coordinators[mode] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = here_weather_coordinators + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class HEREWeatherDataUpdateCoordinator(DataUpdateCoordinator): + """Get the latest data from HERE.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, mode: str) -> None: + """Initialize the data object.""" + self.here_client = herepy.DestinationWeatherApi(entry.data[CONF_API_KEY]) + self.latitude = entry.data[CONF_LATITUDE] + self.longitude = entry.data[CONF_LONGITUDE] + self.weather_product_type = herepy.WeatherProductType[mode] + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + async def _async_update_data(self) -> list: + """Perform data update.""" + try: + async with async_timeout.timeout(10): + data = await self.hass.async_add_executor_job(self._get_data) + return data + except herepy.InvalidRequestError as error: + raise UpdateFailed( + f"Unable to fetch data from HERE: {error.message}" + ) from error + + def _get_data(self): + """Get the latest data from HERE.""" + is_metric = self.hass.config.units.name == CONF_UNIT_SYSTEM_METRIC + data = self.here_client.weather_for_coordinates( + self.latitude, + self.longitude, + self.weather_product_type, + metric=is_metric, + ) + return extract_data_from_payload_for_product_type( + data, self.weather_product_type + ) + + +def extract_data_from_payload_for_product_type( + data: herepy.DestinationWeatherResponse, product_type: herepy.WeatherProductType +) -> list: + """Extract the actual data from the HERE payload.""" + if product_type == herepy.WeatherProductType.forecast_astronomy: + return data.astronomy["astronomy"] + if product_type == herepy.WeatherProductType.observation: + return data.observations["location"][0]["observation"] + if product_type == herepy.WeatherProductType.forecast_7days: + return data.forecasts["forecastLocation"]["forecast"] + if product_type == herepy.WeatherProductType.forecast_7days_simple: + return data.dailyForecasts["forecastLocation"]["forecast"] + if product_type == herepy.WeatherProductType.forecast_hourly: + return data.hourlyForecasts["forecastLocation"]["forecast"] + _LOGGER.debug("Payload malformed: %s", data) + raise UpdateFailed("Payload malformed") diff --git a/homeassistant/components/here_weather/config_flow.py b/homeassistant/components/here_weather/config_flow.py new file mode 100644 index 0000000000000..d07cbd5f07647 --- /dev/null +++ b/homeassistant/components/here_weather/config_flow.py @@ -0,0 +1,82 @@ +"""Config flow for here_weather integration.""" +from __future__ import annotations + +import herepy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv + +from .const import DEFAULT_MODE, DOMAIN + + +async def async_validate_user_input(hass: HomeAssistant, user_input: dict) -> None: + """Validate the user_input containing coordinates.""" + here_client = herepy.DestinationWeatherApi(user_input[CONF_API_KEY]) + await hass.async_add_executor_job( + here_client.weather_for_coordinates, + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], + herepy.WeatherProductType[DEFAULT_MODE], + ) + + +class HereWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for here_weather.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + await self.async_set_unique_id(_unique_id(user_input)) + self._abort_if_unique_id_configured() + try: + await async_validate_user_input(self.hass, user_input) + except herepy.InvalidRequestError: + errors["base"] = "invalid_request" + except herepy.UnauthorizedError: + errors["base"] = "unauthorized" + else: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + return self.async_show_form( + step_id="user", + data_schema=self._get_schema(user_input), + errors=errors, + ) + + def _get_schema(self, user_input: dict | None) -> vol.Schema: + if user_input is not None: + return vol.Schema( + { + vol.Required(CONF_API_KEY, default=user_input[CONF_API_KEY]): str, + vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str, + vol.Required( + CONF_LATITUDE, default=user_input[CONF_LATITUDE] + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=user_input[CONF_LONGITUDE] + ): cv.longitude, + } + ) + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_NAME, default=DOMAIN): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + +def _unique_id(user_input: dict) -> str: + return f"{user_input[CONF_LATITUDE]}_{user_input[CONF_LONGITUDE]}" diff --git a/homeassistant/components/here_weather/const.py b/homeassistant/components/here_weather/const.py new file mode 100644 index 0000000000000..9f9684dd233d1 --- /dev/null +++ b/homeassistant/components/here_weather/const.py @@ -0,0 +1,500 @@ +"""Constants for the HERE Destination Weather service.""" +from __future__ import annotations + +from homeassistant.const import ( + DEGREE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + LENGTH_CENTIMETERS, + LENGTH_KILOMETERS, + PERCENTAGE, + PRESSURE_MBAR, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) + +DOMAIN = "here_weather" + +DEFAULT_SCAN_INTERVAL = 1800 # 30 minutes + +MODE_ASTRONOMY = "forecast_astronomy" +MODE_HOURLY = "forecast_hourly" +MODE_DAILY = "forecast_7days" +MODE_DAILY_SIMPLE = "forecast_7days_simple" +MODE_OBSERVATION = "observation" +CONF_MODES = [ + MODE_ASTRONOMY, + MODE_HOURLY, + MODE_DAILY, + MODE_DAILY_SIMPLE, + MODE_OBSERVATION, +] +DEFAULT_MODE = MODE_DAILY_SIMPLE + +ASTRONOMY_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "sunrise": {"name": "Sunrise", "unit_of_measurement": None, "device_class": None}, + "sunset": {"name": "Sunset", "unit_of_measurement": None, "device_class": None}, + "moonrise": {"name": "Moonrise", "unit_of_measurement": None, "device_class": None}, + "moonset": {"name": "Moonset", "unit_of_measurement": None, "device_class": None}, + "moonPhase": { + "name": "Moon Phase", + "unit_of_measurement": PERCENTAGE, + "device_class": None, + }, + "moonPhaseDesc": { + "name": "Moon Phase Description", + "unit_of_measurement": None, + "device_class": None, + }, + "city": {"name": "City", "unit_of_measurement": None, "device_class": None}, + "latitude": {"name": "Latitude", "unit_of_measurement": None, "device_class": None}, + "longitude": { + "name": "Longitude", + "unit_of_measurement": None, + "device_class": None, + }, + "utcTime": { + "name": "UTC Time", + "unit_of_measurement": None, + "device_class": DEVICE_CLASS_TIMESTAMP, + }, +} + +COMMON_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "daylight": {"name": "Daylight", "unit_of_measurement": None, "device_class": None}, + "description": { + "name": "Description", + "unit_of_measurement": None, + "device_class": None, + }, + "skyInfo": {"name": "Sky Info", "unit_of_measurement": None, "device_class": None}, + "skyDescription": { + "name": "Sky Description", + "unit_of_measurement": None, + "device_class": None, + }, + "temperatureDesc": { + "name": "Temperature Description", + "unit_of_measurement": None, + "device_class": None, + }, + "comfort": { + "name": "Comfort", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "humidity": { + "name": "Humidity", + "unit_of_measurement": PERCENTAGE, + "device_class": DEVICE_CLASS_HUMIDITY, + }, + "dewPoint": { + "name": "Dew Point", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "precipitationProbability": { + "name": "Precipitation Probability", + "unit_of_measurement": PERCENTAGE, + "device_class": None, + }, + "precipitationDesc": { + "name": "Precipitation Description", + "unit_of_measurement": None, + "device_class": None, + }, + "airDescription": { + "name": "Air Description", + "unit_of_measurement": None, + "device_class": None, + }, + "windSpeed": { + "name": "Wind Speed", + "unit_of_measurement": SPEED_KILOMETERS_PER_HOUR, + "device_class": None, + }, + "windDirection": { + "name": "Wind Direction", + "unit_of_measurement": DEGREE, + "device_class": None, + }, + "windDesc": { + "name": "Wind Description", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "windDescShort": { + "name": "Wind Description Short", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "icon": {"name": "Icon", "unit_of_measurement": None, "device_class": None}, + "iconName": { + "name": "Icon Name", + "unit_of_measurement": None, + "device_class": None, + }, + "iconLink": { + "name": "Icon Link", + "unit_of_measurement": None, + "device_class": None, + }, +} + +NON_OBSERVATION_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "rainFall": { + "name": "Rain Fall", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "snowFall": { + "name": "Snow Fall", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitationProbability": { + "name": "Precipitation Probability", + "unit_of_measurement": PERCENTAGE, + "device_class": None, + }, + "dayOfWeek": { + "name": "Day of Week", + "unit_of_measurement": None, + "device_class": None, + }, + "weekday": {"name": "Week Day", "unit_of_measurement": None, "device_class": None}, + "utcTime": { + "name": "UTC Time", + "unit_of_measurement": None, + "device_class": DEVICE_CLASS_TIMESTAMP, + }, + **COMMON_ATTRIBUTES, +} + +HOURLY_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "temperature": { + "name": "Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "airInfo": {"name": "Air Info", "unit_of_measurement": None, "device_class": None}, + "visibility": { + "name": "Visibility", + "unit_of_measurement": LENGTH_KILOMETERS, + "device_class": None, + }, + **NON_OBSERVATION_ATTRIBUTES, +} + +DAILY_SIMPLE_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "highTemperature": { + "name": "High Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "lowTemperature": { + "name": "Low Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "beaufortScale": { + "name": "Beaufort Scale", + "unit_of_measurement": None, + "device_class": None, + }, + "beaufortDescription": { + "name": "Beaufort Scale Description", + "unit_of_measurement": None, + "device_class": None, + }, + "uvIndex": {"name": "UV Index", "unit_of_measurement": None, "device_class": None}, + "uvDesc": { + "name": "UV Index Description", + "unit_of_measurement": None, + "device_class": None, + }, + "barometerPressure": { + "name": "Barometric Pressure", + "unit_of_measurement": PRESSURE_MBAR, + "device_class": DEVICE_CLASS_PRESSURE, + }, + **NON_OBSERVATION_ATTRIBUTES, +} + +DAILY_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "daySegment": { + "name": "Day Segment", + "unit_of_measurement": None, + "device_class": None, + }, + "temperature": { + "name": "Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "beaufortScale": { + "name": "Beaufort Scale", + "unit_of_measurement": None, + "device_class": None, + }, + "beaufortDescription": { + "name": "Beaufort Scale Description", + "unit_of_measurement": None, + "device_class": None, + }, + "visibility": { + "name": "Visibility", + "unit_of_measurement": LENGTH_KILOMETERS, + "device_class": None, + }, + **NON_OBSERVATION_ATTRIBUTES, +} + +OBSERVATION_ATTRIBUTES: dict[str, dict[str, str | None]] = { + "temperature": { + "name": "Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "highTemperature": { + "name": "High Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "lowTemperature": { + "name": "Low Temperature", + "unit_of_measurement": TEMP_CELSIUS, + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "precipitation1H": { + "name": "Precipitation Over 1 Hour", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation3H": { + "name": "Precipitation Over 3 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation6H": { + "name": "Precipitation Over 6 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation12H": { + "name": "Precipitation Over 12 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "precipitation24H": { + "name": "Precipitation Over 24 Hours", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "barometerPressure": { + "name": "Barometric Pressure", + "unit_of_measurement": PRESSURE_MBAR, + "device_class": DEVICE_CLASS_PRESSURE, + }, + "barometerTrend": { + "name": "Barometric Pressure Trend", + "unit_of_measurement": None, + "device_class": None, + }, + "visibility": { + "name": "Visibility", + "unit_of_measurement": LENGTH_KILOMETERS, + "device_class": None, + }, + "snowCover": { + "name": "Snow Cover", + "unit_of_measurement": LENGTH_CENTIMETERS, + "device_class": None, + }, + "activeAlerts": { + "name": "Active Alerts", + "unit_of_measurement": None, + "device_class": None, + }, + "country": {"name": "Country", "unit_of_measurement": None, "device_class": None}, + "state": {"name": "State", "unit_of_measurement": None, "device_class": None}, + "city": {"name": "City", "unit_of_measurement": None, "device_class": None}, + **COMMON_ATTRIBUTES, +} + +SENSOR_TYPES: dict[str, dict[str, dict[str, str | None]]] = { + MODE_ASTRONOMY: ASTRONOMY_ATTRIBUTES, + MODE_HOURLY: HOURLY_ATTRIBUTES, + MODE_DAILY_SIMPLE: DAILY_SIMPLE_ATTRIBUTES, + MODE_DAILY: DAILY_ATTRIBUTES, + MODE_OBSERVATION: OBSERVATION_ATTRIBUTES, +} + +CONDITION_CLASSES: dict[str, list[str]] = { + "clear-night": [ + "night_passing_clouds", + "night_mostly_clear", + "night_clearing_skies", + "night_clear", + ], + "cloudy": [ + "night_decreasing_cloudiness", + "night_mostly_cloudy", + "night_morning_clouds", + "night_afternoon_clouds", + "night_high_clouds", + "night_high_level_clouds", + "low_clouds", + "overcast", + "cloudy", + "afternoon_clouds", + "morning_clouds", + "high_level_clouds", + "high_clouds", + ], + "fog": [ + "night_low_level_haze", + "night_smoke", + "night_haze", + "dense_fog", + "fog", + "light_fog", + "early_fog", + "early_fog_followed_by_sunny_skies", + "low_level_haze", + "smoke", + "haze", + "ice_fog", + "hazy_sunshine", + ], + "hail": ["hail"], + "lightning": [ + "night_tstorms", + "night_scattered_tstorms", + "scattered_tstorms", + "night_a_few_tstorms", + "night_isolated_tstorms", + "night_widely_scattered_tstorms", + "tstorms_early", + "isolated_tstorms_late", + "scattered_tstorms_late", + "widely_scattered_tstorms", + "isolated_tstorms", + "a_few_tstorms", + ], + "lightning-rainy": ["thundershowers", "thunderstorms", "tstorms_late", "tstorms"], + "partlycloudy": [ + "partly_sunny", + "mostly_cloudy", + "broken_clouds", + "more_clouds_than_sun", + "night_broken_clouds", + "increasing_cloudiness", + "night_partly_cloudy", + "night_scattered_clouds", + "passing_clounds", + "more_sun_than_clouds", + "scattered_clouds", + "partly_cloudy", + "a_mixture_of_sun_and_clouds", + "increasing_cloudiness", + "decreasing_cloudiness", + "clearing_skies", + "breaks_of_sun_late", + ], + "pouring": [ + "heavy_rain_late", + "heavy_rain_early", + "tons_of_rain", + "lots_of_rain", + "heavy_rain", + "heavy_rain_early", + "heavy_rain_early", + ], + "rainy": [ + "rain_late", + "showers_late", + "rain_early", + "showery", + "showers_early", + "numerous_showers", + "rain", + "light_rain_late", + "sprinkles_late", + "light_rain_early", + "sprinkles_early", + "light_rain", + "sprinkles", + "drizzle", + "night_showers", + "night_sprinkles", + "night_rain_showers", + "night_passing_showers", + "night_light_showers", + "night_a_few_showers", + "night_scattered_showers", + "rain_early", + "scattered_showers", + "a_few_showers", + "light_showers", + "passing_showers", + "rain_showers", + "showers", + ], + "snowy": [ + "heavy_snow_late", + "heavy_snow_early", + "heavy_snow", + "snow_late", + "snow_early", + "moderate_snow", + "snow", + "light_snow_late", + "snow_showers_late", + "flurries_late", + "light_snow_early", + "flurries_early", + "light_snow", + "snow_flurries", + "sleet", + "an_icy_mix_changing_to_snow", + "an_icy_mix_changing_to_rain", + "rain_changing_to_snow", + "icy_mix_early", + "light_icy_mix_late", + "icy_mix_late", + "scattered_flurries", + ], + "snowy-rainy": [ + "freezing_rain", + "light_freezing_rain", + "snow_showers_early", + "snow_showers", + "light_snow_showers", + "snow_rain_mix", + "light_icy_mix_early", + "rain_changing_to_an_icy_mix", + "snow_changing_to_an_icy_mix", + "snow_changing_to_rain", + "heavy_mixture_of_precip", + "light_mixture_of_precip", + "icy_mix", + "mixture_of_precip", + ], + "sunny": ["sunny", "clear", "mostly_sunny", "mostly_clear"], + "windy": ["strong_thunderstorms", "severe_thunderstorms"], + "windy-variant": [], + "exceptional": [ + "blizzard", + "snowstorm", + "duststorm", + "sandstorm", + "hurricane", + "tropical_storm", + "flood", + "flash_floods", + "tornado", + ], +} diff --git a/homeassistant/components/here_weather/manifest.json b/homeassistant/components/here_weather/manifest.json new file mode 100644 index 0000000000000..690faf62c630d --- /dev/null +++ b/homeassistant/components/here_weather/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "here_weather", + "name": "HERE Destination Weather", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/here_weather", + "requirements": [ + "herepy==3.5.3" + ], + "codeowners": [ + "@eifinger" + ], + "iot_class": "cloud_polling" + } diff --git a/homeassistant/components/here_weather/sensor.py b/homeassistant/components/here_weather/sensor.py new file mode 100644 index 0000000000000..62bbf2413dd46 --- /dev/null +++ b/homeassistant/components/here_weather/sensor.py @@ -0,0 +1,116 @@ +"""Sensor platform for the HERE Destination Weather service.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN, SENSOR_TYPES +from .utils import convert_unit_of_measurement_if_needed, get_attribute_from_here_data + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Add here_weather entities from a ConfigEntry.""" + here_weather_coordinators = hass.data[DOMAIN][entry.entry_id] + + sensors_to_add = [] + for sensor_type, weather_attributes in SENSOR_TYPES.items(): + for weather_attribute in weather_attributes: + sensors_to_add.append( + HEREDestinationWeatherSensor( + entry, + here_weather_coordinators[sensor_type], + sensor_type, + weather_attribute, + ) + ) + async_add_entities(sensors_to_add) + + +class HEREDestinationWeatherSensor(CoordinatorEntity, SensorEntity): + """Implementation of an HERE Destination Weather sensor.""" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + sensor_type: str, + weather_attribute: str, + sensor_number: int = 0, # Additional supported offsets will be added in a separate PR + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._base_name = entry.data[CONF_NAME] + self._name_suffix = SENSOR_TYPES[sensor_type][weather_attribute]["name"] + self._latitude = entry.data[CONF_LATITUDE] + self._longitude = entry.data[CONF_LONGITUDE] + self._sensor_type = sensor_type + self._sensor_number = sensor_number + self._weather_attribute = weather_attribute + self._unit_of_measurement = convert_unit_of_measurement_if_needed( + self.coordinator.hass.config.units.name, + SENSOR_TYPES[sensor_type][weather_attribute]["unit_of_measurement"], + ) + self._device_class = SENSOR_TYPES[sensor_type][weather_attribute][ + "device_class" + ] + self._unique_id = "".join( + f"{self._latitude}_{self._longitude}_{self._sensor_type}_{self._name_suffix}_{self._sensor_number}".lower().split() + ) + self._unique_device_id = "".join( + f"{self._latitude}_{self._longitude}_{self._sensor_type}".lower().split() + ) + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"{self._base_name} {self._sensor_type} {self._name_suffix} {self._sensor_number}" + + @property + def unique_id(self) -> str: + """Set unique_id for sensor.""" + return self._unique_id + + @property + def state(self) -> StateType: + """Return the state of the device.""" + return get_attribute_from_here_data( + self.coordinator.data, + self._weather_attribute, + self._sensor_number, + ) + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + + return { + "identifiers": {(DOMAIN, self._unique_device_id)}, + "name": f"{self._base_name} {self._sensor_type}", + "manufacturer": "here.com", + "entry_type": "service", + } + + @property + def device_class(self) -> str | None: + """Return the class of this device.""" + return self._device_class diff --git a/homeassistant/components/here_weather/strings.json b/homeassistant/components/here_weather/strings.json new file mode 100644 index 0000000000000..e1b7f1b5613cc --- /dev/null +++ b/homeassistant/components/here_weather/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "description": "[%key:common::config_flow::description::confirm_setup%]", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "name": "[%key:common::config_flow::data::name%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + } + }, + "error": { + "invalid_request": "HERE reported an invalid request. This indicates the supplied location is not valid.", + "unauthorized": "Invalid credentials. This error is returned if the specified token was invalid or no contract could be found for this token." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/here_weather/utils.py b/homeassistant/components/here_weather/utils.py new file mode 100644 index 0000000000000..13dc85ebb7393 --- /dev/null +++ b/homeassistant/components/here_weather/utils.py @@ -0,0 +1,62 @@ +"""Utility functions for here_weather.""" +from __future__ import annotations + +from homeassistant.const import ( + CONF_UNIT_SYSTEM_METRIC, + LENGTH_CENTIMETERS, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + PRESSURE_INHG, + PRESSURE_MBAR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + + +def convert_temperature_unit_of_measurement_if_needed( + unit_system: str, unit_of_measurement: str +) -> str: + """Convert the temperature unit of measurement to imperial if configured.""" + if unit_system != CONF_UNIT_SYSTEM_METRIC: + unit_of_measurement = TEMP_FAHRENHEIT + return unit_of_measurement + + +def convert_unit_of_measurement_if_needed( + unit_system: str, unit_of_measurement: str | None +) -> str | None: + """Convert the unit of measurement to imperial if configured.""" + if unit_system != CONF_UNIT_SYSTEM_METRIC: + if unit_of_measurement == TEMP_CELSIUS: + unit_of_measurement = TEMP_FAHRENHEIT + elif unit_of_measurement == LENGTH_CENTIMETERS: + unit_of_measurement = LENGTH_INCHES + elif unit_of_measurement == SPEED_KILOMETERS_PER_HOUR: + unit_of_measurement = SPEED_MILES_PER_HOUR + elif unit_of_measurement == PRESSURE_MBAR: + unit_of_measurement = PRESSURE_INHG + elif unit_of_measurement == LENGTH_KILOMETERS: + unit_of_measurement = LENGTH_MILES + return unit_of_measurement + + +def get_attribute_from_here_data( + here_data: list, attribute_name: str, sensor_number: int = 0 +) -> str | None: + """Extract and convert data from HERE response or None if not found.""" + try: + state = here_data[sensor_number][attribute_name] + except KeyError: + return None + state = convert_asterisk_to_none(state) + return state + + +def convert_asterisk_to_none(state: str) -> str | None: + """Convert HERE API representation of None.""" + if state == "*": + return None + return state diff --git a/homeassistant/components/here_weather/weather.py b/homeassistant/components/here_weather/weather.py new file mode 100644 index 0000000000000..524d7c3e8c590 --- /dev/null +++ b/homeassistant/components/here_weather/weather.py @@ -0,0 +1,296 @@ +"""Weather platform for the HERE Destination Weather service.""" +from __future__ import annotations + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_PRESSURE, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, + Forecast, + WeatherEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONDITION_CLASSES, + DEFAULT_MODE, + DOMAIN, + MODE_ASTRONOMY, + MODE_DAILY_SIMPLE, + SENSOR_TYPES, +) +from .utils import ( + convert_temperature_unit_of_measurement_if_needed, + get_attribute_from_here_data, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Add here_weather entities from a ConfigEntry.""" + here_weather_coordinators = hass.data[DOMAIN][entry.entry_id] + + entities_to_add = [] + for sensor_type in SENSOR_TYPES: + if sensor_type != MODE_ASTRONOMY: + entities_to_add.append( + HEREDestinationWeather( + entry, + here_weather_coordinators[sensor_type], + sensor_type, + ) + ) + async_add_entities(entities_to_add) + + +class HEREDestinationWeather(CoordinatorEntity, WeatherEntity): + """Implementation of an HERE Destination Weather WeatherEntity.""" + + def __init__( + self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, mode: str + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._name = entry.data[CONF_NAME] + self._mode = mode + self._unique_id = "".join( + f"{entry.data[CONF_LATITUDE]}_{entry.data[CONF_LONGITUDE]}_{self._mode}".lower().split() + ) + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {self._mode}" + + @property + def unique_id(self): + """Set unique_id for sensor.""" + return self._unique_id + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return get_condition_from_here_data(self.coordinator.data) + + @property + def temperature(self) -> float | None: + """Return the temperature.""" + return get_temperature_from_here_data(self.coordinator.data, self._mode) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return convert_temperature_unit_of_measurement_if_needed( + self.coordinator.hass.config.units.name, TEMP_CELSIUS + ) + + @property + def pressure(self) -> float | None: + """Return the pressure.""" + return get_pressure_from_here_data(self.coordinator.data, self._mode) + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + if ( + humidity := get_attribute_from_here_data(self.coordinator.data, "humidity") + ) is not None: + return float(humidity) + return None + + @property + def wind_speed(self) -> float | None: + """Return the wind speed.""" + return get_wind_speed_from_here_data(self.coordinator.data) + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return get_wind_bearing_from_here_data(self.coordinator.data) + + @property + def attribution(self) -> str | None: + """Return the attribution.""" + return None + + @property + def visibility(self) -> float | None: + """Return the visibility.""" + if "visibility" in SENSOR_TYPES[self._mode]: + if ( + visibility := get_attribute_from_here_data( + self.coordinator.data, "visibility" + ) + ) is not None: + return float(visibility) + return None + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast array.""" + data: list[Forecast] = [] + for offset in range(len(self.coordinator.data)): + data.append( + { + ATTR_FORECAST_CONDITION: get_condition_from_here_data( + self.coordinator.data, offset + ), + ATTR_FORECAST_TIME: get_time_from_here_data( + self.coordinator.data, offset + ), + ATTR_FORECAST_PRECIPITATION_PROBABILITY: get_precipitation_probability( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_PRECIPITATION: calc_precipitation( + self.coordinator.data, offset + ), + ATTR_FORECAST_PRESSURE: get_pressure_from_here_data( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_TEMP: get_high_or_default_temperature_from_here_data( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_TEMP_LOW: get_low_or_default_temperature_from_here_data( + self.coordinator.data, self._mode, offset + ), + ATTR_FORECAST_WIND_BEARING: get_wind_bearing_from_here_data( + self.coordinator.data, offset + ), + ATTR_FORECAST_WIND_SPEED: get_wind_speed_from_here_data( + self.coordinator.data, offset + ), + } + ) + return data + + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + return self._mode == DEFAULT_MODE + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return { + "identifiers": {(DOMAIN, self._unique_id)}, + "name": self.name, + "manufacturer": "here.com", + "entry_type": "service", + } + + +def get_wind_speed_from_here_data(here_data: list, offset: int = 0) -> float: + """Return the wind speed from here_data.""" + wind_speed = get_attribute_from_here_data(here_data, "windSpeed", offset) + assert wind_speed is not None + return float(wind_speed) + + +def get_wind_bearing_from_here_data(here_data: list, offset: int = 0) -> int: + """Return the wind bearing from here_data.""" + wind_bearing = get_attribute_from_here_data(here_data, "windDirection", offset) + assert wind_bearing is not None + return int(wind_bearing) + + +def get_time_from_here_data(here_data: list, offset: int = 0) -> str: + """Return the time from here_data.""" + time = get_attribute_from_here_data(here_data, "utcTime", offset) + assert time is not None + return time + + +def get_pressure_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the pressure from here_data.""" + if "barometerPressure" in SENSOR_TYPES[mode]: + if ( + pressure := get_attribute_from_here_data( + here_data, "barometerPressure", offset + ) + ) is not None: + return float(pressure) + return None + + +def get_precipitation_probability( + here_data: list, mode: str, offset: int = 0 +) -> int | None: + """Return the precipitation probability from here_data.""" + if "precipitationProbability" in SENSOR_TYPES[mode]: + if ( + precipitation_probability := get_attribute_from_here_data( + here_data, "precipitationProbability", offset + ) + ) is not None: + return int(precipitation_probability) + return None + + +def get_condition_from_here_data(here_data: list, offset: int = 0) -> str | None: + """Return the condition from here_data.""" + return next( + ( + k + for k, v in CONDITION_CLASSES.items() + if get_attribute_from_here_data(here_data, "iconName", offset) in v + ), + None, + ) + + +def get_high_or_default_temperature_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the temperature from here_data.""" + temperature = get_attribute_from_here_data(here_data, "highTemperature", offset) + if temperature is not None: + return float(temperature) + + return get_temperature_from_here_data(here_data, mode, offset) + + +def get_low_or_default_temperature_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the temperature from here_data.""" + temperature = get_attribute_from_here_data(here_data, "lowTemperature", offset) + if temperature is not None: + return float(temperature) + return get_temperature_from_here_data(here_data, mode, offset) + + +def get_temperature_from_here_data( + here_data: list, mode: str, offset: int = 0 +) -> float | None: + """Return the temperature from here_data.""" + if mode == MODE_DAILY_SIMPLE: + temperature = get_attribute_from_here_data(here_data, "highTemperature", offset) + else: + temperature = get_attribute_from_here_data(here_data, "temperature", offset) + if temperature is not None: + return float(temperature) + return None + + +def calc_precipitation(here_data: list, offset: int = 0) -> float | None: + """Calculate Precipitation.""" + rain_fall = get_attribute_from_here_data(here_data, "rainFall", offset) + snow_fall = get_attribute_from_here_data(here_data, "snowFall", offset) + if rain_fall is not None and snow_fall is not None: + return float(rain_fall) + float(snow_fall) + return None diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0e7b6c52cc246..dda95cdffcd64 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -109,6 +109,7 @@ "hangouts", "harmony", "heos", + "here_weather", "hisense_aehw4a1", "hive", "hlk_sw16", diff --git a/requirements_all.txt b/requirements_all.txt index a43f754b20221..f6b8452e0f3a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -766,7 +766,8 @@ hdate==0.10.2 heatmiserV3==1.1.18 # homeassistant.components.here_travel_time -herepy==2.0.0 +# homeassistant.components.here_weather +herepy==3.5.3 # homeassistant.components.hikvisioncam hikvision==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5732833e07945..c6ab3cebfc1a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -442,7 +442,8 @@ hatasmota==0.2.20 hdate==0.10.2 # homeassistant.components.here_travel_time -herepy==2.0.0 +# homeassistant.components.here_weather +herepy==3.5.3 # homeassistant.components.hlk_sw16 hlk-sw16==0.0.9 diff --git a/tests/components/here_weather/__init__.py b/tests/components/here_weather/__init__.py new file mode 100644 index 0000000000000..64cc1960b11e9 --- /dev/null +++ b/tests/components/here_weather/__init__.py @@ -0,0 +1,32 @@ +"""Tests for here_weather component.""" +import herepy + +from homeassistant.components.here_weather.const import ( + MODE_ASTRONOMY, + MODE_DAILY, + MODE_DAILY_SIMPLE, + MODE_HOURLY, + MODE_OBSERVATION, +) + +from .const import ( + astronomy_response, + daily_response, + daily_simple_forecasts_response, + hourly_response, + observation_response, +) + + +def mock_weather_for_coordinates(*args, **kwargs): + """Return mock data for request weather product type.""" + if args[2] == herepy.WeatherProductType[MODE_ASTRONOMY]: + return astronomy_response + elif args[2] == herepy.WeatherProductType[MODE_HOURLY]: + return hourly_response + elif args[2] == herepy.WeatherProductType[MODE_DAILY]: + return daily_response + elif args[2] == herepy.WeatherProductType[MODE_DAILY_SIMPLE]: + return daily_simple_forecasts_response + elif args[2] == herepy.WeatherProductType[MODE_OBSERVATION]: + return observation_response diff --git a/tests/components/here_weather/const.py b/tests/components/here_weather/const.py new file mode 100644 index 0000000000000..1360b3818e171 --- /dev/null +++ b/tests/components/here_weather/const.py @@ -0,0 +1,31 @@ +"""Constants for here_weather tests.""" +import json + +import herepy + +from tests.common import load_fixture + +daily_simple_forecasts_response = herepy.DestinationWeatherResponse.new_from_jsondict( + json.loads(load_fixture("here_weather/daily_simple_forecasts.json")), + param_defaults={"dailyForecasts": None}, +) + +astronomy_response = herepy.DestinationWeatherResponse.new_from_jsondict( + json.loads(load_fixture("here_weather/astronomy.json")), + param_defaults={"astronomy": None}, +) + +hourly_response = herepy.DestinationWeatherResponse.new_from_jsondict( + json.loads(load_fixture("here_weather/hourly.json")), + param_defaults={"hourlyForecasts": None}, +) + +observation_response = herepy.DestinationWeatherResponse.new_from_jsondict( + json.loads(load_fixture("here_weather/observation.json")), + param_defaults={"observations": None}, +) + +daily_response = herepy.DestinationWeatherResponse.new_from_jsondict( + json.loads(load_fixture("here_weather/daily.json")), + param_defaults={"forecasts": None}, +) diff --git a/tests/components/here_weather/test_config_flow.py b/tests/components/here_weather/test_config_flow.py new file mode 100644 index 0000000000000..11315ba9085ae --- /dev/null +++ b/tests/components/here_weather/test_config_flow.py @@ -0,0 +1,105 @@ +"""Tests for the here_weather config_flow.""" +from unittest.mock import patch + +import herepy + +from homeassistant import config_entries, setup +from homeassistant.components.here_weather.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + return_value=None, + ), patch( + "homeassistant.components.here_weather.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + existing_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + existing_entry.add_to_hass(hass) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == DOMAIN + assert result2["data"] == { + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: 40.79962, + CONF_LONGITUDE: -73.970314, + } + assert len(mock_setup_entry.mock_calls) == 2 + + +async def test_unauthorized(hass): + """Test handling of an unauthorized api key.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=herepy.UnauthorizedError("Unauthorized"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == "form" + config = { + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config + ) + assert result["type"] == "form" + assert result["errors"]["base"] == "unauthorized" + + +async def test_invalid_request(hass): + """Test handling of an invalid request.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=herepy.InvalidRequestError("Invalid"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == "form" + config = { + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], config + ) + assert result["type"] == "form" + assert result["errors"]["base"] == "invalid_request" diff --git a/tests/components/here_weather/test_init.py b/tests/components/here_weather/test_init.py new file mode 100644 index 0000000000000..213255fc5727f --- /dev/null +++ b/tests/components/here_weather/test_init.py @@ -0,0 +1,33 @@ +"""Tests for the here_weather integration.""" +from unittest.mock import patch + +from homeassistant.components.here_weather.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from . import mock_weather_for_coordinates + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass): + """Test unloading a config entry removes all entities.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN] + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert not hass.data[DOMAIN] diff --git a/tests/components/here_weather/test_sensor.py b/tests/components/here_weather/test_sensor.py new file mode 100644 index 0000000000000..762866c261b17 --- /dev/null +++ b/tests/components/here_weather/test_sensor.py @@ -0,0 +1,150 @@ +"""Tests for the here_weather sensor platform.""" +from datetime import timedelta +from unittest.mock import patch + +import herepy + +from homeassistant.components.here_weather.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_SCAN_INTERVAL, +) +import homeassistant.util.dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from . import mock_weather_for_coordinates + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_sensor_invalid_request(hass): + """Test that sensor value is unavailable after an invalid request.""" + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + "sensor", + DOMAIN, + "40.79962_-73.970314_forecast_7days_simple_windspeed_0", + suggested_object_id="here_weather_forecast_7days_simple_windspeed_0", + disabled_by=None, + ) + + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + sensor = hass.states.get( + "sensor.here_weather_forecast_7days_simple_windspeed_0" + ) + assert sensor.state == "12.03" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=herepy.InvalidRequestError("Invalid"), + ): + async_fire_time_changed(hass, utcnow + timedelta(DEFAULT_SCAN_INTERVAL * 2)) + await hass.async_block_till_done() + sensor = hass.states.get( + "sensor.here_weather_forecast_7days_simple_windspeed_0" + ) + assert sensor.state == "unavailable" + + +async def test_forecast_astronomy(hass): + """Test that forecast_astronomy works.""" + # Patching 'utcnow' to gain more control over the timed update. + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + "sensor", + DOMAIN, + "40.79962_-73.970314_forecast_astronomy_sunrise_0", + suggested_object_id="here_weather_forecast_astronomy_sunrise_0", + disabled_by=None, + ) + + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.here_weather_forecast_astronomy_sunrise_0") + assert sensor.state == "6:55AM" + + +async def test_imperial(hass): + """Test that imperial mode works.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + hass.config.units = IMPERIAL_SYSTEM + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + options={ + CONF_SCAN_INTERVAL: 60, + }, + ) + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + "sensor", + DOMAIN, + "40.79962_-73.970314_forecast_7days_simple_windspeed_0", + suggested_object_id="here_weather_forecast_7days_simple_windspeed_0", + disabled_by=None, + ) + + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + sensor = hass.states.get( + "sensor.here_weather_forecast_7days_simple_windspeed_0" + ) + assert sensor.attributes.get("unit_of_measurement") == "mph" diff --git a/tests/components/here_weather/test_weather.py b/tests/components/here_weather/test_weather.py new file mode 100644 index 0000000000000..79b8cd01b795d --- /dev/null +++ b/tests/components/here_weather/test_weather.py @@ -0,0 +1,108 @@ +"""Tests for the here_weather weather platform.""" +from unittest.mock import patch + +from homeassistant.components.here_weather.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.util.unit_system import IMPERIAL_SYSTEM + +from . import mock_weather_for_coordinates + +from tests.common import MockConfigEntry + + +async def test_weather(hass): + """Test that weather has a value.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + hass.config.units = IMPERIAL_SYSTEM + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + sensor = hass.states.get("weather.here_weather_forecast_7days_simple") + assert sensor.state == "cloudy" + + +async def test_weather_daily(hass): + """Test that weather has a value for mode daily.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + "weather", + DOMAIN, + "40.79962_-73.970314_forecast_7days", + suggested_object_id="here_weather_forecast_7days", + disabled_by=None, + ) + + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + sensor = hass.states.get("weather.here_weather_forecast_7days") + assert sensor.state == "cloudy" + + +async def test_weather_observation(hass): + """Test that weather has a value for mode observation.""" + with patch( + "herepy.DestinationWeatherApi.weather_for_coordinates", + side_effect=mock_weather_for_coordinates, + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "test", + CONF_NAME: DOMAIN, + CONF_LATITUDE: "40.79962", + CONF_LONGITUDE: "-73.970314", + }, + ) + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + "weather", + DOMAIN, + "40.79962_-73.970314_observation", + suggested_object_id="here_weather_observation", + disabled_by=None, + ) + + await hass.config_entries.async_setup(entry.entry_id) + + await hass.async_block_till_done() + + sensor = hass.states.get("weather.here_weather_observation") + assert sensor.state == "cloudy" diff --git a/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json b/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json index 81fb246178c93..e1be0ed42123e 100644 --- a/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json +++ b/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json @@ -1,15 +1,4 @@ { - "_type": "ns2:RoutingServiceErrorType", - "type": "PermissionError", - "subtype": "InvalidCredentials", - "details": "This is not a valid app_id and app_code pair. Please verify that the values are not swapped between the app_id and app_code and the values provisioned by HERE (either by your customer representative or via http://developer.here.com/myapps) were copied correctly into the request.", - "metaInfo": { - "timestamp": "2019-07-10T09:43:14Z", - "mapVersion": "8.30.98.152", - "moduleVersion": "7.2.201927-4307", - "interfaceVersion": "2.6.64", - "availableMapVersion": [ - "8.30.98.152" - ] - } -} \ No newline at end of file + "error": "Unauthorized", + "error_description": "No credentials found" + } \ No newline at end of file diff --git a/tests/fixtures/here_weather/astronomy.json b/tests/fixtures/here_weather/astronomy.json new file mode 100644 index 0000000000000..aaf0b30a71a46 --- /dev/null +++ b/tests/fixtures/here_weather/astronomy.json @@ -0,0 +1,27 @@ +{ + "astronomy": { + "astronomy": [ + { + "sunrise": "6:55AM", + "sunset": "6:33PM", + "moonrise": "1:27PM", + "moonset": "10:58PM", + "moonPhase": 0.328, + "moonPhaseDesc": "Waxing crescent", + "iconName": "cw_waxing_crescent", + "city": "Edgewater", + "latitude": 40.82, + "longitude": -73.97, + "utcTime": "2019-10-04T00:00:00.000-04:00" + } + ], + "country": "United States", + "state": "New York", + "city": "New York", + "latitude": 40.79962, + "longitude": -73.970314, + "timezone": -5 + }, + "feedCreation": "2019-10-04T14:22:46.164Z", + "metric": true +} \ No newline at end of file diff --git a/tests/fixtures/here_weather/daily.json b/tests/fixtures/here_weather/daily.json new file mode 100644 index 0000000000000..5ec55882fd342 --- /dev/null +++ b/tests/fixtures/here_weather/daily.json @@ -0,0 +1,48 @@ +{ + "forecasts": { + "forecastLocation": { + "forecast": [ + { + "daylight": "D", + "daySegment": "Afternoon", + "description": "Overcast. Chilly.", + "skyInfo": "18", + "skyDescription": "Overcast", + "temperature": "4.00", + "temperatureDesc": "Chilly", + "comfort": "0.86", + "humidity": "73", + "dewPoint": "-0.40", + "precipitationProbability": "9", + "precipitationDesc": "", + "rainFall": "*", + "snowFall": "*", + "airInfo": "*", + "airDescription": "", + "windSpeed": "12.96", + "windDirection": "294", + "windDesc": "Northwest", + "windDescShort": "NW", + "beaufortScale": "3", + "beaufortDescription": "Gentle breeze", + "visibility": "11.38", + "icon": "7", + "iconName": "cloudy", + "iconLink": "https://weather.cit.api.here.com/static/weather/icon/17.png", + "dayOfWeek": "3", + "weekday": "Tuesday", + "utcTime": "2019-11-19T12:00:00.000-05:00" + } + ], + "country": "United States", + "state": "New York", + "city": "New York", + "latitude": 43.00035, + "longitude": -75.4999, + "distance": 0.00, + "timezone": -5 + } + }, + "feedCreation": "2019-11-19T22:49:09.348Z", + "metric": true +} \ No newline at end of file diff --git a/tests/fixtures/here_weather/daily_simple_forecasts.json b/tests/fixtures/here_weather/daily_simple_forecasts.json new file mode 100644 index 0000000000000..ede035fe05a08 --- /dev/null +++ b/tests/fixtures/here_weather/daily_simple_forecasts.json @@ -0,0 +1,50 @@ +{ + "dailyForecasts": { + "forecastLocation": { + "forecast": [ + { + "daylight": "D", + "description": "Overcast. Chilly.", + "skyInfo": "18", + "skyDescription": "Overcast", + "temperatureDesc": "Chilly", + "comfort": "-0.55", + "highTemperature": "4.00", + "lowTemperature": "-1.80", + "humidity": "80", + "dewPoint": "-0.40", + "precipitationProbability": "53", + "precipitationDesc": "", + "rainFall": "1.42", + "snowFall": "1.42", + "airInfo": "*", + "airDescription": "", + "windSpeed": "12.03", + "windDirection": "290", + "windDesc": "West", + "windDescShort": "W", + "beaufortScale": "3", + "beaufortDescription": "Gentle breeze", + "uvIndex": "0", + "uvDesc": "Minimal", + "barometerPressure": "1014.29", + "icon": "7", + "iconName": "cloudy", + "iconLink": "https://weather.cit.api.here.com/static/weather/icon/17.png", + "dayOfWeek": "3", + "weekday": "Tuesday", + "utcTime": "2019-11-19T00:00:00.000-05:00" + } + ], + "country": "United States", + "state": "New York", + "city": "New York", + "latitude": 43.00035, + "longitude": -75.4999, + "distance": 0.00, + "timezone": -5 + } + }, + "feedCreation": "2019-11-19T23:01:26.632Z", + "metric": true +} \ No newline at end of file diff --git a/tests/fixtures/here_weather/hourly.json b/tests/fixtures/here_weather/hourly.json new file mode 100644 index 0000000000000..6d35c76524828 --- /dev/null +++ b/tests/fixtures/here_weather/hourly.json @@ -0,0 +1,47 @@ +{ + "hourlyForecasts": { + "forecastLocation": { + "forecast": [ + { + "daylight": "D", + "description": "Overcast. Chilly.", + "skyInfo": "18", + "skyDescription": "Overcast", + "temperature": "4.00", + "temperatureDesc": "Chilly", + "comfort": "0.67", + "humidity": "74", + "dewPoint": "-0.30", + "precipitationProbability": "5", + "precipitationDesc": "", + "rainFall": "*", + "snowFall": "*", + "airInfo": "*", + "airDescription": "", + "windSpeed": "14.04", + "windDirection": "287", + "windDesc": "West", + "windDescShort": "W", + "visibility": "11.31", + "icon": "7", + "iconName": "cloudy", + "iconLink": "https://weather.cit.api.here.com/static/weather/icon/17.png", + "dayOfWeek": "3", + "weekday": "Tuesday", + "utcTime": "2019-11-19T13:00:00.000-05:00", + "localTime": "1311192019", + "localTimeFormat": "HHMMddyyyy" + } + ], + "country": "United States", + "state": "New York", + "city": "New York", + "latitude": 43.00035, + "longitude": -75.4999, + "distance": 0.00, + "timezone": -5 + } + }, + "feedCreation": "2019-11-19T23:06:04.631Z", + "metric": true +} \ No newline at end of file diff --git a/tests/fixtures/here_weather/observation.json b/tests/fixtures/here_weather/observation.json new file mode 100644 index 0000000000000..3932393c1b6ef --- /dev/null +++ b/tests/fixtures/here_weather/observation.json @@ -0,0 +1,61 @@ +{ + "observations": { + "location": [ + { + "observation": [ + { + "daylight": "D", + "description": "Overcast. Cool.", + "skyInfo": "18", + "skyDescription": "Overcast", + "temperature": "46.00", + "temperatureDesc": "Cool", + "comfort": "42.39", + "highTemperature": "50.72", + "lowTemperature": "39.74", + "humidity": "63", + "dewPoint": "34.00", + "precipitation1H": "*", + "precipitation3H": "*", + "precipitation6H": "*", + "precipitation12H": "*", + "precipitation24H": "*", + "precipitationDesc": "", + "airInfo": "*", + "airDescription": "", + "windSpeed": "6.89", + "windDirection": "0", + "windDesc": "North", + "windDescShort": "N", + "barometerPressure": "29.80", + "barometerTrend": "Rising", + "visibility": "10.00", + "snowCover": "*", + "icon": "7", + "iconName": "cloudy", + "iconLink": "https://weather.cit.api.here.com/static/weather/icon/17.png", + "ageMinutes": "46", + "activeAlerts": "6", + "country": "United States", + "state": "New York", + "city": "New York", + "latitude": 40.7996, + "longitude": -73.9703, + "distance": 1.43, + "elevation": 0.00, + "utcTime": "2019-11-19T15:51:00.000-05:00" + } + ], + "country": "United States", + "state": "New York", + "city": "New York", + "latitude": 40.79962, + "longitude": -73.970314, + "distance": 0.00, + "timezone": -5 + } + ] + }, + "feedCreation": "2019-11-19T21:37:57.279Z", + "metric": false +} \ No newline at end of file