diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index b667229f856a..b4c2a3aca334 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -1,7 +1,8 @@ """Cover platform for Teslemetry integration.""" +from dataclasses import asdict, dataclass from itertools import chain -from typing import Any, override +from typing import Any, Self, override from tesla_fleet_api import firmware_at_least from tesla_fleet_api.const import ( @@ -23,7 +24,7 @@ from homeassistant.components.cover import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from . import TeslemetryConfigEntry from .entity import ( @@ -175,6 +176,26 @@ class TeslemetryVehiclePollingWindowEntity( self._attr_is_closed = True +@dataclass +class TeslemetryWindowsExtraStoredData(ExtraStoredData): + """Per-window closed flags stored with the windows cover state.""" + + fd: bool | None + fp: bool | None + rd: bool | None + rp: bool | None + + @override + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the window flags.""" + return asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self: + """Initialize the window flags from a dict.""" + return cls(restored["fd"], restored["fp"], restored["rd"], restored["rp"]) + + class TeslemetryStreamingWindowEntity( TeslemetryVehicleStreamEntity, TeslemetryWindowEntity, CoverRestoreEntity ): @@ -200,6 +221,15 @@ class TeslemetryStreamingWindowEntity( async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() + if (extra_data := await self.async_get_last_extra_data()) is not None: + windows = TeslemetryWindowsExtraStoredData.from_dict(extra_data.as_dict()) + self.fd = windows.fd + self.fp = windows.fp + self.rd = windows.rd + self.rp = windows.rp + elif self._attr_is_closed: + # Without stored flags, closed still means every window was closed + self.fd = self.fp = self.rd = self.rp = True self.async_on_remove( self.stream.async_add_listener( self._handle_stream_update, @@ -218,6 +248,12 @@ class TeslemetryStreamingWindowEntity( f"Adding field {signal} to {self.vehicle.vin}", ) + @property + @override + def extra_restore_state_data(self) -> TeslemetryWindowsExtraStoredData: + """Return the per-window flags to restore.""" + return TeslemetryWindowsExtraStoredData(self.fd, self.fp, self.rd, self.rp) + def _handle_stream_update(self, data: dict[str, Any]) -> None: """Update the entity attributes.""" diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 6232a628dd9c..9a36faaa9d9f 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -17,10 +17,11 @@ from homeassistant.components.cover import ( SERVICE_STOP_COVER, CoverState, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.restore_state import async_get as async_get_restore_state from . import assert_entities, setup_platform from .const import ( @@ -36,6 +37,10 @@ from .const import ( VEHICLE_DATA_NONE, ) +from tests.common import mock_restore_cache_with_extra_data + +WINDOWS_ENTITY_ID = "cover.test_windows" + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover( @@ -572,3 +577,77 @@ async def test_cover_streaming( assert hass.states.get("cover.test_charge_port_door").state == "unknown" assert hass.states.get("cover.test_frunk").state == "unknown" assert hass.states.get("cover.test_trunk").state == "unknown" + + +@pytest.mark.parametrize( + ("restored_state", "extra_data", "expected_state", "expected_extra_data"), + [ + pytest.param( + CoverState.CLOSED, + {"fd": True, "fp": True, "rd": True, "rp": True}, + CoverState.CLOSED, + {"fd": True, "fp": True, "rd": True, "rp": True}, + id="closed", + ), + pytest.param( + CoverState.OPEN, + {"fd": False, "fp": True, "rd": True, "rp": True}, + CoverState.CLOSED, + {"fd": True, "fp": True, "rd": True, "rp": True}, + id="open_window_closes", + ), + pytest.param( + CoverState.OPEN, + {"fd": False, "fp": False, "rd": True, "rp": True}, + CoverState.OPEN, + {"fd": True, "fp": False, "rd": True, "rp": True}, + id="other_window_still_open", + ), + pytest.param( + CoverState.CLOSED, + None, + CoverState.CLOSED, + {"fd": True, "fp": True, "rd": True, "rp": True}, + id="no_extra_data_closed", + ), + pytest.param( + CoverState.OPEN, + None, + STATE_UNKNOWN, + {"fd": True, "fp": None, "rd": None, "rp": None}, + id="no_extra_data_open", + ), + ], +) +async def test_cover_streaming_windows_restore( + hass: HomeAssistant, + mock_add_listener: AsyncMock, + restored_state: CoverState, + extra_data: dict[str, bool] | None, + expected_state: str, + expected_extra_data: dict[str, bool | None], +) -> None: + """Tests the windows cover restores its per-window state.""" + + mock_restore_cache_with_extra_data( + hass, ((State(WINDOWS_ENTITY_ID, restored_state), extra_data),) + ) + + await setup_platform(hass, [Platform.COVER]) + assert hass.states.get(WINDOWS_ENTITY_ID).state == restored_state + + mock_add_listener.send( + { + "vin": VEHICLE_DATA_ALT["response"]["vin"], + "data": {Signal.FD_WINDOW: "WindowStateClosed"}, + "createdAt": "2024-10-04T10:45:17.537Z", + } + ) + await hass.async_block_till_done() + + assert hass.states.get(WINDOWS_ENTITY_ID).state == expected_state + stored_states = { + stored.state.entity_id: stored + for stored in async_get_restore_state(hass).async_get_stored_states() + } + assert stored_states[WINDOWS_ENTITY_ID].extra_data.as_dict() == expected_extra_data