Add DataUpdateCoordinator to met integration (#38405)

* Add DataUpdateCoordinator to met integration

* isort

* redundant fetch_data

* Update homeassistant/components/met/weather.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/weather.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/weather.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/weather.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* Update homeassistant/components/met/weather.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>

* fix black, isort, flake8, hassfest, mypy

* remove unused async_setup method

* replace fetch_data by coordinator request_refresh

* remove redundant  async_update

* track_home

* Apply suggestions from code review

* Apply suggestions from code review

* Update homeassistant/components/met/__init__.py

* Apply suggestions from code review

* Update homeassistant/components/met/__init__.py

* Apply suggestions from code review

* Update homeassistant/components/met/__init__.py

* Apply suggestions from code review

* Update test_config_flow.py

* Apply suggestions from code review

* Apply suggestions from code review

* Update __init__.py

* Create test_init.py

* Update homeassistant/components/met/__init__.py

* Update __init__.py

* Update __init__.py

* Update homeassistant/components/met/__init__.py

Co-authored-by: Chris Talkington <chris@talkingtontech.com>
This commit is contained in:
Vaclav 2020-08-09 20:18:02 +02:00 committed by GitHub
parent 761067559d
commit b258e757c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 227 additions and 96 deletions

View File

@ -1,24 +1,153 @@
"""The met component.""" """The met component."""
from homeassistant.core import Config, HomeAssistant from datetime import timedelta
import logging
from random import randrange
from .config_flow import MetFlowHandler # noqa: F401 import metno
from .const import DOMAIN # noqa: F401
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/"
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: Config) -> bool: async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured Met.""" """Set up configured Met."""
hass.data.setdefault(DOMAIN, {})
return True return True
async def async_setup_entry(hass, config_entry): async def async_setup_entry(hass, config_entry):
"""Set up Met as 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.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, "weather") hass.config_entries.async_forward_entry_setup(config_entry, "weather")
) )
return True return True
async def async_unload_entry(hass, config_entry): async def async_unload_entry(hass, config_entry):
"""Unload a config entry.""" """Unload a config entry."""
await hass.config_entries.async_forward_entry_unload(config_entry, "weather") 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 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

View File

@ -1,4 +1,6 @@
"""Config flow to configure Met component.""" """Config flow to configure Met component."""
from typing import Any, Dict, Optional
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
@ -71,6 +73,12 @@ class MetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors=self._errors, 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): async def async_step_onboarding(self, data=None):
"""Handle a flow initialized by onboarding.""" """Handle a flow initialized by onboarding."""
return self.async_create_entry( return self.async_create_entry(

View File

@ -1,18 +1,15 @@
"""Support for Met.no weather service.""" """Support for Met.no weather service."""
import logging import logging
from random import randrange
import metno
import voluptuous as vol import voluptuous as vol
from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
CONF_ELEVATION, CONF_ELEVATION,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_NAME, CONF_NAME,
EVENT_CORE_CONFIG_UPDATE,
LENGTH_FEET,
LENGTH_METERS, LENGTH_METERS,
LENGTH_MILES, LENGTH_MILES,
PRESSURE_HPA, PRESSURE_HPA,
@ -20,13 +17,10 @@ from homeassistant.const import (
TEMP_CELSIUS, TEMP_CELSIUS,
) )
from homeassistant.helpers import config_validation as cv 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 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 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__) _LOGGER = logging.getLogger(__name__)
@ -36,7 +30,6 @@ ATTRIBUTION = (
) )
DEFAULT_NAME = "Met.no" DEFAULT_NAME = "Met.no"
URL = "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/2.0/classic"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 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: if config.get(CONF_LATITUDE) is None:
config[CONF_TRACK_HOME] = True 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): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add a weather entity from a config_entry.""" """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): class MetWeather(WeatherEntity):
"""Implementation of a Met.no weather condition.""" """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.""" """Initialise the platform with a data instance and site."""
self._config = config self._config = config
self._coordinator = coordinator
self._is_metric = is_metric 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): async def async_added_to_hass(self):
"""Start fetching data.""" """Start fetching data."""
await self._init_data() self.async_on_remove(
if self._config.get(CONF_TRACK_HOME): self._coordinator.async_add_listener(self.async_write_ha_state)
self._unsub_track_home = self.hass.bus.async_listen(
EVENT_CORE_CONFIG_UPDATE, self._init_data
) )
async def _init_data(self, _event=None): async def async_update(self):
"""Initialize and fetch data object.""" """Only used by the generic entity update service."""
if self.track_home: await self._coordinator.async_request_refresh()
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._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()
@property @property
def track_home(self): def track_home(self):
@ -191,12 +123,12 @@ class MetWeather(WeatherEntity):
@property @property
def condition(self): def condition(self):
"""Return the current condition.""" """Return the current condition."""
return self._current_weather_data.get("condition") return self._coordinator.data.current_weather_data.get("condition")
@property @property
def temperature(self): def temperature(self):
"""Return the temperature.""" """Return the temperature."""
return self._current_weather_data.get("temperature") return self._coordinator.data.current_weather_data.get("temperature")
@property @property
def temperature_unit(self): def temperature_unit(self):
@ -206,7 +138,7 @@ class MetWeather(WeatherEntity):
@property @property
def pressure(self): def pressure(self):
"""Return the pressure.""" """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: if self._is_metric or pressure_hpa is None:
return pressure_hpa return pressure_hpa
@ -215,12 +147,12 @@ class MetWeather(WeatherEntity):
@property @property
def humidity(self): def humidity(self):
"""Return the humidity.""" """Return the humidity."""
return self._current_weather_data.get("humidity") return self._coordinator.data.current_weather_data.get("humidity")
@property @property
def wind_speed(self): def wind_speed(self):
"""Return the wind speed.""" """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: if self._is_metric or speed_m_s is None:
return speed_m_s return speed_m_s
@ -231,7 +163,7 @@ class MetWeather(WeatherEntity):
@property @property
def wind_bearing(self): def wind_bearing(self):
"""Return the wind direction.""" """Return the wind direction."""
return self._current_weather_data.get("wind_bearing") return self._coordinator.data.current_weather_data.get("wind_bearing")
@property @property
def attribution(self): def attribution(self):
@ -241,4 +173,4 @@ class MetWeather(WeatherEntity):
@property @property
def forecast(self): def forecast(self):
"""Return the forecast array.""" """Return the forecast array."""
return self._forecast_data return self._coordinator.data.forecast_data

View File

@ -1 +1,26 @@
"""Tests for Met.no.""" """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

View File

@ -103,3 +103,21 @@ async def test_onboarding_step(hass):
assert result["type"] == "create_entry" assert result["type"] == "create_entry"
assert result["title"] == HOME_LOCATION_NAME assert result["title"] == HOME_LOCATION_NAME
assert result["data"] == {"track_home": True} 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

View File

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