"""Support for the Swedish weather institute weather service.""" from __future__ import annotations import asyncio from datetime import datetime, timedelta import logging from typing import Any, Final, TypedDict import aiohttp import async_timeout from smhi import Smhi from smhi.smhi_lib import SmhiForecast, SmhiForecastException from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, ATTR_CONDITION_FOG, ATTR_CONDITION_HAIL, ATTR_CONDITION_LIGHTNING, ATTR_CONDITION_LIGHTNING_RAINY, ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_POURING, ATTR_CONDITION_RAINY, ATTR_CONDITION_SNOWY, ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, 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 import aiohttp_client from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.util import Throttle, slugify from .const import ( ATTR_SMHI_CLOUDINESS, ATTR_SMHI_THUNDER_PROBABILITY, ATTR_SMHI_WIND_GUST_SPEED, ENTITY_ID_SENSOR_FORMAT, ) _LOGGER = logging.getLogger(__name__) # Used to map condition from API results CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_CLOUDY: [5, 6], ATTR_CONDITION_FOG: [7], ATTR_CONDITION_HAIL: [], ATTR_CONDITION_LIGHTNING: [21], ATTR_CONDITION_LIGHTNING_RAINY: [11], ATTR_CONDITION_PARTLYCLOUDY: [3, 4], ATTR_CONDITION_POURING: [10, 20], ATTR_CONDITION_RAINY: [8, 9, 18, 19], ATTR_CONDITION_SNOWY: [15, 16, 17, 25, 26, 27], ATTR_CONDITION_SNOWY_RAINY: [12, 13, 14, 22, 23, 24], ATTR_CONDITION_SUNNY: [1, 2], ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], } # 5 minutes between retrying connect to API again RETRY_TIMEOUT = 5 * 60 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add a weather entity from map location.""" location = config_entry.data name = slugify(location[CONF_NAME]) session = aiohttp_client.async_get_clientsession(hass) entity = SmhiWeather( location[CONF_NAME], location[CONF_LATITUDE], location[CONF_LONGITUDE], session=session, ) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) async_add_entities([entity], True) class SmhiWeather(WeatherEntity): """Representation of a weather entity.""" def __init__( self, name: str, latitude: str, longitude: str, session: aiohttp.ClientSession, ) -> None: """Initialize the SMHI weather entity.""" self._name = name self._latitude = latitude self._longitude = longitude self._forecasts: list[SmhiForecast] | None = None self._fail_count = 0 self._smhi_api = Smhi(self._longitude, self._latitude, session=session) @property def unique_id(self) -> str: """Return a unique id.""" return f"{self._latitude}, {self._longitude}" @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Refresh the forecast data from SMHI weather API.""" try: with async_timeout.timeout(10): self._forecasts = await self.get_weather_forecast() self._fail_count = 0 except (asyncio.TimeoutError, SmhiForecastException): _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes") self._fail_count += 1 if self._fail_count < 3: async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update) async def retry_update(self, _: datetime) -> None: """Retry refresh weather forecast.""" await self.async_update( # pylint: disable=unexpected-keyword-arg no_throttle=True ) async def get_weather_forecast(self) -> list[SmhiForecast]: """Return the current forecasts from SMHI API.""" return await self._smhi_api.async_get_forecast() @property def name(self) -> str: """Return the name of the sensor.""" return self._name @property def temperature(self) -> int | None: """Return the temperature.""" if self._forecasts is not None: return self._forecasts[0].temperature return None @property def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property def humidity(self) -> int | None: """Return the humidity.""" if self._forecasts is not None: return self._forecasts[0].humidity return None @property def wind_speed(self) -> float | None: """Return the wind speed.""" if self._forecasts is not None: # Convert from m/s to km/h return round(self._forecasts[0].wind_speed * 18 / 5) return None @property def wind_gust_speed(self) -> float | None: """Return the wind gust speed.""" if self._forecasts is not None: # Convert from m/s to km/h return round(self._forecasts[0].wind_gust * 18 / 5) return None @property def wind_bearing(self) -> int | None: """Return the wind bearing.""" if self._forecasts is not None: return self._forecasts[0].wind_direction return None @property def visibility(self) -> float | None: """Return the visibility.""" if self._forecasts is not None: return self._forecasts[0].horizontal_visibility return None @property def pressure(self) -> int | None: """Return the pressure.""" if self._forecasts is not None: return self._forecasts[0].pressure return None @property def cloudiness(self) -> int | None: """Return the cloudiness.""" if self._forecasts is not None: return self._forecasts[0].cloudiness return None @property def thunder_probability(self) -> int | None: """Return the chance of thunder, unit Percent.""" if self._forecasts is not None: return self._forecasts[0].thunder return None @property def condition(self) -> str | None: """Return the weather condition.""" if self._forecasts is None: return None return next( (k for k, v in CONDITION_CLASSES.items() if self._forecasts[0].symbol in v), None, ) @property def attribution(self) -> str: """Return the attribution.""" return "Swedish weather institute (SMHI)" @property def forecast(self) -> list[dict[str, Any]] | None: """Return the forecast.""" if self._forecasts is None or len(self._forecasts) < 2: return None data = [] for forecast in self._forecasts[1:]: condition = next( (k for k, v in CONDITION_CLASSES.items() if forecast.symbol in v), None ) data.append( { ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), ATTR_FORECAST_TEMP: forecast.temperature_max, ATTR_FORECAST_TEMP_LOW: forecast.temperature_min, ATTR_FORECAST_PRECIPITATION: round(forecast.total_precipitation, 1), ATTR_FORECAST_CONDITION: condition, } ) return data @property def extra_state_attributes(self) -> ExtraAttributes: """Return SMHI specific attributes.""" extra_attributes: ExtraAttributes = {} if self.cloudiness is not None: extra_attributes[ATTR_SMHI_CLOUDINESS] = self.cloudiness if self.wind_gust_speed is not None: extra_attributes[ATTR_SMHI_WIND_GUST_SPEED] = self.wind_gust_speed if self.thunder_probability is not None: extra_attributes[ATTR_SMHI_THUNDER_PROBABILITY] = self.thunder_probability return extra_attributes class ExtraAttributes(TypedDict, total=False): """Represent the extra state attribute types.""" cloudiness: int thunder_probability: int wind_gust_speed: float