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/__init__.py b/homeassistant/util/__init__.py index 16650fe549d..7a396b44d3b 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -57,7 +57,7 @@ def convert(value, to_type, default=None): """ Converts value to to_type, returns default if fails. """ try: return default if value is None else to_type(value) - except ValueError: + except (ValueError, TypeError): # If value could not be converted return default diff --git a/homeassistant/util/template.py b/homeassistant/util/template.py index d9b1990a252..7b00fb6dcc1 100644 --- a/homeassistant/util/template.py +++ b/homeassistant/util/template.py @@ -10,8 +10,12 @@ import logging import jinja2 from jinja2.sandbox import ImmutableSandboxedEnvironment -from homeassistant.const import STATE_UNKNOWN +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.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() @@ -41,11 +45,18 @@ def render(hass, template, variables=None, **kwargs): if variables is not None: kwargs.update(variables) + location_methods = LocationMethods(hass) + utcnow = dt_util.utcnow() + try: return ENV.from_string(template, { - 'states': AllStates(hass), + 'closest': location_methods.closest, + 'distance': location_methods.distance, 'is_state': hass.states.is_state, - 'is_state_attr': hass.states.is_state_attr + 'is_state_attr': hass.states.is_state_attr, + 'now': dt_util.as_local(utcnow), + 'states': AllStates(hass), + 'utcnow': utcnow, }).render(kwargs).strip() except jinja2.TemplateError as err: raise TemplateError(err) @@ -85,29 +96,155 @@ class DomainStates(object): key=lambda state: state.entity_id)) +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): + gr_entity_id = entities.entity_id + else: + gr_entity_id = str(entities) + + states = [self._hass.states.get(entity_id) for entity_id + in group.expand_entity_ids(self._hass, [gr_entity_id])] + + return loc_helper.closest(latitude, longitude, states) + + def distance(self, *args): + """Calculate distance. + + Will calculate distance from home to a point or between points. + Points can be passed in using state objects or lat/lng coordinates. + """ + locations = [] + + to_process = list(args) + + while to_process: + value = to_process.pop(0) + + if isinstance(value, State): + latitude = value.attributes.get(ATTR_LATITUDE) + longitude = value.attributes.get(ATTR_LONGITUDE) + + if latitude is None or longitude is None: + _LOGGER.warning( + 'Distance:State does not contains a location: %s', + value) + return None + + else: + # We expect this and next value to be lat&lng + if not to_process: + _LOGGER.warning( + 'Distance:Expected latitude and longitude, got %s', + value) + return None + + value_2 = to_process.pop(0) + latitude = convert(value, float) + longitude = convert(value_2, float) + + if latitude is None or longitude is None: + _LOGGER.warning('Distance:Unable to process latitude and ' + 'longitude: %s, %s', value, value_2) + return None + + locations.append((latitude, longitude)) + + if len(locations) == 1: + return self._hass.config.distance(*locations[0]) + + 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): - """ Rounding method that accepts strings. """ + """Rounding filter that accepts strings.""" try: value = round(float(value), precision) return int(value) if precision == 0 else value - except ValueError: + except (ValueError, TypeError): # If value can't be converted to float return value def multiply(value, amount): - """ Converts to float and multiplies value. """ + """Filter to convert value to float and multiply it.""" try: return float(value) * amount - except ValueError: + except (ValueError, TypeError): # If value can't be converted to float return value class TemplateEnvironment(ImmutableSandboxedEnvironment): - """ Home Assistant template environment. """ + """Home Assistant template environment.""" def is_safe_callable(self, obj): + """Test if callback is safe.""" return isinstance(obj, AllStates) or super().is_safe_callable(obj) ENV = TemplateEnvironment() 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_init.py b/tests/util/test_init.py index bd546d4e5e1..1a80d74b065 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -53,6 +53,7 @@ class TestUtil(unittest.TestCase): self.assertEqual(True, util.convert("True", bool)) self.assertEqual(1, util.convert("NOT A NUMBER", int, 1)) self.assertEqual(1, util.convert(None, int, 1)) + self.assertEqual(1, util.convert(object, int, 1)) def test_ensure_unique_string(self): """ Test ensure_unique_string. """ diff --git a/tests/util/test_template.py b/tests/util/test_template.py index 09e0e154888..7a0b990a04a 100644 --- a/tests/util/test_template.py +++ b/tests/util/test_template.py @@ -6,8 +6,12 @@ Tests Home Assistant template util methods. """ # pylint: disable=too-many-public-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 from tests.common import get_test_home_assistant @@ -59,9 +63,6 @@ class TestUtilTemplate(unittest.TestCase): self.hass, '{{ states.sensor.temperature.state | round(1) }}')) - def test_rounding_value2(self): - self.hass.states.set('sensor.temperature', 12.78) - self.assertEqual( '128', template.render( @@ -69,6 +70,34 @@ class TestUtilTemplate(unittest.TestCase): '{{ states.sensor.temperature.state | multiply(10) | round }}' )) + def test_rounding_value_get_original_value_on_error(self): + self.assertEqual( + 'None', + template.render( + self.hass, + '{{ None | round }}' + )) + + self.assertEqual( + 'no_number', + template.render( + self.hass, + '{{ "no_number" | round }}' + )) + + def test_multiply(self): + tests = { + None: 'None', + 10: '100', + '"abcd"': 'abcd' + } + + for inp, out in tests.items(): + self.assertEqual( + out, + template.render(self.hass, + '{{ %s | multiply(10) | round }}' % inp)) + def test_passing_vars_as_keywords(self): self.assertEqual( '127', template.render(self.hass, '{{ hello }}', hello=127)) @@ -143,3 +172,303 @@ class TestUtilTemplate(unittest.TestCase): self.assertEqual( 'unknown', template.render(self.hass, '{{ states("test.object2") }}')) + + @patch('homeassistant.core.dt_util.utcnow', return_value=dt_util.utcnow()) + @patch('homeassistant.util.template.TemplateEnvironment.is_safe_callable', + return_value=True) + def test_now(self, mock_is_safe, mock_utcnow): + self.assertEqual( + dt_util.utcnow().isoformat(), + template.render(self.hass, '{{ now.isoformat() }}')) + + @patch('homeassistant.core.dt_util.utcnow', return_value=dt_util.utcnow()) + @patch('homeassistant.util.template.TemplateEnvironment.is_safe_callable', + return_value=True) + def test_utcnow(self, mock_is_safe, mock_utcnow): + self.assertEqual( + dt_util.utcnow().isoformat(), + template.render(self.hass, '{{ utcnow.isoformat() }}')) + + def test_utcnow_is_exactly_now(self): + self.assertEqual( + 'True', + template.render(self.hass, '{{ utcnow == now }}')) + + def test_distance_function_with_1_state(self): + self.hass.states.set('test.object', 'happy', { + 'latitude': 32.87336, + 'longitude': -117.22943, + }) + + self.assertEqual( + '187', + template.render( + self.hass, '{{ distance(states.test.object) | round }}')) + + def test_distance_function_with_2_states(self): + self.hass.states.set('test.object', 'happy', { + 'latitude': 32.87336, + 'longitude': -117.22943, + }) + + self.hass.states.set('test.object_2', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + self.assertEqual( + '187', + template.render( + self.hass, + '{{ distance(states.test.object, states.test.object_2)' + '| round }}')) + + def test_distance_function_with_1_coord(self): + self.assertEqual( + '187', + template.render( + self.hass, '{{ distance("32.87336", "-117.22943") | round }}')) + + def test_distance_function_with_2_coords(self): + self.assertEqual( + '187', + template.render( + self.hass, + '{{ distance("32.87336", "-117.22943", %s, %s) | round }}' + % (self.hass.config.latitude, self.hass.config.longitude))) + + def test_distance_function_with_1_state_1_coord(self): + self.hass.states.set('test.object_2', 'happy', { + 'latitude': self.hass.config.latitude, + 'longitude': self.hass.config.longitude, + }) + + self.assertEqual( + '187', + template.render( + self.hass, + '{{ distance("32.87336", "-117.22943", states.test.object_2) ' + '| round }}')) + + self.assertEqual( + '187', + template.render( + self.hass, + '{{ distance(states.test.object_2, "32.87336", "-117.22943") ' + '| round }}')) + + def test_distance_function_return_None_if_invalid_state(self): + self.hass.states.set('test.object_2', 'happy', { + 'latitude': 10, + }) + + self.assertEqual( + 'None', + template.render( + self.hass, + '{{ distance(states.test.object_2) | round }}')) + + def test_distance_function_return_None_if_invalid_coord(self): + self.assertEqual( + 'None', + template.render( + self.hass, + '{{ 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) }}'))