From f12f82caacc7497cf0191f161f55d155160e54b9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 26 May 2024 21:04:02 +1000 Subject: [PATCH] Add update platform to Teslemetry (#118145) * Add update platform * Add tests * updates * update test * Fix support features comment * Add assertion --- .../components/teslemetry/__init__.py | 1 + .../components/teslemetry/strings.json | 5 + homeassistant/components/teslemetry/update.py | 105 ++++++++++++++++ .../teslemetry/fixtures/vehicle_data.json | 6 +- .../snapshots/test_diagnostics.ambr | 6 +- .../teslemetry/snapshots/test_update.ambr | 113 ++++++++++++++++++ tests/components/teslemetry/test_update.py | 89 ++++++++++++++ 7 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/teslemetry/update.py create mode 100644 tests/components/teslemetry/snapshots/test_update.ambr create mode 100644 tests/components/teslemetry/test_update.py diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index b1dc3c14baa..e96cba54bf0 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -38,6 +38,7 @@ PLATFORMS: Final = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index ba20bcd31a1..98b1f7f1932 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -454,6 +454,11 @@ "vehicle_state_valet_mode": { "name": "Valet mode" } + }, + "update": { + "vehicle_state_software_update_status": { + "name": "[%key:component::update::title%]" + } } }, "exceptions": { diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py new file mode 100644 index 00000000000..9d5d4aa7453 --- /dev/null +++ b/homeassistant/components/teslemetry/update.py @@ -0,0 +1,105 @@ +"""Update platform for Teslemetry integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from tesla_fleet_api.const import Scope + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + +AVAILABLE = "available" +DOWNLOADING = "downloading" +INSTALLING = "installing" +WIFI_WAIT = "downloading_wifi_wait" +SCHEDULED = "scheduled" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry update platform from a config entry.""" + + async_add_entities( + TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): + """Teslemetry Updates entity.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the Update.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "vehicle_state_software_update_status", + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + # Supported Features + if self.scoped and self._value in ( + AVAILABLE, + SCHEDULED, + ): + # Only allow install when an update has been fully downloaded + self._attr_supported_features = ( + UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL + ) + else: + self._attr_supported_features = UpdateEntityFeature.PROGRESS + + # Installed Version + self._attr_installed_version = self.get("vehicle_state_car_version") + if self._attr_installed_version is not None: + # Remove build from version + self._attr_installed_version = self._attr_installed_version.split(" ")[0] + + # Latest Version + if self._value in ( + AVAILABLE, + SCHEDULED, + INSTALLING, + DOWNLOADING, + WIFI_WAIT, + ): + self._attr_latest_version = self.coordinator.data[ + "vehicle_state_software_update_version" + ] + else: + self._attr_latest_version = self._attr_installed_version + + # In Progress + if self._value in ( + SCHEDULED, + INSTALLING, + ): + self._attr_in_progress = ( + cast(int, self.get("vehicle_state_software_update_install_perc")) + or True + ) + else: + self._attr_in_progress = False + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.schedule_software_update(offset_sec=60)) + self._attr_in_progress = True + self.async_write_ha_state() diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 01cf5f111c7..b5b78242496 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -237,11 +237,11 @@ "service_mode": false, "service_mode_plus": false, "software_update": { - "download_perc": 0, + "download_perc": 100, "expected_duration_sec": 2700, "install_perc": 1, - "status": "", - "version": " " + "status": "available", + "version": "2024.12.0.0" }, "speed_limit_mode": { "active": false, diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 7a44af7fb00..d7348d66d07 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -390,11 +390,11 @@ 'vehicle_state_sentry_mode_available': True, 'vehicle_state_service_mode': False, 'vehicle_state_service_mode_plus': False, - 'vehicle_state_software_update_download_perc': 0, + 'vehicle_state_software_update_download_perc': 100, 'vehicle_state_software_update_expected_duration_sec': 2700, 'vehicle_state_software_update_install_perc': 1, - 'vehicle_state_software_update_status': '', - 'vehicle_state_software_update_version': ' ', + 'vehicle_state_software_update_status': 'available', + 'vehicle_state_software_update_version': '2024.12.0.0', 'vehicle_state_speed_limit_mode_active': False, 'vehicle_state_speed_limit_mode_current_limit_mph': 69, 'vehicle_state_speed_limit_mode_max_limit_mph': 120, diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr new file mode 100644 index 00000000000..ad9c7fea087 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_update[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_software_update_status', + 'unique_id': 'VINVINVIN-vehicle_state_software_update_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.44.30.8', + 'latest_version': '2024.12.0.0', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_alt[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_software_update_status', + 'unique_id': 'VINVINVIN-vehicle_state_software_update_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_alt[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.44.30.8', + 'latest_version': '2023.44.30.8', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py new file mode 100644 index 00000000000..447ec524e90 --- /dev/null +++ b/tests/components/teslemetry/test_update.py @@ -0,0 +1,89 @@ +"""Test the Teslemetry update platform.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL +from homeassistant.components.teslemetry.update import INSTALLING +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed + + +async def test_update( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the update entities are correct.""" + + entry = await setup_platform(hass, [Platform.UPDATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_update_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the update entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.UPDATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_update_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the update entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.UPDATE]) + state = hass.states.get("update.test_update") + assert state.state == STATE_UNKNOWN + + +async def test_update_services( + hass: HomeAssistant, + mock_vehicle_data, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the update services work.""" + + await setup_platform(hass, [Platform.UPDATE]) + + entity_id = "update.test_update" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.schedule_software_update", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + call.assert_called_once() + + VEHICLE_DATA["response"]["vehicle_state"]["software_update"]["status"] = INSTALLING + mock_vehicle_data.return_value = VEHICLE_DATA + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] == 1