diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py new file mode 100644 index 00000000000..b2c9a9c093a --- /dev/null +++ b/homeassistant/components/automation/geo_location.py @@ -0,0 +1,74 @@ +""" +Offer geo location automation rules. + +For more details about this automation trigger, please refer to the +documentation at +https://home-assistant.io/docs/automation/trigger/#geo-location-trigger +""" +import voluptuous as vol + +from homeassistant.components.geo_location import DOMAIN +from homeassistant.core import callback +from homeassistant.const import ( + CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE, EVENT_STATE_CHANGED) +from homeassistant.helpers import ( + condition, config_validation as cv) +from homeassistant.helpers.config_validation import entity_domain + +EVENT_ENTER = 'enter' +EVENT_LEAVE = 'leave' +DEFAULT_EVENT = EVENT_ENTER + +TRIGGER_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'geo_location', + vol.Required(CONF_SOURCE): cv.string, + vol.Required(CONF_ZONE): entity_domain('zone'), + vol.Required(CONF_EVENT, default=DEFAULT_EVENT): + vol.Any(EVENT_ENTER, EVENT_LEAVE), +}) + + +def source_match(state, source): + """Check if the state matches the provided source.""" + return state and state.attributes.get('source') == source + + +async def async_trigger(hass, config, action): + """Listen for state changes based on configuration.""" + source = config.get(CONF_SOURCE).lower() + zone_entity_id = config.get(CONF_ZONE) + trigger_event = config.get(CONF_EVENT) + + @callback + def state_change_listener(event): + """Handle specific state changes.""" + # Skip if the event is not a geo_location entity. + if not event.data.get('entity_id').startswith(DOMAIN): + return + # Skip if the event's source does not match the trigger's source. + from_state = event.data.get('old_state') + to_state = event.data.get('new_state') + if not source_match(from_state, source) \ + and not source_match(to_state, source): + return + + zone_state = hass.states.get(zone_entity_id) + from_match = condition.zone(hass, zone_state, from_state) + to_match = condition.zone(hass, zone_state, to_state) + + # pylint: disable=too-many-boolean-expressions + if trigger_event == EVENT_ENTER and not from_match and to_match or \ + trigger_event == EVENT_LEAVE and from_match and not to_match: + hass.async_run_job(action({ + 'trigger': { + 'platform': 'geo_location', + 'source': source, + 'entity_id': event.data.get('entity_id'), + 'from_state': from_state, + 'to_state': to_state, + 'zone': zone_state, + 'event': trigger_event, + }, + }, context=event.context)) + + return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4dcc171d35c..b5ca708f1b2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -129,6 +129,7 @@ CONF_SENSOR_TYPE = 'sensor_type' CONF_SENSORS = 'sensors' CONF_SHOW_ON_MAP = 'show_on_map' CONF_SLAVE = 'slave' +CONF_SOURCE = 'source' CONF_SSL = 'ssl' CONF_STATE = 'state' CONF_STATE_TEMPLATE = 'state_template' diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py new file mode 100644 index 00000000000..130cdeef99c --- /dev/null +++ b/tests/components/automation/test_geo_location.py @@ -0,0 +1,271 @@ +"""The tests for the geo location trigger.""" +import unittest + +from homeassistant.components import automation, zone +from homeassistant.core import callback, Context +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant, mock_component +from tests.components.automation import common + + +class TestAutomationGeoLocation(unittest.TestCase): + """Test the geo location trigger.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + mock_component(self.hass, 'group') + assert setup_component(self.hass, zone.DOMAIN, { + 'zone': { + 'name': 'test', + 'latitude': 32.880837, + 'longitude': -117.237561, + 'radius': 250, + } + }) + + self.calls = [] + + @callback + def record_call(service): + """Record calls.""" + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_if_fires_on_zone_enter(self): + """Test for firing on zone enter.""" + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - hello - hello - test', + self.calls[0].data['some']) + + # Set out of zone again so we can trigger call + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + common.turn_off(self.hass) + self.hass.block_till_done() + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_enter_on_zone_leave(self): + """Test for not firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_leave(self): + """Test for firing on zone leave.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_leave_on_zone_enter(self): + """Test for not firing on zone enter.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + }) + + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_appear(self): + """Test for firing if entity appears in zone.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity appears in zone without previously existing outside the zone. + context = Context() + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }, context=context) + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + assert self.calls[0].context is context + self.assertEqual( + 'geo_location - geo_location.entity - - hello - test', + self.calls[0].data['some']) + + def test_if_fires_on_zone_disappear(self): + """Test for firing if entity disappears from zone.""" + self.hass.states.set('geo_location.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564, + 'source': 'test_source' + }) + self.hass.block_till_done() + + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'geo_location', + 'source': 'test_source', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + 'data_template': { + 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join(( + 'platform', 'entity_id', + 'from_state.state', 'to_state.state', + 'zone.name')) + }, + + } + } + }) + + # Entity disappears from zone without new coordinates outside the zone. + self.hass.states.async_remove('geo_location.entity') + self.hass.block_till_done() + + self.assertEqual(1, len(self.calls)) + self.assertEqual( + 'geo_location - geo_location.entity - hello - - test', + self.calls[0].data['some'])