Add geofencing to automation

This commit is contained in:
Paulus Schoutsen 2015-09-29 00:18:52 -07:00
parent 5ad27d8cdb
commit 2eb36c18bd
5 changed files with 277 additions and 15 deletions

View file

@ -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

View file

@ -1,8 +1,8 @@
"""Module with location helpers.""" """Module with location helpers."""
import collections import collections
from math import radians, cos, sin, asin, sqrt
import requests import requests
from vincenty import vincenty
LocationInfo = collections.namedtuple( LocationInfo = collections.namedtuple(
@ -31,18 +31,6 @@ def detect_location_info():
return LocationInfo(**data) return LocationInfo(**data)
# From: http://stackoverflow.com/a/4913653/646416
def distance(lon1, lat1, lon2, lat2): def distance(lon1, lat1, lon2, lat2):
""" """ Calculate the distance in meters between two points. """
Calculate the great circle distance in meters between two points specified return vincenty((lon1, lat1), (lon2, lat2)) * 1000
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))

View file

@ -3,6 +3,7 @@ requests>=2,<3
pyyaml>=3.11,<4 pyyaml>=3.11,<4
pytz>=2015.4 pytz>=2015.4
pip>=7.0.0 pip>=7.0.0
vincenty==0.1.2
# Optional, needed for specific components # Optional, needed for specific components

View file

@ -20,6 +20,7 @@ REQUIRES = [
'pyyaml>=3.11,<4', 'pyyaml>=3.11,<4',
'pytz>=2015.4', 'pytz>=2015.4',
'pip>=7.0.0', 'pip>=7.0.0',
'vincenty==0.1.2'
] ]
setup( setup(

View file

@ -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))