diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index aa5d431a7b0..e9e0c00d47d 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -4,21 +4,24 @@ Support for WUnderground weather service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.wunderground/ """ +import asyncio from datetime import timedelta import logging - import re -import requests + +import aiohttp +import async_timeout import voluptuous as vol -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, - LENGTH_MILES, LENGTH_FEET, STATE_UNKNOWN, ATTR_ATTRIBUTION) + LENGTH_MILES, LENGTH_FEET, ATTR_ATTRIBUTION) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.entity import Entity, generate_entity_id +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -627,7 +630,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +@asyncio.coroutine +def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the WUnderground sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -639,13 +644,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for variable in config[CONF_MONITORED_CONDITIONS]: sensors.append(WUndergroundSensor(hass, rest, variable)) - rest.update() + yield from rest.async_update() if not rest.data: raise PlatformNotReady - add_devices(sensors) - - return True + async_add_devices(sensors, True) class WUndergroundSensor(Entity): @@ -663,7 +666,7 @@ class WUndergroundSensor(Entity): self._entity_picture = None self._unit_of_measurement = self._cfg_expand("unit_of_measurement") self.rest.request_feature(SENSOR_TYPES[condition].feature) - self.entity_id = generate_entity_id( + self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, "pws_" + condition, hass=hass) def _cfg_expand(self, what, default=None): @@ -727,15 +730,16 @@ class WUndergroundSensor(Entity): """Return the units of measurement.""" return self._unit_of_measurement - def update(self): + @asyncio.coroutine + def async_update(self): """Update current conditions.""" - self.rest.update() + yield from self.rest.async_update() if not self.rest.data: # no data, return return - self._state = self._cfg_expand("value", STATE_UNKNOWN) + self._state = self._cfg_expand("value") self._update_attrs() self._icon = self._cfg_expand("icon", super().icon) url = self._cfg_expand("entity_picture") @@ -757,6 +761,7 @@ class WUndergroundData(object): self._longitude = longitude self._features = set() self.data = None + self._session = async_get_clientsession(self._hass) def request_feature(self, feature): """Register feature to be fetched from WU API.""" @@ -764,7 +769,7 @@ class WUndergroundData(object): def _build_url(self, baseurl=_RESOURCE): url = baseurl.format( - self._api_key, "/".join(self._features), self._lang) + self._api_key, '/'.join(sorted(self._features)), self._lang) if self._pws_id: url = url + 'pws:{}'.format(self._pws_id) else: @@ -772,20 +777,20 @@ class WUndergroundData(object): return url + '.json' + @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): + def async_update(self): """Get the latest data from WUnderground.""" try: - result = requests.get(self._build_url(), timeout=10).json() + with async_timeout.timeout(10, loop=self._hass.loop): + response = yield from self._session.get(self._build_url()) + result = yield from response.json() if "error" in result['response']: - raise ValueError(result['response']["error"] - ["description"]) - else: - self.data = result - return True + raise ValueError(result['response']["error"]["description"]) + self.data = result except ValueError as err: _LOGGER.error("Check WUnderground API %s", err.args) self.data = None - except requests.RequestException as err: + except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Error fetching WUnderground data: %s", repr(err)) self.data = None diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py index c1508f49851..27047ba0ad0 100644 --- a/tests/components/sensor/test_wunderground.py +++ b/tests/components/sensor/test_wunderground.py @@ -1,13 +1,14 @@ """The tests for the WUnderground platform.""" -import unittest +import asyncio +import aiohttp + +from pytest import raises from homeassistant.components.sensor import wunderground from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES, STATE_UNKNOWN from homeassistant.exceptions import PlatformNotReady - -from requests.exceptions import ConnectionError - -from tests.common import get_test_home_assistant +from homeassistant.setup import async_setup_component +from tests.common import load_fixture, assert_setup_component VALID_CONFIG_PWS = { 'platform': 'wunderground', @@ -21,6 +22,7 @@ VALID_CONFIG_PWS = { VALID_CONFIG = { 'platform': 'wunderground', 'api_key': 'foo', + 'lang': 'EN', 'monitored_conditions': [ 'weather', 'feelslike_c', 'alerts', 'elevation', 'location', 'weather_1d_metric', 'precip_1d_in' @@ -37,268 +39,107 @@ INVALID_CONFIG = { ] } -FEELS_LIKE = '40' -WEATHER = 'Clear' -HTTPS_ICON_URL = 'https://icons.wxug.com/i/c/k/clear.gif' -ALERT_MESSAGE = 'This is a test alert message' -ALERT_ICON = 'mdi:alert-circle-outline' -FORECAST_TEXT = 'Mostly Cloudy. Fog overnight.' -PRECIP_IN = 0.03 +URL = 'http://api.wunderground.com/api/foo/alerts/conditions/forecast/lang' \ + ':EN/q/32.87336,-117.22743.json' +PWS_URL = 'http://api.wunderground.com/api/foo/alerts/conditions/' \ + 'lang:EN/q/pws:bar.json' +INVALID_URL = 'http://api.wunderground.com/api/BOB/alerts/conditions/' \ + 'lang:foo/q/pws:bar.json' -def mocked_requests_get(*args, **kwargs): - """Mock requests.get invocations.""" - class MockResponse: - """Class to represent a mocked response.""" +@asyncio.coroutine +def test_setup(hass, aioclient_mock): + """Test that the component is loaded.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - if str(args[0]).startswith('http://api.wunderground.com/api/foo/'): - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1, - } - }, "current_observation": { - "image": { - "url": - 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - "feelslike_c": FEELS_LIKE, - "weather": WEATHER, - "icon_url": 'http://icons.wxug.com/i/c/k/clear.gif', - "display_location": { - "city": "Holly Springs", - "country": "US", - "full": "Holly Springs, NC" - }, - "observation_location": { - "elevation": "413 ft", - "full": "Twin Lake, Holly Springs, North Carolina" - }, - }, "alerts": [ - { - "type": 'FLO', - "description": "Areal Flood Warning", - "date": "9:36 PM CDT on September 22, 2016", - "expires": "10:00 AM CDT on September 23, 2016", - "message": ALERT_MESSAGE, - }, - - ], "forecast": { - "txt_forecast": { - "date": "22:35 CEST", - "forecastday": [ - { - "period": 0, - "icon_url": - "http://icons.wxug.com/i/c/k/clear.gif", - "title": "Tuesday", - "fcttext": FORECAST_TEXT, - "fcttext_metric": FORECAST_TEXT, - "pop": "0" - }, - ], - }, "simpleforecast": { - "forecastday": [ - { - "date": { - "pretty": "19:00 CEST 4. Duben 2017", - }, - "period": 1, - "high": { - "fahrenheit": "56", - "celsius": "13", - }, - "low": { - "fahrenheit": "43", - "celsius": "6", - }, - "conditions": "Možnost deště", - "icon_url": - "http://icons.wxug.com/i/c/k/chancerain.gif", - "qpf_allday": { - "in": PRECIP_IN, - "mm": 1, - }, - "maxwind": { - "mph": 0, - "kph": 0, - "dir": "", - "degrees": 0, - }, - "avewind": { - "mph": 0, - "kph": 0, - "dir": "severní", - "degrees": 0 - } - }, - ], - }, - }, - }, 200) - else: - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": {}, - "error": { - "type": "keynotfound", - "description": "this key does not exist" - } - } - }, 200) + with assert_setup_component(1, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': VALID_CONFIG}) -def mocked_requests_get_invalid(*args, **kwargs): - """Mock requests.get invocations invalid data.""" - class MockResponse: - """Class to represent a mocked response.""" +@asyncio.coroutine +def test_setup_pws(hass, aioclient_mock): + """Test that the component is loaded with PWS id.""" + aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json')) - def __init__(self, json_data, status_code): - """Initialize the mock response class.""" - self.json_data = json_data - self.status_code = status_code - - def json(self): - """Return the json of the response.""" - return self.json_data - - return MockResponse({ - "response": { - "version": "0.1", - "termsofService": - "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1, - } - }, "current_observation": { - "image": { - "url": - 'http://icons.wxug.com/graphics/wu2/logo_130x80.png', - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - }, - }, 200) + with assert_setup_component(1, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': VALID_CONFIG_PWS}) -class TestWundergroundSetup(unittest.TestCase): - """Test the WUnderground platform.""" +@asyncio.coroutine +def test_setup_invalid(hass, aioclient_mock): + """Test that the component is not loaded with invalid config.""" + aioclient_mock.get(INVALID_URL, + text=load_fixture('wunderground-error.json')) - # pylint: disable=invalid-name - DEVICES = [] + with assert_setup_component(0, 'sensor'): + yield from async_setup_component(hass, 'sensor', + {'sensor': INVALID_CONFIG}) - def add_devices(self, devices): - """Mock add devices.""" - for device in devices: - self.DEVICES.append(device) - def setUp(self): - """Initialize values for this testcase class.""" - self.DEVICES = [] - self.hass = get_test_home_assistant() - self.key = 'foo' - self.config = VALID_CONFIG_PWS - self.lat = 37.8267 - self.lon = -122.423 - self.hass.config.latitude = self.lat - self.hass.config.longitude = self.lon +@asyncio.coroutine +def test_sensor(hass, aioclient_mock): + """Test the WUnderground sensor class and methods.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json')) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() + yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG}) - @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) - def test_setup(self, req_mock): - """Test that the component is loaded if passed in PWS Id.""" - self.assertTrue( - wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, - self.add_devices, None)) - self.assertTrue( - wunderground.setup_platform(self.hass, VALID_CONFIG, - self.add_devices, None)) + state = hass.states.get('sensor.pws_weather') + assert state.state == 'Clear' + assert state.name == "Weather Summary" + assert 'unit_of_measurement' not in state.attributes + assert state.attributes['entity_picture'] == \ + 'https://icons.wxug.com/i/c/k/clear.gif' - with self.assertRaises(PlatformNotReady): - wunderground.setup_platform(self.hass, INVALID_CONFIG, - self.add_devices, None) + state = hass.states.get('sensor.pws_alerts') + assert state.state == '1' + assert state.name == 'Alerts' + assert state.attributes['Message'] == \ + "This is a test alert message" + assert state.attributes['icon'] == 'mdi:alert-circle-outline' + assert 'entity_picture' not in state.attributes - @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) - def test_sensor(self, req_mock): - """Test the WUnderground sensor class and methods.""" - wunderground.setup_platform(self.hass, VALID_CONFIG, self.add_devices, - None) - for device in self.DEVICES: - device.update() - entity_id = device.entity_id - friendly_name = device.name - self.assertTrue(entity_id.startswith('sensor.pws_')) - if entity_id == 'sensor.pws_weather': - self.assertEqual(HTTPS_ICON_URL, device.entity_picture) - self.assertEqual(WEATHER, device.state) - self.assertIsNone(device.unit_of_measurement) - self.assertEqual("Weather Summary", friendly_name) - elif entity_id == 'sensor.pws_alerts': - self.assertEqual(1, device.state) - self.assertEqual(ALERT_MESSAGE, - device.device_state_attributes['Message']) - self.assertEqual(ALERT_ICON, device.icon) - self.assertIsNone(device.entity_picture) - self.assertEqual('Alerts', friendly_name) - elif entity_id == 'sensor.pws_location': - self.assertEqual('Holly Springs, NC', device.state) - self.assertEqual('Location', friendly_name) - elif entity_id == 'sensor.pws_elevation': - self.assertEqual('413', device.state) - self.assertEqual('Elevation', friendly_name) - elif entity_id == 'sensor.pws_feelslike_c': - self.assertIsNone(device.entity_picture) - self.assertEqual(FEELS_LIKE, device.state) - self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement) - self.assertEqual("Feels Like", friendly_name) - elif entity_id == 'sensor.pws_weather_1d_metric': - self.assertEqual(FORECAST_TEXT, device.state) - self.assertEqual('Tuesday', friendly_name) - else: - self.assertEqual(entity_id, 'sensor.pws_precip_1d_in') - self.assertEqual(PRECIP_IN, device.state) - self.assertEqual(LENGTH_INCHES, device.unit_of_measurement) - self.assertEqual('Precipitation Intensity Today', - friendly_name) + state = hass.states.get('sensor.pws_location') + assert state.state == "Holly Springs, NC" + assert state.name == 'Location' - @unittest.mock.patch('requests.get', - side_effect=ConnectionError('test exception')) - def test_connect_failed(self, req_mock): - """Test the WUnderground connection error.""" - with self.assertRaises(PlatformNotReady): - wunderground.setup_platform(self.hass, VALID_CONFIG, - self.add_devices, None) + state = hass.states.get('sensor.pws_elevation') + assert state.state == '413' + assert state.name == 'Elevation' - @unittest.mock.patch('requests.get', - side_effect=mocked_requests_get_invalid) - def test_invalid_data(self, req_mock): - """Test the WUnderground invalid data.""" - wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, - self.add_devices, None) - for device in self.DEVICES: - device.update() - self.assertEqual(STATE_UNKNOWN, device.state) + state = hass.states.get('sensor.pws_feelslike_c') + assert state.state == '40' + assert state.name == "Feels Like" + assert 'entity_picture' not in state.attributes + assert state.attributes['unit_of_measurement'] == TEMP_CELSIUS + + state = hass.states.get('sensor.pws_weather_1d_metric') + assert state.state == "Mostly Cloudy. Fog overnight." + assert state.name == 'Tuesday' + + state = hass.states.get('sensor.pws_precip_1d_in') + assert state.state == '0.03' + assert state.name == "Precipitation Intensity Today" + assert state.attributes['unit_of_measurement'] == LENGTH_INCHES + + +@asyncio.coroutine +def test_connect_failed(hass, aioclient_mock): + """Test the WUnderground connection error.""" + aioclient_mock.get(URL, exc=aiohttp.ClientError()) + with raises(PlatformNotReady): + yield from wunderground.async_setup_platform(hass, VALID_CONFIG, + lambda _: None) + + +@asyncio.coroutine +def test_invalid_data(hass, aioclient_mock): + """Test the WUnderground invalid data.""" + aioclient_mock.get(URL, text=load_fixture('wunderground-invalid.json')) + + yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG}) + + for condition in VALID_CONFIG['monitored_conditions']: + state = hass.states.get('sensor.pws_' + condition) + assert state.state == STATE_UNKNOWN diff --git a/tests/fixtures/wunderground-error.json b/tests/fixtures/wunderground-error.json new file mode 100644 index 00000000000..264ecbf8cd6 --- /dev/null +++ b/tests/fixtures/wunderground-error.json @@ -0,0 +1,11 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": {}, + "error": { + "type": "keynotfound", + "description": "this key does not exist" + } + } +} diff --git a/tests/fixtures/wunderground-invalid.json b/tests/fixtures/wunderground-invalid.json new file mode 100644 index 00000000000..59661c6694d --- /dev/null +++ b/tests/fixtures/wunderground-invalid.json @@ -0,0 +1,18 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1, + "alerts": 1, + "forecast": 1 + } + }, + "current_observation": { + "image": { + "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", + "title": "Weather Underground", + "link": "http://www.wunderground.com" + } + } +} diff --git a/tests/fixtures/wunderground-valid.json b/tests/fixtures/wunderground-valid.json new file mode 100644 index 00000000000..7ac1081cb4e --- /dev/null +++ b/tests/fixtures/wunderground-valid.json @@ -0,0 +1,90 @@ +{ + "response": { + "version": "0.1", + "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", + "features": { + "conditions": 1, + "alerts": 1, + "forecast": 1 + } + }, + "current_observation": { + "image": { + "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", + "title": "Weather Underground", + "link": "http://www.wunderground.com" + }, + "feelslike_c": "40", + "weather": "Clear", + "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", + "display_location": { + "city": "Holly Springs", + "country": "US", + "full": "Holly Springs, NC" + }, + "observation_location": { + "elevation": "413 ft", + "full": "Twin Lake, Holly Springs, North Carolina" + } + }, + "alerts": [ + { + "type": "FLO", + "description": "Areal Flood Warning", + "date": "9:36 PM CDT on September 22, 2016", + "expires": "10:00 AM CDT on September 23, 2016", + "message": "This is a test alert message" + } + ], + "forecast": { + "txt_forecast": { + "date": "22:35 CEST", + "forecastday": [ + { + "period": 0, + "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", + "title": "Tuesday", + "fcttext": "Mostly Cloudy. Fog overnight.", + "fcttext_metric": "Mostly Cloudy. Fog overnight.", + "pop": "0" + } + ] + }, + "simpleforecast": { + "forecastday": [ + { + "date": { + "pretty": "19:00 CEST 4. Duben 2017" + }, + "period": 1, + "high": { + "fahrenheit": "56", + "celsius": "13" + }, + "low": { + "fahrenheit": "43", + "celsius": "6" + }, + "conditions": "Mo\u017enost de\u0161t\u011b", + "icon_url": "http://icons.wxug.com/i/c/k/chancerain.gif", + "qpf_allday": { + "in": 0.03, + "mm": 1 + }, + "maxwind": { + "mph": 0, + "kph": 0, + "dir": "", + "degrees": 0 + }, + "avewind": { + "mph": 0, + "kph": 0, + "dir": "severn\u00ed", + "degrees": 0 + } + } + ] + } + } +}