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."""
now = dt_util.utcnow()
# Check if data should be updated
if self._update is not None and now <= self._update:
return
self._weather.update()
# Find sensor
for time_entry in self._weather.data['product']['time']:
valid_from = dt_util.parse_datetime(time_entry['@from'])
valid_to = dt_util.parse_datetime(time_entry['@to'])
loc_data = time_entry['location']
if self.type not in loc_data or now >= valid_to:
continue
self._update = valid_to
if self.type == 'precipitation' and valid_from < now:
self._state = loc_data[self.type]['@value']
break
elif self.type == 'symbol' and valid_from < now:
self._state = loc_data[self.type]['@number']
break
elif self.type in ('temperature', 'pressure', 'humidity',
'dewpointTemperature'):
self._state = loc_data[self.type]['@value']
break
elif self.type in ('windSpeed', 'windGust'):
self._state = loc_data[self.type]['@mps']
break
elif self.type == 'windDirection':
self._state = float(loc_data[self.type]['@deg'])
break
elif self.type in ('fog', 'cloudiness', 'lowClouds',
'mediumClouds', 'highClouds'):
self._state = loc_data[self.type]['@percent']
break
class YrData(object): class YrData(object):
"""Get the latest data and updates the states.""" """Get the latest data and updates the states."""
def __init__(self, coordinates): def __init__(self, hass, coordinates, devices):
"""Initialize the data object.""" """Initialize the data object."""
self._url = 'http://api.yr.no/weatherapi/locationforecast/1.9/?' \ self._url = 'http://api.yr.no/weatherapi/locationforecast/1.9/?' \
'lat={lat};lon={lon};msl={msl}'.format(**coordinates) 'lat={lat};lon={lon};msl={msl}'.format(**coordinates)
self._nextrun = None self._nextrun = None
self.devices = devices
self.data = {} self.data = {}
self.update() self.hass = hass
def update(self): @asyncio.coroutine
def async_update(self):
"""Get the latest data from yr.no.""" """Get the latest data from yr.no."""
# Check if new will be available def try_again(err: str):
if self._nextrun is not None and dt_util.utcnow() <= self._nextrun: """Schedule again later."""
return _LOGGER.warning('Retrying in 15 minutes: %s', err)
try: nxt = dt_util.utcnow() + timedelta(minutes=15)
with requests.Session() as sess: async_track_point_in_utc_time(self.hass, self.async_update, nxt)
response = sess.get(self._url)
except requests.RequestException:
return
if response.status_code != 200:
return
data = response.text
import xmltodict try:
self.data = xmltodict.parse(data)['weatherdata'] with async_timeout.timeout(10, loop=self.hass.loop):
model = self.data['meta']['model'] resp = yield from self.hass.websession.get(self._url)
if '@nextrun' not in model: if resp.status != 200:
model = model[0] try_again('{} returned {}'.format(self._url, resp.status))
self._nextrun = dt_util.parse_datetime(model['@nextrun']) 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
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
for time_entry in self.data['product']['time']:
valid_from = dt_util.parse_datetime(time_entry['@from'])
valid_to = dt_util.parse_datetime(time_entry['@to'])
loc_data = time_entry['location']
if dev.type not in loc_data or now >= valid_to:
continue
if dev.type == 'precipitation' and valid_from < now:
new_state = loc_data[dev.type]['@value']
break
elif dev.type == 'symbol' and valid_from < now:
new_state = loc_data[dev.type]['@number']
break
elif dev.type in ('temperature', 'pressure', 'humidity',
'dewpointTemperature'):
new_state = loc_data[dev.type]['@value']
break
elif dev.type in ('windSpeed', 'windGust'):
new_state = loc_data[dev.type]['@mps']
break
elif dev.type == 'windDirection':
new_state = float(loc_data[dev.type]['@deg'])
break
elif dev.type in ('fog', 'cloudiness', 'lowClouds',
'mediumClouds', 'highClouds'):
new_state = loc_data[dev.type]['@percent']
break
# pylint: disable=protected-access
if new_state != dev._state:
dev._state = new_state
tasks.append(dev.async_update_ha_state())
yield from asyncio.gather(*tasks, loop=self.hass.loop)

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() """Test the default setup."""
aioclient_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
text=load_fixture('yr.no.json'))
config = {'platform': 'yr',
'elevation': 0}
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})
def test_default_setup(self, requests_mock): state = hass.states.get('sensor.yr_symbol')
"""Test the default 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', assert state.state == '3'
return_value=now): assert state.attributes.get('unit_of_measurement') is None
assert setup_component(self.hass, 'sensor', {
'sensor': {'platform': 'yr',
'elevation': 0}})
state = self.hass.states.get('sensor.yr_symbol')
assert '3' == state.state @asyncio.coroutine
assert state.state.isnumeric() def test_custom_setup(hass, aioclient_mock):
assert state.attributes.get('unit_of_measurement') is None """Test a custom setup."""
aioclient_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
text=load_fixture('yr.no.json'))
def test_custom_setup(self, requests_mock): config = {'platform': 'yr',
"""Test a custom setup.""" 'elevation': 0,
requests_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/', 'monitored_conditions': [
text=load_fixture('yr.no.json')) 'pressure',
now = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC) 'windDirection',
'humidity',
'fog',
'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})
with patch('homeassistant.components.sensor.yr.dt_util.utcnow', state = hass.states.get('sensor.yr_pressure')
return_value=now): assert state.attributes.get('unit_of_measurement') == 'hPa'
assert setup_component(self.hass, 'sensor', { assert state.state == '1009.3'
'sensor': {'platform': 'yr',
'elevation': 0,
'monitored_conditions': [
'pressure',
'windDirection',
'humidity',
'fog',
'windSpeed']}})
state = self.hass.states.get('sensor.yr_pressure') state = hass.states.get('sensor.yr_wind_direction')
assert 'hPa' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == '°'
assert '1009.3' == state.state assert state.state == '103.6'
state = self.hass.states.get('sensor.yr_wind_direction') state = hass.states.get('sensor.yr_humidity')
assert '°' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == '%'
assert '103.6' == state.state assert state.state == '55.5'
state = self.hass.states.get('sensor.yr_humidity') state = hass.states.get('sensor.yr_fog')
assert '%' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == '%'
assert '55.5' == state.state assert state.state == '0.0'
state = self.hass.states.get('sensor.yr_fog') state = hass.states.get('sensor.yr_wind_speed')
assert '%' == state.attributes.get('unit_of_measurement') assert state.attributes.get('unit_of_measurement') == 'm/s'
assert '0.0' == state.state assert state.state == '3.5'
state = self.hass.states.get('sensor.yr_wind_speed')
assert 'm/s', state.attributes.get('unit_of_measurement')
assert '3.5' == state.state

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):