diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py new file mode 100644 index 0000000000000000000000000000000000000000..484dd67e0e451297e0c40aa41361f10fd0afa472 --- /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 6d0635eb340786c206ed9c0cb132caba1e141ccf..a74f568c76c7eacb2c432c2dd985356b69cff0db 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 d5d6bbedca1a89a183e96dccd969db3d9deec90b..79e872ffa4cefb53a426ca1965611639f6d81168 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 99bcf80288bffbf58fcf4d3c40f698f25a91acb6..dd1602fba6f1b040c495756a533b504c63855b17 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 0000000000000000000000000000000000000000..557def8225b31763b1dc820ba0579bfcff310b36 --- /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 0000000000000000000000000000000000000000..212994756d29e7f211ad125ae2e66ca03f0757a6 --- /dev/null +++ b/tests/fixtures/geo_rss_events.xml @@ -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> \ No newline at end of file