From 0563037c5a0d14b49d800b0d8d6d61a0baaa5fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 25 Feb 2026 15:57:05 +0100 Subject: [PATCH] Fix MatterValve state handling and allow None values for attributes (#164066) --- homeassistant/components/matter/valve.py | 34 ++++++++------ .../matter/snapshots/test_valve.ambr | 4 +- tests/components/matter/test_valve.py | 47 ++++++++++++++++++- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py index ce9f16921de..f2deea97d7f 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -69,34 +69,37 @@ class MatterValve(MatterEntity, ValveEntity): def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() - current_state: int + self._attr_is_opening = False + self._attr_is_closing = False + + current_state: int | None current_state = self.get_matter_attribute_value( ValveConfigurationAndControl.Attributes.CurrentState ) - target_state: int + target_state: int | None target_state = self.get_matter_attribute_value( ValveConfigurationAndControl.Attributes.TargetState ) - if ( - current_state == ValveStateEnum.kTransitioning - and target_state == ValveStateEnum.kOpen + + if current_state is None: + self._attr_is_closed = None + elif current_state == ValveStateEnum.kTransitioning and ( + target_state == ValveStateEnum.kOpen ): self._attr_is_opening = True - self._attr_is_closing = False - elif ( - current_state == ValveStateEnum.kTransitioning - and target_state == ValveStateEnum.kClosed + self._attr_is_closed = None + elif current_state == ValveStateEnum.kTransitioning and ( + target_state == ValveStateEnum.kClosed ): - self._attr_is_opening = False self._attr_is_closing = True + self._attr_is_closed = None elif current_state == ValveStateEnum.kClosed: - self._attr_is_opening = False - self._attr_is_closing = False self._attr_is_closed = True - else: - self._attr_is_opening = False - self._attr_is_closing = False + elif current_state == ValveStateEnum.kOpen: self._attr_is_closed = False + else: + self._attr_is_closed = None + # handle optional position if self.supported_features & ValveEntityFeature.SET_POSITION: self._attr_current_valve_position = self.get_matter_attribute_value( @@ -145,6 +148,7 @@ DISCOVERY_SCHEMAS = [ ValveConfigurationAndControl.Attributes.CurrentState, ValveConfigurationAndControl.Attributes.TargetState, ), + allow_none_value=True, optional_attributes=(ValveConfigurationAndControl.Attributes.CurrentLevel,), device_type=(device_types.WaterValve,), ), diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index c0a6b8e6e5c..91ac91f845e 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_valves[mock_valve][valve.mock_valve-entry] +# name: test_valves[mock_valve][mock_valve][valve.mock_valve-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -35,7 +35,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_valves[mock_valve][valve.mock_valve-state] +# name: test_valves[mock_valve][mock_valve][valve.mock_valve-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'water', diff --git a/tests/components/matter/test_valve.py b/tests/components/matter/test_valve.py index d72dd2883eb..dd484eea87e 100644 --- a/tests/components/matter/test_valve.py +++ b/tests/components/matter/test_valve.py @@ -1,5 +1,6 @@ """Test Matter valve.""" +from typing import Any from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters @@ -18,13 +19,21 @@ from .common import ( ) -@pytest.mark.usefixtures("matter_devices") +@pytest.fixture(name="attributes") +def attributes_fixture(request: pytest.FixtureRequest) -> dict[str, Any]: + """Override node attributes for a parametrized test.""" + return getattr(request, "param", {}) + + +@pytest.mark.parametrize("node_fixture", ["mock_valve"]) async def test_valves( hass: HomeAssistant, + matter_node: MatterNode, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, ) -> None: """Test valves.""" + assert matter_node snapshot_matter_entities(hass, entity_registry, snapshot, Platform.VALVE) @@ -152,3 +161,39 @@ async def test_valve( command=clusters.ValveConfigurationAndControl.Commands.Close(), ) matter_client.send_device_command.reset_mock() + + +@pytest.mark.parametrize("node_fixture", ["mock_valve"]) +@pytest.mark.parametrize( + "attributes", + [{"1/129/4": None, "1/129/5": None}], + indirect=True, +) +async def test_valve_discovery_with_nullable_states( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test valve discovery when CurrentState and TargetState are nullable.""" + assert matter_node.node_id == 60 + + state = hass.states.get("valve.mock_valve") + assert state + assert state.state == "unknown" + assert state.attributes["friendly_name"] == "Mock Valve" + + await hass.services.async_call( + "valve", + "open_valve", + { + "entity_id": "valve.mock_valve", + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.ValveConfigurationAndControl.Commands.Open(), + )