GeoRSS events sensor refactored (#16939)

* refactored geo_rss_events sensor to make use of new georss-client library that handles the communication with the rss feed

* fixed lint error
This commit is contained in:
Malte Franken 2018-10-02 18:20:51 +10:00 committed by Paulus Schoutsen
parent b0b3620b2b
commit 13af61e103
6 changed files with 154 additions and 316 deletions

View File

@ -9,7 +9,6 @@ For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.geo_rss_events/ https://home-assistant.io/components/sensor.geo_rss_events/
""" """
import logging import logging
from collections import namedtuple
from datetime import timedelta from datetime import timedelta
import voluptuous as vol import voluptuous as vol
@ -19,9 +18,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import ( from homeassistant.const import (
STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_RADIUS, CONF_URL) STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_RADIUS, CONF_URL)
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
REQUIREMENTS = ['feedparser==5.2.1', 'haversine==0.4.5'] REQUIREMENTS = ['georss_client==0.1']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -38,9 +36,6 @@ DEFAULT_UNIT_OF_MEASUREMENT = 'Events'
DOMAIN = 'geo_rss_events' DOMAIN = 'geo_rss_events'
# Minimum time between updates from the source.
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
SCAN_INTERVAL = timedelta(minutes=5) SCAN_INTERVAL = timedelta(minutes=5)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
@ -67,18 +62,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
_LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s", _LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s",
home_latitude, home_longitude, url, radius_in_km) home_latitude, home_longitude, url, radius_in_km)
# Initialise update service.
data = GeoRssServiceData(home_latitude, home_longitude, url, radius_in_km)
data.update()
# Create all sensors based on categories. # Create all sensors based on categories.
devices = [] devices = []
if not categories: if not categories:
device = GeoRssServiceSensor(None, data, name, unit_of_measurement) device = GeoRssServiceSensor((home_latitude, home_longitude), url,
radius_in_km, None, name,
unit_of_measurement)
devices.append(device) devices.append(device)
else: else:
for category in categories: for category in categories:
device = GeoRssServiceSensor(category, data, name, device = GeoRssServiceSensor((home_latitude, home_longitude), url,
radius_in_km, category, name,
unit_of_measurement) unit_of_measurement)
devices.append(device) devices.append(device)
add_entities(devices, True) add_entities(devices, True)
@ -87,14 +81,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
class GeoRssServiceSensor(Entity): class GeoRssServiceSensor(Entity):
"""Representation of a Sensor.""" """Representation of a Sensor."""
def __init__(self, category, data, service_name, unit_of_measurement): def __init__(self, home_coordinates, url, radius, category, service_name,
unit_of_measurement):
"""Initialize the sensor.""" """Initialize the sensor."""
self._category = category self._category = category
self._data = data
self._service_name = service_name self._service_name = service_name
self._state = STATE_UNKNOWN self._state = STATE_UNKNOWN
self._state_attributes = None self._state_attributes = None
self._unit_of_measurement = unit_of_measurement self._unit_of_measurement = unit_of_measurement
from georss_client.generic_feed import GenericFeed
self._feed = GenericFeed(home_coordinates, url, filter_radius=radius,
filter_categories=None if not category
else [category])
@property @property
def name(self): def name(self):
@ -125,115 +123,25 @@ class GeoRssServiceSensor(Entity):
def update(self): def update(self):
"""Update this sensor from the GeoRSS service.""" """Update this sensor from the GeoRSS service."""
_LOGGER.debug("About to update sensor %s", self.entity_id) import georss_client
self._data.update() status, feed_entries = self._feed.update()
# If no events were found due to an error then just set state to zero. if status == georss_client.UPDATE_OK:
if self._data.events is None:
self._state = 0
else:
if self._category is None:
# Add all events regardless of category.
my_events = self._data.events
else:
# Only keep events that belong to sensor's category.
my_events = [event for event in self._data.events if
event[ATTR_CATEGORY] == self._category]
_LOGGER.debug("Adding events to sensor %s: %s", self.entity_id, _LOGGER.debug("Adding events to sensor %s: %s", self.entity_id,
my_events) feed_entries)
self._state = len(my_events) self._state = len(feed_entries)
# And now compute the attributes from the filtered events. # And now compute the attributes from the filtered events.
matrix = {} matrix = {}
for event in my_events: for entry in feed_entries:
matrix[event[ATTR_TITLE]] = '{:.0f}km'.format( matrix[entry.title] = '{:.0f}km'.format(
event[ATTR_DISTANCE]) entry.distance_to_home)
self._state_attributes = matrix self._state_attributes = matrix
elif status == georss_client.UPDATE_OK_NO_DATA:
_LOGGER.debug("Update successful, but no data received from %s",
class GeoRssServiceData: self._feed)
"""Provide access to GeoRSS feed and stores the latest data.""" # Don't change the state or state attributes.
def __init__(self, home_latitude, home_longitude, url, radius_in_km):
"""Initialize the update service."""
self._home_coordinates = [home_latitude, home_longitude]
self._url = url
self._radius_in_km = radius_in_km
self.events = None
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Retrieve data from GeoRSS feed and store events."""
import feedparser
feed_data = feedparser.parse(self._url)
if not feed_data:
_LOGGER.error("Error fetching feed data from %s", self._url)
else: else:
events = self.filter_entries(feed_data) _LOGGER.warning("Update not successful, no data received from %s",
self.events = events self._feed)
# If no events were found due to an error then just set state to
def filter_entries(self, feed_data): # zero.
"""Filter entries by distance from home coordinates.""" self._state = 0
events = []
_LOGGER.debug("%s entri(es) available in feed %s",
len(feed_data.entries), self._url)
for entry in feed_data.entries:
geometry = None
if hasattr(entry, 'where'):
geometry = entry.where
elif hasattr(entry, 'geo_lat') and hasattr(entry, 'geo_long'):
coordinates = (float(entry.geo_long), float(entry.geo_lat))
point = namedtuple('Point', ['type', 'coordinates'])
geometry = point('Point', coordinates)
if geometry:
distance = self.calculate_distance_to_geometry(geometry)
if distance <= self._radius_in_km:
event = {
ATTR_CATEGORY: None if not hasattr(
entry, 'category') else entry.category,
ATTR_TITLE: None if not hasattr(
entry, 'title') else entry.title,
ATTR_DISTANCE: distance
}
events.append(event)
_LOGGER.debug("%s events found nearby", len(events))
return events
def calculate_distance_to_geometry(self, geometry):
"""Calculate the distance between HA and provided geometry."""
distance = float("inf")
if geometry.type == 'Point':
distance = self.calculate_distance_to_point(geometry)
elif geometry.type == 'Polygon':
distance = self.calculate_distance_to_polygon(
geometry.coordinates[0])
else:
_LOGGER.warning("Not yet implemented: %s", geometry.type)
return distance
def calculate_distance_to_point(self, point):
"""Calculate the distance between HA and the provided point."""
# Swap coordinates to match: (lat, lon).
coordinates = (point.coordinates[1], point.coordinates[0])
return self.calculate_distance_to_coords(coordinates)
def calculate_distance_to_coords(self, coordinates):
"""Calculate the distance between HA and the provided coordinates."""
# Expecting coordinates in format: (lat, lon).
from haversine import haversine
distance = haversine(coordinates, self._home_coordinates)
_LOGGER.debug("Distance from %s to %s: %s km", self._home_coordinates,
coordinates, distance)
return distance
def calculate_distance_to_polygon(self, polygon):
"""Calculate the distance between HA and the provided polygon."""
distance = float("inf")
# Calculate distance from polygon by calculating the distance
# to each point of the polygon but not to each edge of the
# polygon; should be good enough
for polygon_point in polygon:
coordinates = (polygon_point[1], polygon_point[0])
distance = min(distance,
self.calculate_distance_to_coords(coordinates))
_LOGGER.debug("Distance from %s to %s: %s km", self._home_coordinates,
polygon, distance)
return distance

View File

@ -356,7 +356,6 @@ fastdotcom==0.0.3
fedexdeliverymanager==1.0.6 fedexdeliverymanager==1.0.6
# homeassistant.components.feedreader # homeassistant.components.feedreader
# homeassistant.components.sensor.geo_rss_events
feedparser==5.2.1 feedparser==5.2.1
# homeassistant.components.sensor.fints # homeassistant.components.sensor.fints
@ -397,6 +396,9 @@ geizhals==0.0.7
# homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.geo_json_events
geojson_client==0.1 geojson_client==0.1
# homeassistant.components.sensor.geo_rss_events
georss_client==0.1
# homeassistant.components.sensor.gitter # homeassistant.components.sensor.gitter
gitterpy==0.1.7 gitterpy==0.1.7
@ -433,9 +435,6 @@ habitipy==0.2.0
# homeassistant.components.hangouts # homeassistant.components.hangouts
hangups==0.4.5 hangups==0.4.5
# homeassistant.components.sensor.geo_rss_events
haversine==0.4.5
# homeassistant.components.mqtt.server # homeassistant.components.mqtt.server
hbmqtt==0.9.4 hbmqtt==0.9.4

View File

@ -57,7 +57,6 @@ ephem==3.7.6.0
evohomeclient==0.2.7 evohomeclient==0.2.7
# homeassistant.components.feedreader # homeassistant.components.feedreader
# homeassistant.components.sensor.geo_rss_events
feedparser==5.2.1 feedparser==5.2.1
# homeassistant.components.sensor.foobot # homeassistant.components.sensor.foobot
@ -69,15 +68,15 @@ gTTS-token==1.1.2
# homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.geo_json_events
geojson_client==0.1 geojson_client==0.1
# homeassistant.components.sensor.geo_rss_events
georss_client==0.1
# homeassistant.components.ffmpeg # homeassistant.components.ffmpeg
ha-ffmpeg==1.9 ha-ffmpeg==1.9
# homeassistant.components.hangouts # homeassistant.components.hangouts
hangups==0.4.5 hangups==0.4.5
# homeassistant.components.sensor.geo_rss_events
haversine==0.4.5
# homeassistant.components.mqtt.server # homeassistant.components.mqtt.server
hbmqtt==0.9.4 hbmqtt==0.9.4

View File

@ -51,6 +51,7 @@ TEST_REQUIREMENTS = (
'foobot_async', 'foobot_async',
'gTTS-token', 'gTTS-token',
'geojson_client', 'geojson_client',
'georss_client',
'hangups', 'hangups',
'HAP-python', 'HAP-python',
'ha-ffmpeg', 'ha-ffmpeg',

View File

@ -2,26 +2,38 @@
import unittest import unittest
from unittest import mock from unittest import mock
import sys import sys
from unittest.mock import MagicMock, patch
import feedparser
import pytest import pytest
from homeassistant.components import sensor
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, ATTR_FRIENDLY_NAME, \
EVENT_HOMEASSISTANT_START, ATTR_ICON
from homeassistant.setup import setup_component from homeassistant.setup import setup_component
from tests.common import load_fixture, get_test_home_assistant from tests.common import get_test_home_assistant, \
assert_setup_component, fire_time_changed
import homeassistant.components.sensor.geo_rss_events as geo_rss_events import homeassistant.components.sensor.geo_rss_events as geo_rss_events
import homeassistant.util.dt as dt_util
URL = 'http://geo.rss.local/geo_rss_events.xml' URL = 'http://geo.rss.local/geo_rss_events.xml'
VALID_CONFIG_WITH_CATEGORIES = { VALID_CONFIG_WITH_CATEGORIES = {
'platform': 'geo_rss_events', sensor.DOMAIN: [
geo_rss_events.CONF_URL: URL, {
geo_rss_events.CONF_CATEGORIES: [ 'platform': 'geo_rss_events',
'Category 1', geo_rss_events.CONF_URL: URL,
'Category 2' geo_rss_events.CONF_CATEGORIES: [
'Category 1'
]
}
] ]
} }
VALID_CONFIG_WITHOUT_CATEGORIES = { VALID_CONFIG = {
'platform': 'geo_rss_events', sensor.DOMAIN: [
geo_rss_events.CONF_URL: URL {
'platform': 'geo_rss_events',
geo_rss_events.CONF_URL: URL
}
]
} }
@ -34,119 +46,114 @@ class TestGeoRssServiceUpdater(unittest.TestCase):
def setUp(self): def setUp(self):
"""Initialize values for this testcase class.""" """Initialize values for this testcase class."""
self.hass = get_test_home_assistant() self.hass = get_test_home_assistant()
self.config = VALID_CONFIG_WITHOUT_CATEGORIES # self.config = VALID_CONFIG_WITHOUT_CATEGORIES
def tearDown(self): def tearDown(self):
"""Stop everything that was started.""" """Stop everything that was started."""
self.hass.stop() self.hass.stop()
@mock.patch('feedparser.parse', return_value=feedparser.parse("")) @staticmethod
def test_setup_with_categories(self, mock_parse): def _generate_mock_feed_entry(external_id, title, distance_to_home,
"""Test the general setup of this sensor.""" coordinates, category):
self.config = VALID_CONFIG_WITH_CATEGORIES """Construct a mock feed entry for testing purposes."""
self.assertTrue( feed_entry = MagicMock()
setup_component(self.hass, 'sensor', {'sensor': self.config})) feed_entry.external_id = external_id
self.assertIsNotNone( feed_entry.title = title
self.hass.states.get('sensor.event_service_category_1')) feed_entry.distance_to_home = distance_to_home
self.assertIsNotNone( feed_entry.coordinates = coordinates
self.hass.states.get('sensor.event_service_category_2')) feed_entry.category = category
return feed_entry
@mock.patch('feedparser.parse', return_value=feedparser.parse("")) @mock.patch('georss_client.generic_feed.GenericFeed')
def test_setup_without_categories(self, mock_parse): def test_setup(self, mock_feed):
"""Test the general setup of this sensor.""" """Test the general setup of the platform."""
self.assertTrue( # Set up some mock feed entries for this test.
setup_component(self.hass, 'sensor', {'sensor': self.config})) mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5,
self.assertIsNotNone(self.hass.states.get('sensor.event_service_any')) (-31.0, 150.0),
'Category 1')
mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5,
(-31.1, 150.1),
'Category 1')
mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
mock_entry_2]
def setup_data(self, url='url'): utcnow = dt_util.utcnow()
"""Set up data object for use by sensors.""" # Patching 'utcnow' to gain more control over the timed update.
home_latitude = -33.865 with patch('homeassistant.util.dt.utcnow', return_value=utcnow):
home_longitude = 151.209444 with assert_setup_component(1, sensor.DOMAIN):
radius_in_km = 500 self.assertTrue(setup_component(self.hass, sensor.DOMAIN,
data = geo_rss_events.GeoRssServiceData(home_latitude, VALID_CONFIG))
home_longitude, url, # Artificially trigger update.
radius_in_km) self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
return data # Collect events.
self.hass.block_till_done()
def test_update_sensor_with_category(self): all_states = self.hass.states.all()
"""Test updating sensor object.""" assert len(all_states) == 1
raw_data = load_fixture('geo_rss_events.xml')
# Loading raw data from fixture and plug in to data object as URL
# works since the third-party feedparser library accepts a URL
# as well as the actual data.
data = self.setup_data(raw_data)
category = "Category 1"
name = "Name 1"
unit_of_measurement = "Unit 1"
sensor = geo_rss_events.GeoRssServiceSensor(category,
data, name,
unit_of_measurement)
sensor.update() state = self.hass.states.get("sensor.event_service_any")
assert sensor.name == "Name 1 Category 1" self.assertIsNotNone(state)
assert sensor.unit_of_measurement == "Unit 1" assert state.name == "Event Service Any"
assert sensor.icon == "mdi:alert" assert int(state.state) == 2
assert len(sensor._data.events) == 4 assert state.attributes == {
assert sensor.state == 1 ATTR_FRIENDLY_NAME: "Event Service Any",
assert sensor.device_state_attributes == {'Title 1': "117km"} ATTR_UNIT_OF_MEASUREMENT: "Events",
# Check entries of first hit ATTR_ICON: "mdi:alert",
assert sensor._data.events[0][geo_rss_events.ATTR_TITLE] == "Title 1" "Title 1": "16km", "Title 2": "20km"}
assert sensor._data.events[0][
geo_rss_events.ATTR_CATEGORY] == "Category 1"
self.assertAlmostEqual(sensor._data.events[0][
geo_rss_events.ATTR_DISTANCE], 116.586, 0)
def test_update_sensor_without_category(self): # Simulate an update - empty data, but successful update,
"""Test updating sensor object.""" # so no changes to entities.
raw_data = load_fixture('geo_rss_events.xml') mock_feed.return_value.update.return_value = 'OK_NO_DATA', None
data = self.setup_data(raw_data) fire_time_changed(self.hass, utcnow +
category = None geo_rss_events.SCAN_INTERVAL)
name = "Name 2" self.hass.block_till_done()
unit_of_measurement = "Unit 2"
sensor = geo_rss_events.GeoRssServiceSensor(category,
data, name,
unit_of_measurement)
sensor.update() all_states = self.hass.states.all()
assert sensor.name == "Name 2 Any" assert len(all_states) == 1
assert sensor.unit_of_measurement == "Unit 2" state = self.hass.states.get("sensor.event_service_any")
assert sensor.icon == "mdi:alert" assert int(state.state) == 2
assert len(sensor._data.events) == 4
assert sensor.state == 4
assert sensor.device_state_attributes == {'Title 1': "117km",
'Title 2': "302km",
'Title 3': "204km",
'Title 6': "48km"}
def test_update_sensor_without_data(self): # Simulate an update - empty data, removes all entities
"""Test updating sensor object.""" mock_feed.return_value.update.return_value = 'ERROR', None
data = self.setup_data() fire_time_changed(self.hass, utcnow +
category = None 2 * geo_rss_events.SCAN_INTERVAL)
name = "Name 3" self.hass.block_till_done()
unit_of_measurement = "Unit 3"
sensor = geo_rss_events.GeoRssServiceSensor(category,
data, name,
unit_of_measurement)
sensor.update() all_states = self.hass.states.all()
assert sensor.name == "Name 3 Any" assert len(all_states) == 1
assert sensor.unit_of_measurement == "Unit 3" state = self.hass.states.get("sensor.event_service_any")
assert sensor.icon == "mdi:alert" assert int(state.state) == 0
assert len(sensor._data.events) == 0
assert sensor.state == 0
@mock.patch('feedparser.parse', return_value=None) @mock.patch('georss_client.generic_feed.GenericFeed')
def test_update_sensor_with_none_result(self, parse_function): def test_setup_with_categories(self, mock_feed):
"""Test updating sensor object.""" """Test the general setup of the platform."""
data = self.setup_data("http://invalid.url/") # Set up some mock feed entries for this test.
category = None mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5,
name = "Name 4" (-31.0, 150.0),
unit_of_measurement = "Unit 4" 'Category 1')
sensor = geo_rss_events.GeoRssServiceSensor(category, mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5,
data, name, (-31.1, 150.1),
unit_of_measurement) 'Category 1')
mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
mock_entry_2]
sensor.update() with assert_setup_component(1, sensor.DOMAIN):
assert sensor.name == "Name 4 Any" self.assertTrue(setup_component(self.hass, sensor.DOMAIN,
assert sensor.unit_of_measurement == "Unit 4" VALID_CONFIG_WITH_CATEGORIES))
assert sensor.state == 0 # Artificially trigger update.
self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
# Collect events.
self.hass.block_till_done()
all_states = self.hass.states.all()
assert len(all_states) == 1
state = self.hass.states.get("sensor.event_service_category_1")
self.assertIsNotNone(state)
assert state.name == "Event Service Category 1"
assert int(state.state) == 2
assert state.attributes == {
ATTR_FRIENDLY_NAME: "Event Service Category 1",
ATTR_UNIT_OF_MEASUREMENT: "Events",
ATTR_ICON: "mdi:alert",
"Title 1": "16km", "Title 2": "20km"}

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:georss="http://www.georss.org/georss"
xmlns:geo="http://www.w3.org/2003/01/geo/wgs84_pos#">
<channel>
<!-- Entry within vicinity of home coordinates - Point -->
<item>
<title>Title 1</title>
<description>Description 1</description>
<category>Category 1</category>
<pubDate>Sun, 30 Jul 2017 09:00:00 UTC</pubDate>
<guid>GUID 1</guid>
<georss:point>-32.916667 151.75</georss:point>
</item>
<!-- Entry within vicinity of home coordinates - Point -->
<item>
<title>Title 2</title>
<description>Description 2</description>
<category>Category 2</category>
<pubDate>Sun, 30 Jul 2017 09:05:00 GMT</pubDate>
<guid>GUID 2</guid>
<geo:long>148.601111</geo:long>
<geo:lat>-32.256944</geo:lat>
</item>
<!-- Entry within vicinity of home coordinates - Polygon -->
<item>
<title>Title 3</title>
<description>Description 3</description>
<category>Category 3</category>
<pubDate>Sun, 30 Jul 2017 09:05:00 GMT</pubDate>
<guid>GUID 3</guid>
<georss:polygon>
-33.283333 149.1
-33.2999997 149.1
-33.2999997 149.1166663888889
-33.283333 149.1166663888889
-33.283333 149.1
</georss:polygon>
</item>
<!-- Entry out of vicinity of home coordinates - Point -->
<item>
<title>Title 4</title>
<description>Description 4</description>
<category>Category 4</category>
<pubDate>Sun, 30 Jul 2017 09:15:00 GMT</pubDate>
<guid>GUID 4</guid>
<georss:point>52.518611 13.408333</georss:point>
</item>
<!-- Entry without coordinates -->
<item>
<title>Title 5</title>
<description>Description 5</description>
<category>Category 5</category>
<pubDate>Sun, 30 Jul 2017 09:20:00 GMT</pubDate>
<guid>GUID 5</guid>
</item>
<!-- Entry within vicinity of home coordinates -->
<!-- Link instead of GUID; updated instead of pubDate -->
<item>
<title>Title 6</title>
<description>Description 6</description>
<category>Category 6</category>
<updated>2017-07-30T09:25:00.000Z</updated>
<link>Link 6</link>
<georss:point>-33.75801 150.70544</georss:point>
</item>
<!-- Entry with unsupported geometry - Line -->
<item>
<title>Title 1</title>
<description>Description 1</description>
<category>Category 1</category>
<pubDate>Sun, 30 Jul 2017 09:00:00 UTC</pubDate>
<guid>GUID 1</guid>
<georss:line>45.256 -110.45 46.46 -109.48 43.84 -109.86</georss:line>
</item>
</channel>
</rss>