From c3bd8fe8d8363edf35e532b60d41015c7e591aa1 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Mon, 1 Jun 2026 16:42:45 +0200 Subject: [PATCH] Add typed value getters to States container Add get_value_as_* and first_value_as_* methods to the States container that delegate to the existing State.value_as_* properties, returning None when the state is missing and propagating TypeError on type mismatch. Closes #2113 --- docs/device-control.md | 10 +++++ pyoverkiz/models.py | 96 ++++++++++++++++++++++++++++++++++++++++++ tests/test_models.py | 72 +++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/docs/device-control.md b/docs/device-control.md index 00cc9729..7e38360b 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -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") diff --git a/pyoverkiz/models.py b/pyoverkiz/models.py index a9df61fc..9a503744 100644 --- a/pyoverkiz/models.py +++ b/pyoverkiz/models.py @@ -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: @@ -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. diff --git a/tests/test_models.py b/tests/test_models.py index 8875fc2e..69ba3bc5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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()