From f5d212f7427e5e490a66dfa58b51a74aba24ce55 Mon Sep 17 00:00:00 2001 From: dmamelin Date: Thu, 9 Oct 2025 01:20:03 +0200 Subject: [PATCH] Add StateVal conversion helpers and availability checks --- custom_components/pyscript/state.py | 48 +++++++++++++++++++++++ docs/reference.rst | 17 ++++++++- tests/test_state.py | 59 ++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 2 deletions(-) diff --git a/custom_components/pyscript/state.py b/custom_components/pyscript/state.py index 46389af..dab935e 100644 --- a/custom_components/pyscript/state.py +++ b/custom_components/pyscript/state.py @@ -1,11 +1,22 @@ """Handles state variable access and change notification.""" import asyncio +from datetime import datetime import logging +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Context from homeassistant.helpers.restore_state import DATA_RESTORE_STATE from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.template import ( + _SENTINEL, + forgiving_boolean, + forgiving_float, + forgiving_int, + forgiving_round, + raise_no_default, +) +from homeassistant.util import dt as dt_util from .const import LOGGER_PATH from .entity import PyscriptEntity @@ -29,6 +40,43 @@ def __new__(cls, state): new_var.last_reported = state.last_reported return new_var + def as_float(self, default: float = _SENTINEL) -> float: + """Return the state converted to float via the forgiving helper.""" + return forgiving_float(self, default=default) + + def as_int(self, default: int = _SENTINEL, base: int = 10) -> int: + """Return the state converted to int via the forgiving helper.""" + return forgiving_int(self, default=default, base=base) + + def as_bool(self, default: bool = _SENTINEL) -> bool: + """Return the state converted to bool via the forgiving helper.""" + return forgiving_boolean(self, default=default) + + def as_round(self, precision: int = 0, method: str = "common", default: float = _SENTINEL) -> float: + """Return the rounded state value via the forgiving helper.""" + return forgiving_round(self, precision=precision, method=method, default=default) + + def as_datetime(self, default: datetime = _SENTINEL) -> datetime: + """Return the state converted to a datetime, matching the forgiving template behaviour.""" + try: + return dt_util.parse_datetime(self, raise_on_error=True) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("as_datetime", self) + return default + + def is_unknown(self) -> bool: + """Return True if the state equals STATE_UNKNOWN.""" + return self == STATE_UNKNOWN + + def is_unavailable(self) -> bool: + """Return True if the state equals STATE_UNAVAILABLE.""" + return self == STATE_UNAVAILABLE + + def has_value(self) -> bool: + """Return True if the state is neither unknown nor unavailable.""" + return not self.is_unknown() and not self.is_unavailable() + class State: """Class for state functions.""" diff --git a/docs/reference.rst b/docs/reference.rst index 5f62226..9e38b57 100644 --- a/docs/reference.rst +++ b/docs/reference.rst @@ -231,6 +231,21 @@ the variable ``test1_state`` captures both the value and attributes of ``binary_ Later, if ``binary_sensor.test1`` changes, ``test1_state`` continues to represent the previous value and attributes at the time of the assignment. +Keep in mind that ``test1_state`` (and any other value returned by ``state.get()`` or direct state +reference) remains a subclass of ``str``. Pyscript exposes several helper methods on these instances +to simplify conversions and availability checks: + +- ``as_float(default=None)`` +- ``as_int(default=None, base=10)`` +- ``as_bool(default=None)`` +- ``as_round(precision=0, method: Literal["common", "ceil", "floor", "half"] = "common", default=None)`` +- ``as_datetime(default=None)`` +- ``is_unknown()`` / ``is_unavailable()`` / ``has_value()`` + +Each of the ``as_*`` helpers wraps the equivalent forgiving helper from +``homeassistant.helpers.template``. ``default`` is optional in every case: pass it to get a fallback +value on failure, or omit it to have a ``ValueError`` raised. + State variables also support virtual methods that are service calls with that ``entity_id``. For any state variable ``DOMAIN.ENTITY``, any services registered by ``DOMAIN``, e.g., ``DOMAIN.SERVICE``, that have an ``entity_id`` parameter can be called as a method @@ -268,7 +283,7 @@ Four additional virtual attribute values are available when you use a variable d - ``entity_id`` is the DOMAIN.entity as string - ``last_changed`` is the last UTC time the state value was changed (not the attributes) - ``last_updated`` is the last UTC time the state entity was updated -- ``last_reported``is the last UTC time the integration set the state of an entity, regardless of whether it changed or not +- ``last_reported`` is the last UTC time the integration set the state of an entity, regardless of whether it changed or not If you need to compute how many seconds ago the ``binary_sensor.test1`` state changed, you could do this: diff --git a/tests/test_state.py b/tests/test_state.py index 5a07070..beb3ca7 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,11 +1,13 @@ """Test pyscripts test module.""" +from datetime import datetime, timezone from unittest.mock import patch import pytest from custom_components.pyscript.function import Function -from custom_components.pyscript.state import State +from custom_components.pyscript.state import State, StateVal +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import Context, ServiceRegistry, StateMachine from homeassistant.helpers.state import State as HassState @@ -51,3 +53,58 @@ async def test_service_call(hass): # Stop all tasks to avoid conflicts with other tests await Function.waiter_stop() await Function.reaper_stop() + + +def test_state_val_conversions(): + """Test helper conversion methods exposed on StateVal.""" + float_state = StateVal(HassState("test.float", "123.45")) + assert float_state.as_float() == pytest.approx(123.45) + + int_state = StateVal(HassState("test.int", "42")) + assert int_state.as_int() == 42 + + hex_state = StateVal(HassState("test.hex", "FF")) + assert hex_state.as_int(base=16) == 255 + + bool_state = StateVal(HassState("test.bool", "on")) + assert bool_state.as_bool() is True + + round_state = StateVal(HassState("test.round", "3.1415")) + assert round_state.as_round(precision=2) == pytest.approx(3.14) + + datetime_state = StateVal(HassState("test.datetime", "2024-03-05T06:07:08+00:00")) + assert datetime_state.as_datetime() == datetime(2024, 3, 5, 6, 7, 8, tzinfo=timezone.utc) + + invalid_state = StateVal(HassState("test.invalid", "invalid")) + with pytest.raises(ValueError): + invalid_state.as_float() + with pytest.raises(ValueError): + invalid_state.as_int() + with pytest.raises(ValueError): + invalid_state.as_bool() + with pytest.raises(ValueError): + invalid_state.as_round() + with pytest.raises(ValueError): + invalid_state.as_datetime() + + assert invalid_state.as_bool(default=False) is False + + assert invalid_state.as_float(default=1.23) == pytest.approx(1.23) + + assert invalid_state.as_round(default=0) == 0 + + fallback_datetime = datetime(1999, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + assert invalid_state.as_datetime(default=fallback_datetime) == fallback_datetime + + unknown_state = StateVal(HassState("test.unknown", STATE_UNKNOWN)) + assert unknown_state.is_unknown() is True + assert unknown_state.is_unavailable() is False + assert unknown_state.has_value() is False + + unavailable_state = StateVal(HassState("test.unavailable", STATE_UNAVAILABLE)) + assert unavailable_state.is_unavailable() is True + assert unavailable_state.is_unknown() is False + assert unavailable_state.has_value() is False + + standard_state = StateVal(HassState("test.standard", "ready")) + assert standard_state.has_value() is True