Merge pull request #1359 from balloob/template-helpers

New template helpers now, utcnow, distance, closest
This commit is contained in:
Paulus Schoutsen 2016-02-22 19:20:49 -08:00
commit c64da761f1
6 changed files with 559 additions and 12 deletions

View File

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

View File

@ -57,7 +57,7 @@ def convert(value, to_type, default=None):
""" Converts value to to_type, returns default if fails. """ """ Converts value to to_type, returns default if fails. """
try: try:
return default if value is None else to_type(value) return default if value is None else to_type(value)
except ValueError: except (ValueError, TypeError):
# If value could not be converted # If value could not be converted
return default return default

View File

@ -10,8 +10,12 @@ import logging
import jinja2 import jinja2
from jinja2.sandbox import ImmutableSandboxedEnvironment 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.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__) _LOGGER = logging.getLogger(__name__)
_SENTINEL = object() _SENTINEL = object()
@ -41,11 +45,18 @@ def render(hass, template, variables=None, **kwargs):
if variables is not None: if variables is not None:
kwargs.update(variables) kwargs.update(variables)
location_methods = LocationMethods(hass)
utcnow = dt_util.utcnow()
try: try:
return ENV.from_string(template, { return ENV.from_string(template, {
'states': AllStates(hass), 'closest': location_methods.closest,
'distance': location_methods.distance,
'is_state': hass.states.is_state, '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() }).render(kwargs).strip()
except jinja2.TemplateError as err: except jinja2.TemplateError as err:
raise TemplateError(err) raise TemplateError(err)
@ -85,21 +96,146 @@ class DomainStates(object):
key=lambda state: state.entity_id)) 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): def forgiving_round(value, precision=0):
""" Rounding method that accepts strings. """ """Rounding filter that accepts strings."""
try: try:
value = round(float(value), precision) value = round(float(value), precision)
return int(value) if precision == 0 else value return int(value) if precision == 0 else value
except ValueError: except (ValueError, TypeError):
# If value can't be converted to float # If value can't be converted to float
return value return value
def multiply(value, amount): def multiply(value, amount):
""" Converts to float and multiplies value. """ """Filter to convert value to float and multiply it."""
try: try:
return float(value) * amount return float(value) * amount
except ValueError: except (ValueError, TypeError):
# If value can't be converted to float # If value can't be converted to float
return value return value
@ -108,6 +244,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"""Home Assistant template environment.""" """Home Assistant template environment."""
def is_safe_callable(self, obj): def is_safe_callable(self, obj):
"""Test if callback is safe."""
return isinstance(obj, AllStates) or super().is_safe_callable(obj) return isinstance(obj, AllStates) or super().is_safe_callable(obj)
ENV = TemplateEnvironment() ENV = TemplateEnvironment()

View File

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

View File

@ -53,6 +53,7 @@ class TestUtil(unittest.TestCase):
self.assertEqual(True, util.convert("True", bool)) self.assertEqual(True, util.convert("True", bool))
self.assertEqual(1, util.convert("NOT A NUMBER", int, 1)) self.assertEqual(1, util.convert("NOT A NUMBER", int, 1))
self.assertEqual(1, util.convert(None, int, 1)) self.assertEqual(1, util.convert(None, int, 1))
self.assertEqual(1, util.convert(object, int, 1))
def test_ensure_unique_string(self): def test_ensure_unique_string(self):
""" Test ensure_unique_string. """ """ Test ensure_unique_string. """

View File

@ -6,8 +6,12 @@ Tests Home Assistant template util methods.
""" """
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
import unittest import unittest
from unittest.mock import patch
from homeassistant.components import group
from homeassistant.exceptions import TemplateError from homeassistant.exceptions import TemplateError
from homeassistant.util import template from homeassistant.util import template
import homeassistant.util.dt as dt_util
from tests.common import get_test_home_assistant from tests.common import get_test_home_assistant
@ -59,9 +63,6 @@ class TestUtilTemplate(unittest.TestCase):
self.hass, self.hass,
'{{ states.sensor.temperature.state | round(1) }}')) '{{ states.sensor.temperature.state | round(1) }}'))
def test_rounding_value2(self):
self.hass.states.set('sensor.temperature', 12.78)
self.assertEqual( self.assertEqual(
'128', '128',
template.render( template.render(
@ -69,6 +70,34 @@ class TestUtilTemplate(unittest.TestCase):
'{{ states.sensor.temperature.state | multiply(10) | round }}' '{{ 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): def test_passing_vars_as_keywords(self):
self.assertEqual( self.assertEqual(
'127', template.render(self.hass, '{{ hello }}', hello=127)) '127', template.render(self.hass, '{{ hello }}', hello=127))
@ -143,3 +172,303 @@ class TestUtilTemplate(unittest.TestCase):
self.assertEqual( self.assertEqual(
'unknown', 'unknown',
template.render(self.hass, '{{ states("test.object2") }}')) 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) }}'))