Async version of Yr.no (#4158)

* initial

* feedback

* More feedback. Still need to fix match_url

* url_match

* split_lines
This commit is contained in:
Johann Kellerman 2016-11-03 04:34:12 +02:00 committed by Paulus Schoutsen
parent 0d14920758
commit f3595f790a
4 changed files with 186 additions and 140 deletions

View file

@ -4,9 +4,13 @@ Support for Yr.no 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.yr/ https://home-assistant.io/components/sensor.yr/
""" """
import asyncio
from datetime import timedelta
import logging import logging
from xml.parsers.expat import ExpatError
import requests import async_timeout
from aiohttp.web import HTTPException
import voluptuous as vol import voluptuous as vol
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -15,8 +19,10 @@ from homeassistant.const import (
CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, CONF_MONITORED_CONDITIONS, CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, CONF_MONITORED_CONDITIONS,
ATTR_ATTRIBUTION) ATTR_ATTRIBUTION)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
REQUIREMENTS = ['xmltodict==0.10.2'] REQUIREMENTS = ['xmltodict==0.10.2']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -43,15 +49,15 @@ SENSOR_TYPES = {
} }
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): vol.Optional(CONF_MONITORED_CONDITIONS, default=['symbol']): vol.All(
[vol.In(SENSOR_TYPES.keys())], cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES.keys())]),
vol.Optional(CONF_LATITUDE): cv.latitude, vol.Optional(CONF_LATITUDE): cv.latitude,
vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_LONGITUDE): cv.longitude,
vol.Optional(CONF_ELEVATION): vol.Coerce(int), vol.Optional(CONF_ELEVATION): vol.Coerce(int),
}) })
def setup_platform(hass, config, add_devices, discovery_info=None): def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Setup the Yr.no sensor.""" """Setup the Yr.no 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)
@ -63,32 +69,25 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
coordinates = dict(lat=latitude, lon=longitude, msl=elevation) coordinates = dict(lat=latitude, lon=longitude, msl=elevation)
weather = YrData(coordinates)
dev = [] dev = []
for sensor_type in config[CONF_MONITORED_CONDITIONS]: for sensor_type in config[CONF_MONITORED_CONDITIONS]:
dev.append(YrSensor(sensor_type, weather)) dev.append(YrSensor(sensor_type))
yield from async_add_devices(dev)
# add symbol as default sensor weather = YrData(hass, coordinates, dev)
if len(dev) == 0: yield from weather.async_update()
dev.append(YrSensor("symbol", weather))
add_devices(dev)
class YrSensor(Entity): class YrSensor(Entity):
"""Representation of an Yr.no sensor.""" """Representation of an Yr.no sensor."""
def __init__(self, sensor_type, weather): def __init__(self, sensor_type):
"""Initialize the sensor.""" """Initialize the sensor."""
self.client_name = 'yr' self.client_name = 'yr'
self._name = SENSOR_TYPES[sensor_type][0] self._name = SENSOR_TYPES[sensor_type][0]
self.type = sensor_type self.type = sensor_type
self._state = None self._state = None
self._weather = weather
self._unit_of_measurement = SENSOR_TYPES[self.type][1] self._unit_of_measurement = SENSOR_TYPES[self.type][1]
self._update = None
self.update()
@property @property
def name(self): def name(self):
@ -100,6 +99,11 @@ class YrSensor(Entity):
"""Return the state of the device.""" """Return the state of the device."""
return self._state return self._state
@property
def should_poll(self): # pylint: disable=no-self-use
"""No polling needed."""
return False
@property @property
def entity_picture(self): def entity_picture(self):
"""Weather symbol if type is symbol.""" """Weather symbol if type is symbol."""
@ -120,78 +124,97 @@ class YrSensor(Entity):
"""Return the unit of measurement of this entity, if any.""" """Return the unit of measurement of this entity, if any."""
return self._unit_of_measurement return self._unit_of_measurement
def update(self):
"""Get the latest data from yr.no and updates the states.""" class YrData(object):
now = dt_util.utcnow() """Get the latest data and updates the states."""
# Check if data should be updated
if self._update is not None and now <= self._update: def __init__(self, hass, coordinates, devices):
"""Initialize the data object."""
self._url = 'http://api.yr.no/weatherapi/locationforecast/1.9/?' \
'lat={lat};lon={lon};msl={msl}'.format(**coordinates)
self._nextrun = None
self.devices = devices
self.data = {}
self.hass = hass
@asyncio.coroutine
def async_update(self):
"""Get the latest data from yr.no."""
def try_again(err: str):
"""Schedule again later."""
_LOGGER.warning('Retrying in 15 minutes: %s', err)
nxt = dt_util.utcnow() + timedelta(minutes=15)
async_track_point_in_utc_time(self.hass, self.async_update, nxt)
try:
with async_timeout.timeout(10, loop=self.hass.loop):
resp = yield from self.hass.websession.get(self._url)
if resp.status != 200:
try_again('{} returned {}'.format(self._url, resp.status))
return
text = yield from resp.text()
self.hass.loop.create_task(resp.release())
except asyncio.TimeoutError as err:
try_again(err)
return
except HTTPException as err:
resp.close()
try_again(err)
return return
self._weather.update() try:
import xmltodict
self.data = xmltodict.parse(text)['weatherdata']
model = self.data['meta']['model']
if '@nextrun' not in model:
model = model[0]
next_run = dt_util.parse_datetime(model['@nextrun'])
except (ExpatError, IndexError) as err:
try_again(err)
return
# Schedule next execution
async_track_point_in_utc_time(self.hass, self.async_update, next_run)
now = dt_util.utcnow()
tasks = []
# Update all devices
for dev in self.devices:
# Find sensor # Find sensor
for time_entry in self._weather.data['product']['time']: for time_entry in self.data['product']['time']:
valid_from = dt_util.parse_datetime(time_entry['@from']) valid_from = dt_util.parse_datetime(time_entry['@from'])
valid_to = dt_util.parse_datetime(time_entry['@to']) valid_to = dt_util.parse_datetime(time_entry['@to'])
loc_data = time_entry['location'] loc_data = time_entry['location']
if self.type not in loc_data or now >= valid_to: if dev.type not in loc_data or now >= valid_to:
continue continue
self._update = valid_to if dev.type == 'precipitation' and valid_from < now:
new_state = loc_data[dev.type]['@value']
if self.type == 'precipitation' and valid_from < now:
self._state = loc_data[self.type]['@value']
break break
elif self.type == 'symbol' and valid_from < now: elif dev.type == 'symbol' and valid_from < now:
self._state = loc_data[self.type]['@number'] new_state = loc_data[dev.type]['@number']
break break
elif self.type in ('temperature', 'pressure', 'humidity', elif dev.type in ('temperature', 'pressure', 'humidity',
'dewpointTemperature'): 'dewpointTemperature'):
self._state = loc_data[self.type]['@value'] new_state = loc_data[dev.type]['@value']
break break
elif self.type in ('windSpeed', 'windGust'): elif dev.type in ('windSpeed', 'windGust'):
self._state = loc_data[self.type]['@mps'] new_state = loc_data[dev.type]['@mps']
break break
elif self.type == 'windDirection': elif dev.type == 'windDirection':
self._state = float(loc_data[self.type]['@deg']) new_state = float(loc_data[dev.type]['@deg'])
break break
elif self.type in ('fog', 'cloudiness', 'lowClouds', elif dev.type in ('fog', 'cloudiness', 'lowClouds',
'mediumClouds', 'highClouds'): 'mediumClouds', 'highClouds'):
self._state = loc_data[self.type]['@percent'] new_state = loc_data[dev.type]['@percent']
break break
# pylint: disable=protected-access
if new_state != dev._state:
dev._state = new_state
tasks.append(dev.async_update_ha_state())
class YrData(object): yield from asyncio.gather(*tasks, loop=self.hass.loop)
"""Get the latest data and updates the states."""
def __init__(self, coordinates):
"""Initialize the data object."""
self._url = 'http://api.yr.no/weatherapi/locationforecast/1.9/?' \
'lat={lat};lon={lon};msl={msl}'.format(**coordinates)
self._nextrun = None
self.data = {}
self.update()
def update(self):
"""Get the latest data from yr.no."""
# Check if new will be available
if self._nextrun is not None and dt_util.utcnow() <= self._nextrun:
return
try:
with requests.Session() as sess:
response = sess.get(self._url)
except requests.RequestException:
return
if response.status_code != 200:
return
data = response.text
import xmltodict
self.data = xmltodict.parse(data)['weatherdata']
model = self.data['meta']['model']
if '@nextrun' not in model:
model = model[0]
self._nextrun = dt_util.parse_datetime(model['@nextrun'])

View file

@ -27,6 +27,7 @@ disable=
too-many-instance-attributes, too-many-instance-attributes,
too-many-locals, too-many-locals,
too-many-public-methods, too-many-public-methods,
too-many-return-statements,
too-many-statements, too-many-statements,
too-few-public-methods, too-few-public-methods,

View file

@ -1,77 +1,69 @@
"""The tests for the Yr sensor platform.""" """The tests for the Yr sensor platform."""
import asyncio
from datetime import datetime from datetime import datetime
from unittest.mock import patch from unittest.mock import patch
from homeassistant.bootstrap import setup_component from homeassistant.bootstrap import async_setup_component
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from tests.common import get_test_home_assistant, load_fixture from tests.common import assert_setup_component, load_fixture
class TestSensorYr: NOW = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
"""Test the Yr sensor."""
def setup_method(self):
"""Setup things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.hass.config.latitude = 32.87336
self.hass.config.longitude = 117.22743
def teardown_method(self): @asyncio.coroutine
"""Stop everything that was started.""" def test_default_setup(hass, aioclient_mock):
self.hass.stop()
def test_default_setup(self, requests_mock):
"""Test the default setup.""" """Test the default setup."""
requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/', aioclient_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
text=load_fixture('yr.no.json')) text=load_fixture('yr.no.json'))
now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC) config = {'platform': 'yr',
'elevation': 0}
hass.allow_pool = True
with patch('homeassistant.components.sensor.yr.dt_util.utcnow', with patch('homeassistant.components.sensor.yr.dt_util.utcnow',
return_value=now): return_value=NOW), assert_setup_component(1):
assert setup_component(self.hass, 'sensor', { yield from async_setup_component(hass, 'sensor', {'sensor': config})
'sensor': {'platform': 'yr',
'elevation': 0}})
state = self.hass.states.get('sensor.yr_symbol') state = hass.states.get('sensor.yr_symbol')
assert '3' == state.state assert state.state == '3'
assert state.state.isnumeric()
assert state.attributes.get('unit_of_measurement') is None assert state.attributes.get('unit_of_measurement') is None
def test_custom_setup(self, requests_mock):
"""Test a custom setup."""
requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
text=load_fixture('yr.no.json'))
now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
with patch('homeassistant.components.sensor.yr.dt_util.utcnow', @asyncio.coroutine
return_value=now): def test_custom_setup(hass, aioclient_mock):
assert setup_component(self.hass, 'sensor', { """Test a custom setup."""
'sensor': {'platform': 'yr', aioclient_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
text=load_fixture('yr.no.json'))
config = {'platform': 'yr',
'elevation': 0, 'elevation': 0,
'monitored_conditions': [ 'monitored_conditions': [
'pressure', 'pressure',
'windDirection', 'windDirection',
'humidity', 'humidity',
'fog', 'fog',
'windSpeed']}}) 'windSpeed']}
hass.allow_pool = True
with patch('homeassistant.components.sensor.yr.dt_util.utcnow',
return_value=NOW), assert_setup_component(1):
yield from async_setup_component(hass, 'sensor', {'sensor': config})
state = self.hass.states.get('sensor.yr_pressure') state = hass.states.get('sensor.yr_pressure')
assert 'hPa' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == 'hPa'
assert '1009.3' == state.state assert state.state == '1009.3'
state = self.hass.states.get('sensor.yr_wind_direction') state = hass.states.get('sensor.yr_wind_direction')
assert '°' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == '°'
assert '103.6' == state.state assert state.state == '103.6'
state = self.hass.states.get('sensor.yr_humidity') state = hass.states.get('sensor.yr_humidity')
assert '%' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == '%'
assert '55.5' == state.state assert state.state == '55.5'
state = self.hass.states.get('sensor.yr_fog') state = hass.states.get('sensor.yr_fog')
assert '%' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == '%'
assert '0.0' == state.state assert state.state == '0.0'
state = self.hass.states.get('sensor.yr_wind_speed') state = hass.states.get('sensor.yr_wind_speed')
assert 'm/s', state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == 'm/s'
assert '3.5' == state.state assert state.state == '3.5'

View file

@ -4,6 +4,7 @@ from contextlib import contextmanager
import functools import functools
import json as _json import json as _json
from unittest import mock from unittest import mock
from urllib.parse import urlparse, parse_qs
class AiohttpClientMocker: class AiohttpClientMocker:
@ -57,7 +58,8 @@ class AiohttpClientMocker:
return len(self.mock_calls) return len(self.mock_calls)
@asyncio.coroutine @asyncio.coroutine
def match_request(self, method, url, *, auth=None): def match_request(self, method, url, *, auth=None): \
# pylint: disable=unused-variable
"""Match a request against pre-registered requests.""" """Match a request against pre-registered requests."""
for response in self._mocks: for response in self._mocks:
if response.match_request(method, url): if response.match_request(method, url):
@ -74,13 +76,41 @@ class AiohttpClientMockResponse:
def __init__(self, method, url, status, response): def __init__(self, method, url, status, response):
"""Initialize a fake response.""" """Initialize a fake response."""
self.method = method self.method = method
self.url = url self._url = url
self._url_parts = (None if hasattr(url, 'search')
else urlparse(url.lower()))
self.status = status self.status = status
self.response = response self.response = response
def match_request(self, method, url): def match_request(self, method, url):
"""Test if response answers request.""" """Test if response answers request."""
return method == self.method and url == self.url if method.lower() != self.method.lower():
return False
# regular expression matching
if self._url_parts is None:
return self._url.search(url) is not None
req = urlparse(url.lower())
if self._url_parts.scheme and req.scheme != self._url_parts.scheme:
return False
if self._url_parts.netloc and req.netloc != self._url_parts.netloc:
return False
if (req.path or '/') != (self._url_parts.path or '/'):
return False
# Ensure all query components in matcher are present in the request
request_qs = parse_qs(req.query)
matcher_qs = parse_qs(self._url_parts.query)
for key, vals in matcher_qs.items():
for val in vals:
try:
request_qs.get(key, []).remove(val)
except ValueError:
return False
return True
@asyncio.coroutine @asyncio.coroutine
def read(self): def read(self):