From 866a5ae38e97aeb98ce0c705cf5454b8588bc73a Mon Sep 17 00:00:00 2001 From: Kevin Eifinger Date: Tue, 17 Dec 2019 10:26:10 +0100 Subject: [PATCH 1/4] Add template extension coordinates --- homeassistant/helpers/template.py | 70 ++++++++++++++++++++++++++++++- tests/helpers/test_template.py | 70 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b5e62ee2fcd7b..483695baf01ef 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -14,6 +14,7 @@ from jinja2 import contextfilter, contextfunction from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace # type: ignore +import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, @@ -27,6 +28,7 @@ from homeassistant.core import State, callback, split_entity_id, valid_entity_id from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType, TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import convert, dt as dt_util, location as loc_util @@ -44,7 +46,7 @@ _RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M) _RE_GET_ENTITIES = re.compile( - r"(?:(?:states\.|(?Pis_state|is_state_attr|state_attr|states|expand)" + r"(?:(?:states\.|(?Pis_state|is_state_attr|state_attr|states|expand|coordinates)" r"\((?:[\ \'\"]?))(?P[\w]+\.[\w]+)|(?P[\w]+))", re.I | re.M, ) @@ -653,6 +655,71 @@ def distance(hass, *args): ) +def coordinates( + hass, entity_id: str, recursion_history: Optional[list] = None +) -> Optional[str]: + """Get the location from the entity state or attributes.""" + entity = _resolve_state(hass, entity_id) + + if entity is None: + _LOGGER.error("Unable to find entity %s", entity_id) + return None + + # Check if the entity has location attributes + if loc_helper.has_location(entity): + return _get_location_from_attributes(entity) + + # Check if device is in a zone + zone_entity = _resolve_state(hass, "zone.{}".format(entity.state)) + if loc_helper.has_location(zone_entity): + _LOGGER.debug( + "%s is in %s, getting zone location", entity_id, zone_entity.entity_id + ) + return _get_location_from_attributes(zone_entity) + + # Resolve nested entity + if recursion_history is None: + recursion_history = [] + recursion_history.append(entity_id) + if entity.state in recursion_history: + _LOGGER.error( + "Circular Reference detected. The state of %s has already been checked.", + entity.state, + ) + return None + _LOGGER.debug("Getting nested entity for state: %s", entity.state) + nested_entity = _resolve_state(hass, entity.state) + if nested_entity is not None: + _LOGGER.debug("Resolving nested entity_id: %s", entity.state) + return coordinates(hass, entity.state, recursion_history) + + # Check if state is valid coordinate set + if _entity_state_is_valid_coordinate_set(entity.state): + return entity.state + + _LOGGER.error( + "The state of %s is not a valid set of coordinates: %s", entity_id, entity.state + ) + return None + + +def _entity_state_is_valid_coordinate_set(state: str) -> bool: + """Check that the given string is a valid set of coordinates.""" + schema = vol.Schema(cv.gps) + try: + coords = state.split(",") + schema(coords) + return True + except (vol.MultipleInvalid): + return False + + +def _get_location_from_attributes(entity: State) -> str: + """Get the lat/long string from an entities attributes.""" + attr = entity.attributes + return "{},{}".format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + def is_state(hass: HomeAssistantType, entity_id: str, state: State) -> bool: """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) @@ -1026,6 +1093,7 @@ def wrapper(*args, **kwargs): self.globals["is_state_attr"] = hassfunction(is_state_attr) self.globals["state_attr"] = hassfunction(state_attr) self.globals["states"] = AllStates(hass) + self.globals["coordinates"] = hassfunction(coordinates) def is_safe_callable(self, obj): """Test if callback is safe.""" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 3f3eb7a800cc7..1e442f38c6161 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1290,6 +1290,76 @@ async def test_closest_function_home_vs_group_state(hass): ) +def test_coordinates_function_as_attributes(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + hass.states.async_set( + "test.object", "happy", {"latitude": 32.87336, "longitude": -117.22943} + ) + tpl = template.Template("{{ coordinates(states.test.object) }}", hass) + assert tpl.async_render() == "32.87336,-117.22943" + + +def test_coordinates_function_as_state(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + hass.states.async_set("test.object", "32.87336,-117.22943") + tpl = template.Template("{{ coordinates(states.test.object) }}", hass) + assert tpl.async_render() == "32.87336,-117.22943" + + +def test_coordinates_function_device_tracker_in_zone(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + hass.states.async_set( + "zone.home", "zoning", {"latitude": 32.87336, "longitude": -117.22943}, + ) + hass.states.async_set("device_tracker.device", "home") + tpl = template.Template("{{ coordinates(states.device_tracker.device) }}", hass) + assert tpl.async_render() == "32.87336,-117.22943" + + +def test_coordinates_function_device_tracker_from_input_select(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + hass.states.async_set( + "input_select.select", + "device_tracker.device", + {"options": "device_tracker.device"}, + ) + hass.states.async_set("device_tracker.device", "32.87336,-117.22943") + tpl = template.Template("{{ coordinates(states.input_select.select) }}", hass) + assert tpl.async_render() == "32.87336,-117.22943" + + +def test_coordinates_function_returns_none_on_recursion(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + hass.states.async_set( + "test.first", "test.second", + ) + hass.states.async_set("test.second", "test.first") + tpl = template.Template("{{ coordinates(states.test.first) }}", hass) + assert tpl.async_render() == "None" + + +def test_coordinates_function_returns_none_if_invalid_coord(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + hass.states.async_set( + "test.object", "abc", + ) + tpl = template.Template("{{ coordinates(states.test.object) }}", hass) + assert tpl.async_render() == "None" + + +def test_coordinates_function_returns_none_if_invalid_input(hass): + """Test test_coordinates function.""" + _set_up_units(hass) + tpl = template.Template("{{ coordinates(states.abc) }}", hass) + assert tpl.async_render() == "None" + + async def test_expand(hass): """Test expand function.""" info = render_to_info(hass, "{{ expand('test.object') }}") From 4c015b71fb724b6532713a3f02a5c075fa1ec4ab Mon Sep 17 00:00:00 2001 From: Kevin Eifinger Date: Tue, 17 Dec 2019 10:57:38 +0100 Subject: [PATCH 2/4] fix mypy --- homeassistant/helpers/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 483695baf01ef..ab51681533b74 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -656,7 +656,7 @@ def distance(hass, *args): def coordinates( - hass, entity_id: str, recursion_history: Optional[list] = None + hass: HomeAssistantType, entity_id: str, recursion_history: Optional[list] = None ) -> Optional[str]: """Get the location from the entity state or attributes.""" entity = _resolve_state(hass, entity_id) From ce481873599eab0dfd9c77adb7506e20815988d2 Mon Sep 17 00:00:00 2001 From: Kevin Eifinger Date: Tue, 26 May 2020 10:18:46 +0200 Subject: [PATCH 3/4] run pyupgrade --- homeassistant/helpers/template.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ab51681533b74..82dcd1c951442 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -670,12 +670,12 @@ def coordinates( return _get_location_from_attributes(entity) # Check if device is in a zone - zone_entity = _resolve_state(hass, "zone.{}".format(entity.state)) - if loc_helper.has_location(zone_entity): + zone_entity = _resolve_state(hass, f"zone.{entity.state}") + if loc_helper.has_location(zone_entity): # type: ignore _LOGGER.debug( - "%s is in %s, getting zone location", entity_id, zone_entity.entity_id + "%s is in %s, getting zone location", entity_id, zone_entity.entity_id # type: ignore ) - return _get_location_from_attributes(zone_entity) + return _get_location_from_attributes(zone_entity) # type: ignore # Resolve nested entity if recursion_history is None: From 2e657cdb8a8241ccc12617b87758f833b83ed0b2 Mon Sep 17 00:00:00 2001 From: Kevin Eifinger Date: Tue, 26 May 2020 11:13:47 +0200 Subject: [PATCH 4/4] Remove function --- homeassistant/helpers/template.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 82dcd1c951442..52895f49c7884 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -694,24 +694,17 @@ def coordinates( return coordinates(hass, entity.state, recursion_history) # Check if state is valid coordinate set - if _entity_state_is_valid_coordinate_set(entity.state): - return entity.state - - _LOGGER.error( - "The state of %s is not a valid set of coordinates: %s", entity_id, entity.state - ) - return None - - -def _entity_state_is_valid_coordinate_set(state: str) -> bool: - """Check that the given string is a valid set of coordinates.""" - schema = vol.Schema(cv.gps) try: - coords = state.split(",") - schema(coords) - return True - except (vol.MultipleInvalid): - return False + cv.gps(entity.state.split(",")) + except vol.Invalid: + _LOGGER.error( + "The state of %s is not a valid set of coordinates: %s", + entity_id, + entity.state, + ) + return None + else: + return entity.state def _get_location_from_attributes(entity: State) -> str: