From 0d75cd484b11e2ca095e855bfb4cc72aca5899d6 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Sun, 24 Sep 2017 16:12:38 +1000 Subject: [PATCH] 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 --- .../components/sensor/geo_rss_events.py | 243 ++++++++++++++++++ requirements_all.txt | 4 + requirements_test_all.txt | 7 + script/gen_requirements_all.py | 2 + .../components/sensor/test_geo_rss_events.py | 143 +++++++++++ tests/fixtures/geo_rss_events.xml | 76 ++++++ 6 files changed, 475 insertions(+) create mode 100644 homeassistant/components/sensor/geo_rss_events.py create mode 100644 tests/components/sensor/test_geo_rss_events.py create mode 100644 tests/fixtures/geo_rss_events.xml diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py new file mode 100644 index 00000000000..484dd67e0e4 --- /dev/null +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -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 diff --git a/requirements_all.txt b/requirements_all.txt index 6d0635eb340..a74f568c76c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d5d6bbedca1..79e872ffa4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 99bcf80288b..dd1602fba6f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -39,10 +39,12 @@ TEST_REQUIREMENTS = ( 'dsmr_parser', 'ephem', 'evohomeclient', + 'feedparser', 'forecastio', 'fuzzywuzzy', 'gTTS-token', 'ha-ffmpeg', + 'haversine', 'hbmqtt', 'holidays', 'influxdb', diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py new file mode 100644 index 00000000000..557def8225b --- /dev/null +++ b/tests/components/sensor/test_geo_rss_events.py @@ -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 diff --git a/tests/fixtures/geo_rss_events.xml b/tests/fixtures/geo_rss_events.xml new file mode 100644 index 00000000000..212994756d2 --- /dev/null +++ b/tests/fixtures/geo_rss_events.xml @@ -0,0 +1,76 @@ + + + + + + Title 1 + Description 1 + Category 1 + Sun, 30 Jul 2017 09:00:00 UTC + GUID 1 + -32.916667 151.75 + + + + Title 2 + Description 2 + Category 2 + Sun, 30 Jul 2017 09:05:00 GMT + GUID 2 + 148.601111 + -32.256944 + + + + Title 3 + Description 3 + Category 3 + Sun, 30 Jul 2017 09:05:00 GMT + GUID 3 + + -33.283333 149.1 + -33.2999997 149.1 + -33.2999997 149.1166663888889 + -33.283333 149.1166663888889 + -33.283333 149.1 + + + + + Title 4 + Description 4 + Category 4 + Sun, 30 Jul 2017 09:15:00 GMT + GUID 4 + 52.518611 13.408333 + + + + Title 5 + Description 5 + Category 5 + Sun, 30 Jul 2017 09:20:00 GMT + GUID 5 + + + + + Title 6 + Description 6 + Category 6 + 2017-07-30T09:25:00.000Z + Link 6 + -33.75801 150.70544 + + + + Title 1 + Description 1 + Category 1 + Sun, 30 Jul 2017 09:00:00 UTC + GUID 1 + 45.256 -110.45 46.46 -109.48 43.84 -109.86 + + + \ No newline at end of file