Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion homeassistant/helpers/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -44,7 +46,7 @@

_RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M)
_RE_GET_ENTITIES = re.compile(
r"(?:(?:states\.|(?P<func>is_state|is_state_attr|state_attr|states|expand)"
r"(?:(?:states\.|(?P<func>is_state|is_state_attr|state_attr|states|expand|coordinates)"
r"\((?:[\ \'\"]?))(?P<entity_id>[\w]+\.[\w]+)|(?P<variable>[\w]+))",
re.I | re.M,
)
Expand Down Expand Up @@ -653,6 +655,64 @@ def distance(hass, *args):
)


def coordinates(
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)

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, f"zone.{entity.state}")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
zone_entity = _resolve_state(hass, f"zone.{entity.state}")
zone_entity = hass.states.get(entity.state)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting your state to the entity ID of a zone is not an official supported "thing", we shouldn't encode it in our template helper unless we want to adopt this.

Let's remove this support.

if loc_helper.has_location(zone_entity): # type: ignore
_LOGGER.debug(
"%s is in %s, getting zone location", entity_id, zone_entity.entity_id # type: ignore
)
return _get_location_from_attributes(zone_entity) # type: ignore

# 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
try:
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:
"""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)
Expand Down Expand Up @@ -1026,6 +1086,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."""
Expand Down
70 changes: 70 additions & 0 deletions tests/helpers/test_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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') }}")
Expand Down