diff --git a/homeassistant/helpers/template/extensions/datetime.py b/homeassistant/helpers/template/extensions/datetime.py index 33a329e2aad1..412d1ea4421b 100644 --- a/homeassistant/helpers/template/extensions/datetime.py +++ b/homeassistant/helpers/template/extensions/datetime.py @@ -117,6 +117,14 @@ class DateTimeExtension(BaseTemplateExtension): requires_hass=True, limited_ok=False, ), + TemplateFunction( + "timedelta_string", + self.timedelta_string, + as_global=True, + as_filter=True, + requires_hass=True, + limited_ok=False, + ), TemplateFunction( "today_at", self.today_at, @@ -319,3 +327,19 @@ class DateTimeExtension(BaseTemplateExtension): If the value not a datetime object the input will be returned unmodified. """ return self._datetime_as_string(value, precision=precision, future=True) + + def timedelta_string(self, value: Any, precision: int = 1) -> Any: + """Take a timedelta and return a human-readable string representation. + + The result can be in seconds, minutes, hours, days, months and years. + + precision is the number of units to return, with the last unit rounded. + precision=0 returns all units (no early rounding, except for sub-second values). + + Negative timedeltas are formatted using their absolute value. + + If the value is not a timedelta object the input will be returned unmodified. + """ + if not isinstance(value, timedelta): + return value + return dt_util.timedelta_string(value, precision) diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 1863544ba172..16f5d964b562 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -387,6 +387,20 @@ def get_time_remaining(date: dt.datetime, precision: int = 1) -> str: return _get_timestring(rounded_delta, precision) +def timedelta_string(delta: dt.timedelta, precision: int = 1) -> str: + """Return a string representation of a timedelta. + + The result can be in seconds, minutes, hours, days, months and years. + + precision is the number of units to return, with the last unit rounded. + precision=0 returns all units (no early rounding, except for sub-second values). + + Negative timedeltas are treated as their absolute value. + """ + rounded_delta = round(abs(delta.total_seconds())) + return _get_timestring(rounded_delta, precision) + + def parse_time_expression(parameter: Any, min_value: int, max_value: int) -> list[int]: """Parse the time expression part and return a list of times to match.""" if parameter is None or parameter == "*": diff --git a/tests/helpers/template/extensions/test_datetime.py b/tests/helpers/template/extensions/test_datetime.py index a1b2e404181b..650dbb30a3d4 100644 --- a/tests/helpers/template/extensions/test_datetime.py +++ b/tests/helpers/template/extensions/test_datetime.py @@ -786,3 +786,35 @@ async def test_time_until(mock_is_safe, hass: HomeAssistant) -> None: info = render_to_info(hass, time_until_template) assert info.has_time is True + + +@pytest.mark.parametrize( + ("template_str", "expected"), + [ + ("{{ timedelta(seconds=0) | timedelta_string }}", "0 seconds"), + ("{{ timedelta(seconds=1) | timedelta_string }}", "1 second"), + ("{{ timedelta(hours=1) | timedelta_string }}", "1 hour"), + ("{{ timedelta(hours=2) | timedelta_string }}", "2 hours"), + ("{{ timedelta(hours=1, minutes=30) | timedelta_string }}", "2 hours"), + ( + "{{ timedelta(hours=1, minutes=30) | timedelta_string(precision=2) }}", + "1 hour 30 minutes", + ), + ( + "{{ timedelta(hours=1, minutes=54, seconds=33) | timedelta_string(precision=3) }}", + "1 hour 54 minutes 33 seconds", + ), + ( + "{{ timedelta(hours=1, minutes=54, seconds=33) | timedelta_string(precision=0) }}", + "1 hour 54 minutes 33 seconds", + ), + ("{{ timedelta_string(timedelta(hours=1), precision=2) }}", "1 hour"), + ("{{ timedelta_string('string') }}", "string"), + ("{{ timedelta_string(42) }}", 42), + ], +) +def test_timedelta_string( + hass: HomeAssistant, template_str: str, expected: str | int +) -> None: + """Test timedelta_string function and filter.""" + assert render(hass, template_str) == expected diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index b0a6b0cd2795..649315e9a77b 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -292,6 +292,34 @@ def test_time_remaining() -> None: assert dt_util.get_time_remaining(diff) == "1 year" +@pytest.mark.parametrize( + ("delta", "precision", "expected"), + [ + (timedelta(seconds=0), 1, "0 seconds"), + (timedelta(milliseconds=400), 1, "0 seconds"), + (timedelta(milliseconds=600), 1, "1 second"), + (timedelta(seconds=1), 1, "1 second"), + (timedelta(seconds=30), 1, "30 seconds"), + (timedelta(minutes=1), 1, "1 minute"), + (timedelta(minutes=5), 1, "5 minutes"), + (timedelta(hours=1), 1, "1 hour"), + (timedelta(hours=5), 1, "5 hours"), + (timedelta(hours=1, minutes=30), 1, "2 hours"), + (timedelta(hours=1, minutes=30), 2, "1 hour 30 minutes"), + (timedelta(days=2), 1, "2 days"), + (timedelta(days=32), 1, "1 month"), + (timedelta(days=365), 1, "1 year"), + (timedelta(hours=1, minutes=54, seconds=33), 3, "1 hour 54 minutes 33 seconds"), + (timedelta(hours=1, minutes=54, seconds=33), 0, "1 hour 54 minutes 33 seconds"), + (timedelta(hours=-1), 1, "1 hour"), + (timedelta(hours=-1, minutes=-30), 2, "1 hour 30 minutes"), + ], +) +def test_timedelta_string(delta: timedelta, precision: int, expected: str) -> None: + """Test timedelta_string.""" + assert dt_util.timedelta_string(delta, precision) == expected + + def test_parse_time_expression() -> None: """Test parse_time_expression.""" assert list(range(60)) == dt_util.parse_time_expression("*", 0, 59)