From 2a5448835fce78e12549ca8ad538fba15e78b8ed Mon Sep 17 00:00:00 2001 From: jennoian <39549658+jennoian@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:37:37 +0100 Subject: [PATCH] Add Vacuum support to smartthings (#148724) Co-authored-by: Joostlek Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/smartthings/__init__.py | 1 + .../components/smartthings/vacuum.py | 95 +++++++++++++ .../device_status/da_rvc_map_01011.json | 2 +- .../smartthings/snapshots/test_vacuum.ambr | 99 +++++++++++++ tests/components/smartthings/test_vacuum.py | 133 ++++++++++++++++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/smartthings/vacuum.py create mode 100644 tests/components/smartthings/snapshots/test_vacuum.ambr create mode 100644 tests/components/smartthings/test_vacuum.py diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e4259e4182c..9c7621037c7 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -103,6 +103,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VACUUM, Platform.VALVE, Platform.WATER_HEATER, ] diff --git a/homeassistant/components/smartthings/vacuum.py b/homeassistant/components/smartthings/vacuum.py new file mode 100644 index 00000000000..59152842150 --- /dev/null +++ b/homeassistant/components/smartthings/vacuum.py @@ -0,0 +1,95 @@ +"""SmartThings vacuum platform.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pysmartthings import Attribute, Command, SmartThings +from pysmartthings.capability import Capability + +from homeassistant.components.vacuum import ( + StateVacuumEntity, + VacuumActivity, + VacuumEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import FullDevice, SmartThingsConfigEntry +from .const import MAIN +from .entity import SmartThingsEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartThingsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up vacuum entities from SmartThings devices.""" + entry_data = entry.runtime_data + async_add_entities( + SamsungJetBotVacuum(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE in device.status[MAIN] + ) + + +class SamsungJetBotVacuum(SmartThingsEntity, StateVacuumEntity): + """Representation of a Vacuum.""" + + _attr_name = None + _attr_supported_features = ( + VacuumEntityFeature.START + | VacuumEntityFeature.RETURN_HOME + | VacuumEntityFeature.PAUSE + | VacuumEntityFeature.STATE + ) + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Initialize the Samsung robot cleaner vacuum entity.""" + super().__init__( + client, + device, + {Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE}, + ) + + @property + def activity(self) -> VacuumActivity | None: + """Return the current vacuum activity based on operating state.""" + status = self.get_attribute_value( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + ) + + return { + "cleaning": VacuumActivity.CLEANING, + "homing": VacuumActivity.RETURNING, + "idle": VacuumActivity.IDLE, + "paused": VacuumActivity.PAUSED, + "docked": VacuumActivity.DOCKED, + "error": VacuumActivity.ERROR, + "charging": VacuumActivity.DOCKED, + }.get(status) + + async def async_start(self) -> None: + """Start the vacuum's operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.START, + ) + + async def async_pause(self) -> None: + """Pause the vacuum's current operation.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, Command.PAUSE + ) + + async def async_return_to_base(self, **kwargs: Any) -> None: + """Return the vacuum to its base.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Command.RETURN_TO_HOME, + ) diff --git a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json index 14244935308..686207f67d2 100644 --- a/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json @@ -878,7 +878,7 @@ "timestamp": "2025-06-20T14:12:58.012Z" }, "operatingState": { - "value": "dryingMop", + "value": "charging", "timestamp": "2025-07-10T09:52:40.510Z" }, "cleaningStep": { diff --git a/tests/components/smartthings/snapshots/test_vacuum.ambr b/tests/components/smartthings/snapshots/test_vacuum.ambr new file mode 100644 index 00000000000..59bbae2b3e7 --- /dev/null +++ b/tests/components/smartthings/snapshots/test_vacuum.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docked', + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'vacuum', + 'entity_category': None, + 'entity_id': 'vacuum.robot_vacuum', + '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': None, + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '3442dfc6-17c0-a65f-dae0-4c6e01786f44_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_normal_000001][vacuum.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + 'supported_features': , + }), + 'context': , + 'entity_id': 'vacuum.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/smartthings/test_vacuum.py b/tests/components/smartthings/test_vacuum.py new file mode 100644 index 00000000000..6e2406625eb --- /dev/null +++ b/tests/components/smartthings/test_vacuum.py @@ -0,0 +1,133 @@ +"""Test for the SmartThings vacuum platform.""" + +from unittest.mock import AsyncMock + +from pysmartthings import Attribute, Capability, Command +from pysmartthings.models import HealthStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.smartthings import MAIN +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + SERVICE_PAUSE, + SERVICE_RETURN_TO_BASE, + SERVICE_START, + VacuumActivity, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + 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.VACUUM) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +@pytest.mark.parametrize( + ("action", "command"), + [ + (SERVICE_START, Command.START), + (SERVICE_PAUSE, Command.PAUSE), + (SERVICE_RETURN_TO_BASE, Command.RETURN_TO_HOME), + ], +) +async def test_vacuum_actions( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + action: str, + command: Command, +) -> None: + """Test vacuum actions.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + VACUUM_DOMAIN, + action, + {ATTR_ENTITY_ID: "vacuum.robot_vacuum"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + command, + MAIN, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +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("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_update( + hass, + devices, + "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + Capability.SAMSUNG_CE_ROBOT_CLEANER_OPERATING_STATE, + Attribute.OPERATING_STATE, + "error", + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.ERROR + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test availability.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.OFFLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE + + await trigger_health_update( + hass, devices, "05accb39-2017-c98b-a5ab-04a81f4d3d9a", HealthStatus.ONLINE + ) + + assert hass.states.get("vacuum.robot_vacuum").state == VacuumActivity.DOCKED + + +@pytest.mark.parametrize("device_fixture", ["da_rvc_map_01011"]) +async def test_availability_at_start( + hass: HomeAssistant, + unavailable_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unavailable at boot.""" + await setup_integration(hass, mock_config_entry) + assert hass.states.get("vacuum.robot_vacuum").state == STATE_UNAVAILABLE