From 55d1d3d8ae2813ae3f0e5cc320e2f7831812d25b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 8 Feb 2019 09:55:58 +0000 Subject: [PATCH] Move weather.ipma into a component (#20706) * initial version * works * lint * move * hound * fix formatting * update * add extra features * houmd * docstring * fix tests * lint * update requirements_all.txt * new tests * lint * update CODEOWNERS * MockDependency pyipma * hound * bump pyipma version * add config_flow tests * lint * improve test coverage * fix test * address comments by @MartinHjelmare * remove device_info * hound * stale comment * Add deprecation warning * address comments * lint --- CODEOWNERS | 3 + .../components/ipma/.translations/en.json | 18 +++ homeassistant/components/ipma/__init__.py | 31 +++++ homeassistant/components/ipma/config_flow.py | 53 ++++++++ homeassistant/components/ipma/const.py | 14 +++ homeassistant/components/ipma/strings.json | 19 +++ .../{weather/ipma.py => ipma/weather.py} | 48 ++++++- homeassistant/config_entries.py | 1 + requirements_all.txt | 4 +- tests/components/ipma/__init__.py | 1 + tests/components/ipma/test_config_flow.py | 118 ++++++++++++++++++ tests/components/ipma/test_weather.py | 103 +++++++++++++++ tests/components/weather/test_ipma.py | 85 ------------- 13 files changed, 408 insertions(+), 90 deletions(-) create mode 100644 homeassistant/components/ipma/.translations/en.json create mode 100644 homeassistant/components/ipma/__init__.py create mode 100644 homeassistant/components/ipma/config_flow.py create mode 100644 homeassistant/components/ipma/const.py create mode 100644 homeassistant/components/ipma/strings.json rename homeassistant/components/{weather/ipma.py => ipma/weather.py} (82%) create mode 100644 tests/components/ipma/__init__.py create mode 100644 tests/components/ipma/test_config_flow.py create mode 100644 tests/components/ipma/test_weather.py delete mode 100644 tests/components/weather/test_ipma.py diff --git a/CODEOWNERS b/CODEOWNERS index b9f5cec0d64..a10be84472a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -198,6 +198,9 @@ homeassistant/components/homekit/* @cdce8p homeassistant/components/huawei_lte/* @scop homeassistant/components/*/huawei_lte.py @scop +# I +homeassistant/components/ipma/* @dgomes + # K homeassistant/components/knx/* @Julius2342 homeassistant/components/*/knx.py @Julius2342 diff --git a/homeassistant/components/ipma/.translations/en.json b/homeassistant/components/ipma/.translations/en.json new file mode 100644 index 00000000000..d1386757305 --- /dev/null +++ b/homeassistant/components/ipma/.translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "name_exists": "Name already exists" + }, + "step": { + "user": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Name" + }, + "title": "Location" + } + }, + "title": "Portuguese weather service (IPMA)" + } +} \ No newline at end of file diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py new file mode 100644 index 00000000000..87f62371b55 --- /dev/null +++ b/homeassistant/components/ipma/__init__.py @@ -0,0 +1,31 @@ +""" +Component for the Portuguese weather service - IPMA. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ipma/ +""" +from homeassistant.core import Config, HomeAssistant +from .config_flow import IpmaFlowHandler # noqa +from .const import DOMAIN # noqa + +DEFAULT_NAME = 'ipma' + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up configured IPMA.""" + # No support for component configuration + return True + + +async def async_setup_entry(hass, config_entry): + """Set up IPMA station as config entry.""" + 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') + return True diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py new file mode 100644 index 00000000000..bb42a00742e --- /dev/null +++ b/homeassistant/components/ipma/config_flow.py @@ -0,0 +1,53 @@ +"""Config flow to configure IPMA component.""" +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, HOME_LOCATION_NAME + + +@config_entries.HANDLERS.register(DOMAIN) +class IpmaFlowHandler(data_entry_flow.FlowHandler): + """Config flow for IPMA component.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Init IpmaFlowHandler.""" + self._errors = {} + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + self._errors = {} + + if user_input is not None: + if user_input[CONF_NAME] not in\ + self.hass.config_entries.async_entries(DOMAIN): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + self._errors[CONF_NAME] = 'name_exists' + + # default location is set hass configuration + return await self._show_config_form( + name=HOME_LOCATION_NAME, + latitude=self.hass.config.latitude, + longitude=self.hass.config.longitude) + + async def _show_config_form(self, name=None, latitude=None, + longitude=None): + """Show the configuration form to edit location data.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_NAME, default=name): str, + vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, + vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude + }), + errors=self._errors, + ) diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py new file mode 100644 index 00000000000..dbb19294541 --- /dev/null +++ b/homeassistant/components/ipma/const.py @@ -0,0 +1,14 @@ +"""Constants in ipma component.""" +import logging + +from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN + +DOMAIN = 'ipma' + +HOME_LOCATION_NAME = 'Home' + +ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".ipma_{}" +ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( + HOME_LOCATION_NAME) + +_LOGGER = logging.getLogger('homeassistant.components.ipma') diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json new file mode 100644 index 00000000000..f22d1b62fe4 --- /dev/null +++ b/homeassistant/components/ipma/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Portuguese weather service (IPMA)", + "step": { + "user": { + "title": "Location", + "description": "Instituto Português do Mar e Atmosfera", + "data": { + "name": "Name", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "name_exists": "Name already exists" + } + } +} diff --git a/homeassistant/components/weather/ipma.py b/homeassistant/components/ipma/weather.py similarity index 82% rename from homeassistant/components/weather/ipma.py rename to homeassistant/components/ipma/weather.py index fda0fef4f25..ec9b6fec2e8 100644 --- a/homeassistant/components/weather/ipma.py +++ b/homeassistant/components/ipma/weather.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyipma==1.1.6'] +REQUIREMENTS = ['pyipma==1.2.1'] _LOGGER = logging.getLogger(__name__) @@ -56,7 +56,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the ipma platform.""" + """Set up the ipma platform. + + Deprecated. + """ + _LOGGER.warning('Loading IPMA via platform config is deprecated') + latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -64,6 +69,23 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.error("Latitude or longitude not set in Home Assistant config") return + station = await async_get_station(hass, latitude, longitude) + + async_add_entities([IPMAWeather(station, config)], True) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + latitude = config_entry.data[CONF_LATITUDE] + longitude = config_entry.data[CONF_LONGITUDE] + + station = await async_get_station(hass, latitude, longitude) + + async_add_entities([IPMAWeather(station, config_entry.data)], True) + + +async def async_get_station(hass, latitude, longitude): + """Retrieve weather station, station name to be used as the entity name.""" from pyipma import Station websession = async_get_clientsession(hass) @@ -74,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.debug("Initializing for coordinates %s, %s -> station %s", latitude, longitude, station.local) - async_add_entities([IPMAWeather(station, config)], True) + return station class IPMAWeather(WeatherEntity): @@ -103,6 +125,11 @@ class IPMAWeather(WeatherEntity): self._forecast = await self._station.forecast() self._description = self._forecast[0].description + @property + def unique_id(self) -> str: + """Return a unique id.""" + return '{}, {}'.format(self._station.latitude, self._station.longitude) + @property def attribution(self): """Return the attribution.""" @@ -125,26 +152,41 @@ class IPMAWeather(WeatherEntity): @property def temperature(self): """Return the current temperature.""" + if not self._condition: + return None + return self._condition.temperature @property def pressure(self): """Return the current pressure.""" + if not self._condition: + return None + return self._condition.pressure @property def humidity(self): """Return the name of the sensor.""" + if not self._condition: + return None + return self._condition.humidity @property def wind_speed(self): """Return the current windspeed.""" + if not self._condition: + return None + return self._condition.windspeed @property def wind_bearing(self): """Return the current wind bearing (degrees).""" + if not self._condition: + return None + return self._condition.winddirection @property diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c72f0f22827..7c6da2644f6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -149,6 +149,7 @@ FLOWS = [ 'hue', 'ifttt', 'ios', + 'ipma', 'lifx', 'locative', 'luftdaten', diff --git a/requirements_all.txt b/requirements_all.txt index 7033b48b414..8b2a0a7cc6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1063,8 +1063,8 @@ pyialarm==0.3 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 -# homeassistant.components.weather.ipma -pyipma==1.1.6 +# homeassistant.components.ipma.weather +pyipma==1.2.1 # homeassistant.components.sensor.irish_rail_transport pyirishrail==0.0.2 diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py new file mode 100644 index 00000000000..35099c405bb --- /dev/null +++ b/tests/components/ipma/__init__.py @@ -0,0 +1 @@ +"""Tests for the IPMA component.""" diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py new file mode 100644 index 00000000000..4a72318128e --- /dev/null +++ b/tests/components/ipma/test_config_flow.py @@ -0,0 +1,118 @@ +"""Tests for IPMA config flow.""" +from unittest.mock import Mock, patch + +from tests.common import mock_coro + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.components.ipma import config_flow + + +async def test_show_config_form(): + """Test show configuration form.""" + hass = Mock() + flow = config_flow.IpmaFlowHandler() + flow.hass = hass + + result = await flow._show_config_form() + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_show_config_form_default_values(): + """Test show configuration form.""" + hass = Mock() + flow = config_flow.IpmaFlowHandler() + flow.hass = hass + + result = await flow._show_config_form( + name="test", latitude='0', longitude='0') + + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_with_home_location(hass): + """Test config flow . + + Tests the flow when a default location is configured + then it should return a form with default values + """ + flow = config_flow.IpmaFlowHandler() + flow.hass = hass + + hass.config.location_name = 'Home' + hass.config.latitude = 1 + hass.config.longitude = 1 + + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + +async def test_flow_show_form(): + """Test show form scenarios first time. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.IpmaFlowHandler() + flow.hass = hass + + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form: + await flow.async_step_user() + assert len(config_form.mock_calls) == 1 + + +async def test_flow_entry_created_from_user_input(): + """Test that create data from user input. + + Test when the form should show when no configurations exists + """ + hass = Mock() + flow = config_flow.IpmaFlowHandler() + flow.hass = hass + + test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'} + + # Test that entry created when user_input name not exists + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form,\ + patch.object(flow.hass.config_entries, 'async_entries', + return_value=mock_coro()) as config_entries: + + result = await flow.async_step_user(user_input=test_data) + + assert result['type'] == 'create_entry' + assert result['data'] == test_data + assert len(config_entries.mock_calls) == 1 + assert not config_form.mock_calls + + +async def test_flow_entry_config_entry_already_exists(): + """Test that create data from user input and config_entry already exists. + + Test when the form should show when user puts existing name + in the config gui. Then the form should show with error + """ + hass = Mock() + flow = config_flow.IpmaFlowHandler() + flow.hass = hass + + test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'} + + # Test that entry created when user_input name not exists + with \ + patch.object(flow, '_show_config_form', + return_value=mock_coro()) as config_form,\ + patch.object(flow.hass.config_entries, 'async_entries', + return_value={'home': test_data}) as config_entries: + + await flow.async_step_user(user_input=test_data) + + assert len(config_form.mock_calls) == 1 + assert len(config_entries.mock_calls) == 1 + assert len(flow._errors) == 1 diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py new file mode 100644 index 00000000000..c141fbae7f1 --- /dev/null +++ b/tests/components/ipma/test_weather.py @@ -0,0 +1,103 @@ +"""The tests for the IPMA weather component.""" +from unittest.mock import patch +from collections import namedtuple + +from homeassistant.components import weather +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN) + +from tests.common import MockConfigEntry, mock_coro +from homeassistant.setup import async_setup_component + +TEST_CONFIG = { + "name": "HomeTown", + "latitude": "40.00", + "longitude": "-8.00", +} + + +class MockStation(): + """Mock Station from pyipma.""" + + async def observation(self): + """Mock Observation.""" + Observation = namedtuple('Observation', ['temperature', 'humidity', + 'windspeed', 'winddirection', + 'precipitation', 'pressure', + 'description']) + + return Observation(18, 71.0, 3.94, 'NW', 0, 1000.0, '---') + + async def forecast(self): + """Mock Forecast.""" + Forecast = namedtuple('Forecast', ['precipitaProb', 'tMin', 'tMax', + 'predWindDir', 'idWeatherType', + 'classWindSpeed', 'longitude', + 'forecastDate', 'classPrecInt', + 'latitude', 'description']) + + return [Forecast(73.0, 13.7, 18.7, 'NW', 6, 2, -8.64, + '2018-05-31', 2, 40.61, + 'Aguaceiros, com vento Moderado de Noroeste')] + + @property + def local(self): + """Mock location.""" + return "HomeTown" + + @property + def latitude(self): + """Mock latitude.""" + return 0 + + @property + def longitude(self): + """Mock longitude.""" + return 0 + + +async def test_setup_configuration(hass): + """Test for successfully setting up the IPMA platform.""" + with patch('homeassistant.components.ipma.weather.async_get_station', + return_value=mock_coro(MockStation())): + assert await async_setup_component(hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeTown', + 'platform': 'ipma', + } + }) + await hass.async_block_till_done() + + state = hass.states.get('weather.hometown') + assert state.state == 'rainy' + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0 + assert data.get(ATTR_WEATHER_HUMIDITY) == 71 + assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0 + assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94 + assert data.get(ATTR_WEATHER_WIND_BEARING) == 'NW' + assert state.attributes.get('friendly_name') == 'HomeTown' + + +async def test_setup_config_flow(hass): + """Test for successfully setting up the IPMA platform.""" + with patch('homeassistant.components.ipma.weather.async_get_station', + return_value=mock_coro(MockStation())): + entry = MockConfigEntry(domain='ipma', data=TEST_CONFIG) + await hass.config_entries.async_forward_entry_setup( + entry, WEATHER_DOMAIN) + await hass.async_block_till_done() + + state = hass.states.get('weather.hometown') + assert state.state == 'rainy' + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0 + assert data.get(ATTR_WEATHER_HUMIDITY) == 71 + assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0 + assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94 + assert data.get(ATTR_WEATHER_WIND_BEARING) == 'NW' + assert state.attributes.get('friendly_name') == 'HomeTown' diff --git a/tests/components/weather/test_ipma.py b/tests/components/weather/test_ipma.py deleted file mode 100644 index c7c89ecdbdb..00000000000 --- a/tests/components/weather/test_ipma.py +++ /dev/null @@ -1,85 +0,0 @@ -"""The tests for the IPMA weather component.""" -import unittest -from unittest.mock import patch -from collections import namedtuple - -from homeassistant.components import weather -from homeassistant.components.weather import ( - ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, - ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED) -from homeassistant.util.unit_system import METRIC_SYSTEM -from homeassistant.setup import setup_component - -from tests.common import get_test_home_assistant, MockDependency - - -class MockStation(): - """Mock Station from pyipma.""" - - @classmethod - async def get(cls, websession, lat, lon): - """Mock Factory.""" - return MockStation() - - async def observation(self): - """Mock Observation.""" - Observation = namedtuple('Observation', ['temperature', 'humidity', - 'windspeed', 'winddirection', - 'precipitation', 'pressure', - 'description']) - - return Observation(18, 71.0, 3.94, 'NW', 0, 1000.0, '---') - - async def forecast(self): - """Mock Forecast.""" - Forecast = namedtuple('Forecast', ['precipitaProb', 'tMin', 'tMax', - 'predWindDir', 'idWeatherType', - 'classWindSpeed', 'longitude', - 'forecastDate', 'classPrecInt', - 'latitude', 'description']) - - return [Forecast(73.0, 13.7, 18.7, 'NW', 6, 2, -8.64, - '2018-05-31', 2, 40.61, - 'Aguaceiros, com vento Moderado de Noroeste')] - - @property - def local(self): - """Mock location.""" - return "HomeTown" - - -class TestIPMA(unittest.TestCase): - """Test the IPMA weather component.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.config.units = METRIC_SYSTEM - self.lat = self.hass.config.latitude = 40.00 - self.lon = self.hass.config.longitude = -8.00 - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - @MockDependency("pyipma") - @patch("pyipma.Station", new=MockStation) - def test_setup(self, mock_pyipma): - """Test for successfully setting up the IPMA platform.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeTown', - 'platform': 'ipma', - } - }) - - state = self.hass.states.get('weather.hometown') - assert state.state == 'rainy' - - data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0 - assert data.get(ATTR_WEATHER_HUMIDITY) == 71 - assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0 - assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94 - assert data.get(ATTR_WEATHER_WIND_BEARING) == 'NW' - assert state.attributes.get('friendly_name') == 'HomeTown'