Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions homeassistant/components/automation/geo_location.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions homeassistant/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
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'
Expand Down
271 changes: 271 additions & 0 deletions tests/components/automation/test_geo_location.py
Original file line number Diff line number Diff line change
@@ -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'])