Skip to content
Merged
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
48 changes: 48 additions & 0 deletions custom_components/pyscript/state.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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."""
Expand Down
17 changes: 16 additions & 1 deletion docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
59 changes: 58 additions & 1 deletion tests/test_state.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Loading