diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py new file mode 100644 index 00000000000..a3cdc348a24 --- /dev/null +++ b/homeassistant/helpers/location.py @@ -0,0 +1,27 @@ +"""Location helpers for Home Assistant.""" + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import State +from homeassistant.util import location as loc_util + + +def has_location(state): + """Test if state contains a valid location.""" + return (isinstance(state, State) and + isinstance(state.attributes.get(ATTR_LATITUDE), float) and + isinstance(state.attributes.get(ATTR_LONGITUDE), float)) + + +def closest(latitude, longitude, states): + """Return closest state to point.""" + with_location = [state for state in states if has_location(state)] + + if not with_location: + return None + + return min( + with_location, + key=lambda state: loc_util.distance( + latitude, longitude, state.attributes.get(ATTR_LATITUDE), + state.attributes.get(ATTR_LONGITUDE)) + ) diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index 30b90f85144..25d65855ef6 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -10,10 +10,12 @@ import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment +from homeassistant.components import group from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import State from homeassistant.exceptions import TemplateError -from homeassistant.util import convert, dt as dt_util, location +from homeassistant.helpers import location as loc_helper +from homeassistant.util import convert, dt as dt_util, location as loc_util _LOGGER = logging.getLogger(__name__) _SENTINEL = object() @@ -43,16 +45,17 @@ def render(hass, template, variables=None, **kwargs): if variables is not None: kwargs.update(variables) - location_helper = LocationHelpers(hass) + location_methods = LocationMethods(hass) utcnow = dt_util.utcnow() try: return ENV.from_string(template, { - 'distance': location_helper.distance, + 'closest': location_methods.closest, + 'distance': location_methods.distance, 'is_state': hass.states.is_state, 'is_state_attr': hass.states.is_state_attr, - 'states': AllStates(hass), 'now': dt_util.as_local(utcnow), + 'states': AllStates(hass), 'utcnow': utcnow, }).render(kwargs).strip() except jinja2.TemplateError as err: @@ -93,13 +96,75 @@ class DomainStates(object): key=lambda state: state.entity_id)) -class LocationHelpers(object): +class LocationMethods(object): """Class to expose distance helpers to templates.""" def __init__(self, hass): """Initialize distance helpers.""" self._hass = hass + def closest(self, *args): + """Find closest entity. + + Closest to home: + closest(states) + closest(states.device_tracker) + closest('group.children') + closest(states.group.children) + + Closest to a point: + closest(23.456, 23.456, 'group.children') + closest('zone.school', 'group.children') + closest(states.zone.school, 'group.children') + """ + + if len(args) == 1: + latitude = self._hass.config.latitude + longitude = self._hass.config.longitude + entities = args[0] + + elif len(args) == 2: + point_state = self._resolve_state(args[0]) + + if point_state is None: + _LOGGER.warning('Closest:Unable to find state %s', args[0]) + return None + elif not loc_helper.has_location(point_state): + _LOGGER.warning( + 'Closest:State does not contain valid location: %s', + point_state) + return None + + latitude = point_state.attributes.get(ATTR_LATITUDE) + longitude = point_state.attributes.get(ATTR_LONGITUDE) + + entities = args[1] + + else: + latitude = convert(args[0], float) + longitude = convert(args[1], float) + + if latitude is None or longitude is None: + _LOGGER.warning( + 'Closest:Received invalid coordinates: %s, %s', + args[0], args[1]) + return None + + entities = args[2] + + if isinstance(entities, (AllStates, DomainStates)): + states = list(entities) + else: + if isinstance(entities, State): + entity_id = entities.entity_id + else: + entity_id = str(entities) + + states = [self._hass.states.get(entity_id) for entity_id + in group.expand_entity_ids(self._hass, [entity_id])] + + return loc_helper.closest(latitude, longitude, states) + def distance(self, *args): """Calculate distance. @@ -145,7 +210,15 @@ class LocationHelpers(object): if len(locations) == 1: return self._hass.config.distance(*locations[0]) - return location.distance(*locations[0] + locations[1]) + return loc_util.distance(*locations[0] + locations[1]) + + def _resolve_state(self, entity_id_or_state): + """Return state or entity_id if given.""" + if isinstance(entity_id_or_state, State): + return entity_id_or_state + elif isinstance(entity_id_or_state, str): + return self._hass.states.get(entity_id_or_state) + return None def forgiving_round(value, precision=0): diff --git a/tests/helpers/test_location.py b/tests/helpers/test_location.py new file mode 100644 index 00000000000..dfd57f642bd --- /dev/null +++ b/tests/helpers/test_location.py @@ -0,0 +1,53 @@ +"""Tests Home Assistant location helpers.""" +# pylint: disable=too-many-public-methods +import unittest + +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import State +from homeassistant.helpers import location + + +class TestHelpersLocation(unittest.TestCase): + def test_has_location_with_invalid_states(self): + for state in (None, 1, "hello", object): + self.assertFalse(location.has_location(state)) + + def test_has_location_with_states_with_invalid_locations(self): + state = State('hello.world', 'invalid', { + ATTR_LATITUDE: 'no number', + ATTR_LONGITUDE: 123.12 + }) + self.assertFalse(location.has_location(state)) + + def test_has_location_with_states_with_valid_location(self): + state = State('hello.world', 'invalid', { + ATTR_LATITUDE: 123.12, + ATTR_LONGITUDE: 123.12 + }) + self.assertTrue(location.has_location(state)) + + def test_closest_with_no_states_with_location(self): + state = State('light.test', 'on') + state2 = State('light.test', 'on', { + ATTR_LATITUDE: 'invalid', + ATTR_LONGITUDE: 123.45, + }) + state3 = State('light.test', 'on', { + ATTR_LONGITUDE: 123.45, + }) + + self.assertIsNone( + location.closest(123.45, 123.45, [state, state2, state3])) + + def test_closest_returns_closest(self): + state = State('light.test', 'on', { + ATTR_LATITUDE: 124.45, + ATTR_LONGITUDE: 124.45, + }) + state2 = State('light.test', 'on', { + ATTR_LATITUDE: 125.45, + ATTR_LONGITUDE: 125.45, + }) + + self.assertEqual( + state, location.closest(123.45, 123.45, [state, state2])) diff --git a/tests/util/test_template.py b/tests/util/test_template.py index c6c5f18f01c..426d90bd4d9 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -8,6 +8,7 @@ Tests Home Assistant template util methods. import unittest from unittest.mock import patch +from homeassistant.components import group from homeassistant.exceptions import TemplateError from homeassistant.util import template import homeassistant.util.dt as dt_util @@ -272,4 +273,203 @@ class TestUtilTemplate(unittest.TestCase): 'None', template.render( self.hass, - '{{ distance("123", "abc") | round }}')) + '{{ distance("123", "abc") }}')) + + self.assertEqual( + 'None', + template.render( + self.hass, + '{{ distance("123") }}')) + + self.hass.states.set('test.object_2', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + self.assertEqual( + 'None', + template.render( + self.hass, + '{{ distance("123", states.test_object_2) }}')) + + + def test_closest_function_home_vs_domain(self): + self.hass.states.set('test_domain.object', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('not_test_domain.but_closer', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + self.assertEqual( + 'test_domain.object', + template.render(self.hass, + '{{ closest(states.test_domain).entity_id }}')) + + def test_closest_function_home_vs_all_states(self): + self.hass.states.set('test_domain.object', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('test_domain_2.and_closer', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + self.assertEqual( + 'test_domain_2.and_closer', + template.render(self.hass, + '{{ closest(states).entity_id }}')) + + def test_closest_function_home_vs_group_entity_id(self): + self.hass.states.set('test_domain.object', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('not_in_group.but_closer', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + group.Group(self.hass, 'location group', ['test_domain.object']) + + self.assertEqual( + 'test_domain.object', + template.render(self.hass, + '{{ closest("group.location_group").entity_id }}')) + + def test_closest_function_home_vs_group_state(self): + self.hass.states.set('test_domain.object', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('not_in_group.but_closer', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + group.Group(self.hass, 'location group', ['test_domain.object']) + + self.assertEqual( + 'test_domain.object', + template.render( + self.hass, + '{{ closest(states.group.location_group).entity_id }}')) + + def test_closest_function_to_coord(self): + self.hass.states.set('test_domain.closest_home', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('test_domain.closest_zone', 'happy', { + 'latitude': self.hass.config.latitude + 0.2, + 'longitude': self.hass.config.longitude + 0.2, + }) + + self.hass.states.set('zone.far_away', 'zoning', { + 'latitude': self.hass.config.latitude + 0.3, + 'longitude': self.hass.config.longitude + 0.3, + }) + + self.assertEqual( + 'test_domain.closest_zone', + template.render( + self.hass, + '{{ closest("%s", %s, states.test_domain).entity_id }}' + % (self.hass.config.latitude + 0.3, + self.hass.config.longitude + 0.3)) + ) + + def test_closest_function_to_entity_id(self): + self.hass.states.set('test_domain.closest_home', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('test_domain.closest_zone', 'happy', { + 'latitude': self.hass.config.latitude + 0.2, + 'longitude': self.hass.config.longitude + 0.2, + }) + + self.hass.states.set('zone.far_away', 'zoning', { + 'latitude': self.hass.config.latitude + 0.3, + 'longitude': self.hass.config.longitude + 0.3, + }) + + self.assertEqual( + 'test_domain.closest_zone', + template.render( + self.hass, + '{{ closest("zone.far_away", states.test_domain).entity_id }}') + ) + + def test_closest_function_to_state(self): + self.hass.states.set('test_domain.closest_home', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.hass.states.set('test_domain.closest_zone', 'happy', { + 'latitude': self.hass.config.latitude + 0.2, + 'longitude': self.hass.config.longitude + 0.2, + }) + + self.hass.states.set('zone.far_away', 'zoning', { + 'latitude': self.hass.config.latitude + 0.3, + 'longitude': self.hass.config.longitude + 0.3, + }) + + self.assertEqual( + 'test_domain.closest_zone', + template.render( + self.hass, + '{{ closest(states.zone.far_away, ' + 'states.test_domain).entity_id }}') + ) + + def test_closest_function_invalid_state(self): + self.hass.states.set('test_domain.closest_home', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + for state in ('states.zone.non_existing', '"zone.non_existing"'): + self.assertEqual( + 'None', + template.render( + self.hass, '{{ closest(%s, states) }}' % state)) + + def test_closest_function_state_with_invalid_location(self): + self.hass.states.set('test_domain.closest_home', 'happy', { + 'latitude': 'invalid latitude', + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.assertEqual( + 'None', + template.render( + self.hass, + '{{ closest(states.test_domain.closest_home, ' + 'states) }}')) + + def test_closest_function_invalid_coordinates(self): + self.hass.states.set('test_domain.closest_home', 'happy', { + 'latitude': self.hass.config.latitude + 0.1, + 'longitude': self.hass.config.longitude + 0.1, + }) + + self.assertEqual( + 'None', + template.render(self.hass, + '{{ closest("invalid", "coord", states) }}')) + + def test_closest_function_no_location_states(self): + self.assertEqual('None', + template.render(self.hass, '{{ closest(states) }}'))