diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 7ef3c5f8796328..32b3e939c432b6 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -1,24 +1,153 @@ """The met component.""" +from datetime import timedelta +import logging +from random import randrange + +import metno + +from homeassistant.const import ( + CONF_ELEVATION, + CONF_LATITUDE, + CONF_LONGITUDE, + EVENT_CORE_CONFIG_UPDATE, + LENGTH_FEET, + LENGTH_METERS, +) from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.distance import convert as convert_distance +import homeassistant.util.dt as dt_util + +from .const import CONF_TRACK_HOME, DOMAIN + +URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/" -from .config_flow import MetFlowHandler # noqa: F401 -from .const import DOMAIN # noqa: F401 + +_LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: Config) -> bool: """Set up configured Met.""" + hass.data.setdefault(DOMAIN, {}) return True async def async_setup_entry(hass, config_entry): """Set up Met as config entry.""" + coordinator = MetDataUpdateCoordinator(hass, config_entry) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + if config_entry.data.get(CONF_TRACK_HOME, False): + coordinator.track_home() + + hass.data[DOMAIN][config_entry.entry_id] = coordinator + hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, "weather") ) + return True async def async_unload_entry(hass, config_entry): """Unload a config entry.""" await hass.config_entries.async_forward_entry_unload(config_entry, "weather") + hass.data[DOMAIN][config_entry.entry_id].untrack_home() + hass.data[DOMAIN].pop(config_entry.entry_id) + return True + + +class MetDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Met data.""" + + def __init__(self, hass, config_entry): + """Initialize global Met data updater.""" + self._unsub_track_home = None + self.weather = MetWeatherData( + hass, config_entry.data, hass.config.units.is_metric + ) + self.weather.init_data() + + update_interval = timedelta(minutes=randrange(55, 65)) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self): + """Fetch data from Met.""" + try: + return await self.weather.fetch_data() + except Exception as err: + raise UpdateFailed(f"Update failed: {err}") + + def track_home(self): + """Start tracking changes to HA home setting.""" + if self._unsub_track_home: + return + + async def _async_update_weather_data(_event=None): + """Update weather data.""" + self.weather.init_data() + await self.async_refresh() + + self._unsub_track_home = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, _async_update_weather_data + ) + + def untrack_home(self): + """Stop tracking changes to HA home setting.""" + if self._unsub_track_home: + self._unsub_track_home() + self._unsub_track_home = None + + +class MetWeatherData: + """Keep data for Met.no weather entities.""" + + def __init__(self, hass, config, is_metric): + """Initialise the weather entity data.""" + self.hass = hass + self._config = config + self._is_metric = is_metric + self._weather_data = None + self.current_weather_data = {} + self.forecast_data = None + + def init_data(self): + """Weather data inialization - get the coordinates.""" + if self._config.get(CONF_TRACK_HOME, False): + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + elevation = self.hass.config.elevation + else: + latitude = self._config[CONF_LATITUDE] + longitude = self._config[CONF_LONGITUDE] + elevation = self._config[CONF_ELEVATION] + + if not self._is_metric: + elevation = int( + round(convert_distance(elevation, LENGTH_FEET, LENGTH_METERS)) + ) + + coordinates = { + "lat": str(latitude), + "lon": str(longitude), + "msl": str(elevation), + } + + self._weather_data = metno.MetWeatherData( + coordinates, async_get_clientsession(self.hass), URL + ) + + async def fetch_data(self): + """Fetch data from API - (current weather and forecast).""" + await self._weather_data.fetching_data() + self.current_weather_data = self._weather_data.get_current_weather() + time_zone = dt_util.DEFAULT_TIME_ZONE + self.forecast_data = self._weather_data.get_forecast(time_zone) + return self diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 683390429c3923..b9a992bb823e6f 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure Met component.""" +from typing import Any, Dict, Optional + import voluptuous as vol from homeassistant import config_entries @@ -71,6 +73,12 @@ async def _show_config_form( errors=self._errors, ) + async def async_step_import( + self, user_input: Optional[Dict] = None + ) -> Dict[str, Any]: + """Handle configuration by yaml file.""" + return await self.async_step_user(user_input) + async def async_step_onboarding(self, data=None): """Handle a flow initialized by onboarding.""" return self.async_create_entry( diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a1bcc360623f41..9761a2c4034198 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -1,18 +1,15 @@ """Support for Met.no weather service.""" import logging -from random import randrange -import metno import voluptuous as vol from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - EVENT_CORE_CONFIG_UPDATE, - LENGTH_FEET, LENGTH_METERS, LENGTH_MILES, PRESSURE_HPA, @@ -20,13 +17,10 @@ TEMP_CELSIUS, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.event import async_call_later from homeassistant.util.distance import convert as convert_distance -import homeassistant.util.dt as dt_util from homeassistant.util.pressure import convert as convert_pressure -from .const import CONF_TRACK_HOME +from .const import CONF_TRACK_HOME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -36,7 +30,6 @@ ) DEFAULT_NAME = "Met.no" -URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/classic" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -62,100 +55,39 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if config.get(CONF_LATITUDE) is None: config[CONF_TRACK_HOME] = True - async_add_entities([MetWeather(config, hass.config.units.is_metric)]) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) async def async_setup_entry(hass, config_entry, async_add_entities): """Add a weather entity from a config_entry.""" - async_add_entities([MetWeather(config_entry.data, hass.config.units.is_metric)]) + coordinator = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities( + [MetWeather(coordinator, config_entry.data, hass.config.units.is_metric)] + ) class MetWeather(WeatherEntity): """Implementation of a Met.no weather condition.""" - def __init__(self, config, is_metric): + def __init__(self, coordinator, config, is_metric): """Initialise the platform with a data instance and site.""" self._config = config + self._coordinator = coordinator self._is_metric = is_metric - self._unsub_track_home = None - self._unsub_fetch_data = None - self._weather_data = None - self._current_weather_data = {} - self._coordinates = {} - self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" - await self._init_data() - if self._config.get(CONF_TRACK_HOME): - self._unsub_track_home = self.hass.bus.async_listen( - EVENT_CORE_CONFIG_UPDATE, self._init_data - ) - - async def _init_data(self, _event=None): - """Initialize and fetch data object.""" - if self.track_home: - latitude = self.hass.config.latitude - longitude = self.hass.config.longitude - elevation = self.hass.config.elevation - else: - conf = self._config - latitude = conf[CONF_LATITUDE] - longitude = conf[CONF_LONGITUDE] - elevation = conf[CONF_ELEVATION] - - if not self._is_metric: - elevation = convert_distance(elevation, LENGTH_FEET, LENGTH_METERS) - coordinates = { - "lat": latitude, - "lon": longitude, - "msl": elevation, - } - if coordinates == self._coordinates: - return - self._coordinates = coordinates - - self._weather_data = metno.MetWeatherData( - coordinates, async_get_clientsession(self.hass), URL - ) - await self._fetch_data() - - async def will_remove_from_hass(self): - """Handle entity will be removed from hass.""" - if self._unsub_track_home: - self._unsub_track_home() - self._unsub_track_home = None - - if self._unsub_fetch_data: - self._unsub_fetch_data() - self._unsub_fetch_data = None - - async def _fetch_data(self, *_): - """Get the latest data from met.no.""" - if self._unsub_fetch_data: - self._unsub_fetch_data() - self._unsub_fetch_data = None - - if not await self._weather_data.fetching_data(): - # Retry in 15 to 20 minutes. - minutes = 15 + randrange(6) - _LOGGER.error("Retrying in %i minutes", minutes) - self._unsub_fetch_data = async_call_later( - self.hass, minutes * 60, self._fetch_data - ) - return - - # Wait between 55-65 minutes. If people update HA on the hour, this - # will make sure it will spread it out. - - self._unsub_fetch_data = async_call_later( - self.hass, randrange(55, 65) * 60, self._fetch_data + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) ) - self._current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.DEFAULT_TIME_ZONE - self._forecast_data = self._weather_data.get_forecast(time_zone) - self.async_write_ha_state() + async def async_update(self): + """Only used by the generic entity update service.""" + await self._coordinator.async_request_refresh() @property def track_home(self): @@ -191,12 +123,12 @@ def name(self): @property def condition(self): """Return the current condition.""" - return self._current_weather_data.get("condition") + return self._coordinator.data.current_weather_data.get("condition") @property def temperature(self): """Return the temperature.""" - return self._current_weather_data.get("temperature") + return self._coordinator.data.current_weather_data.get("temperature") @property def temperature_unit(self): @@ -206,7 +138,7 @@ def temperature_unit(self): @property def pressure(self): """Return the pressure.""" - pressure_hpa = self._current_weather_data.get("pressure") + pressure_hpa = self._coordinator.data.current_weather_data.get("pressure") if self._is_metric or pressure_hpa is None: return pressure_hpa @@ -215,12 +147,12 @@ def pressure(self): @property def humidity(self): """Return the humidity.""" - return self._current_weather_data.get("humidity") + return self._coordinator.data.current_weather_data.get("humidity") @property def wind_speed(self): """Return the wind speed.""" - speed_m_s = self._current_weather_data.get("wind_speed") + speed_m_s = self._coordinator.data.current_weather_data.get("wind_speed") if self._is_metric or speed_m_s is None: return speed_m_s @@ -231,7 +163,7 @@ def wind_speed(self): @property def wind_bearing(self): """Return the wind direction.""" - return self._current_weather_data.get("wind_bearing") + return self._coordinator.data.current_weather_data.get("wind_bearing") @property def attribution(self): @@ -241,4 +173,4 @@ def attribution(self): @property def forecast(self): """Return the forecast array.""" - return self._forecast_data + return self._coordinator.data.forecast_data diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py index 658ed2901ec7ea..c238fec4cb700a 100644 --- a/tests/components/met/__init__.py +++ b/tests/components/met/__init__.py @@ -1 +1,26 @@ """Tests for Met.no.""" +from homeassistant.components.met.const import DOMAIN +from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry + + +async def init_integration(hass) -> MockConfigEntry: + """Set up the Met integration in Home Assistant.""" + entry_data = { + CONF_NAME: "test", + CONF_LATITUDE: 0, + CONF_LONGITUDE: 0, + CONF_ELEVATION: 0, + } + entry = MockConfigEntry(domain=DOMAIN, data=entry_data) + with patch( + "homeassistant.components.met.metno.MetWeatherData.fetching_data", + return_value=True, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index a4c48f38245983..4d820c1c7c684a 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -103,3 +103,21 @@ async def test_onboarding_step(hass): assert result["type"] == "create_entry" assert result["title"] == HOME_LOCATION_NAME assert result["data"] == {"track_home": True} + + +async def test_import_step(hass): + """Test initializing via import step.""" + test_data = { + "name": "home", + CONF_LONGITUDE: None, + CONF_LATITUDE: None, + CONF_ELEVATION: 0, + "track_home": True, + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=test_data + ) + + assert result["type"] == "create_entry" + assert result["title"] == "home" + assert result["data"] == test_data diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py new file mode 100644 index 00000000000000..a3323f0156515f --- /dev/null +++ b/tests/components/met/test_init.py @@ -0,0 +1,19 @@ +"""Test the Met integration init.""" +from homeassistant.components.met.const import DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED + +from . import init_integration + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN)