From f2962a0d1659e8bda36dc0484fdfd302cc5f1746 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Jun 2019 14:41:27 -0700 Subject: [PATCH] Set up Met during onboarding (#24622) * Set up Met during onboarding * Lint * Add pyMetNo to test reqs --- homeassistant/components/met/config_flow.py | 11 +- homeassistant/components/met/const.py | 2 + homeassistant/components/met/weather.py | 152 +++++++++++++------ homeassistant/components/onboarding/views.py | 4 + homeassistant/config.py | 4 - requirements_test_all.txt | 4 + script/gen_requirements_all.py | 1 + tests/components/met/__init__.py | 1 + tests/components/met/conftest.py | 24 +++ tests/components/met/test_config_flow.py | 16 ++ tests/components/met/test_weather.py | 52 +++++++ tests/components/onboarding/test_views.py | 25 +++ 12 files changed, 242 insertions(+), 54 deletions(-) create mode 100644 tests/components/met/__init__.py create mode 100644 tests/components/met/conftest.py create mode 100644 tests/components/met/test_weather.py diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index 07123a91855..2480b5f29b8 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -7,7 +7,7 @@ from homeassistant.const import ( from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, HOME_LOCATION_NAME +from .const import DOMAIN, HOME_LOCATION_NAME, CONF_TRACK_HOME @callback @@ -61,3 +61,12 @@ class MetFlowHandler(data_entry_flow.FlowHandler): }), errors=self._errors, ) + + async def async_step_onboarding(self, data=None): + """Handle a flow initialized by onboarding.""" + return self.async_create_entry( + title=HOME_LOCATION_NAME, + data={ + CONF_TRACK_HOME: True + } + ) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index cf1f3aac53d..5d61ecadfa3 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -7,6 +7,8 @@ DOMAIN = 'met' HOME_LOCATION_NAME = 'Home' +CONF_TRACK_HOME = 'track_home' + ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".met_{}" ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( HOME_LOCATION_NAME) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 20f408b91ba..c9d0912e623 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,17 +2,21 @@ import logging from random import randrange +import metno import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity from homeassistant.const import ( - CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) + CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, + EVENT_CORE_CONFIG_UPDATE) 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, async_track_utc_time_change) +from homeassistant.helpers.event import async_call_later import homeassistant.util.dt as dt_util +from .const import CONF_TRACK_HOME + _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Weather forecast from met.no, delivered by the Norwegian " \ @@ -27,6 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'Latitude and longitude must exist together'): cv.latitude, vol.Inclusive(CONF_LONGITUDE, 'coordinates', 'Latitude and longitude must exist together'): cv.longitude, + vol.Optional(CONF_ELEVATION): int, }) @@ -35,60 +40,82 @@ async def async_setup_platform(hass, config, async_add_entities, """Set up the Met.no weather platform.""" _LOGGER.warning("Loading Met.no via platform config is deprecated") - name = config.get(CONF_NAME) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0) + # Add defaults. + config = { + CONF_ELEVATION: hass.config.elevation, + **config, + } - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return + if config.get(CONF_LATITUDE) is None: + config[CONF_TRACK_HOME] = True - station = await async_get_station( - hass, name, latitude, longitude, elevation) - async_add_entities([station]) + async_add_entities([MetWeather(config)]) async def async_setup_entry(hass, config_entry, async_add_entities): """Add a weather entity from a config_entry.""" - name = config_entry.data.get(CONF_NAME) - latitude = config_entry.data[CONF_LATITUDE] - longitude = config_entry.data[CONF_LONGITUDE] - elevation = config_entry.data[CONF_ELEVATION] - - station = await async_get_station( - hass, name, latitude, longitude, elevation) - async_add_entities([station]) - - -async def async_get_station(hass, name, latitude, longitude, elevation): - """Retrieve weather station, station name to be used as the entity name.""" - coordinates = { - 'lat': str(latitude), - 'lon': str(longitude), - 'msl': str(elevation), - } - - return MetWeather(name, coordinates, async_get_clientsession(hass)) + async_add_entities([MetWeather(config_entry.data)]) class MetWeather(WeatherEntity): """Implementation of a Met.no weather condition.""" - def __init__(self, name, coordinates, clientsession): + def __init__(self, config): """Initialise the platform with a data instance and site.""" - import metno - self._name = name - self._weather_data = metno.MetWeatherData( - coordinates, clientsession, URL) + self._config = config + self._unsub_track_home = None + self._unsub_fetch_data = None + self._weather_data = None self._current_weather_data = {} self._forecast_data = None async def async_added_to_hass(self): """Start fetching data.""" + self._init_data() await self._fetch_data() - async_track_utc_time_change( - self.hass, self._update, minute=31, second=0) + if self._config.get(CONF_TRACK_HOME): + self._unsub_track_home = self.hass.bus.async_listen( + EVENT_CORE_CONFIG_UPDATE, self._core_config_updated) + + @callback + def _init_data(self): + """Initialize a data object.""" + conf = self._config + + if self.track_home: + latitude = self.hass.config.latitude + longitude = self.hass.config.longitude + elevation = self.hass.config.elevation + else: + latitude = conf[CONF_LATITUDE] + longitude = conf[CONF_LONGITUDE] + elevation = conf[CONF_ELEVATION] + + coordinates = { + 'lat': str(latitude), + 'lon': str(longitude), + 'msl': str(elevation), + } + self._weather_data = metno.MetWeatherData( + coordinates, async_get_clientsession(self.hass), URL) + + async def _core_config_updated(self, _event): + """Handle core config updated.""" + self._init_data() + if self._unsub_fetch_data: + self._unsub_fetch_data() + self._unsub_fetch_data = None + 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.""" @@ -96,28 +123,55 @@ class MetWeather(WeatherEntity): # Retry in 15 to 20 minutes. minutes = 15 + randrange(6) _LOGGER.error("Retrying in %i minutes", minutes) - async_call_later(self.hass, minutes*60, self._fetch_data) + self._unsub_fetch_data = async_call_later( + self.hass, minutes*60, self._fetch_data) return - async_call_later(self.hass, 60*60, self._fetch_data) - await self._update() + # 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._update() + + def _update(self, *_): + """Get the latest data from Met.no.""" + 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 + def track_home(self): + """Return if we are tracking home.""" + return self._config.get(CONF_TRACK_HOME, False) @property def should_poll(self): """No polling needed.""" return False - async def _update(self, *_): - """Get the latest data from Met.no.""" - 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_schedule_update_ha_state() + @property + def unique_id(self): + """Return unique ID.""" + if self.track_home: + return 'home' + + return '{}-{}'.format( + self._config[CONF_LATITUDE], self._config[CONF_LONGITUDE]) @property def name(self): """Return the name of the sensor.""" - return self._name + name = self._config.get(CONF_NAME) + + if name is not None: + return CONF_NAME + + if self.track_home: + return self.hass.config.location_name + + return DEFAULT_NAME @property def condition(self): diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index c8060891fd4..90217016d60 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -148,6 +148,10 @@ class CoreConfigOnboardingView(_BaseOnboardingView): await self._async_mark_done(hass) + await hass.config_entries.flow.async_init('met', context={ + 'source': 'onboarding' + }) + return self.json({}) diff --git a/homeassistant/config.py b/homeassistant/config.py index 7d36fb6f798..ae5d2ce24fd 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -60,10 +60,6 @@ default_config: # http: # base_url: example.duckdns.org:8123 -# Weather prediction -weather: - - platform: met - # Text to speech tts: - platform: google_translate diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b003dea37e..5544793cc7f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -238,6 +238,10 @@ py-canary==0.5.0 # homeassistant.components.tplink pyHS100==0.3.5 +# homeassistant.components.met +# homeassistant.components.norway_air +pyMetno==0.4.6 + # homeassistant.components.blackbird pyblackbird==0.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f1f3655cef3..a8df6f63232 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -90,6 +90,7 @@ TEST_REQUIREMENTS = ( 'libpurecool', 'libsoundtouch', 'luftdaten', + 'pyMetno', 'mbddns', 'mficlient', 'netdisco', diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py new file mode 100644 index 00000000000..658ed2901ec --- /dev/null +++ b/tests/components/met/__init__.py @@ -0,0 +1 @@ +"""Tests for Met.no.""" diff --git a/tests/components/met/conftest.py b/tests/components/met/conftest.py new file mode 100644 index 00000000000..47df348102e --- /dev/null +++ b/tests/components/met/conftest.py @@ -0,0 +1,24 @@ +"""Fixtures for Met weather testing.""" +from unittest.mock import patch + +import pytest + +from tests.common import mock_coro + + +@pytest.fixture +def mock_weather(): + """Mock weather data.""" + with patch('metno.MetWeatherData') as mock_data: + mock_data = mock_data.return_value + mock_data.fetching_data.side_effect = lambda: mock_coro(True) + mock_data.get_current_weather.return_value = { + 'condition': 'cloudy', + 'temperature': 15, + 'pressure': 100, + 'humidity': 50, + 'wind_speed': 10, + 'wind_bearing': 'NE', + } + mock_data.get_forecast.return_value = {} + yield mock_data diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 9e625eaa9f5..b74cc6e9efe 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -131,3 +131,19 @@ async def test_flow_entry_config_entry_already_exists(): assert len(config_form.mock_calls) == 1 assert len(config_entries.mock_calls) == 1 assert len(flow._errors) == 1 + + +async def test_onboarding_step(hass, mock_weather): + """Test initializing via onboarding step.""" + hass = Mock() + + flow = config_flow.MetFlowHandler() + flow.hass = hass + + result = await flow.async_step_onboarding({}) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Home' + assert result['data'] == { + 'track_home': True, + } diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py new file mode 100644 index 00000000000..14f7fa2bfbe --- /dev/null +++ b/tests/components/met/test_weather.py @@ -0,0 +1,52 @@ +"""Test Met weather entity.""" + + +async def test_tracking_home(hass, mock_weather): + """Test we track home.""" + await hass.config_entries.flow.async_init('met', context={ + 'source': 'onboarding' + }) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 + assert len(mock_weather.mock_calls) == 3 + + # Test we track config + await hass.config.async_update( + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + assert len(mock_weather.mock_calls) == 6 + + entry = hass.config_entries.async_entries()[0] + await hass.config_entries.async_remove(entry.entry_id) + assert len(hass.states.async_entity_ids('weather')) == 0 + + +async def test_not_tracking_home(hass, mock_weather): + """Test when we not track home.""" + await hass.config_entries.flow.async_init('met', context={ + 'source': 'user' + }, data={ + 'name': 'Somewhere', + 'latitude': 10, + 'longitude': 20, + 'elevation': 0, + }) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1 + assert len(mock_weather.mock_calls) == 3 + + # Test we do not track config + await hass.config.async_update( + latitude=10, + longitude=20, + ) + await hass.async_block_till_done() + + assert len(mock_weather.mock_calls) == 3 + + entry = hass.config_entries.async_entries()[0] + await hass.config_entries.async_remove(entry.entry_id) + assert len(hass.states.async_entity_ids('weather')) == 0 diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 4e253741286..3f26f5f42e6 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -9,10 +9,17 @@ from homeassistant.components import onboarding from homeassistant.components.onboarding import const, views from tests.common import CLIENT_ID, register_auth_provider +from tests.components.met.conftest import mock_weather # noqa from . import mock_storage +@pytest.fixture(autouse=True) +def always_mock_weather(mock_weather): # noqa + """Mock the Met weather provider.""" + pass + + @pytest.fixture(autouse=True) def auth_active(hass): """Ensure auth is always active.""" @@ -224,3 +231,21 @@ async def test_onboarding_integration_requires_auth(hass, hass_storage, }) assert resp.status == 401 + + +async def test_onboarding_core_sets_up_met(hass, hass_storage, hass_client): + """Test finishing the core step.""" + mock_storage(hass_storage, { + 'done': [const.STEP_USER] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await hass_client() + + resp = await client.post('/api/onboarding/core_config') + + assert resp.status == 200 + + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids('weather')) == 1