mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 10:05:58 -05:00
Update Z-Wave cover moving state based on current position and cover capabilities (#168096)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user