diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 334dab34cea7..685768f96a0f 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -97,13 +97,17 @@ class VeluxCover(VeluxEntity, CoverEntity): self._attr_device_class = CoverDeviceClass.SHUTTER @property - def current_cover_position(self) -> int: + def current_cover_position(self) -> int | None: """Return the current position of the cover.""" + if not self.node.position.known: + return None return 100 - self.node.position.position_percent @property - def is_closed(self) -> bool: + def is_closed(self) -> bool | None: """Return if the cover is closed.""" + if not self.node.position.known: + return None return self.node.position.closed @property @@ -168,22 +172,29 @@ class VeluxDualRollerShutter(VeluxCover): self.part = part @property - def current_cover_position(self) -> int: - """Return the current position of the cover.""" + def _part_position(self) -> Position: + """Return the pyvlx Position for this part of the shutter.""" if self.part == VeluxDualRollerPart.UPPER: - return 100 - self.node.position_upper_curtain.position_percent + return self.node.position_upper_curtain if self.part == VeluxDualRollerPart.LOWER: - return 100 - self.node.position_lower_curtain.position_percent - return 100 - self.node.position.position_percent + return self.node.position_lower_curtain + return self.node.position @property - def is_closed(self) -> bool: + def current_cover_position(self) -> int | None: + """Return the current position of the cover.""" + position = self._part_position + if not position.known: + return None + return 100 - position.position_percent + + @property + def is_closed(self) -> bool | None: """Return if the cover is closed.""" - if self.part == VeluxDualRollerPart.UPPER: - return self.node.position_upper_curtain.closed - if self.part == VeluxDualRollerPart.LOWER: - return self.node.position_lower_curtain.closed - return self.node.position.closed + position = self._part_position + if not position.known: + return None + return position.closed @wrap_pyvlx_call_exceptions async def async_close_cover(self, **kwargs: Any) -> None: @@ -227,6 +238,8 @@ class VeluxBlind(VeluxCover): @property def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" + if not self.node.orientation.known: + return None return 100 - self.node.orientation.position_percent @wrap_pyvlx_call_exceptions diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 2c4cd51a97b7..fb7018bb6ef5 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -74,7 +74,7 @@ def mock_window() -> AsyncMock: window.device_updated_cbs = [] window.is_opening = False window.is_closing = False - window.position = MagicMock(position_percent=30, closed=False) + window.position = MagicMock(position_percent=30, closed=False, known=True) window.wink = AsyncMock() window.pyvlx = MagicMock() return window @@ -89,9 +89,13 @@ def mock_dual_roller_shutter() -> AsyncMock: cover.serial_number = "987654321" cover.is_opening = False cover.is_closing = False - cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) - cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) - cover.position = MagicMock(position_percent=30, closed=False) + cover.position_upper_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position_lower_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position = MagicMock(position_percent=30, closed=False, known=True) cover.pyvlx = MagicMock() return cover @@ -104,11 +108,11 @@ def mock_blind() -> AsyncMock: blind.name = "Test Blind" blind.serial_number = "4711" # Standard cover position (used by current_cover_position) - blind.position = MagicMock(position_percent=40, closed=False) + blind.position = MagicMock(position_percent=40, closed=False, known=True) blind.is_opening = False blind.is_closing = False # Orientation/tilt-related attributes and methods - blind.orientation = MagicMock(position_percent=25) + blind.orientation = MagicMock(position_percent=25, known=True) blind.open_orientation = AsyncMock() blind.close_orientation = AsyncMock() blind.stop_orientation = AsyncMock() @@ -175,9 +179,13 @@ def mock_cover_type(request: pytest.FixtureRequest) -> AsyncMock: cover.serial_number = f"serial_{request.param.__name__}" cover.is_opening = False cover.is_closing = False - cover.position = MagicMock(position_percent=30, closed=False) - cover.position_upper_curtain = MagicMock(position_percent=30, closed=False) - cover.position_lower_curtain = MagicMock(position_percent=30, closed=False) + cover.position = MagicMock(position_percent=30, closed=False, known=True) + cover.position_upper_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) + cover.position_lower_curtain = MagicMock( + position_percent=30, closed=False, known=True + ) cover.pyvlx = MagicMock() return cover diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py index a2620aac31dc..483fbca5593e 100644 --- a/tests/components/velux/test_cover.py +++ b/tests/components/velux/test_cover.py @@ -33,6 +33,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, Platform, ) from homeassistant.core import HomeAssistant @@ -475,6 +476,77 @@ async def test_non_blind_has_no_tilt_position( assert "current_tilt_position" not in state.attributes +# Unknown position tests + + +async def test_window_unknown_position( + hass: HomeAssistant, mock_window: AsyncMock +) -> None: + """When the device position is not known, state and position must be unknown.""" + + entity_id = "cover.test_window" + + mock_window.position.known = False + await update_callback_entity(hass, mock_window) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("current_position") is None + + +@pytest.mark.parametrize( + ("unknown_attr", "unknown_entity_id"), + [ + ("position", "cover.test_dual_roller_shutter"), + ("position_upper_curtain", "cover.test_dual_roller_shutter_upper_shutter"), + ("position_lower_curtain", "cover.test_dual_roller_shutter_lower_shutter"), + ], +) +async def test_dual_roller_shutter_unknown_position( + hass: HomeAssistant, + mock_dual_roller_shutter: AsyncMock, + unknown_attr: str, + unknown_entity_id: str, +) -> None: + """Each part falls back to unknown independently when only its position is unknown.""" + + all_entity_ids = { + "cover.test_dual_roller_shutter", + "cover.test_dual_roller_shutter_upper_shutter", + "cover.test_dual_roller_shutter_lower_shutter", + } + + getattr(mock_dual_roller_shutter, unknown_attr).known = False + await update_callback_entity(hass, mock_dual_roller_shutter) + + state = hass.states.get(unknown_entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + assert state.attributes.get("current_position") is None + + for entity_id in all_entity_ids - {unknown_entity_id}: + state = hass.states.get(entity_id) + assert state is not None + assert state.state != STATE_UNKNOWN + assert state.attributes.get("current_position") == 70 + + +async def test_blind_unknown_tilt_position( + hass: HomeAssistant, mock_blind: AsyncMock +) -> None: + """Tilt position must be None when the orientation is not known.""" + + entity_id = "cover.test_blind" + + mock_blind.orientation.known = False + await update_callback_entity(hass, mock_blind) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes.get("current_tilt_position") is None + + # Exception handling tests