diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6ef9ef57329..26a8add3de5 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -17,6 +17,7 @@ from operator import attrgetter import random import re import statistics +from struct import error as StructError, pack, unpack_from import sys from typing import Any, cast from urllib.parse import urlencode as urllib_urlencode @@ -1639,6 +1640,34 @@ def bitwise_or(first_value, second_value): return first_value | second_value +def struct_pack(value: Any | None, format_string: str) -> bytes | None: + """Pack an object into a bytes object.""" + try: + return pack(format_string, value) + except StructError: + _LOGGER.warning( + "Template warning: 'pack' unable to pack object '%s' with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information", + str(value), + type(value).__name__, + format_string, + ) + return None + + +def struct_unpack(value: bytes, format_string: str, offset: int = 0) -> Any | None: + """Unpack an object from bytes an return the first native object.""" + try: + return unpack_from(format_string, value, offset)[0] + except StructError: + _LOGGER.warning( + "Template warning: 'unpack' unable to unpack object '%s' with format_string '%s' and offset %s see https://docs.python.org/3/library/struct.html for more information", + value, + format_string, + offset, + ) + return None + + def base64_encode(value): """Perform base64 encode.""" return base64.b64encode(value.encode("utf-8")).decode("utf-8") @@ -1823,6 +1852,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["regex_findall_index"] = regex_findall_index self.filters["bitwise_and"] = bitwise_and self.filters["bitwise_or"] = bitwise_or + self.filters["pack"] = struct_pack + self.filters["unpack"] = struct_unpack self.filters["ord"] = ord self.filters["is_number"] = is_number self.filters["float"] = forgiving_float_filter @@ -1853,6 +1884,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["min"] = min self.globals["is_number"] = is_number self.globals["int"] = forgiving_int + self.globals["pack"] = struct_pack + self.globals["unpack"] = struct_unpack self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 99c57983aa5..4a97b99d05d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1505,6 +1505,140 @@ def test_bitwise_or(hass): assert tpl.async_render() == 8 | 2 +def test_pack(hass, caplog): + """Test struct pack method.""" + + # render as filter + tpl = template.Template( + """ +{{ value | pack('>I') }} + """, + hass, + ) + variables = { + "value": 0xDEADBEEF, + } + assert tpl.async_render(variables=variables) == b"\xde\xad\xbe\xef" + + # render as function + tpl = template.Template( + """ +{{ pack(value, '>I') }} + """, + hass, + ) + variables = { + "value": 0xDEADBEEF, + } + assert tpl.async_render(variables=variables) == b"\xde\xad\xbe\xef" + + # test with None value + tpl = template.Template( + """ +{{ pack(value, '>I') }} + """, + hass, + ) + variables = { + "value": None, + } + # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'pack' unable to pack object 'None' with type 'NoneType' and format_string '>I' see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + # test with invalid filter + tpl = template.Template( + """ +{{ pack(value, 'invalid filter') }} + """, + hass, + ) + variables = { + "value": 0xDEADBEEF, + } + # "Template warning: 'pack' unable to pack object with type '%s' and format_string '%s' see https://docs.python.org/3/library/struct.html for more information" + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'pack' unable to pack object '3735928559' with type 'int' and format_string 'invalid filter' see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + +def test_unpack(hass, caplog): + """Test struct unpack method.""" + + # render as filter + tpl = template.Template( + """ +{{ value | unpack('>I') }} + """, + hass, + ) + variables = { + "value": b"\xde\xad\xbe\xef", + } + assert tpl.async_render(variables=variables) == 0xDEADBEEF + + # render as function + tpl = template.Template( + """ +{{ unpack(value, '>I') }} + """, + hass, + ) + variables = { + "value": b"\xde\xad\xbe\xef", + } + assert tpl.async_render(variables=variables) == 0xDEADBEEF + + # unpack with offset + tpl = template.Template( + """ +{{ unpack(value, '>H', offset=2) }} + """, + hass, + ) + variables = { + "value": b"\xde\xad\xbe\xef", + } + assert tpl.async_render(variables=variables) == 0xBEEF + + # test with an empty bytes object + tpl = template.Template( + """ +{{ unpack(value, '>I') }} + """, + hass, + ) + variables = { + "value": b"", + } + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'unpack' unable to unpack object 'b''' with format_string '>I' and offset 0 see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + # test with invalid filter + tpl = template.Template( + """ +{{ unpack(value, 'invalid filter') }} + """, + hass, + ) + variables = { + "value": b"", + } + assert tpl.async_render(variables=variables) is None + assert ( + "Template warning: 'unpack' unable to unpack object 'b''' with format_string 'invalid filter' and offset 0 see https://docs.python.org/3/library/struct.html for more information" + in caplog.text + ) + + def test_distance_function_with_1_state(hass): """Test distance function with 1 state.""" _set_up_units(hass)