Make WUnderground async (#12385)

* 🐎 Async WUnderground

* ∞ Them lines be too long

* Fix pylint warnings

* Changes according to comments

* Remove STATE_UNKNOWN

* 🔬 Fix tests

* Improve tests
This commit is contained in:
Otto Winter 2018-02-16 23:54:11 +01:00 committed by Paulus Schoutsen
parent b3a47722f0
commit fe5626b927
5 changed files with 241 additions and 276 deletions

View File

@ -4,21 +4,24 @@ Support for WUnderground weather service.
For more details about this platform, please refer to the documentation at For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.wunderground/ https://home-assistant.io/components/sensor.wunderground/
""" """
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
import re import re
import requests
import aiohttp
import async_timeout
import voluptuous as vol 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.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT
from homeassistant.const import ( from homeassistant.const import (
CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE,
TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS, 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.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 from homeassistant.util import Throttle
import homeassistant.helpers.config_validation as cv 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.""" """Set up the WUnderground sensor."""
latitude = config.get(CONF_LATITUDE, hass.config.latitude) latitude = config.get(CONF_LATITUDE, hass.config.latitude)
longitude = config.get(CONF_LONGITUDE, hass.config.longitude) 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]: for variable in config[CONF_MONITORED_CONDITIONS]:
sensors.append(WUndergroundSensor(hass, rest, variable)) sensors.append(WUndergroundSensor(hass, rest, variable))
rest.update() yield from rest.async_update()
if not rest.data: if not rest.data:
raise PlatformNotReady raise PlatformNotReady
add_devices(sensors) async_add_devices(sensors, True)
return True
class WUndergroundSensor(Entity): class WUndergroundSensor(Entity):
@ -663,7 +666,7 @@ class WUndergroundSensor(Entity):
self._entity_picture = None self._entity_picture = None
self._unit_of_measurement = self._cfg_expand("unit_of_measurement") self._unit_of_measurement = self._cfg_expand("unit_of_measurement")
self.rest.request_feature(SENSOR_TYPES[condition].feature) 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) ENTITY_ID_FORMAT, "pws_" + condition, hass=hass)
def _cfg_expand(self, what, default=None): def _cfg_expand(self, what, default=None):
@ -727,15 +730,16 @@ class WUndergroundSensor(Entity):
"""Return the units of measurement.""" """Return the units of measurement."""
return self._unit_of_measurement return self._unit_of_measurement
def update(self): @asyncio.coroutine
def async_update(self):
"""Update current conditions.""" """Update current conditions."""
self.rest.update() yield from self.rest.async_update()
if not self.rest.data: if not self.rest.data:
# no data, return # no data, return
return return
self._state = self._cfg_expand("value", STATE_UNKNOWN) self._state = self._cfg_expand("value")
self._update_attrs() self._update_attrs()
self._icon = self._cfg_expand("icon", super().icon) self._icon = self._cfg_expand("icon", super().icon)
url = self._cfg_expand("entity_picture") url = self._cfg_expand("entity_picture")
@ -757,6 +761,7 @@ class WUndergroundData(object):
self._longitude = longitude self._longitude = longitude
self._features = set() self._features = set()
self.data = None self.data = None
self._session = async_get_clientsession(self._hass)
def request_feature(self, feature): def request_feature(self, feature):
"""Register feature to be fetched from WU API.""" """Register feature to be fetched from WU API."""
@ -764,7 +769,7 @@ class WUndergroundData(object):
def _build_url(self, baseurl=_RESOURCE): def _build_url(self, baseurl=_RESOURCE):
url = baseurl.format( url = baseurl.format(
self._api_key, "/".join(self._features), self._lang) self._api_key, '/'.join(sorted(self._features)), self._lang)
if self._pws_id: if self._pws_id:
url = url + 'pws:{}'.format(self._pws_id) url = url + 'pws:{}'.format(self._pws_id)
else: else:
@ -772,20 +777,20 @@ class WUndergroundData(object):
return url + '.json' return url + '.json'
@asyncio.coroutine
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def async_update(self):
"""Get the latest data from WUnderground.""" """Get the latest data from WUnderground."""
try: 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']: if "error" in result['response']:
raise ValueError(result['response']["error"] raise ValueError(result['response']["error"]["description"])
["description"]) self.data = result
else:
self.data = result
return True
except ValueError as err: except ValueError as err:
_LOGGER.error("Check WUnderground API %s", err.args) _LOGGER.error("Check WUnderground API %s", err.args)
self.data = None self.data = None
except requests.RequestException as err: except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOGGER.error("Error fetching WUnderground data: %s", repr(err)) _LOGGER.error("Error fetching WUnderground data: %s", repr(err))
self.data = None self.data = None

View File

@ -1,13 +1,14 @@
"""The tests for the WUnderground platform.""" """The tests for the WUnderground platform."""
import unittest import asyncio
import aiohttp
from pytest import raises
from homeassistant.components.sensor import wunderground from homeassistant.components.sensor import wunderground
from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES, STATE_UNKNOWN from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES, STATE_UNKNOWN
from homeassistant.exceptions import PlatformNotReady from homeassistant.exceptions import PlatformNotReady
from homeassistant.setup import async_setup_component
from requests.exceptions import ConnectionError from tests.common import load_fixture, assert_setup_component
from tests.common import get_test_home_assistant
VALID_CONFIG_PWS = { VALID_CONFIG_PWS = {
'platform': 'wunderground', 'platform': 'wunderground',
@ -21,6 +22,7 @@ VALID_CONFIG_PWS = {
VALID_CONFIG = { VALID_CONFIG = {
'platform': 'wunderground', 'platform': 'wunderground',
'api_key': 'foo', 'api_key': 'foo',
'lang': 'EN',
'monitored_conditions': [ 'monitored_conditions': [
'weather', 'feelslike_c', 'alerts', 'elevation', 'location', 'weather', 'feelslike_c', 'alerts', 'elevation', 'location',
'weather_1d_metric', 'precip_1d_in' 'weather_1d_metric', 'precip_1d_in'
@ -37,268 +39,107 @@ INVALID_CONFIG = {
] ]
} }
FEELS_LIKE = '40' URL = 'http://api.wunderground.com/api/foo/alerts/conditions/forecast/lang' \
WEATHER = 'Clear' ':EN/q/32.87336,-117.22743.json'
HTTPS_ICON_URL = 'https://icons.wxug.com/i/c/k/clear.gif' PWS_URL = 'http://api.wunderground.com/api/foo/alerts/conditions/' \
ALERT_MESSAGE = 'This is a test alert message' 'lang:EN/q/pws:bar.json'
ALERT_ICON = 'mdi:alert-circle-outline' INVALID_URL = 'http://api.wunderground.com/api/BOB/alerts/conditions/' \
FORECAST_TEXT = 'Mostly Cloudy. Fog overnight.' 'lang:foo/q/pws:bar.json'
PRECIP_IN = 0.03
def mocked_requests_get(*args, **kwargs): @asyncio.coroutine
"""Mock requests.get invocations.""" def test_setup(hass, aioclient_mock):
class MockResponse: """Test that the component is loaded."""
"""Class to represent a mocked response.""" aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json'))
def __init__(self, json_data, status_code): with assert_setup_component(1, 'sensor'):
"""Initialize the mock response class.""" yield from async_setup_component(hass, 'sensor',
self.json_data = json_data {'sensor': VALID_CONFIG})
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)
def mocked_requests_get_invalid(*args, **kwargs): @asyncio.coroutine
"""Mock requests.get invocations invalid data.""" def test_setup_pws(hass, aioclient_mock):
class MockResponse: """Test that the component is loaded with PWS id."""
"""Class to represent a mocked response.""" aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json'))
def __init__(self, json_data, status_code): with assert_setup_component(1, 'sensor'):
"""Initialize the mock response class.""" yield from async_setup_component(hass, 'sensor',
self.json_data = json_data {'sensor': VALID_CONFIG_PWS})
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)
class TestWundergroundSetup(unittest.TestCase): @asyncio.coroutine
"""Test the WUnderground platform.""" 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 with assert_setup_component(0, 'sensor'):
DEVICES = [] 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): @asyncio.coroutine
"""Initialize values for this testcase class.""" def test_sensor(hass, aioclient_mock):
self.DEVICES = [] """Test the WUnderground sensor class and methods."""
self.hass = get_test_home_assistant() aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json'))
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
def tearDown(self): # pylint: disable=invalid-name yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG})
"""Stop everything that was started."""
self.hass.stop()
@unittest.mock.patch('requests.get', side_effect=mocked_requests_get) state = hass.states.get('sensor.pws_weather')
def test_setup(self, req_mock): assert state.state == 'Clear'
"""Test that the component is loaded if passed in PWS Id.""" assert state.name == "Weather Summary"
self.assertTrue( assert 'unit_of_measurement' not in state.attributes
wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, assert state.attributes['entity_picture'] == \
self.add_devices, None)) 'https://icons.wxug.com/i/c/k/clear.gif'
self.assertTrue(
wunderground.setup_platform(self.hass, VALID_CONFIG,
self.add_devices, None))
with self.assertRaises(PlatformNotReady): state = hass.states.get('sensor.pws_alerts')
wunderground.setup_platform(self.hass, INVALID_CONFIG, assert state.state == '1'
self.add_devices, None) 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) state = hass.states.get('sensor.pws_location')
def test_sensor(self, req_mock): assert state.state == "Holly Springs, NC"
"""Test the WUnderground sensor class and methods.""" assert state.name == 'Location'
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)
@unittest.mock.patch('requests.get', state = hass.states.get('sensor.pws_elevation')
side_effect=ConnectionError('test exception')) assert state.state == '413'
def test_connect_failed(self, req_mock): assert state.name == 'Elevation'
"""Test the WUnderground connection error."""
with self.assertRaises(PlatformNotReady):
wunderground.setup_platform(self.hass, VALID_CONFIG,
self.add_devices, None)
@unittest.mock.patch('requests.get', state = hass.states.get('sensor.pws_feelslike_c')
side_effect=mocked_requests_get_invalid) assert state.state == '40'
def test_invalid_data(self, req_mock): assert state.name == "Feels Like"
"""Test the WUnderground invalid data.""" assert 'entity_picture' not in state.attributes
wunderground.setup_platform(self.hass, VALID_CONFIG_PWS, assert state.attributes['unit_of_measurement'] == TEMP_CELSIUS
self.add_devices, None)
for device in self.DEVICES: state = hass.states.get('sensor.pws_weather_1d_metric')
device.update() assert state.state == "Mostly Cloudy. Fog overnight."
self.assertEqual(STATE_UNKNOWN, device.state) 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

11
tests/fixtures/wunderground-error.json vendored Normal file
View File

@ -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"
}
}
}

View File

@ -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"
}
}
}

90
tests/fixtures/wunderground-valid.json vendored Normal file
View File

@ -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
}
}
]
}
}
}