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

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
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) }}'))