Skip to content

Commit f5d212f

Browse files
committed
Add StateVal conversion helpers and availability checks
1 parent 515a5c2 commit f5d212f

File tree

3 files changed

+122
-2
lines changed

3 files changed

+122
-2
lines changed

custom_components/pyscript/state.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
"""Handles state variable access and change notification."""
22

33
import asyncio
4+
from datetime import datetime
45
import logging
56

7+
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
68
from homeassistant.core import Context
79
from homeassistant.helpers.restore_state import DATA_RESTORE_STATE
810
from homeassistant.helpers.service import async_get_all_descriptions
11+
from homeassistant.helpers.template import (
12+
_SENTINEL,
13+
forgiving_boolean,
14+
forgiving_float,
15+
forgiving_int,
16+
forgiving_round,
17+
raise_no_default,
18+
)
19+
from homeassistant.util import dt as dt_util
920

1021
from .const import LOGGER_PATH
1122
from .entity import PyscriptEntity
@@ -29,6 +40,43 @@ def __new__(cls, state):
2940
new_var.last_reported = state.last_reported
3041
return new_var
3142

43+
def as_float(self, default: float = _SENTINEL) -> float:
44+
"""Return the state converted to float via the forgiving helper."""
45+
return forgiving_float(self, default=default)
46+
47+
def as_int(self, default: int = _SENTINEL, base: int = 10) -> int:
48+
"""Return the state converted to int via the forgiving helper."""
49+
return forgiving_int(self, default=default, base=base)
50+
51+
def as_bool(self, default: bool = _SENTINEL) -> bool:
52+
"""Return the state converted to bool via the forgiving helper."""
53+
return forgiving_boolean(self, default=default)
54+
55+
def as_round(self, precision: int = 0, method: str = "common", default: float = _SENTINEL) -> float:
56+
"""Return the rounded state value via the forgiving helper."""
57+
return forgiving_round(self, precision=precision, method=method, default=default)
58+
59+
def as_datetime(self, default: datetime = _SENTINEL) -> datetime:
60+
"""Return the state converted to a datetime, matching the forgiving template behaviour."""
61+
try:
62+
return dt_util.parse_datetime(self, raise_on_error=True)
63+
except (ValueError, TypeError):
64+
if default is _SENTINEL:
65+
raise_no_default("as_datetime", self)
66+
return default
67+
68+
def is_unknown(self) -> bool:
69+
"""Return True if the state equals STATE_UNKNOWN."""
70+
return self == STATE_UNKNOWN
71+
72+
def is_unavailable(self) -> bool:
73+
"""Return True if the state equals STATE_UNAVAILABLE."""
74+
return self == STATE_UNAVAILABLE
75+
76+
def has_value(self) -> bool:
77+
"""Return True if the state is neither unknown nor unavailable."""
78+
return not self.is_unknown() and not self.is_unavailable()
79+
3280

3381
class State:
3482
"""Class for state functions."""

docs/reference.rst

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,21 @@ the variable ``test1_state`` captures both the value and attributes of ``binary_
231231
Later, if ``binary_sensor.test1`` changes, ``test1_state`` continues to represent the previous
232232
value and attributes at the time of the assignment.
233233

234+
Keep in mind that ``test1_state`` (and any other value returned by ``state.get()`` or direct state
235+
reference) remains a subclass of ``str``. Pyscript exposes several helper methods on these instances
236+
to simplify conversions and availability checks:
237+
238+
- ``as_float(default=None)``
239+
- ``as_int(default=None, base=10)``
240+
- ``as_bool(default=None)``
241+
- ``as_round(precision=0, method: Literal["common", "ceil", "floor", "half"] = "common", default=None)``
242+
- ``as_datetime(default=None)``
243+
- ``is_unknown()`` / ``is_unavailable()`` / ``has_value()``
244+
245+
Each of the ``as_*`` helpers wraps the equivalent forgiving helper from
246+
``homeassistant.helpers.template``. ``default`` is optional in every case: pass it to get a fallback
247+
value on failure, or omit it to have a ``ValueError`` raised.
248+
234249
State variables also support virtual methods that are service calls with that ``entity_id``.
235250
For any state variable ``DOMAIN.ENTITY``, any services registered by ``DOMAIN``, e.g.,
236251
``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
268283
- ``entity_id`` is the DOMAIN.entity as string
269284
- ``last_changed`` is the last UTC time the state value was changed (not the attributes)
270285
- ``last_updated`` is the last UTC time the state entity was updated
271-
- ``last_reported``is the last UTC time the integration set the state of an entity, regardless of whether it changed or not
286+
- ``last_reported`` is the last UTC time the integration set the state of an entity, regardless of whether it changed or not
272287

273288
If you need to compute how many seconds ago the ``binary_sensor.test1`` state changed, you could
274289
do this:

tests/test_state.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Test pyscripts test module."""
22

3+
from datetime import datetime, timezone
34
from unittest.mock import patch
45

56
import pytest
67

78
from custom_components.pyscript.function import Function
8-
from custom_components.pyscript.state import State
9+
from custom_components.pyscript.state import State, StateVal
10+
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
911
from homeassistant.core import Context, ServiceRegistry, StateMachine
1012
from homeassistant.helpers.state import State as HassState
1113

@@ -51,3 +53,58 @@ async def test_service_call(hass):
5153
# Stop all tasks to avoid conflicts with other tests
5254
await Function.waiter_stop()
5355
await Function.reaper_stop()
56+
57+
58+
def test_state_val_conversions():
59+
"""Test helper conversion methods exposed on StateVal."""
60+
float_state = StateVal(HassState("test.float", "123.45"))
61+
assert float_state.as_float() == pytest.approx(123.45)
62+
63+
int_state = StateVal(HassState("test.int", "42"))
64+
assert int_state.as_int() == 42
65+
66+
hex_state = StateVal(HassState("test.hex", "FF"))
67+
assert hex_state.as_int(base=16) == 255
68+
69+
bool_state = StateVal(HassState("test.bool", "on"))
70+
assert bool_state.as_bool() is True
71+
72+
round_state = StateVal(HassState("test.round", "3.1415"))
73+
assert round_state.as_round(precision=2) == pytest.approx(3.14)
74+
75+
datetime_state = StateVal(HassState("test.datetime", "2024-03-05T06:07:08+00:00"))
76+
assert datetime_state.as_datetime() == datetime(2024, 3, 5, 6, 7, 8, tzinfo=timezone.utc)
77+
78+
invalid_state = StateVal(HassState("test.invalid", "invalid"))
79+
with pytest.raises(ValueError):
80+
invalid_state.as_float()
81+
with pytest.raises(ValueError):
82+
invalid_state.as_int()
83+
with pytest.raises(ValueError):
84+
invalid_state.as_bool()
85+
with pytest.raises(ValueError):
86+
invalid_state.as_round()
87+
with pytest.raises(ValueError):
88+
invalid_state.as_datetime()
89+
90+
assert invalid_state.as_bool(default=False) is False
91+
92+
assert invalid_state.as_float(default=1.23) == pytest.approx(1.23)
93+
94+
assert invalid_state.as_round(default=0) == 0
95+
96+
fallback_datetime = datetime(1999, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
97+
assert invalid_state.as_datetime(default=fallback_datetime) == fallback_datetime
98+
99+
unknown_state = StateVal(HassState("test.unknown", STATE_UNKNOWN))
100+
assert unknown_state.is_unknown() is True
101+
assert unknown_state.is_unavailable() is False
102+
assert unknown_state.has_value() is False
103+
104+
unavailable_state = StateVal(HassState("test.unavailable", STATE_UNAVAILABLE))
105+
assert unavailable_state.is_unavailable() is True
106+
assert unavailable_state.is_unknown() is False
107+
assert unavailable_state.has_value() is False
108+
109+
standard_state = StateVal(HassState("test.standard", "ready"))
110+
assert standard_state.has_value() is True

0 commit comments

Comments
 (0)