diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py index 74d1b036f6c..4d8c3b68edd 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -13,7 +13,8 @@ import voluptuous as vol from homeassistant.components.geo_location import ( PLATFORM_SCHEMA, GeoLocationEvent) from homeassistant.const import ( - CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START) + CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -38,6 +39,8 @@ SOURCE = 'geo_json_events' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) @@ -46,10 +49,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the GeoJSON Events platform.""" url = config[CONF_URL] scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) radius_in_km = config[CONF_RADIUS] # Initialize the entity manager. - feed = GeoJsonFeedManager(hass, add_entities, scan_interval, url, - radius_in_km) + feed = GeoJsonFeedEntityManager( + hass, add_entities, scan_interval, coordinates, url, radius_in_km) def start_feed_manager(event): """Start feed manager.""" @@ -58,87 +63,49 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class GeoJsonFeedManager: - """Feed Manager for GeoJSON feeds.""" +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" - def __init__(self, hass, add_entities, scan_interval, url, radius_in_km): + def __init__(self, hass, add_entities, scan_interval, coordinates, url, + radius_in_km): """Initialize the GeoJSON Feed Manager.""" - from geojson_client.generic_feed import GenericFeed + from geojson_client.generic_feed import GenericFeedManager self._hass = hass - self._feed = GenericFeed( - (hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, url=url) + self._feed_manager = GenericFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, url, filter_radius=radius_in_km) self._add_entities = add_entities self._scan_interval = scan_interval - self.feed_entries = {} - self._managed_external_ids = set() def startup(self): """Start up this manager.""" - self._update() + self._feed_manager.update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._update(), self._scan_interval) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) - status, feed_entries = self._feed.update() - if status == geojson_client.UPDATE_OK: - _LOGGER.debug("Data retrieved %s", feed_entries) - # Keep a copy of all feed entries for future lookups by entities. - self.feed_entries = {entry.external_id: entry - for entry in feed_entries} - # For entity management the external ids from the feed are used. - feed_external_ids = set(self.feed_entries) - remove_external_ids = self._managed_external_ids.difference( - feed_external_ids) - self._remove_entities(remove_external_ids) - update_external_ids = self._managed_external_ids.intersection( - feed_external_ids) - self._update_entities(update_external_ids) - create_external_ids = feed_external_ids.difference( - self._managed_external_ids) - self._generate_new_entities(create_external_ids) - elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug( - "Update successful, but no data received from %s", self._feed) - else: - _LOGGER.warning( - "Update not successful, no data received from %s", self._feed) - # Remove all entities. - self._remove_entities(self._managed_external_ids.copy()) - - def _generate_new_entities(self, external_ids): - """Generate new entities for events.""" - new_entities = [] - for external_id in external_ids: - new_entity = GeoJsonLocationEvent(self, external_id) - _LOGGER.debug("New entity added %s", external_id) - new_entities.append(new_entity) - self._managed_external_ids.add(external_id) + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = GeoJsonLocationEvent(self, external_id) # Add new entities to HA. - self._add_entities(new_entities, True) + self._add_entities([new_entity], True) - def _update_entities(self, external_ids): - """Update entities.""" - for external_id in external_ids: - _LOGGER.debug("Existing entity found %s", external_id) - dispatcher_send( - self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entities(self, external_ids): - """Remove entities.""" - for external_id in external_ids: - _LOGGER.debug("Entity not current anymore %s", external_id) - self._managed_external_ids.remove(external_id) - dispatcher_send( - self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class GeoJsonLocationEvent(GeoLocationEvent): @@ -184,7 +151,7 @@ class GeoJsonLocationEvent(GeoLocationEvent): async def async_update(self): """Update this entity from the data held in the feed manager.""" _LOGGER.debug("Updating %s", self._external_id) - feed_entry = self._feed_manager.feed_entries.get(self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) if feed_entry: self._update_from_feed(feed_entry) diff --git a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py index 1d2a7fadaff..5681e4a53ac 100644 --- a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py +++ b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py @@ -14,7 +14,7 @@ from homeassistant.components.geo_location import ( PLATFORM_SCHEMA, GeoLocationEvent) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_RADIUS, CONF_SCAN_INTERVAL, - EVENT_HOMEASSISTANT_START) + EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -57,18 +57,23 @@ VALID_CATEGORIES = [ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]), + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the GeoJSON Events platform.""" + """Set up the NSW Rural Fire Service Feed platform.""" scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) radius_in_km = config[CONF_RADIUS] categories = config.get(CONF_CATEGORIES) # Initialize the entity manager. - feed = NswRuralFireServiceFeedManager( - hass, add_entities, scan_interval, radius_in_km, categories) + feed = NswRuralFireServiceFeedEntityManager( + hass, add_entities, scan_interval, coordinates, radius_in_km, + categories) def start_feed_manager(event): """Start feed manager.""" @@ -77,93 +82,55 @@ def setup_platform(hass, config, add_entities, discovery_info=None): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class NswRuralFireServiceFeedManager: - """Feed Manager for NSW Rural Fire Service GeoJSON feed.""" +class NswRuralFireServiceFeedEntityManager: + """Feed Entity Manager for NSW Rural Fire Service GeoJSON feed.""" - def __init__(self, hass, add_entities, scan_interval, radius_in_km, - categories): - """Initialize the GeoJSON Feed Manager.""" + def __init__(self, hass, add_entities, scan_interval, coordinates, + radius_in_km, categories): + """Initialize the Feed Entity Manager.""" from geojson_client.nsw_rural_fire_service_feed \ - import NswRuralFireServiceFeed + import NswRuralFireServiceFeedManager self._hass = hass - self._feed = NswRuralFireServiceFeed( - (hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, filter_categories=categories) + self._feed_manager = NswRuralFireServiceFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, filter_radius=radius_in_km, + filter_categories=categories) self._add_entities = add_entities self._scan_interval = scan_interval - self.feed_entries = {} - self._managed_external_ids = set() def startup(self): """Start up this manager.""" - self._update() + self._feed_manager.update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._update(), self._scan_interval) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) - status, feed_entries = self._feed.update() - if status == geojson_client.UPDATE_OK: - _LOGGER.debug("Data retrieved %s", feed_entries) - # Keep a copy of all feed entries for future lookups by entities. - self.feed_entries = {entry.external_id: entry - for entry in feed_entries} - # For entity management the external ids from the feed are used. - feed_external_ids = set(self.feed_entries) - remove_external_ids = self._managed_external_ids.difference( - feed_external_ids) - self._remove_entities(remove_external_ids) - update_external_ids = self._managed_external_ids.intersection( - feed_external_ids) - self._update_entities(update_external_ids) - create_external_ids = feed_external_ids.difference( - self._managed_external_ids) - self._generate_new_entities(create_external_ids) - elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug( - "Update successful, but no data received from %s", self._feed) - else: - _LOGGER.warning( - "Update not successful, no data received from %s", self._feed) - # Remove all entities. - self._remove_entities(self._managed_external_ids.copy()) - - def _generate_new_entities(self, external_ids): - """Generate new entities for events.""" - new_entities = [] - for external_id in external_ids: - new_entity = NswRuralFireServiceLocationEvent(self, external_id) - _LOGGER.debug("New entity added %s", external_id) - new_entities.append(new_entity) - self._managed_external_ids.add(external_id) + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = NswRuralFireServiceLocationEvent(self, external_id) # Add new entities to HA. - self._add_entities(new_entities, True) + self._add_entities([new_entity], True) - def _update_entities(self, external_ids): - """Update entities.""" - for external_id in external_ids: - _LOGGER.debug("Existing entity found %s", external_id) - dispatcher_send( - self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entities(self, external_ids): - """Remove entities.""" - for external_id in external_ids: - _LOGGER.debug("Entity not current anymore %s", external_id) - self._managed_external_ids.remove(external_id) - dispatcher_send( - self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class NswRuralFireServiceLocationEvent(GeoLocationEvent): - """This represents an external event with GeoJSON data.""" + """This represents an external event with NSW Rural Fire Service data.""" def __init__(self, feed_manager, external_id): """Initialize entity with data from feed entry.""" @@ -209,13 +176,13 @@ class NswRuralFireServiceLocationEvent(GeoLocationEvent): @property def should_poll(self): - """No polling needed for GeoJSON location events.""" + """No polling needed for NSW Rural Fire Service location events.""" return False async def async_update(self): """Update this entity from the data held in the feed manager.""" _LOGGER.debug("Updating %s", self._external_id) - feed_entry = self._feed_manager.feed_entries.get(self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) if feed_entry: self._update_from_feed(feed_entry) diff --git a/tests/components/geo_location/test_geo_json_events.py b/tests/components/geo_location/test_geo_json_events.py index f476598adc9..46d1ed630c4 100644 --- a/tests/components/geo_location/test_geo_json_events.py +++ b/tests/components/geo_location/test_geo_json_events.py @@ -1,19 +1,16 @@ """The tests for the geojson platform.""" -import unittest -from unittest import mock -from unittest.mock import patch, MagicMock +from asynctest.mock import patch, MagicMock, call -import homeassistant from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geo_location.geo_json_events import \ - SCAN_INTERVAL, ATTR_EXTERNAL_ID + SCAN_INTERVAL, ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ - ATTR_UNIT_OF_MEASUREMENT -from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, assert_setup_component, \ - fire_time_changed + ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.dispatcher import DATA_DISPATCHER +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed import homeassistant.util.dt as dt_util URL = 'http://geo.json.local/geo_json_events.json' @@ -27,200 +24,218 @@ CONFIG = { ] } +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'geo_json_events', + CONF_URL: URL, + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} -class TestGeoJsonPlatform(unittest.TestCase): - """Test the geojson platform.""" - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + return feed_entry - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - @staticmethod - def _generate_mock_feed_entry(external_id, title, distance_to_home, - coordinates): - """Construct a mock feed entry for testing purposes.""" - feed_entry = MagicMock() - feed_entry.external_id = external_id - feed_entry.title = title - feed_entry.distance_to_home = distance_to_home - feed_entry.coordinates = coordinates - return feed_entry +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0)) + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (-31.3, 150.3)) - @mock.patch('geojson_client.generic_feed.GenericFeed') - def test_setup(self, mock_feed): - """Test the general setup of the platform.""" - # Set up some mock feed entries for this test. - mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, - (-31.0, 150.0)) - mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5, - (-31.1, 150.1)) - mock_entry_3 = self._generate_mock_feed_entry('3456', 'Title 3', 25.5, - (-31.2, 150.2)) - mock_entry_4 = self._generate_mock_feed_entry('4567', 'Title 4', 12.5, - (-31.3, 150.3)) + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.generic_feed.GenericFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, mock_entry_2, mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) - # Artificially trigger update. - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - # Collect events. - self.hass.block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 3 - all_states = self.hass.states.all() - assert len(all_states) == 3 + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-15.5), 7) == 0 - state = self.hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-15.5), 7) == 0 + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-20.5), 7) == 0 - state = self.hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-20.5), 7) == 0 + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-25.5), 7) == 0 - state = self.hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-25.5), 7) == 0 + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1, mock_entry_4, mock_entry_3] - fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) - self.hass.block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 3 - all_states = self.hass.states.all() - assert len(all_states) == 3 + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed.return_value.update.return_value = 'OK_NO_DATA', None - # mock_restdata.return_value.data = None - fire_time_changed(self.hass, utcnow + - 2 * SCAN_INTERVAL) - self.hass.block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 3 - all_states = self.hass.states.all() - assert len(all_states) == 3 + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + - 2 * SCAN_INTERVAL) - self.hass.block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 0 - all_states = self.hass.states.all() - assert len(all_states) == 0 - @mock.patch('geojson_client.generic_feed.GenericFeed') - def test_setup_race_condition(self, mock_feed): - """Test a particular race condition experienced.""" - # 1. Feed returns 1 entry -> Feed manager creates 1 entity. - # 2. Feed returns error -> Feed manager removes 1 entity. - # However, this stayed on and kept listening for dispatcher signals. - # 3. Feed returns 1 entry -> Feed manager creates 1 entity. - # 4. Feed returns 1 entry -> Feed manager updates 1 entity. - # Internally, the previous entity is updating itself, too. - # 5. Feed returns error -> Feed manager removes 1 entity. - # There are now 2 entities trying to remove themselves from HA, but - # the second attempt fails of course. +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 2000.5, (-31.1, 150.1)) - # Set up some mock feed entries for this test. - mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, - (-31.0, 150.0)) + with patch('geojson_client.generic_feed.GenericFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) - # This gives us the ability to assert the '_delete_callback' - # has been called while still executing it. - original_delete_callback = homeassistant.components\ - .geo_location.geo_json_events.GeoJsonLocationEvent\ - ._delete_callback + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - def mock_delete_callback(entity): - original_delete_callback(entity) + all_states = hass.states.async_all() + assert len(all_states) == 1 - with patch('homeassistant.components.geo_location' - '.geo_json_events.GeoJsonLocationEvent' - '._delete_callback', - side_effect=mock_delete_callback, - autospec=True) as mocked_delete_callback: + assert mock_feed.call_args == call( + (15.1, 25.2), URL, filter_radius=200.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 +async def test_setup_race_condition(hass): + """Test a particular race condition experienced.""" + # 1. Feed returns 1 entry -> Feed manager creates 1 entity. + # 2. Feed returns error -> Feed manager removes 1 entity. + # However, this stayed on and kept listening for dispatcher signals. + # 3. Feed returns 1 entry -> Feed manager creates 1 entity. + # 4. Feed returns 1 entry -> Feed manager updates 1 entity. + # Internally, the previous entity is updating itself, too. + # 5. Feed returns error -> Feed manager removes 1 entity. + # There are now 2 entities trying to remove themselves from HA, but + # the second attempt fails of course. - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) - self.hass.block_till_done() + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0)) + delete_signal = SIGNAL_DELETE_ENTITY.format('1234') + update_signal = SIGNAL_UPDATE_ENTITY.format('1234') - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.generic_feed.GenericFeed') as mock_feed: + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) - # Simulate an update - 1 entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1] - fire_time_changed(self.hass, utcnow + 2 * SCAN_INTERVAL) - self.hass.block_till_done() + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] - all_states = self.hass.states.all() - assert len(all_states) == 1 + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - # Simulate an update - 1 entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1] - fire_time_changed(self.hass, utcnow + 3 * SCAN_INTERVAL) - self.hass.block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 - all_states = self.hass.states.all() - assert len(all_states) == 1 + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # Reset mocked method for the next test. - mocked_delete_callback.reset_mock() + all_states = hass.states.async_all() + assert len(all_states) == 0 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + 4 * SCAN_INTERVAL) - self.hass.block_till_done() + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 4 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + # Ensure that delete and update signal targets are now empty. + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 diff --git a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py index 75397d27383..3254fd570ce 100644 --- a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py +++ b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py @@ -1,6 +1,6 @@ """The tests for the geojson platform.""" import datetime -from asynctest.mock import patch, MagicMock +from asynctest.mock import patch, MagicMock, call from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -8,24 +8,33 @@ from homeassistant.components.geo_location.nsw_rural_fire_service_feed import \ ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, ATTR_FIRE, ATTR_LOCATION, \ ATTR_COUNCIL_AREA, ATTR_STATUS, ATTR_TYPE, ATTR_SIZE, \ ATTR_RESPONSIBLE_AGENCY, ATTR_PUBLICATION_DATE -from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ - CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ - ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, \ + ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, \ + CONF_LONGITUDE, CONF_RADIUS, EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_fire_time_changed import homeassistant.util.dt as dt_util -URL = 'http://geo.json.local/geo_json_events.json' CONFIG = { geo_location.DOMAIN: [ { 'platform': 'nsw_rural_fire_service_feed', - CONF_URL: URL, CONF_RADIUS: 200 } ] } +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'nsw_rural_fire_service_feed', + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} + def _generate_mock_feed_entry(external_id, title, distance_to_home, coordinates, category=None, location=None, @@ -55,107 +64,130 @@ def _generate_mock_feed_entry(external_id, title, distance_to_home, async def test_setup(hass): """Test the general setup of the platform.""" # Set up some mock feed entries for this test. - with patch('geojson_client.nsw_rural_fire_service_feed.' - 'NswRuralFireServiceFeed') as mock_feed: - mock_entry_1 = _generate_mock_feed_entry( - '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', - location='Location 1', attribution='Attribution 1', - publication_date=datetime.datetime(2018, 9, 22, 8, 0, - tzinfo=datetime.timezone.utc), - council_area='Council Area 1', status='Status 1', - entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') - mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5, - (-31.1, 150.1), - fire=False) - mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5, - (-31.2, 150.2)) - mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5, - (-31.3, 150.3)) + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', + location='Location 1', attribution='Attribution 1', + publication_date=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + council_area='Council Area 1', status='Status 1', + entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') + mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5, + (-31.1, 150.1), + fire=False) + mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5, + (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5, + (-31.3, 150.3)) + + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, mock_entry_2, mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component( - hass, geo_location.DOMAIN, CONFIG) - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 3 - all_states = hass.states.async_all() - assert len(all_states) == 3 + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_PUBLICATION_DATE: + datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + ATTR_FIRE: True, + ATTR_COUNCIL_AREA: 'Council Area 1', + ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', + ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-15.5), 7) == 0 - state = hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", - ATTR_ATTRIBUTION: "Attribution 1", - ATTR_PUBLICATION_DATE: - datetime.datetime(2018, 9, 22, 8, 0, - tzinfo=datetime.timezone.utc), - ATTR_FIRE: True, - ATTR_COUNCIL_AREA: 'Council Area 1', - ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', - ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-15.5), 7) == 0 + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_FIRE: False, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-20.5), 7) == 0 - state = hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_FIRE: False, - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-20.5), 7) == 0 + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_FIRE: True, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-25.5), 7) == 0 - state = hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_FIRE: True, - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-25.5), 7) == 0 + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1, mock_entry_4, mock_entry_3] - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 3 - all_states = hass.states.async_all() - assert len(all_states) == 3 + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed.return_value.update.return_value = 'OK_NO_DATA', None - # mock_restdata.return_value.data = None - async_fire_time_changed(hass, utcnow + - 2 * SCAN_INTERVAL) - await hass.async_block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 3 - all_states = hass.states.async_all() - assert len(all_states) == 3 + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - async_fire_time_changed(hass, utcnow + - 2 * SCAN_INTERVAL) - await hass.async_block_till_done() + all_states = hass.states.async_all() + assert len(all_states) == 0 - all_states = hass.states.async_all() - assert len(all_states) == 0 + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (-31.1, 150.1)) + + with patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (15.1, 25.2), filter_categories=[], filter_radius=200.0)