Update Z-Wave cover moving state based on current position and cover capabilities (#168096)

This commit is contained in:
AlCalzone
2026-04-17 19:21:49 +00:00
committed by Franck Nijhof
parent 9af7fe22bd
commit 2598dde7aa
2 changed files with 352 additions and 2 deletions
+26 -2
View File
@@ -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()
+326
View File
@@ -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)