Add closest template helper

This commit is contained in:
Paulus Schoutsen 2016-02-21 11:13:40 -08:00
parent 9f5f13644a
commit c3bb6d32aa
4 changed files with 360 additions and 7 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

@ -10,10 +10,12 @@ import logging
import jinja2 import jinja2
from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.sandbox import ImmutableSandboxedEnvironment
from homeassistant.components import group
from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.const import STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import State from homeassistant.core import State
from homeassistant.exceptions import TemplateError 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__) _LOGGER = logging.getLogger(__name__)
_SENTINEL = object() _SENTINEL = object()
@ -43,16 +45,17 @@ def render(hass, template, variables=None, **kwargs):
if variables is not None: if variables is not None:
kwargs.update(variables) kwargs.update(variables)
location_helper = LocationHelpers(hass) location_methods = LocationMethods(hass)
utcnow = dt_util.utcnow() utcnow = dt_util.utcnow()
try: try:
return ENV.from_string(template, { 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': hass.states.is_state,
'is_state_attr': hass.states.is_state_attr, 'is_state_attr': hass.states.is_state_attr,
'states': AllStates(hass),
'now': dt_util.as_local(utcnow), 'now': dt_util.as_local(utcnow),
'states': AllStates(hass),
'utcnow': utcnow, 'utcnow': utcnow,
}).render(kwargs).strip() }).render(kwargs).strip()
except jinja2.TemplateError as err: except jinja2.TemplateError as err:
@ -93,13 +96,75 @@ class DomainStates(object):
key=lambda state: state.entity_id)) key=lambda state: state.entity_id))
class LocationHelpers(object): class LocationMethods(object):
"""Class to expose distance helpers to templates.""" """Class to expose distance helpers to templates."""
def __init__(self, hass): def __init__(self, hass):
"""Initialize distance helpers.""" """Initialize distance helpers."""
self._hass = hass 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): def distance(self, *args):
"""Calculate distance. """Calculate distance.
@ -145,7 +210,15 @@ class LocationHelpers(object):
if len(locations) == 1: if len(locations) == 1:
return self._hass.config.distance(*locations[0]) 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): def forgiving_round(value, precision=0):

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

@ -8,6 +8,7 @@ Tests Home Assistant template util methods.
import unittest import unittest
from unittest.mock import patch 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 import homeassistant.util.dt as dt_util
@ -272,4 +273,203 @@ class TestUtilTemplate(unittest.TestCase):
'None', 'None',
template.render( template.render(
self.hass, 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) }}'))