Skip to content
Open
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
10 changes: 10 additions & 0 deletions docs/device-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ print(f"Orientation: {slats_orientation}")
slats_orientation = device.states.first_value([OverkizState.CORE_SLATS_ORIENTATION, OverkizState.CORE_SLATE_ORIENTATION])
print(f"Orientation: {slats_orientation}")

# Get a value already typed, without casting the StateType union
# get_value_as_* returns the concrete type (or None if the state is missing)
closure = device.states.get_value_as_int(OverkizState.CORE_CLOSURE) # int | None
temperature = device.states.get_value_as_float(OverkizState.CORE_TEMPERATURE) # float | None

# Typed fallback chains mirror first_value()
orientation = device.states.first_value_as_int(
[OverkizState.CORE_SLATS_ORIENTATION, OverkizState.CORE_SLATE_ORIENTATION]
) # int | None

# Check if a single state exists with a non-None value
if device.states.has_value(OverkizState.CORE_SLATS_ORIENTATION):
print("Device has a slats orientation")
Expand Down
96 changes: 96 additions & 0 deletions pyoverkiz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,54 @@ def get_value(self, name: StateName) -> StateType:
return state.value
return None

def get_value_as_int(self, name: StateName) -> int | None:
"""Return the int value of a state by name, or None if missing.

Raises TypeError if the state exists but is not an integer.
"""
state = self._index.get(name)
return state.value_as_int if state is not None else None

def get_value_as_float(self, name: StateName) -> float | None:
"""Return the float value of a state by name, or None if missing.

Raises TypeError if the state exists but is not a float (int is allowed).
"""
state = self._index.get(name)
return state.value_as_float if state is not None else None

def get_value_as_bool(self, name: StateName) -> bool | None:
"""Return the bool value of a state by name, or None if missing.

Raises TypeError if the state exists but is not a boolean.
"""
state = self._index.get(name)
return state.value_as_bool if state is not None else None

def get_value_as_str(self, name: StateName) -> str | None:
"""Return the str value of a state by name, or None if missing.

Raises TypeError if the state exists but is not a string.
"""
state = self._index.get(name)
return state.value_as_str if state is not None else None

def get_value_as_dict(self, name: StateName) -> dict[str, Any] | None:
"""Return the dict value of a state by name, or None if missing.

Raises TypeError if the state exists but is not a JSON object.
"""
state = self._index.get(name)
return state.value_as_dict if state is not None else None

def get_value_as_list(self, name: StateName) -> list[Any] | None:
"""Return the list value of a state by name, or None if missing.

Raises TypeError if the state exists but is not a JSON array.
"""
state = self._index.get(name)
return state.value_as_list if state is not None else None

def first(self, names: list[StateName]) -> State | None:
"""Return the first State that exists and has a non-None value, or None."""
for name in names:
Expand All @@ -204,6 +252,54 @@ def first_value(self, names: list[StateName]) -> StateType:
return state.value
return None

def first_value_as_int(self, names: list[StateName]) -> int | None:
"""Return the int value of the first matching state, or None if none match.

Raises TypeError if the matched state is not an integer.
"""
state = self.first(names)
return state.value_as_int if state is not None else None

def first_value_as_float(self, names: list[StateName]) -> float | None:
"""Return the float value of the first matching state, or None if none match.

Raises TypeError if the matched state is not a float (int is allowed).
"""
state = self.first(names)
return state.value_as_float if state is not None else None

def first_value_as_bool(self, names: list[StateName]) -> bool | None:
"""Return the bool value of the first matching state, or None if none match.

Raises TypeError if the matched state is not a boolean.
"""
state = self.first(names)
return state.value_as_bool if state is not None else None

def first_value_as_str(self, names: list[StateName]) -> str | None:
"""Return the str value of the first matching state, or None if none match.

Raises TypeError if the matched state is not a string.
"""
state = self.first(names)
return state.value_as_str if state is not None else None

def first_value_as_dict(self, names: list[StateName]) -> dict[str, Any] | None:
"""Return the dict value of the first matching state, or None if none match.

Raises TypeError if the matched state is not a JSON object.
"""
state = self.first(names)
return state.value_as_dict if state is not None else None

def first_value_as_list(self, names: list[StateName]) -> list[Any] | None:
"""Return the list value of the first matching state, or None if none match.

Raises TypeError if the matched state is not a JSON array.
"""
state = self.first(names)
return state.value_as_list if state is not None else None

def has_value(self, name: StateName) -> bool:
"""Return True if the state exists and has a non-None value.

Expand Down
72 changes: 72 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,78 @@ def test_states_first_value(self):
value = device.states.first_value(["nonexistent", "core:ClosureState"])
assert value == 100

def test_states_get_value_as_typed_returns_typed_value(self):
"""get_value_as_* returns the value already typed for matching states."""
device = _make_device()
# core:ClosureState is an integer (type 1) with value 100
closure = device.states.get_value_as_int("core:ClosureState")
assert closure == 100
assert isinstance(closure, int)
# int states are also reachable as float
assert device.states.get_value_as_float("core:ClosureState") == 100.0
# core:StatusState is a string (type 3)
assert device.states.get_value_as_str("core:StatusState") == "available"

def test_states_get_value_as_typed_returns_none_when_missing(self):
"""get_value_as_* returns None for a missing state, without raising."""
device = _make_device()
assert device.states.get_value_as_int("nonexistent") is None
assert device.states.get_value_as_float("nonexistent") is None
assert device.states.get_value_as_bool("nonexistent") is None
assert device.states.get_value_as_str("nonexistent") is None
assert device.states.get_value_as_dict("nonexistent") is None
assert device.states.get_value_as_list("nonexistent") is None

def test_states_get_value_as_typed_none_data_type_returns_none(self):
"""get_value_as_* returns None when the state's data type is NONE."""
states = States([State(name="x", type=DataType.NONE, value=None)])
assert states.get_value_as_int("x") is None
assert states.get_value_as_str("x") is None

def test_states_get_value_as_typed_raises_on_type_mismatch(self):
"""get_value_as_* propagates TypeError when the state is the wrong type."""
device = _make_device()
# core:StatusState is a string, not an integer
with pytest.raises(TypeError):
device.states.get_value_as_int("core:StatusState")

def test_states_get_value_as_typed_accepts_enum_keys(self):
"""get_value_as_* accepts OverkizState enums, not just plain strings."""
device = _make_device()
assert device.states.get_value_as_int(OverkizState.CORE_CLOSURE) == 100

def test_states_first_value_as_typed_returns_first_match(self):
"""first_value_as_* returns the typed value of the first matching state."""
device = _make_device()
value = device.states.first_value_as_int(["nonexistent", "core:ClosureState"])
assert value == 100
assert isinstance(value, int)

def test_states_first_value_as_typed_returns_none_when_no_match(self):
"""first_value_as_* returns None when no state matches."""
device = _make_device()
assert device.states.first_value_as_int(["nonexistent", "missing"]) is None
assert device.states.first_value_as_str(["nonexistent", "missing"]) is None

def test_states_first_value_as_typed_raises_on_type_mismatch(self):
"""first_value_as_* propagates TypeError when the matched state is wrong type."""
device = _make_device()
with pytest.raises(TypeError):
device.states.first_value_as_int(["nonexistent", "core:StatusState"])

def test_states_value_as_dict_and_list(self):
"""get_value_as_dict / get_value_as_list return JSON container values."""
states = States(
[
State(name="obj", type=DataType.JSON_OBJECT, value={"a": 1}),
State(name="arr", type=DataType.JSON_ARRAY, value=[1, 2, 3]),
]
)
assert states.get_value_as_dict("obj") == {"a": 1}
assert states.get_value_as_list("arr") == [1, 2, 3]
assert states.first_value_as_dict(["missing", "obj"]) == {"a": 1}
assert states.first_value_as_list(["missing", "arr"]) == [1, 2, 3]

def test_states_has_value(self):
"""device.states.has_value() checks if a single state exists with non-None value."""
device = _make_device()
Expand Down