From 2eb36c18bd4fc8db52b93cff763e67364109117f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 29 Sep 2015 00:18:52 -0700 Subject: [PATCH] Add geofencing to automation --- homeassistant/components/automation/zone.py | 92 ++++++++++ homeassistant/util/location.py | 18 +- requirements_all.txt | 1 + setup.py | 1 + tests/components/automation/test_zone.py | 180 ++++++++++++++++++++ 5 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/automation/zone.py create mode 100644 tests/components/automation/test_zone.py diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py new file mode 100644 index 00000000000..aec2f44b0d8 --- /dev/null +++ b/homeassistant/components/automation/zone.py @@ -0,0 +1,92 @@ +""" +homeassistant.components.automation.zone +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Offers zone automation rules. +""" +import logging + +from homeassistant.components import zone +from homeassistant.helpers.event import track_state_change +from homeassistant.const import MATCH_ALL, ATTR_LATITUDE, ATTR_LONGITUDE + + +CONF_ENTITY_ID = "entity_id" +CONF_ZONE = "zone" +CONF_EVENT = "event" +EVENT_ENTER = "enter" +EVENT_LEAVE = "leave" +DEFAULT_EVENT = EVENT_ENTER + + +def trigger(hass, config, action): + """ Listen for state changes based on `config`. """ + entity_id = config.get(CONF_ENTITY_ID) + zone_entity_id = config.get(CONF_ZONE) + + if entity_id is None or zone_entity_id is None: + logging.getLogger(__name__).error( + "Missing trigger configuration key %s or %s", CONF_ENTITY_ID, + CONF_ZONE) + return False + + event = config.get(CONF_EVENT, DEFAULT_EVENT) + + def zone_automation_listener(entity, from_s, to_s): + """ Listens for state changes and calls action. """ + if from_s and None in (from_s.attributes.get(ATTR_LATITUDE), + from_s.attributes.get(ATTR_LONGITUDE)): + return + + if None in (to_s.attributes.get(ATTR_LATITUDE), + to_s.attributes.get(ATTR_LONGITUDE)): + return + + if from_s: + from_zone = zone.in_zone( + hass, from_s.attributes.get(ATTR_LATITUDE), + from_s.attributes.get(ATTR_LONGITUDE)) + else: + from_zone = None + + to_zone = zone.in_zone(hass, to_s.attributes.get(ATTR_LATITUDE), + to_s.attributes.get(ATTR_LONGITUDE)) + + from_match = from_zone and from_zone.entity_id == zone_entity_id + to_match = to_zone and to_zone.entity_id == zone_entity_id + + if event == EVENT_ENTER and not from_match and to_match or \ + event == EVENT_LEAVE and from_match and not to_match: + action() + + track_state_change( + hass, entity_id, zone_automation_listener, MATCH_ALL, MATCH_ALL) + + return True + + +def if_action(hass, config): + """ Wraps action method with zone based condition. """ + entity_id = config.get(CONF_ENTITY_ID) + zone_entity_id = config.get(CONF_ZONE) + + if entity_id is None or zone_entity_id is None: + logging.getLogger(__name__).error( + "Missing condition configuration key %s or %s", CONF_ENTITY_ID, + CONF_ZONE) + return False + + def if_in_zone(): + """ Test if condition. """ + state = hass.states.get(entity_id) + + if None in (state.attributes.get(ATTR_LATITUDE), + state.attributes.get(ATTR_LONGITUDE)): + return + + cur_zone = zone.in_zone(hass, state.attributes.get(ATTR_LATITUDE), + state.attributes.get(ATTR_LONGITUDE)) + + return cur_zone and cur_zone.entity_id == zone_entity_id + + return if_in_zone diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index ade15131a8f..92ba0d7b3d7 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -1,8 +1,8 @@ """Module with location helpers.""" import collections -from math import radians, cos, sin, asin, sqrt import requests +from vincenty import vincenty LocationInfo = collections.namedtuple( @@ -31,18 +31,6 @@ def detect_location_info(): return LocationInfo(**data) -# From: http://stackoverflow.com/a/4913653/646416 def distance(lon1, lat1, lon2, lat2): - """ - Calculate the great circle distance in meters between two points specified - in decimal degrees on the earth using the Haversine algorithm. - """ - # convert decimal degrees to radians - lon1, lat1, lon2, lat2 = (radians(val) for val in (lon1, lat1, lon2, lat2)) - - dlon = lon2 - lon1 - dlat = lat2 - lat1 - angle = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 - # Radius of earth in meters. - radius = 6371000 - return 2 * radius * asin(sqrt(angle)) + """ Calculate the distance in meters between two points. """ + return vincenty((lon1, lat1), (lon2, lat2)) * 1000 diff --git a/requirements_all.txt b/requirements_all.txt index 04c944e4447..946c3bb252b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,6 +3,7 @@ requests>=2,<3 pyyaml>=3.11,<4 pytz>=2015.4 pip>=7.0.0 +vincenty==0.1.2 # Optional, needed for specific components diff --git a/setup.py b/setup.py index fde7f9bf898..044d5428809 100755 --- a/setup.py +++ b/setup.py @@ -20,6 +20,7 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2015.4', 'pip>=7.0.0', + 'vincenty==0.1.2' ] setup( diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py new file mode 100644 index 00000000000..47ffa76e3be --- /dev/null +++ b/tests/components/automation/test_zone.py @@ -0,0 +1,180 @@ +""" +tests.components.automation.test_location +±±±~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Tests location automation. +""" +import unittest + +from homeassistant.components import automation, zone + +from tests.common import get_test_home_assistant + + +class TestAutomationEvent(unittest.TestCase): + """ Test the event automation. """ + + def setUp(self): # pylint: disable=invalid-name + self.hass = get_test_home_assistant() + zone.setup(self.hass, { + 'zone test': { + 'latitude': 32.880837, + 'longitude': -117.237561, + 'radius': 250, + } + }) + + self.calls = [] + + def record_call(service): + self.calls.append(service) + + self.hass.services.register('test', 'automation', record_call) + + def tearDown(self): # pylint: disable=invalid-name + """ Stop down stuff we started. """ + self.hass.stop() + + def test_if_fires_on_zone_enter(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_enter_on_zone_leave(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'enter', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_zone_leave(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertEqual(1, len(self.calls)) + + def test_if_not_fires_for_leave_on_zone_enter(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.881011, + 'longitude': -117.234758 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + 'event': 'leave', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertEqual(0, len(self.calls)) + + def test_zone_condition(self): + self.hass.states.set('test.entity', 'hello', { + 'latitude': 32.880586, + 'longitude': -117.237564 + }) + self.hass.pool.block_till_done() + + self.assertTrue(automation.setup(self.hass, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event' + }, + 'condition': { + 'platform': 'zone', + 'entity_id': 'test.entity', + 'zone': 'zone.test', + }, + 'action': { + 'service': 'test.automation', + } + } + })) + + self.hass.bus.fire('test_event') + self.hass.pool.block_till_done() + self.assertEqual(1, len(self.calls))