GeoRSS sensor (#9331)
* new geo rss events sensor * SCAN_INTERVAL instead of DEFAULT_SCAN_INTERVAL * removed redefinition CONF_SCAN_INTERVAL * definition of self._name not required * removed unnecessary check and unnecessary parameter * changed log levels * fixed default name not used * streamlined sensor name and entity id generation, removed unnecessary parameter * fixed issue for entries without geometry data * fixed tests after code changes * simplified code * simplified code; removed unnecessary imports * fixed invalid variable name * shorter sensor name and in turn entity id * increasing test coverage for previously untested code * fixed indentation and variable usage * simplified test code * merged two similar tests * fixed an issue if no data could be fetched from external service; added test case for this case
This commit is contained in:
parent
499382a9a9
commit
0d75cd484b
6 changed files with 475 additions and 0 deletions
243
homeassistant/components/sensor/geo_rss_events.py
Normal file
243
homeassistant/components/sensor/geo_rss_events.py
Normal file
|
@ -0,0 +1,243 @@
|
|||
"""
|
||||
Generic GeoRSS events service.
|
||||
|
||||
Retrieves current events (typically incidents or alerts) in GeoRSS format, and
|
||||
shows information on events filtered by distance to the HA instance's location
|
||||
and grouped by category.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/sensor.geo_rss_events/
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
from datetime import timedelta
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.sensor import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_NAME)
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['feedparser==5.2.1', 'haversine==0.4.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_CATEGORY = 'category'
|
||||
ATTR_DISTANCE = 'distance'
|
||||
ATTR_TITLE = 'title'
|
||||
|
||||
CONF_CATEGORIES = 'categories'
|
||||
CONF_RADIUS = 'radius'
|
||||
CONF_URL = 'url'
|
||||
|
||||
DEFAULT_ICON = 'mdi:alert'
|
||||
DEFAULT_NAME = "Event Service"
|
||||
DEFAULT_RADIUS_IN_KM = 20.0
|
||||
DEFAULT_UNIT_OF_MEASUREMENT = 'Events'
|
||||
|
||||
DOMAIN = 'geo_rss_events'
|
||||
|
||||
# Minimum time between updates from the source.
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_URL): cv.string,
|
||||
vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list,
|
||||
[cv.string]),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT,
|
||||
default=DEFAULT_UNIT_OF_MEASUREMENT): cv.string,
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the GeoRSS component."""
|
||||
# Grab location from config
|
||||
home_latitude = hass.config.latitude
|
||||
home_longitude = hass.config.longitude
|
||||
url = config.get(CONF_URL)
|
||||
radius_in_km = config.get(CONF_RADIUS)
|
||||
name = config.get(CONF_NAME)
|
||||
categories = config.get(CONF_CATEGORIES)
|
||||
unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
|
||||
_LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s",
|
||||
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.
|
||||
devices = []
|
||||
if not categories:
|
||||
device = GeoRssServiceSensor(None, data, name, unit_of_measurement)
|
||||
devices.append(device)
|
||||
else:
|
||||
for category in categories:
|
||||
device = GeoRssServiceSensor(category, data, name,
|
||||
unit_of_measurement)
|
||||
devices.append(device)
|
||||
add_devices(devices, True)
|
||||
|
||||
|
||||
class GeoRssServiceSensor(Entity):
|
||||
"""Representation of a Sensor."""
|
||||
|
||||
def __init__(self, category, data, service_name, unit_of_measurement):
|
||||
"""Initialize the sensor."""
|
||||
self._category = category
|
||||
self._data = data
|
||||
self._service_name = service_name
|
||||
self._state = STATE_UNKNOWN
|
||||
self._state_attributes = None
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return '{} {}'.format(self._service_name,
|
||||
'Any' if self._category is None
|
||||
else self._category)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the default icon to use in the frontend."""
|
||||
return DEFAULT_ICON
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return self._state_attributes
|
||||
|
||||
def update(self):
|
||||
"""Update this sensor from the GeoRSS service."""
|
||||
_LOGGER.debug("About to update sensor %s", self.entity_id)
|
||||
self._data.update()
|
||||
# If no events were found due to an error then just set state to zero.
|
||||
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,
|
||||
my_events)
|
||||
self._state = len(my_events)
|
||||
# And now compute the attributes from the filtered events.
|
||||
matrix = {}
|
||||
for event in my_events:
|
||||
matrix[event[ATTR_TITLE]] = '{:.0f}km'.format(
|
||||
event[ATTR_DISTANCE])
|
||||
self._state_attributes = matrix
|
||||
|
||||
|
||||
class GeoRssServiceData(object):
|
||||
"""Provides access to GeoRSS feed and stores the latest data."""
|
||||
|
||||
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:
|
||||
events = self.filter_entries(feed_data)
|
||||
self.events = events
|
||||
|
||||
def filter_entries(self, feed_data):
|
||||
"""Filter entries by distance from home coordinates."""
|
||||
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
|
|
@ -228,6 +228,7 @@ fastdotcom==0.0.1
|
|||
fedexdeliverymanager==1.0.4
|
||||
|
||||
# homeassistant.components.feedreader
|
||||
# homeassistant.components.sensor.geo_rss_events
|
||||
feedparser==5.2.1
|
||||
|
||||
# homeassistant.components.sensor.fitbit
|
||||
|
@ -286,6 +287,9 @@ ha-ffmpeg==1.7
|
|||
# homeassistant.components.media_player.philips_js
|
||||
ha-philipsjs==0.0.1
|
||||
|
||||
# homeassistant.components.sensor.geo_rss_events
|
||||
haversine==0.4.5
|
||||
|
||||
# homeassistant.components.mqtt.server
|
||||
hbmqtt==0.8
|
||||
|
||||
|
|
|
@ -45,6 +45,10 @@ ephem==3.7.6.0
|
|||
# homeassistant.components.climate.honeywell
|
||||
evohomeclient==0.2.5
|
||||
|
||||
# homeassistant.components.feedreader
|
||||
# homeassistant.components.sensor.geo_rss_events
|
||||
feedparser==5.2.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
fuzzywuzzy==0.15.1
|
||||
|
||||
|
@ -54,6 +58,9 @@ gTTS-token==1.1.1
|
|||
# homeassistant.components.ffmpeg
|
||||
ha-ffmpeg==1.7
|
||||
|
||||
# homeassistant.components.sensor.geo_rss_events
|
||||
haversine==0.4.5
|
||||
|
||||
# homeassistant.components.mqtt.server
|
||||
hbmqtt==0.8
|
||||
|
||||
|
|
|
@ -39,10 +39,12 @@ TEST_REQUIREMENTS = (
|
|||
'dsmr_parser',
|
||||
'ephem',
|
||||
'evohomeclient',
|
||||
'feedparser',
|
||||
'forecastio',
|
||||
'fuzzywuzzy',
|
||||
'gTTS-token',
|
||||
'ha-ffmpeg',
|
||||
'haversine',
|
||||
'hbmqtt',
|
||||
'holidays',
|
||||
'influxdb',
|
||||
|
|
143
tests/components/sensor/test_geo_rss_events.py
Normal file
143
tests/components/sensor/test_geo_rss_events.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
"""The test for the geo rss events sensor platform."""
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from homeassistant.setup import setup_component
|
||||
from tests.common import load_fixture, get_test_home_assistant
|
||||
import homeassistant.components.sensor.geo_rss_events as geo_rss_events
|
||||
|
||||
URL = 'http://geo.rss.local/geo_rss_events.xml'
|
||||
VALID_CONFIG_WITH_CATEGORIES = {
|
||||
'platform': 'geo_rss_events',
|
||||
geo_rss_events.CONF_URL: URL,
|
||||
geo_rss_events.CONF_CATEGORIES: [
|
||||
'Category 1',
|
||||
'Category 2'
|
||||
]
|
||||
}
|
||||
VALID_CONFIG_WITHOUT_CATEGORIES = {
|
||||
'platform': 'geo_rss_events',
|
||||
geo_rss_events.CONF_URL: URL
|
||||
}
|
||||
|
||||
|
||||
class TestGeoRssServiceUpdater(unittest.TestCase):
|
||||
"""Test the GeoRss service updater."""
|
||||
|
||||
def setUp(self):
|
||||
"""Initialize values for this testcase class."""
|
||||
self.hass = get_test_home_assistant()
|
||||
self.config = VALID_CONFIG_WITHOUT_CATEGORIES
|
||||
|
||||
def tearDown(self):
|
||||
"""Stop everything that was started."""
|
||||
self.hass.stop()
|
||||
|
||||
def test_setup_with_categories(self):
|
||||
"""Test the general setup of this sensor."""
|
||||
self.config = VALID_CONFIG_WITH_CATEGORIES
|
||||
self.assertTrue(
|
||||
setup_component(self.hass, 'sensor', {'sensor': self.config}))
|
||||
self.assertIsNotNone(
|
||||
self.hass.states.get('sensor.event_service_category_1'))
|
||||
self.assertIsNotNone(
|
||||
self.hass.states.get('sensor.event_service_category_2'))
|
||||
|
||||
def test_setup_without_categories(self):
|
||||
"""Test the general setup of this sensor."""
|
||||
self.assertTrue(
|
||||
setup_component(self.hass, 'sensor', {'sensor': self.config}))
|
||||
self.assertIsNotNone(self.hass.states.get('sensor.event_service_any'))
|
||||
|
||||
def setup_data(self, url='url'):
|
||||
"""Set up data object for use by sensors."""
|
||||
home_latitude = -33.865
|
||||
home_longitude = 151.209444
|
||||
radius_in_km = 500
|
||||
data = geo_rss_events.GeoRssServiceData(home_latitude,
|
||||
home_longitude, url,
|
||||
radius_in_km)
|
||||
return data
|
||||
|
||||
def test_update_sensor_with_category(self):
|
||||
"""Test updating sensor object."""
|
||||
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()
|
||||
assert sensor.name == "Name 1 Category 1"
|
||||
assert sensor.unit_of_measurement == "Unit 1"
|
||||
assert sensor.icon == "mdi:alert"
|
||||
assert len(sensor._data.events) == 4
|
||||
assert sensor.state == 1
|
||||
assert sensor.device_state_attributes == {'Title 1': "117km"}
|
||||
# Check entries of first hit
|
||||
assert sensor._data.events[0][geo_rss_events.ATTR_TITLE] == "Title 1"
|
||||
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):
|
||||
"""Test updating sensor object."""
|
||||
raw_data = load_fixture('geo_rss_events.xml')
|
||||
data = self.setup_data(raw_data)
|
||||
category = None
|
||||
name = "Name 2"
|
||||
unit_of_measurement = "Unit 2"
|
||||
sensor = geo_rss_events.GeoRssServiceSensor(category,
|
||||
data, name,
|
||||
unit_of_measurement)
|
||||
|
||||
sensor.update()
|
||||
assert sensor.name == "Name 2 Any"
|
||||
assert sensor.unit_of_measurement == "Unit 2"
|
||||
assert sensor.icon == "mdi:alert"
|
||||
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):
|
||||
"""Test updating sensor object."""
|
||||
data = self.setup_data()
|
||||
category = None
|
||||
name = "Name 3"
|
||||
unit_of_measurement = "Unit 3"
|
||||
sensor = geo_rss_events.GeoRssServiceSensor(category,
|
||||
data, name,
|
||||
unit_of_measurement)
|
||||
|
||||
sensor.update()
|
||||
assert sensor.name == "Name 3 Any"
|
||||
assert sensor.unit_of_measurement == "Unit 3"
|
||||
assert sensor.icon == "mdi:alert"
|
||||
assert len(sensor._data.events) == 0
|
||||
assert sensor.state == 0
|
||||
|
||||
@mock.patch('feedparser.parse', return_value=None)
|
||||
def test_update_sensor_with_none_result(self, parse_function):
|
||||
"""Test updating sensor object."""
|
||||
data = self.setup_data("http://invalid.url/")
|
||||
category = None
|
||||
name = "Name 4"
|
||||
unit_of_measurement = "Unit 4"
|
||||
sensor = geo_rss_events.GeoRssServiceSensor(category,
|
||||
data, name,
|
||||
unit_of_measurement)
|
||||
|
||||
sensor.update()
|
||||
assert sensor.name == "Name 4 Any"
|
||||
assert sensor.unit_of_measurement == "Unit 4"
|
||||
assert sensor.state == 0
|
76
tests/fixtures/geo_rss_events.xml
vendored
Normal file
76
tests/fixtures/geo_rss_events.xml
vendored
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?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>
|
Loading…
Add table
Reference in a new issue