diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4f5379684226..cf79e9b2ace0 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -495,18 +495,42 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" + # Check before issuing the command in case targetValue report arrives early. + already_open = ( + (cv := self._current_position_value) is not None + and cv.value is not None + and (tpv := self._target_position_value) is not None + and tpv.value == cv.value == self._fully_open_position + ) result = await self._async_set_value(self._up_value, True) # StartLevelChange: SUCCESS means the device started moving in the desired direction - if result is not None and result.status in SET_VALUE_SUCCESS: + if ( + result is not None + and result.status in SET_VALUE_SUCCESS + and self.supported_features & CoverEntityFeature.SET_POSITION + and not already_open + ): self._attr_is_opening = True self._attr_is_closing = False self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" + # Check before issuing the command in case targetValue report arrives early. + already_closed = ( + (cv := self._current_position_value) is not None + and cv.value is not None + and (tpv := self._target_position_value) is not None + and tpv.value == cv.value == self._fully_closed_position + ) result = await self._async_set_value(self._down_value, True) # StartLevelChange: SUCCESS means the device started moving in the desired direction - if result is not None and result.status in SET_VALUE_SUCCESS: + if ( + result is not None + and result.status in SET_VALUE_SUCCESS + and self.supported_features & CoverEntityFeature.SET_POSITION + and not already_closed + ): self._attr_is_opening = False self._attr_is_closing = True self.async_write_ha_state() diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index ef778be50fef..33300c62c4d4 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -67,6 +67,27 @@ def platforms() -> list[str]: return [Platform.COVER] +@pytest.fixture(name="window_covering_outbound_bottom_no_position") +def window_covering_outbound_bottom_no_position_fixture( + client: MagicMock, + window_covering_outbound_bottom_state: dict[str, Any], +) -> Node: + """Load a Window Covering node that does not support setting a position.""" + node_state = copy.deepcopy(window_covering_outbound_bottom_state) + for value in node_state["values"]: + if value.get("commandClass") != CommandClass.WINDOW_COVERING: + continue + if value.get("propertyKey") != 13: + continue + value["propertyKey"] = 12 + value["propertyKeyName"] = "Outbound Bottom (no position)" + if "metadata" in value and "ccSpecific" in value["metadata"]: + value["metadata"]["ccSpecific"]["parameter"] = 12 + node = Node(client, node_state) + client.driver.controller.nodes[node.node_id] = node + return node + + async def test_window_cover( hass: HomeAssistant, client: MagicMock, @@ -1896,3 +1917,308 @@ async def test_multilevel_switch_cover_v3_no_moving_state_unsupervised( ) state = hass.states.get(WINDOW_COVER_ENTITY) assert state.state == CoverState.CLOSED + + +async def test_window_covering_cover_moving_state_position_support( + hass: HomeAssistant, + client: MagicMock, + window_covering_outbound_bottom: Node, + integration: MockConfigEntry, +) -> None: + """Test moving state is only set when not already at the target endpoint.""" + node = window_covering_outbound_bottom + entity_id = "cover.node_2_outbound_bottom" + + # Initial currentValue is 52 (mid-position). open_cover SHOULD set OPENING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + # Clear moving state before next scenario. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Simulate device reaching fully open (raw Z-Wave value 99 → HA position 100%). + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 99, + "prevValue": 52, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 99, + "prevValue": 52, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPEN + + # Already fully open — open_cover must NOT set OPENING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) + + # Fully open but not fully closed — close_cover SHOULD set CLOSING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSING + + # Clear moving state before next scenario. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Simulate device reaching fully closed (raw Z-Wave value 0 → HA position 0%). + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 0, + "prevValue": 99, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 0, + "prevValue": 99, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSED + + # Already fully closed — close_cover must NOT set CLOSING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) + + # From fully closed, open_cover SHOULD set OPENING (not at fully open endpoint). + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + # Simulate the device moving: targetValue arrives first (early report), then + # currentValue catches up to halfway. Moving state must stay OPENING throughout. + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 99, + "prevValue": 0, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 52, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + # Reverse halfway: close_cover while mid-travel MUST set CLOSING (not at endpoint). + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSING + + # Simulate the device moving back down: targetValue=0 arrives first (early report), + # then currentValue reaches halfway. Moving state must stay CLOSING throughout. + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "targetValue", + "propertyKey": 13, + "newValue": 0, + "prevValue": 99, + "propertyName": "targetValue", + }, + }, + ) + ) + node.receive_event( + Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Window Covering", + "commandClass": 106, + "endpoint": 0, + "property": "currentValue", + "propertyKey": 13, + "newValue": 52, + "prevValue": 99, + "propertyName": "currentValue", + }, + }, + ) + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.CLOSING + + # Reverse halfway: open_cover while mid-travel MUST set OPENING (not at endpoint). + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == CoverState.OPENING + + +async def test_window_covering_cover_moving_state_no_position( + hass: HomeAssistant, + client: MagicMock, + window_covering_outbound_bottom_no_position: Node, + integration: MockConfigEntry, +) -> None: + """Test that moving state is never set for Window Covering without position support.""" + entity_id = "cover.node_2_outbound_bottom" + + # No SET_POSITION feature — open_cover must NOT set OPENING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING) + + # No SET_POSITION feature — close_cover must NOT set CLOSING. + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state not in (CoverState.OPENING, CoverState.CLOSING)