diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 1fa6a1e259b..8b5860bc3af 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -80,6 +80,7 @@ PLATFORMS = [ Platform.SCENE, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, Platform.VALVE, ] diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py new file mode 100644 index 00000000000..bd856bd38ba --- /dev/null +++ b/homeassistant/components/smartthings/update.py @@ -0,0 +1,89 @@ +"""Support for update entities through the SmartThings cloud API.""" + +from __future__ import annotations + +from typing import Any + +from awesomeversion import AwesomeVersion +from pysmartthings import Attribute, Capability, Command + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add update entities for a config entry.""" + entry_data = entry.runtime_data + async_add_entities( + SmartThingsUpdateEntity( + entry_data.client, device, entry_data.rooms, {Capability.FIRMWARE_UPDATE} + ) + for device in entry_data.devices.values() + if Capability.FIRMWARE_UPDATE in device.status[MAIN] + ) + + +def is_hex_version(version: str) -> bool: + """Check if the version is a hex version.""" + return len(version) == 8 and all(c in "0123456789abcdefABCDEF" for c in version) + + +class SmartThingsUpdateEntity(SmartThingsEntity, UpdateEntity): + """Define a SmartThings update entity.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = ( + UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS + ) + + @property + def installed_version(self) -> str | None: + """Return the installed version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.CURRENT_VERSION + ) + + @property + def latest_version(self) -> str | None: + """Return the available version of the entity.""" + return self.get_attribute_value( + Capability.FIRMWARE_UPDATE, Attribute.AVAILABLE_VERSION + ) + + @property + def in_progress(self) -> bool: + """Return if the entity is in progress.""" + return ( + self.get_attribute_value(Capability.FIRMWARE_UPDATE, Attribute.STATE) + == "updateInProgress" + ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the firmware update.""" + await self.execute_device_command( + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + ) + + def version_is_newer(self, latest_version: str, installed_version: str) -> bool: + """Return if the latest version is newer.""" + if is_hex_version(latest_version): + latest_version = f"0x{latest_version}" + if is_hex_version(installed_version): + installed_version = f"0x{installed_version}" + return AwesomeVersion(latest_version) > AwesomeVersion(installed_version) diff --git a/tests/components/smartthings/fixtures/device_status/contact_sensor.json b/tests/components/smartthings/fixtures/device_status/contact_sensor.json index fa158d41b39..ca8c2628c99 100644 --- a/tests/components/smartthings/fixtures/device_status/contact_sensor.json +++ b/tests/components/smartthings/fixtures/device_status/contact_sensor.json @@ -36,7 +36,7 @@ "value": null }, "availableVersion": { - "value": "00000103", + "value": "00000104", "timestamp": "2025-02-09T13:59:19.101Z" }, "lastUpdateStatus": { diff --git a/tests/components/smartthings/snapshots/test_update.ambr b/tests/components/smartthings/snapshots/test_update.ambr new file mode 100644 index 00000000000..e74d2d8518c --- /dev/null +++ b/tests/components/smartthings/snapshots/test_update.ambr @@ -0,0 +1,421 @@ +# serializer version: 1 +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '286ba274-4093-4bcb-849c-a1a3efe7b1e5', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[bosch_radiator_thermostat_ii][update.radiator_thermostat_ii_m_wohnzimmer_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Radiator Thermostat II [+M] Wohnzimmer Firmware', + 'in_progress': False, + 'installed_version': '2.00.09 (20009)', + 'latest_version': '2.00.09 (20009)', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.radiator_thermostat_ii_m_wohnzimmer_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.dimmer_debian_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'd0268a69-abfb-4c92-a646-61cec2e510ad', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[centralite][update.dimmer_debian_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Dimmer Debian Firmware', + 'in_progress': False, + 'installed_version': '16015010', + 'latest_version': '16015010', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.dimmer_debian_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '2d9a892b-1c93-45a5-84cb-0e81889498c6', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[contact_sensor][update.front_door_open_closed_sensor_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': '.Front Door Open/Closed Sensor Firmware', + 'in_progress': False, + 'installed_version': '00000103', + 'latest_version': '00000104', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.front_door_open_closed_sensor_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '71afed1c-006d-4e48-b16e-e7f88f9fd638', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[ikea_kadrilj][update.kitchen_ikea_kadrilj_window_blind_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Kitchen IKEA KADRILJ Window blind Firmware', + 'in_progress': False, + 'installed_version': '22007631', + 'latest_version': '22007631', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.kitchen_ikea_kadrilj_window_blind_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.deck_door_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '7d246592-93db-4d72-a10d-5a51793ece8c', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[multipurpose_sensor][update.deck_door_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Deck Door Firmware', + 'in_progress': False, + 'installed_version': '0000001B', + 'latest_version': '0000001B', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.deck_door_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '550a1c72-65a0-4d55-b97b-75168e055398', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[smart_plug][update.arlo_beta_basestation_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Arlo Beta Basestation Firmware', + 'in_progress': False, + 'installed_version': '00102101', + 'latest_version': '00102101', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.arlo_beta_basestation_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.basement_door_lock_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'a9f587c5-5d8b-4273-8907-e7f609af5158', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[yale_push_button_deadbolt_lock][update.basement_door_lock_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/smartthings/icon.png', + 'friendly_name': 'Basement Door Lock Firmware', + 'in_progress': False, + 'installed_version': '00840847', + 'latest_version': '00840847', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.basement_door_lock_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/smartthings/test_update.py b/tests/components/smartthings/test_update.py new file mode 100644 index 00000000000..8c3d9e1a968 --- /dev/null +++ b/tests/components/smartthings/test_update.py @@ -0,0 +1,142 @@ +"""Test for the SmartThings update platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.smartthings.const import MAIN +from homeassistant.components.update import ( + ATTR_IN_PROGRESS, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration, snapshot_smartthings_entities, trigger_update + +from tests.common import MockConfigEntry + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.UPDATE) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_installing_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test installing an update.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: "update.front_door_open_closed_sensor_firmware"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Command.UPDATE_FIRMWARE, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_ON + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.CURRENT_VERSION, + "00000104", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").state + == STATE_OFF + ) + + +@pytest.mark.parametrize("device_fixture", ["contact_sensor"]) +async def test_state_progress_update( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state progress update.""" + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is False + ) + + await trigger_update( + hass, + devices, + "2d9a892b-1c93-45a5-84cb-0e81889498c6", + Capability.FIRMWARE_UPDATE, + Attribute.STATE, + "updateInProgress", + ) + + assert ( + hass.states.get("update.front_door_open_closed_sensor_firmware").attributes[ + ATTR_IN_PROGRESS + ] + is True + ) + + +@pytest.mark.parametrize("device_fixture", ["centralite"]) +async def test_state_update_available( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test state update available.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_OFF + + await trigger_update( + hass, + devices, + "d0268a69-abfb-4c92-a646-61cec2e510ad", + Capability.FIRMWARE_UPDATE, + Attribute.AVAILABLE_VERSION, + "16015011", + ) + + assert hass.states.get("update.dimmer_debian_firmware").state == STATE_ON