From a1a1d65ee4c2b12120322b98711480e275f2b8da Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Dec 2025 19:56:55 +0100 Subject: [PATCH] Add Hood fan speed select entity to SmartThings (#157841) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/smartthings/fan.py | 120 +++++++++++++++++- .../components/smartthings/icons.json | 11 ++ .../components/smartthings/strings.json | 11 ++ .../device_status/da_ks_hood_01001.json | 4 +- .../smartthings/snapshots/test_fan.ambr | 59 +++++++++ .../smartthings/snapshots/test_switch.ambr | 2 +- tests/components/smartthings/test_fan.py | 114 +++++++++++++++-- 7 files changed, 309 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 1c4cb4edc4a..c5a2c5748a0 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -11,6 +11,8 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -22,6 +24,9 @@ from .entity import SmartThingsEntity SPEED_RANGE = (1, 3) # off is not included +SMART = 14 +PRESET_SMART = "smart" + async def async_setup_entry( hass: HomeAssistant, @@ -30,7 +35,7 @@ async def async_setup_entry( ) -> None: """Add fans for a config entry.""" entry_data = entry.runtime_data - async_add_entities( + entities: list[FanEntity] = [ SmartThingsFan(entry_data.client, device) for device in entry_data.devices.values() if Capability.SWITCH in device.status[MAIN] @@ -42,7 +47,20 @@ async def async_setup_entry( ) ) and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN] + ] + entities.extend( + SmartThingsHood(entry_data.client, device) + for device in entry_data.devices.values() + if Capability.SWITCH in device.status[MAIN] + and Capability.SAMSUNG_CE_HOOD_FAN_SPEED in device.status[MAIN] + and ( + device.status[MAIN][Capability.SAMSUNG_CE_HOOD_FAN_SPEED][ + Attribute.SETTABLE_MIN_FAN_SPEED + ].value + == SMART + ) ) + async_add_entities(entities) class SmartThingsFan(SmartThingsEntity, FanEntity): @@ -149,3 +167,103 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES ) + + +class SmartThingsHood(SmartThingsEntity, FanEntity): + """Define a SmartThings Hood.""" + + _attr_name = None + _attr_supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED + ) + _attr_preset_modes = [PRESET_SMART] + _attr_translation_key = "hood" + + def __init__(self, client: SmartThings, device: FullDevice) -> None: + """Init the class.""" + super().__init__( + client, + device, + { + Capability.SWITCH, + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + }, + ) + + @property + def fan_speeds(self) -> list[int]: + """Return a list of available fan speeds.""" + return [ + speed + for speed in self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.SUPPORTED_HOOD_FAN_SPEED + ) + if speed != SMART + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset_mode of the fan.""" + await self.execute_device_command( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Command.SET_HOOD_FAN_SPEED, + argument=SMART, + ) + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.execute_device_command(Capability.SWITCH, Command.OFF) + else: + await self.execute_device_command( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Command.SET_HOOD_FAN_SPEED, + argument=percentage_to_ordered_list_item(self.fan_speeds, percentage), + ) + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn the fan on.""" + await self.execute_device_command(Capability.SWITCH, Command.ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fan off.""" + await self.execute_device_command(Capability.SWITCH, Command.OFF) + + @property + def is_on(self) -> bool: + """Return true if fan is on.""" + return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on" + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + if ( + self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.HOOD_FAN_SPEED + ) + == SMART + ): + return PRESET_SMART + return None + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + fan_speed = self.get_attribute_value( + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, Attribute.HOOD_FAN_SPEED + ) + if fan_speed == SMART: + return None + return ordered_list_item_to_percentage(self.fan_speeds, fan_speed) + + @property + def speed_count(self) -> int: + """Return the number of available speeds.""" + return len(self.fan_speeds) diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json index e9b17060b8f..dfdd689ea0c 100644 --- a/homeassistant/components/smartthings/icons.json +++ b/homeassistant/components/smartthings/icons.json @@ -53,6 +53,17 @@ } } }, + "fan": { + "hood": { + "state_attributes": { + "preset_mode": { + "state": { + "smart": "mdi:brain" + } + } + } + } + }, "number": { "freezer_temperature": { "default": "mdi:snowflake-thermometer" diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 29387d22d64..ed25822ee7f 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -138,6 +138,17 @@ } } }, + "fan": { + "hood": { + "state_attributes": { + "preset_mode": { + "state": { + "smart": "Smart" + } + } + } + } + }, "number": { "cool_select_plus_temperature": { "name": "CoolSelect+ temperature" diff --git a/tests/components/smartthings/fixtures/device_status/da_ks_hood_01001.json b/tests/components/smartthings/fixtures/device_status/da_ks_hood_01001.json index 73b1cc27ddb..4d9a7334f7b 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ks_hood_01001.json +++ b/tests/components/smartthings/fixtures/device_status/da_ks_hood_01001.json @@ -48,7 +48,7 @@ }, "switch": { "switch": { - "value": "off", + "value": "on", "timestamp": "2025-11-11T23:41:24.907Z" } }, @@ -305,7 +305,7 @@ "timestamp": "2025-11-11T23:41:21.525Z" }, "hoodFanSpeed": { - "value": 14, + "value": 15, "timestamp": "2025-11-11T23:41:21.525Z" }, "supportedHoodFanSpeed": { diff --git a/tests/components/smartthings/snapshots/test_fan.ambr b/tests/components/smartthings/snapshots/test_fan.ambr index 10710c88617..1219c58dd42 100644 --- a/tests/components/smartthings/snapshots/test_fan.ambr +++ b/tests/components/smartthings/snapshots/test_fan.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_all_entities[da_ks_hood_01001][fan.range_hood-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'smart', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.range_hood', + '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': 'hood', + 'unique_id': 'fa5fca25-fa7a-1807-030a-2f72ee0f7bff_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ks_hood_01001][fan.range_hood-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Range hood', + 'percentage': 25, + 'percentage_step': 25.0, + 'preset_mode': None, + 'preset_modes': list([ + 'smart', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.range_hood', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[fake_fan][fan.fake_fan-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 56bc312b644..c1833296ef9 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -236,7 +236,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_all_entities[da_ks_walloven_0107x][switch.four_sabbath_mode-entry] diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 36a453ff595..1a179f5bc7b 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from pysmartthings import Capability, Command +from pysmartthings import Attribute, Capability, Command from pysmartthings.models import HealthStatus import pytest from syrupy.assertion import SnapshotAssertion @@ -26,7 +26,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration, snapshot_smartthings_entities, trigger_health_update +from . import ( + setup_integration, + snapshot_smartthings_entities, + trigger_health_update, + trigger_update, +) from tests.common import MockConfigEntry @@ -44,7 +49,13 @@ async def test_all_entities( snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.FAN) -@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "device_id"), + [ + ("fake_fan", "fan.fake_fan", "f1af21a2-d5a1-437c-b10a-b34a87394b71"), + ("da_ks_hood_01001", "fan.range_hood", "fa5fca25-fa7a-1807-030a-2f72ee0f7bff"), + ], +) @pytest.mark.parametrize( ("action", "command"), [ @@ -58,6 +69,8 @@ async def test_turn_on_off( mock_config_entry: MockConfigEntry, action: str, command: Command, + entity_id: str, + device_id: str, ) -> None: """Test turning on and off.""" await setup_integration(hass, mock_config_entry) @@ -65,11 +78,11 @@ async def test_turn_on_off( await hass.services.async_call( FAN_DOMAIN, action, - {ATTR_ENTITY_ID: "fan.fake_fan"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) devices.execute_device_command.assert_called_once_with( - "f1af21a2-d5a1-437c-b10a-b34a87394b71", + device_id, Capability.SWITCH, command, MAIN, @@ -100,11 +113,19 @@ async def test_set_percentage( ) -@pytest.mark.parametrize("device_fixture", ["fake_fan"]) +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "device_id"), + [ + ("fake_fan", "fan.fake_fan", "f1af21a2-d5a1-437c-b10a-b34a87394b71"), + ("da_ks_hood_01001", "fan.range_hood", "fa5fca25-fa7a-1807-030a-2f72ee0f7bff"), + ], +) async def test_set_percentage_off( hass: HomeAssistant, devices: AsyncMock, mock_config_entry: MockConfigEntry, + entity_id: str, + device_id: str, ) -> None: """Test setting the speed percentage of the fan.""" await setup_integration(hass, mock_config_entry) @@ -112,11 +133,11 @@ async def test_set_percentage_off( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.fake_fan", ATTR_PERCENTAGE: 0}, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 0}, blocking=True, ) devices.execute_device_command.assert_called_once_with( - "f1af21a2-d5a1-437c-b10a-b34a87394b71", + device_id, Capability.SWITCH, Command.OFF, MAIN, @@ -204,3 +225,80 @@ async def test_availability_at_start( """Test unavailable at boot.""" await setup_integration(hass, mock_config_entry) assert hass.states.get("fan.fake_fan").state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("device_fixture", ["da_ks_hood_01001"]) +async def test_set_hood_percentage( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the speed percentage of the hood.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: "fan.range_hood", ATTR_PERCENTAGE: 50}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "fa5fca25-fa7a-1807-030a-2f72ee0f7bff", + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Command.SET_HOOD_FAN_SPEED, + MAIN, + argument=16, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_hood_01001"]) +async def test_set_hood_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the preset mode of the hood.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: "fan.range_hood", ATTR_PRESET_MODE: "smart"}, + blocking=True, + ) + devices.execute_device_command.assert_called_once_with( + "fa5fca25-fa7a-1807-030a-2f72ee0f7bff", + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Command.SET_HOOD_FAN_SPEED, + MAIN, + argument=14, + ) + + +@pytest.mark.parametrize("device_fixture", ["da_ks_hood_01001"]) +async def test_updating_hood_preset_mode( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test updating the preset mode of the hood.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("fan.range_hood") + assert state + assert state.attributes[ATTR_PRESET_MODE] is None + assert state.attributes[ATTR_PERCENTAGE] == 25 + + await trigger_update( + hass, + devices, + "fa5fca25-fa7a-1807-030a-2f72ee0f7bff", + Capability.SAMSUNG_CE_HOOD_FAN_SPEED, + Attribute.HOOD_FAN_SPEED, + 14, + ) + + state = hass.states.get("fan.range_hood") + assert state + assert state.attributes[ATTR_PRESET_MODE] == "smart" + assert state.attributes[ATTR_PERCENTAGE] is None