diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index d7dafca90e68..a02045333502 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -120,13 +120,33 @@ class BlockShellyCover(ShellyBlockAttributeEntity, CoverEntity): self.control_result: dict[str, Any] | None = None self._attr_name = None # Main device entity self._attr_unique_id: str = f"{coordinator.mac}-{block.description}" - if self.coordinator.device.settings["rollers"][0]["positioning"]: + self._positioning: bool = self.coordinator.device.settings["rollers"][0][ + "positioning" + ] + # Without positioning the direction it last travelled in is all there is, + # and that says nothing about where it stopped + self._attr_assumed_state = not self._positioning + if self._positioning: self._attr_supported_features |= CoverEntityFeature.SET_POSITION @property @override - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """If cover is closed.""" + if not self._positioning: + # An uncalibrated roller parks its position on 101, so the direction + # it last travelled in is all there is to go on + last_direction = self.coordinator.device.status["rollers"][0].get( + "last_direction" + ) + if self.control_result: + last_direction = self.control_result.get( + "last_direction", last_direction + ) + if not last_direction: + return None + return cast(str, last_direction) == "close" + if self.control_result: return cast(bool, self.control_result["current_pos"] == 0) @@ -134,8 +154,11 @@ class BlockShellyCover(ShellyBlockAttributeEntity, CoverEntity): @property @override - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Position of the cover.""" + if not self._positioning: + return None + if self.control_result: return cast(int, self.control_result["current_pos"]) diff --git a/tests/components/shelly/test_cover.py b/tests/components/shelly/test_cover.py index d028bb04dd92..100a49506aab 100644 --- a/tests/components/shelly/test_cover.py +++ b/tests/components/shelly/test_cover.py @@ -1,7 +1,7 @@ """Tests for Shelly cover platform.""" from copy import deepcopy -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock from freezegun.api import FrozenDateTimeFactory import pytest @@ -23,7 +23,13 @@ from homeassistant.components.cover import ( CoverState, ) from homeassistant.components.shelly.const import RPC_COVER_UPDATE_TIME_SEC -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry @@ -111,6 +117,83 @@ async def test_block_device_update( state = hass.states.get("cover.test_name") assert state assert state.state == CoverState.OPEN + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize( + ("last_direction", "expected_state"), + [ + ("close", CoverState.CLOSED), + ("open", CoverState.OPEN), + # Nothing has moved since the device booted + (None, STATE_UNKNOWN), + ], +) +async def test_block_device_roller_without_positioning( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, + last_direction: str | None, + expected_state: str, +) -> None: + """Test an uncalibrated roller reports the direction it last travelled in.""" + settings = deepcopy(mock_block_device.settings) + settings["rollers"][0]["positioning"] = False + monkeypatch.setattr(mock_block_device, "settings", settings) + + status = deepcopy(mock_block_device.status) + # An uncalibrated roller parks its position on 101 + status["rollers"] = [{"current_pos": 101, "last_direction": last_direction}] + monkeypatch.setattr(mock_block_device, "status", status) + + await init_integration(hass, 1) + + assert (state := hass.states.get("cover.test_name")) + assert state.state == expected_state + assert state.attributes.get(ATTR_CURRENT_POSITION) is None + # Stopping mid travel leaves the direction saying more than it knows, so + # both buttons stay available + assert state.attributes[ATTR_ASSUMED_STATE] is True + + +async def test_block_device_roller_without_positioning_stopped( + hass: HomeAssistant, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test stopping an uncalibrated roller keeps it on its last direction.""" + settings = deepcopy(mock_block_device.settings) + settings["rollers"][0]["positioning"] = False + monkeypatch.setattr(mock_block_device, "settings", settings) + + status = deepcopy(mock_block_device.status) + status["rollers"] = [{"current_pos": 101, "last_direction": "close"}] + monkeypatch.setattr(mock_block_device, "status", status) + + # An uncalibrated roller answers a command with position 101 as well + monkeypatch.setattr( + mock_block_device.blocks[ROLLER_BLOCK_ID], + "set_state", + AsyncMock( + side_effect=lambda go, roller_pos=0: {"current_pos": 101, "state": go} + ), + ) + + await init_integration(hass, 1) + + entity_id = "cover.test_name" + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert (state := hass.states.get(entity_id)) + assert state.state == CoverState.CLOSED async def test_block_device_no_roller_blocks(