From 48d9df74ed872edb21af41d70340af98b2230f3f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 9 Jul 2024 16:36:34 +1000 Subject: [PATCH] Add sunroof to Teslemetry (#121476) --- homeassistant/components/teslemetry/cover.py | 88 ++++++++--- .../components/teslemetry/strings.json | 3 + .../teslemetry/fixtures/vehicle_data.json | 4 +- .../teslemetry/snapshots/test_cover.ambr | 144 ++++++++++++++++++ .../snapshots/test_diagnostics.ambr | 4 +- tests/components/teslemetry/test_cover.py | 49 +++++- 6 files changed, 267 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index 44e84626eb2..0b6d30b1faf 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -2,10 +2,9 @@ from __future__ import annotations -from itertools import chain from typing import Any -from tesla_fleet_api.const import Scope, Trunk, WindowCommand +from tesla_fleet_api.const import Scope, SunRoofCommand, Trunk, WindowCommand from homeassistant.components.cover import ( CoverDeviceClass, @@ -34,29 +33,20 @@ async def async_setup_entry( """Set up the Teslemetry cover platform from a config entry.""" async_add_entities( - chain( - ( - TeslemetryWindowEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - ), - ( - TeslemetryChargePortEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - ), - ( - TeslemetryFrontTrunkEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - ), - ( - TeslemetryRearTrunkEntity(vehicle, entry.runtime_data.scopes) - for vehicle in entry.runtime_data.vehicles - ), + klass(vehicle, entry.runtime_data.scopes) + for (klass) in ( + TeslemetryWindowEntity, + TeslemetryChargePortEntity, + TeslemetryFrontTrunkEntity, + TeslemetryRearTrunkEntity, + TeslemetrySunroofEntity, ) + for vehicle in entry.runtime_data.vehicles ) class TeslemetryWindowEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for current charge.""" + """Cover entity for the windows.""" _attr_device_class = CoverDeviceClass.WINDOW @@ -148,7 +138,7 @@ class TeslemetryChargePortEntity(TeslemetryVehicleEntity, CoverEntity): class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the charge port.""" + """Cover entity for the front trunk.""" _attr_device_class = CoverDeviceClass.DOOR @@ -175,7 +165,7 @@ class TeslemetryFrontTrunkEntity(TeslemetryVehicleEntity, CoverEntity): class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): - """Cover entity for the charge port.""" + """Cover entity for the rear trunk.""" _attr_device_class = CoverDeviceClass.DOOR @@ -217,3 +207,57 @@ class TeslemetryRearTrunkEntity(TeslemetryVehicleEntity, CoverEntity): await handle_vehicle_command(self.api.actuate_trunk(Trunk.REAR)) self._attr_is_closed = True self.async_write_ha_state() + + +class TeslemetrySunroofEntity(TeslemetryVehicleEntity, CoverEntity): + """Cover entity for the sunroof.""" + + _attr_device_class = CoverDeviceClass.WINDOW + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + _attr_entity_registry_enabled_default = False + + def __init__(self, vehicle: TeslemetryVehicleData, scopes: list[Scope]) -> None: + """Initialize the sensor.""" + super().__init__(vehicle, "vehicle_state_sun_roof_state") + + self.scoped = Scope.VEHICLE_CMDS in scopes + if not self.scoped: + self._attr_supported_features = CoverEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update the entity attributes.""" + value = self._value + if value in (None, "unknown"): + self._attr_is_closed = None + else: + self._attr_is_closed = value == "closed" + + self._attr_current_cover_position = self.get( + "vehicle_state_sun_roof_percent_open" + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open sunroof.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.VENT)) + self._attr_is_closed = False + self.async_write_ha_state() + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close sunroof.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.CLOSE)) + self._attr_is_closed = True + self.async_write_ha_state() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Close sunroof.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await handle_vehicle_command(self.api.sun_roof_control(SunRoofCommand.STOP)) + self._attr_is_closed = False + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index 9ff14f2dc8c..48eb4aae8bc 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -274,6 +274,9 @@ "vehicle_state_rt": { "name": "Trunk" }, + "vehicle_state_sun_roof_state": { + "name": "Sunroof" + }, "windows": { "name": "Windows" } diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 6c787df4897..3845ae48559 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -176,7 +176,7 @@ "roof_color": "RoofColorGlass", "seat_type": null, "spoiler_type": "None", - "sun_roof_installed": null, + "sun_roof_installed": true, "supports_qr_pairing": false, "third_row_seats": "None", "timestamp": 1705707520649, @@ -250,6 +250,8 @@ "min_limit_mph": 50, "pin_code_set": true }, + "sun_roof_state": "open", + "vehicle_state_sun_roof_percent_open": 20, "timestamp": 1705707520649, "tpms_hard_warning_fl": false, "tpms_hard_warning_fr": false, diff --git a/tests/components/teslemetry/snapshots/test_cover.ambr b/tests/components/teslemetry/snapshots/test_cover.ambr index 7689a08a373..7ffb9c4a1f9 100644 --- a/tests/components/teslemetry/snapshots/test_cover.ambr +++ b/tests/components/teslemetry/snapshots/test_cover.ambr @@ -95,6 +95,54 @@ 'state': 'closed', }) # --- +# name: test_cover[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Sunroof', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_cover[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -287,6 +335,54 @@ 'state': 'open', }) # --- +# name: test_cover_alt[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_alt[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Sunroof', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_cover_alt[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -479,6 +575,54 @@ 'state': 'closed', }) # --- +# name: test_cover_noscope[cover.test_sunroof-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_sunroof', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Sunroof', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'vehicle_state_sun_roof_state', + 'unique_id': 'LRWXF7EK4KC700000-vehicle_state_sun_roof_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_cover_noscope[cover.test_sunroof-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'window', + 'friendly_name': 'Test Sunroof', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_sunroof', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_cover_noscope[cover.test_trunk-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 4a942daa508..11f8a91c1aa 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -337,7 +337,7 @@ 'vehicle_config_roof_color': 'RoofColorGlass', 'vehicle_config_seat_type': None, 'vehicle_config_spoiler_type': 'None', - 'vehicle_config_sun_roof_installed': None, + 'vehicle_config_sun_roof_installed': True, 'vehicle_config_supports_qr_pairing': False, 'vehicle_config_third_row_seats': 'None', 'vehicle_config_timestamp': 1705707520649, @@ -402,6 +402,7 @@ 'vehicle_state_speed_limit_mode_max_limit_mph': 120, 'vehicle_state_speed_limit_mode_min_limit_mph': 50, 'vehicle_state_speed_limit_mode_pin_code_set': True, + 'vehicle_state_sun_roof_state': 'open', 'vehicle_state_timestamp': 1705707520649, 'vehicle_state_tpms_hard_warning_fl': False, 'vehicle_state_tpms_hard_warning_fr': False, @@ -426,6 +427,7 @@ 'vehicle_state_vehicle_name': 'Test', 'vehicle_state_vehicle_self_test_progress': 0, 'vehicle_state_vehicle_self_test_requested': False, + 'vehicle_state_vehicle_state_sun_roof_percent_open': 20, 'vehicle_state_webcam_available': True, 'vin': '**REDACTED**', }), diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index 5f99a5d9c79..8d4493ab25f 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from syrupy import SnapshotAssertion from tesla_fleet_api.exceptions import VehicleOffline @@ -9,6 +10,7 @@ from homeassistant.components.cover import ( DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, + SERVICE_STOP_COVER, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -24,6 +26,7 @@ from . import assert_entities, setup_platform from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -35,19 +38,21 @@ async def test_cover( assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_alt( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_vehicle_data, ) -> None: - """Tests that the cover entities are correct without scopes.""" + """Tests that the cover entities are correct with alternate values.""" mock_vehicle_data.return_value = VEHICLE_DATA_ALT entry = await setup_platform(hass, [Platform.COVER]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_noscope( hass: HomeAssistant, snapshot: SnapshotAssertion, @@ -73,6 +78,7 @@ async def test_cover_offline( assert state.state == STATE_UNKNOWN +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, ) -> None: @@ -186,3 +192,44 @@ async def test_cover_services( state = hass.states.get(entity_id) assert state assert state.state is STATE_CLOSED + + # Sunroof + entity_id = "cover.test_sunroof" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.sun_roof_control", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_OPEN + + call.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + call.assert_called_once() + state = hass.states.get(entity_id) + assert state + assert state.state is STATE_CLOSED